diff --git a/extensions/codestory/package.json b/extensions/codestory/package.json index 29de0bf3d1d..bb3806ab061 100644 --- a/extensions/codestory/package.json +++ b/extensions/codestory/package.json @@ -24,6 +24,7 @@ "defaultChatParticipant", "inlineCompletionsAdditions", "interactive", + "mappedEditsProvider", "modelSelection", "terminalSelection" ], @@ -58,37 +59,33 @@ "command": "codestory.feedback", "title": "Provide feedback on Discord", "icon": "assets/discord.svg" + }, + { + "command": "codestory.startRecordingContext", + "title": "Start Recording context" + }, + { + "command": "codestory.stopRecordingContext", + "title": "Stop Recording Context" } ], - "viewsContainers": { - "activitybar": [ - { - "id": "changeTracker", - "title": "Change Tracker", - "icon": "assets/versions.svg" - } - ] - }, - "aideChatParticipants": [ + "aideAgents": [ { "id": "aide", - "name": "Chat", + "name": "Aide", + "fullName": "Aide", "description": "What can I help you with today?", + "supportsModelPicker": true, "isDefault": true, + "metadata": { + "icon": "assets/aide-agent.png", + "requester": "assets/aide-user.png" + }, "locations": [ "panel" ] } ], - "menus": { - "view/title": [ - { - "command": "codestory.feedback", - "when": "view == cs-chat", - "group": "navigation" - } - ] - }, "configuration": { "type": "object", "title": "Aide Extension settings", @@ -167,6 +164,11 @@ "type": "boolean", "default": true, "description": "Should we allow clip board content to be sent over" + }, + "aide.deepReasoning": { + "type": "boolean", + "default": false, + "description": "Should use deep reasoning before making edits" } } } diff --git a/extensions/codestory/src/chatState/convertStreamToMessage.ts b/extensions/codestory/src/chatState/convertStreamToMessage.ts index 4c9ed8b8243..d5765fdc239 100644 --- a/extensions/codestory/src/chatState/convertStreamToMessage.ts +++ b/extensions/codestory/src/chatState/convertStreamToMessage.ts @@ -17,7 +17,7 @@ import { AdjustedLineContent, LineContent, LineIndentManager } from '../completi export const reportFromStreamToSearchProgress = async ( stream: AsyncIterator, - response: vscode.AideChatResponseStream, + response: vscode.ChatResponseStream, cancellationToken: vscode.CancellationToken, workingDirectory: string, ): Promise => { @@ -79,7 +79,11 @@ export const reportFromStreamToSearchProgress = async ( // the reporef location to the message and that would solve a lot of // problems. if (!enteredAnswerGenerationLoop) { - response.markdown('\n'); + if (conversationMessage.answer.delta !== null) { + response.markdown(conversationMessage.answer.delta); + } else { + response.markdown('\n'); + } // progress.report(new CSChatProgressContent('\n## Answer\n\n' + conversationMessage.answer.delta)); enteredAnswerGenerationLoop = true; } else { @@ -193,7 +197,7 @@ export const reportCodeSpansToChat = (codeSpans: CodeSpan[], workingDirectory: s return '## Relevant code snippets\n\n' + codeSpansString + suffixString; }; -export const reportCodeReferencesToChat = (response: vscode.AideChatResponseStream, codeSpans: CodeSpan[], workingDirectory: string) => { +export const reportCodeReferencesToChat = (response: vscode.ChatResponseStream, codeSpans: CodeSpan[], workingDirectory: string) => { const sortedCodeSpans = codeSpans.sort((a, b) => { if (a.score !== null && b.score !== null) { return b.score - a.score; @@ -225,7 +229,7 @@ export const reportCodeReferencesToChat = (response: vscode.AideChatResponseStre export const reportProcUpdateToChat = ( - progress: vscode.AideChatResponseStream, + progress: vscode.ChatResponseStream, proc: AgentStep, workingDirectory: string, ) => { @@ -252,7 +256,7 @@ const pattern = /(?:^|\s)(\w+\s+at\s+[\w/.-]+)?(.*)/s; export const reportAgentEventsToChat = async ( editMode: boolean, stream: AsyncIterableIterator, - response: vscode.AgentResponseStream, + response: vscode.AideAgentResponseStream, threadId: string, token: vscode.CancellationToken, sidecarClient: SideCarClient, @@ -294,33 +298,31 @@ export const reportAgentEventsToChat = async ( thinking: item.thinking, }; }); - response.initialSearchSymbols(initialSearchSymbolInformation); + // response.initialSearchSymbols(initialSearchSymbolInformation); } else if (event.event.FrameworkEvent.RepoMapGenerationStart) { - response.repoMapGeneration(false); + // response.repoMapGeneration(false); } else if (event.event.FrameworkEvent.RepoMapGenerationFinished) { - response.repoMapGeneration(true); + // response.repoMapGeneration(true); } else if (event.event.FrameworkEvent.LongContextSearchStart) { - response.longContextSearch(false); + // response.longContextSearch(false); } else if (event.event.FrameworkEvent.LongContextSearchFinished) { - response.longContextSearch(true); + // response.longContextSearch(true); } else if (event.event.FrameworkEvent.OpenFile) { const filePath = event.event.FrameworkEvent.OpenFile.fs_file_path; if (filePath) { - response.openFile({ - uri: vscode.Uri.file(filePath), - }); + response.reference(vscode.Uri.file(filePath)); } } else if (event.event.FrameworkEvent.CodeIterationFinished) { - response.codeIterationFinished({ edits: iterationEdits }); + // response.codeIterationFinished({ edits: iterationEdits }); } else if (event.event.FrameworkEvent.ReferenceFound) { - response.referenceFound({ references: event.event.FrameworkEvent.ReferenceFound }); + // response.referenceFound({ references: event.event.FrameworkEvent.ReferenceFound }); } else if (event.event.FrameworkEvent.RelevantReference) { const ref = event.event.FrameworkEvent.RelevantReference; - response.relevantReference({ - uri: vscode.Uri.file(ref.fs_file_path), - symbolName: ref.symbol_name, - reason: ref.reason, - }); + // response.relevantReference({ + // uri: vscode.Uri.file(ref.fs_file_path), + // symbolName: ref.symbol_name, + // reason: ref.reason, + // }); } else if (event.event.FrameworkEvent.GroupedReferences) { const groupedRefs = event.event.FrameworkEvent.GroupedReferences; const followups: { [key: string]: { symbolName: string; uri: vscode.Uri }[] } = {}; @@ -332,7 +334,7 @@ export const reportAgentEventsToChat = async ( }; }); } - response.followups(followups); + // response.followups(followups); } else if (event.event.FrameworkEvent.SearchIteration) { // console.log(event.event.FrameworkEvent.SearchIteration); } else if (event.event.FrameworkEvent.AgenticTopLevelThinking) { @@ -349,13 +351,13 @@ export const reportAgentEventsToChat = async ( const symbolEventKey = symbolEventKeys[0] as keyof typeof symbolEvent; // If this is a symbol event then we have to make sure that we are getting the probe request over here if (!editMode && symbolEventKey === 'Probe' && symbolEvent.Probe !== undefined) { - response.breakdown({ - reference: { - uri: vscode.Uri.file(symbolEvent.Probe.symbol_identifier.fs_file_path ?? 'symbol_not_found'), - name: symbolEvent.Probe.symbol_identifier.symbol_name, - }, - query: new vscode.MarkdownString(symbolEvent.Probe.probe_request) - }); + // response.breakdown({ + // reference: { + // uri: vscode.Uri.file(symbolEvent.Probe.symbol_identifier.fs_file_path ?? 'symbol_not_found'), + // name: symbolEvent.Probe.symbol_identifier.symbol_name, + // }, + // query: new vscode.MarkdownString(symbolEvent.Probe.probe_request) + // }); } } else if (event.event.SymbolEventSubStep) { const { symbol_identifier, event: symbolEventSubStep } = event.event.SymbolEventSubStep; @@ -369,7 +371,7 @@ export const reportAgentEventsToChat = async ( const startPosition = new vscode.Position(goToDefinition.range.startPosition.line, goToDefinition.range.startPosition.character); const endPosition = new vscode.Position(goToDefinition.range.endPosition.line, goToDefinition.range.endPosition.character); const range = new vscode.Range(startPosition, endPosition); - response.location({ uri, range, name: symbol_identifier.symbol_name, thinking: goToDefinition.thinking }); + // response.location({ uri, range, name: symbol_identifier.symbol_name, thinking: goToDefinition.thinking }); continue; } else if (symbolEventSubStep.Edit) { if (!symbol_identifier.fs_file_path) { @@ -381,21 +383,21 @@ export const reportAgentEventsToChat = async ( if (editEvent.CodeCorrectionTool) { } if (editEvent.ThinkingForEdit) { - response.breakdown({ - reference: { - uri: vscode.Uri.file(symbol_identifier.fs_file_path), - name: symbol_identifier.symbol_name - }, - response: new vscode.MarkdownString(editEvent.ThinkingForEdit.thinking), - }); + // response.breakdown({ + // reference: { + // uri: vscode.Uri.file(symbol_identifier.fs_file_path), + // name: symbol_identifier.symbol_name + // }, + // response: new vscode.MarkdownString(editEvent.ThinkingForEdit.thinking), + // }); } if (editEvent.RangeSelectionForEdit) { - response.breakdown({ - reference: { - uri: vscode.Uri.file(symbol_identifier.fs_file_path), - name: symbol_identifier.symbol_name, - } - }); + // response.breakdown({ + // reference: { + // uri: vscode.Uri.file(symbol_identifier.fs_file_path), + // name: symbol_identifier.symbol_name, + // } + // }); } else if (editEvent.EditCodeStreaming) { // we have to do some state management over here // we send 3 distinct type of events over here @@ -469,13 +471,13 @@ export const reportAgentEventsToChat = async ( const subStepType = probeRequestKeys[0]; if (!editMode && subStepType === 'ProbeAnswer' && probeSubStep.ProbeAnswer !== undefined) { const probeAnswer = probeSubStep.ProbeAnswer; - response.breakdown({ - reference: { - uri: vscode.Uri.file(symbol_identifier.fs_file_path), - name: symbol_identifier.symbol_name - }, - response: new vscode.MarkdownString(probeAnswer) - }); + // response.breakdown({ + // reference: { + // uri: vscode.Uri.file(symbol_identifier.fs_file_path), + // name: symbol_identifier.symbol_name + // }, + // response: new vscode.MarkdownString(probeAnswer) + // }); } } } else if (event.event.RequestEvent) { @@ -600,7 +602,7 @@ export class StreamProcessor { documentLineIndex: number; sentEdits: boolean; documentLineLimit: number; - constructor(progress: vscode.AgentResponseStream, + constructor(progress: vscode.ChatResponseStream, lines: string[], indentStyle: IndentStyleSpaces | undefined, uri: vscode.Uri, @@ -712,7 +714,7 @@ export class StreamProcessor { class DocumentManager { indentStyle: IndentStyleSpaces; - progress: vscode.AgentResponseStream; + progress: vscode.ChatResponseStream; lines: LineContent[]; firstSentLineIndex: number; firstRangeLine: number; @@ -722,7 +724,7 @@ class DocumentManager { applyDirectly: boolean; constructor( - progress: vscode.AgentResponseStream, + progress: vscode.ChatResponseStream, lines: string[], // Fix the way we provide context over here? range: SidecarRequestRange, @@ -795,10 +797,10 @@ class DocumentManager { await vscode.workspace.applyEdit(edits); } else if (this.limiter === null) { - await this.progress.codeEdit({ edits, iterationId: 'mock' }); + // await this.progress.codeEdit({ edits, iterationId: 'mock' }); } else { await this.limiter.queue(async () => { - await this.progress.codeEdit({ edits, iterationId: 'mock' }); + // await this.progress.codeEdit({ edits, iterationId: 'mock' }); }); } return index + 1; @@ -810,10 +812,10 @@ class DocumentManager { await vscode.workspace.applyEdit(edits); } else if (this.limiter === null) { - await this.progress.codeEdit({ edits, iterationId: 'mock' }); + // await this.progress.codeEdit({ edits, iterationId: 'mock' }); } else { await this.limiter.queue(async () => { - await this.progress.codeEdit({ edits, iterationId: 'mock' }); + // await this.progress.codeEdit({ edits, iterationId: 'mock' }); }); } return index + 1; @@ -845,10 +847,10 @@ class DocumentManager { await vscode.workspace.applyEdit(edits); } else if (this.limiter === null) { - await this.progress.codeEdit({ edits, iterationId: 'mock' }); + // await this.progress.codeEdit({ edits, iterationId: 'mock' }); } else { await this.limiter.queue(async () => { - await this.progress.codeEdit({ edits, iterationId: 'mock' }); + // await this.progress.codeEdit({ edits, iterationId: 'mock' }); }); } return startIndex + 1; @@ -872,10 +874,10 @@ class DocumentManager { await vscode.workspace.applyEdit(edits); } else if (this.limiter === null) { - await this.progress.codeEdit({ edits, iterationId: 'mock' }); + // await this.progress.codeEdit({ edits, iterationId: 'mock' }); } else { await this.limiter.queue(async () => { - await this.progress.codeEdit({ edits, iterationId: 'mock' }); + // await this.progress.codeEdit({ edits, iterationId: 'mock' }); }); } return this.lines.length; @@ -898,10 +900,10 @@ class DocumentManager { await vscode.workspace.applyEdit(edits); } else if (this.limiter === null) { - await this.progress.codeEdit({ edits, iterationId: 'mock' }); + // await this.progress.codeEdit({ edits, iterationId: 'mock' }); } else { await this.limiter.queue(async () => { - await this.progress.codeEdit({ edits, iterationId: 'mock' }); + // await this.progress.codeEdit({ edits, iterationId: 'mock' }); }); } return index + 2; diff --git a/extensions/codestory/src/completions/providers/aideAgentProvider.ts b/extensions/codestory/src/completions/providers/aideAgentProvider.ts new file mode 100644 index 00000000000..91802e04b8b --- /dev/null +++ b/extensions/codestory/src/completions/providers/aideAgentProvider.ts @@ -0,0 +1,262 @@ +/*--------------------------------------------------------------------------------------------- + * 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 vscode from 'vscode'; + +import { AnswerSplitOnNewLineAccumulatorStreaming, reportAgentEventsToChat, reportFromStreamToSearchProgress, StreamProcessor } from '../../chatState/convertStreamToMessage'; +import { applyEdits, applyEditsDirectly, Limiter } from '../../server/applyEdits'; +import { RecentEditsRetriever } from '../../server/editedFiles'; +import { handleRequest } from '../../server/requestHandler'; +import { EditedCodeStreamingRequest, SidecarApplyEditsRequest, SidecarContextEvent } from '../../server/types'; +import { RepoRef, SideCarClient } from '../../sidecar/client'; +import { getUserId } from '../../utilities/uniqueId'; +import { ProjectContext } from '../../utilities/workspaceContext'; + +export class AideAgentSessionProvider implements vscode.AideSessionParticipant { + private aideAgent: vscode.AideSessionAgent; + + editorUrl: string | undefined; + private iterationEdits = new vscode.WorkspaceEdit(); + private requestHandler: http.Server | null = null; + private editsMap = new Map(); + private eventQueue: vscode.AideAgentRequest[] = []; + private limiter = new Limiter(1); + private openResponseStream: vscode.AideAgentResponseStream | undefined; + private processingEvents: Map = new Map(); + private sessionId: string | undefined; + + 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( + private currentRepoRef: RepoRef, + private projectContext: ProjectContext, + private sidecarClient: SideCarClient, + private workingDirectory: string, + recentEditsRetriever: RecentEditsRetriever, + ) { + 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}`; + this.editorUrl = editorUrl; + }); + + this.aideAgent = vscode.aideAgent.createChatParticipant('aide', { + newSession: this.newSession.bind(this), + handleEvent: this.handleEvent.bind(this) + }); + this.aideAgent.iconPath = vscode.Uri.joinPath(vscode.extensions.getExtension('codestory-ghost.codestoryai')?.extensionUri ?? vscode.Uri.parse(''), 'assets', 'aide-agent.png'); + this.aideAgent.requester = { + name: getUserId(), + icon: vscode.Uri.joinPath(vscode.extensions.getExtension('codestory-ghost.codestoryai')?.extensionUri ?? vscode.Uri.parse(''), 'assets', 'aide-user.png') + }; + this.aideAgent.supportIssueReporting = false; + this.aideAgent.welcomeMessageProvider = { + provideWelcomeMessage: async () => { + return [ + 'Hi, I\'m **Aide**, your personal coding assistant! I can find, understand, explain, debug or write code for you.', + ]; + } + }; + } + + async sendContextRecording(events: SidecarContextEvent[]) { + await this.sidecarClient.sendContextRecording(events, this.editorUrl); + } + + async provideEditStreamed(request: EditedCodeStreamingRequest): Promise<{ + fs_file_path: String; + success: boolean; + }> { + if (!this.openResponseStream) { + console.log('editing_streamed::no_open_response_stream'); + return { + fs_file_path: '', + success: false, + }; + } + const editStreamEvent = request; + 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, + 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; + } + + newSession(sessionId: string): void { + this.sessionId = sessionId; + } + + handleEvent(event: vscode.AideAgentRequest): void { + this.eventQueue.push(event); + if (this.sessionId && !this.processingEvents.has(event.id)) { + this.processingEvents.set(event.id, true); + this.processEvent(event); + } + } + + private async processEvent(event: vscode.AideAgentRequest): Promise { + if (!this.sessionId) { + return; + } + + const responseStream = this.openResponseStream = await this.aideAgent.initResponse(this.sessionId); + if (!responseStream) { + return; + } + + await this.generateResponse(this.sessionId, event, responseStream); + this.processingEvents.delete(event.id); + } + + private async generateResponse(sessionId: string, event: vscode.AideAgentRequest, responseStream: vscode.AideAgentResponseStream) { + if (!this.editorUrl) { + responseStream.close(); + return; + } + + // TODO(@ghostwriternr): This is a temporary value, the token should ideally be passed to the request/response lifecycle. + const cts = new vscode.CancellationTokenSource(); + const query = event.prompt; + if (event.mode === vscode.AideAgentMode.Chat) { + const followupResponse = this.sidecarClient.followupQuestion(query, this.currentRepoRef, sessionId, event.references, this.projectContext.labels); + await reportFromStreamToSearchProgress(followupResponse, responseStream, cts.token, this.workingDirectory); + } else if (event.mode === vscode.AideAgentMode.Edit) { + // TODO(@ghostwriternr): Get the right values after implementing scopes again. + const isAnchorEditing = true; + const isWholeCodebase = false; + const probeResponse = this.sidecarClient.startAgentCodeEdit(query, event.references, this.editorUrl, event.id, isWholeCodebase, isAnchorEditing); + await reportAgentEventsToChat(true, probeResponse, responseStream, event.id, cts.token, this.sidecarClient, this.iterationEdits, this.limiter); + } + responseStream.close(); + } + + dispose() { + this.aideAgent.dispose(); + } +} diff --git a/extensions/codestory/src/completions/providers/chatprovider.ts b/extensions/codestory/src/completions/providers/chatprovider.ts index ab4ce2cc281..4aef4273c0b 100644 --- a/extensions/codestory/src/completions/providers/chatprovider.ts +++ b/extensions/codestory/src/completions/providers/chatprovider.ts @@ -2,7 +2,10 @@ * 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'; @@ -13,10 +16,8 @@ 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'; class CSChatParticipant implements vscode.ChatRequesterInformation { name: string; @@ -67,7 +68,7 @@ class CSChatResponseForProgress implements vscode.ChatResult { } export class CSChatAgentProvider implements vscode.Disposable { - private chatAgent: vscode.AideChatParticipant; + private chatAgent: vscode.ChatParticipant; private _workingDirectory: string; private _repoName: string; @@ -94,8 +95,7 @@ export class CSChatAgentProvider implements vscode.Disposable { this._currentRepoRef = repoRef; this._projectContext = projectContext; - this.chatAgent = vscode.aideChat.createChatParticipant('aide', this.defaultAgentRequestHandler); - // this.chatAgent.isDefault = true; + 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', @@ -106,7 +106,7 @@ export class CSChatAgentProvider implements vscode.Disposable { 'assets', 'aide-user.png' )); - this.chatAgent.supportIssueReporting = true; + this.chatAgent.supportIssueReporting = false; this.chatAgent.welcomeMessageProvider = { provideWelcomeMessage: async () => { return [ @@ -116,15 +116,15 @@ export class CSChatAgentProvider implements vscode.Disposable { }; // register the extra variables here - registerOpenFiles(); - registerTerminalSelection(); + // registerOpenFiles(); + // registerTerminalSelection(); this.chatAgent.editsProvider = this.editsProvider; } - defaultAgentRequestHandler: vscode.AideChatExtendedRequestHandler = async (request, _context, response, token) => { + defaultAgentRequestHandler: vscode.ChatExtendedRequestHandler = async (request, _context, response, token) => { let requestType: UserMessageType = 'general'; const slashCommand = request.command; - if (request.location === vscode.AideChatLocation.Editor) { + if (request.location === vscode.ChatLocation.Editor) { await provideInteractiveEditorResponse( this._currentRepoRef, this._sideCarClient, @@ -145,6 +145,8 @@ export class CSChatAgentProvider implements vscode.Disposable { } } + const threadId = uuid.v4(); + logger.info(`[codestory][request_type][provideResponseWithProgress] ${requestType}`); if (requestType === 'explain') { // Implement the explain feature here @@ -154,7 +156,7 @@ export class CSChatAgentProvider implements vscode.Disposable { 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); + const explainResponse = this._sideCarClient.explainQuery(explainString, this._currentRepoRef, currentSelection, threadId); await reportFromStreamToSearchProgress(explainResponse, response, token, this._workingDirectory); return new CSChatResponseForProgress(); } @@ -166,7 +168,7 @@ export class CSChatAgentProvider implements vscode.Disposable { this._uniqueUserId, ); const searchString = request.prompt.toString().slice('/search'.length).trim(); - const searchResponse = this._sideCarClient.searchQuery(searchString, this._currentRepoRef, request.threadId); + const searchResponse = this._sideCarClient.searchQuery(searchString, this._currentRepoRef, threadId); await reportFromStreamToSearchProgress(searchResponse, response, token, this._workingDirectory); // We get back here a bunch of responses which we have to pass properly to the agent return new CSChatResponseForProgress(); @@ -179,7 +181,7 @@ export class CSChatAgentProvider implements vscode.Disposable { this._uniqueUserId, ); const projectLabels = this._projectContext.labels; - const followupResponse = this._sideCarClient.followupQuestion(query, this._currentRepoRef, request.threadId, request.references, projectLabels); + const followupResponse = this._sideCarClient.followupQuestion(query, this._currentRepoRef, threadId, request.references, projectLabels); await reportFromStreamToSearchProgress(followupResponse, response, token, this._workingDirectory); return new CSChatResponseForProgress(); } @@ -574,3 +576,4 @@ class DocumentManager { return index + 2; } } +*/ diff --git a/extensions/codestory/src/completions/providers/editorSessionProvider.ts b/extensions/codestory/src/completions/providers/editorSessionProvider.ts index ccc72bd8ef7..6a96dc644b6 100644 --- a/extensions/codestory/src/completions/providers/editorSessionProvider.ts +++ b/extensions/codestory/src/completions/providers/editorSessionProvider.ts @@ -179,8 +179,8 @@ export async function provideInteractiveEditorResponse( repoRef: RepoRef, sidecarClient: SideCarClient, workingDirectory: string, - request: vscode.AideChatRequest, - progress: vscode.AideChatResponseStream, + request: vscode.ChatRequest, + progress: vscode.ChatResponseStream, token: vscode.CancellationToken, ): Promise { const variables = request.references; diff --git a/extensions/codestory/src/completions/providers/openFiles.ts b/extensions/codestory/src/completions/providers/openFiles.ts index 231611bde4b..43ccd18f4e4 100644 --- a/extensions/codestory/src/completions/providers/openFiles.ts +++ b/extensions/codestory/src/completions/providers/openFiles.ts @@ -8,7 +8,7 @@ import * as vscode from 'vscode'; export const OPEN_FILES_VARIABLE = 'openFiles'; export function registerOpenFiles() { - vscode.aideChat.registerChatVariableResolver( + vscode.aideAgent.registerChatVariableResolver( OPEN_FILES_VARIABLE, OPEN_FILES_VARIABLE, 'Open files in the workspace', diff --git a/extensions/codestory/src/completions/providers/probeProvider.ts b/extensions/codestory/src/completions/providers/probeProvider.ts index ca612d724e4..e225181c475 100644 --- a/extensions/codestory/src/completions/providers/probeProvider.ts +++ b/extensions/codestory/src/completions/providers/probeProvider.ts @@ -3,6 +3,7 @@ * 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'; @@ -12,7 +13,7 @@ import { AnswerSplitOnNewLineAccumulatorStreaming, reportAgentEventsToChat, Stre import postHogClient from '../../posthog/client'; import { applyEdits, applyEditsDirectly, Limiter } from '../../server/applyEdits'; import { handleRequest } from '../../server/requestHandler'; -import { EditedCodeStreamingRequest, SidecarApplyEditsRequest } from '../../server/types'; +import { EditedCodeStreamingRequest, SidecarApplyEditsRequest, SidecarContextEvent } from '../../server/types'; import { SideCarClient } from '../../sidecar/client'; import { getUniqueId } from '../../utilities/uniqueId'; import { RecentEditsRetriever } from '../../server/editedFiles'; @@ -95,13 +96,19 @@ export class AideProbeProvider implements vscode.Disposable { onDidUserAction: this.userFollowup.bind(this), } ); - */ - vscode.aideAgent.registerAideAgentProvider( - 'aideAgentProvider', - { - provideTriggerResponse: this.provideProbeResponse.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); } /* @@ -145,7 +152,6 @@ export class AideProbeProvider implements vscode.Disposable { }, }); } - */ async provideEditStreamed(request: EditedCodeStreamingRequest): Promise<{ fs_file_path: String; @@ -310,3 +316,4 @@ function isAnchorBasedEditing(scope: vscode.AideAgentScope): boolean { return false; } } +*/ diff --git a/extensions/codestory/src/completions/providers/reportEditorSessionAnswerStream.ts b/extensions/codestory/src/completions/providers/reportEditorSessionAnswerStream.ts index 7d2b695970a..e58601ae2e0 100644 --- a/extensions/codestory/src/completions/providers/reportEditorSessionAnswerStream.ts +++ b/extensions/codestory/src/completions/providers/reportEditorSessionAnswerStream.ts @@ -19,7 +19,7 @@ export interface EditMessage { export const reportFromStreamToEditorSessionProgress = async ( stream: AsyncIterator, - progress: vscode.AideChatResponseStream, + progress: vscode.ChatResponseStream, cancellationToken: vscode.CancellationToken, _currentRepoRef: RepoRef, _workingDirectory: string, @@ -304,7 +304,7 @@ class StreamProcessor { sentEdits: boolean; documentLineLimit: number; constructor( - progress: vscode.AideChatResponseStream, + progress: vscode.ChatResponseStream, document: vscode.TextDocument, lines: string[], contextSelection: InLineAgentContextSelection, @@ -436,13 +436,13 @@ class StreamProcessor { class DocumentManager { indentStyle: IndentStyleSpaces; - progress: vscode.AideChatResponseStream; + progress: vscode.ChatResponseStream; lines: LineContent[]; firstSentLineIndex: number; firstRangeLine: number; constructor( - progress: vscode.AideChatResponseStream, + progress: vscode.ChatResponseStream, private document: vscode.TextDocument, lines: string[], contextSelection: InLineAgentContextSelection, diff --git a/extensions/codestory/src/completions/providers/terminalSelection.ts b/extensions/codestory/src/completions/providers/terminalSelection.ts index 67aa167e10a..a3af68457e2 100644 --- a/extensions/codestory/src/completions/providers/terminalSelection.ts +++ b/extensions/codestory/src/completions/providers/terminalSelection.ts @@ -8,7 +8,7 @@ export const TERMINAL_SELECTION_VARIABLE = 'terminalSelection'; export function registerTerminalSelection() { // TODO(skcd): This is not working, we are parsing the value as a JSON which makes sense maybe? - vscode.aideChat.registerChatVariableResolver( + vscode.aideAgent.registerChatVariableResolver( TERMINAL_SELECTION_VARIABLE, TERMINAL_SELECTION_VARIABLE, 'User selection in the terminal', diff --git a/extensions/codestory/src/csEvents/csEventHandler.ts b/extensions/codestory/src/csEvents/csEventHandler.ts index b61637b98f0..8f5d704c484 100644 --- a/extensions/codestory/src/csEvents/csEventHandler.ts +++ b/extensions/codestory/src/csEvents/csEventHandler.ts @@ -4,9 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import postHogClient from '../posthog/client'; -import { getUniqueId } from '../utilities/uniqueId'; import { getSymbolNavigationActionTypeLabel } from '../utilities/stringifyEvent'; +import { SidecarContextEvent, SidecarRequestRange } from '../server/types'; type UsageRequest = { type: 'InlineCompletion' | 'ChatRequest' | 'InlineCodeEdit' | 'AgenticCodeEdit'; @@ -21,9 +20,12 @@ const RETRY_DELAY_MS = 1000; // 1 second export class CSEventHandler implements vscode.CSEventHandler, vscode.Disposable { private _disposable: vscode.Disposable; private _subscriptionsAPIBase: string | null = null; + // The current recording session which the user is going through over here + private _currentSession: SidecarContextEvent[]; - constructor(private readonly _context: vscode.ExtensionContext) { + constructor(private readonly _context: vscode.ExtensionContext, _editorUrl: string | undefined) { this._disposable = vscode.csevents.registerCSEventHandler(this); + this._currentSession = []; if (vscode.env.uriScheme === 'aide') { this._subscriptionsAPIBase = 'https://api.codestory.ai'; @@ -32,17 +34,30 @@ export class CSEventHandler implements vscode.CSEventHandler, vscode.Disposable } } - handleSymbolNavigation(event: vscode.SymbolNavigationEvent): void { - const currentWindow = vscode.window.activeTextEditor?.document.uri.fsPath; - postHogClient?.capture({ - distinctId: getUniqueId(), - event: 'symbol_navigation', - properties: { - action: getSymbolNavigationActionTypeLabel(event.action), - file_path: event.uri.fsPath, - current_window: currentWindow, - }, + async handleSymbolNavigation(event: vscode.SymbolNavigationEvent): Promise { + const textDocument = await vscode.workspace.openTextDocument(vscode.Uri.file(event.uri.fsPath)); + const wordRange = textDocument.getWordRangeAtPosition(event.position); + const lineContent = textDocument.lineAt(event.position.line).text; + let textAtRange = undefined; + if (wordRange !== undefined) { + textAtRange = textDocument.getText(wordRange); + } + this._currentSession.push({ + LSPContextEvent: { + fs_file_path: event.uri.fsPath, + position: { + line: event.position.line, + character: event.position.character, + byteOffset: 0, + }, + source_word: textAtRange, + source_line: lineContent, + destination: null, + event_type: getSymbolNavigationActionTypeLabel(event.action), + } }); + console.log('handleSymbolNavigation'); + console.log(event); } async handleAgentCodeEdit(event: { accepted: boolean; added: number; removed: number }): Promise { @@ -118,6 +133,129 @@ export class CSEventHandler implements vscode.CSEventHandler, vscode.Disposable } } + /** + * Starts recording the user activity over here and keeps track of the set of events the user is doing + */ + async startRecording() { + this._currentSession = []; + } + + /** + * Stops recording the user activity and returns an object which can be used for inferencing + */ + async stopRecording(): Promise { + const currentSession = this._currentSession; + this._currentSession = []; + return currentSession; + } + + /** + * We are changing to a new text document or focussing on something new, its important + * to record this event + */ + async onDidChangeTextDocument(filePath: string) { + console.log('cs_event_handler::on_did_change_text_document', filePath); + // this._currentSession.push({ + // OpenFile: { + // fs_file_path: filePath, + // } + // }); + } + + /** + * We are going to record the fact that the selection was changed in the editor + * This allows the the user to give context to the LLM by literally just doing what + * they would normally do in the editor + */ + async onDidChangeTextDocumentSelection(filePath: string, selections: readonly vscode.Selection[]) { + // we are getting multiple selections for the same file, so figure out what to do + // over here + const currentSelectionRange = this.selectionToSidecarRange(selections[0]); + if (this._currentSession.length === 0) { + // we should not track any events which are just movements, a movement + // in the editor shows up as a 0 length selection + if (currentSelectionRange.startPosition.line === currentSelectionRange.endPosition.line && currentSelectionRange.startPosition.character === currentSelectionRange.endPosition.character) { + return; + } + this._currentSession.push({ + Selection: { + fs_file_path: filePath, + range: this.selectionToSidecarRange(selections[0]), + } + }); + return; + } + const lastEvent = this._currentSession.at(-1); + const textDocument = await vscode.workspace.openTextDocument(filePath); + // If we have a lsp context event then we most likely here have the destination + // location over here + if (lastEvent !== undefined && lastEvent.LSPContextEvent !== undefined) { + lastEvent.LSPContextEvent.destination = { + position: currentSelectionRange.startPosition, + fs_file_path: filePath, + line_content: textDocument.lineAt(currentSelectionRange.startPosition.line).text, + }; + console.log('onDidChangeTextDocumentSelection::update_destination'); + return; + } + // we should not track any events which are just movements, a movement + // in the editor shows up as a 0 length selection + if (currentSelectionRange.startPosition.line === currentSelectionRange.endPosition.line && currentSelectionRange.startPosition.character === currentSelectionRange.endPosition.character) { + return; + } + if (lastEvent !== undefined && lastEvent.Selection !== null && lastEvent.Selection !== undefined) { + // we compare both the start and the end position line numbers here + // because the selection can be from the top dragging or the bottom + // dragging + if (lastEvent.Selection.range.startPosition.line === currentSelectionRange.startPosition.line || lastEvent.Selection.range.endPosition.line === currentSelectionRange.endPosition.line) { + this._currentSession[this._currentSession.length - 1] = { + Selection: { + fs_file_path: filePath, + range: currentSelectionRange, + } + }; + return; + } + } + this._currentSession.push({ + Selection: { + fs_file_path: filePath, + range: currentSelectionRange, + } + }); + console.log('selectionLenght', selections.length); + } + + selectionToSidecarRange(selection: vscode.Selection): SidecarRequestRange { + if (selection.isReversed) { + return { + startPosition: { + line: selection.active.line, + character: selection.active.character, + byteOffset: 0, + }, + endPosition: { + line: selection.anchor.line, + character: selection.anchor.character, + byteOffset: 0, + } + }; + } else { + return { + startPosition: { + line: selection.anchor.line, + character: selection.anchor.character, + byteOffset: 0, + }, + endPosition: { + line: selection.active.line, + character: selection.active.character, + byteOffset: 0, + } + }; + } + } + dispose(): void { this._disposable.dispose(); } diff --git a/extensions/codestory/src/extension.ts b/extensions/codestory/src/extension.ts index b70166a89a7..f8630d2ba28 100644 --- a/extensions/codestory/src/extension.ts +++ b/extensions/codestory/src/extension.ts @@ -6,14 +6,15 @@ import * as os from 'os'; import { commands, DiagnosticSeverity, env, ExtensionContext, languages, modelSelection, window, workspace, } from 'vscode'; import { createInlineCompletionItemProvider } from './completions/create-inline-completion-item-provider'; -import { CSChatAgentProvider } from './completions/providers/chatprovider'; -import { AideProbeProvider } from './completions/providers/probeProvider'; +import { AideAgentSessionProvider } from './completions/providers/aideAgentProvider'; +import { CSEventHandler } from './csEvents/csEventHandler'; import { getGitCurrentHash, getGitRepoName } from './git/helper'; import { aideCommands } from './inlineCompletion/commands'; import { startupStatusBar } from './inlineCompletion/statusBar'; import logger from './logger'; import postHogClient from './posthog/client'; import { AideQuickFix } from './quickActions/fix'; +import { RecentEditsRetriever } from './server/editedFiles'; import { RepoRef, RepoRefBackend, SideCarClient } from './sidecar/client'; import { loadOrSaveToStorage } from './storage/types'; import { copySettings } from './utilities/copySettings'; @@ -25,8 +26,6 @@ import { readCustomSystemInstruction } from './utilities/systemInstruction'; import { CodeSymbolInformationEmbeddings } from './utilities/types'; import { getUniqueId } from './utilities/uniqueId'; import { ProjectContext } from './utilities/workspaceContext'; -import { CSEventHandler } from './csEvents/csEventHandler'; -import { RecentEditsRetriever } from './server/editedFiles'; export let SIDECAR_CLIENT: SideCarClient | null = null; @@ -41,6 +40,7 @@ export async function activate(context: ExtensionContext) { platform: os.platform(), }, }); + const registerPreCopyCommand = commands.registerCommand( 'webview.preCopySettings', async () => { @@ -95,9 +95,6 @@ export async function activate(context: ExtensionContext) { } }); - const csEventHandler = new CSEventHandler(context); - context.subscriptions.push(csEventHandler); - // Get model selection configuration const modelConfiguration = await modelSelection.getConfiguration(); // Setup the sidecar client here @@ -176,20 +173,36 @@ export async function activate(context: ExtensionContext) { const aideQuickFix = new AideQuickFix(); languages.registerCodeActionsProvider('*', aideQuickFix); + /* const chatAgentProvider = new CSChatAgentProvider( rootPath, repoName, repoHash, uniqueUserId, sidecarClient, currentRepo, projectContext ); context.subscriptions.push(chatAgentProvider); + */ // add the recent edits retriver to the subscriptions // so we can grab the recent edits very quickly const recentEditsRetriever = new RecentEditsRetriever(300 * 1000, workspace); context.subscriptions.push(recentEditsRetriever); + // Register the agent session provider + const agentSessionProvider = new AideAgentSessionProvider( + currentRepo, + projectContext, + sidecarClient, + rootPath, + recentEditsRetriever + ); + const editorUrl = agentSessionProvider.editorUrl; + context.subscriptions.push(agentSessionProvider); + + /* const probeProvider = new AideProbeProvider(sidecarClient, rootPath, recentEditsRetriever); + const editorUrl = probeProvider.editorUrl(); context.subscriptions.push(probeProvider); + */ // Register feedback commands context.subscriptions.push( @@ -199,26 +212,16 @@ export async function activate(context: ExtensionContext) { }) ); - //workspace.onDidSaveTextDocument(async (textDocument) => { - // const time = new Date(); - // const path = textDocument.uri.fsPath; - // console.log(`File ${path} saved at ${time}`); - // // @sartoshi-foot-dao - // // sidecarClient.doSomethingWith(path, time); - //}); - - window.onDidChangeActiveTextEditor(async (editor) => { - if (editor) { - const activeDocument = editor.document; - if (activeDocument) { - const activeDocumentUri = activeDocument.uri; - if (shouldTrackFile(activeDocumentUri)) { - await sidecarClient.documentOpen( - activeDocumentUri.fsPath, - activeDocument.getText(), - activeDocument.languageId - ); - } + // When the selection changes in the editor we should trigger an event + window.onDidChangeTextEditorSelection(async (event) => { + const textEditor = event.textEditor; + if (shouldTrackFile(textEditor.document.uri)) { + console.log('onDidChangeTextEditorSelection'); + console.log(event.selections); + // track the changed selection over here + const selections = event.selections; + if (selections.length !== 0) { + await csEventHandler.onDidChangeTextDocumentSelection(textEditor.document.uri.fsPath, selections); } } }); @@ -257,6 +260,54 @@ export async function activate(context: ExtensionContext) { } }); + // Gets access to all the events the editor is throwing our way + const csEventHandler = new CSEventHandler(context, editorUrl); + context.subscriptions.push(csEventHandler); + + const startRecording = commands.registerCommand( + 'codestory.startRecordingContext', + async () => { + await csEventHandler.startRecording(); + console.log('start recording context'); + } + ); + context.subscriptions.push(startRecording); + const stopRecording = commands.registerCommand( + 'codestory.stopRecordingContext', + async () => { + const response = await csEventHandler.stopRecording(); + await agentSessionProvider.sendContextRecording(response); + console.log(JSON.stringify(response)); + console.log('stop recording context'); + } + ); + context.subscriptions.push(stopRecording); + + // records when we change to a new text document + workspace.onDidChangeTextDocument(async (event) => { + console.log('onDidChangeTextDocument'); + const fileName = event.document.fileName; + await csEventHandler.onDidChangeTextDocument(fileName); + }); + + window.onDidChangeActiveTextEditor(async (editor) => { + if (editor) { + const activeDocument = editor.document; + if (activeDocument) { + const activeDocumentUri = activeDocument.uri; + if (shouldTrackFile(activeDocumentUri)) { + // track that changed document over here + await csEventHandler.onDidChangeTextDocument(activeDocumentUri.fsPath); + await sidecarClient.documentOpen( + activeDocumentUri.fsPath, + activeDocument.getText(), + activeDocument.languageId + ); + } + } + } + }); + // shouldn't all listeners have this? context.subscriptions.push(diagnosticsListener); } diff --git a/extensions/codestory/src/server/applyEdits.ts b/extensions/codestory/src/server/applyEdits.ts index 39fa0cd57af..c6afb2e8c9f 100644 --- a/extensions/codestory/src/server/applyEdits.ts +++ b/extensions/codestory/src/server/applyEdits.ts @@ -67,7 +67,7 @@ export async function applyEditsDirectly( */ export async function applyEdits( request: SidecarApplyEditsRequest, - response: vscode.AgentResponseStream, + response: vscode.AideAgentResponseStream, iterationEdits: vscode.WorkspaceEdit, ): Promise { // const limiter = new Limiter(1); @@ -89,7 +89,7 @@ export async function applyEdits( // we also want to save the file at this point after applying the edit await vscode.workspace.save(fileUri); } else { - await response.codeEdit({ edits: workspaceEdit, iterationId: 'mock' }); + // await response.codeEdit({ edits: workspaceEdit, iterationId: 'mock' }); } diff --git a/extensions/codestory/src/server/quickFix.ts b/extensions/codestory/src/server/quickFix.ts index 4bd02e9bee4..a0ae757ef6a 100644 --- a/extensions/codestory/src/server/quickFix.ts +++ b/extensions/codestory/src/server/quickFix.ts @@ -17,7 +17,7 @@ class QuickFixList { this.request_ids.set(requestId, options); } - getForRequestId(requestId: string, selectedActionId: number): { label: string; command: string; arguments: any; } | undefined { + getForRequestId(requestId: string, selectedActionId: number): { label: string; command: string; arguments: any } | undefined { const options = this.request_ids.get(requestId); if (options === undefined) { return undefined; @@ -38,8 +38,13 @@ export async function quickFixInvocation(request: LSPQuickFixInvocationRequest): const file_path = request.fs_file_path; const possibleAction = QUICK_FIX_LIST.getForRequestId(requestId, actionId); if (possibleAction !== undefined) { - // execute the command - await vscode.commands.executeCommand(possibleAction.command, possibleAction.arguments); + if (Array.isArray(possibleAction.arguments)) { + // If arguments is an array, spread the arguments + await vscode.commands.executeCommand(possibleAction.command, ...possibleAction.arguments); + } else { + // If arguments is an object, pass it directly + await vscode.commands.executeCommand(possibleAction.command, possibleAction.arguments); + } // we also save the file after invoking it await vscode.workspace.save(vscode.Uri.file(file_path)); return { diff --git a/extensions/codestory/src/server/types.ts b/extensions/codestory/src/server/types.ts index d769bddbe99..ebd50c70ca4 100644 --- a/extensions/codestory/src/server/types.ts +++ b/extensions/codestory/src/server/types.ts @@ -50,6 +50,7 @@ export type CodeEditAgentBody = { codebase_search: boolean; anchor_editing: boolean; enable_import_nodes: boolean; + deep_reasoning: boolean; }; export type AnchorSessionStart = { @@ -932,3 +933,41 @@ export type SidecarDiagnosticsResponse = { message: string; range: SidecarResponseRange; }; + +export interface SidecarOpenFileContextEvent { + fs_file_path: string; +} + +/** + * The destiation after doing a lsp action + */ +export interface SidecarLSPDestination { + position: SidecarRequestPosition; + fs_file_path: string; + line_content: string; +} + +export interface SidecarLSPContextEvent { + fs_file_path: string; + position: SidecarRequestPosition; + source_word: string | undefined; + source_line: string; + // destination where we land after invoking a lsp destination + destination: SidecarLSPDestination | null; + event_type: string; +} + +export interface SidecarSelectionContextEvent { + fs_file_path: string; + range: SidecarRequestRange; +} + +/** + * All the context driven events which can happen in the editor which are useful + * and done by the user in a quest to provide additional context to the agent + */ +export interface SidecarContextEvent { + OpenFile?: SidecarOpenFileContextEvent; + LSPContextEvent?: SidecarLSPContextEvent; + Selection?: SidecarSelectionContextEvent; +} diff --git a/extensions/codestory/src/sidecar/client.ts b/extensions/codestory/src/sidecar/client.ts index 21cb590c71b..47dea65f879 100644 --- a/extensions/codestory/src/sidecar/client.ts +++ b/extensions/codestory/src/sidecar/client.ts @@ -11,7 +11,7 @@ import { StreamCompletionResponse, StreamCompletionResponseUpdates } from '../co import { OPEN_FILES_VARIABLE } from '../completions/providers/openFiles'; import { TERMINAL_SELECTION_VARIABLE } from '../completions/providers/terminalSelection'; import { CompletionRequest, CompletionResponse } from '../inlineCompletion/sidecarCompletion'; -import { CodeEditAgentBody, ProbeAgentBody, SideCarAgentEvent, UserContext } from '../server/types'; +import { CodeEditAgentBody, ProbeAgentBody, SideCarAgentEvent, SidecarContextEvent, UserContext } from '../server/types'; import { SelectionDataForExplain } from '../utilities/getSelectionContext'; import { sidecarNotIndexRepository } from '../utilities/sidecarUrl'; import { sleep } from '../utilities/sleep'; @@ -953,6 +953,8 @@ export class SideCarClient { language: activeWindowData.language, }; } + const codestoryConfiguration = vscode.workspace.getConfiguration('aide'); + const deepReasoning = codestoryConfiguration.get('deepReasoning') as boolean; const body: CodeEditAgentBody = { user_query: query, editor_url: editorUrl, @@ -963,6 +965,7 @@ export class SideCarClient { codebase_search: codebaseSearch, anchor_editing: isAnchorEditing, enable_import_nodes: false, + deep_reasoning: deepReasoning, }; const asyncIterableResponse = await callServerEventStreamingBufferedPOST(url, body); for await (const line of asyncIterableResponse) { @@ -977,6 +980,30 @@ export class SideCarClient { } } } + + async sendContextRecording( + contextEvents: readonly SidecarContextEvent[], + editorUrl: string | undefined, + ) { + if (editorUrl === undefined) { + console.log('editorUrl not found'); + return; + } + const baseUrl = new URL(this._url); + baseUrl.pathname = '/api/agentic/context_recording'; + const url = baseUrl.toString(); + const body = { + context_events: contextEvents, + editor_url: editorUrl, + }; + await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + } } diff --git a/extensions/codestory/src/timeline/events/collection.ts b/extensions/codestory/src/timeline/events/collection.ts index e41d105080e..ff61f8a4a82 100644 --- a/extensions/codestory/src/timeline/events/collection.ts +++ b/extensions/codestory/src/timeline/events/collection.ts @@ -495,7 +495,7 @@ export const taskComplete = (): ToolingEvent => { }; type ChatProgress = { - response: vscode.AideChatResponseStream; + response: vscode.ChatResponseStream; cancellationToken: vscode.CancellationToken; }; diff --git a/extensions/codestory/src/utilities/copySettings.ts b/extensions/codestory/src/utilities/copySettings.ts index a3e219727c7..9a468486115 100644 --- a/extensions/codestory/src/utilities/copySettings.ts +++ b/extensions/codestory/src/utilities/copySettings.ts @@ -8,7 +8,7 @@ // cp ~/Library/Application\ Support/Code/User/settings.json ~/Library/Application\ Support/Aide/User import { Logger } from 'winston'; -import { window } from 'vscode'; +import { commands, env, Uri, window } from 'vscode'; import * as fs from 'fs'; import * as os from 'os'; import * as process from 'process'; @@ -17,14 +17,6 @@ import { runCommandAsync } from './commandRunner'; import { get } from 'https'; -// Example array of folder name prefixes to exclude -const extensionsToGetFromOpenVSX = [ - 'ms-python.python', - 'ms-python.debugpy', - 'ms-python.vscode-pylance', -]; - - type Architectures = typeof process.arch; type ExtensionPlatorm = 'universal' | `${NodeJS.Platform}-${Architectures}`; @@ -34,7 +26,25 @@ type VsixMetadata = { }; }; +export interface IProductConfiguration { + updateUrl: string; + commit: string; + quality: string; + dataFolderName: string; + serverApplicationName?: string; + serverDataFolderName?: string; +} + + +function getProductConfiguration(): IProductConfiguration { + const content = fs.readFileSync(path.join(env.appRoot, 'product.json')).toString(); + return JSON.parse(content) as IProductConfiguration; +} + export const copySettings = async (workingDirectory: string, logger: Logger) => { + + const { dataFolderName } = getProductConfiguration(); + window.showInformationMessage('Copying settings from vscode to aide'); // We want to execute the cp command above // First we want to ensure that ~/.aide exists @@ -73,7 +83,7 @@ export const copySettings = async (workingDirectory: string, logger: Logger) => try { if (userProfilePath) { const keybindingsFolder = path.join(userProfilePath, '.vscode', 'extensions'); - const destinationFolder = path.join(userProfilePath, '.vscode-oss', 'extensions'); + const destinationFolder = path.join(userProfilePath, dataFolderName, 'extensions'); copyFiles(keybindingsFolder, destinationFolder); } } catch (exception) { @@ -83,7 +93,7 @@ export const copySettings = async (workingDirectory: string, logger: Logger) => } const homeDir = os.homedir(); - const { exitCode: exitCodeMkdir } = await runCommandAsync(workingDirectory, 'mkdir', ['-p', `${homeDir}/.vscode-oss`]); + const { exitCode: exitCodeMkdir } = await runCommandAsync(workingDirectory, 'mkdir', ['-p', `${homeDir}/${dataFolderName}`]); if (exitCodeMkdir !== 0) { window.showErrorMessage('Error creating ~/.aide directory'); logger.error('Error creating ~/.aide directory'); @@ -93,7 +103,7 @@ export const copySettings = async (workingDirectory: string, logger: Logger) => // EXTENSIONS const srcDir = path.join(homeDir, '.vscode/extensions'); - const destDir = path.join(homeDir, '.vscode-oss/'); + const destDir = path.join(homeDir, `${dataFolderName}/extensions`); // Get all subdirectories in the source folder const allDirs = fs.readdirSync(srcDir).filter(file => { @@ -102,6 +112,8 @@ export const copySettings = async (workingDirectory: string, logger: Logger) => }); + window.showInformationMessage(`Installing extensions from OpenVSX...`); + for (const dir of allDirs) { const namespaceAndExt = getNamesapceAndExtension(dir); @@ -112,55 +124,44 @@ export const copySettings = async (workingDirectory: string, logger: Logger) => } const [namespace, extension] = namespaceAndExt; - if (extensionsToGetFromOpenVSX.some(prefix => dir.startsWith(prefix))) { + let openVSXExtensionPath: string | undefined; + try { + const vsixResponse = await fetch(`https://open-vsx.org/api/${namespace}/${extension}`); + if (!vsixResponse.ok) { + throw new Error(`Failed to fetch OpenVSX metadata for ${namespace}.${extension}`); + } + const vsixMetadata = await vsixResponse.json() as VsixMetadata; + if (!vsixMetadata) { + throw new Error(`No OpenVSX metadata found for ${namespace}.${extension}`); + } - window.showInformationMessage(`Cannot copy ${namespace}.${extension} from VSCode to Aide. Installing from OpenVSX instead.`); - let openVSXExtensionPath: string | undefined; - try { - const vsixResponse = await fetch(`https://open-vsx.org/api/${namespace}/${extension}`); - if (!vsixResponse.ok) { - throw new Error(`Failed to fetch OpenVSX metadata for ${namespace}.${extension}`); - } - const vsixMetadata = await vsixResponse.json() as VsixMetadata; - if (!vsixMetadata) { - throw new Error(`No OpenVSX metadata found for ${namespace}.${extension}`); - } - const tempFile = `${namespace}.${extension}.vsix`; - const platform = `${os.platform()}-${os.arch()}` as ExtensionPlatorm; - if (vsixMetadata.downloads.universal) { - console.log(`Found universal download URL for ${namespace}.${extension}`); - openVSXExtensionPath = await downloadFileToFolder(vsixMetadata.downloads.universal, destDir, tempFile); - } else if (vsixMetadata.downloads[platform]) { - console.log(`Found platform-specific download URL for ${namespace}.${extension} on ${platform}`); - const platformSpecificDownloadUrl = vsixMetadata.downloads[platform]; - openVSXExtensionPath = await downloadFileToFolder(platformSpecificDownloadUrl, destDir, tempFile); - } - if (!openVSXExtensionPath) { - throw new Error(`Failed to find a suitabile download URL for the ${namespace}.${extension} extension for ${os.platform()} and ${os.arch()}`); - } - await installExtensionFromVSXFile(workingDirectory, openVSXExtensionPath); - console.log(`Successfully installed ${namespace}.${extension} from OpenVSX`); - } catch (error) { - console.error(`Failed to install from VSX: ${namespace}.${extension}. Error: ${error.message}`); - } finally { - if (openVSXExtensionPath) { - fs.unlinkSync(openVSXExtensionPath); - console.log(`Deleted installation file: ${openVSXExtensionPath}`); - } + const tempFile = `${namespace}.${extension}.vsix`; + const platform = `${os.platform()}-${os.arch()}` as ExtensionPlatorm; + if (vsixMetadata.downloads.universal) { + console.log(`Found universal download URL for ${namespace}.${extension}`); + openVSXExtensionPath = await downloadFileToFolder(vsixMetadata.downloads.universal, destDir, tempFile); + } else if (vsixMetadata.downloads[platform]) { + console.log(`Found platform-specific download URL for ${namespace}.${extension} on ${platform}`); + const platformSpecificDownloadUrl = vsixMetadata.downloads[platform]; + openVSXExtensionPath = await downloadFileToFolder(platformSpecificDownloadUrl, destDir, tempFile); } - } else { - const srcPath = path.join(srcDir, dir); - const destPath = path.join(destDir, dir); - const { exitCode } = await runCommandAsync(workingDirectory, 'cp', ['-R', srcPath, destPath]); - if (exitCode !== 0) { - window.showErrorMessage(`Error copying directory: ${dir}`); - console.error(`Failed to copy directory: ${dir}`); - } else { - console.log(`Successfully copied: ${dir}`); + if (!openVSXExtensionPath) { + throw new Error(`Failed to find a suitabile download URL for the ${namespace}.${extension} extension for ${os.platform()} and ${os.arch()}`); + } + await commands.executeCommand('workbench.extensions.command.installFromVSIX', Uri.parse(openVSXExtensionPath)); + console.log(`Successfully installed ${namespace}.${extension} from OpenVSX`); + } catch (error) { + console.error(`Failed to install from VSX: ${namespace}.${extension}. Error: ${error.message}`); + } finally { + if (openVSXExtensionPath) { + fs.unlinkSync(openVSXExtensionPath); + console.log(`Deleted installation file: ${openVSXExtensionPath}`); } } } + window.showInformationMessage(`Completed installing extensions from OpenVSX`); + // Now we can copy over keybindings.json and settings.json // We want to ensure that ~/Library/Application\\ Support/Aide/User exists // of if its on linux it might be on path: ~/.config/aide @@ -313,28 +314,3 @@ function downloadFileToFolder(fileUrl: string, downloadFolder: string, fileName: }); }); } - -async function installExtensionFromVSXFile(workingDirectory: string, vsixPath: string) { - const platform = os.platform(); - try { - let exitCode: number; - - switch (platform) { - case 'win32': - ({ exitCode } = await runCommandAsync(workingDirectory, 'code.cmd', ['--install-extension', vsixPath])); - break; - case 'darwin': - case 'linux': - ({ exitCode } = await runCommandAsync(workingDirectory, 'code', ['--install-extension', vsixPath])); - break; - default: - throw new Error(`Can't install on ${platform} from VSX file: ${vsixPath}`); - } - - if (exitCode !== 0) { - throw new Error(`Failed to install extension from VSX file: ${vsixPath}`); - } - } catch (err) { - console.error(err); - } -} diff --git a/extensions/codestory/src/utilities/stringifyEvent.ts b/extensions/codestory/src/utilities/stringifyEvent.ts index fc610af924b..e5603ad2b32 100644 --- a/extensions/codestory/src/utilities/stringifyEvent.ts +++ b/extensions/codestory/src/utilities/stringifyEvent.ts @@ -8,18 +8,18 @@ import { SymbolNavigationActionType } from 'vscode'; export function getSymbolNavigationActionTypeLabel(actionType: SymbolNavigationActionType): string { switch (actionType) { case SymbolNavigationActionType.GoToDefinition: - return 'Go To Definition'; + return 'GoToDefinition'; case SymbolNavigationActionType.GoToDeclaration: - return 'Go To Declaration'; + return 'GoToDeclaration'; case SymbolNavigationActionType.GoToTypeDefinition: - return 'Go To Type Definition'; + return 'GoToTypeDefinition'; case SymbolNavigationActionType.GoToImplementation: - return 'Go To Implementation'; + return 'GoToImplementation'; case SymbolNavigationActionType.GoToReferences: - return 'Go To References'; + return 'GoToReferences'; case SymbolNavigationActionType.GenericGoToLocation: - return 'Generic Go To Location'; + return 'GenericGoToLocation'; default: - return 'Unknown Action Type'; + return 'NotTracked'; } } diff --git a/extensions/codestory/tsconfig.json b/extensions/codestory/tsconfig.json index cebac6011b5..6d0eadb5b09 100644 --- a/extensions/codestory/tsconfig.json +++ b/extensions/codestory/tsconfig.json @@ -24,6 +24,7 @@ "../../src/vscode-dts/vscode.proposed.interactive.d.ts", "../../src/vscode-dts/vscode.proposed.languageModels.d.ts", "../../src/vscode-dts/vscode.proposed.lmTools.d.ts", + "../../src/vscode-dts/vscode.proposed.mappedEditsProvider.d.ts", "../../src/vscode-dts/vscode.proposed.modelSelection.d.ts", "../../src/vscode-dts/vscode.proposed.terminalSelection.d.ts" ] diff --git a/extensions/theme-defaults/themes/codestory_dark.json b/extensions/theme-defaults/themes/codestory_dark.json index 8876404f84e..cb8df93e9d3 100644 --- a/extensions/theme-defaults/themes/codestory_dark.json +++ b/extensions/theme-defaults/themes/codestory_dark.json @@ -1,387 +1,1131 @@ { "name": "Codestory Dark Theme", + "$schema": "vscode://schemas/color-theme", "type": "dark", - "semanticHighlighting": true, - "semanticTokenColors": { - "enumMember": { - "foreground": "#56b6c2" - }, - "variable.constant": { - "foreground": "#d19a66" - }, - "variable.defaultLibrary": { - "foreground": "#e5c07b" - }, - "variable:dart": { - "foreground": "#d19a66" - }, - "property:dart": { - "foreground": "#d19a66" - }, - "annotation:dart": { - "foreground": "#d19a66" - }, - "parameter.label:dart": { - "foreground": "#abb2bf" - }, - "macro": { - "foreground": "#d19a66" - }, - "tomlArrayKey": { - "foreground": "#e5c07b" - }, - "memberOperatorOverload": { - "foreground": "#c678dd" - } + "colors": { + "activityBar.activeBackground": "#181818", + "activityBar.background": "#181818", + "activityBar.border": "#383838", + "activityBar.foreground": "#e3e1e3", + "activityBar.inactiveForeground": "#7a797a", + "activityBarBadge.background": "#228df2", + "activityBarBadge.foreground": "#d6d6dd", + "badge.background": "#228df2", + "badge.foreground": "#d6d6dd", + "breadcrumb.activeSelectionForeground": "#d6d6dd", + "breadcrumb.background": "#181818", + "breadcrumb.focusForeground": "#d6d6dd", + "breadcrumb.foreground": "#a6a6a6", + "button.background": "#228df2", + "button.foreground": "#e6e6ed", + "button.hoverBackground": "#359dff", + "button.secondaryBackground": "#1d1d1d", + "button.secondaryForeground": "#d6d6dd", + "button.secondaryHoverBackground": "#303030", + "checkbox.background": "#1d1d1d", + "checkbox.border": "#4f4f4f", + "checkbox.foreground": "#d6d6dd", + "commandCenter.activeBackground": "#1d1d1d", + "commandCenter.background": "#181818", + "commandCenter.foreground": "#c1c1c1", + "debugExceptionWidget.background": "#1d1d1d", + "debugExceptionWidget.border": "#383838", + "debugToolBar.background": "#343334", + "debugToolBar.border": "#383838", + "diffEditor.border": "#383838", + "diffEditor.insertedTextBackground": "#83d6c530", + "diffEditor.insertedTextBorder": "#d6d6dd00", + "diffEditor.removedTextBackground": "#f14c4c30", + "diffEditor.removedTextBorder": "#d6d6dd00", + "dropdown.background": "#1d1d1d", + "dropdown.border": "#383838", + "dropdown.foreground": "#d6d6dd", + "editor.background": "#181818", + "editor.findMatchBackground": "#163764", + "editor.findMatchBorder": "#00000000", + "editor.findMatchHighlightBackground": "#7c511a", + "editor.findMatchHighlightBorder": "#d7d7dd02", + "editor.findRangeHighlightBackground": "#1d1d1d", + "editor.findRangeHighlightBorder": "#d6d6dd00", + "editor.foldBackground": "#1d1d1d", + "editor.foreground": "#d6d6dd", + "editor.hoverHighlightBackground": "#5b51ec70", + "editor.inactiveSelectionBackground": "#363636", + "editor.lineHighlightBackground": "#181818", + "editor.lineHighlightBorder": "#d6d6dd00", + "editor.rangeHighlightBackground": "#1d1d1d", + "editor.rangeHighlightBorder": "#38383800", + "editor.selectionBackground": "#163761", + "editor.selectionHighlightBackground": "#16376170", + "editor.selectionHighlightBorder": "#d6d6dd00", + "editor.wordHighlightBackground": "#ff000000", + "editor.wordHighlightBorder": "#d6d6dd00", + "editor.wordHighlightStrongBackground": "#16376170", + "editor.wordHighlightStrongBorder": "#d6d6dd00", + "editorBracketMatch.background": "#163761", + "editorBracketMatch.border": "#d6d6dd00", + "editorCodeLens.foreground": "#d6d6dd", + "editorCursor.background": "#181818", + "editorCursor.foreground": "#d6d6dd", + "editorError.background": "#b73a3400", + "editorError.border": "#d6d6dd00", + "editorError.foreground": "#f14c4c", + "editorGroup.border": "#383838", + "editorGroup.emptyBackground": "#181818", + "editorGroupHeader.border": "#d6d6dd00", + "editorGroupHeader.tabsBackground": "#181818", + "editorGroupHeader.tabsBorder": "#292929", + "editorGutter.addedBackground": "#15ac91", + "editorGutter.background": "#181818", + "editorGutter.commentRangeForeground": "#454547", + "editorGutter.deletedBackground": "#f14c4c", + "editorGutter.foldingControlForeground": "#d6d6dd", + "editorGutter.modifiedBackground": "#e5b95c", + "editorHoverWidget.background": "#1d1d1d", + "editorHoverWidget.border": "#383838", + "editorHoverWidget.foreground": "#d6d6dd", + "editorIndentGuide.activeBackground": "#737377", + "editorIndentGuide.background": "#383838", + "editorInfo.background": "#d6d6dd00", + "editorInfo.border": "#d6d6dd00", + "editorInfo.foreground": "#228df2", + "editorInlayHint.background": "#2b2b2b", + "editorInlayHint.foreground": "#838383", + "editorLineNumber.activeForeground": "#c2c2c2", + "editorLineNumber.foreground": "#535353", + "editorLink.activeForeground": "#228df2", + "editorMarkerNavigation.background": "#2d2d30", + "editorMarkerNavigationError.background": "#f14c4c", + "editorMarkerNavigationInfo.background": "#4c9df3", + "editorMarkerNavigationWarning.background": "#e5b95c", + "editorOverviewRuler.background": "#25252500", + "editorOverviewRuler.border": "#7f7f7f4d", + "editorRuler.foreground": "#383838", + "editorSuggestWidget.background": "#1d1d1d", + "editorSuggestWidget.border": "#383838", + "editorSuggestWidget.foreground": "#d6d6dd", + "editorSuggestWidget.highlightForeground": "#d6d6dd", + "editorSuggestWidget.selectedBackground": "#163761", + "editorWarning.background": "#a9904000", + "editorWarning.border": "#d6d6dd00", + "editorWarning.foreground": "#ea7620", + "editorWhitespace.foreground": "#737373", + "editorWidget.background": "#181818", + "editorWidget.foreground": "#d6d6dd", + "editorWidget.resizeBorder": "#ea7620", + "focusBorder": "#d6d6dd00", + "foreground": "#d6d6dd", + "gitDecoration.addedResourceForeground": "#5a964d", + "gitDecoration.conflictingResourceForeground": "#aaa0fa", + "gitDecoration.deletedResourceForeground": "#f14c4c", + "gitDecoration.ignoredResourceForeground": "#666666", + "gitDecoration.modifiedResourceForeground": "#1981ef", + "gitDecoration.stageDeletedResourceForeground": "#f14c4c", + "gitDecoration.stageModifiedResourceForeground": "#1981ef", + "gitDecoration.submoduleResourceForeground": "#1981ef", + "gitDecoration.untrackedResourceForeground": "#3ea17f", + "icon.foreground": "#d6d6dd", + "input.background": "#212121", + "input.border": "#ffffff1e", + "input.foreground": "#d6d6dd", + "input.placeholderForeground": "#7b7b7b", + "inputOption.activeBackground": "#de3c72", + "inputOption.activeBorder": "#d6d6dd00", + "inputOption.activeForeground": "#d6d6dd", + "list.activeSelectionBackground": "#163761", + "list.activeSelectionForeground": "#d6d6dd", + "list.dropBackground": "#d6d6dd00", + "list.focusBackground": "#5b51ec", + "list.focusForeground": "#d6d6dd", + "list.highlightForeground": "#d6d6dd", + "list.hoverBackground": "#2a282a", + "list.hoverForeground": "#d6d6dd", + "list.inactiveSelectionBackground": "#3c3b3c", + "list.inactiveSelectionForeground": "#d6d6dd", + "listFilterWidget.background": "#5b51ec", + "listFilterWidget.noMatchesOutline": "#f14c4c", + "listFilterWidget.outline": "#00000000", + "menu.background": "#181818", + "menu.border": "#000000", + "menu.foreground": "#d6d6dd", + "menu.selectionBackground": "#194176", + "menu.selectionBorder": "#00000000", + "menu.selectionForeground": "#d6d6dd", + "menu.separatorBackground": "#3e3e3e", + "menubar.selectionBackground": "#d6d6dd20", + "menubar.selectionBorder": "#d6d6dd00", + "menubar.selectionForeground": "#d6d6dd", + "merge.commonContentBackground": "#1d1d1d", + "merge.commonHeaderBackground": "#323232", + "merge.currentContentBackground": "#1a493d", + "merge.currentHeaderBackground": "#83d6c595", + "merge.incomingContentBackground": "#28384b", + "merge.incomingHeaderBackground": "#395f8f", + "minimap.background": "#181818", + "minimap.errorHighlight": "#f14c4c", + "minimap.findMatchHighlight": "#15ac9170", + "minimap.selectionHighlight": "#363636", + "minimap.warningHighlight": "#ea7620", + "minimapGutter.addedBackground": "#15ac91", + "minimapGutter.deletedBackground": "#f14c4c", + "minimapGutter.modifiedBackground": "#e5b95c", + "notebook.focusedCellBorder": "#15ac91", + "notebook.focusedEditorBorder": "#15ac9177", + "notificationCenter.border": "#2c2c2c", + "notificationCenterHeader.background": "#2c2c2c", + "notificationCenterHeader.foreground": "#d6d6dd", + "notificationToast.border": "#383838", + "notifications.background": "#1d1d1d", + "notifications.border": "#2c2c2c", + "notifications.foreground": "#d6d6dd", + "notificationsErrorIcon.foreground": "#f14c4c", + "notificationsInfoIcon.foreground": "#228df2", + "notificationsWarningIcon.foreground": "#ea7620", + "panel.background": "#181818", + "panel.border": "#383838", + "panelSection.border": "#383838", + "panelTitle.activeBorder": "#d6d6dd", + "panelTitle.activeForeground": "#d6d6dd", + "panelTitle.inactiveForeground": "#d6d6dd", + "peekView.border": "#383838", + "peekViewEditor.background": "#001f33", + "peekViewEditor.matchHighlightBackground": "#ea762070", + "peekViewEditor.matchHighlightBorder": "#d6d6dd00", + "peekViewEditorGutter.background": "#001f33", + "peekViewResult.background": "#1d1d1d", + "peekViewResult.fileForeground": "#d6d6dd", + "peekViewResult.lineForeground": "#d6d6dd", + "peekViewResult.matchHighlightBackground": "#ea762070", + "peekViewResult.selectionBackground": "#363636", + "peekViewResult.selectionForeground": "#d6d6dd", + "peekViewTitle.background": "#1d1d1d", + "peekViewTitleDescription.foreground": "#d6d6dd", + "peekViewTitleLabel.foreground": "#d6d6dd", + "pickerGroup.border": "#383838", + "pickerGroup.foreground": "#d6d6dd", + "progressBar.background": "#15ac91", + "scrollbar.shadow": "#d6d6dd00", + "scrollbarSlider.activeBackground": "#676767", + "scrollbarSlider.background": "#67676750", + "scrollbarSlider.hoverBackground": "#676767", + "selection.background": "#163761", + "settings.focusedRowBackground": "#d6d6dd07", + "settings.headerForeground": "#d6d6dd", + "sideBar.background": "#181818", + "sideBar.border": "#383838", + "sideBar.dropBackground": "#d6d6dd00", + "sideBar.foreground": "#d1d1d1", + "sideBarSectionHeader.background": "#18181800", + "sideBarSectionHeader.border": "#d1d1d100", + "sideBarSectionHeader.foreground": "#d1d1d1", + "sideBarTitle.foreground": "#d1d1d1", + "statusBar.background": "#181818", + "statusBar.border": "#383838", + "statusBar.debuggingBackground": "#ea7620", + "statusBar.debuggingBorder": "#d6d6dd00", + "statusBar.debuggingForeground": "#e7e7e7", + "statusBar.foreground": "#d6d6dd", + "statusBar.noFolderBackground": "#181818", + "statusBar.noFolderBorder": "#d6d6dd00", + "statusBar.noFolderForeground": "#6b6b6b", + "statusBarItem.activeBackground": "#d6d6dd25", + "statusBarItem.hoverBackground": "#d6d6dd20", + "statusBarItem.remoteBackground": "#5b51ec", + "statusBarItem.remoteForeground": "#d6d6dd", + "tab.activeBackground": "#181818", + "tab.activeBorder": "#181818", + "tab.activeBorderTop": "#d6d6dd", + "tab.activeForeground": "#d6d6dd", + "tab.border": "#292929", + "tab.hoverBorder": "#222222", + "tab.hoverForeground": "#d6d6dd", + "tab.inactiveBackground": "#181818", + "tab.inactiveForeground": "#d6d6dd", + "terminal.ansiBlack": "#676767", + "terminal.ansiBlue": "#4c9df3", + "terminal.ansiBrightBlack": "#676767", + "terminal.ansiBrightBlue": "#4c9df3", + "terminal.ansiBrightCyan": "#75d3ba", + "terminal.ansiBrightGreen": "#15ac91", + "terminal.ansiBrightMagenta": "#e567dc", + "terminal.ansiBrightRed": "#f14c4c", + "terminal.ansiBrightWhite": "#d6d6dd", + "terminal.ansiBrightYellow": "#e5b95c", + "terminal.ansiCyan": "#75d3ba", + "terminal.ansiGreen": "#15ac91", + "terminal.ansiMagenta": "#e567dc", + "terminal.ansiRed": "#f14c4c", + "terminal.ansiWhite": "#d6d6dd", + "terminal.ansiYellow": "#e5b95c", + "terminal.background": "#191919", + "terminal.border": "#383838", + "terminal.foreground": "#d6d6dd", + "terminal.selectionBackground": "#636262fd", + "terminalCursor.background": "#5b51ec", + "terminalCursor.foreground": "#d6d6dd", + "textLink.foreground": "#228df2", + "titleBar.activeBackground": "#181818", + "titleBar.activeForeground": "#d1d1d1", + "titleBar.border": "#383838", + "titleBar.inactiveBackground": "#191919", + "titleBar.inactiveForeground": "#cccccc99", + "tree.indentGuidesStroke": "#d6d6dd00", + "walkThrough.embeddedEditorBackground": "#00000050", + "widget.shadow": "#111111eb" + //"actionBar.toggledBackground": "#de3c72", + //"activityBar.activeBorder": "#e3e1e3", + //"activityBar.dropBorder": "#e3e1e3", + //"activityBarTop.activeBorder": "#e7e7e7", + //"activityBarTop.dropBorder": "#e7e7e7", + //"activityBarTop.foreground": "#e7e7e7", + //"activityBarTop.inactiveForeground": "#e7e7e799", + //"banner.background": "#163761", + //"banner.foreground": "#d6d6dd", + //"banner.iconForeground": "#228df2", + //"breadcrumbPicker.background": "#181818", + //"button.separator": "#e6e6ed66", + //"charts.blue": "#228df2", + //"charts.foreground": "#d6d6dd", + //"charts.green": "#89d185", + //"charts.lines": "#d6d6dd80", + //"charts.orange": "#15ac9170", + //"charts.purple": "#b180d7", + //"charts.red": "#f14c4c", + //"charts.yellow": "#ea7620", + //"chat.avatarBackground": "#1f1f1f", + //"chat.avatarForeground": "#d6d6dd", + //"chat.requestBackground": "#1818189e", + //"chat.requestBorder": "#ffffff1a", + //"chat.slashCommandBackground": "#34414b8f", + //"chat.slashCommandForeground": "#40a6ff", + //"checkbox.selectBackground": "#181818", + //"checkbox.selectBorder": "#d6d6dd", + //"commandCenter.activeBorder": "#d1d1d14d", + //"commandCenter.activeForeground": "#d6d6dd", + //"commandCenter.border": "#d1d1d133", + //"commandCenter.debuggingBackground": "#ea762042", + //"commandCenter.inactiveBorder": "#cccccc26", + //"commandCenter.inactiveForeground": "#cccccc99", + //"commentsView.resolvedIcon": "#cccccc80", + //"commentsView.unresolvedIcon": "#d6d6dd00", + //"debugConsole.errorForeground": "#f48771", + //"debugConsole.infoForeground": "#228df2", + //"debugConsole.sourceForeground": "#d6d6dd", + //"debugConsole.warningForeground": "#ea7620", + //"debugConsoleInputIcon.foreground": "#d6d6dd", + //"debugIcon.breakpointCurrentStackframeForeground": "#ffcc00", + //"debugIcon.breakpointDisabledForeground": "#848484", + //"debugIcon.breakpointForeground": "#e51400", + //"debugIcon.breakpointStackframeForeground": "#89d185", + //"debugIcon.breakpointUnverifiedForeground": "#848484", + //"debugIcon.continueForeground": "#75beff", + //"debugIcon.disconnectForeground": "#f48771", + //"debugIcon.pauseForeground": "#75beff", + //"debugIcon.restartForeground": "#89d185", + //"debugIcon.startForeground": "#89d185", + //"debugIcon.stepBackForeground": "#75beff", + //"debugIcon.stepIntoForeground": "#75beff", + //"debugIcon.stepOutForeground": "#75beff", + //"debugIcon.stepOverForeground": "#75beff", + //"debugIcon.stopForeground": "#f48771", + //"debugTokenExpression.boolean": "#4e94ce", + //"debugTokenExpression.error": "#f48771", + //"debugTokenExpression.name": "#c586c0", + //"debugTokenExpression.number": "#b5cea8", + //"debugTokenExpression.string": "#ce9178", + //"debugTokenExpression.type": "#4a90e2", + //"debugTokenExpression.value": "#cccccc99", + //"debugView.exceptionLabelBackground": "#6c2022", + //"debugView.exceptionLabelForeground": "#d6d6dd", + //"debugView.stateLabelBackground": "#88888844", + //"debugView.stateLabelForeground": "#d6d6dd", + //"debugView.valueChangedHighlight": "#569cd6", + //"descriptionForeground": "#d6d6ddb3", + //"diffEditor.diagonalFill": "#cccccc33", + //"diffEditor.insertedLineBackground": "#9bb95533", + //"diffEditor.move.border": "#8b8b8b9c", + //"diffEditor.moveActive.border": "#ffa500", + //"diffEditor.removedLineBackground": "#ff000033", + //"diffEditor.unchangedCodeBackground": "#74747429", + //"diffEditor.unchangedRegionBackground": "#181818", + //"diffEditor.unchangedRegionForeground": "#d6d6dd", + //"diffEditor.unchangedRegionShadow": "#000000", + //"disabledForeground": "#cccccc80", + //"editor.focusedStackFrameHighlightBackground": "#7abd7a4d", + //"editor.foldPlaceholderForeground": "#808080", + //"editor.inlineValuesBackground": "#ffc80033", + //"editor.inlineValuesForeground": "#ffffff80", + //"editor.linkedEditingBackground": "#ff00004d", + //"editor.placeholder.foreground": "#ffffff56", + //"editor.snippetFinalTabstopHighlightBorder": "#525252", + //"editor.snippetTabstopHighlightBackground": "#7c7c7c4d", + //"editor.stackFrameHighlightBackground": "#ffff0033", + //"editor.symbolHighlightBackground": "#7c511a", + //"editor.wordHighlightTextBackground": "#ff000000", + //"editor.wordHighlightTextBorder": "#d6d6dd00", + //"editorActiveLineNumber.foreground": "#c6c6c6", + //"editorBracketHighlight.foreground1": "#ffd700", + //"editorBracketHighlight.foreground2": "#da70d6", + //"editorBracketHighlight.foreground3": "#179fff", + //"editorBracketHighlight.foreground4": "#00000000", + //"editorBracketHighlight.foreground5": "#00000000", + //"editorBracketHighlight.foreground6": "#00000000", + //"editorBracketHighlight.unexpectedBracket.foreground": "#ff1212cc", + //"editorBracketPairGuide.activeBackground1": "#00000000", + //"editorBracketPairGuide.activeBackground2": "#00000000", + //"editorBracketPairGuide.activeBackground3": "#00000000", + //"editorBracketPairGuide.activeBackground4": "#00000000", + //"editorBracketPairGuide.activeBackground5": "#00000000", + //"editorBracketPairGuide.activeBackground6": "#00000000", + //"editorBracketPairGuide.background1": "#00000000", + //"editorBracketPairGuide.background2": "#00000000", + //"editorBracketPairGuide.background3": "#00000000", + //"editorBracketPairGuide.background4": "#00000000", + //"editorBracketPairGuide.background5": "#00000000", + //"editorBracketPairGuide.background6": "#00000000", + //"editorCommentsWidget.rangeActiveBackground": "#d6d6dd00", + //"editorCommentsWidget.rangeBackground": "#d6d6dd00", + //"editorCommentsWidget.replyInputBackground": "#1d1d1d", + //"editorCommentsWidget.resolvedBorder": "#cccccc80", + //"editorCommentsWidget.unresolvedBorder": "#d6d6dd00", + //"editorGhostText.foreground": "#ffffff56", + //"editorGroup.dropBackground": "#53595d80", + //"editorGroup.dropIntoPromptBackground": "#181818", + //"editorGroup.dropIntoPromptForeground": "#d6d6dd", + //"editorGroupHeader.noTabsBackground": "#181818", + //"editorGutter.commentGlyphForeground": "#d6d6dd", + //"editorGutter.commentUnresolvedGlyphForeground": "#d6d6dd", + //"editorHint.foreground": "#eeeeeeb3", + //"editorHoverWidget.highlightForeground": "#d6d6dd", + //"editorHoverWidget.statusBarBackground": "#232323", + //"editorIndentGuide.activeBackground1": "#737377", + //"editorIndentGuide.activeBackground2": "#00000000", + //"editorIndentGuide.activeBackground3": "#00000000", + //"editorIndentGuide.activeBackground4": "#00000000", + //"editorIndentGuide.activeBackground5": "#00000000", + //"editorIndentGuide.activeBackground6": "#00000000", + //"editorIndentGuide.background1": "#383838", + //"editorIndentGuide.background2": "#00000000", + //"editorIndentGuide.background3": "#00000000", + //"editorIndentGuide.background4": "#00000000", + //"editorIndentGuide.background5": "#00000000", + //"editorIndentGuide.background6": "#00000000", + //"editorInlayHint.parameterBackground": "#2b2b2b", + //"editorInlayHint.parameterForeground": "#838383", + //"editorInlayHint.typeBackground": "#2b2b2b", + //"editorInlayHint.typeForeground": "#838383", + //"editorLightBulb.foreground": "#ffcc00", + //"editorLightBulbAi.foreground": "#ffcc00", + //"editorLightBulbAutoFix.foreground": "#75beff", + //"editorMarkerNavigationError.headerBackground": "#f14c4c1a", + //"editorMarkerNavigationInfo.headerBackground": "#4c9df31a", + //"editorMarkerNavigationWarning.headerBackground": "#e5b95c1a", + //"editorMultiCursor.primary.background": "#181818", + //"editorMultiCursor.primary.foreground": "#d6d6dd", + //"editorMultiCursor.secondary.background": "#181818", + //"editorMultiCursor.secondary.foreground": "#d6d6dd", + //"editorOverviewRuler.addedForeground": "#15ac9199", + //"editorOverviewRuler.bracketMatchForeground": "#a0a0a0", + //"editorOverviewRuler.commentForeground": "#454547", + //"editorOverviewRuler.commentUnresolvedForeground": "#454547", + //"editorOverviewRuler.commonContentForeground": "#323232", + //"editorOverviewRuler.currentContentForeground": "#83d6c595", + //"editorOverviewRuler.deletedForeground": "#f14c4c99", + //"editorOverviewRuler.errorForeground": "#ff1212b3", + //"editorOverviewRuler.findMatchForeground": "#d186167e", + //"editorOverviewRuler.incomingContentForeground": "#395f8f", + //"editorOverviewRuler.infoForeground": "#228df2", + //"editorOverviewRuler.inlineChatInserted": "#83d6c51d", + //"editorOverviewRuler.inlineChatRemoved": "#f14c4c1d", + //"editorOverviewRuler.modifiedForeground": "#e5b95c99", + //"editorOverviewRuler.rangeHighlightForeground": "#007acc99", + //"editorOverviewRuler.selectionHighlightForeground": "#a0a0a0cc", + //"editorOverviewRuler.warningForeground": "#ea7620", + //"editorOverviewRuler.wordHighlightForeground": "#a0a0a0cc", + //"editorOverviewRuler.wordHighlightStrongForeground": "#c0a0c0cc", + //"editorOverviewRuler.wordHighlightTextForeground": "#a0a0a0cc", + //"editorPane.background": "#181818", + //"editorStickyScroll.background": "#181818", + //"editorStickyScroll.shadow": "#d6d6dd00", + //"editorStickyScrollHover.background": "#2a2d2e", + //"editorSuggestWidget.focusHighlightForeground": "#d6d6dd", + //"editorSuggestWidget.selectedForeground": "#d6d6dd", + //"editorSuggestWidgetStatus.foreground": "#d6d6dd80", + //"editorUnicodeHighlight.background": "#a9904000", + //"editorUnicodeHighlight.border": "#ea7620", + //"editorUnnecessaryCode.opacity": "#000000aa", + //"editorWidget.border": "#454545", + //"errorForeground": "#f48771", + //"extensionBadge.remoteBackground": "#228df2", + //"extensionBadge.remoteForeground": "#d6d6dd", + //"extensionButton.background": "#228df2", + //"extensionButton.foreground": "#e6e6ed", + //"extensionButton.hoverBackground": "#359dff", + //"extensionButton.prominentBackground": "#228df2", + //"extensionButton.prominentForeground": "#e6e6ed", + //"extensionButton.prominentHoverBackground": "#359dff", + //"extensionButton.separator": "#e6e6ed66", + //"extensionIcon.preReleaseForeground": "#1d9271", + //"extensionIcon.sponsorForeground": "#d758b3", + //"extensionIcon.starForeground": "#ff8e00", + //"extensionIcon.verifiedForeground": "#228df2", + //"gitDecoration.renamedResourceForeground": "#73c991", + //"inlineChat.background": "#181818", + //"inlineChat.border": "#454545", + //"inlineChat.foreground": "#d6d6dd", + //"inlineChat.shadow": "#111111eb", + //"inlineChatDiff.inserted": "#83d6c518", + //"inlineChatDiff.removed": "#f14c4c18", + //"inlineChatInput.background": "#212121", + //"inlineChatInput.border": "#454545", + //"inlineChatInput.focusBorder": "#d6d6dd00", + //"inlineChatInput.placeholderForeground": "#7b7b7b", + //"inputOption.hoverBackground": "#5a5d5e80", + //"inputValidation.errorBackground": "#5a1d1d", + //"inputValidation.errorBorder": "#be1100", + //"inputValidation.infoBackground": "#063b49", + //"inputValidation.infoBorder": "#007acc", + //"inputValidation.warningBackground": "#352a05", + //"inputValidation.warningBorder": "#b89500", + //"interactive.activeCodeBorder": "#383838", + //"interactive.inactiveCodeBorder": "#3c3b3c", + //"keybindingLabel.background": "#8080802b", + //"keybindingLabel.border": "#33333399", + //"keybindingLabel.bottomBorder": "#44444499", + //"keybindingLabel.foreground": "#cccccc", + //"keybindingTable.headerBackground": "#d6d6dd0a", + //"keybindingTable.rowsBackground": "#d6d6dd0a", + //"list.deemphasizedForeground": "#8c8c8c", + //"list.dropBetweenBackground": "#d6d6dd", + //"list.errorForeground": "#f88070", + //"list.filterMatchBackground": "#7c511a", + //"list.filterMatchBorder": "#d7d7dd02", + //"list.focusHighlightForeground": "#d6d6dd", + //"list.focusOutline": "#d6d6dd00", + //"list.invalidItemForeground": "#b89500", + //"list.warningForeground": "#cca700", + //"listFilterWidget.shadow": "#111111eb", + //"mergeEditor.change.background": "#9bb95533", + //"mergeEditor.change.word.background": "#9ccc2c33", + //"mergeEditor.changeBase.background": "#4b1818", + //"mergeEditor.changeBase.word.background": "#6f1313", + //"mergeEditor.conflict.handled.minimapOverViewRuler": "#adaca8ee", + //"mergeEditor.conflict.handledFocused.border": "#c1c1c1cc", + //"mergeEditor.conflict.handledUnfocused.border": "#86868649", + //"mergeEditor.conflict.input1.background": "#83d6c53c", + //"mergeEditor.conflict.input2.background": "#395f8f66", + //"mergeEditor.conflict.unhandled.minimapOverViewRuler": "#fcba03", + //"mergeEditor.conflict.unhandledFocused.border": "#ffa600", + //"mergeEditor.conflict.unhandledUnfocused.border": "#ffa6007a", + //"mergeEditor.conflictingLines.background": "#ffea0047", + //"minimap.foregroundOpacity": "#000000", + //"minimap.infoHighlight": "#228df2", + //"minimap.selectionOccurrenceHighlight": "#676767", + //"minimapSlider.activeBackground": "#67676780", + //"minimapSlider.background": "#67676728", + //"minimapSlider.hoverBackground": "#67676780", + //"multiDiffEditor.border": "#d1d1d100", + //"multiDiffEditor.headerBackground": "#262626", + //"notebook.cellBorderColor": "#3c3b3c", + //"notebook.cellEditorBackground": "#181818", + //"notebook.cellInsertionIndicator": "#d6d6dd00", + //"notebook.cellStatusBarItemHoverBackground": "#ffffff26", + //"notebook.cellToolbarSeparator": "#80808059", + //"notebook.editorBackground": "#181818", + //"notebook.inactiveFocusedCellBorder": "#3c3b3c", + //"notebook.selectedCellBackground": "#3c3b3c", + //"notebook.selectedCellBorder": "#3c3b3c", + //"notebook.symbolHighlightBackground": "#ffffff0b", + //"notebookEditorOverviewRuler.runningCellForeground": "#89d185", + //"notebookScrollbarSlider.activeBackground": "#676767", + //"notebookScrollbarSlider.background": "#67676750", + //"notebookScrollbarSlider.hoverBackground": "#676767", + //"notebookStatusErrorIcon.foreground": "#f48771", + //"notebookStatusRunningIcon.foreground": "#d6d6dd", + //"notebookStatusSuccessIcon.foreground": "#89d185", + //"notificationLink.foreground": "#228df2", + //"panel.dropBorder": "#d6d6dd", + //"panelInput.border": "#ffffff1e", + //"panelSection.dropBackground": "#53595d80", + //"panelSectionHeader.background": "#80808033", + //"panelStickyScroll.background": "#181818", + //"panelStickyScroll.shadow": "#d6d6dd00", + //"peekViewEditorStickyScroll.background": "#001f33", + //"ports.iconRunningProcessForeground": "#5b51ec", + //"problemsErrorIcon.foreground": "#f14c4c", + //"problemsInfoIcon.foreground": "#228df2", + //"problemsWarningIcon.foreground": "#ea7620", + //"profileBadge.background": "#4d4d4d", + //"profileBadge.foreground": "#ffffff", + //"profiles.sashBorder": "#383838", + //"quickInput.background": "#181818", + //"quickInput.foreground": "#d6d6dd", + //"quickInputList.focusBackground": "#163761", + //"quickInputList.focusForeground": "#d6d6dd", + //"quickInputTitle.background": "#ffffff1b", + //"sash.hoverBorder": "#d6d6dd00", + //"scm.historyItemAdditionsForeground": "#5a964d", + //"scm.historyItemDeletionsForeground": "#f14c4c", + //"scm.historyItemSelectedStatisticsBorder": "#d6d6dd33", + //"scm.historyItemStatisticsBorder": "#d6d6dd33", + //"search.resultsInfoForeground": "#d6d6dda6", + //"searchEditor.findMatchBackground": "#7c511aa8", + //"searchEditor.findMatchBorder": "#d7d7dd01", + //"searchEditor.textInputBorder": "#ffffff1e", + //"settings.checkboxBackground": "#1d1d1d", + //"settings.checkboxBorder": "#4f4f4f", + //"settings.checkboxForeground": "#d6d6dd", + //"settings.dropdownBackground": "#1d1d1d", + //"settings.dropdownBorder": "#383838", + //"settings.dropdownForeground": "#d6d6dd", + //"settings.dropdownListBorder": "#454545", + //"settings.focusedRowBorder": "#d6d6dd00", + //"settings.headerBorder": "#383838", + //"settings.modifiedItemIndicator": "#0c7d9d", + //"settings.numberInputBackground": "#212121", + //"settings.numberInputBorder": "#ffffff1e", + //"settings.numberInputForeground": "#d6d6dd", + //"settings.rowHoverBackground": "#2a282a4d", + //"settings.sashBorder": "#383838", + //"settings.settingsHeaderHoverForeground": "#d6d6ddb3", + //"settings.textInputBackground": "#212121", + //"settings.textInputBorder": "#ffffff1e", + //"settings.textInputForeground": "#d6d6dd", + //"sideBarActivityBarTop.border": "#d1d1d100", + //"sideBarStickyScroll.background": "#181818", + //"sideBarStickyScroll.shadow": "#d6d6dd00", + //"sideBarTitle.background": "#181818", + //"sideBySideEditor.horizontalBorder": "#383838", + //"sideBySideEditor.verticalBorder": "#383838", + //"simpleFindWidget.sashBorder": "#454545", + //"statusBar.focusBorder": "#d6d6dd", + //"statusBarItem.compactHoverBackground": "#ffffff33", + //"statusBarItem.errorBackground": "#c72e0f", + //"statusBarItem.errorForeground": "#ffffff", + //"statusBarItem.errorHoverBackground": "#d6d6dd20", + //"statusBarItem.errorHoverForeground": "#d6d6dd", + //"statusBarItem.focusBorder": "#d6d6dd", + //"statusBarItem.hoverForeground": "#d6d6dd", + //"statusBarItem.offlineBackground": "#6c1717", + //"statusBarItem.offlineForeground": "#d6d6dd", + //"statusBarItem.offlineHoverBackground": "#d6d6dd20", + //"statusBarItem.offlineHoverForeground": "#d6d6dd", + //"statusBarItem.prominentBackground": "#00000080", + //"statusBarItem.prominentForeground": "#d6d6dd", + //"statusBarItem.prominentHoverBackground": "#0000004d", + //"statusBarItem.prominentHoverForeground": "#d6d6dd", + //"statusBarItem.remoteHoverBackground": "#d6d6dd20", + //"statusBarItem.remoteHoverForeground": "#d6d6dd", + //"statusBarItem.warningBackground": "#92470e", + //"statusBarItem.warningForeground": "#ffffff", + //"statusBarItem.warningHoverBackground": "#d6d6dd20", + //"statusBarItem.warningHoverForeground": "#d6d6dd", + //"symbolIcon.arrayForeground": "#d6d6dd", + //"symbolIcon.booleanForeground": "#d6d6dd", + //"symbolIcon.classForeground": "#ee9d28", + //"symbolIcon.colorForeground": "#d6d6dd", + //"symbolIcon.constantForeground": "#d6d6dd", + //"symbolIcon.constructorForeground": "#b180d7", + //"symbolIcon.enumeratorForeground": "#ee9d28", + //"symbolIcon.enumeratorMemberForeground": "#75beff", + //"symbolIcon.eventForeground": "#ee9d28", + //"symbolIcon.fieldForeground": "#75beff", + //"symbolIcon.fileForeground": "#d6d6dd", + //"symbolIcon.folderForeground": "#d6d6dd", + //"symbolIcon.functionForeground": "#b180d7", + //"symbolIcon.interfaceForeground": "#75beff", + //"symbolIcon.keyForeground": "#d6d6dd", + //"symbolIcon.keywordForeground": "#d6d6dd", + //"symbolIcon.methodForeground": "#b180d7", + //"symbolIcon.moduleForeground": "#d6d6dd", + //"symbolIcon.namespaceForeground": "#d6d6dd", + //"symbolIcon.nullForeground": "#d6d6dd", + //"symbolIcon.numberForeground": "#d6d6dd", + //"symbolIcon.objectForeground": "#d6d6dd", + //"symbolIcon.operatorForeground": "#d6d6dd", + //"symbolIcon.packageForeground": "#d6d6dd", + //"symbolIcon.propertyForeground": "#d6d6dd", + //"symbolIcon.referenceForeground": "#d6d6dd", + //"symbolIcon.snippetForeground": "#d6d6dd", + //"symbolIcon.stringForeground": "#d6d6dd", + //"symbolIcon.structForeground": "#d6d6dd", + //"symbolIcon.textForeground": "#d6d6dd", + //"symbolIcon.typeParameterForeground": "#d6d6dd", + //"symbolIcon.unitForeground": "#d6d6dd", + //"symbolIcon.variableForeground": "#75beff", + //"tab.activeModifiedBorder": "#3399cc", + //"tab.dragAndDropBorder": "#d6d6dd", + //"tab.inactiveModifiedBorder": "#3399cc80", + //"tab.lastPinnedBorder": "#d6d6dd00", + //"tab.selectedBackground": "#181818", + //"tab.selectedBorderTop": "#d6d6dd", + //"tab.selectedForeground": "#d6d6dd", + //"tab.unfocusedActiveBackground": "#181818", + //"tab.unfocusedActiveBorder": "#18181880", + //"tab.unfocusedActiveBorderTop": "#d6d6dd80", + //"tab.unfocusedActiveForeground": "#d6d6dd80", + //"tab.unfocusedActiveModifiedBorder": "#3399cc80", + //"tab.unfocusedHoverBorder": "#22222280", + //"tab.unfocusedHoverForeground": "#d6d6dd80", + //"tab.unfocusedInactiveBackground": "#181818", + //"tab.unfocusedInactiveForeground": "#d6d6dd80", + //"tab.unfocusedInactiveModifiedBorder": "#3399cc40", + //"terminal.dropBackground": "#53595d80", + //"terminal.findMatchBackground": "#163764", + //"terminal.findMatchHighlightBackground": "#7c511a", + //"terminal.hoverHighlightBackground": "#5b51ec38", + //"terminal.inactiveSelectionBackground": "#6362627e", + //"terminal.initialHintForeground": "#ffffff56", + //"terminal.tab.activeBorder": "#181818", + //"terminalCommandDecoration.defaultBackground": "#ffffff40", + //"terminalCommandDecoration.errorBackground": "#f14c4c", + //"terminalCommandDecoration.successBackground": "#1b81a8", + //"terminalOverviewRuler.cursorForeground": "#a0a0a0cc", + //"terminalOverviewRuler.findMatchForeground": "#d186167e", + //"terminalStickyScrollHover.background": "#2a2d2e", + //"testing.coverCountBadgeBackground": "#228df2", + //"testing.coverCountBadgeForeground": "#d6d6dd", + //"testing.coveredBackground": "#83d6c530", + //"testing.coveredBorder": "#83d6c524", + //"testing.coveredGutterBackground": "#83d6c51d", + //"testing.iconErrored": "#f14c4c", + //"testing.iconErrored.retired": "#f14c4cb3", + //"testing.iconFailed": "#f14c4c", + //"testing.iconFailed.retired": "#f14c4cb3", + //"testing.iconPassed": "#73c991", + //"testing.iconPassed.retired": "#73c991b3", + //"testing.iconQueued": "#cca700", + //"testing.iconQueued.retired": "#cca700b3", + //"testing.iconSkipped": "#848484", + //"testing.iconSkipped.retired": "#848484b3", + //"testing.iconUnset": "#848484", + //"testing.iconUnset.retired": "#848484b3", + //"testing.message.error.decorationForeground": "#f14c4c", + //"testing.message.error.lineBackground": "#ff000033", + //"testing.message.info.decorationForeground": "#d6d6dd80", + //"testing.messagePeekBorder": "#228df2", + //"testing.messagePeekHeaderBackground": "#228df21a", + //"testing.peekBorder": "#f14c4c", + //"testing.peekHeaderBackground": "#f14c4c1a", + //"testing.runAction": "#73c991", + //"testing.uncoveredBackground": "#f14c4c30", + //"testing.uncoveredBorder": "#f14c4c24", + //"testing.uncoveredBranchBackground": "#692b2b", + //"testing.uncoveredGutterBackground": "#f14c4c48", + //"textBlockQuote.background": "#222222", + //"textBlockQuote.border": "#007acc80", + //"textCodeBlock.background": "#0a0a0a66", + //"textLink.activeForeground": "#3794ff", + //"textPreformat.background": "#ffffff1a", + //"textPreformat.foreground": "#d7ba7d", + //"textSeparator.foreground": "#ffffff2e", + //"toolbar.activeBackground": "#63666750", + //"toolbar.hoverBackground": "#5a5d5e50", + //"tree.inactiveIndentGuidesStroke": "#d6d6dd00", + //"tree.tableColumnsBorder": "#cccccc20", + //"tree.tableOddRowsBackground": "#d6d6dd0a", + //"activityBar.activeFocusBorder": null, + //"activityBarTop.activeBackground": null, + //"activityBarTop.background": null, + //"button.border": null, + //"contrastActiveBorder": null, + //"contrastBorder": null, + //"diffEditorGutter.insertedLineBackground": null, + //"diffEditorGutter.removedLineBackground": null, + //"diffEditorOverview.insertedForeground": null, + //"diffEditorOverview.removedForeground": null, + //"dropdown.listBackground": null, + //"editor.findMatchForeground": null, + //"editor.findMatchHighlightForeground": null, + //"editor.selectionForeground": null, + //"editor.snippetFinalTabstopHighlightBackground": null, + //"editor.snippetTabstopHighlightBorder": null, + //"editor.symbolHighlightBorder": null, + //"editorGhostText.background": null, + //"editorGhostText.border": null, + //"editorGroup.dropIntoPromptBorder": null, + //"editorGroup.focusedEmptyBorder": null, + //"editorHint.border": null, + //"editorLineNumber.dimmedForeground": null, + //"editorStickyScroll.border": null, + //"editorSuggestWidget.selectedIconForeground": null, + //"editorUnnecessaryCode.border": null, + //"inputValidation.errorForeground": null, + //"inputValidation.infoForeground": null, + //"inputValidation.warningForeground": null, + //"list.activeSelectionIconForeground": null, + //"list.focusAndSelectionOutline": null, + //"list.inactiveFocusBackground": null, + //"list.inactiveFocusOutline": null, + //"list.inactiveSelectionIconForeground": null, + //"merge.border": null, + //"multiDiffEditor.background": null, + //"notebook.cellHoverBackground": null, + //"notebook.focusedCellBackground": null, + //"notebook.inactiveSelectedCellBorder": null, + //"notebook.outputContainerBackgroundColor": null, + //"notebook.outputContainerBorderColor": null, + //"outputView.background": null, + //"outputViewStickyScroll.background": null, + //"panelSectionHeader.border": null, + //"panelSectionHeader.foreground": null, + //"panelStickyScroll.border": null, + //"quickInput.list.focusBackground": null, + //"quickInputList.focusIconForeground": null, + //"sideBarStickyScroll.border": null, + //"tab.hoverBackground": null, + //"tab.unfocusedHoverBackground": null, + //"terminal.findMatchBorder": null, + //"terminal.findMatchHighlightBorder": null, + //"terminal.selectionForeground": null, + //"terminalStickyScroll.background": null, + //"terminalStickyScroll.border": null, + //"testing.message.info.lineBackground": null, + //"toolbar.hoverOutline": null, + //"widget.border": null, + //"window.activeBorder": null, + //"window.inactiveBorder": null }, "tokenColors": [ { - "scope": "meta.embedded", + "scope": "string.quoted.binary.single.python", + "settings": { + "foreground": "#A8CC7C", + "fontStyle": "" + } + }, + { + "scope": ["constant.language.false.cpp", "constant.language.true.cpp"], "settings": { - "foreground": "#abb2bf" + "foreground": "#82D2CE", + "fontStyle": "" } }, { - "name": "unison punctuation", "scope": "punctuation.definition.delayed.unison,punctuation.definition.list.begin.unison,punctuation.definition.list.end.unison,punctuation.definition.ability.begin.unison,punctuation.definition.ability.end.unison,punctuation.operator.assignment.as.unison,punctuation.separator.pipe.unison,punctuation.separator.delimiter.unison,punctuation.definition.hash.unison", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" + } + }, + { + "scope": "keyword.control.directive", + "settings": { + "foreground": "#A8CC7C" + } + }, + { + "scope": "constant.other.ellipsis.python", + "settings": { + "foreground": "#D1D1D1", + "fontStyle": "" } }, { - "name": "haskell variable generic-type", "scope": "variable.other.generic-type.haskell", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" + } + }, + { + "scope": "punctuation.definition.tag", + "settings": { + "foreground": "#898989", + "fontStyle": "" } }, { - "name": "haskell storage type", "scope": "storage.type.haskell", "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { - "name": "support.variable.magic.python", "scope": "support.variable.magic.python", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "punctuation.separator.parameters.python", "scope": "punctuation.separator.period.python,punctuation.separator.element.python,punctuation.parenthesis.begin.python,punctuation.parenthesis.end.python", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "variable.parameter.function.language.special.self.python", "scope": "variable.parameter.function.language.special.self.python", "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "variable.parameter.function.language.special.cls.python", - "scope": "variable.parameter.function.language.special.cls.python", + "scope": "variable.language.this.cpp", "settings": { - "foreground": "#e5c07b" + "foreground": "#82D2CE", + "fontStyle": "" } }, { - "name": "storage.modifier.lifetime.rust", "scope": "storage.modifier.lifetime.rust", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "support.function.std.rust", "scope": "support.function.std.rust", "settings": { - "foreground": "#61afef" + "foreground": "#AAA0FA" } }, { - "name": "entity.name.lifetime.rust", "scope": "entity.name.lifetime.rust", "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" + } + }, + { + "scope": "variable.other.property", + "settings": { + "foreground": "#AA9BF5" } }, { - "name": "variable.language.rust", "scope": "variable.language.rust", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "support.constant.edge", "scope": "support.constant.edge", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "regexp constant character-class", "scope": "constant.other.character-class.regexp", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "keyword.operator", - "scope": ["keyword.operator.word"], + "scope": "keyword.operator.quantifier.regexp", "settings": { - "foreground": "#c678dd" + "foreground": "#F8C762" } }, { - "name": "regexp operator.quantifier", - "scope": "keyword.operator.quantifier.regexp", + "scope": "punctuation.definition.string.begin,punctuation.definition.string.end", "settings": { - "foreground": "#d19a66" + "foreground": "#E394DC" } }, { - "name": "Text", "scope": "variable.parameter.function", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "Comment Markup Link", "scope": "comment markup.link", "settings": { - "foreground": "#5c6370" + "foreground": "#6D6D6D" } }, { - "name": "markup diff", "scope": "markup.changed.diff", "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "diff", "scope": "meta.diff.header.from-file,meta.diff.header.to-file,punctuation.definition.from-file.diff,punctuation.definition.to-file.diff", "settings": { - "foreground": "#61afef" + "foreground": "#AAA0FA" } }, { - "name": "inserted.diff", "scope": "markup.inserted.diff", "settings": { - "foreground": "#98c379" + "foreground": "#E394DC" } }, { - "name": "deleted.diff", "scope": "markup.deleted.diff", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "c++ function", "scope": "meta.function.c,meta.function.cpp", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "c++ block", "scope": "punctuation.section.block.begin.bracket.curly.cpp,punctuation.section.block.end.bracket.curly.cpp,punctuation.terminator.statement.c,punctuation.section.block.begin.bracket.curly.c,punctuation.section.block.end.bracket.curly.c,punctuation.section.parens.begin.bracket.round.c,punctuation.section.parens.end.bracket.round.c,punctuation.section.parameters.begin.bracket.round.c,punctuation.section.parameters.end.bracket.round.c", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "js/ts punctuation separator key-value", "scope": "punctuation.separator.key-value", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "js/ts import keyword", "scope": "keyword.operator.expression.import", "settings": { - "foreground": "#61afef" + "foreground": "#AAA0FA" } }, { - "name": "math js/ts", "scope": "support.constant.math", "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "math property js/ts", "scope": "support.constant.property.math", "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { - "name": "js/ts variable.other.constant", "scope": "variable.other.constant", "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" + } + }, + { + "scope": "variable.other.constant", + "settings": { + "foreground": "#AA9BF5" } }, { - "name": "java type", "scope": [ "storage.type.annotation.java", "storage.type.object.array.java" ], "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "java source", "scope": "source.java", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "java modifier.import", "scope": "punctuation.section.block.begin.java,punctuation.section.block.end.java,punctuation.definition.method-parameters.begin.java,punctuation.definition.method-parameters.end.java,meta.method.identifier.java,punctuation.section.method.begin.java,punctuation.section.method.end.java,punctuation.terminator.java,punctuation.section.class.begin.java,punctuation.section.class.end.java,punctuation.section.inner-class.begin.java,punctuation.section.inner-class.end.java,meta.method-call.java,punctuation.section.class.begin.bracket.curly.java,punctuation.section.class.end.bracket.curly.java,punctuation.section.method.begin.bracket.curly.java,punctuation.section.method.end.bracket.curly.java,punctuation.separator.period.java,punctuation.bracket.angle.java,punctuation.definition.annotation.java,meta.method.body.java", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "java modifier.import", "scope": "meta.method.java", "settings": { - "foreground": "#61afef" + "foreground": "#AAA0FA" } }, { - "name": "java modifier.import", "scope": "storage.modifier.import.java,storage.type.java,storage.type.generic.java", "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "java instanceof", "scope": "keyword.operator.instanceof.java", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "java variable.name", "scope": "meta.definition.variable.name.java", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "operator logical", "scope": "keyword.operator.logical", "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "operator bitwise", "scope": "keyword.operator.bitwise", "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "operator channel", "scope": "keyword.operator.channel", "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "support.constant.property-value.scss", - "scope": "support.constant.property-value.scss,support.constant.property-value.css", - "settings": { - "foreground": "#d19a66" - } - }, - { - "name": "CSS/SCSS/LESS Operators", "scope": "keyword.operator.css,keyword.operator.scss,keyword.operator.less", "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "css color standard name", "scope": "support.constant.color.w3c-standard-color-name.css,support.constant.color.w3c-standard-color-name.scss", "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { - "name": "css comma", "scope": "punctuation.separator.list.comma.css", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "css attribute-name.id", "scope": "support.constant.color.w3c-standard-color-name.css", "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { - "name": "css property-name", - "scope": "support.type.vendored.property-name.css", + "scope": "support.module.node,support.type.object.module,support.module.node", "settings": { - "foreground": "#56b6c2" + "foreground": "#EFB080" } }, { - "name": "js/ts module", - "scope": "support.module.node,support.type.object.module,support.module.node", + "scope": "entity.name.type.module", "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "entity.name.type.module", - "scope": "entity.name.type.module", + "scope": ",meta.object-literal.key,support.variable.object.process,support.variable.object.node", + "settings": { + "foreground": "#D6D6DD" + } + }, + { + "scope": "variable.other.readwrite", "settings": { - "foreground": "#e5c07b" + "foreground": "#94C1FA" } }, { - "name": "js variable readwrite", - "scope": "variable.other.readwrite,meta.object-literal.key,support.variable.property,support.variable.object.process,support.variable.object.node", + "scope": "support.variable.property", "settings": { - "foreground": "#e06c75" + "foreground": "#AA9BF5" } }, { - "name": "js/ts json", "scope": "support.constant.json", "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { - "name": "js/ts Keyword", "scope": [ "keyword.operator.expression.instanceof", "keyword.operator.new", @@ -390,1247 +1134,1045 @@ "keyword.operator.expression.keyof" ], "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "js/ts console", "scope": "support.type.object.console", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "js/ts support.variable.property.process", "scope": "support.variable.property.process", "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { - "name": "js console function", - "scope": "entity.name.function,support.function.console", + "scope": "entity.name.function.js,support.function.console.js", "settings": { - "foreground": "#61afef" + "foreground": "#EBC88D" } }, { - "name": "keyword.operator.misc.rust", "scope": "keyword.operator.misc.rust", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "keyword.operator.sigil.rust", "scope": "keyword.operator.sigil.rust", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "operator", "scope": "keyword.operator.delete", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "js dom", "scope": "support.type.object.dom", "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "js dom variable", "scope": "support.variable.dom,support.variable.property.dom", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "keyword.operator", "scope": "keyword.operator.arithmetic,keyword.operator.comparison,keyword.operator.decrement,keyword.operator.increment,keyword.operator.relational", "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "C operator assignment", "scope": "keyword.operator.assignment.c,keyword.operator.comparison.c,keyword.operator.c,keyword.operator.increment.c,keyword.operator.decrement.c,keyword.operator.bitwise.shift.c,keyword.operator.assignment.cpp,keyword.operator.comparison.cpp,keyword.operator.cpp,keyword.operator.increment.cpp,keyword.operator.decrement.cpp,keyword.operator.bitwise.shift.cpp", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "Punctuation", "scope": "punctuation.separator.delimiter", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "Other punctuation .c", "scope": "punctuation.separator.c,punctuation.separator.cpp", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "C type posix-reserved", "scope": "support.type.posix-reserved.c,support.type.posix-reserved.cpp", "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "keyword.operator.sizeof.c", "scope": "keyword.operator.sizeof.c,keyword.operator.sizeof.cpp", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "python parameter", "scope": "variable.parameter.function.language.python", "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { - "name": "python type", "scope": "support.type.python", "settings": { - "foreground": "#56b6c2" + "foreground": "#82D2CE" } }, { - "name": "python logical", "scope": "keyword.operator.logical.python", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "pyCs", "scope": "variable.parameter.function.python", "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { - "name": "python block", "scope": "punctuation.definition.arguments.begin.python,punctuation.definition.arguments.end.python,punctuation.separator.arguments.python,punctuation.definition.list.begin.python,punctuation.definition.list.end.python", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "python function-call.generic", "scope": "meta.function-call.generic.python", "settings": { - "foreground": "#61afef" + "foreground": "#AAA0FA" } }, { - "name": "python placeholder reset to normal string", "scope": "constant.character.format.placeholder.other.python", "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { - "name": "Operators", "scope": "keyword.operator", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "Compound Assignment Operators", "scope": "keyword.operator.assignment.compound", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "Compound Assignment Operators js/ts", "scope": "keyword.operator.assignment.compound.js,keyword.operator.assignment.compound.ts", "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "Keywords", "scope": "keyword", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "Namespaces", "scope": "entity.name.namespace", "settings": { - "foreground": "#e5c07b" + "foreground": "#D1D1D1" } }, { - "name": "Variables", "scope": "variable", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "Variables", "scope": "variable.c", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "Language variables", "scope": "variable.language", "settings": { - "foreground": "#e5c07b" + "foreground": "#C1808A" } }, { - "name": "Java Variables", "scope": "token.variable.parameter.java", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "Java Imports", "scope": "import.storage.java", "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "Packages", "scope": "token.package.keyword", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "Packages", "scope": "token.package", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "Functions", "scope": [ "entity.name.function", "meta.require", - "support.function.any-method", + "support.function", "variable.function" ], "settings": { - "foreground": "#61afef" + "foreground": "#EFB080" } }, { - "name": "Classes", "scope": "entity.name.type.namespace", "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "Classes", "scope": "support.class, entity.name.type.class", "settings": { - "foreground": "#e5c07b" + "foreground": "#87C3FF" } }, { - "name": "Class name", "scope": "entity.name.class.identifier.namespace.type", "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "Class name", "scope": [ "entity.name.class", "variable.other.class.js", "variable.other.class.ts" ], "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "Class name php", "scope": "variable.other.class.php", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "Type Name", "scope": "entity.name.type", "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "Keyword Control", - "scope": "keyword.control", + "scope": "keyword.control.directive.include.cpp", "settings": { - "foreground": "#c678dd" + "foreground": "#A8CC7C" } }, { - "name": "Control Elements", "scope": "control.elements, keyword.operator.less", "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { - "name": "Methods", "scope": "keyword.other.special-method", "settings": { - "foreground": "#61afef" + "foreground": "#AAA0FA" } }, { - "name": "Storage", "scope": "storage", "settings": { - "foreground": "#c678dd" + "foreground": "#82D2CE" + } + }, + { + "scope": ["storage.modifier.reference", "storage.modifier.pointer"], + "settings": { + "foreground": "#D1D1D1", + "fontStyle": "" } }, { - "name": "Storage JS TS", "scope": "token.storage", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "Source Js Keyword Operator Delete,source Js Keyword Operator In,source Js Keyword Operator Of,source Js Keyword Operator Instanceof,source Js Keyword Operator New,source Js Keyword Operator Typeof,source Js Keyword Operator Void", "scope": "keyword.operator.expression.delete,keyword.operator.expression.in,keyword.operator.expression.of,keyword.operator.expression.instanceof,keyword.operator.new,keyword.operator.expression.typeof,keyword.operator.expression.void", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "Java Storage", "scope": "token.storage.type.java", "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "Support", "scope": "support.function", "settings": { - "foreground": "#56b6c2" + "foreground": "#EFB080" } }, { - "name": "Support type", - "scope": "support.type.property-name", + "scope": "meta.property-name.css", "settings": { - "foreground": "#abb2bf" + "foreground": "#87C3FF", + "fontStyle": "" } }, { - "name": "[VSCODE-CUSTOM] toml support", - "scope": "support.type.property-name.toml, support.type.property-name.table.toml, support.type.property-name.array.toml", + "scope": "meta.tag", "settings": { - "foreground": "#e06c75" + "foreground": "#FAD075" } }, { - "name": "Support type", - "scope": "support.constant.property-value", + "scope": "string", "settings": { - "foreground": "#abb2bf" + "foreground": "#E394DC" } }, { - "name": "Support type", - "scope": "support.constant.font-name", + "scope": "entity.other.inherited-class", "settings": { - "foreground": "#d19a66" + "foreground": "#EFB080" } }, { - "name": "Meta tag", - "scope": "meta.tag", + "scope": "constant.other.symbol", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "Strings", - "scope": "string", + "scope": "constant.numeric", "settings": { - "foreground": "#98c379" + "foreground": "#EBC88D" } }, { - "name": "Constant other symbol", - "scope": "constant.other.symbol", + "scope": "constant.other.color", "settings": { - "foreground": "#56b6c2" + "foreground": "#EBC88D" } }, { - "name": "Integers", - "scope": "constant.numeric", + "scope": "punctuation.definition.constant", "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { - "name": "Constants", - "scope": "constant", + "scope": [ + "entity.name.tag.template", + "entity.name.tag.script", + "entity.name.tag.style" + ], "settings": { - "foreground": "#d19a66" + "foreground": "#AF9CFF" } }, { - "name": "Constants", - "scope": "punctuation.definition.constant", + "scope": ["entity.name.tag.html"], "settings": { - "foreground": "#d19a66" + "foreground": "#87C3FF" } }, { - "name": "Tags", - "scope": "entity.name.tag", + "scope": "meta.property-value.css", "settings": { - "foreground": "#e06c75" + "foreground": "#E394DC", + "fontStyle": "" } }, { - "name": "Attributes", "scope": "entity.other.attribute-name", "settings": { - "foreground": "#d19a66" + "foreground": "#AAA0FA" } }, { - "name": "Attribute IDs", "scope": "entity.other.attribute-name.id", "settings": { - "foreground": "#61afef" + "foreground": "#AAA0FA", + "fontStyle": "" } }, { - "name": "Attribute class", "scope": "entity.other.attribute-name.class.css", "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762", + "fontStyle": "" } }, { - "name": "Selector", "scope": "meta.selector", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "Headings", "scope": "markup.heading", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "Headings", "scope": "markup.heading punctuation.definition.heading, entity.name.section", "settings": { - "foreground": "#61afef" + "foreground": "#AAA0FA" } }, { - "name": "Units", "scope": "keyword.other.unit", "settings": { - "foreground": "#e06c75" + "foreground": "#EBC88D" } }, { - "name": "Bold", "scope": "markup.bold,todo.bold", "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { - "name": "Bold", "scope": "punctuation.definition.bold", "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "markup Italic", "scope": "markup.italic, punctuation.definition.italic,todo.emphasis", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "emphasis md", "scope": "emphasis md", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "[VSCODE-CUSTOM] Markdown headings", "scope": "entity.name.section.markdown", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "[VSCODE-CUSTOM] Markdown heading Punctuation Definition", "scope": "punctuation.definition.heading.markdown", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "punctuation.definition.list.begin.markdown", "scope": "punctuation.definition.list.begin.markdown", "settings": { - "foreground": "#e5c07b" + "foreground": "#D6D6DD" } }, { - "name": "[VSCODE-CUSTOM] Markdown heading setext", "scope": "markup.heading.setext", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "[VSCODE-CUSTOM] Markdown Punctuation Definition Bold", "scope": "punctuation.definition.bold.markdown", "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { - "name": "[VSCODE-CUSTOM] Markdown Inline Raw", "scope": "markup.inline.raw.markdown", "settings": { - "foreground": "#98c379" + "foreground": "#E394DC" } }, { - "name": "[VSCODE-CUSTOM] Markdown Inline Raw", "scope": "markup.inline.raw.string.markdown", "settings": { - "foreground": "#98c379" - } - }, - { - "name": "[VSCODE-CUSTOM] Markdown Inline Raw punctuation", - "scope": "punctuation.definition.raw.markdown", - "settings": { - "foreground": "#e5c07b" + "foreground": "#E394DC" } }, { - "name": "[VSCODE-CUSTOM] Markdown List Punctuation Definition", "scope": "punctuation.definition.list.markdown", "settings": { - "foreground": "#e5c07b" + "foreground": "#D6D6DD" } }, { - "name": "[VSCODE-CUSTOM] Markdown Punctuation Definition String", "scope": [ "punctuation.definition.string.begin.markdown", "punctuation.definition.string.end.markdown", "punctuation.definition.metadata.markdown" ], "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "beginning.punctuation.definition.list.markdown", "scope": ["beginning.punctuation.definition.list.markdown"], "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "[VSCODE-CUSTOM] Markdown Punctuation Definition Link", "scope": "punctuation.definition.metadata.markdown", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "[VSCODE-CUSTOM] Markdown Underline Link/Image", "scope": "markup.underline.link.markdown,markup.underline.link.image.markdown", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "[VSCODE-CUSTOM] Markdown Link Title/Description", "scope": "string.other.link.title.markdown,string.other.link.description.markdown", "settings": { - "foreground": "#61afef" - } - }, - { - "name": "[VSCODE-CUSTOM] Asciidoc Inline Raw", - "scope": "markup.raw.monospace.asciidoc", - "settings": { - "foreground": "#98c379" - } - }, - { - "name": "[VSCODE-CUSTOM] Asciidoc Inline Raw Punctuation Definition", - "scope": "punctuation.definition.asciidoc", - "settings": { - "foreground": "#e5c07b" + "foreground": "#AAA0FA" } }, { - "name": "[VSCODE-CUSTOM] Asciidoc List Punctuation Definition", - "scope": "markup.list.asciidoc", - "settings": { - "foreground": "#e5c07b" - } - }, - { - "name": "[VSCODE-CUSTOM] Asciidoc underline link", - "scope": "markup.link.asciidoc,markup.other.url.asciidoc", - "settings": { - "foreground": "#c678dd" - } - }, - { - "name": "[VSCODE-CUSTOM] Asciidoc link name", - "scope": "string.unquoted.asciidoc,markup.other.url.asciidoc", + "scope": "string.regexp", "settings": { - "foreground": "#61afef" + "foreground": "#D6D6DD" } }, { - "name": "Regular Expressions", - "scope": "string.regexp", + "scope": "constant.character.escape", "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "Embedded", "scope": "punctuation.section.embedded, variable.interpolation", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "Embedded", "scope": "punctuation.section.embedded.begin,punctuation.section.embedded.end", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "illegal", "scope": "invalid.illegal", "settings": { - "foreground": "#ffffff" + "foreground": "#D6D6DD" } }, { - "name": "illegal", "scope": "invalid.illegal.bad-ampersand.html", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "scope": "invalid.illegal.unrecognized-tag.html", - "settings": { - "foreground": "#e06c75" - } - }, - { - "name": "Broken", "scope": "invalid.broken", "settings": { - "foreground": "#ffffff" + "foreground": "#D6D6DD" } }, { - "name": "Deprecated", "scope": "invalid.deprecated", "settings": { - "foreground": "#ffffff" - } - }, - { - "name": "html Deprecated", - "scope": "invalid.deprecated.entity.other.attribute-name.html", - "settings": { - "foreground": "#d19a66" + "foreground": "#D6D6DD" } }, { - "name": "Unimplemented", "scope": "invalid.unimplemented", "settings": { - "foreground": "#ffffff" + "foreground": "#D6D6DD" } }, { - "name": "Source Json Meta Structure Dictionary Json > String Quoted Json", "scope": "source.json meta.structure.dictionary.json > string.quoted.json", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "Source Json Meta Structure Dictionary Json > String Quoted Json > Punctuation String", "scope": "source.json meta.structure.dictionary.json > string.quoted.json > punctuation.string", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "Source Json Meta Structure Dictionary Json > Value Json > String Quoted Json,source Json Meta Structure Array Json > Value Json > String Quoted Json,source Json Meta Structure Dictionary Json > Value Json > String Quoted Json > Punctuation,source Json Meta Structure Array Json > Value Json > String Quoted Json > Punctuation", "scope": "source.json meta.structure.dictionary.json > value.json > string.quoted.json,source.json meta.structure.array.json > value.json > string.quoted.json,source.json meta.structure.dictionary.json > value.json > string.quoted.json > punctuation,source.json meta.structure.array.json > value.json > string.quoted.json > punctuation", "settings": { - "foreground": "#98c379" + "foreground": "#E394DC" } }, { - "name": "Source Json Meta Structure Dictionary Json > Constant Language Json,source Json Meta Structure Array Json > Constant Language Json", "scope": "source.json meta.structure.dictionary.json > constant.language.json,source.json meta.structure.array.json > constant.language.json", "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "[VSCODE-CUSTOM] JSON Property Name", "scope": "support.type.property-name.json", "settings": { - "foreground": "#e06c75" - } - }, - { - "name": "[VSCODE-CUSTOM] JSON Punctuation for Property Name", - "scope": "support.type.property-name.json punctuation", - "settings": { - "foreground": "#e06c75" + "foreground": "#82D2CE" } }, { - "name": "laravel blade tag", "scope": "text.html.laravel-blade source.php.embedded.line.html entity.name.tag.laravel-blade", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "laravel blade @", "scope": "text.html.laravel-blade source.php.embedded.line.html support.constant.laravel-blade", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "use statement for other classes", - "scope": "support.other.namespace.use.php,support.other.namespace.use-as.php,entity.other.alias.php,meta.interface.php", + "scope": "support.other.namespace.use.php,support.other.namespace.use-as.php,support.other.namespace.php,entity.other.alias.php,meta.interface.php", "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "error suppression", "scope": "keyword.operator.error-control.php", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "php instanceof", "scope": "keyword.operator.type.php", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "style double quoted array index normal begin", "scope": "punctuation.section.array.begin.php", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "style double quoted array index normal end", "scope": "punctuation.section.array.end.php", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "php illegal.non-null-typehinted", "scope": "invalid.illegal.non-null-typehinted.php", "settings": { - "foreground": "#f44747" + "foreground": "#F44747" } }, { - "name": "php types", "scope": "storage.type.php,meta.other.type.phpdoc.php,keyword.other.type.php,keyword.other.array.phpdoc.php", "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "php call-function", "scope": "meta.function-call.php,meta.function-call.object.php,meta.function-call.static.php", "settings": { - "foreground": "#61afef" + "foreground": "#AAA0FA" } }, { - "name": "php function-resets", "scope": "punctuation.definition.parameters.begin.bracket.round.php,punctuation.definition.parameters.end.bracket.round.php,punctuation.separator.delimiter.php,punctuation.section.scope.begin.php,punctuation.section.scope.end.php,punctuation.terminator.expression.php,punctuation.definition.arguments.begin.bracket.round.php,punctuation.definition.arguments.end.bracket.round.php,punctuation.definition.storage-type.begin.bracket.round.php,punctuation.definition.storage-type.end.bracket.round.php,punctuation.definition.array.begin.bracket.round.php,punctuation.definition.array.end.bracket.round.php,punctuation.definition.begin.bracket.round.php,punctuation.definition.end.bracket.round.php,punctuation.definition.begin.bracket.curly.php,punctuation.definition.end.bracket.curly.php,punctuation.definition.section.switch-block.end.bracket.curly.php,punctuation.definition.section.switch-block.start.bracket.curly.php,punctuation.definition.section.switch-block.begin.bracket.curly.php,punctuation.definition.section.switch-block.end.bracket.curly.php", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "support php constants", "scope": "support.constant.core.rust", "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { - "name": "support php constants", "scope": "support.constant.ext.php,support.constant.std.php,support.constant.core.php,support.constant.parser-token.php", "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { - "name": "php goto", "scope": "entity.name.goto-label.php,support.other.php", "settings": { - "foreground": "#61afef" + "foreground": "#AAA0FA" } }, { - "name": "php logical/bitwise operator", "scope": "keyword.operator.logical.php,keyword.operator.bitwise.php,keyword.operator.arithmetic.php", "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "php regexp operator", "scope": "keyword.operator.regexp.php", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "php comparison", "scope": "keyword.operator.comparison.php", "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "php heredoc/nowdoc", "scope": "keyword.operator.heredoc.php,keyword.operator.nowdoc.php", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "python function decorator @", "scope": "meta.function.decorator.python", "settings": { - "foreground": "#61afef" + "foreground": "#A8CC7C" + } + }, + { + "scope": "punctuation.definition.decorator.python,entity.name.function.decorator.python", + "settings": { + "foreground": "#A8CC7C", + "fontStyle": "" } }, { - "name": "python function support", "scope": "support.token.decorator.python,meta.function.decorator.identifier.python", "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "parameter function js/ts", "scope": "function.parameter", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "brace function", "scope": "function.brace", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "parameter function ruby cs", "scope": "function.parameter.ruby, function.parameter.cs", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "constant.language.symbol.ruby", "scope": "constant.language.symbol.ruby", "settings": { - "foreground": "#56b6c2" - } - }, - { - "name": "constant.language.symbol.hashkey.ruby", - "scope": "constant.language.symbol.hashkey.ruby", - "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "rgb-value", "scope": "rgb-value", "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "rgb value", "scope": "inline-color-decoration rgb-value", "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { - "name": "rgb value less", "scope": "less rgb-value", "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { - "name": "sass selector", "scope": "selector.sass", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "ts primitive/builtin types", "scope": "support.type.primitive.ts,support.type.builtin.ts,support.type.primitive.tsx,support.type.builtin.tsx", "settings": { - "foreground": "#e5c07b" + "foreground": "#82D2CE" } }, { - "name": "block scope", "scope": "block.scope.end,block.scope.begin", "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "cs storage type", "scope": "storage.type.cs", "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "cs local variable", "scope": "entity.name.variable.local.cs", "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { "scope": "token.info-token", "settings": { - "foreground": "#61afef" + "foreground": "#AAA0FA" } }, { "scope": "token.warn-token", "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { "scope": "token.error-token", "settings": { - "foreground": "#f44747" + "foreground": "#F44747" } }, { "scope": "token.debug-token", "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "String interpolation", "scope": [ "punctuation.definition.template-expression.begin", "punctuation.definition.template-expression.end", "punctuation.section.embedded" ], "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "Reset JavaScript string interpolation expression", "scope": ["meta.template.expression"], "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "Import module JS", "scope": ["keyword.operator.module"], "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "js Flowtype", "scope": ["support.type.type.flowtype"], "settings": { - "foreground": "#61afef" + "foreground": "#AAA0FA" } }, { - "name": "js Flow", "scope": ["support.type.primitive"], "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "js class prop", "scope": ["meta.property.object"], "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "js func parameter", "scope": ["variable.parameter.function.js"], "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "js template literals begin", "scope": ["keyword.other.template.begin"], "settings": { - "foreground": "#98c379" + "foreground": "#E394DC" } }, { - "name": "js template literals end", "scope": ["keyword.other.template.end"], "settings": { - "foreground": "#98c379" + "foreground": "#E394DC" } }, { - "name": "js template literals variable braces begin", "scope": ["keyword.other.substitution.begin"], "settings": { - "foreground": "#98c379" + "foreground": "#E394DC" } }, { - "name": "js template literals variable braces end", "scope": ["keyword.other.substitution.end"], "settings": { - "foreground": "#98c379" + "foreground": "#E394DC" } }, { - "name": "js operator.assignment", "scope": ["keyword.operator.assignment"], "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "go operator", "scope": ["keyword.operator.assignment.go"], "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "go operator", "scope": [ "keyword.operator.arithmetic.go", "keyword.operator.address.go" ], "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "Go package name", "scope": ["entity.name.package.go"], "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "elm prelude", "scope": ["support.type.prelude.elm"], "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "elm constant", "scope": ["support.constant.elm"], "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { - "name": "template literal", "scope": ["punctuation.quasi.element"], "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "html/pug (jade) escaped characters and entities", "scope": ["constant.character.entity"], "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "styling css pseudo-elements/classes to be able to differentiate from classes which are the same colour", "scope": [ "entity.other.attribute-name.pseudo-element", "entity.other.attribute-name.pseudo-class" ], "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "Clojure globals", "scope": ["entity.global.clojure"], "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "Clojure symbols", "scope": ["meta.symbol.clojure"], "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "Clojure constants", "scope": ["constant.keyword.clojure"], "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "CoffeeScript Function Argument", "scope": ["meta.arguments.coffee", "variable.parameter.function.coffee"], "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "Ini Default Text", "scope": ["source.ini"], "settings": { - "foreground": "#98c379" + "foreground": "#E394DC" } }, { - "name": "Makefile prerequisities", "scope": ["meta.scope.prerequisites.makefile"], "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "Makefile text colour", "scope": ["source.makefile"], "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "Groovy import names", "scope": ["storage.modifier.import.groovy"], "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "Groovy Methods", "scope": ["meta.method.groovy"], "settings": { - "foreground": "#61afef" + "foreground": "#AAA0FA" } }, { - "name": "Groovy Variables", "scope": ["meta.definition.variable.name.groovy"], "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "Groovy Inheritance", "scope": ["meta.definition.class.inherited.classes.groovy"], "settings": { - "foreground": "#98c379" + "foreground": "#E394DC" } }, { - "name": "HLSL Semantic", "scope": ["support.variable.semantic.hlsl"], "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "HLSL Types", "scope": [ "support.type.texture.hlsl", "support.type.sampler.hlsl", @@ -1640,484 +2182,173 @@ "support.type.object.hlsl" ], "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "SQL Variables", "scope": ["text.variable", "text.bracketed"], "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "types", "scope": ["support.type.swift", "support.type.vb.asp"], "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "heading 1, keyword", "scope": ["entity.name.function.xi"], "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "heading 2, callable", "scope": ["entity.name.class.xi"], "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "heading 3, property", "scope": ["constant.character.character-class.regexp.xi"], "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "heading 4, type, class, interface", "scope": ["constant.regexp.xi"], "settings": { - "foreground": "#c678dd" + "foreground": "#83D6C5" } }, { - "name": "heading 5, enums, preprocessor, constant, decorator", "scope": ["keyword.control.xi"], "settings": { - "foreground": "#56b6c2" + "foreground": "#D6D6DD" } }, { - "name": "heading 6, number", "scope": ["invalid.xi"], "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "string", "scope": ["beginning.punctuation.definition.quote.markdown.xi"], "settings": { - "foreground": "#98c379" + "foreground": "#E394DC" } }, { - "name": "comments", "scope": ["beginning.punctuation.definition.list.markdown.xi"], "settings": { - "foreground": "#7f848e" + "foreground": "#6D6D6D" } }, { - "name": "link", "scope": ["constant.character.xi"], "settings": { - "foreground": "#61afef" + "foreground": "#AAA0FA" } }, { - "name": "accent", "scope": ["accent.xi"], "settings": { - "foreground": "#61afef" + "foreground": "#AAA0FA" } }, { - "name": "wikiword", "scope": ["wikiword.xi"], "settings": { - "foreground": "#d19a66" + "foreground": "#F8C762" } }, { - "name": "language operators like '+', '-' etc", "scope": ["constant.other.color.rgb-value.xi"], "settings": { - "foreground": "#ffffff" + "foreground": "#D6D6DD" } }, { - "name": "elements to dim", "scope": ["punctuation.definition.tag.xi"], "settings": { - "foreground": "#5c6370" + "foreground": "#6D6D6D" } }, { - "name": "C++/C#", "scope": [ "entity.name.label.cs", "entity.name.scope-resolution.function.call", "entity.name.scope-resolution.function.definition" ], "settings": { - "foreground": "#e5c07b" + "foreground": "#EFB080" } }, { - "name": "Markdown underscore-style headers", "scope": [ "entity.name.label.cs", "markup.heading.setext.1.markdown", "markup.heading.setext.2.markdown" ], "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "meta.brace.square", "scope": [" meta.brace.square"], "settings": { - "foreground": "#abb2bf" + "foreground": "#D6D6DD" } }, { - "name": "Comments", "scope": "comment, punctuation.definition.comment", "settings": { - "foreground": "#7f848e", + "foreground": "#6D6D6D", "fontStyle": "italic" } }, { - "name": "[VSCODE-CUSTOM] Markdown Quote", "scope": "markup.quote.markdown", "settings": { - "foreground": "#5c6370" + "foreground": "#6D6D6D" } }, { - "name": "punctuation.definition.block.sequence.item.yaml", "scope": "punctuation.definition.block.sequence.item.yaml", "settings": { - "foreground": "#abb2bf" - } - }, - { - "scope": [ - "constant.language.symbol.elixir", - "constant.language.symbol.double-quoted.elixir" - ], - "settings": { - "foreground": "#56b6c2" - } - }, - { - "scope": ["entity.name.variable.parameter.cs"], - "settings": { - "foreground": "#e5c07b" - } - }, - { - "scope": ["entity.name.variable.field.cs"], - "settings": { - "foreground": "#e06c75" - } - }, - { - "name": "Deleted", - "scope": "markup.deleted", - "settings": { - "foreground": "#e06c75" - } - }, - { - "name": "Inserted", - "scope": "markup.inserted", - "settings": { - "foreground": "#98c379" - } - }, - { - "name": "Underline", - "scope": "markup.underline", - "settings": { - "fontStyle": "underline" - } - }, - { - "name": "punctuation.section.embedded.begin.php", - "scope": [ - "punctuation.section.embedded.begin.php", - "punctuation.section.embedded.end.php" - ], - "settings": { - "foreground": "#BE5046" - } - }, - { - "name": "support.other.namespace.php", - "scope": ["support.other.namespace.php"], - "settings": { - "foreground": "#abb2bf" - } - }, - { - "name": "Latex variable parameter", - "scope": ["variable.parameter.function.latex"], - "settings": { - "foreground": "#e06c75" - } - }, - { - "name": "variable.other.object", - "scope": ["variable.other.object"], - "settings": { - "foreground": "#e5c07b" + "foreground": "#D6D6DD" } }, { - "name": "variable.other.constant.property", - "scope": ["variable.other.constant.property"], + "scope": ["constant.language.symbol.elixir"], "settings": { - "foreground": "#e06c75" + "foreground": "#D6D6DD" } }, { - "name": "entity.other.inherited-class", - "scope": ["entity.other.inherited-class"], - "settings": { - "foreground": "#e5c07b" - } - }, - { - "name": "c variable readwrite", - "scope": "variable.other.readwrite.c", - "settings": { - "foreground": "#e06c75" - } - }, - { - "name": "php scope", - "scope": "entity.name.variable.parameter.php,punctuation.separator.colon.php,constant.other.php", - "settings": { - "foreground": "#abb2bf" - } - }, - { - "name": "Assembly", - "scope": ["constant.numeric.decimal.asm.x86_64"], - "settings": { - "foreground": "#c678dd" - } - }, - { - "scope": ["support.other.parenthesis.regexp"], - "settings": { - "foreground": "#d19a66" - } - }, - { - "scope": ["constant.character.escape"], - "settings": { - "foreground": "#56b6c2" - } - }, - { - "scope": ["string.regexp"], - "settings": { - "foreground": "#e06c75" - } - }, - { - "scope": ["log.info"], - "settings": { - "foreground": "#98c379" - } - }, - { - "scope": ["log.warning"], - "settings": { - "foreground": "#e5c07b" - } - }, - { - "scope": ["log.error"], - "settings": { - "foreground": "#e06c75" - } - }, - { - "scope": "keyword.operator.expression.is", - "settings": { - "foreground": "#c678dd" - } - }, - { - "scope": "entity.name.label", + "scope": "entity.other.attribute-name.js,entity.other.attribute-name.ts,entity.other.attribute-name.jsx,entity.other.attribute-name.tsx,variable.parameter,variable.language.super", "settings": { - "foreground": "#e06c75" + "fontStyle": "italic" } }, { - "name": "js/ts italic", - "scope": "entity.other.attribute-name.js,entity.other.attribute-name.ts,entity.other.attribute-name.jsx,entity.other.attribute-name.tsx,variable.parameter,variable.language.super", + "scope": "comment.line.double-slash,comment.block.documentation", "settings": { "fontStyle": "italic" } }, { - "name": "comment", - "scope": "comment.line.double-slash,comment.block.documentation", + "scope": "keyword.control.import.python,keyword.control.flow.python", "settings": { "fontStyle": "italic" } }, { - "name": "markup.italic.markdown", "scope": "markup.italic.markdown", "settings": { "fontStyle": "italic" } } - ], - "colors": { - "activityBar.background": "#282c34", - "activityBar.foreground": "#d7dae0", - "activityBarBadge.background": "#4d78cc", - "activityBarBadge.foreground": "#f8fafd", - "badge.background": "#282c34", - "button.background": "#404754", - "button.secondaryBackground": "#30333d", - "button.secondaryForeground": "#c0bdbd", - "checkbox.border": "#404754", - "debugToolBar.background": "#21252b", - "descriptionForeground": "#abb2bf", - "diffEditor.insertedTextBackground": "#00809b33", - "dropdown.background": "#21252b", - "dropdown.border": "#21252b", - "editor.background": "#282c34", - "editor.findMatchBackground": "#d19a6644", - "editor.findMatchBorder": "#ffffff5a", - "editor.findMatchHighlightBackground": "#ffffff22", - "editor.foreground": "#abb2bf", - "editorBracketHighlight.foreground1": "#d19a66", - "editorBracketHighlight.foreground2": "#c678dd", - "editorBracketHighlight.foreground3": "#56b6c2", - "editorHoverWidget.highlightForeground": "#61afef", - "editorInlayHint.foreground": "#abb2bf", - "editorInlayHint.background": "#2c313c", - "editor.lineHighlightBackground": "#2c313c", - "editorLineNumber.activeForeground": "#abb2bf", - "editorGutter.addedBackground": "#109868", - "editorGutter.deletedBackground": "#9A353D", - "editorGutter.modifiedBackground": "#948B60", - "editorOverviewRuler.addedBackground": "#109868", - "editorOverviewRuler.deletedBackground": "#9A353D", - "editorOverviewRuler.modifiedBackground": "#948B60", - "editor.selectionBackground": "#67769660", - "editor.selectionHighlightBackground": "#ffffff10", - "editor.selectionHighlightBorder": "#dddddd", - "editor.wordHighlightBackground": "#d2e0ff2f", - "editor.wordHighlightBorder": "#7f848e", - "editor.wordHighlightStrongBackground": "#abb2bf26", - "editor.wordHighlightStrongBorder": "#7f848e", - "editorBracketMatch.background": "#515a6b", - "editorBracketMatch.border": "#515a6b", - "editorCursor.background": "#ffffffc9", - "editorCursor.foreground": "#528bff", - "editorError.foreground": "#c24038", - "editorGroup.background": "#181a1f", - "editorGroup.border": "#181a1f", - "editorGroupHeader.tabsBackground": "#21252b", - "editorHoverWidget.background": "#21252b", - "editorHoverWidget.border": "#181a1f", - "editorIndentGuide.activeBackground": "#c8c8c859", - "editorIndentGuide.background": "#3b4048", - "editorLineNumber.foreground": "#495162", - "editorMarkerNavigation.background": "#21252b", - "editorRuler.foreground": "#abb2bf26", - "editorSuggestWidget.background": "#21252b", - "editorSuggestWidget.border": "#181a1f", - "editorSuggestWidget.selectedBackground": "#2c313a", - "editorWarning.foreground": "#d19a66", - "editorWhitespace.foreground": "#ffffff1d", - "editorWidget.background": "#21252b", - "focusBorder": "#3e4452", - "gitDecoration.ignoredResourceForeground": "#636b78", - "input.background": "#1d1f23", - "input.foreground": "#abb2bf", - "list.activeSelectionBackground": "#2c313a", - "list.activeSelectionForeground": "#d7dae0", - "list.focusBackground": "#323842", - "list.focusForeground": "#f0f0f0", - "list.highlightForeground": "#ecebeb", - "list.hoverBackground": "#2c313a", - "list.hoverForeground": "#abb2bf", - "list.inactiveSelectionBackground": "#323842", - "list.inactiveSelectionForeground": "#d7dae0", - "list.warningForeground": "#d19a66", - "menu.foreground": "#abb2bf", - "menu.separatorBackground": "#343a45", - "minimapGutter.addedBackground": "#109868", - "minimapGutter.deletedBackground": "#9A353D", - "minimapGutter.modifiedBackground": "#948B60", - "panel.border": "#3e4452", - "panelSectionHeader.background": "#21252b", - "peekViewEditor.background": "#1b1d23", - "peekViewEditor.matchHighlightBackground": "#29244b", - "peekViewResult.background": "#22262b", - "scrollbar.shadow": "#23252c", - "scrollbarSlider.activeBackground": "#747d9180", - "scrollbarSlider.background": "#4e566660", - "scrollbarSlider.hoverBackground": "#5a637580", - "settings.focusedRowBackground": "#282c34", - "settings.headerForeground": "#fff", - "sideBar.background": "#21252b", - "sideBar.foreground": "#abb2bf", - "sideBarSectionHeader.background": "#282c34", - "sideBarSectionHeader.foreground": "#abb2bf", - "statusBar.background": "#21252b", - "statusBar.debuggingBackground": "#cc6633", - "statusBar.debuggingBorder": "#ff000000", - "statusBar.debuggingForeground": "#ffffff", - "statusBar.foreground": "#9da5b4", - "statusBar.noFolderBackground": "#21252b", - "statusBarItem.remoteBackground": "#4d78cc", - "statusBarItem.remoteForeground": "#f8fafd", - "tab.activeBackground": "#282c34", - "tab.activeBorder": "#b4b4b4", - "tab.activeForeground": "#dcdcdc", - "tab.border": "#181a1f", - "tab.hoverBackground": "#323842", - "tab.inactiveBackground": "#21252b", - "tab.unfocusedHoverBackground": "#323842", - "terminal.ansiBlack": "#3f4451", - "terminal.ansiBlue": "#4aa5f0", - "terminal.ansiBrightBlack": "#4f5666", - "terminal.ansiBrightBlue": "#4dc4ff", - "terminal.ansiBrightCyan": "#4cd1e0", - "terminal.ansiBrightGreen": "#a5e075", - "terminal.ansiBrightMagenta": "#de73ff", - "terminal.ansiBrightRed": "#ff616e", - "terminal.ansiBrightWhite": "#e6e6e6", - "terminal.ansiBrightYellow": "#f0a45d", - "terminal.ansiCyan": "#42b3c2", - "terminal.ansiGreen": "#8cc265", - "terminal.ansiMagenta": "#c162de", - "terminal.ansiRed": "#e05561", - "terminal.ansiWhite": "#d7dae0", - "terminal.ansiYellow": "#d18f52", - "terminal.background": "#282c34", - "terminal.border": "#3e4452", - "terminal.foreground": "#abb2bf", - "terminal.selectionBackground": "#abb2bf30", - "textBlockQuote.background": "#2e3440", - "textBlockQuote.border": "#4b5362", - "textLink.foreground": "#61afef", - "textPreformat.foreground": "#d19a66", - "titleBar.activeBackground": "#282c34", - "titleBar.activeForeground": "#9da5b4", - "titleBar.inactiveBackground": "#282c34", - "titleBar.inactiveForeground": "#6b717d", - "tree.indentGuidesStroke": "#ffffff1d", - "walkThrough.embeddedEditorBackground": "#2e3440", - "welcomePage.buttonHoverBackground": "#404754" - } + ] } diff --git a/src/vs/base/common/network.ts b/src/vs/base/common/network.ts index 7f141568abf..f3aec06f44d 100644 --- a/src/vs/base/common/network.ts +++ b/src/vs/base/common/network.ts @@ -84,6 +84,15 @@ export namespace Schemas { /** Scheme used for the chat input editor. */ export const vscodeChatSesssion = 'vscode-chat-editor'; + /** Scheme used for code blocks in an aide agent session. */ + export const vscodeAideAgentCodeBlock = 'vscode-aide-agent-code-block'; + + /** Scheme used for LHS of code compare (aka diff) blocks in aide agent session. */ + export const vscodeAideAgentCodeCompareBlock = 'vscode-aide-agent-code-compare-block'; + + /** Scheme used for the aide agent input editor. */ + export const vscodeAideAgentSesssion = 'vscode-aide-agent-editor'; + /** * Scheme used internally for webviews that aren't linked to a resource (i.e. not custom editors) */ diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index 81d4f5e3b35..6d3ac569768 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -3153,7 +3153,7 @@ class EditorMinimap extends BaseEditorOption = { 'codestory': ['Gpt4', 'GPT3_5_16k', 'CodeLlama7BInstruct', 'ClaudeHaiku', 'ClaudeSonnet', 'DeepSeekCoder33BInstruct', 'Gpt4Turbo'], - 'openai-default': ['Gpt4Turbo', 'Gpt4_32k', 'Gpt4', 'GPT3_5_16k', 'GPT3_5', 'Gpt4O'], + 'openai-default': ['Gpt4Turbo', 'Gpt4_32k', 'Gpt4', 'GPT3_5_16k', 'GPT3_5', 'Gpt4O', 'o1-preview', 'o1-mini'], 'azure-openai': ['Gpt4Turbo', 'Gpt4_32k', 'Gpt4', 'GPT3_5_16k', 'GPT3_5'], 'togetherai': ['Mixtral', 'MistralInstruct', 'CodeLlama13BInstruct', 'CodeLlama7BInstruct', 'DeepSeekCoder33BInstruct'], 'openai-compatible': ['Mixtral', 'MistralInstruct', 'CodeLlama13BInstruct', 'CodeLlama7BInstruct', 'DeepSeekCoder1.3BInstruct', 'DeepSeekCoder6BInstruct', 'DeepSeekCoder33BInstruct', 'DeepSeekCoderV2'], diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 819381bf178..e5fea7623d2 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -16,7 +16,9 @@ import { StatusBarItemsExtensionPoint } from './statusBarExtensionPoint.js'; // --- mainThread participants import './mainThreadLocalization.js'; -import './mainThreadAideAgentProvider.js'; +import './mainThreadAideAgentAgents2.js'; +import './mainThreadAideAgentVariables.js'; +import './mainThreadAideAgentCodeMapper.js'; import './mainThreadBulkEdits.js'; import './mainThreadModelSelection.js'; import './mainThreadLanguageModels.js'; diff --git a/src/vs/workbench/api/browser/mainThreadAideAgentAgents2.ts b/src/vs/workbench/api/browser/mainThreadAideAgentAgents2.ts new file mode 100644 index 00000000000..4c52300273a --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadAideAgentAgents2.ts @@ -0,0 +1,345 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise } from '../../../base/common/async.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { IMarkdownString } from '../../../base/common/htmlContent.js'; +import { Disposable, DisposableMap, IDisposable } from '../../../base/common/lifecycle.js'; +import { revive } from '../../../base/common/marshalling.js'; +import { escapeRegExpCharacters } from '../../../base/common/strings.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; +import { URI, UriComponents } from '../../../base/common/uri.js'; +import { Position } from '../../../editor/common/core/position.js'; +import { Range } from '../../../editor/common/core/range.js'; +import { getWordAtText } from '../../../editor/common/core/wordHelper.js'; +import { CompletionContext, CompletionItem, CompletionItemKind, CompletionList } from '../../../editor/common/languages.js'; +import { ITextModel } from '../../../editor/common/model.js'; +import { ILanguageFeaturesService } from '../../../editor/common/services/languageFeatures.js'; +import { ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../platform/log/common/log.js'; +import { IAideAgentWidgetService } from '../../contrib/aideAgent/browser/aideAgent.js'; +import { ChatInputPart } from '../../contrib/aideAgent/browser/aideAgentInputPart.js'; +import { AddDynamicVariableAction, IAddDynamicVariableContext } from '../../contrib/aideAgent/browser/contrib/aideAgentDynamicVariables.js'; +import { ChatAgentLocation, IAideAgentAgentService, IChatAgentHistoryEntry, IChatAgentImplementation, IChatAgentRequest } from '../../contrib/aideAgent/common/aideAgentAgents.js'; +import { ChatRequestAgentPart } from '../../contrib/aideAgent/common/aideAgentParserTypes.js'; +import { ChatRequestParser } from '../../contrib/aideAgent/common/aideAgentRequestParser.js'; +import { IAideAgentService, IChatContentReference, IChatFollowup, IChatProgress, IChatTask, IChatWarningMessage } from '../../contrib/aideAgent/common/aideAgentService.js'; +import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; +import { IExtensionService } from '../../services/extensions/common/extensions.js'; +import { ExtHostAideAgentAgentsShape, ExtHostContext, IAideAgentProgressDto, IChatParticipantMetadata, IDynamicChatAgentProps, IExtensionChatAgentMetadata, MainContext, MainThreadAideAgentAgentsShape2 } from '../common/extHost.protocol.js'; + +interface AgentData { + dispose: () => void; + id: string; + extensionId: ExtensionIdentifier; + hasFollowups?: boolean; +} + +export class MainThreadChatTask implements IChatTask { + public readonly kind = 'progressTask'; + + public readonly deferred = new DeferredPromise(); + + private readonly _onDidAddProgress = new Emitter(); + public get onDidAddProgress(): Event { return this._onDidAddProgress.event; } + + public readonly progress: (IChatWarningMessage | IChatContentReference)[] = []; + + constructor(public content: IMarkdownString) { } + + task() { + return this.deferred.p; + } + + isSettled() { + return this.deferred.isSettled; + } + + complete(v: string | void) { + this.deferred.complete(v); + } + + add(progress: IChatWarningMessage | IChatContentReference): void { + this.progress.push(progress); + this._onDidAddProgress.fire(progress); + } +} + +@extHostNamedCustomer(MainContext.MainThreadAideAgentAgents2) +export class MainThreadChatAgents2 extends Disposable implements MainThreadAideAgentAgentsShape2 { + + private readonly _agents = this._register(new DisposableMap()); + private readonly _agentCompletionProviders = this._register(new DisposableMap()); + private readonly _agentIdsToCompletionProviders = this._register(new DisposableMap); + + private readonly _chatParticipantDetectionProviders = this._register(new DisposableMap()); + + private readonly _pendingProgress = new Map void>(); + private readonly _proxy: ExtHostAideAgentAgentsShape; + + private _responsePartHandlePool = 0; + private readonly _activeTasks = new Map(); + + constructor( + extHostContext: IExtHostContext, + @IAideAgentAgentService private readonly _chatAgentService: IAideAgentAgentService, + @IAideAgentService private readonly _chatService: IAideAgentService, + @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @IAideAgentWidgetService private readonly _chatWidgetService: IAideAgentWidgetService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ILogService private readonly _logService: ILogService, + @IExtensionService private readonly _extensionService: IExtensionService, + ) { + super(); + this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostAideAgentAgents2); + + this._register(this._chatService.onDidDisposeSession(e => { + this._proxy.$releaseSession(e.sessionId); + })); + this._register(this._chatService.onDidPerformUserAction(e => { + if (typeof e.agentId === 'string') { + for (const [handle, agent] of this._agents) { + if (agent.id === e.agentId) { + if (e.action.kind === 'vote') { + this._proxy.$acceptFeedback(handle, e.result ?? {}, e.action); + } else { + this._proxy.$acceptAction(handle, e.result || {}, e); + } + break; + } + } + } + })); + } + + $unregisterAgent(handle: number): void { + this._agents.deleteAndDispose(handle); + } + + $transferActiveChatSession(toWorkspace: UriComponents): void { + const widget = this._chatWidgetService.lastFocusedWidget; + const sessionId = widget?.viewModel?.model.sessionId; + if (!sessionId) { + this._logService.error(`MainThreadChat#$transferActiveChatSession: No active chat session found`); + return; + } + + const inputValue = widget?.inputEditor.getValue() ?? ''; + this._chatService.transferChatSession({ sessionId, inputValue }, URI.revive(toWorkspace)); + } + + $registerAgent(handle: number, extension: ExtensionIdentifier, id: string, metadata: IExtensionChatAgentMetadata, dynamicProps: IDynamicChatAgentProps | undefined): void { + const staticAgentRegistration = this._chatAgentService.getAgent(id); + if (!staticAgentRegistration && !dynamicProps) { + if (this._chatAgentService.getAgentsByName(id).length) { + // Likely some extension authors will not adopt the new ID, so give a hint if they register a + // participant by name instead of ID. + throw new Error(`chatParticipant must be declared with an ID in package.json. The "id" property may be missing! "${id}"`); + } + + throw new Error(`chatParticipant must be declared in package.json: ${id}`); + } + + const impl: IChatAgentImplementation = { + initSession: (sessionId) => { + return this._proxy.$initSession(handle, sessionId); + }, + invoke: async (request, token) => { + return await this._proxy.$invokeAgent(handle, request, { history: [] }, token) ?? {}; + }, + provideFollowups: async (request, result, history, token): Promise => { + if (!this._agents.get(handle)?.hasFollowups) { + return []; + } + + return this._proxy.$provideFollowups(request, handle, result, { history }, token); + }, + provideWelcomeMessage: (location: ChatAgentLocation, token: CancellationToken) => { + return this._proxy.$provideWelcomeMessage(handle, location, token); + }, + provideChatTitle: (history, token) => { + return this._proxy.$provideChatTitle(handle, history, token); + }, + provideSampleQuestions: (location: ChatAgentLocation, token: CancellationToken) => { + return this._proxy.$provideSampleQuestions(handle, location, token); + } + }; + + let disposable: IDisposable; + if (!staticAgentRegistration && dynamicProps) { + const extensionDescription = this._extensionService.extensions.find(e => ExtensionIdentifier.equals(e.identifier, extension)); + disposable = this._chatAgentService.registerDynamicAgent( + { + id, + name: dynamicProps.name, + description: dynamicProps.description, + extensionId: extension, + extensionDisplayName: extensionDescription?.displayName ?? extension.value, + extensionPublisherId: extensionDescription?.publisher ?? '', + publisherDisplayName: dynamicProps.publisherName, + fullName: dynamicProps.fullName, + metadata: revive(metadata), + slashCommands: [], + disambiguation: [], + locations: [ChatAgentLocation.Panel] // TODO all dynamic participants are panel only? + }, + impl); + } else { + disposable = this._chatAgentService.registerAgentImplementation(id, impl); + } + + this._agents.set(handle, { + id: id, + extensionId: extension, + dispose: disposable.dispose, + hasFollowups: metadata.hasFollowups + }); + } + + $updateAgent(handle: number, metadataUpdate: IExtensionChatAgentMetadata): void { + const data = this._agents.get(handle); + if (!data) { + this._logService.error(`MainThreadChatAgents2#$updateAgent: No agent with handle ${handle} registered`); + return; + } + data.hasFollowups = metadataUpdate.hasFollowups; + this._chatAgentService.updateAgent(data.id, revive(metadataUpdate)); + } + + async $initResponse(sessionId: string): Promise { + const { responseId, callback } = await this._chatService.initiateResponse(sessionId); + this._pendingProgress.set(responseId, callback); + return responseId; + } + + async $handleProgressChunk(responseId: string, progress: IAideAgentProgressDto, responsePartHandle?: number): Promise { + const revivedProgress = revive(progress) as IChatProgress; + if (revivedProgress.kind === 'progressTask') { + const handle = ++this._responsePartHandlePool; + const responsePartId = `${responseId}_${handle}`; + const task = new MainThreadChatTask(revivedProgress.content); + this._activeTasks.set(responsePartId, task); + this._pendingProgress.get(responseId)?.(task); + return handle; + } else if (responsePartHandle !== undefined) { + const responsePartId = `${responseId}_${responsePartHandle}`; + const task = this._activeTasks.get(responsePartId); + switch (revivedProgress.kind) { + case 'progressTaskResult': + if (task && revivedProgress.content) { + task.complete(revivedProgress.content.value); + this._activeTasks.delete(responsePartId); + } else { + task?.complete(undefined); + } + return responsePartHandle; + case 'warning': + case 'reference': + task?.add(revivedProgress); + return; + } + } + this._pendingProgress.get(responseId)?.(revivedProgress); + } + + $registerAgentCompletionsProvider(handle: number, id: string, triggerCharacters: string[]): void { + const provide = async (query: string, token: CancellationToken) => { + const completions = await this._proxy.$invokeCompletionProvider(handle, query, token); + return completions.map((c) => ({ ...c, icon: c.icon ? ThemeIcon.fromId(c.icon) : undefined })); + }; + this._agentIdsToCompletionProviders.set(id, this._chatAgentService.registerAgentCompletionProvider(id, provide)); + + this._agentCompletionProviders.set(handle, this._languageFeaturesService.completionProvider.register({ scheme: ChatInputPart.INPUT_SCHEME, hasAccessToAllModels: true }, { + _debugDisplayName: 'chatAgentCompletions:' + handle, + triggerCharacters, + provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, token: CancellationToken) => { + const widget = this._chatWidgetService.getWidgetByInputUri(model.uri); + if (!widget || !widget.viewModel) { + return; + } + + const triggerCharsPart = triggerCharacters.map(c => escapeRegExpCharacters(c)).join(''); + const wordRegex = new RegExp(`[${triggerCharsPart}]\\S*`, 'g'); + const query = getWordAtText(position.column, wordRegex, model.getLineContent(position.lineNumber), 0)?.word ?? ''; + + if (query && !triggerCharacters.some(c => query.startsWith(c))) { + return; + } + + const parsedRequest = this._instantiationService.createInstance(ChatRequestParser).parseChatRequest(widget.viewModel.sessionId, model.getValue()).parts; + const agentPart = parsedRequest.find((part): part is ChatRequestAgentPart => part instanceof ChatRequestAgentPart); + const thisAgentId = this._agents.get(handle)?.id; + if (agentPart?.agent.id !== thisAgentId) { + return; + } + + const range = computeCompletionRanges(model, position, wordRegex); + if (!range) { + return null; + } + + const result = await provide(query, token); + const variableItems = result.map(v => { + const insertText = v.insertText ?? (typeof v.label === 'string' ? v.label : v.label.label); + const rangeAfterInsert = new Range(range.insert.startLineNumber, range.insert.startColumn, range.insert.endLineNumber, range.insert.startColumn + insertText.length); + return { + label: v.label, + range, + insertText: insertText + ' ', + kind: CompletionItemKind.Text, + detail: v.detail, + documentation: v.documentation, + command: { id: AddDynamicVariableAction.ID, title: '', arguments: [{ id: v.id, widget, range: rangeAfterInsert, variableData: revive(v.value) as any, command: v.command } satisfies IAddDynamicVariableContext] } + } satisfies CompletionItem; + }); + + return { + suggestions: variableItems + } satisfies CompletionList; + } + })); + } + + $unregisterAgentCompletionsProvider(handle: number, id: string): void { + this._agentCompletionProviders.deleteAndDispose(handle); + this._agentIdsToCompletionProviders.deleteAndDispose(id); + } + + $registerChatParticipantDetectionProvider(handle: number): void { + this._chatParticipantDetectionProviders.set(handle, this._chatAgentService.registerChatParticipantDetectionProvider(handle, + { + provideParticipantDetection: async (request: IChatAgentRequest, history: IChatAgentHistoryEntry[], options: { location: ChatAgentLocation; participants: IChatParticipantMetadata[] }, token: CancellationToken) => { + return await this._proxy.$detectChatParticipant(handle, request, { history }, options, token); + } + } + )); + } + + $unregisterChatParticipantDetectionProvider(handle: number): void { + this._chatParticipantDetectionProviders.deleteAndDispose(handle); + } +} + + +function computeCompletionRanges(model: ITextModel, position: Position, reg: RegExp): { insert: Range; replace: Range } | undefined { + const varWord = getWordAtText(position.column, reg, model.getLineContent(position.lineNumber), 0); + if (!varWord && model.getWordUntilPosition(position).word) { + // inside a "normal" word + return; + } + + let insert: Range; + let replace: Range; + if (!varWord) { + insert = replace = Range.fromPositions(position); + } else { + insert = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, position.column); + replace = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, varWord.endColumn); + } + + return { insert, replace }; +} diff --git a/src/vs/workbench/api/browser/mainThreadAideAgentCodeMapper.ts b/src/vs/workbench/api/browser/mainThreadAideAgentCodeMapper.ts new file mode 100644 index 00000000000..218feb0953b --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadAideAgentCodeMapper.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { Disposable, DisposableMap, IDisposable } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import { ICodeMapperProvider, ICodeMapperRequest, ICodeMapperResponse, IAideAgentCodeMapperService } from '../../contrib/aideAgent/common/aideAgentCodeMapperService.js'; +import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; +import { ExtHostCodeMapperShape, ExtHostContext, ICodeMapperProgressDto, ICodeMapperRequestDto, MainContext, MainThreadCodeMapperShape } from '../common/extHost.protocol.js'; + +@extHostNamedCustomer(MainContext.MainThreadAideAgentCodeMapper) +export class MainThreadChatCodemapper extends Disposable implements MainThreadCodeMapperShape { + + private providers = this._register(new DisposableMap()); + private readonly _proxy: ExtHostCodeMapperShape; + private static _requestHandlePool: number = 0; + private _responseMap = new Map(); + + constructor( + extHostContext: IExtHostContext, + @IAideAgentCodeMapperService private readonly codeMapperService: IAideAgentCodeMapperService + ) { + super(); + this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostCodeMapper); + } + + $registerCodeMapperProvider(handle: number): void { + const impl: ICodeMapperProvider = { + mapCode: async (uiRequest: ICodeMapperRequest, response: ICodeMapperResponse, token: CancellationToken) => { + const requestId = String(MainThreadChatCodemapper._requestHandlePool++); + this._responseMap.set(requestId, response); + const extHostRequest: ICodeMapperRequestDto = { + requestId, + codeBlocks: uiRequest.codeBlocks, + conversation: uiRequest.conversation + }; + try { + return await this._proxy.$mapCode(handle, extHostRequest, token).then((result) => result ?? undefined); + } finally { + this._responseMap.delete(requestId); + } + } + }; + + const disposable = this.codeMapperService.registerCodeMapperProvider(handle, impl); + this.providers.set(handle, disposable); + } + + $unregisterCodeMapperProvider(handle: number): void { + this.providers.deleteAndDispose(handle); + } + + $handleProgress(requestId: string, data: ICodeMapperProgressDto): Promise { + const response = this._responseMap.get(requestId); + if (response) { + const resource = URI.revive(data.uri); + response.textEdit(resource, data.edits); + } + return Promise.resolve(); + } +} diff --git a/src/vs/workbench/api/browser/mainThreadAideAgentProvider.ts b/src/vs/workbench/api/browser/mainThreadAideAgentProvider.ts deleted file mode 100644 index d69428c914e..00000000000 --- a/src/vs/workbench/api/browser/mainThreadAideAgentProvider.ts +++ /dev/null @@ -1,61 +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 { Disposable } from '../../../base/common/lifecycle.js'; -import { revive } from '../../../base/common/marshalling.js'; -import { ICSAccountService } from '../../../platform/codestoryAccount/common/csAccount.js'; -import { IAideAgentImplementation } from '../../contrib/aideAgent/common/aideAgent.js'; -import { IAgentResponseProgress, IAideAgentService } from '../../contrib/aideAgent/common/aideAgentService.js'; -import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; -import { ExtHostAideAgentProviderShape, ExtHostContext, IAideAgentProgressDto, MainContext, MainThreadAideAgentProviderShape } from '../common/extHost.protocol.js'; - -@extHostNamedCustomer(MainContext.MainThreadAideAgentProvider) -export class MainThreadAideAgentProvider extends Disposable implements MainThreadAideAgentProviderShape { - private readonly _proxy: ExtHostAideAgentProviderShape; - private readonly _pendingProgress = new Map Promise>(); - - constructor( - extHostContext: IExtHostContext, - @IAideAgentService private readonly aideAgentService: IAideAgentService, - @ICSAccountService private readonly csAccountService: ICSAccountService - ) { - super(); - this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostAideAgentProvider); - } - - $registerAideAgentProvider(handle: number): void { - const impl: IAideAgentImplementation = { - trigger: async (request, progress, token) => { - const authenticated = await this.csAccountService.ensureAuthenticated(); - if (!authenticated) { - return {}; - } - - this._pendingProgress.set(request.id, progress); - try { - return await this._proxy.$trigger(handle, request, token) || {}; - } finally { - this._pendingProgress.delete(request.id); - } - } - }; - - this.aideAgentService.registerAgentProvider(impl); - } - - async $handleProgress(requestId: string, progress: IAideAgentProgressDto, handle?: number): Promise { - const revivedProgress = revive(progress) as IAgentResponseProgress; - if (revivedProgress.kind === 'progressTask') { - // - } else if (handle !== undefined) { - - } - this._pendingProgress.get(requestId)?.(revivedProgress); - } - - $unregisterAideAgentProvider(handle: number): void { - // TODO - } -} diff --git a/src/vs/workbench/api/browser/mainThreadAideAgentVariables.ts b/src/vs/workbench/api/browser/mainThreadAideAgentVariables.ts new file mode 100644 index 00000000000..b97f47a12e9 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadAideAgentVariables.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableMap } from '../../../base/common/lifecycle.js'; +import { revive } from '../../../base/common/marshalling.js'; +import { ExtHostChatVariablesShape, ExtHostContext, IChatVariableResolverProgressDto, MainContext, MainThreadChatVariablesShape } from '../common/extHost.protocol.js'; +import { IChatRequestVariableValue, IChatVariableData, IChatVariableResolverProgress, IAideAgentVariablesService } from '../../contrib/aideAgent/common/aideAgentVariables.js'; +import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; + +@extHostNamedCustomer(MainContext.MainThreadAideAgentVariables) +export class MainThreadChatVariables implements MainThreadChatVariablesShape { + + private readonly _proxy: ExtHostChatVariablesShape; + private readonly _variables = new DisposableMap(); + private readonly _pendingProgress = new Map void>(); + + constructor( + extHostContext: IExtHostContext, + @IAideAgentVariablesService private readonly _chatVariablesService: IAideAgentVariablesService, + ) { + this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostChatVariables); + } + + dispose(): void { + this._variables.clearAndDisposeAll(); + } + + $registerVariable(handle: number, data: IChatVariableData): void { + const registration = this._chatVariablesService.registerVariable(data, async (messageText, _arg, model, progress, token) => { + const varRequestId = `${model.sessionId}-${handle}`; + this._pendingProgress.set(varRequestId, progress); + const result = revive(await this._proxy.$resolveVariable(handle, varRequestId, messageText, token)); + + this._pendingProgress.delete(varRequestId); + return result as any; // 'revive' type signature doesn't like this type for some reason + }); + this._variables.set(handle, registration); + } + + async $handleProgressChunk(requestId: string, progress: IChatVariableResolverProgressDto): Promise { + const revivedProgress = revive(progress); + this._pendingProgress.get(requestId)?.(revivedProgress as IChatVariableResolverProgress); + } + + $unregisterVariable(handle: number): void { + this._variables.deleteAndDispose(handle); + } +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 256b9a440d3..8f608f53e82 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -112,7 +112,9 @@ import { ExtHostCodeMapper } from './extHostCodeMapper.js'; import { IExtHostCSAuthentication } from './extHostCSAuthentication.js'; import { ExtHostCSEvents } from './extHostCSEvents.js'; import { ExtHostModelSelection } from './extHostModelSelection.js'; -import { ExtHostAideAgentProvider } from './extHostAideAgentProvider.js'; +import { ExtHostAideAgentAgents2 } from './extHostAideAgentAgents2.js'; +import { ExtHostAideAgentVariables } from './extHostAideAgentVariables.js'; +import { ExtHostAideAgentCodeMapper } from './extHostAideAgentCodeMapper.js'; export interface IExtensionRegistries { mine: ExtensionDescriptionRegistry; @@ -221,7 +223,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostChatAgents2 = rpcProtocol.set(ExtHostContext.ExtHostChatAgents2, new ExtHostChatAgents2(rpcProtocol, extHostLogService, extHostCommands, extHostDocuments)); const extHostChatVariables = rpcProtocol.set(ExtHostContext.ExtHostChatVariables, new ExtHostChatVariables(rpcProtocol)); const extHostLanguageModelTools = rpcProtocol.set(ExtHostContext.ExtHostLanguageModelTools, new ExtHostLanguageModelTools(rpcProtocol)); - const extHostAideAgentProvider = rpcProtocol.set(ExtHostContext.ExtHostAideAgentProvider, new ExtHostAideAgentProvider(rpcProtocol)); + const extHostAideAgentAgents2 = rpcProtocol.set(ExtHostContext.ExtHostAideAgentAgents2, new ExtHostAideAgentAgents2(rpcProtocol, extHostLogService, extHostCommands, extHostDocuments)); + const extHostAideAgentCodeMapper = rpcProtocol.set(ExtHostContext.ExtHostAideAgentCodeMapper, new ExtHostAideAgentCodeMapper(rpcProtocol)); + const extHostAideAgentVariables = rpcProtocol.set(ExtHostContext.ExtHostAideAgentVariables, new ExtHostAideAgentVariables(rpcProtocol)); const extHostAiRelatedInformation = rpcProtocol.set(ExtHostContext.ExtHostAiRelatedInformation, new ExtHostRelatedInformation(rpcProtocol)); const extHostAiEmbeddingVector = rpcProtocol.set(ExtHostContext.ExtHostAiEmbeddingVector, new ExtHostAiEmbeddingVector(rpcProtocol)); const extHostStatusBar = rpcProtocol.set(ExtHostContext.ExtHostStatusBar, new ExtHostStatusBar(rpcProtocol, extHostCommands.converter)); @@ -1488,6 +1492,26 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I }, }; + // namespace: aideAgent + const aideAgent: typeof vscode.aideAgent = { + createChatParticipant(id: string, resolver: vscode.AideSessionParticipant) { + checkProposedApiEnabled(extension, 'aideAgent'); + return extHostAideAgentAgents2.createChatAgent(extension, id, resolver); + }, + registerChatParticipantDetectionProvider(provider: vscode.ChatParticipantDetectionProvider) { + checkProposedApiEnabled(extension, 'aideAgent'); + return extHostAideAgentAgents2.registerChatParticipantDetectionProvider(provider); + }, + registerChatVariableResolver(id: string, name: string, userDescription: string, modelDescription: string | undefined, isSlow: boolean | undefined, resolver: vscode.ChatVariableResolver, fullName?: string, icon?: vscode.ThemeIcon) { + checkProposedApiEnabled(extension, 'aideAgent'); + return extHostAideAgentVariables.registerVariableResolver(extension, id, name, userDescription, modelDescription, isSlow, resolver, fullName, icon?.id); + }, + registerMappedEditsProvider2(provider: vscode.MappedEditsProvider2) { + checkProposedApiEnabled(extension, 'aideAgent'); + return extHostAideAgentCodeMapper.registerMappedEditsProvider(extension, provider); + }, + }; + // namespace: lm const lm: typeof vscode.lm = { selectChatModels: (selector) => { @@ -1535,14 +1559,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I }, }; - // namespace: aideAgent - const aideAgent: typeof vscode.aideAgent = { - registerAideAgentProvider(id: string, provider: vscode.AideAgentProvider) { - checkProposedApiEnabled(extension, 'aideAgent'); - return extHostAideAgentProvider.registerAgentprovider(extension, id, provider); - } - }; - // namespace: speech const speech: typeof vscode.speech = { registerSpeechProvider(id: string, provider: vscode.SpeechProvider) { @@ -1840,6 +1856,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I LanguageModelChatResponseTextPart: extHostTypes.LanguageModelTextPart, LanguageModelChatResponseToolCallPart: extHostTypes.LanguageModelToolCallPart, LanguageModelError: extHostTypes.LanguageModelError, + AideAgentMode: extHostTypes.AideAgentMode, 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 d63036cd3ee..f4b5cf75aa0 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -52,9 +52,7 @@ import { EditSessionIdentityMatch } from '../../../platform/workspace/common/edi import { WorkspaceTrustRequestOptions } from '../../../platform/workspace/common/workspaceTrust.js'; import { SaveReason } from '../../common/editor.js'; import { IRevealOptions, ITreeItem, IViewBadge } from '../../common/views.js'; -import { IAgentTriggerComplete } from '../../contrib/aideAgent/common/aideAgent.js'; -import { IAgentTriggerPayload } from '../../contrib/aideAgent/common/aideAgentModel.js'; -import { IAgentResponseProgress, IAgentTask, IAgentTaskDto, IAgentTextEdit } from '../../contrib/aideAgent/common/aideAgentService.js'; +import { IChatEndResponse } from '../../contrib/aideAgent/common/aideAgentService.js'; import { CallHierarchyItem } from '../../contrib/callHierarchy/common/callHierarchy.js'; import { ChatAgentLocation, IChatAgentMetadata, IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/chatAgents.js'; import { ICodeMapperRequest, ICodeMapperResult } from '../../contrib/chat/common/chatCodeMapperService.js'; @@ -1424,19 +1422,17 @@ export type IChatProgressDto = ///////////////////////// END CHAT ///////////////////////// ///////////////////////// START AIDE ///////////////////////// -export type IAideAgentTextEditDto = Omit, 'edits'> & { edits: IWorkspaceEditDto }; -type IAideAgentWithTaskDto = Dto> | IAgentTaskDto; -export type IAideAgentProgressDto = Dto> | IAideAgentWithTaskDto; - -export interface MainThreadAideAgentProviderShape extends IDisposable { - $registerAideAgentProvider(handle: number): void; - $unregisterAideAgentProvider(handle: number): void; - $handleProgress(requestId: string, progress: IAideAgentProgressDto, handle?: number): Promise; +export interface ExtHostAideAgentAgentsShape extends ExtHostChatAgentsShape2 { + $initSession(handle: number, sessionId: string): void; } -export interface ExtHostAideAgentProviderShape { - $trigger(handle: number, request: IAgentTriggerPayload, token: CancellationToken): Promise; +export type IAideAgentProgressDto = IChatProgressDto | Dto; + +export interface MainThreadAideAgentAgentsShape2 extends MainThreadChatAgentsShape2 { + $initResponse(sessionId: string): Promise; + $handleProgressChunk(responseId: string, chunk: IAideAgentProgressDto, handle?: number): Promise; } + ///////////////////////// END AIDE ///////////////////////// export interface ExtHostUrlsShape { @@ -2964,11 +2960,13 @@ export const MainContext = { MainThreadBulkEdits: createProxyIdentifier('MainThreadBulkEdits'), MainThreadLanguageModels: createProxyIdentifier('MainThreadLanguageModels'), MainThreadEmbeddings: createProxyIdentifier('MainThreadEmbeddings'), - MainThreadAideAgentProvider: createProxyIdentifier('MainThreadAideAgentProvider'), MainThreadChatAgents2: createProxyIdentifier('MainThreadChatAgents2'), MainThreadCodeMapper: createProxyIdentifier('MainThreadCodeMapper'), MainThreadChatVariables: createProxyIdentifier('MainThreadChatVariables'), MainThreadLanguageModelTools: createProxyIdentifier('MainThreadChatSkills'), + MainThreadAideAgentAgents2: createProxyIdentifier('MainThreadAideAgentAgents2'), + MainThreadAideAgentCodeMapper: createProxyIdentifier('MainThreadAideAgentCodeMapper'), + MainThreadAideAgentVariables: createProxyIdentifier('MainThreadAideAgentVariables'), MainThreadClipboard: createProxyIdentifier('MainThreadClipboard'), MainThreadCommands: createProxyIdentifier('MainThreadCommands'), MainThreadComments: createProxyIdentifier('MainThreadComments'), @@ -3094,7 +3092,9 @@ export const ExtHostContext = { ExtHostChatVariables: createProxyIdentifier('ExtHostChatVariables'), ExtHostLanguageModelTools: createProxyIdentifier('ExtHostChatSkills'), ExtHostChatProvider: createProxyIdentifier('ExtHostChatProvider'), - ExtHostAideAgentProvider: createProxyIdentifier('ExtHostAideAgentProvider'), + ExtHostAideAgentAgents2: createProxyIdentifier('ExtHostAideAgentAgents'), + ExtHostAideAgentCodeMapper: createProxyIdentifier('ExtHostAideAgentCodeMapper'), + ExtHostAideAgentVariables: createProxyIdentifier('ExtHostAideAgentVariables'), ExtHostSpeech: createProxyIdentifier('ExtHostSpeech'), ExtHostEmbeddings: createProxyIdentifier('ExtHostEmbeddings'), ExtHostAiRelatedInformation: createProxyIdentifier('ExtHostAiRelatedInformation'), diff --git a/src/vs/workbench/api/common/extHostAideAgentAgents2.ts b/src/vs/workbench/api/common/extHostAideAgentAgents2.ts new file mode 100644 index 00000000000..2488ae4dee7 --- /dev/null +++ b/src/vs/workbench/api/common/extHostAideAgentAgents2.ts @@ -0,0 +1,836 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { coalesce } from '../../../base/common/arrays.js'; +import { raceCancellation } from '../../../base/common/async.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { toErrorMessage } from '../../../base/common/errorMessage.js'; +import { Emitter } from '../../../base/common/event.js'; +import { IMarkdownString } from '../../../base/common/htmlContent.js'; +import { Iterable } from '../../../base/common/iterator.js'; +import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; +import { revive } from '../../../base/common/marshalling.js'; +import { assertType } from '../../../base/common/types.js'; +import { URI } from '../../../base/common/uri.js'; +import { ExtensionIdentifier, IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; +import { ILogService } from '../../../platform/log/common/log.js'; +import { ChatAgentLocation, IChatAgentRequest, IChatAgentResult } from '../../contrib/aideAgent/common/aideAgentAgents.js'; +import { ChatAgentVoteDirection, IChatFollowup, IChatResponseErrorDetails, IChatUserActionEvent, IChatVoteAction } from '../../contrib/aideAgent/common/aideAgentService.js'; +import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; +import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; +import { ExtHostAideAgentAgentsShape, IAideAgentProgressDto, IChatAgentCompletionItem, IChatAgentHistoryEntryDto, IMainContext, MainContext, MainThreadAideAgentAgentsShape2 } from './extHost.protocol.js'; +import { CommandsConverter, ExtHostCommands } from './extHostCommands.js'; +import { ExtHostDocuments } from './extHostDocuments.js'; +import * as typeConvert from './extHostTypeConverters.js'; +import * as extHostTypes from './extHostTypes.js'; + +class AideAgentResponseStream { + private _isClosed: boolean = false; + private _apiObject: vscode.AideAgentResponseStream | undefined; + + constructor( + private readonly _responseId: string, + private readonly _proxy: MainThreadAideAgentAgentsShape2, + private readonly _commandsConverter: CommandsConverter, + private readonly _sessionDisposables: DisposableStore + ) { } + + close() { + this._isClosed = true; + } + + get apiObject() { + + if (!this._apiObject) { + const that = this; + + function throwIfDone(source: Function | undefined) { + if (that._isClosed) { + const err = new Error('Response stream has been closed'); + Error.captureStackTrace(err, source); + throw err; + } + } + + const _report = (progress: IAideAgentProgressDto, task?: (progress: vscode.Progress) => Thenable) => { + if (task) { + const progressReporterPromise = this._proxy.$handleProgressChunk(this._responseId, progress); + const progressReporter = { + report: (p: vscode.ChatResponseWarningPart | vscode.ChatResponseReferencePart) => { + progressReporterPromise?.then((handle) => { + if (handle) { + if (extHostTypes.MarkdownString.isMarkdownString(p.value)) { + this._proxy.$handleProgressChunk(this._responseId, typeConvert.ChatResponseWarningPart.from(p), handle); + } else { + this._proxy.$handleProgressChunk(this._responseId, typeConvert.ChatResponseReferencePart.from(p), handle); + } + } + }); + } + }; + + Promise.all([progressReporterPromise, task?.(progressReporter)]).then(([handle, res]) => { + if (handle !== undefined) { + this._proxy.$handleProgressChunk(this._responseId, typeConvert.ChatTaskResult.from(res), handle); + } + }); + } else { + this._proxy.$handleProgressChunk(this._responseId, progress); + } + }; + + this._apiObject = { + markdown(value) { + throwIfDone(this.markdown); + const part = new extHostTypes.ChatResponseMarkdownPart(value); + const dto = typeConvert.ChatResponseMarkdownPart.from(part); + _report(dto); + return this; + }, + markdownWithVulnerabilities(value, vulnerabilities) { + throwIfDone(this.markdown); + + const part = new extHostTypes.ChatResponseMarkdownWithVulnerabilitiesPart(value, vulnerabilities); + const dto = typeConvert.ChatResponseMarkdownWithVulnerabilitiesPart.from(part); + _report(dto); + return this; + }, + codeblockUri(value) { + throwIfDone(this.codeblockUri); + const part = new extHostTypes.ChatResponseCodeblockUriPart(value); + const dto = typeConvert.ChatResponseCodeblockUriPart.from(part); + _report(dto); + return this; + }, + filetree(value, baseUri) { + throwIfDone(this.filetree); + const part = new extHostTypes.ChatResponseFileTreePart(value, baseUri); + const dto = typeConvert.ChatResponseFilesPart.from(part); + _report(dto); + return this; + }, + anchor(value, title?: string) { + throwIfDone(this.anchor); + const part = new extHostTypes.ChatResponseAnchorPart(value, title); + const dto = typeConvert.ChatResponseAnchorPart.from(part); + _report(dto); + return this; + }, + button(value) { + throwIfDone(this.anchor); + const part = new extHostTypes.ChatResponseCommandButtonPart(value); + const dto = typeConvert.ChatResponseCommandButtonPart.from(part, that._commandsConverter, that._sessionDisposables); + _report(dto); + return this; + }, + progress(value, task?: ((progress: vscode.Progress) => Thenable)) { + throwIfDone(this.progress); + const part = new extHostTypes.ChatResponseProgressPart2(value, task); + const dto = task ? typeConvert.ChatTask.from(part) : typeConvert.ChatResponseProgressPart.from(part); + _report(dto, task); + return this; + }, + warning(value) { + throwIfDone(this.progress); + const part = new extHostTypes.ChatResponseWarningPart(value); + const dto = typeConvert.ChatResponseWarningPart.from(part); + _report(dto); + return this; + }, + reference(value, iconPath) { + return this.reference2(value, iconPath); + }, + reference2(_value, _iconPath, _options) { + throwIfDone(this.reference); + + /* TODO(@ghostwriternr): Temporarily remove this until we have a way to pass the request object to the agent + if (typeof value === 'object' && 'variableName' in value && !value.value) { + // The participant used this variable. Does that variable have any references to pull in? + const matchingVarData = that._request.variables.variables.find(v => v.name === value.variableName); + if (matchingVarData) { + let references: Dto[] | undefined; + if (matchingVarData.references?.length) { + references = matchingVarData.references.map(r => ({ + kind: 'reference', + reference: { variableName: value.variableName, value: r.reference as URI | Location } + } satisfies IChatContentReference)); + } else { + // Participant sent a variableName reference but the variable produced no references. Show variable reference with no value + const part = new extHostTypes.ChatResponseReferencePart(value, iconPath, options); + const dto = typeConvert.ChatResponseReferencePart.from(part); + references = [dto]; + } + + references.forEach(r => _report(r)); + return this; + } else { + // Something went wrong- that variable doesn't actually exist + } + } else { + const part = new extHostTypes.ChatResponseReferencePart(value, iconPath, options); + const dto = typeConvert.ChatResponseReferencePart.from(part); + _report(dto); + } + */ + + return this; + }, + codeCitation(value: vscode.Uri, license: string, snippet: string): void { + throwIfDone(this.codeCitation); + + const part = new extHostTypes.ChatResponseCodeCitationPart(value, license, snippet); + const dto = typeConvert.ChatResponseCodeCitationPart.from(part); + _report(dto); + }, + textEdit(target, edits) { + throwIfDone(this.textEdit); + + const part = new extHostTypes.ChatResponseTextEditPart(target, edits); + const dto = typeConvert.ChatResponseTextEditPart.from(part); + _report(dto); + return this; + }, + detectedParticipant(participant, command) { + throwIfDone(this.detectedParticipant); + + const part = new extHostTypes.ChatResponseDetectedParticipantPart(participant, command); + const dto = typeConvert.ChatResponseDetectedParticipantPart.from(part); + _report(dto); + return this; + }, + confirmation(title, message, data, buttons) { + throwIfDone(this.confirmation); + + const part = new extHostTypes.ChatResponseConfirmationPart(title, message, data, buttons); + const dto = typeConvert.ChatResponseConfirmationPart.from(part); + _report(dto); + return this; + }, + push(part) { + throwIfDone(this.push); + + if ( + part instanceof extHostTypes.ChatResponseTextEditPart || + part instanceof extHostTypes.ChatResponseMarkdownWithVulnerabilitiesPart || + part instanceof extHostTypes.ChatResponseDetectedParticipantPart || + part instanceof extHostTypes.ChatResponseWarningPart || + part instanceof extHostTypes.ChatResponseConfirmationPart || + part instanceof extHostTypes.ChatResponseCodeCitationPart || + part instanceof extHostTypes.ChatResponseMovePart + ) { } + + if (part instanceof extHostTypes.ChatResponseReferencePart) { + // Ensure variable reference values get fixed up + this.reference2(part.value, part.iconPath, part.options); + } else { + const dto = typeConvert.ChatResponsePart.from(part, that._commandsConverter, that._sessionDisposables); + _report(dto); + } + + return this; + }, + close() { + const dto = typeConvert.ChatResponseClosePart.from(); + _report(dto); + that.close(); + } + }; + } + + return this._apiObject; + } +} + +export class ExtHostAideAgentAgents2 extends Disposable implements ExtHostAideAgentAgentsShape { + private static _idPool = 0; + + private readonly _agents = new Map(); + private readonly _proxy: MainThreadAideAgentAgentsShape2; + + private static _participantDetectionProviderIdPool = 0; + private readonly _participantDetectionProviders = new Map(); + + private readonly _sessionDisposables: DisposableMap = this._register(new DisposableMap()); + private readonly _completionDisposables: DisposableMap = this._register(new DisposableMap()); + + constructor( + mainContext: IMainContext, + private readonly _logService: ILogService, + private readonly _commands: ExtHostCommands, + private readonly _documents: ExtHostDocuments + ) { + super(); + this._proxy = mainContext.getProxy(MainContext.MainThreadAideAgentAgents2); + } + + transferActiveChat(newWorkspace: vscode.Uri): void { + this._proxy.$transferActiveChatSession(newWorkspace); + } + + createChatAgent(extension: IExtensionDescription, id: string, handler: vscode.AideSessionParticipant): vscode.AideSessionAgent { + const handle = ExtHostAideAgentAgents2._idPool++; + const agent = new ExtHostChatAgent( + extension, id, this._proxy, handle, + // Preserve the correct 'this' context + (sessionId: string) => this.initResponse(sessionId), + handler.newSession, handler.handleEvent + ); + this._agents.set(handle, agent); + + this._proxy.$registerAgent(handle, extension.identifier, id, {}, undefined); + return agent.apiAgent; + } + + registerChatParticipantDetectionProvider(provider: vscode.ChatParticipantDetectionProvider): vscode.Disposable { + const handle = ExtHostAideAgentAgents2._participantDetectionProviderIdPool++; + this._participantDetectionProviders.set(handle, provider); + this._proxy.$registerChatParticipantDetectionProvider(handle); + return toDisposable(() => { + this._participantDetectionProviders.delete(handle); + this._proxy.$unregisterChatParticipantDetectionProvider(handle); + }); + } + + async $detectChatParticipant(handle: number, requestDto: Dto, context: { history: IChatAgentHistoryEntryDto[] }, options: { location: ChatAgentLocation; participants?: vscode.ChatParticipantMetadata[] }, token: CancellationToken): Promise { + const { request, location, history } = await this._createRequest(requestDto, context); + + const provider = this._participantDetectionProviders.get(handle); + if (!provider) { + return undefined; + } + + return provider.provideParticipantDetection( + typeConvert.ChatAgentRequest.to(request, location), + { history }, + { participants: options.participants, location: typeConvert.ChatLocation.to(options.location) }, + token + ); + } + + private async _createRequest(requestDto: Dto, context: { history: IChatAgentHistoryEntryDto[] }) { + const request = revive(requestDto); + const convertedHistory = await this.prepareHistoryTurns(request.agentId, context); + + // in-place converting for location-data + let location: vscode.ChatRequestEditorData | vscode.ChatRequestNotebookData | undefined; + if (request.locationData?.type === ChatAgentLocation.Editor) { + // editor data + const document = this._documents.getDocument(request.locationData.document); + location = new extHostTypes.ChatRequestEditorData(document, typeConvert.Selection.to(request.locationData.selection), typeConvert.Range.to(request.locationData.wholeRange)); + + } else if (request.locationData?.type === ChatAgentLocation.Notebook) { + // notebook data + const cell = this._documents.getDocument(request.locationData.sessionInputUri); + location = new extHostTypes.ChatRequestNotebookData(cell); + + } else if (request.locationData?.type === ChatAgentLocation.Terminal) { + // TBD + } + + return { request, location, history: convertedHistory }; + } + + $initSession(handle: number, sessionId: string): void { + const agent = this._agents.get(handle); + if (!agent) { + throw new Error(`[CHAT](${handle}) CANNOT init session because the agent is not registered`); + } + + // Init session disposables + let sessionDisposables = this._sessionDisposables.get(sessionId); + if (!sessionDisposables) { + sessionDisposables = new DisposableStore(); + this._sessionDisposables.set(sessionId, sessionDisposables); + } + + return agent.initSession(sessionId); + } + + async $invokeAgent(handle: number, requestDto: Dto, context: { history: IChatAgentHistoryEntryDto[] }, token: CancellationToken): Promise { + const agent = this._agents.get(handle); + if (!agent) { + throw new Error(`[CHAT](${handle}) CANNOT invoke agent because the agent is not registered`); + } + + try { + const { request, location } = await this._createRequest(requestDto, context); + if (!isProposedApiEnabled(agent.extension, 'chatParticipantAdditions')) { + delete request.userSelectedModelId; + } + + const task = agent.invoke( + typeConvert.AideAgentRequest.to(request, location), + token + ); + + return await raceCancellation(Promise.resolve(task).then((result) => { + if (result?.metadata) { + try { + JSON.stringify(result.metadata); + } catch (err) { + const msg = `result.metadata MUST be JSON.stringify-able. Got error: ${err.message}`; + this._logService.error(`[${agent.extension.identifier.value}] [@${agent.id}] ${msg}`, agent.extension); + return { errorDetails: { message: msg }, nextQuestion: result.nextQuestion } satisfies IChatAgentResult; + } + } + let errorDetails: IChatResponseErrorDetails | undefined; + if (result?.errorDetails) { + errorDetails = { + ...result.errorDetails, + responseIsIncomplete: true + }; + } + + return { errorDetails, metadata: result?.metadata, nextQuestion: result?.nextQuestion } satisfies IChatAgentResult; + }), token); + } catch (e) { + this._logService.error(e, agent.extension); + + if (e instanceof extHostTypes.LanguageModelError && e.cause) { + e = e.cause; + } + + return { errorDetails: { message: toErrorMessage(e), responseIsIncomplete: true } }; + } + } + + private async initResponse(sessionId: string): Promise { + const sessionDisposables = this._sessionDisposables.get(sessionId); + if (!sessionDisposables) { + return undefined; + } + + const responseId = await this._proxy.$initResponse(sessionId); + const stream = new AideAgentResponseStream(responseId, this._proxy, this._commands.converter, sessionDisposables); + return stream.apiObject; + } + + private async prepareHistoryTurns(agentId: string, context: { history: IChatAgentHistoryEntryDto[] }): Promise<(vscode.ChatRequestTurn | vscode.ChatResponseTurn)[]> { + const res: (vscode.ChatRequestTurn | vscode.ChatResponseTurn)[] = []; + + for (const h of context.history) { + const ehResult = typeConvert.ChatAgentResult.to(h.result); + const result: vscode.ChatResult = agentId === h.request.agentId ? + ehResult : + { ...ehResult, metadata: undefined }; + + // REQUEST turn + const varsWithoutTools = h.request.variables.variables + .filter(v => !v.isTool) + .map(typeConvert.ChatPromptReference.to); + const toolReferences = h.request.variables.variables + .filter(v => v.isTool) + .map(typeConvert.ChatLanguageModelToolReference.to); + const turn = new extHostTypes.ChatRequestTurn(h.request.message, h.request.command, varsWithoutTools, h.request.agentId); + turn.toolReferences = toolReferences; + res.push(turn); + + // RESPONSE turn + const parts = coalesce(h.response.map(r => typeConvert.ChatResponsePart.toContent(r, this._commands.converter))); + res.push(new extHostTypes.ChatResponseTurn(parts, result, h.request.agentId, h.request.command)); + } + + return res; + } + + $releaseSession(sessionId: string): void { + this._sessionDisposables.deleteAndDispose(sessionId); + } + + async $provideFollowups(requestDto: Dto, handle: number, result: IChatAgentResult, context: { history: IChatAgentHistoryEntryDto[] }, token: CancellationToken): Promise { + const agent = this._agents.get(handle); + if (!agent) { + return Promise.resolve([]); + } + + const request = revive(requestDto); + const convertedHistory = await this.prepareHistoryTurns(agent.id, context); + + const ehResult = typeConvert.ChatAgentResult.to(result); + return (await agent.provideFollowups(ehResult, { history: convertedHistory }, token)) + .filter(f => { + // The followup must refer to a participant that exists from the same extension + const isValid = !f.participant || Iterable.some( + this._agents.values(), + a => a.id === f.participant && ExtensionIdentifier.equals(a.extension.identifier, agent.extension.identifier)); + if (!isValid) { + this._logService.warn(`[@${agent.id}] ChatFollowup refers to an unknown participant: ${f.participant}`); + } + return isValid; + }) + .map(f => typeConvert.ChatFollowup.from(f, request)); + } + + $acceptFeedback(handle: number, result: IChatAgentResult, voteAction: IChatVoteAction): void { + const agent = this._agents.get(handle); + if (!agent) { + return; + } + + const ehResult = typeConvert.ChatAgentResult.to(result); + let kind: extHostTypes.ChatResultFeedbackKind; + switch (voteAction.direction) { + case ChatAgentVoteDirection.Down: + kind = extHostTypes.ChatResultFeedbackKind.Unhelpful; + break; + case ChatAgentVoteDirection.Up: + kind = extHostTypes.ChatResultFeedbackKind.Helpful; + break; + } + + const feedback: vscode.ChatResultFeedback = { + result: ehResult, + kind, + unhelpfulReason: isProposedApiEnabled(agent.extension, 'chatParticipantAdditions') ? voteAction.reason : undefined, + }; + agent.acceptFeedback(Object.freeze(feedback)); + } + + $acceptAction(handle: number, result: IChatAgentResult, event: IChatUserActionEvent): void { + const agent = this._agents.get(handle); + if (!agent) { + return; + } + if (event.action.kind === 'vote') { + // handled by $acceptFeedback + return; + } + + const ehAction = typeConvert.ChatAgentUserActionEvent.to(result, event, this._commands.converter); + if (ehAction) { + agent.acceptAction(Object.freeze(ehAction)); + } + } + + async $invokeCompletionProvider(handle: number, query: string, token: CancellationToken): Promise { + const agent = this._agents.get(handle); + if (!agent) { + return []; + } + + let disposables = this._completionDisposables.get(handle); + if (disposables) { + // Clear any disposables from the last invocation of this completion provider + disposables.clear(); + } else { + disposables = new DisposableStore(); + this._completionDisposables.set(handle, disposables); + } + + const items = await agent.invokeCompletionProvider(query, token); + + return items.map((i) => typeConvert.ChatAgentCompletionItem.from(i, this._commands.converter, disposables)); + } + + async $provideWelcomeMessage(handle: number, location: ChatAgentLocation, token: CancellationToken): Promise<(string | IMarkdownString)[] | undefined> { + const agent = this._agents.get(handle); + if (!agent) { + return; + } + + return await agent.provideWelcomeMessage(typeConvert.ChatLocation.to(location), token); + } + + async $provideChatTitle(handle: number, context: IChatAgentHistoryEntryDto[], token: CancellationToken): Promise { + const agent = this._agents.get(handle); + if (!agent) { + return; + } + + const history = await this.prepareHistoryTurns(agent.id, { history: context }); + return await agent.provideTitle({ history }, token); + } + + async $provideSampleQuestions(handle: number, location: ChatAgentLocation, token: CancellationToken): Promise { + const agent = this._agents.get(handle); + if (!agent) { + return; + } + + return (await agent.provideSampleQuestions(typeConvert.ChatLocation.to(location), token)) + .map(f => typeConvert.ChatFollowup.from(f, undefined)); + } +} + +class ExtHostChatAgent { + + private _followupProvider: vscode.ChatFollowupProvider | undefined; + private _iconPath: vscode.Uri | { light: vscode.Uri; dark: vscode.Uri } | vscode.ThemeIcon | undefined; + private _helpTextPrefix: string | vscode.MarkdownString | undefined; + private _helpTextVariablesPrefix: string | vscode.MarkdownString | undefined; + private _helpTextPostfix: string | vscode.MarkdownString | undefined; + private _isSecondary: boolean | undefined; + private _onDidReceiveFeedback = new Emitter(); + private _onDidPerformAction = new Emitter(); + private _supportIssueReporting: boolean | undefined; + private _agentVariableProvider?: { provider: vscode.ChatParticipantCompletionItemProvider; triggerCharacters: string[] }; + private _welcomeMessageProvider?: vscode.ChatWelcomeMessageProvider | undefined; + private _titleProvider?: vscode.ChatTitleProvider | undefined; + private _requester: vscode.ChatRequesterInformation | undefined; + private _supportsSlowReferences: boolean | undefined; + + constructor( + public readonly extension: IExtensionDescription, + public readonly id: string, + private readonly _proxy: MainThreadAideAgentAgentsShape2, + private readonly _handle: number, + private _initResponse: vscode.AideSessionEventSender, + private _sessionHandler: vscode.AideSessionHandler, + private _requestHandler: vscode.AideSessionEventHandler, + ) { } + + initSession(sessionId: string): void { + this._sessionHandler(sessionId); + } + + acceptFeedback(feedback: vscode.ChatResultFeedback) { + this._onDidReceiveFeedback.fire(feedback); + } + + acceptAction(event: vscode.ChatUserActionEvent) { + this._onDidPerformAction.fire(event); + } + + async invokeCompletionProvider(query: string, token: CancellationToken): Promise { + if (!this._agentVariableProvider) { + return []; + } + + return await this._agentVariableProvider.provider.provideCompletionItems(query, token) ?? []; + } + + async provideFollowups(result: vscode.ChatResult, context: vscode.ChatContext, token: CancellationToken): Promise { + if (!this._followupProvider) { + return []; + } + + const followups = await this._followupProvider.provideFollowups(result, context, token); + if (!followups) { + return []; + } + return followups + // Filter out "command followups" from older providers + .filter(f => !(f && 'commandId' in f)) + // Filter out followups from older providers before 'message' changed to 'prompt' + .filter(f => !(f && 'message' in f)); + } + + async provideWelcomeMessage(location: vscode.ChatLocation, token: CancellationToken): Promise<(string | IMarkdownString)[] | undefined> { + if (!this._welcomeMessageProvider) { + return []; + } + const content = await this._welcomeMessageProvider.provideWelcomeMessage(location, token); + if (!content) { + return []; + } + return content.map(item => { + if (typeof item === 'string') { + return item; + } else { + return typeConvert.MarkdownString.from(item); + } + }); + } + + async provideTitle(context: vscode.ChatContext, token: CancellationToken): Promise { + if (!this._titleProvider) { + return; + } + + return await this._titleProvider.provideChatTitle(context, token) ?? undefined; + } + + async provideSampleQuestions(location: vscode.ChatLocation, token: CancellationToken): Promise { + if (!this._welcomeMessageProvider || !this._welcomeMessageProvider.provideSampleQuestions) { + return []; + } + const content = await this._welcomeMessageProvider.provideSampleQuestions(location, token); + if (!content) { + return []; + } + + return content; + } + + get apiAgent(): vscode.AideSessionAgent { + let disposed = false; + let updateScheduled = false; + const updateMetadataSoon = () => { + if (disposed) { + return; + } + if (updateScheduled) { + return; + } + updateScheduled = true; + queueMicrotask(() => { + this._proxy.$updateAgent(this._handle, { + icon: !this._iconPath ? undefined : + this._iconPath instanceof URI ? this._iconPath : + 'light' in this._iconPath ? this._iconPath.light : + undefined, + iconDark: !this._iconPath ? undefined : + 'dark' in this._iconPath ? this._iconPath.dark : + undefined, + themeIcon: this._iconPath instanceof extHostTypes.ThemeIcon ? this._iconPath : undefined, + hasFollowups: this._followupProvider !== undefined, + isSecondary: this._isSecondary, + helpTextPrefix: (!this._helpTextPrefix || typeof this._helpTextPrefix === 'string') ? this._helpTextPrefix : typeConvert.MarkdownString.from(this._helpTextPrefix), + helpTextVariablesPrefix: (!this._helpTextVariablesPrefix || typeof this._helpTextVariablesPrefix === 'string') ? this._helpTextVariablesPrefix : typeConvert.MarkdownString.from(this._helpTextVariablesPrefix), + helpTextPostfix: (!this._helpTextPostfix || typeof this._helpTextPostfix === 'string') ? this._helpTextPostfix : typeConvert.MarkdownString.from(this._helpTextPostfix), + supportIssueReporting: this._supportIssueReporting, + requester: this._requester, + supportsSlowVariables: this._supportsSlowReferences, + }); + updateScheduled = false; + }); + }; + + const that = this; + return { + get id() { + return that.id; + }, + get iconPath() { + return that._iconPath; + }, + set iconPath(v) { + that._iconPath = v; + updateMetadataSoon(); + }, + get requestHandler() { + return that._requestHandler; + }, + set requestHandler(v) { + assertType(typeof v === 'function', 'Invalid request handler'); + that._requestHandler = v; + }, + get initResponse() { + return that._initResponse; + }, + get followupProvider() { + return that._followupProvider; + }, + set followupProvider(v) { + that._followupProvider = v; + updateMetadataSoon(); + }, + get helpTextPrefix() { + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); + return that._helpTextPrefix; + }, + set helpTextPrefix(v) { + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); + that._helpTextPrefix = v; + updateMetadataSoon(); + }, + get helpTextVariablesPrefix() { + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); + return that._helpTextVariablesPrefix; + }, + set helpTextVariablesPrefix(v) { + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); + that._helpTextVariablesPrefix = v; + updateMetadataSoon(); + }, + get helpTextPostfix() { + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); + return that._helpTextPostfix; + }, + set helpTextPostfix(v) { + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); + that._helpTextPostfix = v; + updateMetadataSoon(); + }, + get isSecondary() { + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); + return that._isSecondary; + }, + set isSecondary(v) { + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); + that._isSecondary = v; + updateMetadataSoon(); + }, + get supportIssueReporting() { + checkProposedApiEnabled(that.extension, 'chatParticipantPrivate'); + return that._supportIssueReporting; + }, + set supportIssueReporting(v) { + checkProposedApiEnabled(that.extension, 'chatParticipantPrivate'); + that._supportIssueReporting = v; + updateMetadataSoon(); + }, + get onDidReceiveFeedback() { + return that._onDidReceiveFeedback.event; + }, + set participantVariableProvider(v) { + checkProposedApiEnabled(that.extension, 'chatParticipantAdditions'); + that._agentVariableProvider = v; + if (v) { + if (!v.triggerCharacters.length) { + throw new Error('triggerCharacters are required'); + } + + that._proxy.$registerAgentCompletionsProvider(that._handle, that.id, v.triggerCharacters); + } else { + that._proxy.$unregisterAgentCompletionsProvider(that._handle, that.id); + } + }, + get participantVariableProvider() { + checkProposedApiEnabled(that.extension, 'chatParticipantAdditions'); + return that._agentVariableProvider; + }, + set welcomeMessageProvider(v) { + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); + that._welcomeMessageProvider = v; + updateMetadataSoon(); + }, + get welcomeMessageProvider() { + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); + return that._welcomeMessageProvider; + }, + set titleProvider(v) { + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); + that._titleProvider = v; + updateMetadataSoon(); + }, + get titleProvider() { + checkProposedApiEnabled(that.extension, 'defaultChatParticipant'); + return that._titleProvider; + }, + onDidPerformAction: !isProposedApiEnabled(this.extension, 'chatParticipantAdditions') + ? undefined! + : this._onDidPerformAction.event + , + set requester(v) { + that._requester = v; + updateMetadataSoon(); + }, + get requester() { + return that._requester; + }, + set supportsSlowReferences(v) { + checkProposedApiEnabled(that.extension, 'chatParticipantPrivate'); + that._supportsSlowReferences = v; + updateMetadataSoon(); + }, + get supportsSlowReferences() { + checkProposedApiEnabled(that.extension, 'chatParticipantPrivate'); + return that._supportsSlowReferences; + }, + dispose() { + disposed = true; + that._followupProvider = undefined; + that._onDidReceiveFeedback.dispose(); + that._proxy.$unregisterAgent(that._handle); + }, + } satisfies vscode.AideSessionAgent; + } + + invoke(request: vscode.AideAgentRequest, token: CancellationToken): vscode.ProviderResult { + return this._requestHandler(request, token); + } +} diff --git a/src/vs/workbench/api/common/extHostAideAgentCodeMapper.ts b/src/vs/workbench/api/common/extHostAideAgentCodeMapper.ts new file mode 100644 index 00000000000..02b31945fb7 --- /dev/null +++ b/src/vs/workbench/api/common/extHostAideAgentCodeMapper.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; +import { ICodeMapperResult } from '../../contrib/aideAgent/common/aideAgentCodeMapperService.js'; +import * as extHostProtocol from './extHost.protocol.js'; +import { TextEdit } from './extHostTypeConverters.js'; +import { URI } from '../../../base/common/uri.js'; + +export class ExtHostAideAgentCodeMapper implements extHostProtocol.ExtHostCodeMapperShape { + + private static _providerHandlePool: number = 0; + private readonly _proxy: extHostProtocol.MainThreadCodeMapperShape; + private readonly providers = new Map(); + + constructor( + mainContext: extHostProtocol.IMainContext + ) { + this._proxy = mainContext.getProxy(extHostProtocol.MainContext.MainThreadAideAgentCodeMapper); + } + + async $mapCode(handle: number, internalRequest: extHostProtocol.ICodeMapperRequestDto, token: CancellationToken): Promise { + // Received request to map code from the main thread + const provider = this.providers.get(handle); + if (!provider) { + throw new Error(`Received request to map code for unknown provider handle ${handle}`); + } + + // Construct a response object to pass to the provider + const stream: vscode.MappedEditsResponseStream = { + textEdit: (target: vscode.Uri, edits: vscode.TextEdit | vscode.TextEdit[]) => { + edits = (Array.isArray(edits) ? edits : [edits]); + this._proxy.$handleProgress(internalRequest.requestId, { + uri: target, + edits: edits.map(TextEdit.from) + }); + } + }; + + const request: vscode.MappedEditsRequest = { + codeBlocks: internalRequest.codeBlocks.map(block => { + return { + code: block.code, + resource: URI.revive(block.resource) + }; + }), + conversation: internalRequest.conversation + }; + + const result = await provider.provideMappedEdits(request, stream, token); + return result ?? null; + } + + registerMappedEditsProvider(extension: IExtensionDescription, provider: vscode.MappedEditsProvider2): vscode.Disposable { + const handle = ExtHostAideAgentCodeMapper._providerHandlePool++; + this._proxy.$registerCodeMapperProvider(handle); + this.providers.set(handle, provider); + return { + dispose: () => { + return this._proxy.$unregisterCodeMapperProvider(handle); + } + }; + } +} diff --git a/src/vs/workbench/api/common/extHostAideAgentProvider.ts b/src/vs/workbench/api/common/extHostAideAgentProvider.ts deleted file mode 100644 index e6ca26d52de..00000000000 --- a/src/vs/workbench/api/common/extHostAideAgentProvider.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 * as vscode from 'vscode'; -import { raceCancellation } from '../../../base/common/async.js'; -import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; -import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; -import { IAgentTriggerComplete } from '../../contrib/aideAgent/common/aideAgent.js'; -import { IAgentTriggerPayload } from '../../contrib/aideAgent/common/aideAgentModel.js'; -import { ExtHostAideAgentProviderShape, IAideAgentProgressDto, IMainContext, MainContext, MainThreadAideAgentProviderShape } from './extHost.protocol.js'; -import * as typeConvert from './extHostTypeConverters.js'; -import * as extHostTypes from './extHostTypes.js'; - -class AideAgentResponseStream { - private _isClosed: boolean = false; - private _apiObject: vscode.AgentResponseStream | undefined; - - constructor( - private readonly _request: IAgentTriggerPayload, - private readonly _proxy: MainThreadAideAgentProviderShape, - ) { } - - close() { - this._isClosed = true; - } - - get apiObject() { - if (!this._apiObject) { - const that = this; - - function throwIfDone(source: Function | undefined) { - if (that._isClosed) { - const err = new Error('Response stream has been closed'); - Error.captureStackTrace(err, source); - throw err; - } - } - - const _report = (progress: IAideAgentProgressDto, task?: (progress: vscode.Progress) => Thenable) => { - if (task) { - const progressReporterPromise = this._proxy.$handleProgress(this._request.id, progress); - const progressReporter = { - report: (p: vscode.ChatResponseWarningPart) => { - progressReporterPromise?.then((handle) => { - if (handle) { - if (extHostTypes.MarkdownString.isMarkdownString(p.value)) { - this._proxy.$handleProgress(this._request.id, typeConvert.ChatResponseWarningPart.from(p), handle); - } - } - }); - } - }; - - Promise.all([progressReporterPromise, task?.(progressReporter)]).then(([handle, res]) => { - if (handle !== undefined && res !== undefined) { - this._proxy.$handleProgress(this._request.id, typeConvert.ChatTaskResult.from(res), handle); - } - }); - } else { - this._proxy.$handleProgress(this._request.id, progress); - } - }; - - this._apiObject = { - markdown(value) { - throwIfDone(this.markdown); - const part = new extHostTypes.ChatResponseMarkdownPart(value); - const dto = typeConvert.ChatResponseMarkdownPart.from(part); - _report(dto); - return this; - }, - async codeEdit(value) { - throwIfDone(this.codeEdit); - // TODO(@ghostwriternr): Implement codeEdit - return this; - }, - }; - } - - return this._apiObject; - } -} - -export class ExtHostAideAgentProvider extends Disposable implements ExtHostAideAgentProviderShape { - private static _idPool = 0; - - private readonly _providers = new Map(); - private readonly _proxy: MainThreadAideAgentProviderShape; - - constructor( - mainContext: IMainContext - ) { - super(); - this._proxy = mainContext.getProxy(MainContext.MainThreadAideAgentProvider); - } - - async $trigger(handle: number, request: IAgentTriggerPayload, token: vscode.CancellationToken): Promise { - const provider = this._providers.get(handle); - if (!provider) { - return; - } - - const stream = new AideAgentResponseStream(request, this._proxy); - try { - const task = provider.provider.provideTriggerResponse(request, stream.apiObject, token); - - return await raceCancellation(Promise.resolve(task).then((result) => { - return { - errorDetails: result?.errorDetails - } satisfies IAgentTriggerComplete; - }), token); - } catch (err) { - return { errorDetails: err.message } satisfies IAgentTriggerComplete; - } finally { - stream.close(); - } - } - - registerAgentprovider(extension: IExtensionDescription, id: string, provider: vscode.AideAgentProvider): IDisposable { - const handle = ExtHostAideAgentProvider._idPool++; - this._providers.set(handle, { extension, provider }); - this._proxy.$registerAideAgentProvider(handle); - - return toDisposable(() => { - this._proxy.$unregisterAideAgentProvider(handle); - this._providers.delete(handle); - }); - } -} diff --git a/src/vs/workbench/api/common/extHostAideAgentVariables.ts b/src/vs/workbench/api/common/extHostAideAgentVariables.ts new file mode 100644 index 00000000000..28d52357c67 --- /dev/null +++ b/src/vs/workbench/api/common/extHostAideAgentVariables.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { onUnexpectedExternalError } from '../../../base/common/errors.js'; +import { IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; +import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; +import { ExtHostChatVariablesShape, IChatVariableResolverProgressDto, IMainContext, MainContext, MainThreadChatVariablesShape } from './extHost.protocol.js'; +import * as typeConvert from './extHostTypeConverters.js'; +import * as extHostTypes from './extHostTypes.js'; +import { IChatRequestVariableValue, IChatVariableData } from '../../contrib/aideAgent/common/aideAgentVariables.js'; +import { checkProposedApiEnabled } from '../../services/extensions/common/extensions.js'; +import type * as vscode from 'vscode'; + +export class ExtHostAideAgentVariables implements ExtHostChatVariablesShape { + + private static _idPool = 0; + + private readonly _resolver = new Map(); + private readonly _proxy: MainThreadChatVariablesShape; + + constructor(mainContext: IMainContext) { + this._proxy = mainContext.getProxy(MainContext.MainThreadAideAgentVariables); + } + + async $resolveVariable(handle: number, requestId: string, messageText: string, token: CancellationToken): Promise { + const item = this._resolver.get(handle); + if (!item) { + return undefined; + } + try { + if (item.resolver.resolve2) { + checkProposedApiEnabled(item.extension, 'chatParticipantAdditions'); + const stream = new ChatVariableResolverResponseStream(requestId, this._proxy); + const value = await item.resolver.resolve2(item.data.name, { prompt: messageText }, stream.apiObject, token); + + // Temp, ignoring other returned values to convert the array into a single value + if (value && value[0]) { + return value[0].value; + } + } else { + const value = await item.resolver.resolve(item.data.name, { prompt: messageText }, token); + if (value && value[0]) { + return value[0].value; + } + } + } catch (err) { + onUnexpectedExternalError(err); + } + return undefined; + } + + registerVariableResolver(extension: IExtensionDescription, id: string, name: string, userDescription: string, modelDescription: string | undefined, isSlow: boolean | undefined, resolver: vscode.ChatVariableResolver, fullName?: string, themeIconId?: string): IDisposable { + const handle = ExtHostAideAgentVariables._idPool++; + const icon = themeIconId ? ThemeIcon.fromId(themeIconId) : undefined; + this._resolver.set(handle, { extension, data: { id, name, description: userDescription, modelDescription, icon }, resolver: resolver }); + this._proxy.$registerVariable(handle, { id, name, description: userDescription, modelDescription, isSlow, fullName, icon }); + + return toDisposable(() => { + this._resolver.delete(handle); + this._proxy.$unregisterVariable(handle); + }); + } +} + +class ChatVariableResolverResponseStream { + + private _isClosed: boolean = false; + private _apiObject: vscode.ChatVariableResolverResponseStream | undefined; + + constructor( + private readonly _requestId: string, + private readonly _proxy: MainThreadChatVariablesShape, + ) { } + + close() { + this._isClosed = true; + } + + get apiObject() { + if (!this._apiObject) { + const that = this; + + function throwIfDone(source: Function | undefined) { + if (that._isClosed) { + const err = new Error('Response stream has been closed'); + Error.captureStackTrace(err, source); + throw err; + } + } + + const _report = (progress: IChatVariableResolverProgressDto) => { + this._proxy.$handleProgressChunk(this._requestId, progress); + }; + + this._apiObject = { + progress(value) { + throwIfDone(this.progress); + const part = new extHostTypes.ChatResponseProgressPart(value); + const dto = typeConvert.ChatResponseProgressPart.from(part); + _report(dto); + return this; + }, + reference(value) { + throwIfDone(this.reference); + const part = new extHostTypes.ChatResponseReferencePart(value); + const dto = typeConvert.ChatResponseReferencePart.from(part); + _report(dto); + return this; + }, + push(part) { + throwIfDone(this.push); + + if (part instanceof extHostTypes.ChatResponseReferencePart) { + _report(typeConvert.ChatResponseReferencePart.from(part)); + } else if (part instanceof extHostTypes.ChatResponseProgressPart) { + _report(typeConvert.ChatResponseProgressPart.from(part)); + } + + return this; + } + }; + } + + return this._apiObject; + } +} diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 21b977f6139..13d6e2b0511 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import type * as vscode from 'vscode'; import { asArray, coalesce, isNonEmptyArray } from '../../../base/common/arrays.js'; import { VSBuffer, encodeBase64 } from '../../../base/common/buffer.js'; import { IDataTransferFile, IDataTransferItem, UriList } from '../../../base/common/dataTransfer.js'; @@ -33,11 +34,11 @@ import { ITextEditorOptions } from '../../../platform/editor/common/editor.js'; import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { IMarkerData, IRelatedInformation, MarkerSeverity, MarkerTag } from '../../../platform/markers/common/markers.js'; import { ProgressLocation as MainProgressLocation } from '../../../platform/progress/common/progress.js'; -import * as extHostProtocol from './extHost.protocol.js'; -import { CommandsConverter } from './extHostCommands.js'; -import { getPrivateApiFor } from './extHostTestingPrivateApi.js'; import { DEFAULT_EDITOR_ASSOCIATION, SaveReason } from '../../common/editor.js'; import { IViewBadge } from '../../common/views.js'; +import { IChatAgentRequest as IAideAgentRequest } from '../../contrib/aideAgent/common/aideAgentAgents.js'; +import { AgentMode } from '../../contrib/aideAgent/common/aideAgentModel.js'; +import { IChatEndResponse } from '../../contrib/aideAgent/common/aideAgentService.js'; import { ChatAgentLocation, IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/chatAgents.js'; import { IChatRequestVariableEntry } from '../../contrib/chat/common/chatModel.js'; import { IChatAgentDetection, IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatFollowup, IChatMarkdownContent, IChatMoveMessage, IChatProgressMessage, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTextEdit, IChatTreeData, IChatUserActionEvent, IChatWarningMessage } from '../../contrib/chat/common/chatService.js'; @@ -52,7 +53,9 @@ import { CoverageDetails, DetailType, ICoverageCount, IFileCoverage, ISerialized import { EditorGroupColumn } from '../../services/editor/common/editorGroupColumn.js'; import { ACTIVE_GROUP, SIDE_GROUP } from '../../services/editor/common/editorService.js'; import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; -import type * as vscode from 'vscode'; +import * as extHostProtocol from './extHost.protocol.js'; +import { CommandsConverter } from './extHostCommands.js'; +import { getPrivateApiFor } from './extHostTestingPrivateApi.js'; import * as types from './extHostTypes.js'; export namespace Command { @@ -2880,6 +2883,40 @@ export namespace LanguageModelToolResult { ///////////////////////////// END CHAT ///////////////////////////// ///////////////////////////// START AIDE ///////////////////////////// +export namespace AideAgentMode { + export function to(mode: AgentMode): types.AideAgentMode { + switch (mode) { + case AgentMode.Edit: return types.AideAgentMode.Edit; + case AgentMode.Chat: return types.AideAgentMode.Chat; + } + } + + export function from(mode: types.AideAgentMode): AgentMode { + switch (mode) { + case types.AideAgentMode.Edit: return AgentMode.Edit; + case types.AideAgentMode.Chat: return AgentMode.Chat; + } + } +} + +export namespace AideAgentRequest { + export function to(request: IAideAgentRequest, location2: vscode.ChatRequestEditorData | vscode.ChatRequestNotebookData | undefined): vscode.AideAgentRequest { + const chatAgentRequest = ChatAgentRequest.to(request, location2); + return { + ...chatAgentRequest, + id: request.requestId, + mode: AideAgentMode.to(request.mode), + }; + } +} + +export namespace ChatResponseClosePart { + export function from(): Dto { + return { + kind: 'endResponse', + }; + } +} ///////////////////////////// END AIDE ///////////////////////////// export namespace TerminalQuickFix { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 909269a05a7..c03047e91af 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -4689,6 +4689,10 @@ export class LanguageModelError extends Error { //#endregion //#region AideChat +export enum AideAgentMode { + Edit = 1, + Chat = 2, +} //#endregion //#region ai diff --git a/src/vs/workbench/browser/aideSelect.ts b/src/vs/workbench/browser/aideSelect.ts deleted file mode 100644 index a210a9e9ba8..00000000000 --- a/src/vs/workbench/browser/aideSelect.ts +++ /dev/null @@ -1,184 +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 dom from '../../base/browser/dom.js'; -import { IListRenderer, IListVirtualDelegate } from '../../base/browser/ui/list/list.js'; -import { Emitter, Event } from '../../base/common/event.js'; -import { Disposable, DisposableStore, dispose, IDisposable } from '../../base/common/lifecycle.js'; -import { IInstantiationService } from '../../platform/instantiation/common/instantiation.js'; -import { WorkbenchList } from '../../platform/list/browser/listService.js'; - -const $ = dom.$; - -interface ChangeOptionEvent { - index: number; - element: T; -} - -export interface RenderItemFn { - (container: HTMLElement, item: T): IDisposable[]; -} - -export class AideSelect extends Disposable { - - private _focusIndex: number | undefined; - get focusIndex() { return this._focusIndex; } - defaultItemHeight = 36; - maxHeight = Number.POSITIVE_INFINITY; - readonly list: WorkbenchList; - - private readonly _onDidChangeFocus = this._register(new Emitter>()); - readonly onDidChangeFocus = this._onDidChangeFocus.event; - - private readonly _onDidSelect = this._register(new Emitter>()); - readonly onDidSelect = this._onDidSelect.event; - - constructor(panel: HTMLElement, renderItem: RenderItemFn, @IInstantiationService private readonly instantiationService: IInstantiationService) { - - super(); - // List - - const renderer = this.instantiationService.createInstance(OptionRenderer, renderItem, this.defaultItemHeight); - const listDelegate = this.instantiationService.createInstance(ItemListDelegate, this.defaultItemHeight); - const list = this.list = this._register(>this.instantiationService.createInstance( - WorkbenchList, - 'AideSelect', - panel, - listDelegate, - [renderer], - { - setRowLineHeight: false, - supportDynamicHeights: true, - horizontalScrolling: false, - alwaysConsumeMouseWheel: false - } - )); - - this._register(list.onDidChangeContentHeight(height => { - //console.log('onDidChangeContentHeight', height); - const newHeight = Math.min(height, this.maxHeight); - list.layout(newHeight); - })); - this._register(renderer.onDidChangeItemHeight(event => { - //console.log('onDidChangeItemHeight', event); - list.updateElementHeight(event.index, event.height); - })); - this._register(list.onDidChangeFocus(event => { - //console.log('onDidChangeFocus', event); - if (event.indexes.length === 1) { - const index = event.indexes[0]; - list.setSelection([index]); - const element = list.element(index); - - this._onDidChangeFocus.fire({ index, element }); - - if (event.browserEvent) { - this._focusIndex = index; - } - } - })); - this._register(list.onDidOpen(event => { - if (this._focusIndex !== undefined && event.element) { - this._onDidSelect.fire({ index: this._focusIndex, element: event.element }); - } - })); - } -} - - -interface ITemplateData { - currentItem?: T; - currentItemIndex?: number; - currentRenderedHeight: number; - container: HTMLElement; - toDispose: DisposableStore; -} - -interface IItemHeightChangeParams { - element: T; - index: number; - height: number; -} - -class OptionRenderer extends Disposable implements IListRenderer> { - static readonly TEMPLATE_ID = 'aideOptionTemplate'; - - protected readonly _onDidChangeItemHeight = this._register(new Emitter>()); - readonly onDidChangeItemHeight: Event> = this._onDidChangeItemHeight.event; - - constructor( - private readonly renderItem: RenderItemFn, - private readonly defaultItemHeight: number - ) { - super(); - } - - get templateId(): string { - return OptionRenderer.TEMPLATE_ID; - } - - renderTemplate(container: HTMLElement): ITemplateData { - const data: ITemplateData = Object.create(null); - data.toDispose = new DisposableStore(); - data.container = dom.append(container, $('.aide-option-item')); - return data; - } - - renderElement(element: T, index: number, templateData: ITemplateData): void { - - templateData.currentItem = element; - templateData.currentItemIndex = index; - dom.clearNode(templateData.container); - - const disposables = this.renderItem(templateData.container, element); - function addDisposable(d: IDisposable) { - templateData.toDispose.add(d); - } - - disposables.forEach(addDisposable); - this.updateItemHeight(templateData); - } - - disposeTemplate(templateData: ITemplateData): void { - dispose(templateData.toDispose); - } - - private updateItemHeight(templateData: ITemplateData): void { - if (!templateData.currentItem || typeof templateData.currentItemIndex !== 'number') { - return; - } - - const { currentItem: element, currentItemIndex: index } = templateData; - - const newHeight = templateData.container.offsetHeight || this.defaultItemHeight; - const shouldFireEvent = !templateData.currentRenderedHeight || templateData.currentRenderedHeight !== newHeight; - templateData.currentRenderedHeight = newHeight; - if (shouldFireEvent) { - const disposable = templateData.toDispose.add(dom.scheduleAtNextAnimationFrame(dom.getWindow(templateData.container), () => { - templateData.currentRenderedHeight = templateData.container.offsetHeight || this.defaultItemHeight; - disposable.dispose(); - this._onDidChangeItemHeight.fire({ element, index, height: templateData.currentRenderedHeight }); - })); - } - } -} - -class ItemListDelegate implements IListVirtualDelegate { - - constructor(private readonly defaultItemHeight: number) { } - - getHeight(element: T): number { - // Implement custom height for each element - return this.defaultItemHeight; - } - - getTemplateId(element: T): string { - return OptionRenderer.TEMPLATE_ID; - } - - hasDynamicHeight(element: T): boolean { - return true; - } -} diff --git a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts index 9bbd09479b6..f65c676b154 100644 --- a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts +++ b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts @@ -36,8 +36,10 @@ import { HiddenItemStrategy, WorkbenchToolBar } from '../../../../platform/actio import { ActionViewItem, IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { CompositeMenuActions } from '../../actions.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { ICompositeTitleLabel } from '../compositePart.js'; export class AuxiliaryBarPart extends AbstractPaneCompositePart { + override allowDroppingViews = false; static readonly activePanelSettingsKey = 'workbench.auxiliarybar.activepanelid'; static readonly pinnedPanelsKey = 'workbench.auxiliarybar.pinnedPanels'; @@ -203,8 +205,15 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { ]); } + protected override createTitleLabel(parent: HTMLElement): ICompositeTitleLabel { + const titleLabel = super.createTitleLabel(parent); + this.titleLabelElement!.draggable = false; + return titleLabel; + } + protected shouldShowCompositeBar(): boolean { - return this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION) !== ActivityBarPosition.HIDDEN; + // return this.configurationService.getValue(LayoutSettings.ACTIVITY_BAR_LOCATION) !== ActivityBarPosition.HIDDEN; + return false; } // TODO@benibenj chache this diff --git a/src/vs/workbench/browser/parts/auxiliarybar/media/auxiliaryBarPart.css b/src/vs/workbench/browser/parts/auxiliarybar/media/auxiliaryBarPart.css index 0678e18ac48..5093c6f275f 100644 --- a/src/vs/workbench/browser/parts/auxiliarybar/media/auxiliaryBarPart.css +++ b/src/vs/workbench/browser/parts/auxiliarybar/media/auxiliaryBarPart.css @@ -28,6 +28,7 @@ } .monaco-workbench .part.auxiliarybar > .title { + border-bottom: 1px solid var(--vscode-panel-border); background-color: var(--vscode-sideBarTitle-background); } diff --git a/src/vs/workbench/browser/parts/paneCompositePart.ts b/src/vs/workbench/browser/parts/paneCompositePart.ts index baef928de1a..f24c93ff8ba 100644 --- a/src/vs/workbench/browser/parts/paneCompositePart.ts +++ b/src/vs/workbench/browser/parts/paneCompositePart.ts @@ -103,6 +103,7 @@ export interface IPaneCompositePart extends IView { export abstract class AbstractPaneCompositePart extends CompositePart implements IPaneCompositePart { private static readonly MIN_COMPOSITE_BAR_WIDTH = 50; + protected allowDroppingViews: boolean = true; get snap(): boolean { // Always allow snapping closed @@ -263,11 +264,16 @@ export abstract class AbstractPaneCompositePart extends CompositePart { EventHelper.stop(e.eventData, true); diff --git a/src/vs/workbench/browser/parts/sidebar/media/sidebarpart.css b/src/vs/workbench/browser/parts/sidebar/media/sidebarpart.css index c78b9169408..cd84b255b86 100644 --- a/src/vs/workbench/browser/parts/sidebar/media/sidebarpart.css +++ b/src/vs/workbench/browser/parts/sidebar/media/sidebarpart.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ .monaco-workbench .part.sidebar { - border-left: 1px solid var(--vscode-panel-border); + /* border-left: 1px solid var(--vscode-panel-border); */ border-right: 1px solid var(--vscode-panel-border); } @@ -29,6 +29,10 @@ text-transform: uppercase; } +.monaco-workbench .part.sidebar .header { + border-bottom: 1px solid var(--vscode-panel-border); +} + .monaco-workbench .viewlet .collapsible.header .title { overflow: hidden; text-overflow: ellipsis; diff --git a/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentAccessibilityHelp.ts b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentAccessibilityHelp.ts new file mode 100644 index 00000000000..066a3779c14 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentAccessibilityHelp.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 { localize } from '../../../../../nls.js'; +import { ICodeEditor } from '../../../../../editor/browser/editorBrowser.js'; +import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { IAideAgentWidgetService } from '../aideAgent.js'; +import { AccessibleViewProviderId, AccessibleViewType, AccessibleContentProvider } from '../../../../../platform/accessibility/browser/accessibleView.js'; +import { AccessibilityVerbositySettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; +import { AccessibleDiffViewerNext } from '../../../../../editor/browser/widget/diffEditor/commands.js'; +import { INLINE_CHAT_ID } from '../../../inlineChat/common/inlineChat.js'; +import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { CONTEXT_IN_CHAT_SESSION, CONTEXT_RESPONSE, CONTEXT_REQUEST, CONTEXT_CHAT_LOCATION } from '../../common/aideAgentContextKeys.js'; +import { IAccessibleViewImplentation } from '../../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { ChatAgentLocation } from '../../common/aideAgentAgents.js'; + +export class ChatAccessibilityHelp implements IAccessibleViewImplentation { + readonly priority = 107; + readonly name = 'panelChat'; + readonly type = AccessibleViewType.Help; + readonly when = ContextKeyExpr.and(CONTEXT_CHAT_LOCATION.isEqualTo(ChatAgentLocation.Panel), ContextKeyExpr.or(CONTEXT_IN_CHAT_SESSION, CONTEXT_RESPONSE, CONTEXT_REQUEST)); + getProvider(accessor: ServicesAccessor) { + const codeEditor = accessor.get(ICodeEditorService).getActiveCodeEditor() || accessor.get(ICodeEditorService).getFocusedCodeEditor(); + return getChatAccessibilityHelpProvider(accessor, codeEditor ?? undefined, 'panelChat'); + } +} + +export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat'): string { + const content = []; + if (type === 'panelChat') { + content.push(localize('chat.overview', 'The chat view is comprised of an input box and a request/response list. The input box is used to make requests and the list is used to display responses.')); + content.push(localize('chat.requestHistory', 'In the input box, use up and down arrows to navigate your request history. Edit input and use enter or the submit button to run a new request.')); + content.push(localize('chat.inspectResponse', 'In the input box, inspect the last response in the accessible view{0}.', '')); + content.push(localize('chat.followUp', 'In the input box, navigate to the suggested follow up question (Shift+Tab) and press Enter to run it.')); + content.push(localize('chat.announcement', 'Chat responses will be announced as they come in. A response will indicate the number of code blocks, if any, and then the rest of the response.')); + content.push(localize('workbench.action.chat.focus', 'To focus the chat request/response list, which can be navigated with up and down arrows, invoke the Focus Chat command{0}.', '')); + content.push(localize('workbench.action.chat.focusInput', 'To focus the input box for chat requests, invoke the Focus Chat Input command{0}.', '')); + content.push(localize('workbench.action.chat.nextCodeBlock', 'To focus the next code block within a response, invoke the Chat: Next Code Block command{0}.', '')); + content.push(localize('workbench.action.chat.nextFileTree', 'To focus the next file tree within a response, invoke the Chat: Next File Tree command{0}.', '')); + content.push(localize('workbench.action.chat.newChat', 'To create a new session, invoke the New Session command{0}.', '')); + } else { + content.push(localize('inlineChat.overview', "Inline chat occurs within a code editor and takes into account the current selection. It is useful for making changes to the current editor. For example, fixing diagnostics, documenting or refactoring code. Keep in mind that AI generated code may be incorrect.")); + content.push(localize('inlineChat.access', "It can be activated via code actions or directly using the command: Inline Chat: Start Inline Chat{0}.", '')); + content.push(localize('inlineChat.requestHistory', 'In the input box, use Show Previous{0} and Show Next{1} to navigate your request history. Edit input and use enter or the submit button to run a new request.', '', '')); + content.push(localize('inlineChat.inspectResponse', 'In the input box, inspect the response in the accessible view{0}.', '')); + content.push(localize('inlineChat.contextActions', "Context menu actions may run a request prefixed with a /. Type / to discover such ready-made commands.")); + content.push(localize('inlineChat.fix', "If a fix action is invoked, a response will indicate the problem with the current code. A diff editor will be rendered and can be reached by tabbing.")); + content.push(localize('inlineChat.diff', "Once in the diff editor, enter review mode with{0}. Use up and down arrows to navigate lines with the proposed changes.", AccessibleDiffViewerNext.id)); + content.push(localize('inlineChat.toolbar', "Use tab to reach conditional parts like commands, status, message responses and more.")); + } + content.push(localize('chat.signals', "Accessibility Signals can be changed via settings with a prefix of signals.chat. By default, if a request takes more than 4 seconds, you will hear a sound indicating that progress is still occurring.")); + return content.join('\n'); +} + +export function getChatAccessibilityHelpProvider(accessor: ServicesAccessor, editor: ICodeEditor | undefined, type: 'panelChat' | 'inlineChat') { + const widgetService = accessor.get(IAideAgentWidgetService); + const inputEditor: ICodeEditor | undefined = type === 'panelChat' ? widgetService.lastFocusedWidget?.inputEditor : editor; + + if (!inputEditor) { + return; + } + const domNode = inputEditor.getDomNode() ?? undefined; + if (!domNode) { + return; + } + + const cachedPosition = inputEditor.getPosition(); + inputEditor.getSupportedActions(); + const helpText = getAccessibilityHelpText(type); + return new AccessibleContentProvider( + type === 'panelChat' ? AccessibleViewProviderId.Chat : AccessibleViewProviderId.InlineChat, + { type: AccessibleViewType.Help }, + () => helpText, + () => { + if (type === 'panelChat' && cachedPosition) { + inputEditor.setPosition(cachedPosition); + inputEditor.focus(); + + } else if (type === 'inlineChat') { + // TODO@jrieken find a better way for this + const ctrl = <{ focus(): void } | undefined>editor?.getContribution(INLINE_CHAT_ID); + ctrl?.focus(); + + } + }, + type === 'panelChat' ? AccessibilityVerbositySettingId.Chat : AccessibilityVerbositySettingId.InlineChat, + ); +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentActions.ts b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentActions.ts index 93028e45330..1bbdf4bd4f3 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentActions.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentActions.ts @@ -3,241 +3,348 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { coalesce } from '../../../../../base/common/arrays.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { fromNowByDay } from '../../../../../base/common/date.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; -import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; -import { localize2 } from '../../../../../nls.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { ICodeEditor } from '../../../../../editor/browser/editorBrowser.js'; +import { EditorAction2, 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 { IsLinuxContext, IsWindowsContext } from '../../../../../platform/contextkey/common/contextkeys.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { IView } from '../../../../common/views.js'; -import { AideAgentScope } from '../../common/aideAgentModel.js'; -import { IAideAgentService } from '../../common/aideAgentService.js'; -import { CONTEXT_AIDE_CONTROLS_HAS_FOCUS, CONTEXT_AIDE_CONTROLS_HAS_TEXT } from '../aideAgentContextKeys.js'; -import { IAideControlsService } from '../aideControlsService.js'; +import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; +import { clearChatEditor } from './aideAgentClear.js'; +import { CHAT_VIEW_ID, IAideAgentWidgetService, showChatView } from '../aideAgent.js'; +import { IChatEditorOptions } from '../aideAgentEditor.js'; +import { ChatEditorInput } from '../aideAgentEditorInput.js'; +import { ChatViewPane } from '../aideAgentViewPane.js'; +import { CONTEXT_CHAT_ENABLED, CONTEXT_CHAT_INPUT_CURSOR_AT_TOP, CONTEXT_IN_CHAT_INPUT, CONTEXT_IN_CHAT_SESSION } from '../../common/aideAgentContextKeys.js'; +import { IChatDetail, IAideAgentService } from '../../common/aideAgentService.js'; +import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM } from '../../common/aideAgentViewModel.js'; +import { IAideAgentWidgetHistoryService } from '../../common/aideAgentWidgetHistoryService.js'; +import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; +import { ACTIVE_GROUP, IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IViewsService } from '../../../../services/views/common/viewsService.js'; -const AIDE_AGENT_CATEGORY = localize2('aideAgentcategory', 'Aide'); - -export interface IAgentActionContext { - view?: IView; - inputValue?: string; +export interface IChatViewTitleActionContext { + chatView: ChatViewPane; } -class BlurAction extends Action2 { - static readonly ID = 'workbench.action.aideAgentblur'; +export function isChatViewTitleActionContext(obj: unknown): obj is IChatViewTitleActionContext { + return obj instanceof Object && 'chatView' in obj; +} - constructor() { - super({ - id: BlurAction.ID, - title: localize2('aideAgentblur.label', "Blur"), - f1: false, - category: AIDE_AGENT_CATEGORY, - icon: Codicon.send, - precondition: CONTEXT_AIDE_CONTROLS_HAS_FOCUS, - keybinding: { - primary: KeyCode.Escape, - weight: KeybindingWeight.WorkbenchContrib, - when: CONTEXT_AIDE_CONTROLS_HAS_FOCUS - }, - }); - } +export const CHAT_CATEGORY = localize2('aideAgent.category', 'Aide'); +export const CHAT_OPEN_ACTION_ID = 'workbench.action.aideAgent.open'; - async run(accessor: ServicesAccessor) { - const aideControls = accessor.get(IAideControlsService); - aideControls.blurInput(); - } +export interface IChatViewOpenOptions { + /** + * The query for quick chat. + */ + query: string; + /** + * Whether the query is partial and will await more input from the user. + */ + isPartialQuery?: boolean; + /** + * Any previous chat requests and responses that should be shown in the chat view. + */ + previousRequests?: IChatViewOpenRequestEntry[]; } -class SubmitAction extends Action2 { - static readonly ID = 'workbench.action.aideAgentsubmit'; +export interface IChatViewOpenRequestEntry { + request: string; + response: string; +} +class OpenChatGlobalAction extends Action2 { constructor() { super({ - id: SubmitAction.ID, - title: localize2('aideAgentsubmit.label', "Go"), + id: CHAT_OPEN_ACTION_ID, + title: localize2('openChat', "Open Chat"), + icon: Codicon.commentDiscussion, f1: false, - category: AIDE_AGENT_CATEGORY, - icon: Codicon.send, - precondition: CONTEXT_AIDE_CONTROLS_HAS_TEXT, + category: CHAT_CATEGORY, keybinding: { - primary: KeyCode.Enter, weight: KeybindingWeight.WorkbenchContrib, - when: CONTEXT_AIDE_CONTROLS_HAS_TEXT - }, - menu: [ - { - id: MenuId.AideControlsToolbar, - group: 'navigation' - }, - ] + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyI, + mac: { + primary: KeyMod.CtrlCmd | KeyMod.WinCtrl | KeyCode.KeyI + } + } }); } - async run(accessor: ServicesAccessor) { - const aideControls = accessor.get(IAideControlsService); - aideControls.acceptInput(); + override async run(accessor: ServicesAccessor, opts?: string | IChatViewOpenOptions): Promise { + opts = typeof opts === 'string' ? { query: opts } : opts; + + const chatService = accessor.get(IAideAgentService); + const chatWidget = await showChatView(accessor.get(IViewsService)); + if (!chatWidget) { + return; + } + if (opts?.previousRequests?.length && chatWidget.viewModel) { + for (const { request, response } of opts.previousRequests) { + chatService.addCompleteRequest(chatWidget.viewModel.sessionId, request, undefined, 0, { message: response }); + } + } + if (opts?.query) { + if (opts.isPartialQuery) { + chatWidget.setInput(opts.query); + } else { + chatWidget.acceptInput(opts.query); + } + } + + chatWidget.focusInput(); } } -// class CancelAction extends Action2 { -// static readonly ID = 'workbench.action.aideAgentcancel'; - -// constructor() { -// super({ -// id: CancelAction.ID, -// title: localize2('aideAgentcancel.label', "Cancel"), -// f1: false, -// category: PROBE_CATEGORY, -// icon: Codicon.x, -// precondition: isProbeInProgress, -// keybinding: { -// primary: KeyCode.Escape, -// weight: KeybindingWeight.WorkbenchContrib, -// when: CTX.CONTEXT_PROBE_INPUT_HAS_FOCUS -// }, -// menu: [ -// { -// id: MenuId.AideControlsToolbar, -// group: 'navigation', -// when: isProbeInProgress, -// } -// ] -// }); -// } - -// async run(accessor: ServicesAccessor, ...args: any[]) { -// const aideProbeService = accessor.get(IAideProbeService); -// aideProbeService.rejectCodeEdits(); -// aideProbeService.clearSession(); -// } -// } - -// class ClearIterationAction extends Action2 { -// static readonly ID = 'workbench.action.aideAgentstop'; - -// constructor() { -// super({ -// id: ClearIterationAction.ID, -// title: localize2('aideAgentstop.label', "Clear"), -// f1: false, -// category: PROBE_CATEGORY, -// icon: Codicon.send, -// precondition: isProbeIterationFinished, -// keybinding: { -// primary: KeyMod.WinCtrl | KeyCode.KeyL, -// weight: KeybindingWeight.WorkbenchContrib, -// when: CTX.CONTEXT_PROBE_INPUT_HAS_FOCUS, -// }, -// menu: [ -// { -// id: MenuId.AideControlsToolbar, -// group: 'navigation', -// when: isProbeIterationFinished, -// }, -// ] -// }); -// } - -// async run(accessor: ServicesAccessor) { -// const aideProbeService = accessor.get(IAideProbeService); -// aideProbeService.clearSession(); -// } -// } - -class FocusAideControls extends Action2 { - static readonly ID = 'workbench.action.aideAgentfocus'; - +class ChatHistoryAction extends Action2 { constructor() { super({ - id: FocusAideControls.ID, - title: localize2('aideAgentfocus.label', "Focus Aide Controls"), - f1: false, - category: AIDE_AGENT_CATEGORY, - keybinding: { - primary: KeyMod.CtrlCmd | KeyCode.KeyK, - weight: KeybindingWeight.WorkbenchContrib + 1, + id: `workbench.action.aideAgent.history`, + title: localize2('chat.history.label', "Show Sessions..."), + menu: { + id: MenuId.ViewTitle, + when: ContextKeyExpr.equals('view', CHAT_VIEW_ID), + group: 'navigation', + order: 2 }, + category: CHAT_CATEGORY, + icon: Codicon.history, + f1: true, + precondition: CONTEXT_CHAT_ENABLED }); } async run(accessor: ServicesAccessor) { - const aideControlsService = accessor.get(IAideControlsService); - aideControlsService.focusInput(); - } -} + const chatService = accessor.get(IAideAgentService); + const quickInputService = accessor.get(IQuickInputService); + const viewsService = accessor.get(IViewsService); + const editorService = accessor.get(IEditorService); -export class SetAideAgentScopeSelection extends Action2 { - static readonly ID = 'workbench.action.aideAgentsetScopeSelection'; + const showPicker = () => { + const openInEditorButton: IQuickInputButton = { + iconClass: ThemeIcon.asClassName(Codicon.file), + tooltip: localize('interactiveSession.history.editor', "Open in Editor"), + }; + const deleteButton: IQuickInputButton = { + iconClass: ThemeIcon.asClassName(Codicon.x), + tooltip: localize('interactiveSession.history.delete', "Delete"), + }; + const renameButton: IQuickInputButton = { + iconClass: ThemeIcon.asClassName(Codicon.pencil), + tooltip: localize('chat.history.rename', "Rename"), + }; - constructor() { - super({ - id: SetAideAgentScopeSelection.ID, - title: localize2('aideAgentsetScopeSelection.label', "Use selection range as the scope for AI edits"), - f1: false, - category: AIDE_AGENT_CATEGORY, - keybinding: { - primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Digit1, - weight: KeybindingWeight.WorkbenchContrib - }, - }); - } + interface IChatPickerItem extends IQuickPickItem { + chat: IChatDetail; + } - async run(accessor: ServicesAccessor) { - const aideAgentService = accessor.get(IAideAgentService); - aideAgentService.scope = AideAgentScope.Selection; + const getPicks = () => { + const items = chatService.getHistory(); + items.sort((a, b) => (b.lastMessageDate ?? 0) - (a.lastMessageDate ?? 0)); + + let lastDate: string | undefined = undefined; + const picks = items.flatMap((i): [IQuickPickSeparator | undefined, IChatPickerItem] => { + const timeAgoStr = fromNowByDay(i.lastMessageDate, true, true); + const separator: IQuickPickSeparator | undefined = timeAgoStr !== lastDate ? { + type: 'separator', label: timeAgoStr, + } : undefined; + lastDate = timeAgoStr; + return [ + separator, + { + label: i.title, + description: i.isActive ? `(${localize('currentChatLabel', 'current')})` : '', + chat: i, + buttons: i.isActive ? [renameButton] : [ + renameButton, + openInEditorButton, + deleteButton, + ] + } + ]; + }); + + return coalesce(picks); + }; + + const store = new DisposableStore(); + const picker = store.add(quickInputService.createQuickPick({ useSeparators: true })); + picker.placeholder = localize('interactiveSession.history.pick', "Switch to chat"); + const picks = getPicks(); + picker.items = picks; + store.add(picker.onDidTriggerItemButton(async context => { + if (context.button === openInEditorButton) { + const options: IChatEditorOptions = { target: { sessionId: context.item.chat.sessionId }, pinned: true }; + editorService.openEditor({ resource: ChatEditorInput.getNewEditorUri(), options }, ACTIVE_GROUP); + picker.hide(); + } else if (context.button === deleteButton) { + chatService.removeHistoryEntry(context.item.chat.sessionId); + picker.items = getPicks(); + } else if (context.button === renameButton) { + const title = await quickInputService.input({ title: localize('newChatTitle', "New session title"), value: context.item.chat.title }); + if (title) { + chatService.setChatSessionTitle(context.item.chat.sessionId, title); + } + + // The quick input hides the picker, it gets disposed, so we kick it off from scratch + showPicker(); + } + })); + store.add(picker.onDidAccept(async () => { + try { + const item = picker.selectedItems[0]; + const sessionId = item.chat.sessionId; + const view = await viewsService.openView(CHAT_VIEW_ID) as ChatViewPane; + view.loadSession(sessionId); + } finally { + picker.hide(); + } + })); + store.add(picker.onDidHide(() => store.dispose())); + + picker.show(); + }; + showPicker(); } } -export class SetAideAgentScopePinnedContext extends Action2 { - static readonly ID = 'workbench.action.aideAgentsetScopePinnedContext'; - +class OpenChatEditorAction extends Action2 { constructor() { super({ - id: SetAideAgentScopePinnedContext.ID, - title: localize2('aideAgentsetScopePinnedContext.label', "Use Pinned Context as the scope for AI edits"), - f1: false, - category: AIDE_AGENT_CATEGORY, - keybinding: { - primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Digit2, - weight: KeybindingWeight.WorkbenchContrib - }, + id: `workbench.action.openaideAgent`, + title: localize2('interactiveSession.open', "Open Editor"), + f1: true, + category: CHAT_CATEGORY, + precondition: CONTEXT_CHAT_ENABLED }); } async run(accessor: ServicesAccessor) { - const aideAgentService = accessor.get(IAideAgentService); - aideAgentService.scope = AideAgentScope.PinnedContext; + const editorService = accessor.get(IEditorService); + await editorService.openEditor({ resource: ChatEditorInput.getNewEditorUri(), options: { pinned: true } satisfies IChatEditorOptions }); } } -export class SetAideAgentScopeWholeCodebase extends Action2 { - static readonly ID = 'workbench.action.aideAgentsetScopeWholeCodebase'; +export function registerChatActions() { + registerAction2(OpenChatGlobalAction); + registerAction2(ChatHistoryAction); + registerAction2(OpenChatEditorAction); - constructor() { - super({ - id: SetAideAgentScopeWholeCodebase.ID, - title: localize2('aideAgentsetScopeWholeCodebase.label', "Use the whole codebase as the scope for AI edits"), - f1: false, - category: AIDE_AGENT_CATEGORY, - keybinding: { - primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Digit3, - weight: KeybindingWeight.WorkbenchContrib - }, - }); - } + registerAction2(class ClearChatInputHistoryAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.aideAgent.clearInputHistory', + title: localize2('interactiveSession.clearHistory.label', "Clear Input History"), + precondition: CONTEXT_CHAT_ENABLED, + category: CHAT_CATEGORY, + f1: true, + }); + } + async run(accessor: ServicesAccessor, ..._args: any[]) { + const historyService = accessor.get(IAideAgentWidgetHistoryService); + historyService.clearHistory(); + } + }); - async run(accessor: ServicesAccessor) { - const aideAgentService = accessor.get(IAideAgentService); - aideAgentService.scope = AideAgentScope.WholeCodebase; - } + registerAction2(class ClearChatHistoryAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.aideAgent.clearHistory', + title: localize2('chat.clear.label', "Clear All Workspace Chats"), + precondition: CONTEXT_CHAT_ENABLED, + category: CHAT_CATEGORY, + f1: true, + }); + } + async run(accessor: ServicesAccessor, ...args: any[]) { + const editorGroupsService = accessor.get(IEditorGroupsService); + const viewsService = accessor.get(IViewsService); + + const chatService = accessor.get(IAideAgentService); + chatService.clearAllHistoryEntries(); + + const chatView = viewsService.getViewWithId(CHAT_VIEW_ID) as ChatViewPane | undefined; + if (chatView) { + chatView.widget.clear(); + } + + // Clear all chat editors. Have to go this route because the chat editor may be in the background and + // not have a ChatEditorInput. + editorGroupsService.groups.forEach(group => { + group.editors.forEach(editor => { + if (editor instanceof ChatEditorInput) { + clearChatEditor(accessor, editor); + } + }); + }); + } + }); + + registerAction2(class FocusChatAction extends EditorAction2 { + constructor() { + super({ + id: 'aideAgent.action.focus', + title: localize2('actions.interactiveSession.focus', 'Focus Chat List'), + precondition: ContextKeyExpr.and(CONTEXT_IN_CHAT_INPUT), + category: CHAT_CATEGORY, + keybinding: [ + // On mac, require that the cursor is at the top of the input, to avoid stealing cmd+up to move the cursor to the top + { + when: CONTEXT_CHAT_INPUT_CURSOR_AT_TOP, + primary: KeyMod.CtrlCmd | KeyCode.UpArrow, + weight: KeybindingWeight.EditorContrib, + }, + // On win/linux, ctrl+up can always focus the chat list + { + when: ContextKeyExpr.or(IsWindowsContext, IsLinuxContext), + primary: KeyMod.CtrlCmd | KeyCode.UpArrow, + weight: KeybindingWeight.EditorContrib, + } + ] + }); + } + + runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor): void | Promise { + const editorUri = editor.getModel()?.uri; + if (editorUri) { + const widgetService = accessor.get(IAideAgentWidgetService); + widgetService.getWidgetByInputUri(editorUri)?.focusLastMessage(); + } + } + }); + + registerAction2(class FocusChatInputAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.aideAgent.focusInput', + title: localize2('interactiveSession.focusInput.label', "Focus Chat Input"), + f1: false, + keybinding: { + primary: KeyMod.CtrlCmd | KeyCode.DownArrow, + weight: KeybindingWeight.WorkbenchContrib, + when: ContextKeyExpr.and(CONTEXT_IN_CHAT_SESSION, CONTEXT_IN_CHAT_INPUT.negate()) + } + }); + } + run(accessor: ServicesAccessor, ...args: any[]) { + const widgetService = accessor.get(IAideAgentWidgetService); + widgetService.lastFocusedWidget?.focusInput(); + } + }); } -export function registerAgentActions() { - registerAction2(FocusAideControls); - registerAction2(BlurAction); - registerAction2(SubmitAction); - // registerAction2(CancelAction); - // registerAction2(ClearIterationAction); - registerAction2(SetAideAgentScopeSelection); - registerAction2(SetAideAgentScopePinnedContext); - registerAction2(SetAideAgentScopeWholeCodebase); +export function stringifyItem(item: IChatRequestViewModel | IChatResponseViewModel, includeName = true): string { + if (isRequestVM(item)) { + return (includeName ? `${item.username}: ` : '') + item.messageText; + } else { + return (includeName ? `${item.username}: ` : '') + item.response.toString(); + } } diff --git a/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentClear.ts b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentClear.ts new file mode 100644 index 00000000000..9b47e9e1900 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentClear.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IChatEditorOptions } from '../aideAgentEditor.js'; +import { ChatEditorInput } from '../aideAgentEditorInput.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; + +export async function clearChatEditor(accessor: ServicesAccessor, chatEditorInput?: ChatEditorInput): Promise { + const editorService = accessor.get(IEditorService); + + if (!chatEditorInput) { + const editorInput = editorService.activeEditor; + chatEditorInput = editorInput instanceof ChatEditorInput ? editorInput : undefined; + } + + if (chatEditorInput instanceof ChatEditorInput) { + // A chat editor can only be open in one group + const identifier = editorService.findEditors(chatEditorInput.resource)[0]; + await editorService.replaceEditors([{ + editor: chatEditorInput, + replacement: { resource: ChatEditorInput.getNewEditorUri(), options: { pinned: true } satisfies IChatEditorOptions } + }], identifier.groupId); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentClearActions.ts b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentClearActions.ts new file mode 100644 index 00000000000..4fd4b1adf36 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentClearActions.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { localize2 } from '../../../../../nls.js'; +import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.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 { ActiveEditorContext } from '../../../../common/contextkeys.js'; +import { CHAT_CATEGORY, isChatViewTitleActionContext } from './aideAgentActions.js'; +import { clearChatEditor } from './aideAgentClear.js'; +import { CHAT_VIEW_ID, IAideAgentWidgetService } from '../aideAgent.js'; +import { ChatEditorInput } from '../aideAgentEditorInput.js'; +import { ChatViewPane } from '../aideAgentViewPane.js'; +import { CONTEXT_IN_CHAT_SESSION, CONTEXT_CHAT_ENABLED } from '../../common/aideAgentContextKeys.js'; +import { IViewsService } from '../../../../services/views/common/viewsService.js'; + +export const ACTION_ID_NEW_CHAT = `workbench.action.aideAgent.newChat`; + +export function registerNewChatActions() { + registerAction2(class NewChatEditorAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.aideAgentEditor.newChat', + title: localize2('chat.newChat.label', "New Session"), + icon: Codicon.plus, + f1: false, + precondition: CONTEXT_CHAT_ENABLED, + menu: [{ + id: MenuId.EditorTitle, + group: 'navigation', + order: 0, + when: ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), + }] + }); + } + async run(accessor: ServicesAccessor, ...args: any[]) { + announceChatCleared(accessor.get(IAccessibilitySignalService)); + await clearChatEditor(accessor); + } + }); + + registerAction2(class GlobalClearChatAction extends Action2 { + constructor() { + super({ + id: ACTION_ID_NEW_CHAT, + title: localize2('chat.newChat.label', "New Session"), + category: CHAT_CATEGORY, + icon: Codicon.plus, + precondition: CONTEXT_CHAT_ENABLED, + f1: true, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyCode.KeyL, + mac: { + primary: KeyMod.WinCtrl | KeyCode.KeyL + }, + when: CONTEXT_IN_CHAT_SESSION + }, + menu: [{ + id: MenuId.AideAgentContext, + group: 'z_clear' + }, + { + id: MenuId.ViewTitle, + when: ContextKeyExpr.equals('view', CHAT_VIEW_ID), + group: 'navigation', + order: 1 + }] + }); + } + + async run(accessor: ServicesAccessor, ...args: any[]) { + const context = args[0]; + const accessibilitySignalService = accessor.get(IAccessibilitySignalService); + if (isChatViewTitleActionContext(context)) { + // Is running in the Chat view title + announceChatCleared(accessibilitySignalService); + context.chatView.widget.clear(); + context.chatView.widget.focusInput(); + } else { + // Is running from f1 or keybinding + const widgetService = accessor.get(IAideAgentWidgetService); + const viewsService = accessor.get(IViewsService); + + let widget = widgetService.lastFocusedWidget; + if (!widget) { + const chatView = await viewsService.openView(CHAT_VIEW_ID) as ChatViewPane; + widget = chatView.widget; + } + + announceChatCleared(accessibilitySignalService); + widget.clear(); + widget.focusInput(); + } + } + }); +} + +function announceChatCleared(accessibilitySignalService: IAccessibilitySignalService): void { + accessibilitySignalService.playSignal(AccessibilitySignal.clear); +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentCodeblockActions.ts b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentCodeblockActions.ts new file mode 100644 index 00000000000..e3bb70aa6ad --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentCodeblockActions.ts @@ -0,0 +1,637 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Codicon } from '../../../../../base/common/codicons.js'; +import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ICodeEditor } from '../../../../../editor/browser/editorBrowser.js'; +import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js'; +import { EditorContextKeys } from '../../../../../editor/common/editorContextKeys.js'; +import { TextEdit } from '../../../../../editor/common/languages.js'; +import { CopyAction } from '../../../../../editor/contrib/clipboard/browser/clipboard.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IProgressService, ProgressLocation } from '../../../../../platform/progress/common/progress.js'; +import { TerminalLocation } from '../../../../../platform/terminal/common/terminal.js'; +import { IUntitledTextResourceEditorInput } from '../../../../common/editor.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { accessibleViewInCodeBlock } from '../../../accessibility/browser/accessibilityConfiguration.js'; +import { ITerminalEditorService, ITerminalGroupService, ITerminalService } from '../../../terminal/browser/terminal.js'; +import { ICodeMapperCodeBlock, IAideAgentCodeMapperService } from '../../common/aideAgentCodeMapperService.js'; +import { CONTEXT_CHAT_EDIT_APPLIED, CONTEXT_CHAT_ENABLED, CONTEXT_IN_CHAT_INPUT, CONTEXT_IN_CHAT_SESSION } from '../../common/aideAgentContextKeys.js'; +import { IAideAgentEditingService } from '../../common/aideAgentEditingService.js'; +import { ChatCopyKind, IAideAgentService } from '../../common/aideAgentService.js'; +import { IChatResponseViewModel, isResponseVM } from '../../common/aideAgentViewModel.js'; +import { IAideAgentCodeBlockContextProviderService, IAideAgentWidgetService } from '../aideAgent.js'; +import { DefaultChatTextEditor, ICodeBlockActionContext, ICodeCompareBlockActionContext } from '../codeBlockPart.js'; +import { CHAT_CATEGORY } from './aideAgentActions.js'; +import { ApplyCodeBlockOperation, InsertCodeBlockOperation } from './codeBlockOperations.js'; + +const shellLangIds = [ + 'fish', + 'ps1', + 'pwsh', + 'powershell', + 'sh', + 'shellscript', + 'zsh' +]; + +export interface IChatCodeBlockActionContext extends ICodeBlockActionContext { + element: IChatResponseViewModel; +} + +export function isCodeBlockActionContext(thing: unknown): thing is ICodeBlockActionContext { + return typeof thing === 'object' && thing !== null && 'code' in thing && 'element' in thing; +} + +export function isCodeCompareBlockActionContext(thing: unknown): thing is ICodeCompareBlockActionContext { + return typeof thing === 'object' && thing !== null && 'element' in thing; +} + +function isResponseFiltered(context: ICodeBlockActionContext) { + return isResponseVM(context.element) && context.element.errorDetails?.responseIsFiltered; +} + +abstract class ChatCodeBlockAction extends Action2 { + run(accessor: ServicesAccessor, ...args: any[]) { + let context = args[0]; + if (!isCodeBlockActionContext(context)) { + const codeEditorService = accessor.get(ICodeEditorService); + const editor = codeEditorService.getFocusedCodeEditor() || codeEditorService.getActiveCodeEditor(); + if (!editor) { + return; + } + + context = getContextFromEditor(editor, accessor); + if (!isCodeBlockActionContext(context)) { + return; + } + } + + return this.runWithContext(accessor, context); + } + + abstract runWithContext(accessor: ServicesAccessor, context: ICodeBlockActionContext): any; +} + +export function registerChatCodeBlockActions() { + registerAction2(class CopyCodeBlockAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.aideAgent.copyCodeBlock', + title: localize2('interactive.copyCodeBlock.label', "Copy"), + f1: false, + category: CHAT_CATEGORY, + icon: Codicon.copy, + menu: { + id: MenuId.AideAgentCodeBlock, + group: 'navigation', + order: 30 + } + }); + } + + run(accessor: ServicesAccessor, ...args: any[]) { + const context = args[0]; + if (!isCodeBlockActionContext(context) || isResponseFiltered(context)) { + return; + } + + const clipboardService = accessor.get(IClipboardService); + clipboardService.writeText(context.code); + + if (isResponseVM(context.element)) { + const chatService = accessor.get(IAideAgentService); + chatService.notifyUserAction({ + agentId: context.element.agent?.id, + command: context.element.slashCommand?.name, + sessionId: context.element.sessionId, + // requestId: context.element.requestId, + // TODO(@ghostwriternr): This is obviously wrong, but not critical to fix yet. + requestId: context.element.id, + result: context.element.result, + action: { + kind: 'copy', + codeBlockIndex: context.codeBlockIndex, + copyKind: ChatCopyKind.Toolbar, + copiedCharacters: context.code.length, + totalCharacters: context.code.length, + copiedText: context.code, + } + }); + } + } + }); + + CopyAction?.addImplementation(50000, 'chat-codeblock', (accessor) => { + // get active code editor + const editor = accessor.get(ICodeEditorService).getFocusedCodeEditor(); + if (!editor) { + return false; + } + + const editorModel = editor.getModel(); + if (!editorModel) { + return false; + } + + const context = getContextFromEditor(editor, accessor); + if (!context) { + return false; + } + + const noSelection = editor.getSelections()?.length === 1 && editor.getSelection()?.isEmpty(); + const copiedText = noSelection ? + editorModel.getValue() : + editor.getSelections()?.reduce((acc, selection) => acc + editorModel.getValueInRange(selection), '') ?? ''; + const totalCharacters = editorModel.getValueLength(); + + // Report copy to extensions + const chatService = accessor.get(IAideAgentService); + const element = context.element as IChatResponseViewModel | undefined; + if (element) { + chatService.notifyUserAction({ + agentId: element.agent?.id, + command: element.slashCommand?.name, + sessionId: element.sessionId, + // requestId: element.requestId, + // TODO(@ghostwriternr): This is obviously wrong, but not critical to fix yet. + requestId: element.id, + result: element.result, + action: { + kind: 'copy', + codeBlockIndex: context.codeBlockIndex, + copyKind: ChatCopyKind.Action, + copiedText, + copiedCharacters: copiedText.length, + totalCharacters, + } + }); + } + + // Copy full cell if no selection, otherwise fall back on normal editor implementation + if (noSelection) { + accessor.get(IClipboardService).writeText(context.code); + return true; + } + + return false; + }); + + registerAction2(class SmartApplyInEditorAction extends ChatCodeBlockAction { + + private operation: ApplyCodeBlockOperation | undefined; + + constructor() { + super({ + id: 'workbench.action.aideAgent.applyInEditor', + title: localize2('interactive.applyInEditor.label', "Apply in Editor"), + precondition: CONTEXT_CHAT_ENABLED, + f1: true, + category: CHAT_CATEGORY, + icon: Codicon.gitPullRequestGoToChanges, + + menu: { + id: MenuId.AideAgentCodeBlock, + group: 'navigation', + when: ContextKeyExpr.and( + CONTEXT_IN_CHAT_SESSION, + ...shellLangIds.map(e => ContextKeyExpr.notEquals(EditorContextKeys.languageId.key, e)) + ), + order: 10 + }, + keybinding: { + when: ContextKeyExpr.or(ContextKeyExpr.and(CONTEXT_IN_CHAT_SESSION, CONTEXT_IN_CHAT_INPUT.negate()), accessibleViewInCodeBlock), + primary: KeyMod.CtrlCmd | KeyCode.Enter, + mac: { primary: KeyMod.WinCtrl | KeyCode.Enter }, + weight: KeybindingWeight.ExternalExtension + 1 + }, + }); + } + + override runWithContext(accessor: ServicesAccessor, context: ICodeBlockActionContext) { + if (!this.operation) { + this.operation = accessor.get(IInstantiationService).createInstance(ApplyCodeBlockOperation); + } + return this.operation.run(context); + } + }); + + registerAction2(class ApplyAllAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.aideAgent.applyAll', + title: localize2('chat.applyAll.label', "Apply All Edits"), + precondition: CONTEXT_CHAT_ENABLED, // improve this condition + f1: true, + category: CHAT_CATEGORY, + icon: Codicon.edit + }); + } + + override async run(accessor: ServicesAccessor, ...args: any[]) { + const chatWidgetService = accessor.get(IAideAgentWidgetService); + const codemapperService = accessor.get(IAideAgentCodeMapperService); + const progressService = accessor.get(IProgressService); + const chatEditingService = accessor.get(IAideAgentEditingService); + const notificationService = accessor.get(INotificationService); + + if (chatEditingService.currentEditingSession) { + // there is already an editing session active, we should not start a new one + // TODO: figure out a way to implement follow-ups + notificationService.info(localize('chatCodeBlock.applyAll.editingSessionActive', 'An editing session is already active, please accept or reject the current proposed edits before continuing.')); + return; + } + + const widget = chatWidgetService.lastFocusedWidget; + if (!widget) { + return; + } + + const item = widget.getFocus(); + if (!isResponseVM(item)) { + return; + } + + const codeblocks = widget.getCodeBlockInfosForResponse(item); + const request: ICodeMapperCodeBlock[] = []; + for (const codeblock of codeblocks) { + if (codeblock.codemapperUri && codeblock.uri) { + const code = codeblock.getContent(); + request.push({ resource: codeblock.codemapperUri, code }); + } + } + + await chatEditingService.createEditingSession(async (stream) => { + + const response = { + textEdit: (resource: URI, textEdits: TextEdit[]) => { + stream.textEdits(resource, textEdits); + } + }; + + // Invoke the code mapper for all the code blocks in this response + const tokenSource = new CancellationTokenSource(); + await progressService.withProgress({ + location: ProgressLocation.Notification, + title: localize2('chatCodeBlock.generatingEdits', 'Applying all edits').value, + cancellable: true + }, async (task) => { + task.report({ message: localize2('chatCodeBlock.generating', 'Generating edits...').value }); + await codemapperService.mapCode({ codeBlocks: request, conversation: [] }, response, tokenSource.token); + task.report({ message: localize2('chatCodeBlock.applyAllEdits', 'Applying edits to workspace...').value }); + }, () => tokenSource.cancel()); + }); + } + }); + + registerAction2(class SmartApplyInEditorAction extends ChatCodeBlockAction { + constructor() { + super({ + id: 'workbench.action.aideAgent.insertCodeBlock', + title: localize2('interactive.insertCodeBlock.label', "Insert At Cursor"), + precondition: CONTEXT_CHAT_ENABLED, + f1: true, + category: CHAT_CATEGORY, + icon: Codicon.insert, + menu: { + id: MenuId.AideAgentCodeBlock, + group: 'navigation', + when: CONTEXT_IN_CHAT_SESSION, + order: 20 + }, + keybinding: { + when: ContextKeyExpr.or(ContextKeyExpr.and(CONTEXT_IN_CHAT_SESSION, CONTEXT_IN_CHAT_INPUT.negate()), accessibleViewInCodeBlock), + primary: KeyMod.CtrlCmd | KeyCode.Enter, + mac: { primary: KeyMod.WinCtrl | KeyCode.Enter }, + weight: KeybindingWeight.ExternalExtension + 1 + }, + }); + } + + override runWithContext(accessor: ServicesAccessor, context: ICodeBlockActionContext) { + const operation = accessor.get(IInstantiationService).createInstance(InsertCodeBlockOperation); + return operation.run(context); + } + }); + + registerAction2(class InsertIntoNewFileAction extends ChatCodeBlockAction { + constructor() { + super({ + id: 'workbench.action.aideAgent.insertIntoNewFile', + title: localize2('interactive.insertIntoNewFile.label', "Insert into New File"), + precondition: CONTEXT_CHAT_ENABLED, + f1: true, + category: CHAT_CATEGORY, + icon: Codicon.newFile, + menu: { + id: MenuId.AideAgentCodeBlock, + group: 'navigation', + isHiddenByDefault: true, + order: 40, + } + }); + } + + override async runWithContext(accessor: ServicesAccessor, context: ICodeBlockActionContext) { + if (isResponseFiltered(context)) { + // When run from command palette + return; + } + + const editorService = accessor.get(IEditorService); + const chatService = accessor.get(IAideAgentService); + + editorService.openEditor({ contents: context.code, languageId: context.languageId, resource: undefined } satisfies IUntitledTextResourceEditorInput); + + if (isResponseVM(context.element)) { + chatService.notifyUserAction({ + agentId: context.element.agent?.id, + command: context.element.slashCommand?.name, + sessionId: context.element.sessionId, + // requestId: context.element.requestId, + // TODO(@ghostwriternr): This is obviously wrong, but not critical to fix yet. + requestId: context.element.id, + result: context.element.result, + action: { + kind: 'insert', + codeBlockIndex: context.codeBlockIndex, + totalCharacters: context.code.length, + newFile: true + } + }); + } + } + }); + + registerAction2(class RunInTerminalAction extends ChatCodeBlockAction { + constructor() { + super({ + id: 'workbench.action.aideAgent.runInTerminal', + title: localize2('interactive.runInTerminal.label', "Insert into Terminal"), + precondition: CONTEXT_CHAT_ENABLED, + f1: true, + category: CHAT_CATEGORY, + icon: Codicon.terminal, + menu: [{ + id: MenuId.AideAgentCodeBlock, + group: 'navigation', + when: ContextKeyExpr.and( + CONTEXT_IN_CHAT_SESSION, + ContextKeyExpr.or(...shellLangIds.map(e => ContextKeyExpr.equals(EditorContextKeys.languageId.key, e))) + ), + }, + { + id: MenuId.AideAgentCodeBlock, + group: 'navigation', + isHiddenByDefault: true, + when: ContextKeyExpr.and( + CONTEXT_IN_CHAT_SESSION, + ...shellLangIds.map(e => ContextKeyExpr.notEquals(EditorContextKeys.languageId.key, e)) + ) + }], + keybinding: [{ + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.Enter, + mac: { + primary: KeyMod.WinCtrl | KeyMod.Alt | KeyCode.Enter + }, + weight: KeybindingWeight.EditorContrib, + when: ContextKeyExpr.or(CONTEXT_IN_CHAT_SESSION, accessibleViewInCodeBlock), + }] + }); + } + + override async runWithContext(accessor: ServicesAccessor, context: ICodeBlockActionContext) { + if (isResponseFiltered(context)) { + // When run from command palette + return; + } + + const chatService = accessor.get(IAideAgentService); + const terminalService = accessor.get(ITerminalService); + const editorService = accessor.get(IEditorService); + const terminalEditorService = accessor.get(ITerminalEditorService); + const terminalGroupService = accessor.get(ITerminalGroupService); + + let terminal = await terminalService.getActiveOrCreateInstance(); + + // isFeatureTerminal = debug terminal or task terminal + const unusableTerminal = terminal.xterm?.isStdinDisabled || terminal.shellLaunchConfig.isFeatureTerminal; + terminal = unusableTerminal ? await terminalService.createTerminal() : terminal; + + terminalService.setActiveInstance(terminal); + await terminal.focusWhenReady(true); + if (terminal.target === TerminalLocation.Editor) { + const existingEditors = editorService.findEditors(terminal.resource); + terminalEditorService.openEditor(terminal, { viewColumn: existingEditors?.[0].groupId }); + } else { + terminalGroupService.showPanel(true); + } + + terminal.runCommand(context.code, false); + + if (isResponseVM(context.element)) { + chatService.notifyUserAction({ + agentId: context.element.agent?.id, + command: context.element.slashCommand?.name, + sessionId: context.element.sessionId, + // requestId: context.element.requestId, + // TODO(@ghostwriternr): This is obviously wrong, but not critical to fix yet. + requestId: context.element.id, + result: context.element.result, + action: { + kind: 'runInTerminal', + codeBlockIndex: context.codeBlockIndex, + languageId: context.languageId, + } + }); + } + } + }); + + function navigateCodeBlocks(accessor: ServicesAccessor, reverse?: boolean): void { + const codeEditorService = accessor.get(ICodeEditorService); + const chatWidgetService = accessor.get(IAideAgentWidgetService); + const widget = chatWidgetService.lastFocusedWidget; + if (!widget) { + return; + } + + const editor = codeEditorService.getFocusedCodeEditor(); + const editorUri = editor?.getModel()?.uri; + const curCodeBlockInfo = editorUri ? widget.getCodeBlockInfoForEditor(editorUri) : undefined; + const focused = !widget.inputEditor.hasWidgetFocus() && widget.getFocus(); + const focusedResponse = isResponseVM(focused) ? focused : undefined; + + const currentResponse = curCodeBlockInfo ? + curCodeBlockInfo.element : + (focusedResponse ?? widget.viewModel?.getItems().reverse().find((item): item is IChatResponseViewModel => isResponseVM(item))); + if (!currentResponse || !isResponseVM(currentResponse)) { + return; + } + + widget.reveal(currentResponse); + const responseCodeblocks = widget.getCodeBlockInfosForResponse(currentResponse); + const focusIdx = curCodeBlockInfo ? + (curCodeBlockInfo.codeBlockIndex + (reverse ? -1 : 1) + responseCodeblocks.length) % responseCodeblocks.length : + reverse ? responseCodeblocks.length - 1 : 0; + + responseCodeblocks[focusIdx]?.focus(); + } + + registerAction2(class NextCodeBlockAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.aideAgent.nextCodeBlock', + title: localize2('interactive.nextCodeBlock.label', "Next Code Block"), + keybinding: { + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.PageDown, + mac: { primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.PageDown, }, + weight: KeybindingWeight.WorkbenchContrib, + when: CONTEXT_IN_CHAT_SESSION, + }, + precondition: CONTEXT_CHAT_ENABLED, + f1: true, + category: CHAT_CATEGORY, + }); + } + + run(accessor: ServicesAccessor, ..._args: any[]) { + navigateCodeBlocks(accessor); + } + }); + + registerAction2(class PreviousCodeBlockAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.aideAgent.previousCodeBlock', + title: localize2('interactive.previousCodeBlock.label', "Previous Code Block"), + keybinding: { + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.PageUp, + mac: { primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.PageUp, }, + weight: KeybindingWeight.WorkbenchContrib, + when: CONTEXT_IN_CHAT_SESSION, + }, + precondition: CONTEXT_CHAT_ENABLED, + f1: true, + category: CHAT_CATEGORY, + }); + } + + run(accessor: ServicesAccessor, ...args: any[]) { + navigateCodeBlocks(accessor, true); + } + }); +} + +function getContextFromEditor(editor: ICodeEditor, accessor: ServicesAccessor): ICodeBlockActionContext | undefined { + const chatWidgetService = accessor.get(IAideAgentWidgetService); + const chatCodeBlockContextProviderService = accessor.get(IAideAgentCodeBlockContextProviderService); + const model = editor.getModel(); + if (!model) { + return; + } + + const widget = chatWidgetService.lastFocusedWidget; + const codeBlockInfo = widget?.getCodeBlockInfoForEditor(model.uri); + if (!codeBlockInfo) { + for (const provider of chatCodeBlockContextProviderService.providers) { + const context = provider.getCodeBlockContext(editor); + if (context) { + return context; + } + } + return; + } + + return { + element: codeBlockInfo.element, + codeBlockIndex: codeBlockInfo.codeBlockIndex, + code: editor.getValue(), + languageId: editor.getModel()!.getLanguageId(), + codemapperUri: codeBlockInfo.codemapperUri + }; +} + +export function registerChatCodeCompareBlockActions() { + + abstract class ChatCompareCodeBlockAction extends Action2 { + run(accessor: ServicesAccessor, ...args: any[]) { + const context = args[0]; + if (!isCodeCompareBlockActionContext(context)) { + return; + // TODO@jrieken derive context + } + + return this.runWithContext(accessor, context); + } + + abstract runWithContext(accessor: ServicesAccessor, context: ICodeCompareBlockActionContext): any; + } + + registerAction2(class ApplyEditsCompareBlockAction extends ChatCompareCodeBlockAction { + constructor() { + super({ + id: 'workbench.action.aideAgent.applyCompareEdits', + title: localize2('interactive.compare.apply', "Apply Edits"), + f1: false, + category: CHAT_CATEGORY, + icon: Codicon.check, + precondition: ContextKeyExpr.and(EditorContextKeys.hasChanges, CONTEXT_CHAT_EDIT_APPLIED.negate()), + menu: { + id: MenuId.AideAgentCompareBlock, + group: 'navigation', + order: 1, + } + }); + } + + async runWithContext(accessor: ServicesAccessor, context: ICodeCompareBlockActionContext): Promise { + + const editorService = accessor.get(IEditorService); + const instaService = accessor.get(IInstantiationService); + + const editor = instaService.createInstance(DefaultChatTextEditor); + await editor.apply(context.element, context.edit, context.diffEditor); + + await editorService.openEditor({ + resource: context.edit.uri, + options: { revealIfVisible: true }, + }); + } + }); + + registerAction2(class DiscardEditsCompareBlockAction extends ChatCompareCodeBlockAction { + constructor() { + super({ + id: 'workbench.action.aideAgent.discardCompareEdits', + title: localize2('interactive.compare.discard', "Discard Edits"), + f1: false, + category: CHAT_CATEGORY, + icon: Codicon.trash, + precondition: ContextKeyExpr.and(EditorContextKeys.hasChanges, CONTEXT_CHAT_EDIT_APPLIED.negate()), + menu: { + id: MenuId.AideAgentCompareBlock, + group: 'navigation', + order: 2, + } + }); + } + + async runWithContext(accessor: ServicesAccessor, context: ICodeCompareBlockActionContext): Promise { + const instaService = accessor.get(IInstantiationService); + const editor = instaService.createInstance(DefaultChatTextEditor); + editor.discard(context.element, context.edit); + } + }); +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentContextActions.ts b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentContextActions.ts new file mode 100644 index 00000000000..5bd5ed8b1d0 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentContextActions.ts @@ -0,0 +1,189 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { FileAccess, Schemas } from '../../../../../base/common/network.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { EditorType } from '../../../../../editor/common/editorCommon.js'; +import { Command } from '../../../../../editor/common/languages.js'; +import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; +import { IGotoSymbolQuickPickItem } from '../../../../../editor/contrib/quickAccess/browser/gotoSymbolQuickAccess.js'; +import { SuggestController } from '../../../../../editor/contrib/suggest/browser/suggestController.js'; +import { localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IViewsService } from '../../../../services/views/common/viewsService.js'; +import { ISymbolQuickPickItem } from '../../../search/browser/symbolsQuickAccess.js'; +import { ChatAgentLocation } from '../../common/aideAgentAgents.js'; +import { chatVariableLeader } from '../../common/aideAgentParserTypes.js'; +import { IAideAgentVariablesService } from '../../common/aideAgentVariables.js'; +import { showChatView } from '../aideAgent.js'; +import { CHAT_CATEGORY } from './aideAgentActions.js'; + +export function registerChatContextActions() { + registerAction2(AttachFileAction); + registerAction2(AttachSelectionAction); + registerAction2(TriggerContextProvider); +} + +export type IChatContextQuickPickItem = IFileQuickPickItem | IDynamicVariableQuickPickItem | IStaticVariableQuickPickItem | IGotoSymbolQuickPickItem | ISymbolQuickPickItem | IQuickAccessQuickPickItem | IToolQuickPickItem; + +export interface IFileQuickPickItem extends IQuickPickItem { + kind: 'file'; + id: string; + name: string; + value: URI; + isDynamic: true; + + resource: URI; +} + +export interface IDynamicVariableQuickPickItem extends IQuickPickItem { + kind: 'dynamic'; + id: string; + name?: string; + value: unknown; + isDynamic: true; + + icon?: ThemeIcon; + command?: Command; +} + +export interface IToolQuickPickItem extends IQuickPickItem { + kind: 'tool'; + id: string; + name?: string; + icon?: ThemeIcon; +} + +export interface IStaticVariableQuickPickItem extends IQuickPickItem { + kind: 'static'; + id: string; + name: string; + value: unknown; + isDynamic?: false; + + icon?: ThemeIcon; +} + +export interface IQuickAccessQuickPickItem extends IQuickPickItem { + kind: 'quickaccess'; + id: string; + name: string; + value: string; + + prefix: string; +} + +class AttachFileAction extends Action2 { + + static readonly ID = 'workbench.action.aideAgent.attachFile'; + + constructor() { + super({ + id: AttachFileAction.ID, + title: localize2('workbench.action.aideAgent.attachFile.label', "Attach File"), + category: CHAT_CATEGORY, + f1: false + }); + } + + override async run(accessor: ServicesAccessor, ...args: any[]): Promise { + const variablesService = accessor.get(IAideAgentVariablesService); + const textEditorService = accessor.get(IEditorService); + + const activeUri = textEditorService.activeEditor?.resource; + if (textEditorService.activeTextEditorControl?.getEditorType() === EditorType.ICodeEditor && activeUri && [Schemas.file, Schemas.vscodeRemote, Schemas.untitled].includes(activeUri.scheme)) { + variablesService.attachContext('file', activeUri, ChatAgentLocation.Panel); + } + } +} + +class AttachSelectionAction extends Action2 { + + static readonly ID = 'workbench.action.aideAgent.attachSelection'; + + constructor() { + super({ + id: AttachSelectionAction.ID, + title: localize2('workbench.action.aideAgent.attachSelection.label', "Add Selection to Chat"), + category: CHAT_CATEGORY, + f1: false + }); + } + + override async run(accessor: ServicesAccessor, ...args: any[]): Promise { + const variablesService = accessor.get(IAideAgentVariablesService); + const textEditorService = accessor.get(IEditorService); + + const activeEditor = textEditorService.activeTextEditorControl; + const activeUri = textEditorService.activeEditor?.resource; + if (textEditorService.activeTextEditorControl?.getEditorType() === EditorType.ICodeEditor && activeUri && [Schemas.file, Schemas.vscodeRemote, Schemas.untitled].includes(activeUri.scheme)) { + const selection = activeEditor?.getSelection(); + if (selection) { + variablesService.attachContext('file', { uri: activeUri, range: selection }, ChatAgentLocation.Panel); + } + } + } +} + +class TriggerContextProvider extends Action2 { + static readonly ID = 'workbench.action.aideAgent.triggerContextProvider'; + + constructor() { + super({ + id: TriggerContextProvider.ID, + title: localize2('workbench.action.aideAgent.triggerContextProvider.label', "Add context"), + f1: false, + category: CHAT_CATEGORY, + icon: { + light: FileAccess.asFileUri('vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/media/at-light.svg'), + dark: FileAccess.asFileUri('vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/media/at-dark.svg'), + }, + menu: [ + { + id: MenuId.AideAgentInput, + group: 'navigation', + } + ] + }); + } + + override async run(accessor: ServicesAccessor, ...args: any[]): Promise { + const viewsService = accessor.get(IViewsService); + const languageFeaturesService = accessor.get(ILanguageFeaturesService); + + const chatWidget = await showChatView(viewsService); + if (!chatWidget) { + return; + } + + const inputEditor = chatWidget.inputEditor; + const suggestController = SuggestController.get(inputEditor); + if (!suggestController) { + return; + } + + const completionProviders = languageFeaturesService.completionProvider.getForAllLanguages(); + + // get the current position from chatWidget and insert the context + const position = inputEditor.getPosition(); + if (!position) { + return; + } + const range = { + startLineNumber: position.lineNumber, + startColumn: position.column, + endLineNumber: position.lineNumber, + endColumn: position.column + }; + + inputEditor.executeEdits('insertContextTrigger', [{ range, text: chatVariableLeader }]); + chatWidget.focusInput(); + suggestController.triggerSuggest(new Set(completionProviders)); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentCopyActions.ts b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentCopyActions.ts new file mode 100644 index 00000000000..b515121e5ff --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentCopyActions.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; +import { CHAT_CATEGORY, stringifyItem } from './aideAgentActions.js'; +import { IAideAgentWidgetService } from '../aideAgent.js'; +import { CONTEXT_RESPONSE_FILTERED } from '../../common/aideAgentContextKeys.js'; +import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM, isResponseVM } from '../../common/aideAgentViewModel.js'; + +export function registerChatCopyActions() { + registerAction2(class CopyAllAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.aideAgent.copyAll', + title: localize2('interactive.copyAll.label', "Copy All"), + f1: false, + category: CHAT_CATEGORY, + menu: { + id: MenuId.AideAgentContext, + when: CONTEXT_RESPONSE_FILTERED.toNegated(), + group: 'copy', + } + }); + } + + run(accessor: ServicesAccessor, ...args: any[]) { + const clipboardService = accessor.get(IClipboardService); + const chatWidgetService = accessor.get(IAideAgentWidgetService); + const widget = chatWidgetService.lastFocusedWidget; + if (widget) { + const viewModel = widget.viewModel; + const sessionAsText = viewModel?.getItems() + .filter((item): item is (IChatRequestViewModel | IChatResponseViewModel) => isRequestVM(item) || (isResponseVM(item) && !item.errorDetails?.responseIsFiltered)) + .map(item => stringifyItem(item)) + .join('\n\n'); + if (sessionAsText) { + clipboardService.writeText(sessionAsText); + } + } + } + }); + + registerAction2(class CopyItemAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.aideAgent.copyItem', + title: localize2('interactive.copyItem.label', "Copy"), + f1: false, + category: CHAT_CATEGORY, + menu: { + id: MenuId.AideAgentContext, + when: CONTEXT_RESPONSE_FILTERED.toNegated(), + group: 'copy', + } + }); + } + + run(accessor: ServicesAccessor, ...args: any[]) { + const item = args[0]; + if (!isRequestVM(item) && !isResponseVM(item)) { + return; + } + + const clipboardService = accessor.get(IClipboardService); + const text = stringifyItem(item, false); + clipboardService.writeText(text); + } + }); +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentDeveloperActions.ts b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentDeveloperActions.ts new file mode 100644 index 00000000000..418b8d20f49 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentDeveloperActions.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { localize2 } from '../../../../../nls.js'; +import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; +import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { IAideAgentWidgetService } from '../aideAgent.js'; + +export function registerChatDeveloperActions() { + registerAction2(LogChatInputHistoryAction); +} + +class LogChatInputHistoryAction extends Action2 { + + static readonly ID = 'workbench.action.aideAgent.logInputHistory'; + + constructor() { + super({ + id: LogChatInputHistoryAction.ID, + title: localize2('workbench.action.chat.logInputHistory.label', "Log Chat Input History"), + icon: Codicon.attach, + category: Categories.Developer, + f1: true + }); + } + + override async run(accessor: ServicesAccessor, ...args: any[]): Promise { + const chatWidgetService = accessor.get(IAideAgentWidgetService); + chatWidgetService.lastFocusedWidget?.logInputHistory(); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentExecuteActions.ts b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentExecuteActions.ts new file mode 100644 index 00000000000..00e4e1d02a6 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentExecuteActions.ts @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { 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 { CONTEXT_CHAT_INPUT_HAS_TEXT, CONTEXT_CHAT_LOCATION, CONTEXT_CHAT_REQUEST_IN_PROGRESS, CONTEXT_IN_CHAT_INPUT } from '../../common/aideAgentContextKeys.js'; +import { IAideAgentService } from '../../common/aideAgentService.js'; +import { IAideAgentWidgetService, IChatWidget } from '../aideAgent.js'; +import { CHAT_CATEGORY } from './aideAgentActions.js'; + +export interface IVoiceChatExecuteActionContext { + readonly disableTimeout?: boolean; +} + +export interface IChatExecuteActionContext { + widget?: IChatWidget; + inputValue?: string; + voice?: IVoiceChatExecuteActionContext; +} + +export class SubmitAction extends Action2 { + static readonly ID = 'workbench.action.aideAgent.submit'; + + constructor() { + super({ + id: SubmitAction.ID, + title: localize2('interactive.submit.label', "Send"), + f1: false, + category: CHAT_CATEGORY, + icon: Codicon.send, + precondition: ContextKeyExpr.and(CONTEXT_CHAT_INPUT_HAS_TEXT, CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate()), + keybinding: { + when: CONTEXT_IN_CHAT_INPUT, + primary: KeyCode.Enter, + weight: KeybindingWeight.EditorContrib + }, + menu: [ + { + id: MenuId.AideAgentExecuteSecondary, + group: 'group_1', + }, + { + id: MenuId.AideAgentExecute, + order: 2, + when: CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate(), + group: 'navigation', + }, + ] + }); + } + + run(accessor: ServicesAccessor, ...args: any[]) { + const context: IChatExecuteActionContext | undefined = args[0]; + + const widgetService = accessor.get(IAideAgentWidgetService); + const widget = context?.widget ?? widgetService.lastFocusedWidget; + widget?.acceptInput(context?.inputValue); + } +} + +export const AgentModePickerActionId = 'workbench.action.aideAgent.setMode'; +MenuRegistry.appendMenuItem(MenuId.AideAgentExecute, { + command: { + id: AgentModePickerActionId, + title: localize2('aideAgent.setMode.label', "Set Mode"), + }, + order: 1, + group: 'navigation', + when: ContextKeyExpr.equals(CONTEXT_CHAT_LOCATION.key, 'panel'), +}); + +export class CancelAction extends Action2 { + static readonly ID = 'workbench.action.aideAgent.cancel'; + constructor() { + super({ + id: CancelAction.ID, + title: localize2('interactive.cancel.label', "Cancel"), + f1: false, + category: CHAT_CATEGORY, + icon: Codicon.debugStop, + menu: { + id: MenuId.AideAgentExecute, + when: CONTEXT_CHAT_REQUEST_IN_PROGRESS, + order: 2, + group: 'navigation', + }, + keybinding: { + when: CONTEXT_IN_CHAT_INPUT, + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyCode.Escape, + win: { primary: KeyMod.Alt | KeyCode.Backspace }, + } + }); + } + + run(accessor: ServicesAccessor, ...args: any[]) { + const context: IChatExecuteActionContext | undefined = args[0]; + + const widgetService = accessor.get(IAideAgentWidgetService); + const widget = context?.widget ?? widgetService.lastFocusedWidget; + if (!widget) { + return; + } + + const chatService = accessor.get(IAideAgentService); + if (widget.viewModel) { + chatService.cancelCurrentRequestForSession(widget.viewModel.sessionId); + } + } +} + +export function registerChatExecuteActions() { + registerAction2(SubmitAction); + registerAction2(CancelAction); +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentFileTreeActions.ts b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentFileTreeActions.ts new file mode 100644 index 00000000000..3786d807dfe --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentFileTreeActions.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { localize2 } from '../../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { CHAT_CATEGORY } from './aideAgentActions.js'; +import { IAideAgentWidgetService } from '../aideAgent.js'; +import { CONTEXT_IN_CHAT_SESSION, CONTEXT_CHAT_ENABLED } from '../../common/aideAgentContextKeys.js'; +import { IChatResponseViewModel, isResponseVM } from '../../common/aideAgentViewModel.js'; + +export function registerChatFileTreeActions() { + registerAction2(class NextFileTreeAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.aideAgent.nextFileTree', + title: localize2('interactive.nextFileTree.label', "Next File Tree"), + keybinding: { + primary: KeyMod.CtrlCmd | KeyCode.F9, + weight: KeybindingWeight.WorkbenchContrib, + when: CONTEXT_IN_CHAT_SESSION, + }, + precondition: CONTEXT_CHAT_ENABLED, + f1: true, + category: CHAT_CATEGORY, + }); + } + + run(accessor: ServicesAccessor, ...args: any[]) { + navigateTrees(accessor, false); + } + }); + + registerAction2(class PreviousFileTreeAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.aideAgent.previousFileTree', + title: localize2('interactive.previousFileTree.label', "Previous File Tree"), + keybinding: { + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.F9, + weight: KeybindingWeight.WorkbenchContrib, + when: CONTEXT_IN_CHAT_SESSION, + }, + precondition: CONTEXT_CHAT_ENABLED, + f1: true, + category: CHAT_CATEGORY, + }); + } + + run(accessor: ServicesAccessor, ...args: any[]) { + navigateTrees(accessor, true); + } + }); +} + +function navigateTrees(accessor: ServicesAccessor, reverse: boolean) { + const chatWidgetService = accessor.get(IAideAgentWidgetService); + const widget = chatWidgetService.lastFocusedWidget; + if (!widget) { + return; + } + + const focused = !widget.inputEditor.hasWidgetFocus() && widget.getFocus(); + const focusedResponse = isResponseVM(focused) ? focused : undefined; + + const currentResponse = focusedResponse ?? widget.viewModel?.getItems().reverse().find((item): item is IChatResponseViewModel => isResponseVM(item)); + if (!currentResponse) { + return; + } + + widget.reveal(currentResponse); + const responseFileTrees = widget.getFileTreeInfosForResponse(currentResponse); + const lastFocusedFileTree = widget.getLastFocusedFileTreeForResponse(currentResponse); + const focusIdx = lastFocusedFileTree ? + (lastFocusedFileTree.treeIndex + (reverse ? -1 : 1) + responseFileTrees.length) % responseFileTrees.length : + reverse ? responseFileTrees.length - 1 : 0; + + responseFileTrees[focusIdx]?.focus(); +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentMoveActions.ts b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentMoveActions.ts new file mode 100644 index 00000000000..1a15f4f0ca0 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentMoveActions.ts @@ -0,0 +1,132 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ActiveEditorContext } from '../../../../common/contextkeys.js'; +import { CHAT_CATEGORY, isChatViewTitleActionContext } from './aideAgentActions.js'; +import { CHAT_VIEW_ID, IAideAgentWidgetService } from '../aideAgent.js'; +import { IChatEditorOptions } from '../aideAgentEditor.js'; +import { ChatEditorInput } from '../aideAgentEditorInput.js'; +import { ChatViewPane } from '../aideAgentViewPane.js'; +import { CONTEXT_CHAT_ENABLED } from '../../common/aideAgentContextKeys.js'; +import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; +import { ACTIVE_GROUP, AUX_WINDOW_GROUP, IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IViewsService } from '../../../../services/views/common/viewsService.js'; + +enum MoveToNewLocation { + Editor = 'Editor', + Window = 'Window' +} + +export function registerMoveActions() { + registerAction2(class GlobalMoveToEditorAction extends Action2 { + constructor() { + super({ + id: `workbench.action.aideAgent.openInEditor`, + title: localize2('chat.openInEditor.label', "Open Chat in Editor"), + category: CHAT_CATEGORY, + precondition: CONTEXT_CHAT_ENABLED, + f1: true, + menu: { + id: MenuId.ViewTitle, + when: ContextKeyExpr.equals('view', CHAT_VIEW_ID), + order: 0 + }, + }); + } + + async run(accessor: ServicesAccessor, ...args: any[]) { + const context = args[0]; + executeMoveToAction(accessor, MoveToNewLocation.Editor, isChatViewTitleActionContext(context) ? context.chatView : undefined); + } + }); + + registerAction2(class GlobalMoveToNewWindowAction extends Action2 { + constructor() { + super({ + id: `workbench.action.aideAgent.openInNewWindow`, + title: localize2('chat.openInNewWindow.label', "Open Chat in New Window"), + category: CHAT_CATEGORY, + precondition: CONTEXT_CHAT_ENABLED, + f1: true, + menu: { + id: MenuId.ViewTitle, + when: ContextKeyExpr.equals('view', CHAT_VIEW_ID), + order: 0 + }, + }); + } + + async run(accessor: ServicesAccessor, ...args: any[]) { + const context = args[0]; + executeMoveToAction(accessor, MoveToNewLocation.Window, isChatViewTitleActionContext(context) ? context.chatView : undefined); + } + }); + + registerAction2(class GlobalMoveToSidebarAction extends Action2 { + constructor() { + super({ + id: `workbench.action.aideAgent.openInSidebar`, + title: localize2('interactiveSession.openInSidebar.label', "Open Chat in Side Bar"), + category: CHAT_CATEGORY, + precondition: CONTEXT_CHAT_ENABLED, + f1: true, + menu: [{ + id: MenuId.EditorTitle, + order: 0, + when: ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), + }] + }); + } + + async run(accessor: ServicesAccessor, ...args: any[]) { + return moveToSidebar(accessor); + } + }); +} + +async function executeMoveToAction(accessor: ServicesAccessor, moveTo: MoveToNewLocation, chatView?: ChatViewPane) { + const widgetService = accessor.get(IAideAgentWidgetService); + const editorService = accessor.get(IEditorService); + + const widget = chatView?.widget ?? widgetService.lastFocusedWidget; + if (!widget || !('viewId' in widget.viewContext)) { + await editorService.openEditor({ resource: ChatEditorInput.getNewEditorUri(), options: { pinned: true } }, moveTo === MoveToNewLocation.Window ? AUX_WINDOW_GROUP : ACTIVE_GROUP); + return; + } + + const viewModel = widget.viewModel; + if (!viewModel) { + return; + } + + const sessionId = viewModel.sessionId; + const viewState = widget.getViewState(); + widget.clear(); + + const options: IChatEditorOptions = { target: { sessionId }, pinned: true, viewState: viewState }; + await editorService.openEditor({ resource: ChatEditorInput.getNewEditorUri(), options }, moveTo === MoveToNewLocation.Window ? AUX_WINDOW_GROUP : ACTIVE_GROUP); +} + +async function moveToSidebar(accessor: ServicesAccessor): Promise { + const viewsService = accessor.get(IViewsService); + const editorService = accessor.get(IEditorService); + const editorGroupService = accessor.get(IEditorGroupsService); + + const chatEditorInput = editorService.activeEditor; + let view: ChatViewPane; + if (chatEditorInput instanceof ChatEditorInput && chatEditorInput.sessionId) { + await editorService.closeEditor({ editor: chatEditorInput, groupId: editorGroupService.activeGroup.id }); + view = await viewsService.openView(CHAT_VIEW_ID) as ChatViewPane; + view.loadSession(chatEditorInput.sessionId); + } else { + view = await viewsService.openView(CHAT_VIEW_ID) as ChatViewPane; + } + + view.focus(); +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentTitleActions.ts b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentTitleActions.ts new file mode 100644 index 00000000000..ba32f032ce5 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentTitleActions.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { 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 { CONTEXT_IN_CHAT_INPUT, CONTEXT_IN_CHAT_SESSION, CONTEXT_REQUEST } from '../../common/aideAgentContextKeys.js'; +import { CHAT_CATEGORY } from './aideAgentActions.js'; + +export const MarkUnhelpfulActionId = 'workbench.action.aideAgent.markUnhelpful'; + +export function registerChatTitleActions() { + 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[]) { + /* TODO(@ghostwriternr): Completely get rid of this if removing a request no longer makes sense. + 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); + } + */ + } + }); +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/actions/codeBlockOperations.ts b/src/vs/workbench/contrib/aideAgent/browser/actions/codeBlockOperations.ts new file mode 100644 index 00000000000..f99a48afad4 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/actions/codeBlockOperations.ts @@ -0,0 +1,495 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { coalesce } from '../../../../../base/common/arrays.js'; +import { AsyncIterableObject } from '../../../../../base/common/async.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { CharCode } from '../../../../../base/common/charCode.js'; +import { ResourceMap } from '../../../../../base/common/map.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import * as strings from '../../../../../base/common/strings.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IActiveCodeEditor, isCodeEditor, isDiffEditor } from '../../../../../editor/browser/editorBrowser.js'; +import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js'; +import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { ConversationRequest, ConversationResponse, DocumentContextItem, isLocation, IWorkspaceFileEdit, IWorkspaceTextEdit } from '../../../../../editor/common/languages.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; +import { localize } from '../../../../../nls.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IProgressService, ProgressLocation } from '../../../../../platform/progress/common/progress.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; +import { InlineChatController } from '../../../inlineChat/browser/inlineChatController.js'; +import { insertCell } from '../../../notebook/browser/controller/cellOperations.js'; +import { IActiveNotebookEditor, INotebookEditor } from '../../../notebook/browser/notebookBrowser.js'; +import { CellKind, NOTEBOOK_EDITOR_ID } from '../../../notebook/common/notebookCommon.js'; +import { ChatUserAction, IChatContentReference, IAideAgentService } from '../../common/aideAgentService.js'; +import { isRequestVM, isResponseVM } from '../../common/aideAgentViewModel.js'; +import { ICodeBlockActionContext } from '../codeBlockPart.js'; + +export class InsertCodeBlockOperation { + constructor( + @IEditorService private readonly editorService: IEditorService, + @ITextFileService private readonly textFileService: ITextFileService, + @IBulkEditService private readonly bulkEditService: IBulkEditService, + @ICodeEditorService private readonly codeEditorService: ICodeEditorService, + @IAideAgentService private readonly chatService: IAideAgentService, + @ILanguageService private readonly languageService: ILanguageService, + @IDialogService private readonly dialogService: IDialogService, + ) { + } + + public async run(context: ICodeBlockActionContext) { + const activeEditorControl = getEditableActiveCodeEditor(this.editorService); + if (activeEditorControl) { + await this.handleTextEditor(activeEditorControl, context); + } else { + const activeNotebookEditor = getActiveNotebookEditor(this.editorService); + if (activeNotebookEditor) { + await this.handleNotebookEditor(activeNotebookEditor, context); + } else { + this.notify(localize('insertCodeBlock.noActiveEditor', "To insert the code block, open a code editor or notebook editor and set the cursor at the location where to insert the code block.")); + } + } + notifyUserAction(this.chatService, context, { + kind: 'insert', + codeBlockIndex: context.codeBlockIndex, + totalCharacters: context.code.length + }); + } + + private async handleNotebookEditor(notebookEditor: IActiveNotebookEditor, codeBlockContext: ICodeBlockActionContext): Promise { + if (notebookEditor.isReadOnly) { + this.notify(localize('insertCodeBlock.readonlyNotebook', "Cannot insert the code block to read-only notebook editor.")); + return false; + } + const focusRange = notebookEditor.getFocus(); + const next = Math.max(focusRange.end - 1, 0); + insertCell(this.languageService, notebookEditor, next, CellKind.Code, 'below', codeBlockContext.code, true); + return true; + } + + private async handleTextEditor(codeEditor: IActiveCodeEditor, codeBlockContext: ICodeBlockActionContext): Promise { + const activeModel = codeEditor.getModel(); + if (isReadOnly(activeModel, this.textFileService)) { + this.notify(localize('insertCodeBlock.readonly', "Cannot insert the code block to read-only code editor.")); + return false; + } + + const range = codeEditor.getSelection() ?? new Range(activeModel.getLineCount(), 1, activeModel.getLineCount(), 1); + const text = reindent(codeBlockContext.code, activeModel, range.startLineNumber); + + const edits = [new ResourceTextEdit(activeModel.uri, { range, text })]; + await this.bulkEditService.apply(edits); + this.codeEditorService.listCodeEditors().find(editor => editor.getModel()?.uri.toString() === activeModel.uri.toString())?.focus(); + return true; + } + + private notify(message: string) { + //this.notificationService.notify({ severity: Severity.Info, message }); + this.dialogService.info(message); + } +} + +type IComputeEditsResult = { readonly edits?: Array; readonly codeMapper?: string }; + +export class ApplyCodeBlockOperation { + + private inlineChatPreview: InlineChatPreview | undefined; + + constructor( + @IEditorService private readonly editorService: IEditorService, + @ITextFileService private readonly textFileService: ITextFileService, + @IBulkEditService private readonly bulkEditService: IBulkEditService, + @ICodeEditorService private readonly codeEditorService: ICodeEditorService, + @IAideAgentService private readonly chatService: IAideAgentService, + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @IProgressService private readonly progressService: IProgressService, + @ILanguageService private readonly languageService: ILanguageService, + @IFileService private readonly fileService: IFileService, + @IDialogService private readonly dialogService: IDialogService, + @ILogService private readonly logService: ILogService, + ) { + } + + public async run(context: ICodeBlockActionContext): Promise { + if (this.inlineChatPreview && this.inlineChatPreview.isOpen()) { + await this.dialogService.info( + localize('overlap', "Another code change is being previewed. Please apply or discard the pending changes first."), + ); + return; + } + + let activeEditorControl = getEditableActiveCodeEditor(this.editorService); + + if (context.codemapperUri && !isEqual(activeEditorControl?.getModel().uri, context.codemapperUri)) { + // If the code block is from a code mapper, first reveal the target file + try { + // If the file doesn't exist yet, create it + if (!(await this.fileService.exists(context.codemapperUri))) { + // TODO: try to find the file in the workspace + + await this.fileService.writeFile(context.codemapperUri, VSBuffer.fromString('')); + } + await this.editorService.openEditor({ resource: context.codemapperUri }); + + activeEditorControl = getEditableActiveCodeEditor(this.editorService); + if (activeEditorControl) { + this.tryToRevealCodeBlock(activeEditorControl, context.code); + } + } catch (e) { + this.logService.info('[ApplyCodeBlockOperation] error opening code mapper file', context.codemapperUri, e); + } + } + + let result: IComputeEditsResult | undefined = undefined; + + if (activeEditorControl) { + await this.handleTextEditor(activeEditorControl, context); + } else { + const activeNotebookEditor = getActiveNotebookEditor(this.editorService); + if (activeNotebookEditor) { + result = await this.handleNotebookEditor(activeNotebookEditor, context); + } else { + this.notify(localize('applyCodeBlock.noActiveEditor', "To apply this code block, open a code or notebook editor.")); + } + } + notifyUserAction(this.chatService, context, { + kind: 'apply', + codeBlockIndex: context.codeBlockIndex, + totalCharacters: context.code.length, + codeMapper: result?.codeMapper, + editsProposed: !!result?.edits, + }); + } + + private async handleNotebookEditor(notebookEditor: IActiveNotebookEditor, codeBlockContext: ICodeBlockActionContext): Promise { + if (notebookEditor.isReadOnly) { + this.notify(localize('applyCodeBlock.readonlyNotebook', "Cannot apply code block to read-only notebook editor.")); + return undefined; + } + const focusRange = notebookEditor.getFocus(); + const next = Math.max(focusRange.end - 1, 0); + insertCell(this.languageService, notebookEditor, next, CellKind.Code, 'below', codeBlockContext.code, true); + return undefined; + } + + private async handleTextEditor(codeEditor: IActiveCodeEditor, codeBlockContext: ICodeBlockActionContext): Promise { + if (isReadOnly(codeEditor.getModel(), this.textFileService)) { + this.notify(localize('applyCodeBlock.readonly', "Cannot apply code block to read-only file.")); + return undefined; + } + + const result = await this.computeEdits(codeEditor, codeBlockContext); + if (result.edits) { + const showWithPreview = await this.applyWithInlinePreview(result.edits, codeEditor); + if (!showWithPreview) { + await this.bulkEditService.apply(result.edits, { showPreview: true }); + const activeModel = codeEditor.getModel(); + this.codeEditorService.listCodeEditors().find(editor => editor.getModel()?.uri.toString() === activeModel.uri.toString())?.focus(); + } + } + return result; + } + + private async computeEdits(codeEditor: IActiveCodeEditor, codeBlockActionContext: ICodeBlockActionContext): Promise { + const activeModel = codeEditor.getModel(); + + const mappedEditsProviders = this.languageFeaturesService.mappedEditsProvider.ordered(activeModel); + if (mappedEditsProviders.length > 0) { + + // 0th sub-array - editor selections array if there are any selections + // 1st sub-array - array with documents used to get the chat reply + const docRefs: DocumentContextItem[][] = []; + collectDocumentContextFromSelections(codeEditor, docRefs); + collectDocumentContextFromContext(codeBlockActionContext, docRefs); + + const cancellationTokenSource = new CancellationTokenSource(); + let codeMapper; // the last used code mapper + try { + const result = await this.progressService.withProgress( + { location: ProgressLocation.Notification, delay: 500, sticky: true, cancellable: true }, + async progress => { + for (const provider of mappedEditsProviders) { + codeMapper = provider.displayName; + progress.report({ message: localize('applyCodeBlock.progress', "Applying code block using {0}...", codeMapper) }); + const mappedEdits = await provider.provideMappedEdits( + activeModel, + [codeBlockActionContext.code], + { + documents: docRefs, + conversation: getChatConversation(codeBlockActionContext), + }, + cancellationTokenSource.token + ); + if (mappedEdits) { + return { edits: mappedEdits.edits, codeMapper }; + } + } + return undefined; + }, + () => cancellationTokenSource.cancel() + ); + if (result) { + return result; + } + } catch (e) { + this.notify(localize('applyCodeBlock.error', "Failed to apply code block: {0}", e.message)); + } finally { + cancellationTokenSource.dispose(); + } + return { edits: [], codeMapper }; + } + return { edits: [], codeMapper: undefined }; + } + + private async applyWithInlinePreview(edits: Array, codeEditor: IActiveCodeEditor): Promise { + const firstEdit = edits[0]; + if (!ResourceTextEdit.is(firstEdit)) { + return false; + } + const resource = firstEdit.resource; + const textEdits = coalesce(edits.map(edit => ResourceTextEdit.is(edit) && isEqual(resource, edit.resource) ? edit.textEdit : undefined)); + if (textEdits.length !== edits.length) { // more than one file has changed, fall back to bulk edit preview + return false; + } + const editorToApply = await this.codeEditorService.openCodeEditor({ resource }, codeEditor); + if (editorToApply) { + const inlineChatController = InlineChatController.get(editorToApply); + if (inlineChatController) { + const tokenSource = new CancellationTokenSource(); + let isOpen = true; + const firstEdit = textEdits[0]; + editorToApply.revealLineInCenterIfOutsideViewport(firstEdit.range.startLineNumber); + const promise = inlineChatController.reviewEdits(textEdits[0].range, AsyncIterableObject.fromArray(textEdits), tokenSource.token); + promise.finally(() => { + isOpen = false; + tokenSource.dispose(); + }); + this.inlineChatPreview = { + promise, + isOpen: () => isOpen, + cancel: () => tokenSource.cancel(), + }; + return true; + } + } + return false; + } + + private tryToRevealCodeBlock(codeEditor: IActiveCodeEditor, codeBlock: string): void { + const match = codeBlock.match(/(\S[^\n]*)\n/); // substring that starts with a non-whitespace character and ends with a newline + if (match && match[1].length > 10) { + const findMatch = codeEditor.getModel().findNextMatch(match[1], { lineNumber: 1, column: 1 }, false, false, null, false); + if (findMatch) { + codeEditor.revealRangeInCenter(findMatch.range); + } + } + } + + private notify(message: string) { + //this.notificationService.notify({ severity: Severity.Info, message }); + this.dialogService.info(message); + } + +} + +type InlineChatPreview = { + isOpen(): boolean; + cancel(): void; + readonly promise: Promise; +}; + +function notifyUserAction(chatService: IAideAgentService, context: ICodeBlockActionContext, action: ChatUserAction) { + if (isResponseVM(context.element)) { + chatService.notifyUserAction({ + agentId: context.element.agent?.id, + command: context.element.slashCommand?.name, + sessionId: context.element.sessionId, + // requestId: context.element.requestId, + // TODO(@ghostwriternr): This is obviously wrong, but not critical to fix yet. + requestId: context.element.id, + result: context.element.result, + action + }); + } +} + +function getActiveNotebookEditor(editorService: IEditorService): IActiveNotebookEditor | undefined { + const activeEditorPane = editorService.activeEditorPane; + if (activeEditorPane?.getId() === NOTEBOOK_EDITOR_ID) { + const notebookEditor = activeEditorPane.getControl() as INotebookEditor; + if (notebookEditor.hasModel()) { + return notebookEditor; + } + } + return undefined; +} + +function getEditableActiveCodeEditor(editorService: IEditorService): IActiveCodeEditor | undefined { + const activeCodeEditorInNotebook = getActiveNotebookEditor(editorService)?.activeCodeEditor; + if (activeCodeEditorInNotebook && activeCodeEditorInNotebook.hasTextFocus() && activeCodeEditorInNotebook.hasModel()) { + return activeCodeEditorInNotebook; + } + + let activeEditorControl = editorService.activeTextEditorControl; + if (isDiffEditor(activeEditorControl)) { + activeEditorControl = activeEditorControl.getOriginalEditor().hasTextFocus() ? activeEditorControl.getOriginalEditor() : activeEditorControl.getModifiedEditor(); + } + + if (!isCodeEditor(activeEditorControl)) { + return undefined; + } + + if (!activeEditorControl.hasModel()) { + return undefined; + } + return activeEditorControl; +} + +function isReadOnly(model: ITextModel, textFileService: ITextFileService): boolean { + // Check if model is editable, currently only support untitled and text file + const activeTextModel = textFileService.files.get(model.uri) ?? textFileService.untitled.get(model.uri); + return !!activeTextModel?.isReadonly(); +} + +function collectDocumentContextFromSelections(codeEditor: IActiveCodeEditor, result: DocumentContextItem[][]): void { + const activeModel = codeEditor.getModel(); + const currentDocUri = activeModel.uri; + const currentDocVersion = activeModel.getVersionId(); + const selections = codeEditor.getSelections(); + if (selections.length > 0) { + result.push([ + { + uri: currentDocUri, + version: currentDocVersion, + ranges: selections, + } + ]); + } +} + + +function collectDocumentContextFromContext(context: ICodeBlockActionContext, result: DocumentContextItem[][]): void { + if (isResponseVM(context.element) && context.element.usedContext?.documents) { + result.push(context.element.usedContext.documents); + } +} + +function getChatConversation(context: ICodeBlockActionContext): (ConversationRequest | ConversationResponse)[] { + // TODO@aeschli for now create a conversation with just the current element + // this will be expanded in the future to include the request and any other responses + + if (isResponseVM(context.element)) { + return [{ + type: 'response', + message: context.element.response.toMarkdown(), + references: getReferencesAsDocumentContext(context.element.contentReferences) + }]; + } else if (isRequestVM(context.element)) { + return [{ + type: 'request', + message: context.element.messageText, + }]; + } else { + return []; + } +} + +function getReferencesAsDocumentContext(res: readonly IChatContentReference[]): DocumentContextItem[] { + const map = new ResourceMap(); + for (const r of res) { + let uri; + let range; + if (URI.isUri(r.reference)) { + uri = r.reference; + } else if (isLocation(r.reference)) { + uri = r.reference.uri; + range = r.reference.range; + } + if (uri) { + const item = map.get(uri); + if (item) { + if (range) { + item.ranges.push(range); + } + } else { + map.set(uri, { uri, version: -1, ranges: range ? [range] : [] }); + } + } + } + return [...map.values()]; +} + + +function reindent(codeBlockContent: string, model: ITextModel, seletionStartLine: number): string { + const newContent = strings.splitLines(codeBlockContent); + if (newContent.length === 0) { + return codeBlockContent; + } + + const formattingOptions = model.getFormattingOptions(); + const codeIndentLevel = computeIndentation(model.getLineContent(seletionStartLine), formattingOptions.tabSize).level; + + const indents = newContent.map(line => computeIndentation(line, formattingOptions.tabSize)); + + // find the smallest indent level in the code block + const newContentIndentLevel = indents.reduce((min, indent, index) => { + if (indent.length !== newContent[index].length) { // ignore empty lines + return Math.min(indent.level, min); + } + return min; + }, Number.MAX_VALUE); + + if (newContentIndentLevel === Number.MAX_VALUE || newContentIndentLevel === codeIndentLevel) { + // all lines are empty or the indent is already correct + return codeBlockContent; + } + const newLines = []; + for (let i = 0; i < newContent.length; i++) { + const { level, length } = indents[i]; + const newLevel = Math.max(0, codeIndentLevel + level - newContentIndentLevel); + const newIndentation = formattingOptions.insertSpaces ? ' '.repeat(formattingOptions.tabSize * newLevel) : '\t'.repeat(newLevel); + newLines.push(newIndentation + newContent[i].substring(length)); + } + return newLines.join('\n'); +} + +/** + * Returns: + * - level: the line's the ident level in tabs + * - length: the number of characters of the leading whitespace + */ +export function computeIndentation(line: string, tabSize: number): { level: number; length: number } { + let nSpaces = 0; + let level = 0; + let i = 0; + let length = 0; + const len = line.length; + while (i < len) { + const chCode = line.charCodeAt(i); + if (chCode === CharCode.Space) { + nSpaces++; + if (nSpaces === tabSize) { + level++; + nSpaces = 0; + length = i + 1; + } + } else if (chCode === CharCode.Tab) { + level++; + nSpaces = 0; + length = i + 1; + } else { + break; + } + i++; + } + return { level, length }; +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgent.contribution.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgent.contribution.ts index 158ff4d32db..ffd6c0c129c 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/aideAgent.contribution.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgent.contribution.ts @@ -3,20 +3,287 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { MarkdownString, isMarkdownString } from '../../../../base/common/htmlContent.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { isMacintosh } from '../../../../base/common/platform.js'; +import * as nls from '../../../../nls.js'; +import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js'; +import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; +import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor.js'; +import { ChatAccessibilityHelp } from './actions/aideAgentAccessibilityHelp.js'; +import { registerChatActions } from './actions/aideAgentActions.js'; +import { ACTION_ID_NEW_CHAT, registerNewChatActions } from './actions/aideAgentClearActions.js'; +import { registerChatCodeBlockActions, registerChatCodeCompareBlockActions } from './actions/aideAgentCodeblockActions.js'; +import { registerChatContextActions } from './actions/aideAgentContextActions.js'; +import { registerChatCopyActions } from './actions/aideAgentCopyActions.js'; +import { registerChatDeveloperActions } from './actions/aideAgentDeveloperActions.js'; +import { SubmitAction, registerChatExecuteActions } from './actions/aideAgentExecuteActions.js'; +import { registerChatFileTreeActions } from './actions/aideAgentFileTreeActions.js'; +import { registerChatTitleActions } from './actions/aideAgentTitleActions.js'; +import { IAideAgentAccessibilityService, IAideAgentCodeBlockContextProviderService, IAideAgentWidgetService } from './aideAgent.js'; +import { AideAgentAccessibilityService } from './aideAgentAccessibilityService.js'; +import { ChatEditor, IChatEditorOptions } from './aideAgentEditor.js'; +import { ChatEditorInput, ChatEditorInputSerializer } from './aideAgentEditorInput.js'; +import { agentSlashCommandToMarkdown, agentToMarkdown } from './aideAgentMarkdownDecorationsRenderer.js'; +import { ChatCompatibilityNotifier, ChatExtensionPointHandler } from './aideAgentParticipantContributions.js'; +import { ChatResponseAccessibleView } from './aideAgentResponseAccessibleView.js'; +import { ChatVariablesService } from './aideAgentVariables.js'; +import { ChatWidgetService } from './aideAgentWidget.js'; +import { AideAgentCodeBlockContextProviderService } from './codeBlockContextProviderService.js'; +import './contrib/aideAgentContextAttachments.js'; +import './contrib/aideAgentInputCompletions.js'; +import './contrib/aideAgentInputEditorContrib.js'; +import { ChatAgentLocation, ChatAgentNameService, ChatAgentService, IAideAgentAgentNameService, IAideAgentAgentService } from '../common/aideAgentAgents.js'; +import { chatVariableLeader } from '../common/aideAgentParserTypes.js'; import { IAideAgentService } from '../common/aideAgentService.js'; -import { AideAgentService } from '../common/aideAgentServiceImpl.js'; -import { registerAgentActions } from './actions/aideAgentActions.js'; -import { AideControls } from './aideControls.js'; -import { AideControlsService, IAideControlsService } from './aideControlsService.js'; +import { ChatService } from '../common/aideAgentServiceImpl.js'; +import { ChatSlashCommandService, IAideAgentSlashCommandService } from '../common/aideAgentSlashCommands.js'; +import { IAideAgentVariablesService } from '../common/aideAgentVariables.js'; +import { ChatWidgetHistoryService, IAideAgentWidgetHistoryService } from '../common/aideAgentWidgetHistoryService.js'; +import { IAideAgentLMService, LanguageModelsService } from '../common/languageModels.js'; +import { IAideAgentLMStatsService, LanguageModelStatsService } from '../common/languageModelStats.js'; +import { IAideAgentLMToolsService, LanguageModelToolsService } from '../common/languageModelToolsService.js'; +import { LanguageModelToolsExtensionPointHandler } from '../common/tools/languageModelToolsContribution.js'; +import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; +import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; +import '../common/aideAgentColors.js'; +import { ChatGettingStartedContribution } from './aideAgentGettingStarted.js'; +import { CodeMapperService, IAideAgentCodeMapperService } from '../common/aideAgentCodeMapperService.js'; +import { IAideAgentEditingService } from '../common/aideAgentEditingService.js'; +import { ChatEditingService } from './aideAgentEditingService.js'; -// Register services -registerSingleton(IAideAgentService, AideAgentService, InstantiationType.Delayed); -registerSingleton(IAideControlsService, AideControlsService, InstantiationType.Delayed); +// Register configuration +const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); +configurationRegistry.registerConfiguration({ + id: 'chatSidebar', + title: nls.localize('interactiveSessionConfigurationTitle', "Chat"), + type: 'object', + properties: { + 'chat.editor.fontSize': { + type: 'number', + description: nls.localize('interactiveSession.editor.fontSize', "Controls the font size in pixels in chat codeblocks."), + default: isMacintosh ? 14 : 14, + }, + 'chat.editor.fontFamily': { + type: 'string', + description: nls.localize('interactiveSession.editor.fontFamily', "Controls the font family in chat codeblocks."), + default: 'default' + }, + 'chat.editor.fontWeight': { + type: 'string', + description: nls.localize('interactiveSession.editor.fontWeight', "Controls the font weight in chat codeblocks."), + default: 'default' + }, + 'chat.editor.wordWrap': { + type: 'string', + description: nls.localize('interactiveSession.editor.wordWrap', "Controls whether lines should wrap in chat codeblocks."), + default: 'off', + enum: ['on', 'off'] + }, + 'chat.editor.lineHeight': { + type: 'number', + description: nls.localize('interactiveSession.editor.lineHeight', "Controls the line height in pixels in chat codeblocks. Use 0 to compute the line height from the font size."), + default: 0 + }, + 'chat.experimental.implicitContext': { + type: 'boolean', + description: nls.localize('chat.experimental.implicitContext', "Controls whether a checkbox is shown to allow the user to determine which implicit context is included with a chat participant's prompt."), + deprecated: true, + default: false + }, + 'chat.experimental.variables.editor': { + type: 'boolean', + description: nls.localize('chat.experimental.variables.editor', "Enables variables for editor chat."), + default: true + }, + 'chat.experimental.variables.notebook': { + type: 'boolean', + description: nls.localize('chat.experimental.variables.notebook', "Enables variables for notebook chat."), + default: false + }, + 'chat.experimental.variables.terminal': { + type: 'boolean', + description: nls.localize('chat.experimental.variables.terminal', "Enables variables for terminal chat."), + default: false + }, + 'chat.experimental.detectParticipant.enabled': { + type: 'boolean', + description: nls.localize('chat.experimental.detectParticipant.enabled', "Enables chat participant autodetection for panel chat."), + default: null + }, + } +}); +Registry.as(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create( + ChatEditor, + ChatEditorInput.EditorID, + nls.localize('chat', "Chat") + ), + [ + new SyncDescriptor(ChatEditorInput) + ] +); -// Register actions -registerAgentActions(); +class ChatResolverContribution extends Disposable { -// Register workbench contributions -registerWorkbenchContribution2(AideControls.ID, AideControls, WorkbenchPhase.Eventually); + static readonly ID = 'workbench.contrib.aideAgentResolver'; + + constructor( + @IEditorResolverService editorResolverService: IEditorResolverService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + this._register(editorResolverService.registerEditor( + `${Schemas.vscodeAideAgentSesssion}:**/**`, + { + id: ChatEditorInput.EditorID, + label: nls.localize('chat', "Chat"), + priority: RegisteredEditorPriority.builtin + }, + { + singlePerResource: true, + canSupportResource: resource => resource.scheme === Schemas.vscodeAideAgentSesssion + }, + { + createEditorInput: ({ resource, options }) => { + return { editor: instantiationService.createInstance(ChatEditorInput, resource, options as IChatEditorOptions), options }; + } + } + )); + } +} + +AccessibleViewRegistry.register(new ChatResponseAccessibleView()); +AccessibleViewRegistry.register(new ChatAccessibilityHelp()); + +class ChatSlashStaticSlashCommandsContribution extends Disposable { + + constructor( + @IAideAgentSlashCommandService slashCommandService: IAideAgentSlashCommandService, + @ICommandService commandService: ICommandService, + @IAideAgentAgentService chatAgentService: IAideAgentAgentService, + @IAideAgentVariablesService chatVariablesService: IAideAgentVariablesService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'clear', + detail: nls.localize('clear', "Start a new session"), + sortText: 'z2_clear', + executeImmediately: true, + locations: [ChatAgentLocation.Panel] + }, async () => { + commandService.executeCommand(ACTION_ID_NEW_CHAT); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'help', + detail: '', + sortText: 'z1_help', + executeImmediately: true, + locations: [ChatAgentLocation.Panel] + }, async (prompt, progress) => { + const defaultAgent = chatAgentService.getDefaultAgent(ChatAgentLocation.Panel); + const agents = chatAgentService.getAgents(); + + // Report prefix + if (defaultAgent?.metadata.helpTextPrefix) { + if (isMarkdownString(defaultAgent.metadata.helpTextPrefix)) { + progress.report({ content: defaultAgent.metadata.helpTextPrefix, kind: 'markdownContent' }); + } else { + progress.report({ content: new MarkdownString(defaultAgent.metadata.helpTextPrefix), kind: 'markdownContent' }); + } + progress.report({ content: new MarkdownString('\n\n'), kind: 'markdownContent' }); + } + + // Report agent list + const agentText = (await Promise.all(agents + .filter(a => a.id !== defaultAgent?.id) + .filter(a => a.locations.includes(ChatAgentLocation.Panel)) + .map(async a => { + const description = a.description ? `- ${a.description}` : ''; + const agentMarkdown = instantiationService.invokeFunction(accessor => agentToMarkdown(a, true, accessor)); + const agentLine = `- ${agentMarkdown} ${description}`; + const commandText = a.slashCommands.map(c => { + const description = c.description ? `- ${c.description}` : ''; + return `\t* ${agentSlashCommandToMarkdown(a, c)} ${description}`; + }).join('\n'); + + return (agentLine + '\n' + commandText).trim(); + }))).join('\n'); + progress.report({ content: new MarkdownString(agentText, { isTrusted: { enabledCommands: [SubmitAction.ID] } }), kind: 'markdownContent' }); + + // Report variables + if (defaultAgent?.metadata.helpTextVariablesPrefix) { + progress.report({ content: new MarkdownString('\n\n'), kind: 'markdownContent' }); + if (isMarkdownString(defaultAgent.metadata.helpTextVariablesPrefix)) { + progress.report({ content: defaultAgent.metadata.helpTextVariablesPrefix, kind: 'markdownContent' }); + } else { + progress.report({ content: new MarkdownString(defaultAgent.metadata.helpTextVariablesPrefix), kind: 'markdownContent' }); + } + + const variables = [ + ...chatVariablesService.getVariables(ChatAgentLocation.Panel), + { name: 'file', description: nls.localize('file', "Choose a file in the workspace") } + ]; + const variableText = variables + .map(v => `* \`${chatVariableLeader}${v.name}\` - ${v.description}`) + .join('\n'); + progress.report({ content: new MarkdownString('\n' + variableText), kind: 'markdownContent' }); + } + + // Report help text ending + if (defaultAgent?.metadata.helpTextPostfix) { + progress.report({ content: new MarkdownString('\n\n'), kind: 'markdownContent' }); + if (isMarkdownString(defaultAgent.metadata.helpTextPostfix)) { + progress.report({ content: defaultAgent.metadata.helpTextPostfix, kind: 'markdownContent' }); + } else { + progress.report({ content: new MarkdownString(defaultAgent.metadata.helpTextPostfix), kind: 'markdownContent' }); + } + } + })); + } +} + +const workbenchContributionsRegistry = Registry.as(WorkbenchExtensions.Workbench); +registerWorkbenchContribution2(ChatResolverContribution.ID, ChatResolverContribution, WorkbenchPhase.BlockStartup); +workbenchContributionsRegistry.registerWorkbenchContribution(ChatSlashStaticSlashCommandsContribution, LifecyclePhase.Eventually); +Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer(ChatEditorInput.TypeID, ChatEditorInputSerializer); +registerWorkbenchContribution2(ChatExtensionPointHandler.ID, ChatExtensionPointHandler, WorkbenchPhase.BlockStartup); +registerWorkbenchContribution2(LanguageModelToolsExtensionPointHandler.ID, LanguageModelToolsExtensionPointHandler, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatCompatibilityNotifier.ID, ChatCompatibilityNotifier, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(ChatGettingStartedContribution.ID, ChatGettingStartedContribution, WorkbenchPhase.Eventually); + +registerChatActions(); +registerChatCopyActions(); +registerChatCodeBlockActions(); +registerChatCodeCompareBlockActions(); +registerChatFileTreeActions(); +registerChatTitleActions(); +registerChatExecuteActions(); +registerNewChatActions(); +registerChatContextActions(); +registerChatDeveloperActions(); + +registerSingleton(IAideAgentService, ChatService, InstantiationType.Delayed); +registerSingleton(IAideAgentWidgetService, ChatWidgetService, InstantiationType.Delayed); +registerSingleton(IAideAgentAccessibilityService, AideAgentAccessibilityService, InstantiationType.Delayed); +registerSingleton(IAideAgentWidgetHistoryService, ChatWidgetHistoryService, InstantiationType.Delayed); +registerSingleton(IAideAgentLMService, LanguageModelsService, InstantiationType.Delayed); +registerSingleton(IAideAgentLMStatsService, LanguageModelStatsService, InstantiationType.Delayed); +registerSingleton(IAideAgentSlashCommandService, ChatSlashCommandService, InstantiationType.Delayed); +registerSingleton(IAideAgentAgentService, ChatAgentService, InstantiationType.Delayed); +registerSingleton(IAideAgentAgentNameService, ChatAgentNameService, InstantiationType.Delayed); +registerSingleton(IAideAgentVariablesService, ChatVariablesService, InstantiationType.Delayed); +registerSingleton(IAideAgentLMToolsService, LanguageModelToolsService, InstantiationType.Delayed); +registerSingleton(IAideAgentCodeBlockContextProviderService, AideAgentCodeBlockContextProviderService, InstantiationType.Delayed); +registerSingleton(IAideAgentCodeMapperService, CodeMapperService, InstantiationType.Delayed); +registerSingleton(IAideAgentEditingService, ChatEditingService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgent.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgent.ts new file mode 100644 index 00000000000..7965eb8df7c --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgent.ts @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; +import { localize } from '../../../../nls.js'; +import { MenuId } from '../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ChatViewPane } from './aideAgentViewPane.js'; +import { IChatViewState, IChatWidgetCompletionContext, IChatWidgetContrib } from './aideAgentWidget.js'; +import { ICodeBlockActionContext } from './codeBlockPart.js'; +import { ChatAgentLocation, IChatAgentCommand, IChatAgentData } from '../common/aideAgentAgents.js'; +import { IChatRequestVariableEntry, IChatResponseModel } from '../common/aideAgentModel.js'; +import { IParsedChatRequest } from '../common/aideAgentParserTypes.js'; +import { CHAT_PROVIDER_ID } from '../common/aideAgentParticipantContribTypes.js'; +import { IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, IChatWelcomeMessageViewModel } from '../common/aideAgentViewModel.js'; +import { IViewsService } from '../../../services/views/common/viewsService.js'; + +export const IAideAgentWidgetService = createDecorator('aideAgentWidgetService'); + +export interface IAideAgentWidgetService { + + readonly _serviceBrand: undefined; + + /** + * Returns the most recently focused widget if any. + */ + readonly lastFocusedWidget: IChatWidget | undefined; + + getWidgetByInputUri(uri: URI): IChatWidget | undefined; + getWidgetBySessionId(sessionId: string): IChatWidget | undefined; +} + +export async function showChatView(viewsService: IViewsService): Promise { + return (await viewsService.openView(CHAT_VIEW_ID))?.widget; +} + +export const IAideAgentAccessibilityService = createDecorator('aideAgentAccessibilityService'); +export interface IAideAgentAccessibilityService { + readonly _serviceBrand: undefined; + acceptRequest(): number; + acceptResponse(response: IChatResponseViewModel | string | undefined, requestId: number): void; +} + +export interface IChatCodeBlockInfo { + readonly ownerMarkdownPartId: string; + readonly codeBlockIndex: number; + readonly element: ChatTreeItem; + readonly uri: URI | undefined; + codemapperUri: URI | undefined; + focus(): void; + getContent(): string; +} + +export interface IChatFileTreeInfo { + treeDataId: string; + treeIndex: number; + focus(): void; +} + +export type ChatTreeItem = IChatRequestViewModel | IChatResponseViewModel | IChatWelcomeMessageViewModel; + +export interface IChatListItemRendererOptions { + readonly renderStyle?: 'default' | 'compact' | 'minimal'; + readonly noHeader?: boolean; + readonly noPadding?: boolean; + readonly editableCodeBlock?: boolean; + readonly renderTextEditsAsSummary?: (uri: URI) => boolean; +} + +export interface IChatWidgetViewOptions { + renderInputOnTop?: boolean; + renderFollowups?: boolean; + renderStyle?: 'default' | 'compact' | 'minimal'; + supportsFileReferences?: boolean; + filter?: (item: ChatTreeItem) => boolean; + rendererOptions?: IChatListItemRendererOptions; + menus?: { + /** + * The menu that is inside the input editor, use for send, dictation + */ + executeToolbar?: MenuId; + /** + * The menu that next to the input editor, use for close, config etc + */ + inputSideToolbar?: MenuId; + /** + * The telemetry source for all commands of this widget + */ + telemetrySource?: string; + }; + defaultElementHeight?: number; + editorOverflowWidgetsDomNode?: HTMLElement; +} + +export interface IChatViewViewContext { + viewId: string; +} + +export type IChatWidgetViewContext = IChatViewViewContext | {}; + +export interface IChatWidget { + readonly onDidChangeViewModel: Event; + readonly onDidAcceptInput: Event; + readonly onDidHide: Event; + readonly onDidSubmitAgent: Event<{ agent: IChatAgentData; slashCommand?: IChatAgentCommand }>; + readonly onDidChangeAgent: Event<{ agent: IChatAgentData; slashCommand?: IChatAgentCommand }>; + readonly onDidChangeParsedInput: Event; + readonly onDidChangeContext: Event<{ removed?: IChatRequestVariableEntry[]; added?: IChatRequestVariableEntry[] }>; + readonly location: ChatAgentLocation; + readonly viewContext: IChatWidgetViewContext; + readonly viewModel: IChatViewModel | undefined; + readonly inputEditor: ICodeEditor; + readonly supportsFileReferences: boolean; + readonly parsedInput: IParsedChatRequest; + lastSelectedAgent: IChatAgentData | undefined; + readonly scopedContextKeyService: IContextKeyService; + completionContext: IChatWidgetCompletionContext; + + getContrib(id: string): T | undefined; + reveal(item: ChatTreeItem): void; + focus(item: ChatTreeItem): void; + getSibling(item: ChatTreeItem, type: 'next' | 'previous'): ChatTreeItem | undefined; + getFocus(): ChatTreeItem | undefined; + setInput(query?: string): void; + getInput(): string; + logInputHistory(): void; + acceptInput(query?: string): Promise; + acceptInputWithPrefix(prefix: string): void; + setInputPlaceholder(placeholder: string): void; + resetInputPlaceholder(): void; + focusLastMessage(): void; + focusInput(): void; + hasInputFocus(): boolean; + getCodeBlockInfoForEditor(uri: URI): IChatCodeBlockInfo | undefined; + getCodeBlockInfosForResponse(response: IChatResponseViewModel): IChatCodeBlockInfo[]; + getFileTreeInfosForResponse(response: IChatResponseViewModel): IChatFileTreeInfo[]; + getLastFocusedFileTreeForResponse(response: IChatResponseViewModel): IChatFileTreeInfo | undefined; + setContext(overwrite: boolean, ...context: IChatRequestVariableEntry[]): void; + clear(): void; + getViewState(): IChatViewState; +} + + +export interface ICodeBlockActionContextProvider { + getCodeBlockContext(editor?: ICodeEditor): ICodeBlockActionContext | undefined; +} + +export const IAideAgentCodeBlockContextProviderService = createDecorator('aideAgentCodeBlockContextProviderService'); +export interface IAideAgentCodeBlockContextProviderService { + readonly _serviceBrand: undefined; + readonly providers: ICodeBlockActionContextProvider[]; + registerProvider(provider: ICodeBlockActionContextProvider, id: string): IDisposable; +} + +export const GeneratingPhrase = localize('generating', "Generating"); + +export const CHAT_VIEW_ID = `workbench.panel.chat.view.${CHAT_PROVIDER_ID}`; diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentAccessibilityProvider.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentAccessibilityProvider.ts new file mode 100644 index 00000000000..9d3b54c9028 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentAccessibilityProvider.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AriaRole } from '../../../../base/browser/ui/aria/aria.js'; +import { IListAccessibilityProvider } from '../../../../base/browser/ui/list/listWidget.js'; +import { marked } from '../../../../base/common/marked/marked.js'; +import { localize } from '../../../../nls.js'; +import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; +import { IAccessibleViewService } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { ChatTreeItem } from './aideAgent.js'; +import { isRequestVM, isResponseVM, isWelcomeVM, IChatResponseViewModel } from '../common/aideAgentViewModel.js'; + +export class ChatAccessibilityProvider implements IListAccessibilityProvider { + + constructor( + @IAccessibleViewService private readonly _accessibleViewService: IAccessibleViewService + ) { + + } + getWidgetRole(): AriaRole { + return 'list'; + } + + getRole(element: ChatTreeItem): AriaRole | undefined { + return 'listitem'; + } + + getWidgetAriaLabel(): string { + return localize('chat', "Chat"); + } + + getAriaLabel(element: ChatTreeItem): string { + if (isRequestVM(element)) { + return element.messageText; + } + + if (isResponseVM(element)) { + return this._getLabelWithCodeBlockCount(element); + } + + if (isWelcomeVM(element)) { + return element.content.map(c => 'value' in c ? c.value : c.map(followup => followup.message).join('\n')).join('\n'); + } + + return ''; + } + + private _getLabelWithCodeBlockCount(element: IChatResponseViewModel): string { + const accessibleViewHint = this._accessibleViewService.getOpenAriaHint(AccessibilityVerbositySettingId.Chat); + let label: string = ''; + const fileTreeCount = element.response.value.filter((v) => !('value' in v))?.length ?? 0; + let fileTreeCountHint = ''; + switch (fileTreeCount) { + case 0: + break; + case 1: + fileTreeCountHint = localize('singleFileTreeHint', "1 file tree"); + break; + default: + fileTreeCountHint = localize('multiFileTreeHint', "{0} file trees", fileTreeCount); + break; + } + const codeBlockCount = marked.lexer(element.response.toString()).filter(token => token.type === 'code')?.length ?? 0; + switch (codeBlockCount) { + case 0: + label = accessibleViewHint ? localize('noCodeBlocksHint', "{0} {1} {2}", fileTreeCountHint, element.response.toString(), accessibleViewHint) : localize('noCodeBlocks', "{0} {1}", fileTreeCountHint, element.response.toString()); + break; + case 1: + label = accessibleViewHint ? localize('singleCodeBlockHint', "{0} 1 code block: {1} {2}", fileTreeCountHint, element.response.toString(), accessibleViewHint) : localize('singleCodeBlock', "{0} 1 code block: {1}", fileTreeCountHint, element.response.toString()); + break; + default: + label = accessibleViewHint ? localize('multiCodeBlockHint', "{0} {1} code blocks: {2}", fileTreeCountHint, codeBlockCount, element.response.toString(), accessibleViewHint) : localize('multiCodeBlock', "{0} {1} code blocks", fileTreeCountHint, codeBlockCount, element.response.toString()); + break; + } + return label; + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentAccessibilityService.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentAccessibilityService.ts new file mode 100644 index 00000000000..14878ccf5bc --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentAccessibilityService.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { status } from '../../../../base/browser/ui/aria/aria.js'; +import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; +import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { AccessibilityProgressSignalScheduler } from '../../../../platform/accessibilitySignal/browser/progressAccessibilitySignalScheduler.js'; +import { IAideAgentAccessibilityService } from './aideAgent.js'; +import { IChatResponseViewModel } from '../common/aideAgentViewModel.js'; +import { renderStringAsPlaintext } from '../../../../base/browser/markdownRenderer.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { AccessibilityVoiceSettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; + +const CHAT_RESPONSE_PENDING_ALLOWANCE_MS = 4000; +export class AideAgentAccessibilityService extends Disposable implements IAideAgentAccessibilityService { + + declare readonly _serviceBrand: undefined; + + private _pendingSignalMap: DisposableMap = this._register(new DisposableMap()); + + private _requestId: number = 0; + + constructor( + @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @ICommandService private readonly _commandService: ICommandService, + ) { + super(); + } + acceptRequest(): number { + this._requestId++; + this._accessibilitySignalService.playSignal(AccessibilitySignal.chatRequestSent, { allowManyInParallel: true }); + this._pendingSignalMap.set(this._requestId, this._instantiationService.createInstance(AccessibilityProgressSignalScheduler, CHAT_RESPONSE_PENDING_ALLOWANCE_MS, undefined)); + return this._requestId; + } + acceptResponse(response: IChatResponseViewModel | string | undefined, requestId: number): void { + this._pendingSignalMap.deleteAndDispose(requestId); + const isPanelChat = typeof response !== 'string'; + const responseContent = typeof response === 'string' ? response : response?.response.toString(); + this._accessibilitySignalService.playSignal(AccessibilitySignal.chatResponseReceived, { allowManyInParallel: true }); + if (!response || !responseContent) { + return; + } + const errorDetails = isPanelChat && response.errorDetails ? ` ${response.errorDetails.message}` : ''; + const plainTextResponse = renderStringAsPlaintext(new MarkdownString(responseContent)); + if (this._configurationService.getValue(AccessibilityVoiceSettingId.AutoSynthesize) === 'on') { + this._commandService.executeCommand('workbench.action.chat.readChatResponseAloud'); + } else { + status(plainTextResponse + errorDetails); + } + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentAttachmentsContentPart.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentAttachmentsContentPart.ts new file mode 100644 index 00000000000..e2b8c7b682c --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentAttachmentsContentPart.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { IChatRequestVariableEntry } from '../../common/aideAgentModel.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ResourceLabels } from '../../../../browser/labels.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { FileKind } from '../../../../../platform/files/common/files.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { basename, dirname } from '../../../../../base/common/path.js'; +import { localize } from '../../../../../nls.js'; +import { ChatResponseReferencePartStatusKind, IChatContentReference } from '../../common/aideAgentService.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; + +export class ChatAttachmentsContentPart extends Disposable { + private readonly attachedContextDisposables = this._register(new DisposableStore()); + + private readonly _onDidChangeVisibility = this._register(new Emitter()); + private readonly _contextResourceLabels = this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility.event }); + + constructor( + private readonly variables: IChatRequestVariableEntry[], + private readonly contentReferences: ReadonlyArray = [], + public readonly domNode: HTMLElement = dom.$('.chat-attached-context'), + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IOpenerService private readonly openerService: IOpenerService, + ) { + super(); + + this.initAttachedContext(domNode); + } + + private initAttachedContext(container: HTMLElement) { + dom.clearNode(container); + this.attachedContextDisposables.clear(); + dom.setVisibility(Boolean(this.variables.length), this.domNode); + + this.variables.forEach((attachment) => { + const widget = dom.append(container, dom.$('.chat-attached-context-attachment.show-file-icons')); + const label = this._contextResourceLabels.create(widget, { supportIcons: true }); + const file = 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; + + 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; + + 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); + } + + label.setFile(file, { + fileKind: FileKind.FILE, + hidePath: true, + range, + title: correspondingContentReference?.options?.status?.description + }); + widget.ariaLabel = ariaLabel; + widget.tabIndex = 0; + widget.style.cursor = 'pointer'; + + this.attachedContextDisposables.add(dom.addDisposableListener(widget, 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 = attachment.fullName ?? attachment.name; + const withIcon = attachment.icon?.id ? `$(${attachment.icon.id}) ${attachmentLabel}` : attachmentLabel; + label.setLabel(withIcon, correspondingContentReference?.options?.status?.description); + + widget.ariaLabel = localize('chat.attachment3', "Attached context: {0}.", attachment.name); + widget.tabIndex = 0; + } + + if (isAttachmentPartialOrOmitted) { + widget.classList.add('warning'); + } + const description = correspondingContentReference?.options?.status?.description; + if (isAttachmentPartialOrOmitted) { + widget.ariaLabel = `${widget.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'); + } + } + } + }); + } +} + diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentCodeCitationContentPart.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentCodeCitationContentPart.ts new file mode 100644 index 00000000000..a1c60e572a0 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentCodeCitationContentPart.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { ChatTreeItem } from '../aideAgent.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './aideAgentContentParts.js'; +import { getCodeCitationsMessage } from '../../common/aideAgentModel.js'; +import { IChatCodeCitations, IChatRendererContent } from '../../common/aideAgentViewModel.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; + +type ChatCodeCitationOpenedClassification = { + owner: 'roblourens'; + comment: 'Indicates when a user opens chat code citations'; +}; + +export class ChatCodeCitationContentPart extends Disposable implements IChatContentPart { + public readonly domNode: HTMLElement; + + constructor( + citations: IChatCodeCitations, + context: IChatContentPartRenderContext, + @IEditorService private readonly editorService: IEditorService, + @ITelemetryService private readonly telemetryService: ITelemetryService + ) { + super(); + + const label = getCodeCitationsMessage(citations.citations); + const elements = dom.h('.chat-code-citation-message@root', [ + dom.h('span.chat-code-citation-label@label'), + dom.h('.chat-code-citation-button-container@button'), + ]); + elements.label.textContent = label + ' - '; + const button = this._register(new Button(elements.button, { + buttonBackground: undefined, + buttonBorder: undefined, + buttonForeground: undefined, + buttonHoverBackground: undefined, + buttonSecondaryBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryHoverBackground: undefined, + buttonSeparator: undefined + })); + button.label = localize('viewMatches', "View matches"); + this._register(button.onDidClick(() => { + const citationText = `# Code Citations\n\n` + citations.citations.map(c => `## License: ${c.license}\n${c.value.toString()}\n\n\`\`\`\n${c.snippet}\n\`\`\`\n\n`).join('\n'); + this.editorService.openEditor({ resource: undefined, contents: citationText, languageId: 'markdown' }); + this.telemetryService.publicLog2<{}, ChatCodeCitationOpenedClassification>('openedChatCodeCitations'); + })); + this.domNode = elements.root; + } + + hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean { + return other.kind === 'codeCitations'; + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentCollections.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentCollections.ts new file mode 100644 index 00000000000..4432e44201a --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentCollections.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable, Disposable } from '../../../../../base/common/lifecycle.js'; + +export class ResourcePool extends Disposable { + private readonly pool: T[] = []; + + private _inUse = new Set; + public get inUse(): ReadonlySet { + return this._inUse; + } + + constructor( + private readonly _itemFactory: () => T, + ) { + super(); + } + + get(): T { + if (this.pool.length > 0) { + const item = this.pool.pop()!; + this._inUse.add(item); + return item; + } + + const item = this._register(this._itemFactory()); + this._inUse.add(item); + return item; + } + + release(item: T): void { + this._inUse.delete(item); + this.pool.push(item); + } +} + +export interface IDisposableReference extends IDisposable { + object: T; + isStale: () => boolean; +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentCommandContentPart.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentCommandContentPart.ts new file mode 100644 index 00000000000..1e75ccd3a80 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentCommandContentPart.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './aideAgentContentParts.js'; +import { IChatProgressRenderableResponseContent } from '../../common/aideAgentModel.js'; +import { IChatCommandButton } from '../../common/aideAgentService.js'; +import { isResponseVM } from '../../common/aideAgentViewModel.js'; + +const $ = dom.$; + +export class ChatCommandButtonContentPart extends Disposable implements IChatContentPart { + public readonly domNode: HTMLElement; + + constructor( + commandButton: IChatCommandButton, + context: IChatContentPartRenderContext, + @ICommandService private readonly commandService: ICommandService + ) { + super(); + + this.domNode = $('.chat-command-button'); + const enabled = !isResponseVM(context.element) || !context.element.isStale; + const tooltip = enabled ? + commandButton.command.tooltip : + localize('commandButtonDisabled', "Button not available in restored chat"); + const button = this._register(new Button(this.domNode, { ...defaultButtonStyles, supportIcons: true, title: tooltip })); + button.label = commandButton.command.title; + button.enabled = enabled; + + // TODO still need telemetry for command buttons + this._register(button.onDidClick(() => this.commandService.executeCommand(commandButton.command.id, ...(commandButton.command.arguments ?? [])))); + } + + hasSameContent(other: IChatProgressRenderableResponseContent): boolean { + // No other change allowed for this content type + return other.kind === 'command'; + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentConfirmationContentPart.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentConfirmationContentPart.ts new file mode 100644 index 00000000000..f18ad163461 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentConfirmationContentPart.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../../../base/common/event.js'; +import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ChatConfirmationWidget } from './aideAgentConfirmationWidget.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './aideAgentContentParts.js'; +import { IChatProgressRenderableResponseContent } from '../../common/aideAgentModel.js'; +import { IChatConfirmation, IChatSendRequestOptions, IAideAgentService } from '../../common/aideAgentService.js'; +import { isResponseVM } from '../../common/aideAgentViewModel.js'; + +export class ChatConfirmationContentPart extends Disposable implements IChatContentPart { + public readonly domNode: HTMLElement; + + private readonly _onDidChangeHeight = this._register(new Emitter()); + public readonly onDidChangeHeight = this._onDidChangeHeight.event; + + constructor( + confirmation: IChatConfirmation, + context: IChatContentPartRenderContext, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IAideAgentService private readonly chatService: IAideAgentService, + ) { + super(); + + const element = context.element; + const buttons = confirmation.buttons + ? confirmation.buttons.map(button => ({ + label: button, + data: confirmation.data + })) + : [ + { label: localize('accept', "Accept"), data: confirmation.data }, + { label: localize('dismiss', "Dismiss"), data: confirmation.data, isSecondary: true }, + ]; + const confirmationWidget = this._register(this.instantiationService.createInstance(ChatConfirmationWidget, confirmation.title, confirmation.message, buttons)); + confirmationWidget.setShowButtons(!confirmation.isUsed); + + this._register(confirmationWidget.onDidClick(async e => { + if (isResponseVM(element)) { + const prompt = `${e.label}: "${confirmation.title}"`; + const data: IChatSendRequestOptions = e.isSecondary ? + { rejectedConfirmationData: [e.data] } : + { acceptedConfirmationData: [e.data] }; + data.agentId = element.agent?.id; + data.slashCommand = element.slashCommand?.name; + data.confirmation = e.label; + if (await this.chatService.sendRequest(element.sessionId, prompt, data)) { + confirmation.isUsed = true; + confirmationWidget.setShowButtons(false); + this._onDidChangeHeight.fire(); + } + } + })); + + this.domNode = confirmationWidget.domNode; + } + + hasSameContent(other: IChatProgressRenderableResponseContent): boolean { + // No other change allowed for this content type + return other.kind === 'confirmation'; + } + + addDisposable(disposable: IDisposable): void { + this._register(disposable); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentConfirmationWidget.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentConfirmationWidget.ts new file mode 100644 index 00000000000..098a1673a33 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentConfirmationWidget.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import './media/aideAgentConfirmationWidget.css'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { MarkdownRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; + +export interface IChatConfirmationButton { + label: string; + isSecondary?: boolean; + data: any; +} + +export class ChatConfirmationWidget extends Disposable { + private _onDidClick = this._register(new Emitter()); + get onDidClick(): Event { return this._onDidClick.event; } + + private _domNode: HTMLElement; + get domNode(): HTMLElement { + return this._domNode; + } + + setShowButtons(showButton: boolean): void { + this.domNode.classList.toggle('hideButtons', !showButton); + } + + constructor( + title: string, + message: string, + buttons: IChatConfirmationButton[], + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + + const elements = dom.h('.chat-confirmation-widget@root', [ + dom.h('.chat-confirmation-widget-title@title'), + dom.h('.chat-confirmation-widget-message@message'), + dom.h('.chat-confirmation-buttons-container@buttonsContainer'), + ]); + this._domNode = elements.root; + const renderer = this._register(this.instantiationService.createInstance(MarkdownRenderer, {})); + + const renderedTitle = this._register(renderer.render(new MarkdownString(title))); + elements.title.appendChild(renderedTitle.element); + + const renderedMessage = this._register(renderer.render(new MarkdownString(message))); + elements.message.appendChild(renderedMessage.element); + + buttons.forEach(buttonData => { + const button = new Button(elements.buttonsContainer, { ...defaultButtonStyles, secondary: buttonData.isSecondary }); + button.label = buttonData.label; + this._register(button.onDidClick(() => this._onDidClick.fire(buttonData))); + }); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentContentParts.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentContentParts.ts new file mode 100644 index 00000000000..847fcad75e6 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentContentParts.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable } from '../../../../../base/common/lifecycle.js'; +import { ChatTreeItem } from '../aideAgent.js'; +import { IChatRendererContent } from '../../common/aideAgentViewModel.js'; + +export interface IChatContentPart extends IDisposable { + domNode: HTMLElement; + + /** + * Returns true if the other content is equivalent to what is already rendered in this content part. + * Returns false if a rerender is needed. + * followingContent is all the content that will be rendered after this content part (to support progress messages' behavior). + */ + hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean; +} + +export interface IChatContentPartRenderContext { + element: ChatTreeItem; + index: number; + content: ReadonlyArray; + preceedingContentParts: ReadonlyArray; +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentMarkdownContentPart.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentMarkdownContentPart.ts new file mode 100644 index 00000000000..a6fc13a401e --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentMarkdownContentPart.ts @@ -0,0 +1,194 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; +import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { equalsIgnoreCase } from '../../../../../base/common/strings.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 { MenuId } from '../../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IChatCodeBlockInfo, IChatListItemRendererOptions } from '../aideAgent.js'; +import { IDisposableReference, ResourcePool } from './aideAgentCollections.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './aideAgentContentParts.js'; +import { IChatRendererDelegate } from '../aideAgentListRenderer.js'; +import { ChatMarkdownDecorationsRenderer } from '../aideAgentMarkdownDecorationsRenderer.js'; +import { ChatEditorOptions } from '../aideAgentOptions.js'; +import { CodeBlockPart, ICodeBlockData, localFileLanguageId, parseLocalFileData } from '../codeBlockPart.js'; +import { IMarkdownVulnerability } from '../../common/annotations.js'; +import { IChatProgressRenderableResponseContent } from '../../common/aideAgentModel.js'; +import { isRequestVM, isResponseVM } from '../../common/aideAgentViewModel.js'; +import { CodeBlockModelCollection } from '../../common/codeBlockModelCollection.js'; +import { URI } from '../../../../../base/common/uri.js'; + +const $ = dom.$; + +export class ChatMarkdownContentPart extends Disposable implements IChatContentPart { + private static idPool = 0; + public readonly id = String(++ChatMarkdownContentPart.idPool); + public readonly domNode: HTMLElement; + private readonly allRefs: IDisposableReference[] = []; + + private readonly _onDidChangeHeight = this._register(new Emitter()); + public readonly onDidChangeHeight = this._onDidChangeHeight.event; + + public readonly codeblocks: IChatCodeBlockInfo[] = []; + + constructor( + private readonly markdown: IMarkdownString, + context: IChatContentPartRenderContext, + private readonly editorPool: EditorPool, + fillInIncompleteTokens = false, + codeBlockStartIndex = 0, + renderer: MarkdownRenderer, + currentWidth: number, + private readonly codeBlockModelCollection: CodeBlockModelCollection, + rendererOptions: IChatListItemRendererOptions, + @IContextKeyService contextKeyService: IContextKeyService, + @ITextModelService private readonly textModelService: ITextModelService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const element = context.element; + const markdownDecorationsRenderer = instantiationService.createInstance(ChatMarkdownDecorationsRenderer); + + // 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; + const result = this._register(renderer.render(markdown, { + fillInIncompleteTokens, + codeBlockRendererSync: (languageId, text) => { + const index = codeBlockIndex++; + let textModel: Promise; + let range: Range | undefined; + let vulns: readonly IMarkdownVulnerability[] | undefined; + let codemapperUri: URI | undefined; + 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); + } catch (e) { + return $('div'); + } + } else { + if (!isRequestVM(element) && !isResponseVM(element)) { + console.error('Trying to render code block in welcome', element.id, index); + return $('div'); + } + + const sessionId = isResponseVM(element) || isRequestVM(element) ? element.sessionId : ''; + const modelEntry = this.codeBlockModelCollection.getOrCreate(sessionId, element, index); + vulns = modelEntry.vulns; + codemapperUri = modelEntry.codemapperUri; + textModel = modelEntry.model; + } + + const hideToolbar = isResponseVM(element) && element.errorDetails?.responseIsFiltered; + const ref = this.renderCodeBlock({ languageId, textModel, codeBlockIndex: index, element, range, hideToolbar, parentContextKeyService: contextKeyService, vulns, codemapperUri }, text, currentWidth, rendererOptions.editableCodeBlock); + 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) + // not during a renderElement OR a progressive render (when we will be firing this event anyway at the end of the render) + this._register(ref.object.onDidChangeContentHeight(() => this._onDidChangeHeight.fire())); + + const ownerMarkdownPartId = this.id; + const info: IChatCodeBlockInfo = new class { + readonly ownerMarkdownPartId = ownerMarkdownPartId; + readonly codeBlockIndex = index; + readonly element = element; + codemapperUri = undefined; // will be set async + public get uri() { + // here we must do a getter because the ref.object is rendered + // async and the uri might be undefined when it's read immediately + return ref.object.uri; + } + public focus() { + ref.object.focus(); + } + public getContent(): string { + return ref.object.editor.getValue(); + } + }(); + this.codeblocks.push(info); + orderedDisposablesList.push(ref); + return ref.object.element; + }, + asyncRenderCallback: () => this._onDidChangeHeight.fire(), + })); + + this._register(markdownDecorationsRenderer.walkTreeAndAnnotateReferenceLinks(result.element)); + + orderedDisposablesList.reverse().forEach(d => this._register(d)); + this.domNode = result.element; + } + + private renderCodeBlock(data: ICodeBlockData, text: string, currentWidth: number, editableCodeBlock: boolean | undefined): 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) => { + // Update the existing object's codemapperUri + this.codeblocks[data.codeBlockIndex].codemapperUri = e.codemapperUri; + }); + } + + editorInfo.render(data, currentWidth, editableCodeBlock); + + return ref; + } + + hasSameContent(other: IChatProgressRenderableResponseContent): boolean { + return other.kind === 'markdownContent' && other.content.value === this.markdown.value; + } + + layout(width: number): void { + this.allRefs.forEach(ref => ref.object.layout(width)); + } + + addDisposable(disposable: IDisposable): void { + this._register(disposable); + } +} + +export class EditorPool extends Disposable { + + private readonly _pool: ResourcePool; + + public inUse(): Iterable { + return this._pool.inUse; + } + + constructor( + options: ChatEditorOptions, + delegate: IChatRendererDelegate, + overflowWidgetsDomNode: HTMLElement | undefined, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + this._pool = this._register(new ResourcePool(() => { + return instantiationService.createInstance(CodeBlockPart, options, MenuId.AideAgentCodeBlock, delegate, overflowWidgetsDomNode); + })); + } + + get(): IDisposableReference { + const codeBlock = this._pool.get(); + let stale = false; + return { + object: codeBlock, + isStale: () => stale, + dispose: () => { + codeBlock.reset(); + stale = true; + this._pool.release(codeBlock); + } + }; + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentProgressContentPart.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentProgressContentPart.ts new file mode 100644 index 00000000000..a6dd933d3f1 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentProgressContentPart.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $ } 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'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { MarkdownRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; +import { ChatTreeItem } from '../aideAgent.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './aideAgentContentParts.js'; +import { IChatProgressMessage, IChatTask } from '../../common/aideAgentService.js'; +import { IChatRendererContent, isResponseVM } from '../../common/aideAgentViewModel.js'; + +export class ChatProgressContentPart extends Disposable implements IChatContentPart { + public readonly domNode: HTMLElement; + + private readonly showSpinner: boolean; + + constructor( + progress: IChatProgressMessage | IChatTask, + renderer: MarkdownRenderer, + context: IChatContentPartRenderContext, + forceShowSpinner?: boolean, + forceShowMessage?: boolean + ) { + super(); + + const followingContent = context.content.slice(context.index + 1); + this.showSpinner = forceShowSpinner ?? shouldShowSpinner(followingContent, context.element); + const hideMessage = forceShowMessage !== true && followingContent.some(part => part.kind !== 'progressMessage'); + if (hideMessage) { + // Placeholder, don't show the progress message + this.domNode = $(''); + return; + } + + if (this.showSpinner) { + // TODO@roblourens is this the right place for this? + // 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}`, { + supportThemeIcons: true + }); + const result = this._register(renderer.render(markdown)); + result.element.classList.add('progress-step'); + + this.domNode = result.element; + } + + hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean { + // Needs rerender when spinner state changes + const showSpinner = shouldShowSpinner(followingContent, element); + return other.kind === 'progressMessage' && this.showSpinner === showSpinner; + } +} + +function shouldShowSpinner(followingContent: IChatRendererContent[], element: ChatTreeItem): boolean { + return isResponseVM(element) && !element.isComplete && followingContent.length === 0; +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentReferencesContentPart.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentReferencesContentPart.ts new file mode 100644 index 00000000000..edafcec5f97 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentReferencesContentPart.ts @@ -0,0 +1,394 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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 { coalesce } from '../../../../../base/common/arrays.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { matchesSomeScheme, Schemas } from '../../../../../base/common/network.js'; +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 { localize } from '../../../../../nls.js'; +import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.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 { WorkbenchList } from '../../../../../platform/list/browser/listService.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { fillEditorsDragData } from '../../../../browser/dnd.js'; +import { IResourceLabel, ResourceLabels } from '../../../../browser/labels.js'; +import { ColorScheme } from '../../../../browser/web.api.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, 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'; + +const $ = dom.$; + +export interface IChatReferenceListItem extends IChatContentReference { + title?: string; +} + +export type IChatCollapsibleListItem = IChatReferenceListItem | IChatWarningMessage; + +export class ChatCollapsibleListContentPart extends Disposable implements IChatContentPart { + public readonly domNode: HTMLElement; + + private readonly _onDidChangeHeight = this._register(new Emitter()); + public readonly onDidChangeHeight = this._onDidChangeHeight.event; + + constructor( + private readonly data: ReadonlyArray, + labelOverride: string | undefined, + element: IChatResponseViewModel, + contentReferencesListPool: CollapsibleListPool, + @IOpenerService openerService: IOpenerService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IClipboardService private readonly clipboardService: IClipboardService, + ) { + super(); + + const referencesLabel = labelOverride ?? (data.length > 1 ? + 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, + buttonBorder: undefined, + buttonForeground: undefined, + buttonHoverBackground: undefined, + buttonSecondaryBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryHoverBackground: undefined, + buttonSeparator: undefined + })); + this.domNode = $('.chat-used-context', undefined, buttonElement); + 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); + })); + + const ref = this._register(contentReferencesListPool.get()); + const list = ref.object; + this.domNode.appendChild(list.getHTMLElement().parentElement!); + + this._register(list.onDidOpen((e) => { + 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) { + openerService.open( + uri, + { + fromUserGesture: true, + editorOptions: { + ...e.editorOptions, + ...{ + selection: uriOrLocation && 'range' in uriOrLocation ? uriOrLocation.range : undefined + } + } + }); + } + } + })); + 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]); + } + }]; + } + }); + } + } + + })); + + const maxItemsShown = 6; + const itemsShown = Math.min(data.length, maxItemsShown); + const height = itemsShown * 22; + list.layout(height); + list.getHTMLElement().style.height = `${height}px`; + list.splice(0, list.length, data); + } + + 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; + } + + private updateAriaLabel(element: HTMLElement, label: string, expanded?: boolean): void { + element.ariaLabel = expanded ? localize('usedReferencesExpanded', "{0}, expanded", label) : localize('usedReferencesCollapsed', "{0}, collapsed", label); + } + + addDisposable(disposable: IDisposable): void { + this._register(disposable); + } +} + +export class CollapsibleListPool extends Disposable { + private _pool: ResourcePool>; + + public get inUse(): ReadonlySet> { + return this._pool.inUse; + } + + constructor( + private _onDidChangeVisibility: Event, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IThemeService private readonly themeService: IThemeService, + @ILabelService private readonly labelService: ILabelService, + ) { + super(); + this._pool = this._register(new ResourcePool(() => this.listFactory())); + } + + private listFactory(): WorkbenchList { + const resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility })); + + 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)], + { + alwaysConsumeMouseWheel: false, + accessibilityProvider: { + getAriaLabel: (element: IChatCollapsibleListItem) => { + if (element.kind === 'warning') { + return element.content.value; + } + const reference = element.reference; + if (typeof reference === 'string') { + return reference; + } else if ('variableName' in reference) { + return reference.variableName; + } else if (URI.isUri(reference)) { + return basename(reference.path); + } else { + return basename(reference.uri.path); + } + }, + + getWidgetAriaLabel: () => localize('chatCollapsibleList', "Collapsible Chat List") + }, + dnd: { + getDragURI: (element: IChatCollapsibleListItem) => getDragURI(element)?.toString() ?? null, + getDragLabel: (elements, originalEvent) => { + const uris: URI[] = coalesce(elements.map(getDragURI)); + if (!uris.length) { + return undefined; + } else if (uris.length === 1) { + return this.labelService.getUriLabel(uris[0], { relative: true }); + } else { + return `${uris.length}`; + } + }, + dispose: () => { }, + onDragOver: () => false, + drop: () => { }, + onDragStart: (data, originalEvent) => { + try { + const elements = data.getData() as IChatCollapsibleListItem[]; + const uris: URI[] = coalesce(elements.map(getDragURI)); + this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, uris, originalEvent)); + } catch { + // noop + } + }, + }, + }); + + return list; + } + + get(): IDisposableReference> { + const object = this._pool.get(); + let stale = false; + return { + object, + isStale: () => stale, + dispose: () => { + stale = true; + this._pool.release(object); + } + }; + } +} + +class CollapsibleListDelegate implements IListVirtualDelegate { + getHeight(element: IChatCollapsibleListItem): number { + return 22; + } + + getTemplateId(element: IChatCollapsibleListItem): string { + return CollapsibleListRenderer.TEMPLATE_ID; + } +} + +interface ICollapsibleListTemplate { + label: IResourceLabel; + templateDisposables: IDisposable; +} + +class CollapsibleListRenderer implements IListRenderer { + static TEMPLATE_ID = 'chatCollapsibleListRenderer'; + readonly templateId: string = CollapsibleListRenderer.TEMPLATE_ID; + + constructor( + private labels: ResourceLabels, + @IThemeService private readonly themeService: IThemeService, + @IAideAgentVariablesService private readonly chatVariablesService: IAideAgentVariablesService, + @IProductService private readonly productService: IProductService, + ) { } + + renderTemplate(container: HTMLElement): ICollapsibleListTemplate { + const templateDisposables = new DisposableStore(); + const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true })); + return { templateDisposables, label }; + } + + + private getReferenceIcon(data: IChatContentReference): URI | ThemeIcon | undefined { + if (ThemeIcon.isThemeIcon(data.iconPath)) { + return data.iconPath; + } else { + return this.themeService.getColorTheme().type === ColorScheme.DARK && data.iconPath?.dark + ? data.iconPath?.dark + : data.iconPath?.light; + } + } + + renderElement(data: IChatCollapsibleListItem, index: number, templateData: ICollapsibleListTemplate, height: number | undefined): void { + if (data.kind === 'warning') { + templateData.label.setResource({ name: data.content.value }, { icon: Codicon.warning }); + return; + } + + const reference = data.reference; + const icon = this.getReferenceIcon(data); + templateData.label.element.style.display = 'flex'; + if (typeof reference === 'object' && 'variableName' in reference) { + if (reference.value) { + const uri = URI.isUri(reference.value) ? reference.value : reference.value.uri; + templateData.label.setResource( + { + resource: uri, + name: basenameOrAuthority(uri), + description: `${chatVariableLeader}${reference.variableName}`, + range: 'range' in reference.value ? reference.value.range : undefined, + }, { icon, title: data.options?.status?.description ?? data.title }); + } else { + const variable = this.chatVariablesService.getVariable(reference.variableName); + // This is a hack to get chat attachment ThemeIcons to render for resource labels + const asThemeIcon = variable?.icon ? `$(${variable.icon.id}) ` : ''; + const asVariableName = `${chatVariableLeader}${reference.variableName}`; // Fallback, shouldn't really happen + const label = `${asThemeIcon}${variable?.fullName ?? asVariableName}`; + templateData.label.setLabel(label, asVariableName, { title: data.options?.status?.description ?? variable?.description }); + } + } else if (typeof reference === 'string') { + templateData.label.setLabel(reference, undefined, { iconPath: URI.isUri(icon) ? icon : undefined, title: data.options?.status?.description ?? data.title }); + + } else { + const uri = 'uri' in reference ? reference.uri : reference; + if (uri.scheme === 'https' && isEqualAuthority(uri.authority, 'github.com') && uri.path.includes('/tree/')) { + // Parse a nicer label for GitHub URIs that point at a particular commit + file + const label = uri.path.split('/').slice(1, 3).join('/'); + const description = uri.path.split('/').slice(5).join('/'); + templateData.label.setResource({ resource: uri, name: label, description }, { icon: Codicon.github, title: data.title }); + } else if (uri.scheme === this.productService.urlProtocol && isEqualAuthority(uri.authority, SETTINGS_AUTHORITY)) { + // a nicer label for settings URIs + const settingId = uri.path.substring(1); + templateData.label.setResource({ resource: uri, name: settingId }, { icon: Codicon.settingsGear, title: localize('setting.hover', "Open setting '{0}'", settingId) }); + } else if (matchesSomeScheme(uri, Schemas.mailto, Schemas.http, Schemas.https)) { + templateData.label.setResource({ resource: uri, name: uri.toString() }, { icon: icon ?? Codicon.globe, title: data.options?.status?.description ?? data.title ?? uri.toString() }); + } else { + templateData.label.setFile(uri, { + fileKind: FileKind.FILE, + // Should not have this live-updating data on a historical reference + fileDecorations: { badges: false, colors: false }, + range: 'range' in reference ? reference.range : undefined, + title: data.options?.status?.description ?? data.title + }); + } + } + + for (const selector of ['.monaco-icon-suffix-container', '.monaco-icon-name-container']) { + const element = templateData.label.element.querySelector(selector); + if (element) { + if (data.options?.status?.kind === ChatResponseReferencePartStatusKind.Omitted || data.options?.status?.kind === ChatResponseReferencePartStatusKind.Partial) { + element.classList.add('warning'); + } else { + element.classList.remove('warning'); + } + } + } + } + + disposeTemplate(templateData: ICollapsibleListTemplate): void { + templateData.templateDisposables.dispose(); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentTaskContentPart.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentTaskContentPart.ts new file mode 100644 index 00000000000..2855cf40433 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentTaskContentPart.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { Event } from '../../../../../base/common/event.js'; +import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { MarkdownRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './aideAgentContentParts.js'; +import { ChatProgressContentPart } from './aideAgentProgressContentPart.js'; +import { ChatCollapsibleListContentPart, CollapsibleListPool } from './aideAgentReferencesContentPart.js'; +import { IChatProgressRenderableResponseContent } from '../../common/aideAgentModel.js'; +import { IChatTask } from '../../common/aideAgentService.js'; +import { IChatResponseViewModel } from '../../common/aideAgentViewModel.js'; + +export class ChatTaskContentPart extends Disposable implements IChatContentPart { + public readonly domNode: HTMLElement; + public readonly onDidChangeHeight: Event; + + constructor( + private readonly task: IChatTask, + contentReferencesListPool: CollapsibleListPool, + renderer: MarkdownRenderer, + context: IChatContentPartRenderContext, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + if (task.progress.length) { + const refsPart = this._register(instantiationService.createInstance(ChatCollapsibleListContentPart, task.progress, task.content.value, context.element as IChatResponseViewModel, contentReferencesListPool)); + this.domNode = dom.$('.chat-progress-task'); + this.domNode.appendChild(refsPart.domNode); + this.onDidChangeHeight = refsPart.onDidChangeHeight; + } else { + // #217645 + const isSettled = task.isSettled?.() ?? true; + const progressPart = this._register(instantiationService.createInstance(ChatProgressContentPart, task, renderer, context, !isSettled, true)); + this.domNode = progressPart.domNode; + this.onDidChangeHeight = Event.None; + } + } + + hasSameContent(other: IChatProgressRenderableResponseContent): boolean { + return other.kind === 'progressTask' + && other.progress.length === this.task.progress.length + && other.isSettled() === this.task.isSettled(); + } + + addDisposable(disposable: IDisposable): void { + this._register(disposable); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentTextEditContentPart.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentTextEditContentPart.ts new file mode 100644 index 00000000000..d48b294913c --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentTextEditContentPart.ts @@ -0,0 +1,255 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable, IDisposable, IReference, RefCountedDisposable, toDisposable } 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 } from '../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { ISingleEditOperation } from '../../../../../editor/common/core/editOperation.js'; +import { TextEdit } from '../../../../../editor/common/languages.js'; +import { createTextBufferFactoryFromSnapshot } from '../../../../../editor/common/model/textModel.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; +import { DefaultModelSHA1Computer } from '../../../../../editor/common/services/modelService.js'; +import { IResolvedTextEditorModel, ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { localize } from '../../../../../nls.js'; +import { MenuId } from '../../../../../platform/actions/common/actions.js'; +import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; +import { createDecorator, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IChatListItemRendererOptions } from '../aideAgent.js'; +import { IDisposableReference, ResourcePool } from './aideAgentCollections.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './aideAgentContentParts.js'; +import { IChatRendererDelegate } from '../aideAgentListRenderer.js'; +import { ChatEditorOptions } from '../aideAgentOptions.js'; +import { CodeCompareBlockPart, ICodeCompareBlockData, ICodeCompareBlockDiffData } from '../codeBlockPart.js'; +import { IChatProgressRenderableResponseContent, IChatTextEditGroup } from '../../common/aideAgentModel.js'; +import { IAideAgentService } from '../../common/aideAgentService.js'; +import { IChatResponseViewModel, isResponseVM } from '../../common/aideAgentViewModel.js'; + +const $ = dom.$; + +const IAideAgentCodeCompareModelService = createDecorator('IAideAgentCodeCompareModelService'); + +interface IAideAgentCodeCompareModelService { + _serviceBrand: undefined; + createModel(response: IChatResponseViewModel, chatTextEdit: IChatTextEditGroup): Promise>; +} + +export class ChatTextEditContentPart extends Disposable implements IChatContentPart { + public readonly domNode: HTMLElement; + private readonly comparePart: IDisposableReference | undefined; + + private readonly _onDidChangeHeight = this._register(new Emitter()); + public readonly onDidChangeHeight = this._onDidChangeHeight.event; + + constructor( + chatTextEdit: IChatTextEditGroup, + context: IChatContentPartRenderContext, + rendererOptions: IChatListItemRendererOptions, + diffEditorPool: DiffEditorPool, + currentWidth: number, + @IAideAgentCodeCompareModelService private readonly codeCompareModelService: IAideAgentCodeCompareModelService + ) { + super(); + const element = context.element; + + assertType(isResponseVM(element)); + + // TODO@jrieken move this into the CompareCodeBlock and properly say what kind of changes happen + if (rendererOptions.renderTextEditsAsSummary?.(chatTextEdit.uri)) { + if (element.response.value.every(item => item.kind === 'textEditGroup')) { + this.domNode = $('.interactive-edits-summary', undefined, !element.isComplete + ? '' + : element.isCanceled + ? localize('edits0', "Making changes was aborted.") + : localize('editsSummary', "Made changes.")); + } else { + this.domNode = $('div'); + } + + // TODO@roblourens this case is now handled outside this Part in ChatListRenderer, but can it be cleaned up? + // return; + } else { + + + const cts = new CancellationTokenSource(); + + let isDisposed = false; + this._register(toDisposable(() => { + isDisposed = true; + cts.dispose(true); + })); + + this.comparePart = this._register(diffEditorPool.get()); + + // Attach this after updating text/layout of the editor, so it should only be fired when the size updates later (horizontal scrollbar, wrapping) + // not during a renderElement OR a progressive render (when we will be firing this event anyway at the end of the render) + this._register(this.comparePart.object.onDidChangeContentHeight(() => { + this._onDidChangeHeight.fire(); + })); + + const data: ICodeCompareBlockData = { + element, + edit: chatTextEdit, + diffData: (async () => { + + const ref = await this.codeCompareModelService.createModel(element, chatTextEdit); + + if (isDisposed) { + ref.dispose(); + return; + } + + this._register(ref); + + return { + modified: ref.object.modified.textEditorModel, + original: ref.object.original.textEditorModel, + originalSha1: ref.object.originalSha1 + } satisfies ICodeCompareBlockDiffData; + })() + }; + this.comparePart.object.render(data, currentWidth, cts.token); + + this.domNode = this.comparePart.object.element; + } + } + + layout(width: number): void { + this.comparePart?.object.layout(width); + } + + hasSameContent(other: IChatProgressRenderableResponseContent): boolean { + // No other change allowed for this content type + return other.kind === 'textEditGroup'; + } + + addDisposable(disposable: IDisposable): void { + this._register(disposable); + } +} + +export class DiffEditorPool extends Disposable { + + private readonly _pool: ResourcePool; + + public inUse(): Iterable { + return this._pool.inUse; + } + + constructor( + options: ChatEditorOptions, + delegate: IChatRendererDelegate, + overflowWidgetsDomNode: HTMLElement | undefined, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + this._pool = this._register(new ResourcePool(() => { + return instantiationService.createInstance(CodeCompareBlockPart, options, MenuId.AideAgentCompareBlock, delegate, overflowWidgetsDomNode); + })); + } + + get(): IDisposableReference { + const codeBlock = this._pool.get(); + let stale = false; + return { + object: codeBlock, + isStale: () => stale, + dispose: () => { + codeBlock.reset(); + stale = true; + this._pool.release(codeBlock); + } + }; + } +} + +class CodeCompareModelService implements IAideAgentCodeCompareModelService { + + declare readonly _serviceBrand: undefined; + + constructor( + @ITextModelService private readonly textModelService: ITextModelService, + @IModelService private readonly modelService: IModelService, + @IAideAgentService private readonly chatService: IAideAgentService, + ) { } + + async createModel(element: IChatResponseViewModel, chatTextEdit: IChatTextEditGroup): Promise> { + + const original = await this.textModelService.createModelReference(chatTextEdit.uri); + + const modified = await this.textModelService.createModelReference((this.modelService.createModel( + createTextBufferFactoryFromSnapshot(original.object.textEditorModel.createSnapshot()), + { languageId: original.object.textEditorModel.getLanguageId(), onDidChange: Event.None }, + URI.from({ scheme: Schemas.vscodeAideAgentCodeBlock, path: chatTextEdit.uri.path, query: generateUuid() }), + false + )).uri); + + const d = new RefCountedDisposable(toDisposable(() => { + original.dispose(); + modified.dispose(); + })); + + // compute the sha1 of the original model + let originalSha1: string = ''; + if (chatTextEdit.state) { + originalSha1 = chatTextEdit.state.sha1; + } else { + const sha1 = new DefaultModelSHA1Computer(); + if (sha1.canComputeSHA1(original.object.textEditorModel)) { + originalSha1 = sha1.computeSHA1(original.object.textEditorModel); + chatTextEdit.state = { sha1: originalSha1, applied: 0 }; + } + } + + // apply edits to the "modified" model + const chatModel = this.chatService.getSession(element.sessionId)!; + const editGroups: ISingleEditOperation[][] = []; + for (const exchange of chatModel.getExchanges()) { + if (!('response' in exchange)) { + continue; + } + for (const item of exchange.response.value) { + if (item.kind !== 'textEditGroup' || item.state?.applied || !isEqual(item.uri, chatTextEdit.uri)) { + continue; + } + for (const group of item.edits) { + const edits = group.map(TextEdit.asEditOperation); + editGroups.push(edits); + } + } + if (exchange === element.model) { + break; + } + } + for (const edits of editGroups) { + modified.object.textEditorModel.pushEditOperations(null, edits, () => null); + } + + // self-acquire a reference to diff models for a short while + // because streaming usually means we will be using the original-model + // repeatedly and thereby also should reuse the modified-model and just + // update it with more edits + d.acquire(); + setTimeout(() => d.release(), 5000); + + return { + object: { + originalSha1, + original: original.object, + modified: modified.object + }, + dispose() { + d.release(); + }, + }; + } +} + +registerSingleton(IAideAgentCodeCompareModelService, CodeCompareModelService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentTreeContentPart.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentTreeContentPart.ts new file mode 100644 index 00000000000..13b14225e30 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentTreeContentPart.ts @@ -0,0 +1,225 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; +import { ITreeCompressionDelegate } from '../../../../../base/browser/ui/tree/asyncDataTree.js'; +import { ICompressedTreeNode } from '../../../../../base/browser/ui/tree/compressedObjectTreeModel.js'; +import { ICompressibleTreeRenderer } from '../../../../../base/browser/ui/tree/objectTree.js'; +import { IAsyncDataSource, ITreeNode } from '../../../../../base/browser/ui/tree/tree.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { FileKind, FileType } from '../../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { WorkbenchCompressibleAsyncDataTree } 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 { ChatTreeItem } from '../aideAgent.js'; +import { IDisposableReference, ResourcePool } from './aideAgentCollections.js'; +import { IChatContentPart } from './aideAgentContentParts.js'; +import { IChatProgressRenderableResponseContent } from '../../common/aideAgentModel.js'; +import { IChatResponseProgressFileTreeData } from '../../common/aideAgentService.js'; +import { createFileIconThemableTreeContainerScope } from '../../../files/browser/views/explorerView.js'; +import { IFilesConfiguration } from '../../../files/common/files.js'; + +const $ = dom.$; + +export class ChatTreeContentPart extends Disposable implements IChatContentPart { + public readonly domNode: HTMLElement; + + private readonly _onDidChangeHeight = this._register(new Emitter()); + public readonly onDidChangeHeight = this._onDidChangeHeight.event; + + public readonly onDidFocus: Event; + + private tree: WorkbenchCompressibleAsyncDataTree; + + constructor( + data: IChatResponseProgressFileTreeData, + element: ChatTreeItem, + treePool: TreePool, + treeDataIndex: number, + @IOpenerService private readonly openerService: IOpenerService + ) { + super(); + + const ref = this._register(treePool.get()); + this.tree = ref.object; + this.onDidFocus = this.tree.onDidFocus; + + this._register(this.tree.onDidOpen((e) => { + if (e.element && !('children' in e.element)) { + this.openerService.open(e.element.uri); + } + })); + this._register(this.tree.onDidChangeCollapseState(() => { + this._onDidChangeHeight.fire(); + })); + this._register(this.tree.onContextMenu((e) => { + e.browserEvent.preventDefault(); + e.browserEvent.stopPropagation(); + })); + + this.tree.setInput(data).then(() => { + if (!ref.isStale()) { + this.tree.layout(); + this._onDidChangeHeight.fire(); + } + }); + + this.domNode = this.tree.getHTMLElement().parentElement!; + } + + domFocus() { + this.tree.domFocus(); + } + + hasSameContent(other: IChatProgressRenderableResponseContent): boolean { + // No other change allowed for this content type + return other.kind === 'treeData'; + } + + addDisposable(disposable: IDisposable): void { + this._register(disposable); + } +} + +export class TreePool extends Disposable { + private _pool: ResourcePool>; + + public get inUse(): ReadonlySet> { + return this._pool.inUse; + } + + constructor( + private _onDidChangeVisibility: Event, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IConfigurationService private readonly configService: IConfigurationService, + @IThemeService private readonly themeService: IThemeService, + ) { + super(); + this._pool = this._register(new ResourcePool(() => this.treeFactory())); + } + + private treeFactory(): WorkbenchCompressibleAsyncDataTree { + const resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility })); + + const container = $('.interactive-response-progress-tree'); + this._register(createFileIconThemableTreeContainerScope(container, this.themeService)); + + const tree = this.instantiationService.createInstance( + WorkbenchCompressibleAsyncDataTree, + 'ChatListRenderer', + container, + new ChatListTreeDelegate(), + new ChatListTreeCompressionDelegate(), + [new ChatListTreeRenderer(resourceLabels, this.configService.getValue('explorer.decorations'))], + new ChatListTreeDataSource(), + { + collapseByDefault: () => false, + expandOnlyOnTwistieClick: () => false, + identityProvider: { + getId: (e: IChatResponseProgressFileTreeData) => e.uri.toString() + }, + accessibilityProvider: { + getAriaLabel: (element: IChatResponseProgressFileTreeData) => element.label, + getWidgetAriaLabel: () => localize('treeAriaLabel', "File Tree") + }, + alwaysConsumeMouseWheel: false + }); + + return tree; + } + + get(): IDisposableReference> { + const object = this._pool.get(); + let stale = false; + return { + object, + isStale: () => stale, + dispose: () => { + stale = true; + this._pool.release(object); + } + }; + } +} + +class ChatListTreeDelegate implements IListVirtualDelegate { + static readonly ITEM_HEIGHT = 22; + + getHeight(element: IChatResponseProgressFileTreeData): number { + return ChatListTreeDelegate.ITEM_HEIGHT; + } + + getTemplateId(element: IChatResponseProgressFileTreeData): string { + return 'chatListTreeTemplate'; + } +} + +class ChatListTreeCompressionDelegate implements ITreeCompressionDelegate { + isIncompressible(element: IChatResponseProgressFileTreeData): boolean { + return !element.children; + } +} + +interface IChatListTreeRendererTemplate { + templateDisposables: DisposableStore; + label: IResourceLabel; +} + +class ChatListTreeRenderer implements ICompressibleTreeRenderer { + templateId: string = 'chatListTreeTemplate'; + + constructor(private labels: ResourceLabels, private decorations: IFilesConfiguration['explorer']['decorations']) { } + + renderCompressedElements(element: ITreeNode, void>, index: number, templateData: IChatListTreeRendererTemplate, height: number | undefined): void { + templateData.label.element.style.display = 'flex'; + const label = element.element.elements.map((e) => e.label); + templateData.label.setResource({ resource: element.element.elements[0].uri, name: label }, { + title: element.element.elements[0].label, + fileKind: element.children ? FileKind.FOLDER : FileKind.FILE, + extraClasses: ['explorer-item'], + fileDecorations: this.decorations + }); + } + renderTemplate(container: HTMLElement): IChatListTreeRendererTemplate { + const templateDisposables = new DisposableStore(); + const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true })); + return { templateDisposables, label }; + } + renderElement(element: ITreeNode, index: number, templateData: IChatListTreeRendererTemplate, height: number | undefined): void { + templateData.label.element.style.display = 'flex'; + if (!element.children.length && element.element.type !== FileType.Directory) { + templateData.label.setFile(element.element.uri, { + fileKind: FileKind.FILE, + hidePath: true, + fileDecorations: this.decorations, + }); + } else { + templateData.label.setResource({ resource: element.element.uri, name: element.element.label }, { + title: element.element.label, + fileKind: FileKind.FOLDER, + fileDecorations: this.decorations + }); + } + } + disposeTemplate(templateData: IChatListTreeRendererTemplate): void { + templateData.templateDisposables.dispose(); + } +} + +class ChatListTreeDataSource implements IAsyncDataSource { + hasChildren(element: IChatResponseProgressFileTreeData): boolean { + return !!element.children; + } + + async getChildren(element: IChatResponseProgressFileTreeData): Promise> { + return element.children ?? []; + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentWarningContentPart.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentWarningContentPart.ts new file mode 100644 index 00000000000..d43a3d36aa3 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentWarningContentPart.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { MarkdownRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; +import { IChatContentPart } from './aideAgentContentParts.js'; +import { IChatProgressRenderableResponseContent } from '../../common/aideAgentModel.js'; + +const $ = dom.$; + +export class ChatWarningContentPart extends Disposable implements IChatContentPart { + public readonly domNode: HTMLElement; + + constructor( + kind: 'info' | 'warning' | 'error', + content: IMarkdownString, + renderer: MarkdownRenderer, + ) { + super(); + + this.domNode = $('.chat-notification-widget'); + let icon; + let iconClass; + switch (kind) { + case 'warning': + icon = Codicon.warning; + iconClass = '.chat-warning-codicon'; + break; + case 'error': + icon = Codicon.error; + iconClass = '.chat-error-codicon'; + break; + case 'info': + icon = Codicon.info; + iconClass = '.chat-info-codicon'; + break; + } + this.domNode.appendChild($(iconClass, undefined, renderIcon(icon))); + const markdownContent = renderer.render(content); + this.domNode.appendChild(markdownContent.element); + } + + hasSameContent(other: IChatProgressRenderableResponseContent): boolean { + // No other change allowed for this content type + return other.kind === 'warning'; + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/media/aideAgentConfirmationWidget.css b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/media/aideAgentConfirmationWidget.css new file mode 100644 index 00000000000..bb9d7bc7b7e --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/media/aideAgentConfirmationWidget.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-confirmation-widget { + border: 1px solid var(--vscode-chat-requestBorder); + border-radius: 4px; + padding: 8px 12px 12px; +} + +.chat-confirmation-widget:not(:last-child) { + margin-bottom: 16px; +} + +.chat-confirmation-widget .chat-confirmation-widget-title { + font-weight: 600; +} + +.chat-confirmation-widget .chat-confirmation-widget-title p { + margin: 0 0 4px 0; +} + +.chat-confirmation-widget .chat-confirmation-widget-message .rendered-markdown p { + margin-top: 0; +} + +.chat-confirmation-widget .chat-confirmation-widget-message .rendered-markdown > :last-child { + margin-bottom: 0px; +} + +.chat-confirmation-widget .chat-confirmation-buttons-container { + display: flex; + gap: 8px; + margin-top: 13px; +} + +.chat-confirmation-widget.hideButtons .chat-confirmation-buttons-container { + display: none; +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/media/at-dark.svg b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/media/at-dark.svg new file mode 100644 index 00000000000..2b255300044 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/media/at-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/media/at-light.svg b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/media/at-light.svg new file mode 100644 index 00000000000..ddf482a7d52 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/media/at-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContextKeys.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContextKeys.ts deleted file mode 100644 index a8dca0f849d..00000000000 --- a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContextKeys.ts +++ /dev/null @@ -1,10 +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 { localize } from '../../../../nls.js'; -import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; - -export const CONTEXT_AIDE_CONTROLS_HAS_TEXT = new RawContextKey('aideControlsHasText', false, { type: 'boolean', description: localize('aideControlsHasText', "True when the AI controls input has text.") }); -export const CONTEXT_AIDE_CONTROLS_HAS_FOCUS = new RawContextKey('aideControlsHasFocus', false, { type: 'boolean', description: localize('aideControlsHasFocus', "True when the AI controls input has focus.") }); diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditingService.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditingService.ts new file mode 100644 index 00000000000..b991adc9e6d --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditingService.ts @@ -0,0 +1,412 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Sequencer } from '../../../../base/common/async.js'; +import { BugIndicatingError } from '../../../../base/common/errors.js'; +import { Disposable, IReference } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, IObservable, ITransaction, observableValue, ValueWithChangeEventFromObservable } from '../../../../base/common/observable.js'; +import { Constants } from '../../../../base/common/uint.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IBulkEditService, ResourceTextEdit } from '../../../../editor/browser/services/bulkEditService.js'; +import { Range } from '../../../../editor/common/core/range.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 { IResolvedTextEditorModel, ITextModelContentProvider, ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { EditorActivation } from '../../../../platform/editor/common/editor.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; +import { MultiDiffEditorInput } from '../../multiDiffEditor/browser/multiDiffEditorInput.js'; +import { IMultiDiffSourceResolver, IMultiDiffSourceResolverService, IResolvedMultiDiffSource, MultiDiffEditorItem } from '../../multiDiffEditor/browser/multiDiffSourceResolverService.js'; +import { ChatEditingSessionState, IAideAgentEditingService, IChatEditingSession, IChatEditingSessionStream, IModifiedFileEntry } from '../common/aideAgentEditingService.js'; + +const acceptedChatEditingResourceContextKey = new RawContextKey('acceptedAideAgentEditingResource', []); +const chatEditingResourceContextKey = new RawContextKey('aideAgentEditingResource', undefined); +const inChatEditingSessionContextKey = new RawContextKey('inAideAgentEditingSession', undefined); + +export class ChatEditingService extends Disposable implements IAideAgentEditingService { + + _serviceBrand: undefined; + + private readonly _currentSessionObs = observableValue(this, null); + + get currentEditingSession(): IChatEditingSession | null { + return this._currentSessionObs.get(); + } + + constructor( + @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IMultiDiffSourceResolverService multiDiffSourceResolverService: IMultiDiffSourceResolverService, + @ITextModelService textModelService: ITextModelService, + @IContextKeyService contextKeyService: IContextKeyService + ) { + super(); + this._register(multiDiffSourceResolverService.registerResolver(_instantiationService.createInstance(ChatEditingMultiDiffSourceResolver, this._currentSessionObs))); + textModelService.registerTextModelContentProvider(ChatEditingTextModelContentProvider.scheme, _instantiationService.createInstance(ChatEditingTextModelContentProvider, this._currentSessionObs)); + this._register(bindContextKey(acceptedChatEditingResourceContextKey, contextKeyService, (reader) => { + const currentSession = this._currentSessionObs.read(reader); + if (!currentSession) { + return; + } + const entries = currentSession.entries.read(reader); + const acceptedEntries = entries.filter(entry => entry.accepted.read(reader)); + return acceptedEntries.map(entry => entry.modifiedDocumentId); + })); + } + + async createEditingSession(builder: (stream: IChatEditingSessionStream) => Promise): Promise { + if (this._currentSessionObs.get()) { + throw new BugIndicatingError('Cannot have more than one active editing session'); + } + + const input = MultiDiffEditorInput.fromResourceMultiDiffEditorInput({ + multiDiffSource: ChatEditingMultiDiffSourceResolver.getMultiDiffSourceUri(), + label: localize('multiDiffEditorInput.name', "Suggested Edits") + }, this._instantiationService); + + await this._editorGroupsService.activeGroup.openEditor(input, { pinned: true, activation: EditorActivation.ACTIVATE }); + + const session = this._instantiationService.createInstance(ChatEditingSession, { killCurrentEditingSession: () => this.killCurrentEditingSession() }); + this._currentSessionObs.set(session, undefined); + + const stream: IChatEditingSessionStream = { + textEdits: (resource: URI, textEdits: TextEdit[]) => { + session.acceptTextEdits(resource, textEdits); + } + }; + + try { + await builder(stream); + } finally { + session.resolve(); + } + } + + killCurrentEditingSession() { + // close all editors + for (const group of this._editorGroupsService.groups) { + for (const editor of group.editors) { + if (editor.resource?.scheme === ChatEditingMultiDiffSourceResolver.scheme) { + group.closeEditor(editor); + } + } + } + const currentSession = this._currentSessionObs.get(); + if (currentSession) { + currentSession.dispose(); + this._currentSessionObs.set(null, undefined); + } + } +} + +class ChatEditingMultiDiffSourceResolver implements IMultiDiffSourceResolver { + public static readonly scheme = 'chat-editing-multi-diff-source'; + + public static getMultiDiffSourceUri(): URI { + return URI.from({ + scheme: ChatEditingMultiDiffSourceResolver.scheme, + path: '', + }); + } + + constructor( + private readonly _currentSession: IObservable, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { } + + canHandleUri(uri: URI): boolean { + return uri.scheme === ChatEditingMultiDiffSourceResolver.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.modifiedDocumentId, + // [inChatEditingSessionContextKey.key]: true + }, + ); + }); + }); + readonly resources = new ValueWithChangeEventFromObservable(this._resources); + + readonly contextKeys = { + [inChatEditingSessionContextKey.key]: true + }; + + constructor( + private readonly _currentSession: IObservable + ) { } +} + +registerAction2(class AcceptAction extends Action2 { + constructor() { + super({ + id: 'aideAgentEditing.acceptFile', + title: localize2('accept.file', 'Accept File'), + // icon: Codicon.goToFile, + menu: { + when: ContextKeyExpr.notIn(chatEditingResourceContextKey.key, acceptedChatEditingResourceContextKey.key), + id: MenuId.MultiDiffEditorFileToolbar, + order: 0, + group: 'navigation', + }, + }); + } + + async run(accessor: ServicesAccessor, ...args: any[]): Promise { + const chatEditingService = accessor.get(IAideAgentEditingService); + const currentEditingSession = chatEditingService.currentEditingSession; + if (!currentEditingSession) { + return; + } + const uri = args[0] as URI; + const entries = currentEditingSession.entries.get(); + await entries.find(e => String(e.modifiedURI) === String(uri))?.accept(undefined); + } +}); + +registerAction2(class AcceptAllAction extends Action2 { + constructor() { + super({ + id: 'aideAgentEditing.acceptAllFiles', + title: localize2('accept.allFiles', 'Accept All'), + // icon: Codicon.goToFile, + menu: { + when: ContextKeyExpr.equals('resourceScheme', ChatEditingMultiDiffSourceResolver.scheme), + id: MenuId.EditorTitle, + order: 0, + group: 'navigation', + }, + }); + } + + async run(accessor: ServicesAccessor, ...args: any[]): Promise { + const chatEditingService = accessor.get(IAideAgentEditingService); + const editorGroupsService = accessor.get(IEditorGroupsService); + const currentEditingSession = chatEditingService.currentEditingSession; + if (!currentEditingSession) { + return; + } + const entries = currentEditingSession.entries.get(); + await Promise.all( + // TODO: figure out how to run this in a transaction + entries.map(entry => entry.accept(undefined)) + ); + + const uri = args[0]; + + for (const group of editorGroupsService.groups) { + for (const editor of group.editors) { + if (String(editor.resource) === String(uri)) { + group.closeEditor(editor); + } + } + } + } +}); + +type ChatEditingTextModelContentQueryData = { kind: 'empty' } | { kind: 'doc'; documentId: string }; + +class ChatEditingTextModelContentProvider implements ITextModelContentProvider { + public static readonly scheme = 'chat-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.getModel(data.documentId); + } +} + +class ChatEditingSession extends Disposable implements IChatEditingSession { + private readonly _state = observableValue(this, ChatEditingSessionState.StreamingEdits); + private readonly _entriesObs = observableValue(this, []); + public get entries(): IObservable { + return this._entriesObs; + } + private readonly _sequencer = new Sequencer(); + + private _entries: readonly ModifiedFileEntry[] = []; + + get state(): IObservable { + return this._state; + } + + constructor( + parent: { killCurrentEditingSession(): void }, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ITextModelService private readonly _textModelService: ITextModelService, + ) { + super(); + + // auto-dispose + autorun((reader) => { + if (this.state.read(reader) === ChatEditingSessionState.StreamingEdits) { + return; + } + const entries = this.entries.read(reader); + const pendingEntries = entries.filter(entry => !entry.accepted.read(reader)); + if (pendingEntries.length > 0) { + return; + } + + // all entries were accepted + parent.killCurrentEditingSession(); + }); + } + + getModel(documentId: string): ITextModel | null { + const entry = this._entries.find(e => e.modifiedDocumentId === documentId); + return entry?.modifiedDocument ?? null; + } + + acceptTextEdits(resource: URI, textEdits: TextEdit[]): void { + // ensure that the edits are processed sequentially + this._sequencer.queue(() => this._acceptTextEdits(resource, textEdits)); + } + + resolve(): void { + // ensure that the edits are processed sequentially + this._sequencer.queue(() => this._resolve()); + } + + private async _acceptTextEdits(resource: URI, textEdits: TextEdit[]): Promise { + const entry = await this._getOrCreateModifiedFileEntry(resource); + entry.modifiedDocument.applyEdits(textEdits); + } + + private async _resolve(): Promise { + this._state.set(ChatEditingSessionState.Idle, undefined); + } + + private async _getOrCreateModifiedFileEntry(resource: URI): Promise { + const existingEntry = this._entries.find(e => e.resource.toString() === resource.toString()); + if (existingEntry) { + return existingEntry; + } + + const entry = await this._createModifiedFileEntry(resource); + this._register(entry); + this._entries = [...this._entries, entry]; + this._entriesObs.set(this._entries, undefined); + + return entry; + } + + private async _createModifiedFileEntry(resource: URI): Promise { + let ref: IReference; + try { + ref = await this._textModelService.createModelReference(resource); + } catch (err) { + // this file does not exist yet + return this._instantiationService.createInstance(ModifiedFileEntry, resource, null); + } + + return this._instantiationService.createInstance(ModifiedFileEntry, resource, ref); + } +} + +class ModifiedFileEntry extends Disposable implements IModifiedFileEntry { + static lastModifiedFileId = 0; + + public readonly originalURI: URI; + public readonly originalDocument: ITextModel | null; + public readonly modifiedDocumentId = `modified-file::${++ModifiedFileEntry.lastModifiedFileId}`; + public readonly modifiedDocument: ITextModel; + + public get modifiedURI(): URI { + return this.modifiedDocument.uri; + } + private readonly _acceptedObs = observableValue(this, false); + public get accepted(): IObservable { + return this._acceptedObs; + } + + constructor( + public readonly resource: URI, + resourceRef: IReference | null, + @IModelService modelService: IModelService, + @ILanguageService languageService: ILanguageService, + @IBulkEditService private readonly _bulkEditService: IBulkEditService, + ) { + super(); + this.originalDocument = resourceRef ? resourceRef.object.textEditorModel : null; + const initialModifiedContent = this.originalDocument ? this.originalDocument.getValue() : ''; + const languageSelection = this.originalDocument ? languageService.createById(this.originalDocument.getLanguageId()) : languageService.createByFilepathOrFirstLine(resource); + + this.modifiedDocument = this._register(modelService.createModel(initialModifiedContent, languageSelection, ChatEditingTextModelContentProvider.getFileURI(this.modifiedDocumentId, resource.path), false)); + this.originalURI = this.originalDocument ? this.originalDocument.uri : ChatEditingTextModelContentProvider.getEmptyFileURI(); + if (resourceRef) { + this._register(resourceRef); + } + } + + async accept(transaction: ITransaction | undefined): Promise { + if (this._acceptedObs.get()) { + // already applied + return; + } + + const textEdit: TextEdit = { + range: new Range(1, 1, Constants.MAX_SAFE_SMALL_INTEGER, Constants.MAX_SAFE_SMALL_INTEGER), + text: this.modifiedDocument.getValue() + }; + this._acceptedObs.set(true, transaction); + await this._bulkEditService.apply([new ResourceTextEdit(this.originalURI, textEdit, undefined)]); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditor.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditor.ts new file mode 100644 index 00000000000..7e36f5b3a48 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditor.ts @@ -0,0 +1,135 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { IContextKeyService, IScopedContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { editorBackground, editorForeground, inputBackground } from '../../../../platform/theme/common/colorRegistry.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; +import { IEditorOpenContext } from '../../../common/editor.js'; +import { Memento } from '../../../common/memento.js'; +import { clearChatEditor } from './actions/aideAgentClear.js'; +import { ChatEditorInput } from './aideAgentEditorInput.js'; +import { ChatWidget, IChatViewState } from './aideAgentWidget.js'; +import { ChatAgentLocation } from '../common/aideAgentAgents.js'; +import { IChatModel, IExportableChatData, ISerializableChatData } from '../common/aideAgentModel.js'; +import { CHAT_PROVIDER_ID } from '../common/aideAgentParticipantContribTypes.js'; +import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; +import { EDITOR_DRAG_AND_DROP_BACKGROUND } from '../../../common/theme.js'; + +export interface IChatEditorOptions extends IEditorOptions { + target?: { sessionId: string } | { data: IExportableChatData | ISerializableChatData }; +} + +export class ChatEditor extends EditorPane { + private widget!: ChatWidget; + + private _scopedContextKeyService!: IScopedContextKeyService; + override get scopedContextKeyService() { + return this._scopedContextKeyService; + } + + private _memento: Memento | undefined; + private _viewState: IChatViewState | undefined; + + constructor( + group: IEditorGroup, + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService themeService: IThemeService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IStorageService private readonly storageService: IStorageService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + ) { + super(ChatEditorInput.EditorID, group, telemetryService, themeService, storageService); + } + + private async clear() { + if (this.input) { + return this.instantiationService.invokeFunction(clearChatEditor, this.input as ChatEditorInput); + } + } + + protected override createEditor(parent: HTMLElement): void { + this._scopedContextKeyService = this._register(this.contextKeyService.createScoped(parent)); + const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService]))); + + this.widget = this._register( + scopedInstantiationService.createInstance( + ChatWidget, + ChatAgentLocation.Panel, + undefined, + { supportsFileReferences: true }, + { + listForeground: editorForeground, + listBackground: editorBackground, + overlayBackground: EDITOR_DRAG_AND_DROP_BACKGROUND, + inputEditorBackground: inputBackground, + resultEditorBackground: editorBackground + })); + this._register(this.widget.onDidClear(() => this.clear())); + this.widget.render(parent); + this.widget.setVisible(true); + } + + protected override setEditorVisible(visible: boolean): void { + super.setEditorVisible(visible); + + this.widget?.setVisible(visible); + } + + public override focus(): void { + super.focus(); + + this.widget?.focusInput(); + } + + override clearInput(): void { + this.saveState(); + super.clearInput(); + } + + override async setInput(input: ChatEditorInput, options: IChatEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { + super.setInput(input, options, context, token); + + const editorModel = await input.resolve(); + if (!editorModel) { + throw new Error(`Failed to get model for chat editor. id: ${input.sessionId}`); + } + + if (!this.widget) { + throw new Error('ChatEditor lifecycle issue: no editor widget'); + } + + this.updateModel(editorModel.model, options?.viewState ?? input.options.viewState); + } + + private updateModel(model: IChatModel, viewState?: IChatViewState): void { + this._memento = new Memento('aide-agent-session-editor-' + CHAT_PROVIDER_ID, this.storageService); + this._viewState = viewState ?? this._memento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE) as IChatViewState; + this.widget.setModel(model, { ...this._viewState }); + } + + protected override saveState(): void { + this.widget?.saveState(); + + if (this._memento && this._viewState) { + const widgetViewState = this.widget.getViewState(); + this._viewState.inputValue = widgetViewState.inputValue; + this._memento.saveMemento(); + } + } + + override layout(dimension: dom.Dimension, position?: dom.IDomPosition | undefined): void { + if (this.widget) { + this.widget.layout(dimension.height, dimension.width); + } + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditorInput.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditorInput.ts new file mode 100644 index 00000000000..352db7e92db --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditorInput.ts @@ -0,0 +1,212 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import * as nls from '../../../../nls.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; +import { EditorInputCapabilities, IEditorSerializer, IUntypedEditorInput } from '../../../common/editor.js'; +import { EditorInput } from '../../../common/editor/editorInput.js'; +import type { IChatEditorOptions } from './aideAgentEditor.js'; +import { ChatAgentLocation } from '../common/aideAgentAgents.js'; +import { IChatModel } from '../common/aideAgentModel.js'; +import { IAideAgentService } from '../common/aideAgentService.js'; + +const ChatEditorIcon = registerIcon('chat-editor-label-icon', Codicon.commentDiscussion, nls.localize('chatEditorLabelIcon', 'Icon of the chat editor label.')); + +export class ChatEditorInput extends EditorInput { + static readonly countsInUse = new Set(); + + static readonly TypeID: string = 'workbench.input.aideAgentSession'; + static readonly EditorID: string = 'workbench.editor.aideAgentSession'; + + private readonly inputCount: number; + public sessionId: string | undefined; + + private model: IChatModel | undefined; + + static getNewEditorUri(): URI { + const handle = Math.floor(Math.random() * 1e9); + return ChatUri.generate(handle); + } + + static getNextCount(): number { + let count = 0; + while (ChatEditorInput.countsInUse.has(count)) { + count++; + } + + return count; + } + + constructor( + readonly resource: URI, + readonly options: IChatEditorOptions, + @IAideAgentService private readonly chatService: IAideAgentService + ) { + super(); + + const parsed = ChatUri.parse(resource); + if (typeof parsed?.handle !== 'number') { + throw new Error('Invalid chat URI'); + } + + this.sessionId = (options.target && 'sessionId' in options.target) ? + options.target.sessionId : + undefined; + this.inputCount = ChatEditorInput.getNextCount(); + ChatEditorInput.countsInUse.add(this.inputCount); + this._register(toDisposable(() => ChatEditorInput.countsInUse.delete(this.inputCount))); + } + + override get editorId(): string | undefined { + return ChatEditorInput.EditorID; + } + + override get capabilities(): EditorInputCapabilities { + return super.capabilities | EditorInputCapabilities.Singleton; + } + + override matches(otherInput: EditorInput | IUntypedEditorInput): boolean { + return otherInput instanceof ChatEditorInput && otherInput.resource.toString() === this.resource.toString(); + } + + override get typeId(): string { + return ChatEditorInput.TypeID; + } + + override getName(): string { + return this.model?.title || nls.localize('chatEditorName', "Chat") + (this.inputCount > 0 ? ` ${this.inputCount + 1}` : ''); + } + + override getIcon(): ThemeIcon { + return ChatEditorIcon; + } + + override async resolve(): Promise { + if (typeof this.sessionId === 'string') { + this.model = this.chatService.getOrRestoreSession(this.sessionId); + } else if (!this.options.target) { + this.model = this.chatService.startSession(ChatAgentLocation.Panel, CancellationToken.None); + } else if ('data' in this.options.target) { + this.model = this.chatService.loadSessionFromContent(this.options.target.data); + } + + if (!this.model) { + return null; + } + + this.sessionId = this.model.sessionId; + this._register(this.model.onDidChange(() => this._onDidChangeLabel.fire())); + + return this._register(new ChatEditorModel(this.model)); + } + + override dispose(): void { + super.dispose(); + if (this.sessionId) { + this.chatService.clearSession(this.sessionId); + } + } +} + +export class ChatEditorModel extends Disposable { + private _onWillDispose = this._register(new Emitter()); + readonly onWillDispose = this._onWillDispose.event; + + private _isDisposed = false; + private _isResolved = false; + + constructor( + readonly model: IChatModel + ) { super(); } + + async resolve(): Promise { + this._isResolved = true; + } + + isResolved(): boolean { + return this._isResolved; + } + + isDisposed(): boolean { + return this._isDisposed; + } + + override dispose(): void { + super.dispose(); + this._isDisposed = true; + } +} + +export namespace ChatUri { + + export const scheme = Schemas.vscodeAideAgentSesssion; + + + export function generate(handle: number): URI { + return URI.from({ scheme, path: `chat-${handle}` }); + } + + export function parse(resource: URI): { handle: number } | undefined { + if (resource.scheme !== scheme) { + return undefined; + } + + const match = resource.path.match(/chat-(\d+)/); + const handleStr = match?.[1]; + if (typeof handleStr !== 'string') { + return undefined; + } + + const handle = parseInt(handleStr); + if (isNaN(handle)) { + return undefined; + } + + return { handle }; + } +} + +interface ISerializedChatEditorInput { + options: IChatEditorOptions; + sessionId: string; + resource: URI; +} + +export class ChatEditorInputSerializer implements IEditorSerializer { + canSerialize(input: EditorInput): input is ChatEditorInput & { readonly sessionId: string } { + return input instanceof ChatEditorInput && typeof input.sessionId === 'string'; + } + + serialize(input: EditorInput): string | undefined { + if (!this.canSerialize(input)) { + return undefined; + } + + const obj: ISerializedChatEditorInput = { + options: input.options, + sessionId: input.sessionId, + resource: input.resource + }; + return JSON.stringify(obj); + } + + deserialize(instantiationService: IInstantiationService, serializedEditor: string): EditorInput | undefined { + try { + const parsed: ISerializedChatEditorInput = JSON.parse(serializedEditor); + const resource = URI.revive(parsed.resource); + return instantiationService.createInstance(ChatEditorInput, resource, { ...parsed.options, target: { sessionId: parsed.sessionId } }); + } catch (err) { + return undefined; + } + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentFollowups.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentFollowups.ts new file mode 100644 index 00000000000..c8e86f0b598 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentFollowups.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { Button, IButtonStyles } from '../../../../base/browser/ui/button/button.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { ChatAgentLocation, IAideAgentAgentService } from '../common/aideAgentAgents.js'; +import { formatChatQuestion } from '../common/aideAgentParserTypes.js'; +import { IChatFollowup } from '../common/aideAgentService.js'; + +const $ = dom.$; + +export class ChatFollowups extends Disposable { + constructor( + container: HTMLElement, + followups: T[], + private readonly location: ChatAgentLocation, + private readonly options: IButtonStyles | undefined, + private readonly clickHandler: (followup: T) => void, + @IAideAgentAgentService private readonly chatAgentService: IAideAgentAgentService + ) { + super(); + + const followupsContainer = dom.append(container, $('.interactive-session-followups')); + followups.forEach(followup => this.renderFollowup(followupsContainer, followup)); + } + + private renderFollowup(container: HTMLElement, followup: T): void { + + if (!this.chatAgentService.getDefaultAgent(this.location)) { + // No default agent yet, which affects how followups are rendered, so can't render this yet + return; + } + + const tooltipPrefix = formatChatQuestion(this.chatAgentService, this.location, '', followup.agentId, followup.subCommand); + if (tooltipPrefix === undefined) { + return; + } + + const baseTitle = followup.kind === 'reply' ? + (followup.title || followup.message) + : followup.title; + const message = followup.kind === 'reply' ? followup.message : followup.title; + const tooltip = (tooltipPrefix + + ('tooltip' in followup && followup.tooltip || message)).trim(); + const button = this._register(new Button(container, { ...this.options, title: tooltip })); + if (followup.kind === 'reply') { + button.element.classList.add('interactive-followup-reply'); + } else if (followup.kind === 'command') { + button.element.classList.add('interactive-followup-command'); + } + button.element.ariaLabel = localize('followUpAriaLabel', "Follow up question: {0}", baseTitle); + button.label = new MarkdownString(baseTitle); + + this._register(button.onDidClick(() => this.clickHandler(followup))); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentGettingStarted.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentGettingStarted.ts new file mode 100644 index 00000000000..5d4e0d31734 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentGettingStarted.ts @@ -0,0 +1,139 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IActivityService, NumberBadge } from '../../../services/activity/common/activity.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IExtensionService } from '../../../services/extensions/common/extensions.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope, } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { applicationConfigurationNodeBase } from '../../../common/configuration.js'; +import { localize } from '../../../../nls.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { CHAT_VIEW_ID } from './aideAgent.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IExtensionManagementService } from '../../../../platform/extensionManagement/common/extensionManagement.js'; + +const showChatGettingStartedConfigKey = 'workbench.panel.chat.view.experimental.showGettingStarted'; + +export class ChatGettingStartedContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.aideAgentGettingStarted'; + private readonly showChatGettingStartedDisposable = this._register(new MutableDisposable()); + constructor( + @IContextKeyService private readonly contextService: IContextKeyService, + @IProductService private readonly productService: IProductService, + @IStorageService private readonly storageService: IStorageService, + @IActivityService private readonly activityService: IActivityService, + @IExtensionService private readonly extensionService: IExtensionService, + @ICommandService private readonly commandService: ICommandService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService, + ) { + super(); + + if (!this.productService.gitHubEntitlement) { + return; + } + + if (this.storageService.get(showChatGettingStartedConfigKey, StorageScope.APPLICATION) !== undefined) { + return; + } + + this.extensionManagementService.getInstalled().then(async exts => { + const installed = exts.find(value => ExtensionIdentifier.equals(value.identifier.id, this.productService.gitHubEntitlement!.extensionId)); + if (!installed) { + this.registerListeners(); + return; + } + this.storageService.store(showChatGettingStartedConfigKey, 'installed', StorageScope.APPLICATION, StorageTarget.MACHINE); + }); + } + + private registerListeners() { + + this._register(this.extensionService.onDidChangeExtensions(async (result) => { + + if (this.storageService.get(showChatGettingStartedConfigKey, StorageScope.APPLICATION) !== undefined) { + return; + } + + for (const ext of result.added) { + if (ExtensionIdentifier.equals(this.productService.gitHubEntitlement!.extensionId, ext.identifier)) { + this.displayBadge(); + return; + } + } + })); + + this.extensionService.onDidChangeExtensionsStatus(async (event) => { + + if (this.storageService.get(showChatGettingStartedConfigKey, StorageScope.APPLICATION) !== undefined) { + return; + } + + for (const ext of event) { + if (ExtensionIdentifier.equals(this.productService.gitHubEntitlement!.extensionId, ext.value)) { + const extensionStatus = this.extensionService.getExtensionsStatus(); + if (extensionStatus[ext.value].activationTimes) { + this.displayChatPanel(); + return; + } + } + } + }); + + this._register(this.contextService.onDidChangeContext(event => { + if (this.storageService.get(showChatGettingStartedConfigKey, StorageScope.APPLICATION) === undefined) { + return; + } + if (event.affectsSome(new Set([`view.${CHAT_VIEW_ID}.visible`]))) { + if (this.contextService.contextMatchesRules(ContextKeyExpr.deserialize(`${CHAT_VIEW_ID}.visible`))) { + this.showChatGettingStartedDisposable.clear(); + } + } + })); + } + + private async displayBadge() { + const showGettingStartedExp = this.configurationService.inspect(showChatGettingStartedConfigKey).value ?? ''; + if (!showGettingStartedExp || showGettingStartedExp !== 'showBadge') { + return; + } + + const badge = new NumberBadge(1, () => localize('chat.openPanel', 'Open Chat Panel')); + this.showChatGettingStartedDisposable.value = this.activityService.showViewActivity(CHAT_VIEW_ID, { badge }); + this.storageService.store(showChatGettingStartedConfigKey, showGettingStartedExp, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + private async displayChatPanel() { + const showGettingStartedExp = this.configurationService.inspect(showChatGettingStartedConfigKey).value ?? ''; + if (!showGettingStartedExp || showGettingStartedExp !== 'showChatPanel') { + return; + } + + this.commandService.executeCommand(`${CHAT_VIEW_ID}.focus`); + this.storageService.store(showChatGettingStartedConfigKey, showGettingStartedExp, StorageScope.APPLICATION, StorageTarget.MACHINE); + } +} + +const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); +configurationRegistry.registerConfiguration({ + ...applicationConfigurationNodeBase, + properties: { + 'workbench.panel.chat.view.experimental.showGettingStarted': { + scope: ConfigurationScope.MACHINE, + type: 'string', + default: '', + tags: ['experimental'], + description: localize('workbench.panel.chat.view.showGettingStarted', "When enabled, shows a getting started experiments in the chat panel.") + } + } +}); + diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentInlineAnchorWidget.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentInlineAnchorWidget.ts new file mode 100644 index 00000000000..2ec900ac6d5 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentInlineAnchorWidget.ts @@ -0,0 +1,299 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; +import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { IAction } from '../../../../base/common/actions.js'; +import { Lazy } from '../../../../base/common/lazy.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { basename } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; +import { IRange } from '../../../../editor/common/core/range.js'; +import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; +import { Location, SymbolKinds } 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 { DefinitionAction } from '../../../../editor/contrib/gotoSymbol/browser/goToCommands.js'; +import * as nls from '../../../../nls.js'; +import { createAndFillInContextMenuActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { Action2, IMenuService, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { FileKind, IFileService } from '../../../../platform/files/common/files.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { fillEditorsDragData } from '../../../browser/dnd.js'; +import { ResourceContextKey } from '../../../common/contextkeys.js'; +import { ExplorerFolderContext } from '../../files/common/files.js'; +import { ContentRefData } from '../common/annotations.js'; +import { IChatRequestVariableEntry } from '../common/aideAgentModel.js'; +import { IAideAgentWidgetService } from './aideAgent.js'; + +export class InlineAnchorWidget extends Disposable { + + constructor( + element: HTMLAnchorElement, + data: ContentRefData, + @IContextKeyService originalContextKeyService: IContextKeyService, + @IContextMenuService contextMenuService: IContextMenuService, + @IFileService fileService: IFileService, + @IHoverService hoverService: IHoverService, + @IInstantiationService instantiationService: IInstantiationService, + @ILabelService labelService: ILabelService, + @ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService, + @ILanguageService languageService: ILanguageService, + @IMenuService menuService: IMenuService, + @IModelService modelService: IModelService, + @ITelemetryService telemetryService: ITelemetryService, + ) { + super(); + + const contextKeyService = this._register(originalContextKeyService.createScoped(element)); + const anchorId = new Lazy(generateUuid); + + element.classList.add('chat-inline-anchor-widget', 'show-file-icons'); + + let iconText: string; + let iconClasses: string[]; + + let location: { readonly uri: URI; readonly range?: IRange }; + let contextMenuId: MenuId; + let contextMenuArg: URI | { readonly uri: URI; readonly range?: IRange }; + if (data.kind === 'symbol') { + location = data.symbol.location; + contextMenuId = MenuId.AideAgentInlineSymbolAnchorContext; + contextMenuArg = location; + + iconText = data.symbol.name; + iconClasses = ['codicon', ...getIconClasses(modelService, languageService, undefined, undefined, SymbolKinds.toIcon(data.symbol.kind))]; + + const model = modelService.getModel(location.uri); + if (model) { + const hasDefinitionProvider = EditorContextKeys.hasDefinitionProvider.bindTo(contextKeyService); + const hasReferenceProvider = EditorContextKeys.hasReferenceProvider.bindTo(contextKeyService); + const updateContents = () => { + if (model.isDisposed()) { + return; + } + + hasDefinitionProvider.set(languageFeaturesService.definitionProvider.has(model)); + hasReferenceProvider.set(languageFeaturesService.definitionProvider.has(model)); + }; + updateContents(); + this._register(languageFeaturesService.definitionProvider.onDidChange(updateContents)); + this._register(languageFeaturesService.referenceProvider.onDidChange(updateContents)); + } + + this._register(dom.addDisposableListener(element, 'click', () => { + telemetryService.publicLog2<{ + anchorId: string; + }, { + anchorId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Unique identifier for the current anchor.' }; + owner: 'mjbvz'; + comment: 'Provides insight into the usage of Chat features.'; + }>('chat.inlineAnchor.openSymbol', { + anchorId: anchorId.value + }); + })); + } else { + location = data; + contextMenuId = MenuId.AideAgentInlineResourceAnchorContext; + contextMenuArg = location.uri; + + const resourceContextKey = this._register(new ResourceContextKey(contextKeyService, fileService, languageService, modelService)); + resourceContextKey.set(location.uri); + + const label = labelService.getUriBasenameLabel(location.uri); + iconText = location.range && data.kind !== 'symbol' ? + `${label}#${location.range.startLineNumber}-${location.range.endLineNumber}` : + label; + + const fileKind = location.uri.path.endsWith('/') ? FileKind.FOLDER : FileKind.FILE; + iconClasses = getIconClasses(modelService, languageService, location.uri, fileKind); + + const isFolderContext = ExplorerFolderContext.bindTo(contextKeyService); + fileService.stat(location.uri) + .then(stat => { + isFolderContext.set(stat.isDirectory); + }) + .catch(() => { }); + + this._register(dom.addDisposableListener(element, 'click', () => { + telemetryService.publicLog2<{ + anchorId: string; + }, { + anchorId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Unique identifier for the current anchor.' }; + owner: 'mjbvz'; + comment: 'Provides insight into the usage of Chat features.'; + }>('chat.inlineAnchor.openResource', { + anchorId: anchorId.value + }); + })); + } + + const iconEl = dom.$('span.icon'); + iconEl.classList.add(...iconClasses); + element.replaceChildren(iconEl, dom.$('span.icon-label', {}, iconText)); + + const fragment = location.range ? `${location.range.startLineNumber}-${location.range.endLineNumber}` : ''; + element.setAttribute('data-href', location.uri.with({ fragment }).toString()); + + // Context menu + this._register(dom.addDisposableListener(element, dom.EventType.CONTEXT_MENU, domEvent => { + const event = new StandardMouseEvent(dom.getWindow(domEvent), domEvent); + dom.EventHelper.stop(domEvent, true); + + contextMenuService.showContextMenu({ + contextKeyService, + getAnchor: () => event, + getActions: () => { + const menu = menuService.getMenuActions(contextMenuId, contextKeyService, { arg: contextMenuArg }); + const primary: IAction[] = []; + createAndFillInContextMenuActions(menu, primary); + return primary; + }, + }); + })); + + // Hover + const relativeLabel = labelService.getUriLabel(location.uri, { relative: true }); + this._register(hoverService.setupManagedHover(getDefaultHoverDelegate('element'), element, relativeLabel)); + + // Drag and drop + element.draggable = true; + this._register(dom.addDisposableListener(element, 'dragstart', e => { + instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, [location.uri], e)); + + e.dataTransfer?.setDragImage(element, 0, 0); + })); + } +} + +//#region Resource context menu + +registerAction2(class GoToDefinitionAction extends Action2 { + + static readonly id = 'aideAgent.inlineResourceAnchor.attachToContext'; + + constructor() { + super({ + id: GoToDefinitionAction.id, + title: { + ...nls.localize2('actions.attach.label', "Attach File as Context"), + }, + menu: [{ + id: MenuId.AideAgentInlineResourceAnchorContext, + group: 'chat', + order: 1, + when: ExplorerFolderContext.negate(), + }] + }); + } + + override async run(accessor: ServicesAccessor, resource: URI): Promise { + const chatWidgetService = accessor.get(IAideAgentWidgetService); + const widget = chatWidgetService.lastFocusedWidget; + if (!widget) { + return; + } + + const context: IChatRequestVariableEntry = { + value: resource, + id: resource.toString(), + name: basename(resource), + isFile: true, + isDynamic: true + }; + widget.setContext(true, context); + } +}); + +//#endregion + +//#region Symbol context menu + +registerAction2(class GoToDefinitionAction extends Action2 { + + static readonly id = 'aideAgent.inlineSymbolAnchor.goToDefinition'; + + constructor() { + super({ + id: GoToDefinitionAction.id, + title: { + ...nls.localize2('actions.goToDecl.label', "Go to Definition"), + mnemonicTitle: nls.localize({ key: 'miGotoDefinition', comment: ['&& denotes a mnemonic'] }, "Go to &&Definition"), + }, + precondition: EditorContextKeys.hasDefinitionProvider, + menu: [{ + id: MenuId.AideAgentInlineSymbolAnchorContext, + group: 'navigation', + order: 1.1, + },] + }); + } + + override async run(accessor: ServicesAccessor, location: Location): Promise { + const editorService = accessor.get(ICodeEditorService); + + await editorService.openCodeEditor({ + resource: location.uri, options: { + selection: { + startColumn: location.range.startColumn, + startLineNumber: location.range.startLineNumber, + } + } + }, null); + + const action = new DefinitionAction({ openToSide: false, openInPeek: false, muteMessage: true }, { title: { value: '', original: '' }, id: '', precondition: undefined }); + return action.run(accessor); + } +}); + +registerAction2(class GoToReferencesAction extends Action2 { + + static readonly id = 'aideAgent.inlineSymbolAnchor.goToReferences'; + + constructor() { + super({ + id: GoToReferencesAction.id, + title: { + ...nls.localize2('goToReferences.label', "Go to References"), + mnemonicTitle: nls.localize({ key: 'miGotoReference', comment: ['&& denotes a mnemonic'] }, "Go to &&References"), + }, + precondition: EditorContextKeys.hasReferenceProvider, + menu: [{ + id: MenuId.AideAgentInlineSymbolAnchorContext, + group: 'navigation', + order: 1.1, + },] + }); + } + + override async run(accessor: ServicesAccessor, location: Location): Promise { + const editorService = accessor.get(ICodeEditorService); + const commandService = accessor.get(ICommandService); + + await editorService.openCodeEditor({ + resource: location.uri, options: { + selection: { + startColumn: location.range.startColumn, + startLineNumber: location.range.startLineNumber, + } + } + }, null); + + await commandService.executeCommand('editor.action.goToReferences'); + } +}); + +//#endregion diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentInputPart.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentInputPart.ts new file mode 100644 index 00000000000..666da311fff --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentInputPart.ts @@ -0,0 +1,810 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { DEFAULT_FONT_FAMILY } from '../../../../base/browser/fonts.js'; +import { IHistoryNavigationWidget } from '../../../../base/browser/history.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import * as aria from '../../../../base/browser/ui/aria/aria.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { IAction } from '../../../../base/common/actions.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 } from '../../../../base/common/lifecycle.js'; +import { basename, dirname } from '../../../../base/common/path.js'; +import { isMacintosh } from '../../../../base/common/platform.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; +import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; +import { CodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { IDimension } from '../../../../editor/common/core/dimension.js'; +import { IPosition } from '../../../../editor/common/core/position.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { ITextModel } from '../../../../editor/common/model.js'; +import { IModelService } from '../../../../editor/common/services/model.js'; +import { ContentHoverController } from '../../../../editor/contrib/hover/browser/contentHoverController.js'; +import { GlyphHoverController } from '../../../../editor/contrib/hover/browser/glyphHoverController.js'; +import { localize } from '../../../../nls.js'; +import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; +import { ActionViewItemWithKb } from '../../../../platform/actionbarWithKeybindings/browser/actionViewItemWithKb.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 { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { FileKind } from '../../../../platform/files/common/files.js'; +import { registerAndCreateHistoryNavigationContext } from '../../../../platform/history/browser/contextScopedHistoryWidget.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 { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { ResourceLabels } from '../../../browser/labels.js'; +import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; +import { AccessibilityCommandId } from '../../accessibility/common/accessibilityCommands.js'; +import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions, setupSimpleEditorSelectionStyling } from '../../codeEditor/browser/simpleEditorOptions.js'; +import { ChatAgentLocation } from '../common/aideAgentAgents.js'; +import { CONTEXT_CHAT_INPUT_CURSOR_AT_TOP, CONTEXT_CHAT_INPUT_HAS_FOCUS, CONTEXT_CHAT_INPUT_HAS_TEXT, CONTEXT_IN_CHAT_INPUT } from '../common/aideAgentContextKeys.js'; +import { AgentMode, 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 { IAideAgentLMService } from '../common/languageModels.js'; +import { AgentModePickerActionId, CancelAction, IChatExecuteActionContext, SubmitAction } from './actions/aideAgentExecuteActions.js'; +import { IChatWidget } from './aideAgent.js'; +import { ChatFollowups } from './aideAgentFollowups.js'; + +const $ = dom.$; + +const INPUT_EDITOR_MAX_HEIGHT = 250; + +interface IChatInputPartOptions { + renderFollowups: boolean; + renderStyle?: 'default' | 'compact'; + menus: { + executeToolbar: MenuId; + inputSideToolbar?: MenuId; + telemetrySource?: string; + }; + editorOverflowWidgetsDomNode?: HTMLElement; +} + +export class ChatInputPart extends Disposable implements IHistoryNavigationWidget { + static readonly INPUT_SCHEME = 'aideAgentSessionInput'; + private static _counter = 0; + + private _onDidLoadInputState = this._register(new Emitter()); + readonly onDidLoadInputState = this._onDidLoadInputState.event; + + private _onDidChangeHeight = this._register(new Emitter()); + readonly onDidChangeHeight = this._onDidChangeHeight.event; + + private _onDidFocus = this._register(new Emitter()); + readonly onDidFocus = this._onDidFocus.event; + + private _onDidBlur = this._register(new Emitter()); + readonly onDidBlur = this._onDidBlur.event; + + private _onDidChangeContext = this._register(new Emitter<{ removed?: IChatRequestVariableEntry[]; added?: IChatRequestVariableEntry[] }>()); + readonly onDidChangeContext = this._onDidChangeContext.event; + + private _onDidAcceptFollowup = this._register(new Emitter<{ followup: IChatFollowup; response: IChatResponseViewModel | undefined }>()); + readonly onDidAcceptFollowup = this._onDidAcceptFollowup.event; + + public get attachedContext(): ReadonlySet { + return this._attachedContext; + } + + private _indexOfLastAttachedContextDeletedWithKeyboard: number = -1; + private readonly _attachedContext = new Set(); + + private readonly _onDidChangeVisibility = this._register(new Emitter()); + private readonly _contextResourceLabels = this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility.event }); + + private readonly inputEditorMaxHeight: number; + private inputEditorHeight = 0; + private container!: HTMLElement; + + private inputSideToolbarContainer?: HTMLElement; + + private followupsContainer!: HTMLElement; + private readonly followupsDisposables = this._register(new DisposableStore()); + + private attachedContextContainer!: HTMLElement; + private readonly attachedContextDisposables = this._register(new DisposableStore()); + + private _inputPartHeight: number = 0; + get inputPartHeight() { + return this._inputPartHeight; + } + + private _inputEditor!: CodeEditorWidget; + private _inputEditorElement!: HTMLElement; + + private executeToolbar!: MenuWorkbenchToolBar; + private inputActionsToolbar!: MenuWorkbenchToolBar; + + get inputEditor() { + return this._inputEditor; + } + + private history: HistoryNavigator2; + private historyNavigationBackwardsEnablement!: IContextKey; + private historyNavigationForewardsEnablement!: IContextKey; + private inHistoryNavigation = false; + private inputModel: ITextModel | undefined; + private inputEditorHasText: IContextKey; + private chatCursorAtTop: IContextKey; + private inputEditorHasFocus: IContextKey; + + private _currentLanguageModel: string | undefined; + get currentLanguageModel() { + // Map the internal id to the metadata id + const metadataId = this._currentLanguageModel ? this.languageModelsService.lookupLanguageModel(this._currentLanguageModel)?.id : undefined; + return metadataId; + } + + private _onDidChangeCurrentAgentMode = this._register(new Emitter()); + private _currentAgentMode: AgentMode = AgentMode.Chat; + get currentAgentMode() { + return this._currentAgentMode; + } + + private cachedDimensions: dom.Dimension | undefined; + private cachedExecuteToolbarWidth: number | undefined; + private cachedInputToolbarWidth: number | undefined; + + readonly inputUri = URI.parse(`${ChatInputPart.INPUT_SCHEME}:input-${ChatInputPart._counter++}`); + + constructor( + // private readonly editorOptions: ChatEditorOptions, // TODO this should be used + private readonly location: ChatAgentLocation, + private readonly options: IChatInputPartOptions, + private readonly getInputState: () => any, + @IAideAgentWidgetHistoryService private readonly historyService: IAideAgentWidgetHistoryService, + @IModelService private readonly modelService: IModelService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + @IAideAgentLMService private readonly languageModelsService: IAideAgentLMService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + this.inputEditorMaxHeight = this.options.renderStyle === 'compact' ? INPUT_EDITOR_MAX_HEIGHT / 3 : INPUT_EDITOR_MAX_HEIGHT; + + this.inputEditorHasText = CONTEXT_CHAT_INPUT_HAS_TEXT.bindTo(contextKeyService); + this.chatCursorAtTop = CONTEXT_CHAT_INPUT_CURSOR_AT_TOP.bindTo(contextKeyService); + this.inputEditorHasFocus = CONTEXT_CHAT_INPUT_HAS_FOCUS.bindTo(contextKeyService); + + this.history = this.loadHistory(); + this._register(this.historyService.onDidClearHistory(() => this.history = new HistoryNavigator2([{ text: '' }], 50, historyKeyFn))); + + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(AccessibilityVerbositySettingId.Chat)) { + this.inputEditor.updateOptions({ ariaLabel: this._getAriaLabel() }); + } + })); + } + + 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; + } + + private loadHistory(): HistoryNavigator2 { + const history = this.historyService.getHistory(this.location); + if (history.length === 0) { + history.push({ text: '' }); + } + + return new HistoryNavigator2(history, 50, historyKeyFn); + } + + private _getAriaLabel(): string { + const verbose = this.configurationService.getValue(AccessibilityVerbositySettingId.Chat); + if (verbose) { + const kbLabel = this.keybindingService.lookupKeybinding(AccessibilityCommandId.OpenAccessibilityHelp)?.getLabel(); + return kbLabel ? localize('actions.chat.accessibiltyHelp', "Chat Input, Type to ask questions or type / for topics, press enter to send out the request. Use {0} for Chat Accessibility Help.", kbLabel) : localize('chatInput.accessibilityHelpNoKb', "Chat Input, Type code here and press Enter to run. Use the Chat Accessibility Help command for more information."); + } + 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 { + this.history = this.loadHistory(); + this.history.add({ text: inputValue ?? this.history.current().text, state: inputState }); + + if (inputValue) { + this.setValue(inputValue, false); + } + } + + logInputHistory(): void { + const historyStr = [...this.history].map(entry => JSON.stringify(entry)).join('\n'); + this.logService.info(`[${this.location}] Chat input history:`, historyStr); + } + + setVisible(visible: boolean): void { + this._onDidChangeVisibility.fire(visible); + } + + get element(): HTMLElement { + return this.container; + } + + showPreviousValue(): void { + const inputState = this.getInputState(); + if (this.history.isAtEnd()) { + this.saveCurrentValue(inputState); + } else { + if (!this.history.has({ text: this._inputEditor.getValue(), state: inputState })) { + this.saveCurrentValue(inputState); + this.history.resetCursor(); + } + } + + this.navigateHistory(true); + } + + showNextValue(): void { + const inputState = this.getInputState(); + if (this.history.isAtEnd()) { + return; + } else { + if (!this.history.has({ text: this._inputEditor.getValue(), state: inputState })) { + this.saveCurrentValue(inputState); + this.history.resetCursor(); + } + } + + this.navigateHistory(false); + } + + private navigateHistory(previous: boolean): void { + const historyEntry = previous ? + this.history.previous() : this.history.next(); + + aria.status(historyEntry.text); + + this.inHistoryNavigation = true; + this.setValue(historyEntry.text, true); + this.inHistoryNavigation = false; + + this._onDidLoadInputState.fire(historyEntry.state); + if (previous) { + this._inputEditor.setPosition({ lineNumber: 1, column: 1 }); + } else { + const model = this._inputEditor.getModel(); + if (!model) { + return; + } + + this._inputEditor.setPosition(getLastPosition(model)); + } + } + + setValue(value: string, transient: boolean): void { + this.inputEditor.setValue(value); + // always leave cursor at the end + this.inputEditor.setPosition({ lineNumber: 1, column: value.length + 1 }); + + if (!transient) { + this.saveCurrentValue(this.getInputState()); + } + } + + private saveCurrentValue(inputState: any): void { + const newEntry = { text: this._inputEditor.getValue(), state: inputState }; + this.history.replaceLast(newEntry); + } + + focus() { + this._inputEditor.focus(); + } + + hasFocus(): boolean { + return this._inputEditor.hasWidgetFocus(); + } + + /** + * Reset the input and update history. + * @param userQuery If provided, this will be added to the history. Followups and programmatic queries should not be passed. + */ + async acceptInput(isUserQuery?: boolean): Promise { + if (isUserQuery) { + const userQuery = this._inputEditor.getValue(); + 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._attachedContext.clear(); + this._onDidLoadInputState.fire({}); + if (this.accessibilityService.isScreenReaderOptimized() && isMacintosh) { + this._acceptInputForVoiceover(); + } else { + this._inputEditor.focus(); + this._inputEditor.setValue(''); + } + } + + private _acceptInputForVoiceover(): void { + const domNode = this._inputEditor.getDomNode(); + if (!domNode) { + return; + } + // Remove the input editor from the DOM temporarily to prevent VoiceOver + // from reading the cleared text (the request) to the user. + domNode.remove(); + this._inputEditor.setValue(''); + this._inputEditorElement.appendChild(domNode); + this._inputEditor.focus(); + } + + attachContext(overwrite: boolean, ...contentReferences: IChatRequestVariableEntry[]): void { + const removed = []; + if (overwrite) { + removed.push(...Array.from(this._attachedContext)); + this._attachedContext.clear(); + } + + if (contentReferences.length > 0) { + for (const reference of contentReferences) { + this._attachedContext.add(reference); + } + } + + if (removed.length > 0 || contentReferences.length > 0) { + this.initAttachedContext(this.attachedContextContainer); + + if (!overwrite) { + this._onDidChangeContext.fire({ removed, added: contentReferences }); + } + } + } + + render(container: HTMLElement, initialValue: string, widget: IChatWidget) { + let elements; + if (this.options.renderStyle === 'compact') { + elements = dom.h('.interactive-input-part', [ + dom.h('.interactive-input-and-side-toolbar@inputAndSideToolbar', [ + dom.h('.chat-input-container@inputContainer', [ + dom.h('.chat-editor-container@editorContainer'), + dom.h('.chat-input-toolbars@inputToolbars'), + ]), + ]), + dom.h('.chat-attached-context@attachedContextContainer'), + dom.h('.interactive-input-followups@followupsContainer'), + ]); + } else { + elements = dom.h('.interactive-input-part', [ + dom.h('.interactive-input-followups@followupsContainer'), + dom.h('.interactive-input-and-side-toolbar@inputAndSideToolbar', [ + dom.h('.chat-input-container@inputContainer', [ + dom.h('.chat-attached-context@attachedContextContainer'), + dom.h('.chat-editor-container@editorContainer'), + dom.h('.chat-input-toolbars@inputToolbars'), + ]), + ]), + ]); + } + this.container = elements.root; + container.append(this.container); + this.container.classList.toggle('compact', this.options.renderStyle === 'compact'); + this.followupsContainer = elements.followupsContainer; + const inputAndSideToolbar = elements.inputAndSideToolbar; // The chat input and toolbar to the right + const inputContainer = elements.inputContainer; // The chat editor, attachments, and toolbars + const editorContainer = elements.editorContainer; + this.attachedContextContainer = elements.attachedContextContainer; + const toolbarsContainer = elements.inputToolbars; + this.initAttachedContext(this.attachedContextContainer); + + const inputScopedContextKeyService = this._register(this.contextKeyService.createScoped(inputContainer)); + CONTEXT_IN_CHAT_INPUT.bindTo(inputScopedContextKeyService).set(true); + const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, inputScopedContextKeyService]))); + + const { historyNavigationBackwardsEnablement, historyNavigationForwardsEnablement } = this._register(registerAndCreateHistoryNavigationContext(inputScopedContextKeyService, this)); + this.historyNavigationBackwardsEnablement = historyNavigationBackwardsEnablement; + this.historyNavigationForewardsEnablement = historyNavigationForwardsEnablement; + + const options: IEditorConstructionOptions = getSimpleEditorOptions(this.configurationService); + options.overflowWidgetsDomNode = this.options.editorOverflowWidgetsDomNode; + options.readOnly = false; + options.ariaLabel = this._getAriaLabel(); + options.fontFamily = DEFAULT_FONT_FAMILY; + options.fontSize = 13; + options.lineHeight = 20; + options.padding = this.options.renderStyle === 'compact' ? { top: 2, bottom: 2 } : { top: 8, bottom: 8 }; + options.cursorWidth = 1; + options.wrappingStrategy = 'advanced'; + options.bracketPairColorization = { enabled: false }; + options.suggest = { + showIcons: false, + showSnippets: false, + showWords: true, + showStatusBar: false, + insertMode: 'replace', + }; + options.scrollbar = { ...(options.scrollbar ?? {}), vertical: 'hidden' }; + options.stickyScroll = { enabled: false }; + options.acceptSuggestionOnEnter = 'on'; + + this._inputEditorElement = dom.append(editorContainer!, $(chatInputEditorContainerSelector)); + const editorOptions = getSimpleCodeEditorWidgetOptions(); + editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID, GlyphHoverController.ID])); + this._inputEditor = this._register(scopedInstantiationService.createInstance(CodeEditorWidget, this._inputEditorElement, options, editorOptions)); + + this._register(this._inputEditor.onDidChangeModelContent(() => { + const currentHeight = Math.min(this._inputEditor.getContentHeight(), this.inputEditorMaxHeight); + if (currentHeight !== this.inputEditorHeight) { + this.inputEditorHeight = currentHeight; + this._onDidChangeHeight.fire(); + } + + const model = this._inputEditor.getModel(); + const inputHasText = !!model && model.getValue().trim().length > 0; + this.inputEditorHasText.set(inputHasText); + })); + this._register(this._inputEditor.onDidFocusEditorText(() => { + this.inputEditorHasFocus.set(true); + this._onDidFocus.fire(); + inputContainer.classList.toggle('focused', true); + })); + this._register(this._inputEditor.onDidBlurEditorText(() => { + this.inputEditorHasFocus.set(false); + inputContainer.classList.toggle('focused', false); + + this._onDidBlur.fire(); + })); + + 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, + actionViewItemProvider: (action, options) => { + if (action instanceof MenuItemAction) { + return this.instantiationService.createInstance(MenuEntryActionViewItem, action, undefined); + } + + return undefined; + } + })); + this.inputActionsToolbar.context = { widget } satisfies IChatExecuteActionContext; + this._register(this.inputActionsToolbar.onDidChangeMenuItems(() => { + if (this.cachedDimensions && typeof this.cachedInputToolbarWidth === 'number' && this.cachedInputToolbarWidth !== this.inputActionsToolbar.getItemsWidth()) { + this.layout(this.cachedDimensions.height, this.cachedDimensions.width); + } + })); + this.executeToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, toolbarsContainer, this.options.menus.executeToolbar, { + telemetrySource: this.options.menus.telemetrySource, + menuOptions: { + shouldForwardArgs: true + }, + hiddenItemStrategy: HiddenItemStrategy.Ignore, // keep it lean when hiding items and avoid a "..." overflow menu + actionViewItemProvider: (action, options) => { + if ((action.id === SubmitAction.ID || action.id === CancelAction.ID) && action instanceof MenuItemAction) { + return this.instantiationService.createInstance(ActionViewItemWithKb, action); + } + + if (action.id === AgentModePickerActionId && action instanceof MenuItemAction) { + const itemDelegate: AgentModeSetterDelegate = { + onDidChangeMode: this._onDidChangeCurrentAgentMode.event, + setMode: (modeId: string) => { + this._currentAgentMode = modeId as AgentMode; + } + }; + return this.instantiationService.createInstance(AgentModeActionViewItem, action, this._currentAgentMode, itemDelegate); + } + + return undefined; + } + })); + this.executeToolbar.context = { widget } satisfies IChatExecuteActionContext; + this._register(this.executeToolbar.onDidChangeMenuItems(() => { + if (this.cachedDimensions && typeof this.cachedExecuteToolbarWidth === 'number' && this.cachedExecuteToolbarWidth !== this.executeToolbar.getItemsWidth()) { + this.layout(this.cachedDimensions.height, this.cachedDimensions.width); + } + })); + if (this.options.menus.inputSideToolbar) { + const toolbarSide = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, inputAndSideToolbar, this.options.menus.inputSideToolbar, { + telemetrySource: this.options.menus.telemetrySource, + menuOptions: { + shouldForwardArgs: true + } + })); + this.inputSideToolbarContainer = toolbarSide.getElement(); + toolbarSide.getElement().classList.add('chat-side-toolbar'); + toolbarSide.context = { widget } satisfies IChatExecuteActionContext; + } + + let inputModel = this.modelService.getModel(this.inputUri); + if (!inputModel) { + inputModel = this.modelService.createModel('', null, this.inputUri, true); + this._register(inputModel); + } + + this.inputModel = inputModel; + this.inputModel.updateOptions({ bracketColorizationOptions: { enabled: false, independentColorPoolPerBracketType: false } }); + this._inputEditor.setModel(this.inputModel); + if (initialValue) { + this.inputModel.setValue(initialValue); + const lineNumber = this.inputModel.getLineCount(); + this._inputEditor.setPosition({ lineNumber, column: this.inputModel.getLineMaxColumn(lineNumber) }); + } + + const onDidChangeCursorPosition = () => { + const model = this._inputEditor.getModel(); + if (!model) { + return; + } + + const position = this._inputEditor.getPosition(); + if (!position) { + return; + } + + const atTop = position.column === 1 && position.lineNumber === 1; + this.chatCursorAtTop.set(atTop); + + this.historyNavigationBackwardsEnablement.set(atTop); + this.historyNavigationForewardsEnablement.set(position.equals(getLastPosition(model))); + }; + this._register(this._inputEditor.onDidChangeCursorPosition(e => onDidChangeCursorPosition())); + onDidChangeCursorPosition(); + } + + private initAttachedContext(container: HTMLElement, isLayout = false) { + const oldHeight = container.offsetHeight; + dom.clearNode(container); + this.attachedContextDisposables.clear(); + dom.setVisibility(Boolean(this.attachedContext.size), this.attachedContextContainer); + if (!this.attachedContext.size) { + this._indexOfLastAttachedContextDeletedWithKeyboard = -1; + } + [...this.attachedContext.values()].forEach((attachment, index) => { + const widget = dom.append(container, $('.chat-attached-context-attachment.show-file-icons')); + const label = this._contextResourceLabels.create(widget, { supportIcons: true }); + const file = 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 (file && attachment.isFile) { + const fileBasename = basename(file.path); + const fileDirname = dirname(file.path); + const friendlyName = `${fileBasename} ${fileDirname}`; + const ariaLabel = range ? localize('chat.fileAttachmentWithRange', "Attached file, {0}, line {1} to line {2}", friendlyName, range.startLineNumber, range.endLineNumber) : localize('chat.fileAttachment', "Attached file, {0}", friendlyName); + + label.setFile(file, { + fileKind: FileKind.FILE, + hidePath: true, + range, + }); + widget.ariaLabel = ariaLabel; + widget.tabIndex = 0; + } else { + const attachmentLabel = attachment.fullName ?? attachment.name; + const withIcon = attachment.icon?.id ? `$(${attachment.icon.id}) ${attachmentLabel}` : attachmentLabel; + label.setLabel(withIcon, undefined); + + widget.ariaLabel = localize('chat.attachment', "Attached context, {0}", attachment.name); + widget.tabIndex = 0; + } + + const clearButton = new Button(widget, { supportIcons: true }); + + // If this item is rendering in place of the last attached context item, focus the clear button so the user can continue deleting attached context items with the keyboard + if (index === Math.min(this._indexOfLastAttachedContextDeletedWithKeyboard, this.attachedContext.size - 1)) { + clearButton.focus(); + } + + this.attachedContextDisposables.add(clearButton); + clearButton.icon = Codicon.close; + const disp = clearButton.onDidClick((e) => { + this._attachedContext.delete(attachment); + disp.dispose(); + + // Set focus to the next attached context item if deletion was triggered by a keystroke (vs a mouse click) + if (dom.isKeyboardEvent(e)) { + const event = new StandardKeyboardEvent(e); + if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { + this._indexOfLastAttachedContextDeletedWithKeyboard = index; + } + } + + this._onDidChangeHeight.fire(); + this._onDidChangeContext.fire({ removed: [attachment] }); + }); + this.attachedContextDisposables.add(disp); + }); + + if (oldHeight !== container.offsetHeight && !isLayout) { + this._onDidChangeHeight.fire(); + } + } + + async renderFollowups(items: IChatFollowup[] | undefined, response: IChatResponseViewModel | undefined): Promise { + if (!this.options.renderFollowups) { + return; + } + this.followupsDisposables.clear(); + dom.clearNode(this.followupsContainer); + + if (items && items.length > 0) { + this.followupsDisposables.add(this.instantiationService.createInstance, ChatFollowups>(ChatFollowups, this.followupsContainer, items, this.location, undefined, followup => this._onDidAcceptFollowup.fire({ followup, response }))); + } + this._onDidChangeHeight.fire(); + } + + get contentHeight(): number { + const data = this.getLayoutData(); + return data.followupsHeight + data.inputPartEditorHeight + data.inputPartVerticalPadding + data.inputEditorBorder + data.attachmentsHeight + data.toolbarsHeight; + } + + layout(height: number, width: number) { + this.cachedDimensions = new dom.Dimension(width, height); + + return this._layout(height, width); + } + + private previousInputEditorDimension: IDimension | undefined; + private _layout(height: number, width: number, allowRecurse = true): void { + this.initAttachedContext(this.attachedContextContainer, true); + + const data = this.getLayoutData(); + const inputEditorHeight = Math.min(data.inputPartEditorHeight, height - data.followupsHeight - data.attachmentsHeight - data.inputPartVerticalPadding - data.toolbarsHeight); + + const followupsWidth = width - data.inputPartHorizontalPadding; + this.followupsContainer.style.width = `${followupsWidth}px`; + + this._inputPartHeight = data.inputPartVerticalPadding + data.followupsHeight + inputEditorHeight + data.inputEditorBorder + data.attachmentsHeight + data.toolbarsHeight; + + const initialEditorScrollWidth = this._inputEditor.getScrollWidth(); + const newEditorWidth = width - data.inputPartHorizontalPadding - data.editorBorder - data.inputPartHorizontalPaddingInside - data.toolbarsWidth - data.sideToolbarWidth; + const newDimension = { width: newEditorWidth, height: inputEditorHeight }; + if (!this.previousInputEditorDimension || (this.previousInputEditorDimension.width !== newDimension.width || this.previousInputEditorDimension.height !== newDimension.height)) { + // This layout call has side-effects that are hard to understand. eg if we are calling this inside a onDidChangeContent handler, this can trigger the next onDidChangeContent handler + // to be invoked, and we have a lot of these on this editor. Only doing a layout this when the editor size has actually changed makes it much easier to follow. + this._inputEditor.layout(newDimension); + this.previousInputEditorDimension = newDimension; + } + + if (allowRecurse && initialEditorScrollWidth < 10) { + // This is probably the initial layout. Now that the editor is layed out with its correct width, it should report the correct contentHeight + return this._layout(height, width, false); + } + } + + private getLayoutData() { + 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; + return { + inputEditorBorder: 2, + followupsHeight: this.followupsContainer.offsetHeight, + inputPartEditorHeight: Math.min(this._inputEditor.getContentHeight(), this.inputEditorMaxHeight), + inputPartHorizontalPadding: this.options.renderStyle === 'compact' ? 12 : 32, + inputPartVerticalPadding: this.options.renderStyle === 'compact' ? 12 : 30, + attachmentsHeight: this.attachedContextContainer.offsetHeight, + editorBorder: 2, + inputPartHorizontalPaddingInside: 12, + toolbarsWidth: this.options.renderStyle === 'compact' ? executeToolbarWidth + executeToolbarPadding + inputToolbarWidth + inputToolbarPadding : 0, + toolbarsHeight: this.options.renderStyle === 'compact' ? 0 : 22, + sideToolbarWidth: this.inputSideToolbarContainer ? dom.getTotalWidth(this.inputSideToolbarContainer) + 4 /*gap*/ : 0, + }; + } + + saveState(): void { + this.saveCurrentValue(this.getInputState()); + const inputHistory = [...this.history]; + this.historyService.saveHistory(this.location, inputHistory); + } +} + +const historyKeyFn = (entry: IChatHistoryEntry) => JSON.stringify(entry); + +function getLastPosition(model: ITextModel): IPosition { + return { lineNumber: model.getLineCount(), column: model.getLineLength(model.getLineCount()) + 1 }; +} + +interface AgentModeSetterDelegate { + onDidChangeMode: Event; + setMode(selectedModeId: string): void; +} + +class AgentModeActionViewItem extends MenuEntryActionViewItem { + constructor( + action: MenuItemAction, + private currentAgentMode: AgentMode, + private delegate: AgentModeSetterDelegate, + @IKeybindingService keybindingService: IKeybindingService, + @INotificationService notificationService: INotificationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IThemeService themeService: IThemeService, + @IContextMenuService contextMenuService: IContextMenuService, + @IAccessibilityService _accessibilityService: IAccessibilityService + ) { + super(action, undefined, keybindingService, notificationService, contextKeyService, themeService, contextMenuService, _accessibilityService); + + this._register(delegate.onDidChangeMode(modeId => { + this.currentAgentMode = modeId as AgentMode; + this.updateLabel(); + })); + } + + override async onClick(): Promise { + this._openContextMenu(); + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('agentmode-picker-item'); + } + + protected override updateLabel(): void { + if (this.label) { + this.label.textContent = this.currentAgentMode; + dom.reset(this.label, ...renderLabelWithIcons(`${this.currentAgentMode}$(chevron-down)`)); + } + } + + private _openContextMenu() { + const setAgentModeAction = (mode: string): IAction => { + return { + id: mode, + label: mode, + tooltip: '', + class: undefined, + enabled: true, + checked: mode === this.currentAgentMode, + run: () => { + this.currentAgentMode = mode as AgentMode; + this.delegate.setMode(mode); + this.updateLabel(); + } + }; + }; + + this._contextMenuService.showContextMenu({ + getAnchor: () => this.element!, + getActions: () => [ + setAgentModeAction('Edit'), + setAgentModeAction('Chat'), + ] + }); + } +} + +const chatInputEditorContainerSelector = '.interactive-input-editor'; +setupSimpleEditorSelectionStyling(chatInputEditorContainerSelector); diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentListRenderer.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentListRenderer.ts new file mode 100644 index 00000000000..97a6cd25dcf --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentListRenderer.ts @@ -0,0 +1,1023 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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 { IMarkdownString, 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 { FileAccess } from '../../../../base/common/network.js'; +import { clamp } from '../../../../base/common/numbers.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 { 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 { 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 { ColorScheme } from '../../../../platform/theme/common/theme.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IWorkbenchIssueService } from '../../issue/common/issue.js'; +import { annotateSpecialMarkdownContent } from '../common/annotations.js'; +import { ChatAgentLocation, IChatAgentMetadata } from '../common/aideAgentAgents.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 { IChatRequestVariableEntry, IChatTextEditGroup } from '../common/aideAgentModel.js'; +import { chatSubcommandLeader } from '../common/aideAgentParserTypes.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IChatConfirmation, IChatContentReference, IChatFollowup, IChatTask, IChatTreeData } from '../common/aideAgentService.js'; +import { IChatCodeCitations, IChatReferences, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, IChatWelcomeMessageViewModel, isRequestVM, isResponseVM, isWelcomeVM } from '../common/aideAgentViewModel.js'; +import { getNWords } from '../common/aideAgentWordCounter.js'; +import { CodeBlockModelCollection } from '../common/codeBlockModelCollection.js'; +import { MarkUnhelpfulActionId } from './actions/aideAgentTitleActions.js'; +import { ChatTreeItem, GeneratingPhrase, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions } from './aideAgent.js'; +import { ChatAttachmentsContentPart } from './aideAgentContentParts/aideAgentAttachmentsContentPart.js'; +import { ChatCodeCitationContentPart } from './aideAgentContentParts/aideAgentCodeCitationContentPart.js'; +import { ChatCommandButtonContentPart } from './aideAgentContentParts/aideAgentCommandContentPart.js'; +import { ChatConfirmationContentPart } from './aideAgentContentParts/aideAgentConfirmationContentPart.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './aideAgentContentParts/aideAgentContentParts.js'; +import { ChatMarkdownContentPart, EditorPool } from './aideAgentContentParts/aideAgentMarkdownContentPart.js'; +import { ChatProgressContentPart } from './aideAgentContentParts/aideAgentProgressContentPart.js'; +import { ChatCollapsibleListContentPart, CollapsibleListPool } from './aideAgentContentParts/aideAgentReferencesContentPart.js'; +import { ChatTaskContentPart } from './aideAgentContentParts/aideAgentTaskContentPart.js'; +import { ChatTextEditContentPart, DiffEditorPool } from './aideAgentContentParts/aideAgentTextEditContentPart.js'; +import { ChatTreeContentPart, TreePool } from './aideAgentContentParts/aideAgentTreeContentPart.js'; +import { ChatWarningContentPart } from './aideAgentContentParts/aideAgentWarningContentPart.js'; +import { ChatFollowups } from './aideAgentFollowups.js'; +import { ChatMarkdownDecorationsRenderer } from './aideAgentMarkdownDecorationsRenderer.js'; +import { ChatMarkdownRenderer } from './aideAgentMarkdownRenderer.js'; +import { ChatEditorOptions } from './aideAgentOptions.js'; +import { ChatCodeBlockContentProvider, CodeBlockPart } from './codeBlockPart.js'; + +const $ = dom.$; + +interface IChatListItemTemplate { + currentElement?: ChatTreeItem; + renderedParts?: IChatContentPart[]; + readonly rowContainer: HTMLElement; + readonly titleToolbar?: MenuWorkbenchToolBar; + readonly avatarContainer: HTMLElement; + readonly username: HTMLElement; + readonly detail: HTMLElement; + readonly value: HTMLElement; + readonly contextKeyService: IContextKeyService; + readonly instantiationService: IInstantiationService; + readonly templateDisposables: IDisposable; + readonly elementDisposables: DisposableStore; +} + +interface IItemHeightChangeParams { + element: ChatTreeItem; + height: number; +} + +const forceVerboseLayoutTracing = false + // || Boolean("TRUE") // causes a linter warning so that it cannot be pushed + ; + +export interface IChatRendererDelegate { + getListLength(): number; + + readonly onDidScroll?: Event; +} + +export class ChatListItemRenderer extends Disposable implements ITreeRenderer { + static readonly ID = 'item'; + + private readonly codeBlocksByResponseId = new Map(); + private readonly codeBlocksByEditorUri = new ResourceMap(); + + private readonly fileTreesByResponseId = new Map(); + private readonly focusedFileTreesByResponseId = new Map(); + + private readonly renderer: MarkdownRenderer; + private readonly markdownDecorationsRenderer: ChatMarkdownDecorationsRenderer; + + protected readonly _onDidClickFollowup = this._register(new Emitter()); + readonly onDidClickFollowup: Event = this._onDidClickFollowup.event; + + private readonly _onDidClickRerunWithAgentOrCommandDetection = new Emitter(); + readonly onDidClickRerunWithAgentOrCommandDetection: Event = this._onDidClickRerunWithAgentOrCommandDetection.event; + + protected readonly _onDidChangeItemHeight = this._register(new Emitter()); + readonly onDidChangeItemHeight: Event = this._onDidChangeItemHeight.event; + + private readonly _editorPool: EditorPool; + private readonly _diffEditorPool: DiffEditorPool; + private readonly _treePool: TreePool; + private readonly _contentReferencesListPool: CollapsibleListPool; + + private _currentLayoutWidth: number = 0; + private _isVisible = true; + private _onDidChangeVisibility = this._register(new Emitter()); + + constructor( + editorOptions: ChatEditorOptions, + private readonly location: ChatAgentLocation, + private readonly rendererOptions: IChatListItemRendererOptions, + private readonly delegate: IChatRendererDelegate, + private readonly codeBlockModelCollection: CodeBlockModelCollection, + overflowWidgetsDomNode: HTMLElement | undefined, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IConfigurationService configService: IConfigurationService, + @ILogService private readonly logService: ILogService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IThemeService private readonly themeService: IThemeService, + ) { + super(); + + this.renderer = this._register(this.instantiationService.createInstance(ChatMarkdownRenderer, undefined)); + this.markdownDecorationsRenderer = this.instantiationService.createInstance(ChatMarkdownDecorationsRenderer); + this._editorPool = this._register(this.instantiationService.createInstance(EditorPool, editorOptions, delegate, overflowWidgetsDomNode)); + this._diffEditorPool = this._register(this.instantiationService.createInstance(DiffEditorPool, editorOptions, delegate, overflowWidgetsDomNode)); + this._treePool = this._register(this.instantiationService.createInstance(TreePool, this._onDidChangeVisibility.event)); + this._contentReferencesListPool = this._register(this.instantiationService.createInstance(CollapsibleListPool, this._onDidChangeVisibility.event)); + + this._register(this.instantiationService.createInstance(ChatCodeBlockContentProvider)); + } + + get templateId(): string { + return ChatListItemRenderer.ID; + } + + editorsInUse(): Iterable { + return this._editorPool.inUse(); + } + + private traceLayout(method: string, message: string) { + if (forceVerboseLayoutTracing) { + this.logService.info(`ChatListItemRenderer#${method}: ${message}`); + } else { + this.logService.trace(`ChatListItemRenderer#${method}: ${message}`); + } + } + + /** + * Compute a rate to render at in words/s. + */ + private getProgressiveRenderRate(element: IChatResponseViewModel): number { + if (element.isComplete) { + return 80; + } + + if (element.contentUpdateTimings && element.contentUpdateTimings.impliedWordLoadRate) { + const minRate = 5; + const maxRate = 80; + + const rate = element.contentUpdateTimings.impliedWordLoadRate; + return clamp(rate, minRate, maxRate); + } + + return 8; + } + + getCodeBlockInfosForResponse(response: IChatResponseViewModel): IChatCodeBlockInfo[] { + const codeBlocks = this.codeBlocksByResponseId.get(response.id); + return codeBlocks ?? []; + } + + getCodeBlockInfoForEditor(uri: URI): IChatCodeBlockInfo | undefined { + return this.codeBlocksByEditorUri.get(uri); + } + + getFileTreeInfosForResponse(response: IChatResponseViewModel): IChatFileTreeInfo[] { + const fileTrees = this.fileTreesByResponseId.get(response.id); + return fileTrees ?? []; + } + + getLastFocusedFileTreeForResponse(response: IChatResponseViewModel): IChatFileTreeInfo | undefined { + const fileTrees = this.fileTreesByResponseId.get(response.id); + const lastFocusedFileTreeIndex = this.focusedFileTreesByResponseId.get(response.id); + if (fileTrees?.length && lastFocusedFileTreeIndex !== undefined && lastFocusedFileTreeIndex < fileTrees.length) { + return fileTrees[lastFocusedFileTreeIndex]; + } + return undefined; + } + + setVisible(visible: boolean): void { + this._isVisible = visible; + this._onDidChangeVisibility.fire(visible); + } + + layout(width: number): void { + this._currentLayoutWidth = width - (this.rendererOptions.noPadding ? 0 : 40); // padding + for (const editor of this._editorPool.inUse()) { + editor.layout(this._currentLayoutWidth); + } + for (const diffEditor of this._diffEditorPool.inUse()) { + diffEditor.layout(this._currentLayoutWidth); + } + } + + renderTemplate(container: HTMLElement): IChatListItemTemplate { + const templateDisposables = new DisposableStore(); + const rowContainer = dom.append(container, $('.interactive-item-container')); + if (this.rendererOptions.renderStyle === 'compact') { + rowContainer.classList.add('interactive-item-compact'); + } + if (this.rendererOptions.noPadding) { + rowContainer.classList.add('no-padding'); + } + + let headerParent = rowContainer; + let valueParent = rowContainer; + let detailContainerParent: HTMLElement | undefined; + let toolbarParent: HTMLElement | undefined; + + if (this.rendererOptions.renderStyle === 'minimal') { + rowContainer.classList.add('interactive-item-compact'); + rowContainer.classList.add('minimal'); + // ----------------------------------------------------- + // icon | details + // | references + // | value + // ----------------------------------------------------- + const lhsContainer = dom.append(rowContainer, $('.column.left')); + const rhsContainer = dom.append(rowContainer, $('.column.right')); + + headerParent = lhsContainer; + detailContainerParent = rhsContainer; + valueParent = rhsContainer; + toolbarParent = dom.append(rowContainer, $('.header')); + } + + const header = dom.append(headerParent, $('.header')); + const user = dom.append(header, $('.user')); + user.tabIndex = 0; + user.role = 'toolbar'; + const avatarContainer = dom.append(user, $('.avatar-container')); + const username = dom.append(user, $('h3.username')); + const detailContainer = dom.append(detailContainerParent ?? user, $('span.detail-container')); + const detail = dom.append(detailContainer, $('span.detail')); + dom.append(detailContainer, $('span.chat-animated-ellipsis')); + const value = dom.append(valueParent, $('.value')); + const elementDisposables = new DisposableStore(); + + const contextKeyService = templateDisposables.add(this.contextKeyService.createScoped(rowContainer)); + const scopedInstantiationService = templateDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService]))); + + let titleToolbar: MenuWorkbenchToolBar | undefined; + if (this.rendererOptions.noHeader) { + header.classList.add('hidden'); + } else { + titleToolbar = templateDisposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, toolbarParent ?? header, MenuId.AideAgentMessageTitle, { + menuOptions: { + shouldForwardArgs: true + }, + toolbarOptions: { + shouldInlineSubmenu: submenu => 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 }; + return template; + } + + renderElement(node: ITreeNode, index: number, templateData: IChatListItemTemplate): void { + this.renderChatTreeItem(node.element, index, templateData); + } + + renderChatTreeItem(element: ChatTreeItem, index: number, templateData: IChatListItemTemplate): void { + templateData.currentElement = element; + const kind = isRequestVM(element) ? 'request' : + isResponseVM(element) ? 'response' : + 'welcome'; + this.traceLayout('renderElement', `${kind}, index=${index}`); + + CONTEXT_RESPONSE.bindTo(templateData.contextKeyService).set(isResponseVM(element)); + CONTEXT_REQUEST.bindTo(templateData.contextKeyService).set(isRequestVM(element)); + CONTEXT_RESPONSE_DETECTED_AGENT_COMMAND.bindTo(templateData.contextKeyService).set(isResponseVM(element) && element.agentOrSlashCommandDetected); + if (isResponseVM(element)) { + CONTEXT_CHAT_RESPONSE_SUPPORT_ISSUE_REPORTING.bindTo(templateData.contextKeyService).set(!!element.agent?.metadata.supportIssueReporting); + CONTEXT_RESPONSE_VOTE.bindTo(templateData.contextKeyService).set(element.vote === ChatAgentVoteDirection.Up ? 'up' : element.vote === ChatAgentVoteDirection.Down ? 'down' : ''); + } else { + CONTEXT_RESPONSE_VOTE.bindTo(templateData.contextKeyService).set(''); + } + + if (templateData.titleToolbar) { + templateData.titleToolbar.context = element; + } + + CONTEXT_RESPONSE_ERROR.bindTo(templateData.contextKeyService).set(isResponseVM(element) && !!element.errorDetails); + const isFiltered = !!(isResponseVM(element) && element.errorDetails?.responseIsFiltered); + CONTEXT_RESPONSE_FILTERED.bindTo(templateData.contextKeyService).set(isFiltered); + + templateData.rowContainer.classList.toggle('interactive-request', isRequestVM(element)); + templateData.rowContainer.classList.toggle('interactive-response', isResponseVM(element)); + templateData.rowContainer.classList.toggle('interactive-welcome', isWelcomeVM(element)); + templateData.rowContainer.classList.toggle('show-detail-progress', isResponseVM(element) && !element.isComplete && !element.progressMessages.length); + templateData.username.textContent = element.username; + if (!this.rendererOptions.noHeader) { + this.renderAvatar(element, templateData); + } + + dom.clearNode(templateData.detail); + if (isResponseVM(element)) { + this.renderDetail(element, templateData); + } + + if (isRequestVM(element) && element.confirmation) { + this.renderConfirmationAction(element, templateData); + } + + // Do a progressive render if + // - This the last response in the list + // - And it has some content + // - And the response is not complete + // - Or, we previously started a progressive rendering of this element (if the element is complete, we will finish progressive rendering with a very fast rate) + if (isResponseVM(element) && index === this.delegate.getListLength() - 1 && (!element.isComplete || element.renderData) && element.response.value.length) { + this.traceLayout('renderElement', `start progressive render ${kind}, index=${index}`); + + const timer = templateData.elementDisposables.add(new dom.WindowIntervalTimer()); + const runProgressiveRender = (initial?: boolean) => { + try { + if (this.doNextProgressiveRender(element, index, templateData, !!initial)) { + timer.cancel(); + } + } catch (err) { + // Kill the timer if anything went wrong, avoid getting stuck in a nasty rendering loop. + timer.cancel(); + this.logService.error(err); + } + }; + timer.cancelAndSet(runProgressiveRender, 50, dom.getWindow(templateData.rowContainer)); + runProgressiveRender(true); + } else if (isResponseVM(element)) { + this.basicRenderElement(element, index, templateData); + } else if (isRequestVM(element)) { + this.basicRenderElement(element, index, templateData); + } else { + this.renderWelcomeMessage(element, templateData); + } + } + + private renderDetail(element: IChatResponseViewModel, templateData: IChatListItemTemplate): void { + templateData.elementDisposables.add(autorun(reader => { + this._renderDetail(element, templateData); + })); + } + + private _renderDetail(element: IChatResponseViewModel, templateData: IChatListItemTemplate): void { + + dom.clearNode(templateData.detail); + + if (element.agentOrSlashCommandDetected) { + const msg = element.slashCommand ? localize('usedAgentSlashCommand', "used {0} [[(rerun without)]]", `${chatSubcommandLeader}${element.slashCommand.name}`) : localize('usedAgent', "[[(rerun without)]]"); + dom.reset(templateData.detail, renderFormattedText(msg, { + className: 'agentOrSlashCommandDetected', + inline: true, + actionHandler: { + disposables: templateData.elementDisposables, + callback: (content) => { + this._onDidClickRerunWithAgentOrCommandDetection.fire(element); + }, + } + })); + + } else if (!element.isComplete) { + templateData.detail.textContent = GeneratingPhrase; + } + } + + private renderConfirmationAction(element: IChatRequestViewModel, templateData: IChatListItemTemplate) { + dom.clearNode(templateData.detail); + if (element.confirmation) { + templateData.detail.textContent = localize('chatConfirmationAction', 'selected "{0}"', element.confirmation); + } + } + + private renderAvatar(element: ChatTreeItem, templateData: IChatListItemTemplate): void { + const icon = isResponseVM(element) ? + this.getAgentIcon(element.agent?.metadata) : + (element.avatarIcon ?? Codicon.account); + if (icon instanceof URI) { + const avatarIcon = dom.$('img.icon'); + avatarIcon.src = FileAccess.uriToBrowserUri(icon).toString(true); + templateData.avatarContainer.replaceChildren(dom.$('.avatar', undefined, avatarIcon)); + } else { + const avatarIcon = dom.$(ThemeIcon.asCSSSelector(icon)); + templateData.avatarContainer.replaceChildren(dom.$('.avatar.codicon-avatar', undefined, avatarIcon)); + } + } + + private getAgentIcon(agent: IChatAgentMetadata | undefined): URI | ThemeIcon { + if (agent?.themeIcon) { + return agent.themeIcon; + } else if (agent?.iconDark && this.themeService.getColorTheme().type === ColorScheme.DARK) { + return agent.iconDark; + } else if (agent?.icon) { + return agent.icon; + } else { + return Codicon.copilot; + } + } + + private basicRenderElement(element: ChatTreeItem, index: number, templateData: IChatListItemTemplate) { + let value: IChatRendererContent[] = []; + if (isRequestVM(element) && !element.confirmation) { + const markdown = 'message' in element.message ? + element.message.message : + this.markdownDecorationsRenderer.convertParsedRequestToMarkdown(element.message); + value = [{ content: new MarkdownString(markdown), kind: 'markdownContent' }]; + } else if (isResponseVM(element)) { + if (element.contentReferences.length) { + value.push({ kind: 'references', references: element.contentReferences }); + } + value.push(...annotateSpecialMarkdownContent(element.response.value)); + if (element.codeCitations.length) { + value.push({ kind: 'codeCitations', citations: element.codeCitations }); + } + } + + dom.clearNode(templateData.value); + + if (isResponseVM(element)) { + this.renderDetail(element, templateData); + } + + const isFiltered = !!(isResponseVM(element) && element.errorDetails?.responseIsFiltered); + + const parts: IChatContentPart[] = []; + if (!isFiltered) { + value.forEach((data, index) => { + const context: IChatContentPartRenderContext = { + element, + index, + content: value, + preceedingContentParts: parts, + }; + const newPart = this.renderChatContentPart(data, templateData, context); + if (newPart) { + templateData.value.appendChild(newPart.domNode); + parts.push(newPart); + } + }); + } + + if (templateData.renderedParts) { + dispose(templateData.renderedParts); + } + templateData.renderedParts = parts; + + if (!isFiltered) { + if (isRequestVM(element) && element.variables.length) { + const newPart = this.renderAttachments(element.variables, element.contentReferences, templateData); + if (newPart) { + templateData.value.appendChild(newPart.domNode); + templateData.elementDisposables.add(newPart); + } + } + } + + if (isResponseVM(element) && element.errorDetails?.message) { + const renderedError = this.instantiationService.createInstance(ChatWarningContentPart, element.errorDetails.responseIsFiltered ? 'info' : 'error', new MarkdownString(element.errorDetails.message), this.renderer); + templateData.elementDisposables.add(renderedError); + templateData.value.appendChild(renderedError.domNode); + } + + const newHeight = templateData.rowContainer.offsetHeight; + const fireEvent = !element.currentRenderedHeight || element.currentRenderedHeight !== newHeight; + element.currentRenderedHeight = newHeight; + if (fireEvent) { + const disposable = templateData.elementDisposables.add(dom.scheduleAtNextAnimationFrame(dom.getWindow(templateData.value), () => { + // Have to recompute the height here because codeblock rendering is currently async and it may have changed. + // If it becomes properly sync, then this could be removed. + element.currentRenderedHeight = templateData.rowContainer.offsetHeight; + disposable.dispose(); + this._onDidChangeItemHeight.fire({ element, height: element.currentRenderedHeight }); + })); + } + } + + private updateItemHeight(templateData: IChatListItemTemplate): void { + if (!templateData.currentElement) { + return; + } + + const newHeight = templateData.rowContainer.offsetHeight; + templateData.currentElement.currentRenderedHeight = newHeight; + this._onDidChangeItemHeight.fire({ element: templateData.currentElement, height: newHeight }); + } + + private renderWelcomeMessage(element: IChatWelcomeMessageViewModel, templateData: IChatListItemTemplate) { + dom.clearNode(templateData.value); + + element.content.forEach((item, i) => { + if (Array.isArray(item)) { + const scopedInstaService = templateData.elementDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, templateData.contextKeyService]))); + templateData.elementDisposables.add( + scopedInstaService.createInstance, ChatFollowups>( + ChatFollowups, + templateData.value, + item, + this.location, + undefined, + followup => this._onDidClickFollowup.fire(followup))); + } else { + const context: IChatContentPartRenderContext = { + element, + index: i, + // NA for welcome msg + content: [], + preceedingContentParts: [] + }; + const result = this.renderMarkdown(item, templateData, context); + templateData.value.appendChild(result.domNode); + templateData.elementDisposables.add(result); + } + }); + + const newHeight = templateData.rowContainer.offsetHeight; + const fireEvent = !element.currentRenderedHeight || element.currentRenderedHeight !== newHeight; + element.currentRenderedHeight = newHeight; + if (fireEvent) { + const disposable = templateData.elementDisposables.add(dom.scheduleAtNextAnimationFrame(dom.getWindow(templateData.value), () => { + // Have to recompute the height here because codeblock rendering is currently async and it may have changed. + // If it becomes properly sync, then this could be removed. + element.currentRenderedHeight = templateData.rowContainer.offsetHeight; + disposable.dispose(); + this._onDidChangeItemHeight.fire({ element, height: element.currentRenderedHeight }); + })); + } + } + + /** + * @returns true if progressive rendering should be considered complete- the element's data is fully rendered or the view is not visible + */ + private doNextProgressiveRender(element: IChatResponseViewModel, index: number, templateData: IChatListItemTemplate, isInRenderElement: boolean): boolean { + if (!this._isVisible) { + return true; + } + + if (element.isCanceled) { + this.traceLayout('doNextProgressiveRender', `canceled, index=${index}`); + element.renderData = undefined; + this.basicRenderElement(element, index, templateData); + return true; + } + + let isFullyRendered = false; + this.traceLayout('doNextProgressiveRender', `START progressive render, index=${index}, renderData=${JSON.stringify(element.renderData)}`); + const contentForThisTurn = this.getNextProgressiveRenderContent(element); + const partsToRender = this.diff(templateData.renderedParts ?? [], contentForThisTurn, element); + isFullyRendered = partsToRender.every(part => part === null); + + if (isFullyRendered) { + if (element.isComplete) { + // Response is done and content is rendered, so do a normal render + this.traceLayout('doNextProgressiveRender', `END progressive render, index=${index} and clearing renderData, response is complete`); + element.renderData = undefined; + this.basicRenderElement(element, index, templateData); + return true; + } + + // Nothing new to render, not done, keep waiting + this.traceLayout('doNextProgressiveRender', 'caught up with the stream- no new content to render'); + return false; + } + + // Do an actual progressive render + this.traceLayout('doNextProgressiveRender', `doing progressive render, ${partsToRender.length} parts to render`); + this.renderChatContentDiff(partsToRender, contentForThisTurn, element, templateData); + + const height = templateData.rowContainer.offsetHeight; + element.currentRenderedHeight = height; + if (!isInRenderElement) { + this._onDidChangeItemHeight.fire({ element, height: templateData.rowContainer.offsetHeight }); + } + + return false; + } + + private renderChatContentDiff(partsToRender: ReadonlyArray, contentForThisTurn: ReadonlyArray, element: IChatResponseViewModel, templateData: IChatListItemTemplate): void { + const renderedParts = templateData.renderedParts ?? []; + templateData.renderedParts = renderedParts; + partsToRender.forEach((partToRender, index) => { + if (!partToRender) { + // null=no change + return; + } + + const alreadyRenderedPart = templateData.renderedParts?.[index]; + if (alreadyRenderedPart) { + alreadyRenderedPart.dispose(); + } + + const preceedingContentParts = renderedParts.slice(0, index); + const context: IChatContentPartRenderContext = { + element, + content: contentForThisTurn, + preceedingContentParts, + index + }; + const newPart = this.renderChatContentPart(partToRender, templateData, context); + if (newPart) { + // Maybe the part can't be rendered in this context, but this shouldn't really happen + if (alreadyRenderedPart) { + try { + // This method can throw HierarchyRequestError + alreadyRenderedPart.domNode.replaceWith(newPart.domNode); + } catch (err) { + this.logService.error('ChatListItemRenderer#renderChatContentDiff: error replacing part', err); + } + } else { + templateData.value.appendChild(newPart.domNode); + } + + renderedParts[index] = newPart; + } else if (alreadyRenderedPart) { + alreadyRenderedPart.domNode.remove(); + } + }); + } + + /** + * Returns all content parts that should be rendered, and trimmed markdown content. We will diff this with the current rendered set. + */ + private getNextProgressiveRenderContent(element: IChatResponseViewModel): IChatRendererContent[] { + const data = this.getDataForProgressiveRender(element); + + const renderableResponse = annotateSpecialMarkdownContent(element.response.value); + + this.traceLayout('getNextProgressiveRenderContent', `Want to render ${data.numWordsToRender} at ${data.rate} words/s, counting...`); + let numNeededWords = data.numWordsToRender; + const partsToRender: IChatRendererContent[] = []; + if (element.contentReferences.length) { + partsToRender.push({ kind: 'references', references: element.contentReferences }); + } + + for (let i = 0; i < renderableResponse.length; i++) { + const part = renderableResponse[i]; + if (numNeededWords <= 0) { + break; + } + + if (part.kind === 'markdownContent') { + const wordCountResult = getNWords(part.content.value, numNeededWords); + if (wordCountResult.isFullString) { + partsToRender.push(part); + } else { + partsToRender.push({ kind: 'markdownContent', content: new MarkdownString(wordCountResult.value, part.content) }); + } + + this.traceLayout('getNextProgressiveRenderContent', ` Chunk ${i}: Want to render ${numNeededWords} words and found ${wordCountResult.returnedWordCount} words. Total words in chunk: ${wordCountResult.totalWordCount}`); + numNeededWords -= wordCountResult.returnedWordCount; + } else { + partsToRender.push(part); + } + } + + const lastWordCount = element.contentUpdateTimings?.lastWordCount ?? 0; + const newRenderedWordCount = data.numWordsToRender - numNeededWords; + const bufferWords = lastWordCount - newRenderedWordCount; + this.traceLayout('getNextProgressiveRenderContent', `Want to render ${data.numWordsToRender} words. Rendering ${newRenderedWordCount} words. Buffer: ${bufferWords} words`); + if (newRenderedWordCount > 0 && newRenderedWordCount !== element.renderData?.renderedWordCount) { + // Only update lastRenderTime when we actually render new content + element.renderData = { lastRenderTime: Date.now(), renderedWordCount: newRenderedWordCount, renderedParts: partsToRender }; + } + + return partsToRender; + } + + private getDataForProgressiveRender(element: IChatResponseViewModel) { + const renderData = element.renderData ?? { lastRenderTime: 0, renderedWordCount: 0 }; + + const rate = this.getProgressiveRenderRate(element); + const numWordsToRender = renderData.lastRenderTime === 0 ? + 1 : + renderData.renderedWordCount + + // Additional words to render beyond what's already rendered + Math.floor((Date.now() - renderData.lastRenderTime) / 1000 * rate); + + return { + numWordsToRender, + rate + }; + } + + private diff(renderedParts: ReadonlyArray, contentToRender: ReadonlyArray, element: ChatTreeItem): ReadonlyArray { + const diff: (IChatRendererContent | null)[] = []; + for (let i = 0; i < contentToRender.length; i++) { + const content = contentToRender[i]; + const renderedPart = renderedParts[i]; + + if (!renderedPart || !renderedPart.hasSameContent(content, contentToRender.slice(i + 1), element)) { + diff.push(content); + } else { + // null -> no change + diff.push(null); + } + } + + return diff; + } + + private renderChatContentPart(content: IChatRendererContent, templateData: IChatListItemTemplate, context: IChatContentPartRenderContext): IChatContentPart | undefined { + if (content.kind === 'treeData') { + return this.renderTreeData(content, templateData, context); + } else if (content.kind === 'progressMessage') { + return this.instantiationService.createInstance(ChatProgressContentPart, content, this.renderer, context); + } else if (content.kind === 'progressTask') { + return this.renderProgressTask(content, templateData, context); + } else if (content.kind === 'command') { + return this.instantiationService.createInstance(ChatCommandButtonContentPart, content, context); + } else if (content.kind === 'textEditGroup') { + return this.renderTextEdit(context, content, templateData); + } else if (content.kind === 'confirmation') { + return this.renderConfirmation(context, content, templateData); + } else if (content.kind === 'warning') { + return this.instantiationService.createInstance(ChatWarningContentPart, 'warning', content.content, this.renderer); + } else if (content.kind === 'markdownContent') { + return this.renderMarkdown(content.content, templateData, context); + } else if (content.kind === 'references') { + return this.renderContentReferencesListData(content, undefined, context, templateData); + } else if (content.kind === 'codeCitations') { + return this.renderCodeCitationsListData(content, context, templateData); + } + + return undefined; + } + + private renderTreeData(content: IChatTreeData, templateData: IChatListItemTemplate, context: IChatContentPartRenderContext): IChatContentPart { + const data = content.treeData; + const treeDataIndex = context.preceedingContentParts.filter(part => part instanceof ChatTreeContentPart).length; + const treePart = this.instantiationService.createInstance(ChatTreeContentPart, data, context.element, this._treePool, treeDataIndex); + + treePart.addDisposable(treePart.onDidChangeHeight(() => { + this.updateItemHeight(templateData); + })); + + if (isResponseVM(context.element)) { + const fileTreeFocusInfo = { + treeDataId: data.uri.toString(), + treeIndex: treeDataIndex, + focus() { + treePart.domFocus(); + } + }; + + // TODO@roblourens there's got to be a better way to navigate trees + treePart.addDisposable(treePart.onDidFocus(() => { + this.focusedFileTreesByResponseId.set(context.element.id, fileTreeFocusInfo.treeIndex); + })); + + const fileTrees = this.fileTreesByResponseId.get(context.element.id) ?? []; + fileTrees.push(fileTreeFocusInfo); + this.fileTreesByResponseId.set(context.element.id, distinct(fileTrees, (v) => v.treeDataId)); + treePart.addDisposable(toDisposable(() => this.fileTreesByResponseId.set(context.element.id, fileTrees.filter(v => v.treeDataId !== data.uri.toString())))); + } + + return treePart; + } + + private renderContentReferencesListData(references: IChatReferences, labelOverride: string | undefined, context: IChatContentPartRenderContext, templateData: IChatListItemTemplate): ChatCollapsibleListContentPart { + const referencesPart = this.instantiationService.createInstance(ChatCollapsibleListContentPart, references.references, labelOverride, context.element as IChatResponseViewModel, this._contentReferencesListPool); + referencesPart.addDisposable(referencesPart.onDidChangeHeight(() => { + this.updateItemHeight(templateData); + })); + + return referencesPart; + } + + private renderCodeCitationsListData(citations: IChatCodeCitations, context: IChatContentPartRenderContext, templateData: IChatListItemTemplate): ChatCodeCitationContentPart { + const citationsPart = this.instantiationService.createInstance(ChatCodeCitationContentPart, citations, context); + return citationsPart; + } + + private renderProgressTask(task: IChatTask, templateData: IChatListItemTemplate, context: IChatContentPartRenderContext): IChatContentPart | undefined { + if (!isResponseVM(context.element)) { + return; + } + + const taskPart = this.instantiationService.createInstance(ChatTaskContentPart, task, this._contentReferencesListPool, this.renderer, context); + taskPart.addDisposable(taskPart.onDidChangeHeight(() => { + this.updateItemHeight(templateData); + })); + return taskPart; + } + + private renderConfirmation(context: IChatContentPartRenderContext, confirmation: IChatConfirmation, templateData: IChatListItemTemplate): IChatContentPart { + const part = this.instantiationService.createInstance(ChatConfirmationContentPart, confirmation, context); + part.addDisposable(part.onDidChangeHeight(() => this.updateItemHeight(templateData))); + return part; + } + + private renderAttachments(variables: IChatRequestVariableEntry[], contentReferences: ReadonlyArray | undefined, templateData: IChatListItemTemplate) { + return this.instantiationService.createInstance(ChatAttachmentsContentPart, variables, contentReferences, undefined); + } + + private renderTextEdit(context: IChatContentPartRenderContext, chatTextEdit: IChatTextEditGroup, templateData: IChatListItemTemplate): IChatContentPart { + const textEditPart = this.instantiationService.createInstance(ChatTextEditContentPart, chatTextEdit, context, this.rendererOptions, this._diffEditorPool, this._currentLayoutWidth); + textEditPart.addDisposable(textEditPart.onDidChangeHeight(() => { + textEditPart.layout(this._currentLayoutWidth); + this.updateItemHeight(templateData); + })); + + return textEditPart; + } + + private renderMarkdown(markdown: IMarkdownString, templateData: IChatListItemTemplate, context: IChatContentPartRenderContext): IChatContentPart { + const element = context.element; + const fillInIncompleteTokens = isResponseVM(element) && (!element.isComplete || element.isCanceled || element.errorDetails?.responseIsFiltered || element.errorDetails?.responseIsIncomplete || !!element.renderData); + const codeBlockStartIndex = context.preceedingContentParts.reduce((acc, part) => acc + (part instanceof ChatMarkdownContentPart ? part.codeblocks.length : 0), 0); + const markdownPart = this.instantiationService.createInstance(ChatMarkdownContentPart, markdown, context, this._editorPool, fillInIncompleteTokens, codeBlockStartIndex, this.renderer, this._currentLayoutWidth, this.codeBlockModelCollection, this.rendererOptions); + const markdownPartId = markdownPart.id; + markdownPart.addDisposable(markdownPart.onDidChangeHeight(() => { + markdownPart.layout(this._currentLayoutWidth); + this.updateItemHeight(templateData); + })); + + const codeBlocksByResponseId = this.codeBlocksByResponseId.get(element.id) ?? []; + this.codeBlocksByResponseId.set(element.id, codeBlocksByResponseId); + markdownPart.addDisposable(toDisposable(() => { + const codeBlocksByResponseId = this.codeBlocksByResponseId.get(element.id); + if (codeBlocksByResponseId) { + // Only delete if this is my code block + markdownPart.codeblocks.forEach((info, i) => { + const codeblock = codeBlocksByResponseId[codeBlockStartIndex + i]; + if (codeblock?.ownerMarkdownPartId === markdownPartId) { + delete codeBlocksByResponseId[codeBlockStartIndex + i]; + } + }); + } + })); + + markdownPart.codeblocks.forEach((info, i) => { + codeBlocksByResponseId[codeBlockStartIndex + i] = info; + if (info.uri) { + const uri = info.uri; + this.codeBlocksByEditorUri.set(uri, info); + markdownPart.addDisposable(toDisposable(() => { + const codeblock = this.codeBlocksByEditorUri.get(uri); + if (codeblock?.ownerMarkdownPartId === markdownPartId) { + this.codeBlocksByEditorUri.delete(uri); + } + })); + } + }); + + return markdownPart; + } + + disposeElement(node: ITreeNode, index: number, templateData: IChatListItemTemplate): void { + this.traceLayout('disposeElement', `Disposing element, index=${index}`); + + // We could actually reuse a template across a renderElement call? + if (templateData.renderedParts) { + try { + dispose(coalesce(templateData.renderedParts)); + templateData.renderedParts = undefined; + dom.clearNode(templateData.value); + } catch (err) { + throw err; + } + } + + templateData.currentElement = undefined; + templateData.elementDisposables.clear(); + } + + disposeTemplate(templateData: IChatListItemTemplate): void { + templateData.templateDisposables.dispose(); + } +} + +export class ChatListDelegate implements IListVirtualDelegate { + constructor( + private readonly defaultElementHeight: number, + @ILogService private readonly logService: ILogService + ) { } + + private _traceLayout(method: string, message: string) { + if (forceVerboseLayoutTracing) { + this.logService.info(`ChatListDelegate#${method}: ${message}`); + } else { + this.logService.trace(`ChatListDelegate#${method}: ${message}`); + } + } + + getHeight(element: ChatTreeItem): number { + const kind = isRequestVM(element) ? 'request' : 'response'; + const height = ('currentRenderedHeight' in element ? element.currentRenderedHeight : undefined) ?? this.defaultElementHeight; + this._traceLayout('getHeight', `${kind}, height=${height}`); + return height; + } + + getTemplateId(element: ChatTreeItem): string { + return ChatListItemRenderer.ID; + } + + hasDynamicHeight(element: ChatTreeItem): boolean { + 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/aideAgentMarkdownDecorationsRenderer.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentMarkdownDecorationsRenderer.ts new file mode 100644 index 00000000000..363185f4b53 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentMarkdownDecorationsRenderer.ts @@ -0,0 +1,266 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { toErrorMessage } from '../../../../base/common/errorMessage.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { revive } from '../../../../base/common/marshalling.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js'; +import { ContentRefData, contentRefUrl } from '../common/annotations.js'; +import { getFullyQualifiedId, IChatAgentCommand, IChatAgentData, IAideAgentAgentNameService, IAideAgentAgentService } from '../common/aideAgentAgents.js'; +import { chatSlashCommandBackground, chatSlashCommandForeground } from '../common/aideAgentColors.js'; +import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestTextPart, ChatRequestToolPart, ChatRequestVariablePart, chatSubcommandLeader, IParsedChatRequest, IParsedChatRequestPart } from '../common/aideAgentParserTypes.js'; +import { IAideAgentService } from '../common/aideAgentService.js'; +import { IAideAgentVariablesService } from '../common/aideAgentVariables.js'; +import { IAideAgentLMToolsService } from '../common/languageModelToolsService.js'; +import { IAideAgentWidgetService } from './aideAgent.js'; +import { InlineAnchorWidget } from './aideAgentInlineAnchorWidget.js'; +import './media/aideAgentInlineAnchorWidget.css'; + +/** For rendering slash commands, variables */ +const decorationRefUrl = `http://_vscodedecoration_`; + +/** For rendering agent decorations with hover */ +const agentRefUrl = `http://_chatagent_`; + +/** For rendering agent decorations with hover */ +const agentSlashRefUrl = `http://_chatslash_`; + +export function agentToMarkdown(agent: IChatAgentData, isClickable: boolean, accessor: ServicesAccessor): string { + const chatAgentNameService = accessor.get(IAideAgentAgentNameService); + const chatAgentService = accessor.get(IAideAgentAgentService); + + const isAllowed = chatAgentNameService.getAgentNameRestriction(agent); + let name = `${isAllowed ? agent.name : getFullyQualifiedId(agent)}`; + const isDupe = isAllowed && chatAgentService.agentHasDupeName(agent.id); + if (isDupe) { + name += ` (${agent.publisherDisplayName})`; + } + + const args: IAgentWidgetArgs = { agentId: agent.id, name, isClickable }; + return `[${agent.name}](${agentRefUrl}?${encodeURIComponent(JSON.stringify(args))})`; +} + +interface IAgentWidgetArgs { + agentId: string; + name: string; + isClickable?: boolean; +} + +export function agentSlashCommandToMarkdown(agent: IChatAgentData, command: IChatAgentCommand): string { + const text = `${chatSubcommandLeader}${command.name}`; + const args: ISlashCommandWidgetArgs = { agentId: agent.id, command: command.name }; + return `[${text}](${agentSlashRefUrl}?${encodeURIComponent(JSON.stringify(args))})`; +} + +interface ISlashCommandWidgetArgs { + agentId: string; + command: string; +} + +interface IDecorationWidgetArgs { + title?: string; +} + +export class ChatMarkdownDecorationsRenderer extends Disposable { + + constructor( + @IKeybindingService private readonly keybindingService: IKeybindingService, + @ILogService private readonly logService: ILogService, + @IAideAgentAgentService private readonly chatAgentService: IAideAgentAgentService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IHoverService private readonly hoverService: IHoverService, + @IAideAgentService private readonly chatService: IAideAgentService, + @IAideAgentWidgetService private readonly chatWidgetService: IAideAgentWidgetService, + @IAideAgentVariablesService private readonly chatVariablesService: IAideAgentVariablesService, + @ILabelService private readonly labelService: ILabelService, + @IAideAgentLMToolsService private readonly toolsService: IAideAgentLMToolsService, + ) { + super(); + } + + convertParsedRequestToMarkdown(parsedRequest: IParsedChatRequest): string { + let result = ''; + for (const part of parsedRequest.parts) { + if (part instanceof ChatRequestTextPart) { + result += part.text; + } else if (part instanceof ChatRequestAgentPart) { + result += this.instantiationService.invokeFunction(accessor => agentToMarkdown(part.agent, false, accessor)); + } else { + result += this.genericDecorationToMarkdown(part); + } + } + + return result; + } + + private genericDecorationToMarkdown(part: IParsedChatRequestPart): string { + const uri = part instanceof ChatRequestDynamicVariablePart && part.data instanceof URI ? + part.data : + undefined; + const title = uri ? this.labelService.getUriLabel(uri, { relative: true }) : + part instanceof ChatRequestSlashCommandPart ? part.slashCommand.detail : + part instanceof ChatRequestAgentSubcommandPart ? part.command.description : + part instanceof ChatRequestVariablePart ? (this.chatVariablesService.getVariable(part.variableName)?.description) : + part instanceof ChatRequestToolPart ? (this.toolsService.getTool(part.toolId)?.userDescription) : + ''; + + const args: IDecorationWidgetArgs = { title }; + const text = part.text; + return `[${text}](${decorationRefUrl}?${encodeURIComponent(JSON.stringify(args))})`; + } + + walkTreeAndAnnotateReferenceLinks(element: HTMLElement): IDisposable { + const store = new DisposableStore(); + element.querySelectorAll('a').forEach(a => { + const href = a.getAttribute('data-href'); + if (href) { + if (href.startsWith(agentRefUrl)) { + let args: IAgentWidgetArgs | undefined; + try { + args = JSON.parse(decodeURIComponent(href.slice(agentRefUrl.length + 1))); + } catch (e) { + this.logService.error('Invalid chat widget render data JSON', toErrorMessage(e)); + } + + if (args) { + a.parentElement!.replaceChild( + this.renderAgentWidget(args, store), + a); + } + } else if (href.startsWith(agentSlashRefUrl)) { + let args: ISlashCommandWidgetArgs | undefined; + try { + args = JSON.parse(decodeURIComponent(href.slice(agentRefUrl.length + 1))); + } catch (e) { + this.logService.error('Invalid chat slash command render data JSON', toErrorMessage(e)); + } + + if (args) { + a.parentElement!.replaceChild( + this.renderSlashCommandWidget(a.textContent!, args, store), + a); + } + } else if (href.startsWith(decorationRefUrl)) { + let args: IDecorationWidgetArgs | undefined; + try { + args = JSON.parse(decodeURIComponent(href.slice(decorationRefUrl.length + 1))); + } catch (e) { } + + a.parentElement!.replaceChild( + this.renderResourceWidget(a.textContent!, args, store), + a); + } else if (href.startsWith(contentRefUrl)) { + this.renderFileWidget(href, a, store); + } else if (href.startsWith('command:')) { + this.injectKeybindingHint(a, href, this.keybindingService); + } + } + }); + + return store; + } + + private renderAgentWidget(args: IAgentWidgetArgs, store: DisposableStore): HTMLElement { + const nameWithLeader = `${chatAgentLeader}${args.name}`; + let container: HTMLElement; + if (args.isClickable) { + container = dom.$('span.chat-agent-widget'); + const button = store.add(new Button(container, { + buttonBackground: asCssVariable(chatSlashCommandBackground), + buttonForeground: asCssVariable(chatSlashCommandForeground), + buttonHoverBackground: undefined + })); + button.label = nameWithLeader; + store.add(button.onDidClick(() => { + const agent = this.chatAgentService.getAgent(args.agentId); + const widget = this.chatWidgetService.lastFocusedWidget; + if (!widget || !agent) { + return; + } + + this.chatService.sendRequest(widget.viewModel!.sessionId, agent.metadata.sampleRequest ?? '', { location: widget.location, agentId: agent.id }); + })); + } else { + container = this.renderResourceWidget(nameWithLeader, undefined, store); + } + + return container; + } + + private renderSlashCommandWidget(name: string, args: ISlashCommandWidgetArgs, store: DisposableStore): HTMLElement { + const container = dom.$('span.chat-agent-widget.chat-command-widget'); + const agent = this.chatAgentService.getAgent(args.agentId); + const button = store.add(new Button(container, { + buttonBackground: asCssVariable(chatSlashCommandBackground), + buttonForeground: asCssVariable(chatSlashCommandForeground), + buttonHoverBackground: undefined + })); + button.label = name; + store.add(button.onDidClick(() => { + const widget = this.chatWidgetService.lastFocusedWidget; + if (!widget || !agent) { + return; + } + + const command = agent.slashCommands.find(c => c.name === args.command); + this.chatService.sendRequest(widget.viewModel!.sessionId, command?.sampleRequest ?? '', { location: widget.location, agentId: agent.id, slashCommand: args.command }); + })); + + return container; + } + + private renderFileWidget(href: string, a: HTMLAnchorElement, store: DisposableStore): void { + // TODO this can be a nicer FileLabel widget with an icon. Do a simple link for now. + const fullUri = URI.parse(href); + let data: ContentRefData; + try { + data = revive(JSON.parse(fullUri.fragment)); + } catch (err) { + this.logService.error('Invalid chat widget render data JSON', toErrorMessage(err)); + return; + } + + if (data.kind !== 'symbol' && !URI.isUri(data.uri)) { + this.logService.error(`Invalid chat widget render data: ${fullUri.fragment}`); + return; + } + + store.add(this.instantiationService.createInstance(InlineAnchorWidget, a, data)); + } + + private renderResourceWidget(name: string, args: IDecorationWidgetArgs | undefined, store: DisposableStore): HTMLElement { + const container = dom.$('span.chat-resource-widget'); + const alias = dom.$('span', undefined, name); + if (args?.title) { + store.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), container, args.title)); + } + + container.appendChild(alias); + return container; + } + + + private injectKeybindingHint(a: HTMLAnchorElement, href: string, keybindingService: IKeybindingService): void { + const command = href.match(/command:([^\)]+)/)?.[1]; + if (command) { + const kb = keybindingService.lookupKeybinding(command); + if (kb) { + const keybinding = kb.getLabel(); + if (keybinding) { + a.textContent = `${a.textContent} (${keybinding})`; + } + } + } + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentMarkdownRenderer.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentMarkdownRenderer.ts new file mode 100644 index 00000000000..6e78f992eeb --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentMarkdownRenderer.ts @@ -0,0 +1,127 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { MarkdownRenderOptions, MarkedOptions } from '../../../../base/browser/markdownRenderer.js'; +import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { IMarkdownString } from '../../../../base/common/htmlContent.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IMarkdownRendererOptions, IMarkdownRenderResult, MarkdownRenderer } from '../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; +import { ILanguageService } from '../../../../editor/common/languages/language.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { REVEAL_IN_EXPLORER_COMMAND_ID } from '../../files/browser/fileConstants.js'; +import { ITrustedDomainService } from '../../url/browser/trustedDomainService.js'; + +const allowedHtmlTags = [ + 'b', + 'blockquote', + 'br', + 'code', + 'em', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'hr', + 'i', + 'li', + 'ol', + 'p', + 'pre', + 'strong', + 'sub', + 'sup', + 'table', + 'tbody', + 'td', + 'th', + 'thead', + 'tr', + 'ul', + 'a', + 'img', + + // TODO@roblourens when we sanitize attributes in markdown source, we can ban these elements at that step. microsoft/vscode-copilot#5091 + // Not in the official list, but used for codicons and other vscode markdown extensions + 'span', + 'div', +]; + +/** + * This wraps the MarkdownRenderer and applies sanitizer options needed for Chat. + */ +export class ChatMarkdownRenderer extends MarkdownRenderer { + constructor( + options: IMarkdownRendererOptions | undefined, + @ILanguageService languageService: ILanguageService, + @IOpenerService openerService: IOpenerService, + @ITrustedDomainService private readonly trustedDomainService: ITrustedDomainService, + @IHoverService private readonly hoverService: IHoverService, + @IFileService private readonly fileService: IFileService, + @ICommandService private readonly commandService: ICommandService, + ) { + super(options ?? {}, languageService, openerService); + } + + override render(markdown: IMarkdownString | undefined, options?: MarkdownRenderOptions, markedOptions?: MarkedOptions): IMarkdownRenderResult { + options = { + ...options, + remoteImageIsAllowed: (uri) => this.trustedDomainService.isValid(uri), + sanitizerOptions: { + replaceWithPlaintext: true, + allowedTags: allowedHtmlTags, + } + }; + + const mdWithBody: IMarkdownString | undefined = (markdown && markdown.supportHtml) ? + { + ...markdown, + + // dompurify uses DOMParser, which strips leading comments. Wrapping it all in 'body' prevents this. + // The \n\n prevents marked.js from parsing the body contents as just text in an 'html' token, instead of actual markdown. + value: `\n\n${markdown.value}`, + } + : markdown; + const result = super.render(mdWithBody, options, markedOptions); + return this.attachCustomHover(result); + } + + private attachCustomHover(result: IMarkdownRenderResult): IMarkdownRenderResult { + const store = new DisposableStore(); + result.element.querySelectorAll('a').forEach((element) => { + if (element.title) { + const title = element.title; + element.title = ''; + store.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), element, title)); + } + }); + + return { + element: result.element, + dispose: () => { + result.dispose(); + store.dispose(); + } + }; + } + + protected override async openMarkdownLink(link: string, markdown: IMarkdownString) { + try { + const uri = URI.parse(link); + if ((await this.fileService.stat(uri)).isDirectory) { + return this.commandService.executeCommand(REVEAL_IN_EXPLORER_COMMAND_ID, uri); + } + } catch { + // noop + } + + return super.openMarkdownLink(link, markdown); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentOptions.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentOptions.ts new file mode 100644 index 00000000000..054a323f3fe --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentOptions.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Color } from '../../../../base/common/color.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IBracketPairColorizationOptions, IEditorOptions } from '../../../../editor/common/config/editorOptions.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IViewDescriptorService } from '../../../common/views.js'; + +export interface IChatConfiguration { + editor: { + readonly fontSize: number; + readonly fontFamily: string; + readonly lineHeight: number; + readonly fontWeight: string; + readonly wordWrap: 'off' | 'on'; + }; +} + +export interface IChatEditorConfiguration { + readonly foreground: Color | undefined; + readonly inputEditor: IChatInputEditorOptions; + readonly resultEditor: IChatResultEditorOptions; +} + +export interface IChatInputEditorOptions { + readonly backgroundColor: Color | undefined; + readonly accessibilitySupport: string; +} + +export interface IChatResultEditorOptions { + readonly fontSize: number; + readonly fontFamily: string | undefined; + readonly lineHeight: number; + readonly fontWeight: string; + readonly backgroundColor: Color | undefined; + readonly bracketPairColorization: IBracketPairColorizationOptions; + readonly fontLigatures: boolean | string | undefined; + readonly wordWrap: 'off' | 'on'; + + // Bring these back if we make the editors editable + // readonly cursorBlinking: string; + // readonly accessibilitySupport: string; +} + + +export class ChatEditorOptions extends Disposable { + private static readonly lineHeightEm = 1.4; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + private _config!: IChatEditorConfiguration; + public get configuration(): IChatEditorConfiguration { + return this._config; + } + + private static readonly relevantSettingIds = [ + 'chat.editor.lineHeight', + 'chat.editor.fontSize', + 'chat.editor.fontFamily', + 'chat.editor.fontWeight', + 'chat.editor.wordWrap', + 'editor.cursorBlinking', + 'editor.fontLigatures', + 'editor.accessibilitySupport', + 'editor.bracketPairColorization.enabled', + 'editor.bracketPairColorization.independentColorPoolPerBracketType', + ]; + + constructor( + viewId: string | undefined, + private readonly foreground: string, + private readonly inputEditorBackgroundColor: string, + private readonly resultEditorBackgroundColor: string, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IThemeService private readonly themeService: IThemeService, + @IViewDescriptorService private readonly viewDescriptorService: IViewDescriptorService + ) { + super(); + + this._register(this.themeService.onDidColorThemeChange(e => this.update())); + this._register(this.viewDescriptorService.onDidChangeLocation(e => { + if (e.views.some(v => v.id === viewId)) { + this.update(); + } + })); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (ChatEditorOptions.relevantSettingIds.some(id => e.affectsConfiguration(id))) { + this.update(); + } + })); + this.update(); + } + + private update() { + const editorConfig = this.configurationService.getValue('editor'); + + // TODO shouldn't the setting keys be more specific? + const chatEditorConfig = this.configurationService.getValue('chat')?.editor; + const accessibilitySupport = this.configurationService.getValue<'auto' | 'off' | 'on'>('editor.accessibilitySupport'); + this._config = { + foreground: this.themeService.getColorTheme().getColor(this.foreground), + inputEditor: { + backgroundColor: this.themeService.getColorTheme().getColor(this.inputEditorBackgroundColor), + accessibilitySupport, + }, + resultEditor: { + backgroundColor: this.themeService.getColorTheme().getColor(this.resultEditorBackgroundColor), + fontSize: chatEditorConfig.fontSize, + fontFamily: chatEditorConfig.fontFamily === 'default' ? editorConfig.fontFamily : chatEditorConfig.fontFamily, + fontWeight: chatEditorConfig.fontWeight, + lineHeight: chatEditorConfig.lineHeight ? chatEditorConfig.lineHeight : ChatEditorOptions.lineHeightEm * chatEditorConfig.fontSize, + bracketPairColorization: { + enabled: this.configurationService.getValue('editor.bracketPairColorization.enabled'), + independentColorPoolPerBracketType: this.configurationService.getValue('editor.bracketPairColorization.independentColorPoolPerBracketType'), + }, + wordWrap: chatEditorConfig.wordWrap, + fontLigatures: editorConfig.fontLigatures, + } + + }; + this._onDidChange.fire(); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentParticipantContributions.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentParticipantContributions.ts new file mode 100644 index 00000000000..bba2595ba5f --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentParticipantContributions.ts @@ -0,0 +1,355 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { coalesce, isNonEmptyArray } from '../../../../base/common/arrays.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import * as strings from '../../../../base/common/strings.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { Severity } from '../../../../platform/notification/common/notification.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { IViewContainersRegistry, IViewDescriptor, IViewsRegistry, ViewContainer, ViewContainerLocation, Extensions as ViewExtensions } from '../../../common/views.js'; +import { isProposedApiEnabled } from '../../../services/extensions/common/extensions.js'; +import * as extensionsRegistry from '../../../services/extensions/common/extensionsRegistry.js'; +import { showExtensionsWithIdsCommandId } from '../../extensions/browser/extensionsActions.js'; +import { IExtensionsWorkbenchService } from '../../extensions/common/extensions.js'; +import { ChatAgentLocation, IChatAgentData, IAideAgentAgentService } from '../common/aideAgentAgents.js'; +import { CONTEXT_CHAT_EXTENSION_INVALID, CONTEXT_CHAT_PANEL_PARTICIPANT_REGISTERED } from '../common/aideAgentContextKeys.js'; +import { IRawChatParticipantContribution } from '../common/aideAgentParticipantContribTypes.js'; +import { CHAT_VIEW_ID } from './aideAgent.js'; +import { CHAT_SIDEBAR_PANEL_ID, ChatViewPane } from './aideAgentViewPane.js'; + +const chatParticipantExtensionPoint = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint({ + extensionPoint: 'aideAgents', + jsonSchema: { + description: localize('vscode.extension.contributes.chatParticipant', 'Contributes a chat participant'), + type: 'array', + items: { + additionalProperties: false, + type: 'object', + defaultSnippets: [{ body: { name: '', description: '' } }], + required: ['name', 'id'], + properties: { + id: { + description: localize('chatParticipantId', "A unique id for this chat participant."), + type: 'string' + }, + name: { + description: localize('chatParticipantName', "User-facing name for this chat participant. The user will use '#' with this name to invoke the participant. Name must not contain whitespace."), + type: 'string', + pattern: '^[\\w-]+$' + }, + fullName: { + markdownDescription: localize('chatParticipantFullName', "The full name of this chat participant, which is shown as the label for responses coming from this participant. If not provided, {0} is used.", '`name`'), + type: 'string' + }, + description: { + description: localize('chatParticipantDescription', "A description of this chat participant, shown in the UI."), + type: 'string' + }, + isSticky: { + description: localize('chatCommandSticky', "Whether invoking the command puts the chat into a persistent mode, where the command is automatically added to the chat input for the next message."), + type: 'boolean' + }, + sampleRequest: { + description: localize('chatSampleRequest', "When the user clicks this participant in `/help`, this text will be submitted to the participant."), + type: 'string' + }, + when: { + description: localize('chatParticipantWhen', "A condition which must be true to enable this participant."), + type: 'string' + }, + disambiguation: { + description: localize('chatParticipantDisambiguation', "Metadata to help with automatically routing user questions to this chat participant."), + type: 'array', + items: { + additionalProperties: false, + type: 'object', + defaultSnippets: [{ body: { category: '', description: '', examples: [] } }], + required: ['category', 'description', 'examples'], + properties: { + category: { + markdownDescription: localize('chatParticipantDisambiguationCategory', "A detailed name for this category, e.g. `workspace_questions` or `web_questions`."), + type: 'string' + }, + description: { + description: localize('chatParticipantDisambiguationDescription', "A detailed description of the kinds of questions that are suitable for this chat participant."), + type: 'string' + }, + examples: { + description: localize('chatParticipantDisambiguationExamples', "A list of representative example questions that are suitable for this chat participant."), + type: 'array' + }, + } + } + }, + commands: { + markdownDescription: localize('chatCommandsDescription', "Commands available for this chat participant, which the user can invoke with a `/`."), + type: 'array', + items: { + additionalProperties: false, + type: 'object', + defaultSnippets: [{ body: { name: '', description: '' } }], + required: ['name'], + properties: { + name: { + description: localize('chatCommand', "A short name by which this command is referred to in the UI, e.g. `fix` or * `explain` for commands that fix an issue or explain code. The name should be unique among the commands provided by this participant."), + type: 'string' + }, + description: { + description: localize('chatCommandDescription', "A description of this command."), + type: 'string' + }, + when: { + description: localize('chatCommandWhen', "A condition which must be true to enable this command."), + type: 'string' + }, + sampleRequest: { + description: localize('chatCommandSampleRequest', "When the user clicks this command in `/help`, this text will be submitted to the participant."), + type: 'string' + }, + isSticky: { + description: localize('chatCommandSticky', "Whether invoking the command puts the chat into a persistent mode, where the command is automatically added to the chat input for the next message."), + type: 'boolean' + }, + disambiguation: { + description: localize('chatCommandDisambiguation', "Metadata to help with automatically routing user questions to this chat command."), + type: 'array', + items: { + additionalProperties: false, + type: 'object', + defaultSnippets: [{ body: { category: '', description: '', examples: [] } }], + required: ['category', 'description', 'examples'], + properties: { + category: { + markdownDescription: localize('chatCommandDisambiguationCategory', "A detailed name for this category, e.g. `workspace_questions` or `web_questions`."), + type: 'string' + }, + description: { + description: localize('chatCommandDisambiguationDescription', "A detailed description of the kinds of questions that are suitable for this chat command."), + type: 'string' + }, + examples: { + description: localize('chatCommandDisambiguationExamples', "A list of representative example questions that are suitable for this chat command."), + type: 'array' + }, + } + } + } + } + } + }, + supportsToolReferences: { + description: localize('chatParticipantSupportsToolReferences', "Whether this participant supports {0}.", 'ChatRequest#toolReferences'), + type: 'boolean' + } + } + } + }, + activationEventsGenerator: (contributions: IRawChatParticipantContribution[], result: { push(item: string): void }) => { + for (const contrib of contributions) { + result.push(`onAideAgent:${contrib.id}`); + } + }, +}); + +const viewContainerId = CHAT_SIDEBAR_PANEL_ID; +const viewContainer: ViewContainer = Registry.as(ViewExtensions.ViewContainersRegistry).registerViewContainer({ + id: viewContainerId, + title: localize2('chat.viewContainer.label', "Aide"), + icon: Codicon.commentDiscussion, + ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [viewContainerId, { mergeViewWithContainerWhenSingleView: true }]), + storageId: viewContainerId, + hideIfEmpty: false, + order: 0, +}, ViewContainerLocation.AuxiliaryBar, { isDefault: true }); + +export class ChatExtensionPointHandler implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.aideAgentExtensionPointHandler'; + + private _viewContainer: ViewContainer; + private _participantRegistrationDisposables = new DisposableMap(); + + constructor( + @IAideAgentAgentService private readonly _chatAgentService: IAideAgentAgentService, + @ILogService private readonly logService: ILogService + ) { + this._viewContainer = viewContainer; + this.registerDefaultParticipantView(); + this.handleAndRegisterChatExtensions(); + } + + private handleAndRegisterChatExtensions(): void { + chatParticipantExtensionPoint.setHandler((extensions, delta) => { + for (const extension of delta.added) { + for (const providerDescriptor of extension.value) { + if (!providerDescriptor.name?.match(/^[\w-]+$/)) { + this.logService.error(`Extension '${extension.description.identifier.value}' CANNOT register participant with invalid name: ${providerDescriptor.name}. Name must match /^[\\w-]+$/.`); + continue; + } + + if (providerDescriptor.fullName && strings.AmbiguousCharacters.getInstance(new Set()).containsAmbiguousCharacter(providerDescriptor.fullName)) { + this.logService.error(`Extension '${extension.description.identifier.value}' CANNOT register participant with fullName that contains ambiguous characters: ${providerDescriptor.fullName}.`); + continue; + } + + // Spaces are allowed but considered "invisible" + if (providerDescriptor.fullName && strings.InvisibleCharacters.containsInvisibleCharacter(providerDescriptor.fullName.replace(/ /g, ''))) { + this.logService.error(`Extension '${extension.description.identifier.value}' CANNOT register participant with fullName that contains invisible characters: ${providerDescriptor.fullName}.`); + continue; + } + + if (providerDescriptor.isDefault && !isProposedApiEnabled(extension.description, 'defaultChatParticipant')) { + this.logService.error(`Extension '${extension.description.identifier.value}' CANNOT use API proposal: defaultChatParticipant.`); + continue; + } + + if ((providerDescriptor.defaultImplicitVariables || providerDescriptor.locations || providerDescriptor.supportsModelPicker) && !isProposedApiEnabled(extension.description, 'chatParticipantAdditions')) { + this.logService.error(`Extension '${extension.description.identifier.value}' CANNOT use API proposal: chatParticipantAdditions.`); + continue; + } + + if (!providerDescriptor.id || !providerDescriptor.name) { + this.logService.error(`Extension '${extension.description.identifier.value}' CANNOT register participant without both id and name.`); + continue; + } + + const participantsAndCommandsDisambiguation: { + category: string; + description: string; + examples: string[]; + }[] = []; + + if (isProposedApiEnabled(extension.description, 'contribChatParticipantDetection')) { + if (providerDescriptor.disambiguation?.length) { + participantsAndCommandsDisambiguation.push(...providerDescriptor.disambiguation.map((d) => ({ + ...d, category: d.category ?? d.categoryName + }))); + } + if (providerDescriptor.commands) { + for (const command of providerDescriptor.commands) { + if (command.disambiguation?.length) { + participantsAndCommandsDisambiguation.push(...command.disambiguation.map((d) => ({ + ...d, category: d.category ?? d.categoryName + }))); + } + } + } + } + + const store = new DisposableStore(); + store.add(this._chatAgentService.registerAgent( + providerDescriptor.id, + { + extensionId: extension.description.identifier, + publisherDisplayName: extension.description.publisherDisplayName ?? extension.description.publisher, // May not be present in OSS + extensionPublisherId: extension.description.publisher, + extensionDisplayName: extension.description.displayName ?? extension.description.name, + id: providerDescriptor.id, + description: providerDescriptor.description, + supportsModelPicker: providerDescriptor.supportsModelPicker, + when: providerDescriptor.when, + metadata: { + isSticky: providerDescriptor.isSticky, + sampleRequest: providerDescriptor.sampleRequest, + }, + name: providerDescriptor.name, + fullName: providerDescriptor.fullName, + isDefault: providerDescriptor.isDefault, + locations: isNonEmptyArray(providerDescriptor.locations) ? + providerDescriptor.locations.map(ChatAgentLocation.fromRaw) : + [ChatAgentLocation.Panel], + slashCommands: providerDescriptor.commands ?? [], + disambiguation: coalesce(participantsAndCommandsDisambiguation.flat()), + supportsToolReferences: providerDescriptor.supportsToolReferences, + } satisfies IChatAgentData)); + + this._participantRegistrationDisposables.set( + getParticipantKey(extension.description.identifier, providerDescriptor.id), + store + ); + } + } + + for (const extension of delta.removed) { + for (const providerDescriptor of extension.value) { + this._participantRegistrationDisposables.deleteAndDispose(getParticipantKey(extension.description.identifier, providerDescriptor.id)); + } + } + }); + } + + private registerDefaultParticipantView(): IDisposable { + // Register View. Name must be hardcoded because we want to show it even when the extension fails to load due to an API version incompatibility. + const name = 'Aide'; + const viewDescriptor: IViewDescriptor[] = [{ + id: CHAT_VIEW_ID, + containerIcon: this._viewContainer.icon, + containerTitle: this._viewContainer.title.value, + singleViewPaneContainerTitle: this._viewContainer.title.value, + name: { value: name, original: name }, + canToggleVisibility: false, + canMoveView: false, + ctorDescriptor: new SyncDescriptor(ChatViewPane), + when: ContextKeyExpr.or(CONTEXT_CHAT_PANEL_PARTICIPANT_REGISTERED, CONTEXT_CHAT_EXTENSION_INVALID) + }]; + Registry.as(ViewExtensions.ViewsRegistry).registerViews(viewDescriptor, this._viewContainer); + + return toDisposable(() => { + Registry.as(ViewExtensions.ViewsRegistry).deregisterViews(viewDescriptor, this._viewContainer); + }); + } +} + +function getParticipantKey(extensionId: ExtensionIdentifier, participantName: string): string { + return `${extensionId.value}_${participantName}`; +} + +export class ChatCompatibilityNotifier implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.aideAgentCompatNotifier'; + + constructor( + @IExtensionsWorkbenchService extensionsWorkbenchService: IExtensionsWorkbenchService, + @IContextKeyService contextKeyService: IContextKeyService, + @IAideAgentAgentService chatAgentService: IAideAgentAgentService, + @IProductService productService: IProductService, + ) { + // It may be better to have some generic UI for this, for any extension that is incompatible, + // but this is only enabled for Copilot Chat now and it needs to be obvious. + const isInvalid = CONTEXT_CHAT_EXTENSION_INVALID.bindTo(contextKeyService); + extensionsWorkbenchService.queryLocal().then(exts => { + const chat = exts.find(ext => ext.identifier.id === 'github.copilot-chat'); + if (chat?.local?.validations.some(v => v[0] === Severity.Error)) { + const showExtensionLabel = localize('showExtension', "Show Extension"); + const mainMessage = localize('chatFailErrorMessage', "Chat failed to load because the installed version of the {0} extension is not compatible with this version of {1}. Please ensure that the GitHub Copilot Chat extension is up to date.", 'GitHub Copilot Chat', productService.nameLong); + const commandButton = `[${showExtensionLabel}](command:${showExtensionsWithIdsCommandId}?${encodeURIComponent(JSON.stringify([['GitHub.copilot-chat']]))})`; + const versionMessage = `GitHub Copilot Chat version: ${chat.version}`; + const viewsRegistry = Registry.as(ViewExtensions.ViewsRegistry); + viewsRegistry.registerViewWelcomeContent(CHAT_VIEW_ID, { + content: [mainMessage, commandButton, versionMessage].join('\n\n'), + when: CONTEXT_CHAT_EXTENSION_INVALID, + }); + + // This catches vscode starting up with the invalid extension, but the extension may still get updated by vscode after this. + isInvalid.set(true); + } + }); + + const listener = chatAgentService.onDidChangeAgents(() => { + if (chatAgentService.getDefaultAgent(ChatAgentLocation.Panel)) { + isInvalid.set(false); + listener.dispose(); + } + }); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentResponseAccessibleView.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentResponseAccessibleView.ts new file mode 100644 index 00000000000..1a47097d067 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentResponseAccessibleView.ts @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { renderMarkdownAsPlaintext } from '../../../../base/browser/markdownRenderer.js'; +import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; +import { AccessibleViewProviderId, AccessibleViewType, IAccessibleViewContentProvider } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { IAccessibleViewImplentation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; +import { IAideAgentWidgetService, IChatWidget, ChatTreeItem } from './aideAgent.js'; +import { CONTEXT_IN_CHAT_SESSION } from '../common/aideAgentContextKeys.js'; +import { ChatWelcomeMessageModel } from '../common/aideAgentModel.js'; +import { isResponseVM } from '../common/aideAgentViewModel.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; + +export class ChatResponseAccessibleView implements IAccessibleViewImplentation { + readonly priority = 100; + readonly name = 'panelChat'; + readonly type = AccessibleViewType.View; + readonly when = CONTEXT_IN_CHAT_SESSION; + getProvider(accessor: ServicesAccessor) { + const widgetService = accessor.get(IAideAgentWidgetService); + const widget = widgetService.lastFocusedWidget; + if (!widget) { + return; + } + const chatInputFocused = widget.hasInputFocus(); + if (chatInputFocused) { + widget.focusLastMessage(); + } + + const verifiedWidget: IChatWidget = widget; + const focusedItem = verifiedWidget.getFocus(); + + if (!focusedItem) { + return; + } + + return new ChatResponseAccessibleProvider(verifiedWidget, focusedItem, chatInputFocused); + } +} + +class ChatResponseAccessibleProvider extends Disposable implements IAccessibleViewContentProvider { + private _focusedItem: ChatTreeItem; + constructor( + private readonly _widget: IChatWidget, + item: ChatTreeItem, + private readonly _chatInputFocused: boolean + ) { + super(); + this._focusedItem = item; + } + + readonly id = AccessibleViewProviderId.Chat; + readonly verbositySettingKey = AccessibilityVerbositySettingId.Chat; + readonly options = { type: AccessibleViewType.View }; + + provideContent(): string { + return this._getContent(this._focusedItem); + } + + private _getContent(item: ChatTreeItem): string { + const isWelcome = item instanceof ChatWelcomeMessageModel; + let responseContent = isResponseVM(item) ? item.response.toString() : ''; + if (isWelcome) { + const welcomeReplyContents = []; + for (const content of item.content) { + if (Array.isArray(content)) { + welcomeReplyContents.push(...content.map(m => m.message)); + } else { + welcomeReplyContents.push((content as IMarkdownString).value); + } + } + responseContent = welcomeReplyContents.join('\n'); + } + if (!responseContent && 'errorDetails' in item && item.errorDetails) { + responseContent = item.errorDetails.message; + } + return renderMarkdownAsPlaintext(new MarkdownString(responseContent), true); + } + + onClose(): void { + this._widget.reveal(this._focusedItem); + if (this._chatInputFocused) { + this._widget.focusInput(); + } else { + this._widget.focus(this._focusedItem); + } + } + + provideNextContent(): string | undefined { + const next = this._widget.getSibling(this._focusedItem, 'next'); + if (next) { + this._focusedItem = next; + return this._getContent(next); + } + return; + } + + providePreviousContent(): string | undefined { + const previous = this._widget.getSibling(this._focusedItem, 'previous'); + if (previous) { + this._focusedItem = previous; + return this._getContent(previous); + } + return; + } +} + diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentVariables.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentVariables.ts new file mode 100644 index 00000000000..c2b8c4b16d5 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentVariables.ts @@ -0,0 +1,195 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { basename } from '../../../../base/common/path.js'; +import { coalesce } from '../../../../base/common/arrays.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { onUnexpectedExternalError } from '../../../../base/common/errors.js'; +import { Iterable } from '../../../../base/common/iterator.js'; +import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { Location } from '../../../../editor/common/languages.js'; +import { IAideAgentWidgetService, showChatView } from './aideAgent.js'; +import { ChatDynamicVariableModel } from './contrib/aideAgentDynamicVariables.js'; +import { ChatAgentLocation } from '../common/aideAgentAgents.js'; +import { IChatModel, IChatRequestVariableData, IChatRequestVariableEntry } from '../common/aideAgentModel.js'; +import { ChatRequestDynamicVariablePart, ChatRequestToolPart, ChatRequestVariablePart, IParsedChatRequest } from '../common/aideAgentParserTypes.js'; +import { IChatContentReference } from '../common/aideAgentService.js'; +import { IChatRequestVariableValue, IChatVariableData, IChatVariableResolver, IChatVariableResolverProgress, IAideAgentVariablesService, IDynamicVariable } from '../common/aideAgentVariables.js'; +import { ChatContextAttachments } from './contrib/aideAgentContextAttachments.js'; +import { IViewsService } from '../../../services/views/common/viewsService.js'; +import { IAideAgentLMToolsService } from '../common/languageModelToolsService.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; + +interface IChatData { + data: IChatVariableData; + resolver: IChatVariableResolver; +} + +export class ChatVariablesService implements IAideAgentVariablesService { + declare _serviceBrand: undefined; + + private _resolver = new Map(); + + constructor( + @IAideAgentWidgetService private readonly chatWidgetService: IAideAgentWidgetService, + @IViewsService private readonly viewsService: IViewsService, + @IAideAgentLMToolsService private readonly toolsService: IAideAgentLMToolsService, + ) { + } + + async resolveVariables(prompt: IParsedChatRequest, attachedContextVariables: IChatRequestVariableEntry[] | undefined, model: IChatModel, progress: (part: IChatVariableResolverProgress) => void, token: CancellationToken): Promise { + let resolvedVariables: IChatRequestVariableEntry[] = []; + const jobs: Promise[] = []; + + prompt.parts + .forEach((part, i) => { + if (part instanceof ChatRequestVariablePart) { + const data = this._resolver.get(part.variableName.toLowerCase()); + if (data) { + const references: IChatContentReference[] = []; + const variableProgressCallback = (item: IChatVariableResolverProgress) => { + if (item.kind === 'reference') { + references.push(item); + return; + } + progress(item); + }; + jobs.push(data.resolver(prompt.text, part.variableArg, model, variableProgressCallback, token).then(value => { + if (value) { + resolvedVariables[i] = { id: data.data.id, modelDescription: data.data.modelDescription, name: part.variableName, range: part.range, value, references, fullName: data.data.fullName, icon: data.data.icon }; + } + }).catch(onUnexpectedExternalError)); + } + } else if (part instanceof ChatRequestDynamicVariablePart) { + resolvedVariables[i] = { id: part.id, name: part.referenceText, range: part.range, value: part.data, }; + } 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 }; + } + } + }); + + const resolvedAttachedContext: IChatRequestVariableEntry[] = []; + attachedContextVariables + ?.forEach((attachment, i) => { + const data = this._resolver.get(attachment.name?.toLowerCase()); + if (data) { + const references: IChatContentReference[] = []; + const variableProgressCallback = (item: IChatVariableResolverProgress) => { + if (item.kind === 'reference') { + references.push(item); + return; + } + progress(item); + }; + jobs.push(data.resolver(prompt.text, '', model, variableProgressCallback, token).then(value => { + if (value) { + resolvedAttachedContext[i] = { id: data.data.id, modelDescription: data.data.modelDescription, name: attachment.name, fullName: attachment.fullName, range: attachment.range, value, references, icon: attachment.icon }; + } + }).catch(onUnexpectedExternalError)); + } else if (attachment.isDynamic || attachment.isTool) { + resolvedAttachedContext[i] = { ...attachment }; + } + }); + + await Promise.allSettled(jobs); + + // Make array not sparse + resolvedVariables = coalesce(resolvedVariables); + + // "reverse", high index first so that replacement is simple + resolvedVariables.sort((a, b) => b.range!.start - a.range!.start); + + // resolvedAttachedContext is a sparse array + resolvedVariables.push(...coalesce(resolvedAttachedContext)); + + + return { + variables: resolvedVariables, + }; + } + + async resolveVariable(variableName: string, promptText: string, model: IChatModel, progress: (part: IChatVariableResolverProgress) => void, token: CancellationToken): Promise { + const data = this._resolver.get(variableName.toLowerCase()); + if (!data) { + return undefined; + } + + return (await data.resolver(promptText, undefined, model, progress, token)); + } + + hasVariable(name: string): boolean { + return this._resolver.has(name.toLowerCase()); + } + + getVariable(name: string): IChatVariableData | undefined { + return this._resolver.get(name.toLowerCase())?.data; + } + + getVariables(location: ChatAgentLocation): Iterable> { + const all = Iterable.map(this._resolver.values(), data => data.data); + return Iterable.filter(all, data => { + // TODO@jrieken this is improper and should be know from the variable registeration data + return location !== ChatAgentLocation.Editor || !new Set(['selection', 'editor']).has(data.name); + }); + } + + getDynamicVariables(sessionId: string): ReadonlyArray { + // This is slightly wrong... the parser pulls dynamic references from the input widget, but there is no guarantee that message came from the input here. + // Need to ... + // - Parser takes list of dynamic references (annoying) + // - Or the parser is known to implicitly act on the input widget, and we need to call it before calling the chat service (maybe incompatible with the future, but easy) + const widget = this.chatWidgetService.getWidgetBySessionId(sessionId); + if (!widget || !widget.viewModel || !widget.supportsFileReferences) { + return []; + } + + const model = widget.getContrib(ChatDynamicVariableModel.ID); + if (!model) { + return []; + } + + return model.variables; + } + + registerVariable(data: IChatVariableData, resolver: IChatVariableResolver): IDisposable { + const key = data.name.toLowerCase(); + if (this._resolver.has(key)) { + throw new Error(`A chat variable with the name '${data.name}' already exists.`); + } + this._resolver.set(key, { data, resolver }); + return toDisposable(() => { + this._resolver.delete(key); + }); + } + + async attachContext(name: string, value: string | URI | Location, location: ChatAgentLocation) { + if (location !== ChatAgentLocation.Panel) { + return; + } + + const widget = this.chatWidgetService.lastFocusedWidget ?? await showChatView(this.viewsService); + if (!widget || !widget.viewModel) { + return; + } + + const key = name.toLowerCase(); + if (key === 'file' && typeof value !== 'string') { + const uri = URI.isUri(value) ? value : value.uri; + const range = 'range' in value ? value.range : undefined; + widget.getContrib(ChatContextAttachments.ID)?.setContext(false, { value, id: uri.toString() + (range?.toString() ?? ''), name: basename(uri.path), isFile: true, isDynamic: true }); + return; + } + + const resolved = this._resolver.get(key); + if (!resolved) { + return; + } + + widget.getContrib(ChatContextAttachments.ID)?.setContext(false, { ...resolved.data, value }); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentViewPane.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentViewPane.ts new file mode 100644 index 00000000000..7c72480557d --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentViewPane.ts @@ -0,0 +1,235 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.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 { 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 { 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'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { editorBackground } from '../../../../platform/theme/common/colorRegistry.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IViewPaneOptions, ViewPane } from '../../../browser/parts/views/viewPane.js'; +import { Memento } from '../../../common/memento.js'; +import { SIDE_BAR_FOREGROUND } from '../../../common/theme.js'; +import { IViewDescriptorService } from '../../../common/views.js'; +import { ChatAgentLocation, IAideAgentAgentService } from '../common/aideAgentAgents.js'; +import { ChatModelInitState, IChatModel } from '../common/aideAgentModel.js'; +import { CHAT_PROVIDER_ID } from '../common/aideAgentParticipantContribTypes.js'; +import { IAideAgentService } from '../common/aideAgentService.js'; +import { IChatViewTitleActionContext } from './actions/aideAgentActions.js'; +import { ChatWidget, IChatViewState } from './aideAgentWidget.js'; + +interface IViewPaneState extends IChatViewState { + sessionId?: string; +} + +export const CHAT_SIDEBAR_PANEL_ID = 'workbench.panel.aideAgentSidebar'; +export class ChatViewPane extends ViewPane { + private _widget!: ChatWidget; + get widget(): ChatWidget { return this._widget; } + + private readonly modelDisposables = this._register(new DisposableStore()); + private memento: Memento; + private readonly viewState: IViewPaneState; + private didProviderRegistrationFail = false; + private didUnregisterProvider = false; + + constructor( + options: IViewPaneOptions, + @IKeybindingService keybindingService: IKeybindingService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService openerService: IOpenerService, + @IThemeService themeService: IThemeService, + @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService, + @IStorageService private readonly storageService: IStorageService, + @IAideAgentService private readonly chatService: IAideAgentService, + @IAideAgentAgentService private readonly chatAgentService: IAideAgentAgentService, + @ILogService private readonly logService: ILogService, + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); + + // View state for the ViewPane is currently global per-provider basically, but some other strictly per-model state will require a separate memento. + this.memento = new Memento('aide-agent-session-view-' + CHAT_PROVIDER_ID, this.storageService); + this.viewState = this.memento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE) as IViewPaneState; + this._register(this.chatAgentService.onDidChangeAgents(() => { + if (this.chatAgentService.getDefaultAgent(ChatAgentLocation.Panel)) { + if (!this._widget?.viewModel) { + const sessionId = this.getSessionId(); + const model = sessionId ? this.chatService.getOrRestoreSession(sessionId) : undefined; + + // The widget may be hidden at this point, because welcome views were allowed. Use setVisible to + // avoid doing a render while the widget is hidden. This is changing the condition in `shouldShowWelcome` + // so it should fire onDidChangeViewWelcomeState. + try { + this._widget.setVisible(false); + this.updateModel(model); + this.didProviderRegistrationFail = false; + this.didUnregisterProvider = false; + this._onDidChangeViewWelcomeState.fire(); + } finally { + this.widget.setVisible(true); + } + } + } else if (this._widget?.viewModel?.initState === ChatModelInitState.Initialized) { + // Model is initialized, and the default agent disappeared, so show welcome view + this.didUnregisterProvider = true; + } + + this._onDidChangeViewWelcomeState.fire(); + })); + } + + override getActionsContext(): IChatViewTitleActionContext { + return { + chatView: this + }; + } + + private updateModel(model?: IChatModel | undefined): void { + this.modelDisposables.clear(); + + model = model ?? (this.chatService.transferredSessionData?.sessionId + ? this.chatService.getOrRestoreSession(this.chatService.transferredSessionData.sessionId) + : this.chatService.startSession(ChatAgentLocation.Panel, CancellationToken.None)); + if (!model) { + throw new Error('Could not start chat session'); + } + + this._widget.setModel(model, { ...this.viewState }); + this.viewState.sessionId = model.sessionId; + } + + override shouldShowWelcome(): boolean { + if (!this.chatAgentService.getContributedDefaultAgent(ChatAgentLocation.Panel)) { + return true; + } + + const noPersistedSessions = !this.chatService.hasSessions(); + return this.didUnregisterProvider || !this._widget?.viewModel && (noPersistedSessions || this.didProviderRegistrationFail); + } + + private getSessionId() { + let sessionId: string | undefined; + if (this.chatService.transferredSessionData) { + sessionId = this.chatService.transferredSessionData.sessionId; + this.viewState.inputValue = this.chatService.transferredSessionData.inputValue; + } else { + sessionId = this.viewState.sessionId; + } + return sessionId; + } + + protected override renderBody(parent: HTMLElement): void { + try { + super.renderBody(parent); + + const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService]))); + const locationBasedColors = this.getLocationBasedColors(); + this._widget = this._register(scopedInstantiationService.createInstance( + ChatWidget, + ChatAgentLocation.Panel, + { viewId: this.id }, + { supportsFileReferences: true }, + { + listForeground: SIDE_BAR_FOREGROUND, + listBackground: locationBasedColors.background, + overlayBackground: locationBasedColors.overlayBackground, + inputEditorBackground: locationBasedColors.background, + resultEditorBackground: editorBackground + })); + this._register(this.onDidChangeBodyVisibility(visible => { + this._widget.setVisible(visible); + })); + this._register(this._widget.onDidClear(() => this.clear())); + this._widget.render(parent); + + const sessionId = this.getSessionId(); + // Render the welcome view if this session gets disposed at any point, + // including if the provider registration fails + const disposeListener = sessionId ? this._register(this.chatService.onDidDisposeSession((e) => { + if (e.reason === 'initializationFailed') { + this.didProviderRegistrationFail = true; + disposeListener?.dispose(); + this._onDidChangeViewWelcomeState.fire(); + } + })) : undefined; + const model = sessionId ? this.chatService.getOrRestoreSession(sessionId) : undefined; + + this.updateModel(model); + } catch (e) { + this.logService.error(e); + throw e; + } + } + + acceptInput(query?: string): void { + this._widget.acceptInput(query); + } + + private clear(): void { + if (this.widget.viewModel) { + this.chatService.clearSession(this.widget.viewModel.sessionId); + } + + // Grab the widget's latest view state because it will be loaded back into the widget + this.updateViewState(); + this.updateModel(undefined); + } + + loadSession(sessionId: string): void { + if (this.widget.viewModel) { + this.chatService.clearSession(this.widget.viewModel.sessionId); + } + + const newModel = this.chatService.getOrRestoreSession(sessionId); + this.updateModel(newModel); + } + + focusInput(): void { + this._widget.focusInput(); + } + + override focus(): void { + super.focus(); + this._widget.focusInput(); + } + + protected override layoutBody(height: number, width: number): void { + super.layoutBody(height, width); + this._widget.layout(height, width); + } + + override saveState(): void { + if (this._widget) { + // Since input history is per-provider, this is handled by a separate service and not the memento here. + // TODO multiple chat views will overwrite each other + this._widget.saveState(); + + this.updateViewState(); + this.memento.saveMemento(); + } + + super.saveState(); + } + + private updateViewState(): void { + const widgetViewState = this._widget.getViewState(); + this.viewState.inputValue = widgetViewState.inputValue; + this.viewState.inputState = widgetViewState.inputState; + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentWidget.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentWidget.ts new file mode 100644 index 00000000000..e153a797ebb --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentWidget.ts @@ -0,0 +1,1051 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { ITreeContextMenuEvent, ITreeElement } from '../../../../base/browser/ui/tree/tree.js'; +import { disposableTimeout, timeout } from '../../../../base/common/async.js'; +import { toErrorMessage } from '../../../../base/common/errorMessage.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, combinedDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.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 { MenuId } from '../../../../platform/actions/common/actions.js'; +import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { ITextResourceEditorInput } from '../../../../platform/editor/common/editor.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { WorkbenchObjectTree } from '../../../../platform/list/browser/listService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { ChatAgentLocation, IAideAgentAgentService, IChatAgentCommand, IChatAgentData } from '../common/aideAgentAgents.js'; +import { CONTEXT_CHAT_INPUT_HAS_AGENT, 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 { ChatModelInitState, IChatModel, IChatRequestVariableEntry, IChatResponseModel } from '../common/aideAgentModel.js'; +import { ChatRequestAgentPart, IParsedChatRequest, chatAgentLeader, chatSubcommandLeader, formatChatQuestion } from '../common/aideAgentParserTypes.js'; +import { ChatRequestParser } from '../common/aideAgentRequestParser.js'; +import { IAideAgentService, IChatFollowup, IChatLocationData } from '../common/aideAgentService.js'; +import { IAideAgentSlashCommandService } from '../common/aideAgentSlashCommands.js'; +import { ChatViewModel, IChatResponseViewModel, isRequestVM, isResponseVM, isWelcomeVM } from '../common/aideAgentViewModel.js'; +import { CodeBlockModelCollection } from '../common/codeBlockModelCollection.js'; +import { ChatTreeItem, IAideAgentAccessibilityService, IAideAgentWidgetService, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidget, IChatWidgetViewContext, IChatWidgetViewOptions } from './aideAgent.js'; +import { ChatAccessibilityProvider } from './aideAgentAccessibilityProvider.js'; +import { ChatInputPart } from './aideAgentInputPart.js'; +import { ChatListDelegate, ChatListItemRenderer, IChatRendererDelegate } from './aideAgentListRenderer.js'; +import { ChatEditorOptions } from './aideAgentOptions.js'; +import './media/aideAgent.css'; + +const $ = dom.$; + +function revealLastElement(list: WorkbenchObjectTree) { + list.scrollTop = list.scrollHeight - list.renderHeight; +} + +export type IChatInputState = Record; +export interface IChatViewState { + inputValue?: string; + inputState?: IChatInputState; +} + +export interface IChatWidgetStyles { + listForeground: string; + listBackground: string; + overlayBackground: string; + inputEditorBackground: string; + resultEditorBackground: string; +} + +export interface IChatWidgetContrib extends IDisposable { + readonly id: string; + + /** + * A piece of state which is related to the input editor of the chat widget + */ + getInputState?(): any; + + /** + * Called with the result of getInputState when navigating input history. + */ + setInputState?(s: any): void; +} + +export interface IChatWidgetLocationOptions { + location: ChatAgentLocation; + resolveData?(): IChatLocationData | undefined; +} + +export type IChatWidgetCompletionContext = 'default' | 'files' | 'code'; + +export class ChatWidget extends Disposable implements IChatWidget { + public static readonly CONTRIBS: { new(...args: [IChatWidget, ...any]): IChatWidgetContrib }[] = []; + + private readonly _onDidSubmitAgent = this._register(new Emitter<{ agent: IChatAgentData; slashCommand?: IChatAgentCommand }>()); + public readonly onDidSubmitAgent = this._onDidSubmitAgent.event; + + private _onDidChangeAgent = this._register(new Emitter<{ agent: IChatAgentData; slashCommand?: IChatAgentCommand }>()); + readonly onDidChangeAgent = this._onDidChangeAgent.event; + + private _onDidFocus = this._register(new Emitter()); + readonly onDidFocus = this._onDidFocus.event; + + private _onDidChangeViewModel = this._register(new Emitter()); + readonly onDidChangeViewModel = this._onDidChangeViewModel.event; + + private _onDidScroll = this._register(new Emitter()); + readonly onDidScroll = this._onDidScroll.event; + + private _onDidClear = this._register(new Emitter()); + readonly onDidClear = this._onDidClear.event; + + private _onDidAcceptInput = this._register(new Emitter()); + readonly onDidAcceptInput = this._onDidAcceptInput.event; + + private _onDidChangeContext = this._register(new Emitter<{ removed?: IChatRequestVariableEntry[]; added?: IChatRequestVariableEntry[] }>()); + readonly onDidChangeContext = this._onDidChangeContext.event; + + private _onDidHide = this._register(new Emitter()); + readonly onDidHide = this._onDidHide.event; + + private _onDidChangeParsedInput = this._register(new Emitter()); + readonly onDidChangeParsedInput = this._onDidChangeParsedInput.event; + + private readonly _onWillMaybeChangeHeight = new Emitter(); + readonly onWillMaybeChangeHeight: Event = this._onWillMaybeChangeHeight.event; + + private _onDidChangeHeight = this._register(new Emitter()); + readonly onDidChangeHeight = this._onDidChangeHeight.event; + + private readonly _onDidChangeContentHeight = new Emitter(); + readonly onDidChangeContentHeight: Event = this._onDidChangeContentHeight.event; + + private contribs: ReadonlyArray = []; + + private tree!: WorkbenchObjectTree; + private renderer!: ChatListItemRenderer; + private readonly _codeBlockModelCollection: CodeBlockModelCollection; + + private inputPart!: ChatInputPart; + private editorOptions!: ChatEditorOptions; + + private listContainer!: HTMLElement; + private container!: HTMLElement; + + private bodyDimension: dom.Dimension | undefined; + private visibleChangeCount = 0; + private requestInProgress: IContextKey; + private agentInInput: IContextKey; + private agentSupportsModelPicker: IContextKey; + + private _visible = false; + public get visible() { + return this._visible; + } + + private previousTreeScrollHeight: number = 0; + + private readonly viewModelDisposables = this._register(new DisposableStore()); + private _viewModel: ChatViewModel | undefined; + private set viewModel(viewModel: ChatViewModel | undefined) { + if (this._viewModel === viewModel) { + return; + } + + this.viewModelDisposables.clear(); + + this._viewModel = viewModel; + if (viewModel) { + this.viewModelDisposables.add(viewModel); + } + + this._onDidChangeViewModel.fire(); + } + + get viewModel() { + return this._viewModel; + } + + private parsedChatRequest: IParsedChatRequest | undefined; + get parsedInput() { + if (this.parsedChatRequest === undefined) { + this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(this.viewModel!.sessionId, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent }); + } + + return this.parsedChatRequest; + } + + get scopedContextKeyService(): IContextKeyService { + return this.contextKeyService; + } + + private readonly _location: IChatWidgetLocationOptions; + + get location() { + return this._location.location; + } + + readonly viewContext: IChatWidgetViewContext; + + constructor( + location: ChatAgentLocation | IChatWidgetLocationOptions, + _viewContext: IChatWidgetViewContext | undefined, + private readonly viewOptions: IChatWidgetViewOptions, + private readonly styles: IChatWidgetStyles, + @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, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IAideAgentAccessibilityService private readonly chatAccessibilityService: IAideAgentAccessibilityService, + @ILogService private readonly logService: ILogService, + @IThemeService private readonly themeService: IThemeService, + @IAideAgentSlashCommandService private readonly chatSlashCommandService: IAideAgentSlashCommandService, + ) { + super(); + + this.viewContext = _viewContext ?? {}; + + if (typeof location === 'object') { + this._location = location; + } else { + this._location = { location }; + } + + CONTEXT_IN_CHAT_SESSION.bindTo(contextKeyService).set(true); + CONTEXT_CHAT_LOCATION.bindTo(contextKeyService).set(this._location.location); + 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._register((chatWidgetService as ChatWidgetService).register(this)); + + this._codeBlockModelCollection = this._register(instantiationService.createInstance(CodeBlockModelCollection)); + + this._register(codeEditorService.registerCodeEditorOpenHandler(async (input: ITextResourceEditorInput, _source: ICodeEditor | null, _sideBySide?: boolean): Promise => { + const resource = input.resource; + if (resource.scheme !== Schemas.vscodeAideAgentCodeBlock) { + return null; + } + + const responseId = resource.path.split('/').at(1); + if (!responseId) { + return null; + } + + const item = this.viewModel?.getItems().find(item => item.id === responseId); + if (!item) { + return null; + } + + // TODO: needs to reveal the chat view + + this.reveal(item); + + await timeout(0); // wait for list to actually render + + for (const codeBlockPart of this.renderer.editorsInUse()) { + if (extUri.isEqual(codeBlockPart.uri, resource, true)) { + const editor = codeBlockPart.editor; + + let relativeTop = 0; + const editorDomNode = editor.getDomNode(); + if (editorDomNode) { + const row = dom.findParentWithClass(editorDomNode, 'monaco-list-row'); + if (row) { + relativeTop = dom.getTopLeftOffset(editorDomNode).top - dom.getTopLeftOffset(row).top; + } + } + + if (input.options?.selection) { + const editorSelectionTopOffset = editor.getTopForPosition(input.options.selection.startLineNumber, input.options.selection.startColumn); + relativeTop += editorSelectionTopOffset; + + editor.focus(); + editor.setSelection({ + startLineNumber: input.options.selection.startLineNumber, + startColumn: input.options.selection.startColumn, + endLineNumber: input.options.selection.endLineNumber ?? input.options.selection.startLineNumber, + endColumn: input.options.selection.endColumn ?? input.options.selection.startColumn + }); + } + + this.reveal(item, relativeTop); + + return editor; + } + } + return null; + })); + } + + private _lastSelectedAgent: IChatAgentData | undefined; + set lastSelectedAgent(agent: IChatAgentData | undefined) { + this.parsedChatRequest = undefined; + this._lastSelectedAgent = agent; + this._onDidChangeParsedInput.fire(); + } + + get lastSelectedAgent(): IChatAgentData | undefined { + return this._lastSelectedAgent; + } + + private _completionContext: IChatWidgetCompletionContext = 'default'; + set completionContext(context: IChatWidgetCompletionContext) { + this._completionContext = context; + } + + get completionContext(): IChatWidgetCompletionContext { + return this._completionContext; + } + + get supportsFileReferences(): boolean { + return !!this.viewOptions.supportsFileReferences; + } + + get input(): ChatInputPart { + return this.inputPart; + } + + get inputEditor(): ICodeEditor { + return this.inputPart.inputEditor; + } + + get inputUri(): URI { + return this.inputPart.inputUri; + } + + get contentHeight(): number { + return this.inputPart.contentHeight + this.tree.contentHeight; + } + + render(parent: HTMLElement): void { + const viewId = 'viewId' in this.viewContext ? this.viewContext.viewId : undefined; + this.editorOptions = this._register(this.instantiationService.createInstance(ChatEditorOptions, viewId, this.styles.listForeground, this.styles.inputEditorBackground, this.styles.resultEditorBackground)); + const renderInputOnTop = this.viewOptions.renderInputOnTop ?? false; + const renderFollowups = this.viewOptions.renderFollowups ?? !renderInputOnTop; + const renderStyle = this.viewOptions.renderStyle; + + this.container = dom.append(parent, $('.interactive-session')); + if (renderInputOnTop) { + this.createInput(this.container, { renderFollowups, renderStyle }); + this.listContainer = dom.append(this.container, $(`.interactive-list`)); + } else { + this.listContainer = dom.append(this.container, $(`.interactive-list`)); + this.createInput(this.container, { renderFollowups, renderStyle }); + } + + this.createList(this.listContainer, { ...this.viewOptions.rendererOptions, renderStyle }); + + this._register(this.editorOptions.onDidChange(() => this.onDidStyleChange())); + this.onDidStyleChange(); + + // Do initial render + if (this.viewModel) { + this.onDidChangeItems(); + revealLastElement(this.tree); + } + + this.contribs = ChatWidget.CONTRIBS.map(contrib => { + try { + return this._register(this.instantiationService.createInstance(contrib, this)); + } catch (err) { + this.logService.error('Failed to instantiate chat widget contrib', toErrorMessage(err)); + return undefined; + } + }).filter(isDefined); + } + + getContrib(id: string): T | undefined { + return this.contribs.find(c => c.id === id) as T; + } + + focusInput(): void { + this.inputPart.focus(); + } + + hasInputFocus(): boolean { + return this.inputPart.hasFocus(); + } + + getSibling(item: ChatTreeItem, type: 'next' | 'previous'): ChatTreeItem | undefined { + if (!isResponseVM(item)) { + return; + } + const items = this.viewModel?.getItems(); + if (!items) { + return; + } + const responseItems = items.filter(i => isResponseVM(i)); + const targetIndex = responseItems.indexOf(item); + if (targetIndex === undefined) { + return; + } + const indexToFocus = type === 'next' ? targetIndex + 1 : targetIndex - 1; + if (indexToFocus < 0 || indexToFocus > responseItems.length - 1) { + return; + } + return responseItems[indexToFocus]; + } + + clear(): void { + if (this._dynamicMessageLayoutData) { + this._dynamicMessageLayoutData.enabled = true; + } + this._onDidClear.fire(); + } + + private onDidChangeItems(skipDynamicLayout?: boolean) { + if (this.tree && this._visible) { + const treeItems = (this.viewModel?.getItems() ?? []) + .map((item): ITreeElement => { + return { + element: item, + collapsed: false, + collapsible: false + }; + }); + + this._onWillMaybeChangeHeight.fire(); + + this.tree.setChildren(null, treeItems, { + diffIdentityProvider: { + getId: (element) => { + return ((isResponseVM(element) || isRequestVM(element)) ? element.dataId : element.id) + + // TODO? We can give the welcome message a proper VM or get rid of the rest of the VMs + ((isWelcomeVM(element) && this.viewModel) ? `_${ChatModelInitState[this.viewModel.initState]}` : '') + + // Ensure re-rendering an element once slash commands are loaded, so the colorization can be applied. + `${(isRequestVM(element) || isWelcomeVM(element)) /* && !!this.lastSlashCommands ? '_scLoaded' : '' */}` + + // 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}` : '') + + // 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}` : ''); + }, + } + }); + + if (!skipDynamicLayout && this._dynamicMessageLayoutData) { + this.layoutDynamicChatTreeItemMode(); + } + + const lastItem = treeItems[treeItems.length - 1]?.element; + if (lastItem && isResponseVM(lastItem) && lastItem.isComplete) { + this.renderFollowups(lastItem.replyFollowups, lastItem); + } else if (lastItem && isWelcomeVM(lastItem)) { + this.renderFollowups(lastItem.sampleQuestions); + } else { + this.renderFollowups(undefined); + } + } + } + + private async renderFollowups(items: IChatFollowup[] | undefined, response?: IChatResponseViewModel): Promise { + this.inputPart.renderFollowups(items, response); + + if (this.bodyDimension) { + this.layout(this.bodyDimension.height, this.bodyDimension.width); + } + } + + setVisible(visible: boolean): void { + const wasVisible = this._visible; + this._visible = visible; + this.visibleChangeCount++; + this.renderer.setVisible(visible); + this.input.setVisible(visible); + + if (visible) { + this._register(disposableTimeout(() => { + // Progressive rendering paused while hidden, so start it up again. + // Do it after a timeout because the container is not visible yet (it should be but offsetHeight returns 0 here) + if (this._visible) { + this.onDidChangeItems(true); + } + }, 0)); + } else if (wasVisible) { + this._onDidHide.fire(); + } + } + + private createList(listContainer: HTMLElement, options: IChatListItemRendererOptions): void { + const scopedInstantiationService = this._register(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, + }; + + // Create a dom element to hold UI from editor widgets embedded in chat messages + const overflowWidgetsContainer = document.createElement('div'); + overflowWidgetsContainer.classList.add('chat-overflow-widget-container', 'monaco-editor'); + listContainer.append(overflowWidgetsContainer); + + this.renderer = this._register(scopedInstantiationService.createInstance( + ChatListItemRenderer, + this.editorOptions, + this.location, + options, + rendererDelegate, + this._codeBlockModelCollection, + overflowWidgetsContainer, + )); + this._register(this.renderer.onDidClickFollowup(item => { + // is this used anymore? + this.acceptInput(item.message); + })); + this._register(this.renderer.onDidClickRerunWithAgentOrCommandDetection(item => { + /* TODO(@ghostwriternr): Commenting this out definitely breaks rerunning requests. Fix this. + const request = this.chatService.getSession(item.sessionId)?.getExchanges().find(candidate => candidate.id === item.requestId); + if (request) { + this.chatService.resendRequest(request, { noCommandDetection: true, attempt: request.attempt + 1, location: this.location }).catch(e => this.logService.error('FAILED to rerun request', e)); + } + */ + })); + + this.tree = this._register(>scopedInstantiationService.createInstance( + WorkbenchObjectTree, + 'Chat', + listContainer, + delegate, + [this.renderer], + { + identityProvider: { getId: (e: ChatTreeItem) => e.id }, + horizontalScrolling: false, + alwaysConsumeMouseWheel: false, + supportDynamicHeights: true, + hideTwistiesOfChildlessElements: true, + accessibilityProvider: this.instantiationService.createInstance(ChatAccessibilityProvider), + 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, + overrideStyles: { + listFocusBackground: this.styles.listBackground, + listInactiveFocusBackground: this.styles.listBackground, + listActiveSelectionBackground: this.styles.listBackground, + listFocusAndSelectionBackground: this.styles.listBackground, + listInactiveSelectionBackground: this.styles.listBackground, + listHoverBackground: this.styles.listBackground, + listBackground: this.styles.listBackground, + listFocusForeground: this.styles.listForeground, + listHoverForeground: this.styles.listForeground, + listInactiveFocusForeground: this.styles.listForeground, + listInactiveSelectionForeground: this.styles.listForeground, + listActiveSelectionForeground: this.styles.listForeground, + listFocusAndSelectionForeground: this.styles.listForeground, + listActiveSelectionIconForeground: undefined, + listInactiveSelectionIconForeground: undefined, + } + })); + this._register(this.tree.onContextMenu(e => this.onContextMenu(e))); + + this._register(this.tree.onDidChangeContentHeight(() => { + this.onDidChangeTreeContentHeight(); + })); + this._register(this.renderer.onDidChangeItemHeight(e => { + this.tree.updateElementHeight(e.element, e.height); + })); + this._register(this.tree.onDidFocus(() => { + this._onDidFocus.fire(); + })); + this._register(this.tree.onDidScroll(() => { + this._onDidScroll.fire(); + })); + } + + private onContextMenu(e: ITreeContextMenuEvent): void { + e.browserEvent.preventDefault(); + e.browserEvent.stopPropagation(); + + const selected = e.element; + const scopedContextKeyService = this.contextKeyService.createOverlay([ + [CONTEXT_RESPONSE_FILTERED.key, isResponseVM(selected) && !!selected.errorDetails?.responseIsFiltered] + ]); + this.contextMenuService.showContextMenu({ + menuId: MenuId.AideAgentContext, + menuActionOptions: { shouldForwardArgs: true }, + contextKeyService: scopedContextKeyService, + getAnchor: () => e.anchor, + getActionsContext: () => selected, + }); + } + + private onDidChangeTreeContentHeight(): void { + 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 + revealLastElement(this.tree); + }, 0); + } + } + + this.previousTreeScrollHeight = this.tree.scrollHeight; + this._onDidChangeContentHeight.fire(); + } + + private createInput(container: HTMLElement, options?: { renderFollowups: boolean; renderStyle?: 'default' | 'compact' | 'minimal' }): void { + this.inputPart = this._register(this.instantiationService.createInstance(ChatInputPart, + this.location, + { + renderFollowups: options?.renderFollowups ?? true, + renderStyle: options?.renderStyle === 'minimal' ? 'compact' : options?.renderStyle, + menus: { executeToolbar: MenuId.AideAgentExecute, ...this.viewOptions.menus }, + editorOverflowWidgetsDomNode: this.viewOptions.editorOverflowWidgetsDomNode, + }, + () => this.collectInputState() + )); + this.inputPart.render(container, '', this); + + this._register(this.inputPart.onDidLoadInputState(state => { + this.contribs.forEach(c => { + if (c.setInputState) { + const contribState = (typeof state === 'object' && state?.[c.id]) ?? {}; + c.setInputState(contribState); + } + }); + })); + this._register(this.inputPart.onDidFocus(() => this._onDidFocus.fire())); + this._register(this.inputPart.onDidChangeContext((e) => this._onDidChangeContext.fire(e))); + this._register(this.inputPart.onDidAcceptFollowup(e => { + if (!this.viewModel) { + return; + } + + let msg = ''; + if (e.followup.agentId && e.followup.agentId !== this.chatAgentService.getDefaultAgent(this.location)?.id) { + const agent = this.chatAgentService.getAgent(e.followup.agentId); + if (!agent) { + return; + } + + this.lastSelectedAgent = agent; + msg = `${chatAgentLeader}${agent.name} `; + if (e.followup.subCommand) { + msg += `${chatSubcommandLeader}${e.followup.subCommand} `; + } + } else if (!e.followup.agentId && e.followup.subCommand && this.chatSlashCommandService.hasCommand(e.followup.subCommand)) { + msg = `${chatSubcommandLeader}${e.followup.subCommand} `; + } + + msg += e.followup.message; + this.acceptInput(msg); + + if (!e.response) { + // Followups can be shown by the welcome message, then there is no response associated. + // At some point we probably want telemetry for these too. + return; + } + + this.chatService.notifyUserAction({ + sessionId: this.viewModel.sessionId, + // requestId: e.response.requestId, + // TODO(@ghostwriternr): This is obviously wrong, but not super critical. Come back to fix this. + requestId: e.response.id, + agentId: e.response.agent?.id, + command: e.response.slashCommand?.name, + result: e.response.result, + action: { + kind: 'followUp', + followup: e.followup + }, + }); + })); + this._register(this.inputPart.onDidChangeHeight(() => { + if (this.bodyDimension) { + this.layout(this.bodyDimension.height, this.bodyDimension.width); + } + this._onDidChangeContentHeight.fire(); + })); + this._register(this.inputEditor.onDidChangeModelContent(() => { + this.parsedChatRequest = undefined; + this.updateChatInputContext(); + })); + this._register(this.chatAgentService.onDidChangeAgents(() => this.parsedChatRequest = undefined)); + } + + private onDidStyleChange(): void { + this.container.style.setProperty('--vscode-interactive-result-editor-background-color', this.editorOptions.configuration.resultEditor.backgroundColor?.toString() ?? ''); + this.container.style.setProperty('--vscode-interactive-session-foreground', this.editorOptions.configuration.foreground?.toString() ?? ''); + this.container.style.setProperty('--vscode-chat-list-background', this.themeService.getColorTheme().getColor(this.styles.listBackground)?.toString() ?? ''); + } + + setModel(model: IChatModel, viewState: IChatViewState): void { + if (!this.container) { + throw new Error('Call render() before setModel()'); + } + + if (model.sessionId === this.viewModel?.sessionId) { + return; + } + + this._codeBlockModelCollection.clear(); + + this.container.setAttribute('data-session-id', model.sessionId); + this.viewModel = this.instantiationService.createInstance(ChatViewModel, model, this._codeBlockModelCollection); + this.viewModelDisposables.add(Event.accumulate(this.viewModel.onDidChange, 0)(events => { + if (!this.viewModel) { + return; + } + + this.requestInProgress.set(this.viewModel.requestInProgress); + + this.onDidChangeItems(); + if (events.some(e => e?.kind === 'addRequest') && this.visible) { + revealLastElement(this.tree); + this.focusInput(); + } + })); + this.viewModelDisposables.add(this.viewModel.onDidDisposeModel(() => { + // Ensure that view state is saved here, because we will load it again when a new model is assigned + this.inputPart.saveState(); + + // Disposes the viewmodel and listeners + this.viewModel = undefined; + this.onDidChangeItems(); + })); + this.inputPart.initForNewChatModel(viewState.inputValue, viewState.inputState ?? this.collectInputState()); + this.contribs.forEach(c => { + if (c.setInputState && viewState.inputState?.[c.id]) { + c.setInputState(viewState.inputState?.[c.id]); + } + }); + this.viewModelDisposables.add(model.onDidChange((e) => { + if (e.kind === 'setAgent') { + this._onDidChangeAgent.fire({ agent: e.agent, slashCommand: e.command }); + } + })); + + if (this.tree) { + this.onDidChangeItems(); + revealLastElement(this.tree); + } + this.updateChatInputContext(); + } + + getFocus(): ChatTreeItem | undefined { + return this.tree.getFocus()[0] ?? undefined; + } + + reveal(item: ChatTreeItem, relativeTop?: number): void { + this.tree.reveal(item, relativeTop); + } + + focus(item: ChatTreeItem): void { + const items = this.tree.getNode(null).children; + const node = items.find(i => i.element?.id === item.id); + if (!node) { + return; + } + + this.tree.setFocus([node.element]); + this.tree.domFocus(); + } + + refilter() { + this.tree.refilter(); + } + + setInputPlaceholder(placeholder: string): void { + this.viewModel?.setInputPlaceholder(placeholder); + } + + resetInputPlaceholder(): void { + this.viewModel?.resetInputPlaceholder(); + } + + setInput(value = ''): void { + this.inputPart.setValue(value, false); + } + + getInput(): string { + return this.inputPart.inputEditor.getValue(); + } + + logInputHistory(): void { + this.inputPart.logInputHistory(); + } + + async acceptInput(query?: string): Promise { + return this._acceptInput(query ? { query } : undefined); + } + + async acceptInputWithPrefix(prefix: string): Promise { + this._acceptInput({ prefix }); + } + + private collectInputState(): IChatInputState { + const inputState: IChatInputState = {}; + this.contribs.forEach(c => { + if (c.getInputState) { + inputState[c.id] = c.getInputState(); + } + }); + return inputState; + } + + private async _acceptInput(opts: { query: string } | { prefix: string } | undefined): Promise { + if (this.viewModel) { + this._onDidAcceptInput.fire(); + + const editorValue = this.getInput(); + const requestId = this.chatAccessibilityService.acceptRequest(); + const input = !opts ? editorValue : + 'query' in opts ? opts.query : + `${opts.prefix} ${editorValue}`; + const isUserQuery = !opts || 'prefix' in opts; + const result = await this.chatService.sendRequest(this.viewModel.sessionId, input, { + agentMode: this.inputPart.currentAgentMode, + userSelectedModelId: this.inputPart.currentLanguageModel, + location: this.location, + locationData: this._location.resolveData?.(), + parserContext: { selectedAgent: this._lastSelectedAgent }, + attachedContext: [...this.inputPart.attachedContext.values()] + }); + + if (result) { + this.inputPart.acceptInput(isUserQuery); + this._onDidSubmitAgent.fire({ agent: result.agent, slashCommand: result.slashCommand }); + result.responseCompletePromise.then(() => { + const responses = this.viewModel?.getItems().filter(isResponseVM); + const lastResponse = responses?.[responses.length - 1]; + this.chatAccessibilityService.acceptResponse(lastResponse, requestId); + if (lastResponse?.result?.nextQuestion) { + const { prompt, participant, command } = lastResponse.result.nextQuestion; + const question = formatChatQuestion(this.chatAgentService, this.location, prompt, participant, command); + if (question) { + this.input.setValue(question, false); + } + } + }); + return result.responseCreatedPromise; + } + } + return undefined; + } + + setContext(overwrite: boolean, ...contentReferences: IChatRequestVariableEntry[]) { + this.inputPart.attachContext(overwrite, ...contentReferences); + } + + getCodeBlockInfosForResponse(response: IChatResponseViewModel): IChatCodeBlockInfo[] { + return this.renderer.getCodeBlockInfosForResponse(response); + } + + getCodeBlockInfoForEditor(uri: URI): IChatCodeBlockInfo | undefined { + return this.renderer.getCodeBlockInfoForEditor(uri); + } + + getFileTreeInfosForResponse(response: IChatResponseViewModel): IChatFileTreeInfo[] { + return this.renderer.getFileTreeInfosForResponse(response); + } + + getLastFocusedFileTreeForResponse(response: IChatResponseViewModel): IChatFileTreeInfo | undefined { + return this.renderer.getLastFocusedFileTreeForResponse(response); + } + + focusLastMessage(): void { + if (!this.viewModel) { + return; + } + + const items = this.tree.getNode(null).children; + const lastItem = items[items.length - 1]; + if (!lastItem) { + return; + } + + this.tree.setFocus([lastItem.element]); + this.tree.domFocus(); + } + + layout(height: number, width: number): void { + width = Math.min(width, 850); + this.bodyDimension = new dom.Dimension(width, height); + + this.inputPart.layout(height, width); + const inputPartHeight = this.inputPart.inputPartHeight; + const lastElementVisible = this.tree.scrollTop + this.tree.renderHeight >= this.tree.scrollHeight; + + const listHeight = height - inputPartHeight; + + this.tree.layout(listHeight, width); + this.tree.getHTMLElement().style.height = `${listHeight}px`; + this.renderer.layout(width); + if (lastElementVisible) { + revealLastElement(this.tree); + } + + this.listContainer.style.height = `${height - inputPartHeight}px`; + + this._onDidChangeHeight.fire(height); + } + + private _dynamicMessageLayoutData?: { numOfMessages: number; maxHeight: number; enabled: boolean }; + + // An alternative to layout, this allows you to specify the number of ChatTreeItems + // you want to show, and the max height of the container. It will then layout the + // tree to show that many items. + // TODO@TylerLeonhardt: This could use some refactoring to make it clear which layout strategy is being used + setDynamicChatTreeItemLayout(numOfChatTreeItems: number, maxHeight: number) { + this._dynamicMessageLayoutData = { numOfMessages: numOfChatTreeItems, maxHeight, enabled: true }; + this._register(this.renderer.onDidChangeItemHeight(() => this.layoutDynamicChatTreeItemMode())); + + const mutableDisposable = this._register(new MutableDisposable()); + this._register(this.tree.onDidScroll((e) => { + // TODO@TylerLeonhardt this should probably just be disposed when this is disabled + // and then set up again when it is enabled again + if (!this._dynamicMessageLayoutData?.enabled) { + return; + } + mutableDisposable.value = dom.scheduleAtNextAnimationFrame(dom.getWindow(this.listContainer), () => { + if (!e.scrollTopChanged || e.heightChanged || e.scrollHeightChanged) { + return; + } + const renderHeight = e.height; + const diff = e.scrollHeight - renderHeight - e.scrollTop; + if (diff === 0) { + return; + } + + const possibleMaxHeight = (this._dynamicMessageLayoutData?.maxHeight ?? maxHeight); + const width = this.bodyDimension?.width ?? this.container.offsetWidth; + this.inputPart.layout(possibleMaxHeight, width); + const inputPartHeight = this.inputPart.inputPartHeight; + const newHeight = Math.min(renderHeight + diff, possibleMaxHeight - inputPartHeight); + this.layout(newHeight + inputPartHeight, width); + }); + })); + } + + updateDynamicChatTreeItemLayout(numOfChatTreeItems: number, maxHeight: number) { + this._dynamicMessageLayoutData = { numOfMessages: numOfChatTreeItems, maxHeight, enabled: true }; + let hasChanged = false; + let height = this.bodyDimension!.height; + let width = this.bodyDimension!.width; + if (maxHeight < this.bodyDimension!.height) { + height = maxHeight; + hasChanged = true; + } + const containerWidth = this.container.offsetWidth; + if (this.bodyDimension?.width !== containerWidth) { + width = containerWidth; + hasChanged = true; + } + if (hasChanged) { + this.layout(height, width); + } + } + + get isDynamicChatTreeItemLayoutEnabled(): boolean { + return this._dynamicMessageLayoutData?.enabled ?? false; + } + + set isDynamicChatTreeItemLayoutEnabled(value: boolean) { + if (!this._dynamicMessageLayoutData) { + return; + } + this._dynamicMessageLayoutData.enabled = value; + } + + layoutDynamicChatTreeItemMode(): void { + if (!this.viewModel || !this._dynamicMessageLayoutData?.enabled) { + return; + } + + const width = this.bodyDimension?.width ?? this.container.offsetWidth; + this.inputPart.layout(this._dynamicMessageLayoutData.maxHeight, width); + const inputHeight = this.inputPart.inputPartHeight; + + const totalMessages = this.viewModel.getItems(); + // grab the last N messages + const messages = totalMessages.slice(-this._dynamicMessageLayoutData.numOfMessages); + + const needsRerender = messages.some(m => m.currentRenderedHeight === undefined); + const listHeight = needsRerender + ? this._dynamicMessageLayoutData.maxHeight + : messages.reduce((acc, message) => acc + message.currentRenderedHeight!, 0); + + this.layout( + Math.min( + // we add an additional 18px in order to show that there is scrollable content + inputHeight + listHeight + (totalMessages.length > 2 ? 18 : 0), + this._dynamicMessageLayoutData.maxHeight + ), + width + ); + + if (needsRerender || !listHeight) { + // TODO: figure out a better place to reveal the last element + revealLastElement(this.tree); + } + } + + saveState(): void { + this.inputPart.saveState(); + } + + getViewState(): IChatViewState { + return { inputValue: this.getInput(), inputState: this.collectInputState() }; + } + + private updateChatInputContext() { + const currentAgent = this.parsedInput.parts.find(part => part instanceof ChatRequestAgentPart); + this.agentInInput.set(!!currentAgent); + this.agentSupportsModelPicker.set(!currentAgent || !!currentAgent.agent.supportsModelPicker); + } +} + +export class ChatWidgetService implements IAideAgentWidgetService { + + declare readonly _serviceBrand: undefined; + + private _widgets: ChatWidget[] = []; + private _lastFocusedWidget: ChatWidget | undefined = undefined; + + get lastFocusedWidget(): ChatWidget | undefined { + return this._lastFocusedWidget; + } + + constructor() { } + + getWidgetByInputUri(uri: URI): ChatWidget | undefined { + return this._widgets.find(w => isEqual(w.inputUri, uri)); + } + + getWidgetBySessionId(sessionId: string): ChatWidget | undefined { + return this._widgets.find(w => w.viewModel?.sessionId === sessionId); + } + + private setLastFocusedWidget(widget: ChatWidget | undefined): void { + if (widget === this._lastFocusedWidget) { + return; + } + + this._lastFocusedWidget = widget; + } + + register(newWidget: ChatWidget): IDisposable { + if (this._widgets.some(widget => widget === newWidget)) { + throw new Error('Cannot register the same widget multiple times'); + } + + this._widgets.push(newWidget); + + return combinedDisposable( + newWidget.onDidFocus(() => this.setLastFocusedWidget(newWidget)), + toDisposable(() => this._widgets.splice(this._widgets.indexOf(newWidget), 1)) + ); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideControls.ts b/src/vs/workbench/contrib/aideAgent/browser/aideControls.ts deleted file mode 100644 index 7fc4e1a1d5e..00000000000 --- a/src/vs/workbench/contrib/aideAgent/browser/aideControls.ts +++ /dev/null @@ -1,349 +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 dom from '../../../../base/browser/dom.js'; -import { DEFAULT_FONT_FAMILY } from '../../../../base/browser/fonts.js'; -import { ISelectOptionItem, SelectBox } from '../../../../base/browser/ui/selectBox/selectBox.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { basenameOrAuthority } from '../../../../base/common/resources.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { URI } from '../../../../base/common/uri.js'; -import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; -import { isCodeEditor } from '../../../../editor/browser/editorBrowser.js'; -import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; -import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; -import { CodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; -import { IDecorationOptions } from '../../../../editor/common/editorCommon.js'; -import { IModelService } from '../../../../editor/common/services/model.js'; -import { ContentHoverController } from '../../../../editor/contrib/hover/browser/contentHoverController.js'; -import { localize } from '../../../../nls.js'; -import { ActionViewItemWithKb } from '../../../../platform/actionbarWithKeybindings/browser/actionViewItemWithKb.js'; -import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; -import { MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IContextViewService } from '../../../../platform/contextview/browser/contextView.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 { defaultSelectBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { inputPlaceholderForeground } from '../../../../platform/theme/common/colors/inputColors.js'; -import { IThemeService, Themable } from '../../../../platform/theme/common/themeService.js'; -import { SIDE_BAR_BACKGROUND } from '../../../../workbench/common/theme.js'; -import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions } from '../../../../workbench/contrib/codeEditor/browser/simpleEditorOptions.js'; -import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; -import { IBottomBarPartService } from '../../../services/bottomBarPart/browser/bottomBarPartService.js'; -import { AideAgentScope } from '../common/aideAgentModel.js'; -import { IAideAgentService } from '../common/aideAgentService.js'; -import { SetAideAgentScopePinnedContext, SetAideAgentScopeSelection, SetAideAgentScopeWholeCodebase } from './actions/aideAgentActions.js'; -import { CONTEXT_AIDE_CONTROLS_HAS_FOCUS, CONTEXT_AIDE_CONTROLS_HAS_TEXT } from './aideAgentContextKeys.js'; -import { IAideControlsService } from './aideControlsService.js'; -import './media/aideControls.css'; - -const $ = dom.$; - -const inputPlaceholder = { - description: 'aide-controls-input', - decorationType: 'aide-controls-input-editor', -}; - -export class AideControls extends Themable { - public static readonly ID = 'workbench.contrib.aideControls'; - - private part = this.bottomBarPartService.mainPart; - private aideControlEditScope: HTMLElement; - - private _input: CodeEditorWidget; - static readonly INPUT_SCHEME = 'aideControlsInput'; - private static readonly INPUT_URI = URI.parse(`${this.INPUT_SCHEME}:input`); - - private toolbarElement: HTMLElement | undefined; - - private inputHasText: IContextKey; - private inputHasFocus: IContextKey; - - private readonly activeEditorDisposables = this._register(new DisposableStore()); - private anchoredContext: string = ''; - - constructor( - @IAideControlsService aideControlsService: IAideControlsService, - @IAideAgentService private readonly aideAgentService: IAideAgentService, - @IBottomBarPartService private readonly bottomBarPartService: IBottomBarPartService, - @ICodeEditorService private readonly codeEditorService: ICodeEditorService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IContextViewService private readonly contextViewService: IContextViewService, - @IEditorService private readonly editorService: IEditorService, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IKeybindingService private readonly keybindingService: IKeybindingService, - @IModelService private readonly modelService: IModelService, - @IThemeService protected override readonly themeService: IThemeService, - ) { - super(themeService); - aideControlsService.registerControls(this); - - const element = $('.aide-controls'); - this.part.content.appendChild(element); - element.style.backgroundColor = this.theme.getColor(SIDE_BAR_BACKGROUND)?.toString() || ''; - - this.inputHasText = CONTEXT_AIDE_CONTROLS_HAS_TEXT.bindTo(contextKeyService); - this.inputHasFocus = CONTEXT_AIDE_CONTROLS_HAS_FOCUS.bindTo(contextKeyService); - - const aideControlSettings = dom.append(element, $('.aide-controls-settings')); - this.aideControlEditScope = dom.append(aideControlSettings, $('.aide-controls-edit-focus')); - const scopeSelect = new SelectBox( - [ - { - text: localize('selectedRange', "Selected Range"), - description: localize('selectedRangeDescription', "The range of text selected in the editor"), - decoratorRight: this.keybindingService.lookupKeybinding(SetAideAgentScopeSelection.ID)?.getLabel() - }, - { - text: localize('pinnedContext', "Pinned Context"), - description: localize('pinnedContextDescription', "The files you have pinned as context for AI"), - decoratorRight: this.keybindingService.lookupKeybinding(SetAideAgentScopePinnedContext.ID)?.getLabel() - }, - { - text: localize('wholeCodebase', "Whole Codebase"), - description: localize('wholeCodebaseDescription', "The entire codebase of the current workspace"), - decoratorRight: this.keybindingService.lookupKeybinding(SetAideAgentScopeWholeCodebase.ID)?.getLabel() - }, - ], - aideAgentService.scopeSelection, - this.contextViewService, - defaultSelectBoxStyles, - { - ariaLabel: localize('editFocus', 'Edit Focus'), - useCustomDrawn: true, - customDrawnDropdownWidth: 320 - } - ); - scopeSelect.onDidSelect(e => { - const newScope = e.index === 0 ? AideAgentScope.Selection : e.index === 1 ? AideAgentScope.PinnedContext : AideAgentScope.WholeCodebase; - aideAgentService.scope = newScope; - }); - scopeSelect.render(this.aideControlEditScope); - - const inputElement = $('.aide-controls-input-container'); - element.appendChild(inputElement); - this._input = this.createInput(inputElement); - this.updateInputPlaceholder(); - this.layout(); - - this.aideAgentService.startSession(); - - this.toolbarElement = $('.aide-controls-toolbar'); - element.appendChild(this.toolbarElement); - this.createToolbar(this.toolbarElement); - - this.layout(); - this.part.onDidSizeChange((size: dom.IDimension) => { - this.layout(size.width, size.height); - }); - - this.updateScope(aideAgentService.scope); - this.updateInputPlaceholder(); - - this._register(this.editorService.onDidActiveEditorChange(() => { - this.trackActiveEditor(); - })); - - this._register(this.aideAgentService.onDidChangeScope((scope) => { - this.updateScope(scope); - scopeSelect.select(scope === AideAgentScope.Selection ? 0 : scope === AideAgentScope.PinnedContext ? 1 : 2); - })); - } - - private updateScope(scope: AideAgentScope) { - this.updateInputPlaceholder(); - const scopeIcon = scope === AideAgentScope.Selection ? Codicon.listSelection : scope === AideAgentScope.PinnedContext ? Codicon.pinned : Codicon.repo; - this.aideControlEditScope.classList.remove(...Array.from(this.aideControlEditScope.classList).filter(c => c.startsWith('codicon-'))); - this.aideControlEditScope.classList.add(...ThemeIcon.asClassNameArray(scopeIcon)); - } - - private createInput(parent: HTMLElement) { - const inputScopedContextKeyService = this._register(this.contextKeyService.createScoped(parent)); - - const editorElement = $('.aide-controls-input-editor'); - parent.appendChild(editorElement); - const scopedInstantiationService = this.instantiationService.createChild(new ServiceCollection([IContextKeyService, inputScopedContextKeyService])); - const defaultOptions = getSimpleEditorOptions(this.configurationService); - const options: IEditorConstructionOptions = { - ...defaultOptions, - overflowWidgetsDomNode: editorElement, - readOnly: false, - ariaLabel: localize('chatInput', "Edit code"), - fontFamily: DEFAULT_FONT_FAMILY, - fontSize: 13, - lineHeight: 20, - padding: { top: 8, bottom: 8 }, - cursorWidth: 1, - wrappingStrategy: 'advanced', - bracketPairColorization: { enabled: false }, - suggest: { - showIcons: false, - showSnippets: false, - showWords: true, - showStatusBar: false, - insertMode: 'replace', - }, - scrollbar: { ...(defaultOptions.scrollbar ?? {}), vertical: 'hidden' } - }; - const editorOptions = getSimpleCodeEditorWidgetOptions(); - editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID])); - const editor = this._input = this._register(scopedInstantiationService.createInstance(CodeEditorWidget, editorElement, options, editorOptions)); - let editorModel = this.modelService.getModel(AideControls.INPUT_URI); - if (!editorModel) { - editorModel = this.modelService.createModel('', null, AideControls.INPUT_URI, true); - this._register(editorModel); - } - editor.setModel(editorModel); - editor.render(); - - this.codeEditorService.registerDecorationType(inputPlaceholder.description, inputPlaceholder.decorationType, {}); - - this._register(editor.onDidChangeModelContent(() => { - this.inputHasText.set(editor.getValue().length > 0); - this.updateInputPlaceholder(); - this.layout(); - })); - - this._register(editor.onDidFocusEditorText(() => { - this.inputHasFocus.set(true); - this.updateInputPlaceholder(); - })); - - this._register(editor.onDidBlurEditorText(() => { - this.inputHasFocus.set(false); - this.updateInputPlaceholder(); - })); - - return editor; - } - - acceptInput() { - const editorValue = this._input.getValue(); - if (editorValue.length === 0) { - return; - } - - this.aideAgentService.trigger(editorValue); - } - - focusInput() { - this._input.focus(); - } - - private trackActiveEditor() { - this.activeEditorDisposables.clear(); - - const editor = this.editorService.activeTextEditorControl; - if (!isCodeEditor(editor)) { - return; - } - - const resource = editor.getModel()?.uri; - if (!resource) { - return; - } - - this.activeEditorDisposables.add(editor.onDidChangeCursorSelection(e => { - const selection = e.selection; - this.anchoredContext = `${basenameOrAuthority(resource)} from line ${selection.startLineNumber} to ${selection.endLineNumber}`; - this.updateInputPlaceholder(); - })); - } - - private updateInputPlaceholder() { - if (!this.inputHasText.get()) { - let placeholder = 'Start an edit across '; - if (this.aideAgentService.scope === AideAgentScope.Selection) { - placeholder += (this.anchoredContext.length > 0 ? this.anchoredContext : 'the selected range'); - } else if (this.aideAgentService.scope === AideAgentScope.PinnedContext) { - placeholder += 'the pinned context'; - } else { - placeholder += 'the whole codebase'; - } - - if (!this.inputHasFocus.get()) { - const keybinding = this.keybindingService.lookupKeybinding('workbench.action.aideAgent.focus'); - if (keybinding) { - placeholder += ` (${keybinding.getLabel()})`; - } - } - const editor = this.editorService.activeTextEditorControl; - if (!editor || (editor && isCodeEditor(editor))) { - const model = editor?.getModel(); - if (!model) { - placeholder = 'Open a file to start using Aide'; - } - } - - const theme = this.themeService.getColorTheme(); - const transparentForeground = theme.getColor(inputPlaceholderForeground); - const decorationOptions: IDecorationOptions[] = [ - { - range: { - startLineNumber: 1, - endLineNumber: 1, - startColumn: 1, - endColumn: 1000 - }, - renderOptions: { - after: { - contentText: placeholder, - color: transparentForeground?.toString(), - } - } - } - ]; - this._input.setDecorationsByType(inputPlaceholder.description, inputPlaceholder.decorationType, decorationOptions); - } else { - this._input.removeDecorationsByType(inputPlaceholder.decorationType); - } - } - - private createToolbar(parent: HTMLElement) { - const toolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, parent, MenuId.AideControlsToolbar, { - menuOptions: { - shouldForwardArgs: true, - }, - hiddenItemStrategy: HiddenItemStrategy.Ignore, - actionViewItemProvider: (action) => { - if (action instanceof MenuItemAction) { - return this.instantiationService.createInstance(ActionViewItemWithKb, action); - } - return; - } - })); - toolbar.getElement().classList.add('aide-controls-submit-toolbar'); - - this._register(toolbar.onDidChangeMenuItems(() => { - const width = toolbar.getItemsWidth(); - const numberOfItems = toolbar.getItemsLength(); - toolbar.getElement().style.width = `${width + Math.max(0, numberOfItems - 1) * 8}px`; - this.layout(); - })); - - this.layout(); - } - - layout(width?: number, height?: number) { - if (width === undefined) { - width = this.part.dimension?.width ?? 0; - } - if (height === undefined) { - height = this.part.dimension?.height ?? 0; - } - - if (!width || !height) { - return; - } - - const toolbarWidth = this.toolbarElement?.clientWidth ?? 0; - this._input.layout({ width: width - 72 /* gutter */ - 14 /* scrollbar */ - toolbarWidth, height: height }); - } -} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideControlsService.ts b/src/vs/workbench/contrib/aideAgent/browser/aideControlsService.ts deleted file mode 100644 index 09915e124e4..00000000000 --- a/src/vs/workbench/contrib/aideAgent/browser/aideControlsService.ts +++ /dev/null @@ -1,63 +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 { Disposable } from '../../../../base/common/lifecycle.js'; -import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { AideControls } from './aideControls.js'; - -export const IAideControlsService = createDecorator('IAideControlsService'); - -export interface IAideControlsService { - _serviceBrand: undefined; - // Controls - registerControls(controls: AideControls): void; - - // Input - acceptInput(): void; - focusInput(): void; - blurInput(): void; -} - -export class AideControlsService extends Disposable implements IAideControlsService { - _serviceBrand: undefined; - - private _controls: AideControls | undefined; - - constructor( - @ICodeEditorService private readonly codeEditorService: ICodeEditorService, - ) { - super(); - } - - registerControls(controls: AideControls): void { - if (!this._controls) { - this._controls = controls; - } else { - console.warn('AideControls already registered'); - } - } - - acceptInput(): void { - if (this._controls) { - this._controls.acceptInput(); - } - } - - focusInput(): void { - if (this._controls) { - this._controls.focusInput(); - } - } - - blurInput(): void { - if (this._controls) { - const activeEditor = this.codeEditorService.listCodeEditors().find(editor => !editor.hasTextFocus()); - if (activeEditor) { - activeEditor.focus(); - } - } - } -} diff --git a/src/vs/workbench/contrib/aideAgent/browser/codeBlockContextProviderService.ts b/src/vs/workbench/contrib/aideAgent/browser/codeBlockContextProviderService.ts new file mode 100644 index 00000000000..643da058af8 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/codeBlockContextProviderService.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 { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { ICodeBlockActionContextProvider, IAideAgentCodeBlockContextProviderService } from './aideAgent.js'; + +export class AideAgentCodeBlockContextProviderService implements IAideAgentCodeBlockContextProviderService { + declare _serviceBrand: undefined; + private readonly _providers = new Map(); + + get providers(): ICodeBlockActionContextProvider[] { + return [...this._providers.values()]; + } + registerProvider(provider: ICodeBlockActionContextProvider, id: string): IDisposable { + this._providers.set(id, provider); + return toDisposable(() => this._providers.delete(id)); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/codeBlockPart.css b/src/vs/workbench/contrib/aideAgent/browser/codeBlockPart.css new file mode 100644 index 00000000000..92049236a3a --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/codeBlockPart.css @@ -0,0 +1,164 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +.interactive-result-code-block { + position: relative; +} + +.interactive-result-code-block .interactive-result-code-block-toolbar { + display: none; +} + +.interactive-result-code-block .interactive-result-code-block-toolbar > .monaco-action-bar, +.interactive-result-code-block .interactive-result-code-block-toolbar > .monaco-toolbar { + position: absolute; + top: -15px; + height: 26px; + line-height: 26px; + background-color: var(--vscode-interactive-result-editor-background-color, var(--vscode-editor-background)); + border: 1px solid var(--vscode-chat-requestBorder); + z-index: 100; + max-width: 70%; + text-overflow: ellipsis; + overflow: hidden; +} + +.interactive-result-code-block .interactive-result-code-block-toolbar > .monaco-action-bar { + left: 0px +} + +.interactive-result-code-block .interactive-result-code-block-toolbar > .monaco-toolbar { + border-radius: 3px; + right: 10px; +} + +.interactive-result-code-block .monaco-toolbar .action-item { + height: 24px; + width: 24px; + margin: 1px 2px; +} + +.interactive-result-code-block .monaco-toolbar .action-item .codicon { + margin: 1px; +} + +.interactive-result-code-block:hover .interactive-result-code-block-toolbar, +.interactive-result-code-block .interactive-result-code-block-toolbar:focus-within, +.interactive-result-code-block.focused .interactive-result-code-block-toolbar { + display: initial; + border-radius: 2px; +} + +.interactive-result-code-block .interactive-result-code-block-toolbar.force-visibility .monaco-toolbar { + display: initial !important; +} + +.interactive-item-container .value .rendered-markdown [data-code] { + margin: 16px 0; +} + +.interactive-result-code-block { + border: 1px solid var(--vscode-input-border, transparent); + background-color: var(--vscode-interactive-result-editor-background-color); +} + +.interactive-result-code-block:has(.monaco-editor.focused) { + border-color: var(--vscode-focusBorder, transparent); +} + +.interactive-result-code-block, +.interactive-result-code-block .monaco-editor, +.interactive-result-code-block .monaco-editor .overflow-guard { + border-radius: 4px; +} + +.interactive-result-code-block .interactive-result-vulns { + font-size: 0.9em; + padding: 0px 8px 2px 8px; +} + +.interactive-result-code-block .interactive-result-vulns-header { + display: flex; + height: 22px; +} + +.interactive-result-code-block .interactive-result-vulns-header, +.interactive-result-code-block .interactive-result-vulns-list { + opacity: 0.8; +} + +.interactive-result-code-block .interactive-result-vulns-list { + margin: 0px; + padding-bottom: 3px; + padding-left: 16px !important; /* Override markdown styles */ +} + +.interactive-result-code-block.chat-vulnerabilities-collapsed .interactive-result-vulns-list { + display: none; +} + +.interactive-result-code-block .interactive-result-vulns-list .chat-vuln-title { + font-weight: bold; +} + +.interactive-result-code-block.no-vulns .interactive-result-vulns { + display: none; +} + +.interactive-result-code-block .interactive-result-vulns-header .monaco-button { + /* unset Button styles */ + display: inline-flex; + width: 100%; + border: none; + padding: 0; + text-align: initial; + justify-content: initial; + color: var(--vscode-foreground) !important; /* This is inside .rendered-markdown */ + user-select: none; +} + +.interactive-result-code-block .interactive-result-vulns-header .monaco-text-button:focus { + outline: none; +} + +.interactive-result-code-block .interactive-result-vulns-header .monaco-text-button:focus-visible { + outline: 1px solid var(--vscode-focusBorder); +} + +/* compare code block */ + +.interactive-result-code-block.compare.no-diff .message { + display: inherit; +} + +.interactive-result-code-block.compare .message { + display: none; + padding: 6px; +} + + +.interactive-result-code-block.compare .message A { + color: var(--vscode-textLink-foreground); + cursor: pointer; +} + +.interactive-result-code-block.compare .message A > CODE { + color: var(--vscode-textLink-foreground); +} + +.interactive-result-code-block.compare .interactive-result-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 3px; + box-sizing: border-box; + border-bottom: solid 1px var(--vscode-chat-requestBorder); +} + +.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 new file mode 100644 index 00000000000..3190c80ee1a --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/codeBlockPart.ts @@ -0,0 +1,936 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './codeBlockPart.css'; + +import * as dom from '../../../../base/browser/dom.js'; +import { renderFormattedText } from '../../../../base/browser/formattedTextRenderer.js'; +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 { 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'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { DiffEditorWidget } from '../../../../editor/browser/widget/diffEditor/diffEditorWidget.js'; +import { EDITOR_FONT_DEFAULTS, EditorOption, IEditorOptions } from '../../../../editor/common/config/editorOptions.js'; +import { IRange, Range } from '../../../../editor/common/core/range.js'; +import { ScrollType } from '../../../../editor/common/editorCommon.js'; +import { TextEdit } from '../../../../editor/common/languages.js'; +import { EndOfLinePreference, ITextModel } from '../../../../editor/common/model.js'; +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 { 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'; +import { GotoDefinitionAtPositionEditorContribution } from '../../../../editor/contrib/gotoSymbol/browser/link/goToDefinitionAtPosition.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 { MessageController } from '../../../../editor/contrib/message/browser/messageController.js'; +import { ViewportSemanticTokensContribution } from '../../../../editor/contrib/semanticTokens/browser/viewportSemanticTokens.js'; +import { SmartSelectController } from '../../../../editor/contrib/smartSelect/browser/smartSelect.js'; +import { WordHighlighterContribution } from '../../../../editor/contrib/wordHighlighter/browser/wordHighlighter.js'; +import { localize } from '../../../../nls.js'; +import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; +import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.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 { IDialogService } from '../../../../platform/dialogs/common/dialogs.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 { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { ResourceLabel } from '../../../browser/labels.js'; +import { ResourceContextKey } from '../../../common/contextkeys.js'; +import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; +import { InspectEditorTokensController } from '../../codeEditor/browser/inspectEditorTokens/inspectEditorTokens.js'; +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 { ChatTreeItem } from './aideAgent.js'; +import { IChatRendererDelegate } from './aideAgentListRenderer.js'; +import { ChatEditorOptions } from './aideAgentOptions.js'; + +const $ = dom.$; + +export interface ICodeBlockData { + readonly codeBlockIndex: number; + readonly element: unknown; + + readonly textModel: Promise; + readonly languageId: string; + + readonly codemapperUri?: URI; + + readonly vulns?: readonly IMarkdownVulnerability[]; + readonly range?: Range; + + readonly parentContextKeyService?: IContextKeyService; + readonly hideToolbar?: boolean; +} + +/** + * Special markdown code block language id used to render a local file. + * + * The text of the code path should be a {@link LocalFileCodeBlockData} json object. + */ +export const localFileLanguageId = 'vscode-local-file'; + + +export function parseLocalFileData(text: string) { + + interface RawLocalFileCodeBlockData { + readonly uri: UriComponents; + readonly range?: IRange; + } + + let data: RawLocalFileCodeBlockData; + try { + data = JSON.parse(text); + } catch (e) { + throw new Error('Could not parse code block local file data'); + } + + let uri: URI; + try { + uri = URI.revive(data?.uri); + } catch (e) { + throw new Error('Invalid code block local file data URI'); + } + + let range: IRange | undefined; + if (data.range) { + // Note that since this is coming from extensions, position are actually zero based and must be converted. + range = new Range(data.range.startLineNumber + 1, data.range.startColumn + 1, data.range.endLineNumber + 1, data.range.endColumn + 1); + } + + return { uri, range }; +} + +export interface ICodeBlockActionContext { + code: string; + codemapperUri?: URI; + languageId?: string; + codeBlockIndex: number; + element: unknown; +} + +const defaultCodeblockPadding = 10; +export class CodeBlockPart extends Disposable { + protected readonly _onDidChangeContentHeight = this._register(new Emitter()); + public readonly onDidChangeContentHeight = this._onDidChangeContentHeight.event; + + public readonly editor: CodeEditorWidget; + protected readonly toolbar: MenuWorkbenchToolBar; + private readonly contextKeyService: IContextKeyService; + + public readonly element: HTMLElement; + + private readonly vulnsButton: Button; + private readonly vulnsListElement: HTMLElement; + + private currentCodeBlockData: ICodeBlockData | undefined; + private currentScrollWidth = 0; + + private readonly disposableStore = this._register(new DisposableStore()); + private isDisposed = false; + + private resourceContextKey: ResourceContextKey; + + constructor( + private readonly options: ChatEditorOptions, + readonly menuId: MenuId, + delegate: IChatRendererDelegate, + overflowWidgetsDomNode: HTMLElement | undefined, + @IInstantiationService instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IModelService protected readonly modelService: IModelService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + ) { + super(); + this.element = $('.interactive-result-code-block'); + + this.resourceContextKey = this._register(instantiationService.createInstance(ResourceContextKey)); + this.contextKeyService = this._register(contextKeyService.createScoped(this.element)); + const scopedInstantiationService = this._register(instantiationService.createChild(new ServiceCollection([IContextKeyService, this.contextKeyService]))); + const editorElement = dom.append(this.element, $('.interactive-result-editor')); + this.editor = this.createEditor(scopedInstantiationService, editorElement, { + ...getSimpleEditorOptions(this.configurationService), + readOnly: true, + lineNumbers: 'off', + selectOnLineNumbers: true, + scrollBeyondLastLine: false, + lineDecorationsWidth: 8, + dragAndDrop: false, + padding: { top: defaultCodeblockPadding, bottom: defaultCodeblockPadding }, + mouseWheelZoom: false, + scrollbar: { + vertical: 'hidden', + alwaysConsumeMouseWheel: false + }, + definitionLinkOpensInPeek: false, + gotoLocation: { + multiple: 'goto', + multipleDeclarations: 'goto', + multipleDefinitions: 'goto', + multipleImplementations: 'goto', + }, + ariaLabel: localize('chat.codeBlockHelp', 'Code block'), + overflowWidgetsDomNode, + ...this.getEditorOptionsFromConfig(), + }); + + const toolbarElement = dom.append(this.element, $('.interactive-result-code-block-toolbar')); + const editorScopedService = this.editor.contextKeyService.createScoped(toolbarElement); + const editorScopedInstantiationService = this._register(scopedInstantiationService.createChild(new ServiceCollection([IContextKeyService, editorScopedService]))); + this.toolbar = this._register(editorScopedInstantiationService.createInstance(MenuWorkbenchToolBar, toolbarElement, menuId, { + menuOptions: { + shouldForwardArgs: true + } + })); + + const vulnsContainer = dom.append(this.element, $('.interactive-result-vulns')); + const vulnsHeaderElement = dom.append(vulnsContainer, $('.interactive-result-vulns-header', undefined)); + this.vulnsButton = this._register(new Button(vulnsHeaderElement, { + buttonBackground: undefined, + buttonBorder: undefined, + buttonForeground: undefined, + buttonHoverBackground: undefined, + buttonSecondaryBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryHoverBackground: undefined, + buttonSeparator: undefined, + supportIcons: true + })); + + this.vulnsListElement = dom.append(vulnsContainer, $('ul.interactive-result-vulns-list')); + + this._register(this.vulnsButton.onDidClick(() => { + const element = this.currentCodeBlockData!.element as IChatResponseViewModel; + element.vulnerabilitiesListExpanded = !element.vulnerabilitiesListExpanded; + this.vulnsButton.label = this.getVulnerabilitiesLabel(); + this.element.classList.toggle('chat-vulnerabilities-collapsed', !element.vulnerabilitiesListExpanded); + this._onDidChangeContentHeight.fire(); + // this.updateAriaLabel(collapseButton.element, referencesLabel, element.usedReferencesExpanded); + })); + + this._register(this.toolbar.onDidChangeDropdownVisibility(e => { + toolbarElement.classList.toggle('force-visibility', e); + })); + + this._configureForScreenReader(); + this._register(this.accessibilityService.onDidChangeScreenReaderOptimized(() => this._configureForScreenReader())); + this._register(this.configurationService.onDidChangeConfiguration((e) => { + if (e.affectedKeys.has(AccessibilityVerbositySettingId.Chat)) { + this._configureForScreenReader(); + } + })); + + this._register(this.options.onDidChange(() => { + this.editor.updateOptions(this.getEditorOptionsFromConfig()); + })); + + this._register(this.editor.onDidScrollChange(e => { + this.currentScrollWidth = e.scrollWidth; + })); + this._register(this.editor.onDidContentSizeChange(e => { + if (e.contentHeightChanged) { + this._onDidChangeContentHeight.fire(); + } + })); + this._register(this.editor.onDidBlurEditorWidget(() => { + this.element.classList.remove('focused'); + WordHighlighterContribution.get(this.editor)?.stopHighlighting(); + this.clearWidgets(); + })); + this._register(this.editor.onDidFocusEditorWidget(() => { + this.element.classList.add('focused'); + WordHighlighterContribution.get(this.editor)?.restoreViewState(true); + })); + + // Parent list scrolled + if (delegate.onDidScroll) { + this._register(delegate.onDidScroll(e => { + this.clearWidgets(); + })); + } + } + + override dispose() { + this.isDisposed = true; + super.dispose(); + } + + get uri(): URI | undefined { + return this.editor.getModel()?.uri; + } + + private createEditor(instantiationService: IInstantiationService, parent: HTMLElement, options: Readonly): CodeEditorWidget { + return this._register(instantiationService.createInstance(CodeEditorWidget, parent, options, { + isSimpleWidget: false, + contributions: EditorExtensionsRegistry.getSomeEditorContributions([ + MenuPreventer.ID, + SelectionClipboardContributionID, + ContextMenuController.ID, + + WordHighlighterContribution.ID, + ViewportSemanticTokensContribution.ID, + BracketMatchingController.ID, + SmartSelectController.ID, + ContentHoverController.ID, + GlyphHoverController.ID, + MessageController.ID, + GotoDefinitionAtPositionEditorContribution.ID, + ColorDetector.ID, + LinkDetector.ID, + + InspectEditorTokensController.ID, + ]) + })); + } + + focus(): void { + this.editor.focus(); + } + + private updatePaddingForLayout() { + // scrollWidth = "the width of the content that needs to be scrolled" + // contentWidth = "the width of the area where content is displayed" + const horizontalScrollbarVisible = this.currentScrollWidth > this.editor.getLayoutInfo().contentWidth; + const scrollbarHeight = this.editor.getLayoutInfo().horizontalScrollbarHeight; + const bottomPadding = horizontalScrollbarVisible ? + Math.max(defaultCodeblockPadding - scrollbarHeight, 2) : + defaultCodeblockPadding; + this.editor.updateOptions({ padding: { top: defaultCodeblockPadding, bottom: bottomPadding } }); + } + + private _configureForScreenReader(): void { + const toolbarElt = this.toolbar.getElement(); + if (this.accessibilityService.isScreenReaderOptimized()) { + toolbarElt.style.display = 'block'; + toolbarElt.ariaLabel = this.configurationService.getValue(AccessibilityVerbositySettingId.Chat) ? localize('chat.codeBlock.toolbarVerbose', 'Toolbar for code block which can be reached via tab') : localize('chat.codeBlock.toolbar', 'Code block toolbar'); + } else { + toolbarElt.style.display = ''; + } + } + + private getEditorOptionsFromConfig(): IEditorOptions { + return { + wordWrap: this.options.configuration.resultEditor.wordWrap, + fontLigatures: this.options.configuration.resultEditor.fontLigatures, + bracketPairColorization: this.options.configuration.resultEditor.bracketPairColorization, + fontFamily: this.options.configuration.resultEditor.fontFamily === 'default' ? + EDITOR_FONT_DEFAULTS.fontFamily : + this.options.configuration.resultEditor.fontFamily, + fontSize: this.options.configuration.resultEditor.fontSize, + fontWeight: this.options.configuration.resultEditor.fontWeight, + lineHeight: this.options.configuration.resultEditor.lineHeight, + }; + } + + layout(width: number): void { + const contentHeight = this.getContentHeight(); + const editorBorder = 2; + this.editor.layout({ width: width - editorBorder, height: contentHeight }); + this.updatePaddingForLayout(); + } + + private getContentHeight() { + if (this.currentCodeBlockData?.range) { + const lineCount = this.currentCodeBlockData.range.endLineNumber - this.currentCodeBlockData.range.startLineNumber + 1; + const lineHeight = this.editor.getOption(EditorOption.lineHeight); + return lineCount * lineHeight; + } + return this.editor.getContentHeight(); + } + + async render(data: ICodeBlockData, width: number, editable: boolean | undefined) { + this.currentCodeBlockData = data; + if (data.parentContextKeyService) { + this.contextKeyService.updateParent(data.parentContextKeyService); + } + + if (this.options.configuration.resultEditor.wordWrap === 'on') { + // Initialize the editor with the new proper width so that getContentHeight + // will be computed correctly in the next call to layout() + this.layout(width); + } + + await this.updateEditor(data); + if (this.isDisposed) { + return; + } + + 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 }); + + if (data.hideToolbar) { + dom.hide(this.toolbar.getElement()); + } else { + dom.show(this.toolbar.getElement()); + } + + if (data.vulns?.length && isResponseVM(data.element)) { + dom.clearNode(this.vulnsListElement); + this.element.classList.remove('no-vulns'); + this.element.classList.toggle('chat-vulnerabilities-collapsed', !data.element.vulnerabilitiesListExpanded); + dom.append(this.vulnsListElement, ...data.vulns.map(v => $('li', undefined, $('span.chat-vuln-title', undefined, v.title), ' ' + v.description))); + this.vulnsButton.label = this.getVulnerabilitiesLabel(); + } else { + this.element.classList.add('no-vulns'); + } + } + + reset() { + this.clearWidgets(); + } + + private clearWidgets() { + ContentHoverController.get(this.editor)?.hideContentHover(); + GlyphHoverController.get(this.editor)?.hideContentHover(); + } + + private async updateEditor(data: ICodeBlockData): Promise { + const textModel = (await data.textModel).textEditorModel; + this.editor.setModel(textModel); + if (data.range) { + this.editor.setSelection(data.range); + this.editor.revealRangeInCenter(data.range, ScrollType.Immediate); + } + + this.toolbar.context = { + code: textModel.getTextBuffer().getValueInRange(data.range ?? textModel.getFullModelRange(), EndOfLinePreference.TextDefined), + codeBlockIndex: data.codeBlockIndex, + element: data.element, + languageId: textModel.getLanguageId(), + codemapperUri: data.codemapperUri, + } satisfies ICodeBlockActionContext; + this.resourceContextKey.set(textModel.uri); + } + + private getVulnerabilitiesLabel(): string { + if (!this.currentCodeBlockData || !this.currentCodeBlockData.vulns) { + return ''; + } + + const referencesLabel = this.currentCodeBlockData.vulns.length > 1 ? + localize('vulnerabilitiesPlural', "{0} vulnerabilities", this.currentCodeBlockData.vulns.length) : + localize('vulnerabilitiesSingular', "{0} vulnerability", 1); + const icon = (element: IChatResponseViewModel) => element.vulnerabilitiesListExpanded ? Codicon.chevronDown : Codicon.chevronRight; + return `${referencesLabel} $(${icon(this.currentCodeBlockData.element as IChatResponseViewModel).id})`; + } +} + +export class ChatCodeBlockContentProvider extends Disposable implements ITextModelContentProvider { + + constructor( + @ITextModelService textModelService: ITextModelService, + @IModelService private readonly _modelService: IModelService, + ) { + super(); + this._register(textModelService.registerTextModelContentProvider(Schemas.vscodeAideAgentCodeBlock, this)); + } + + async provideTextContent(resource: URI): Promise { + const existing = this._modelService.getModel(resource); + if (existing) { + return existing; + } + return this._modelService.createModel('', null, resource); + } +} + +// + +export interface ICodeCompareBlockActionContext { + readonly element: IChatResponseViewModel; + readonly diffEditor: IDiffEditor; + readonly edit: IChatTextEditGroup; +} + +export interface ICodeCompareBlockDiffData { + modified: ITextModel; + original: ITextModel; + originalSha1: string; +} + +export interface ICodeCompareBlockData { + readonly element: ChatTreeItem; + + readonly edit: IChatTextEditGroup; + + readonly diffData: Promise; + + readonly parentContextKeyService?: IContextKeyService; + // readonly hideToolbar?: boolean; +} + + +// long-lived object that sits in the DiffPool and that gets reused +export class CodeCompareBlockPart extends Disposable { + protected readonly _onDidChangeContentHeight = this._register(new Emitter()); + public readonly onDidChangeContentHeight = this._onDidChangeContentHeight.event; + + private readonly contextKeyService: IContextKeyService; + private readonly diffEditor: DiffEditorWidget; + private readonly resourceLabel: ResourceLabel; + private readonly toolbar: MenuWorkbenchToolBar; + readonly element: HTMLElement; + private readonly messageElement: HTMLElement; + + private readonly _lastDiffEditorViewModel = this._store.add(new MutableDisposable()); + private currentScrollWidth = 0; + + constructor( + private readonly options: ChatEditorOptions, + readonly menuId: MenuId, + delegate: IChatRendererDelegate, + overflowWidgetsDomNode: HTMLElement | undefined, + @IInstantiationService instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IModelService protected readonly modelService: IModelService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + @ILabelService private readonly labelService: ILabelService, + @IOpenerService private readonly openerService: IOpenerService, + ) { + super(); + this.element = $('.interactive-result-code-block'); + this.element.classList.add('compare'); + + this.messageElement = dom.append(this.element, $('.message')); + this.messageElement.setAttribute('role', 'status'); + this.messageElement.tabIndex = 0; + + this.contextKeyService = this._register(contextKeyService.createScoped(this.element)); + const scopedInstantiationService = this._register(instantiationService.createChild(new ServiceCollection([IContextKeyService, this.contextKeyService]))); + 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, { + ...getSimpleEditorOptions(this.configurationService), + lineNumbers: 'on', + selectOnLineNumbers: true, + scrollBeyondLastLine: false, + lineDecorationsWidth: 12, + dragAndDrop: false, + padding: { top: defaultCodeblockPadding, bottom: defaultCodeblockPadding }, + mouseWheelZoom: false, + scrollbar: { + vertical: 'hidden', + alwaysConsumeMouseWheel: false + }, + definitionLinkOpensInPeek: false, + gotoLocation: { + multiple: 'goto', + multipleDeclarations: 'goto', + multipleDefinitions: 'goto', + multipleImplementations: 'goto', + }, + ariaLabel: localize('chat.codeBlockHelp', 'Code block'), + overflowWidgetsDomNode, + ...this.getEditorOptionsFromConfig(), + }); + + this.resourceLabel = this._register(scopedInstantiationService.createInstance(ResourceLabel, editorHeader, { supportIcons: true })); + + const editorScopedService = this.diffEditor.getModifiedEditor().contextKeyService.createScoped(editorHeader); + const editorScopedInstantiationService = this._register(scopedInstantiationService.createChild(new ServiceCollection([IContextKeyService, editorScopedService]))); + this.toolbar = this._register(editorScopedInstantiationService.createInstance(MenuWorkbenchToolBar, editorHeader, menuId, { + menuOptions: { + shouldForwardArgs: true + } + })); + + this._configureForScreenReader(); + this._register(this.accessibilityService.onDidChangeScreenReaderOptimized(() => this._configureForScreenReader())); + this._register(this.configurationService.onDidChangeConfiguration((e) => { + if (e.affectedKeys.has(AccessibilityVerbositySettingId.Chat)) { + this._configureForScreenReader(); + } + })); + + this._register(this.options.onDidChange(() => { + this.diffEditor.updateOptions(this.getEditorOptionsFromConfig()); + })); + + this._register(this.diffEditor.getModifiedEditor().onDidScrollChange(e => { + this.currentScrollWidth = e.scrollWidth; + })); + this._register(this.diffEditor.onDidContentSizeChange(e => { + if (e.contentHeightChanged) { + this._onDidChangeContentHeight.fire(); + } + })); + this._register(this.diffEditor.getModifiedEditor().onDidBlurEditorWidget(() => { + this.element.classList.remove('focused'); + WordHighlighterContribution.get(this.diffEditor.getModifiedEditor())?.stopHighlighting(); + this.clearWidgets(); + })); + this._register(this.diffEditor.getModifiedEditor().onDidFocusEditorWidget(() => { + this.element.classList.add('focused'); + WordHighlighterContribution.get(this.diffEditor.getModifiedEditor())?.restoreViewState(true); + })); + + + // Parent list scrolled + if (delegate.onDidScroll) { + this._register(delegate.onDidScroll(e => { + this.clearWidgets(); + })); + } + } + + get uri(): URI | undefined { + return this.diffEditor.getModifiedEditor().getModel()?.uri; + } + + private createDiffEditor(instantiationService: IInstantiationService, parent: HTMLElement, options: Readonly): DiffEditorWidget { + const widgetOptions: ICodeEditorWidgetOptions = { + isSimpleWidget: false, + contributions: EditorExtensionsRegistry.getSomeEditorContributions([ + MenuPreventer.ID, + SelectionClipboardContributionID, + ContextMenuController.ID, + + WordHighlighterContribution.ID, + ViewportSemanticTokensContribution.ID, + BracketMatchingController.ID, + SmartSelectController.ID, + ContentHoverController.ID, + GlyphHoverController.ID, + GotoDefinitionAtPositionEditorContribution.ID, + ]) + }; + + return this._register(instantiationService.createInstance(DiffEditorWidget, parent, { + scrollbar: { useShadows: false, alwaysConsumeMouseWheel: false, ignoreHorizontalScrollbarInContentHeight: true, }, + renderMarginRevertIcon: false, + diffCodeLens: false, + scrollBeyondLastLine: false, + stickyScroll: { enabled: false }, + originalAriaLabel: localize('original', 'Original'), + modifiedAriaLabel: localize('modified', 'Modified'), + diffAlgorithm: 'advanced', + readOnly: false, + isInEmbeddedEditor: true, + useInlineViewWhenSpaceIsLimited: true, + experimental: { + useTrueInlineView: true, + }, + renderSideBySideInlineBreakpoint: 300, + renderOverviewRuler: false, + compactMode: true, + hideUnchangedRegions: { enabled: true, contextLineCount: 1 }, + renderGutterMenu: false, + lineNumbersMinChars: 1, + ...options + }, { originalEditor: widgetOptions, modifiedEditor: widgetOptions })); + } + + focus(): void { + this.diffEditor.focus(); + } + + private updatePaddingForLayout() { + // scrollWidth = "the width of the content that needs to be scrolled" + // contentWidth = "the width of the area where content is displayed" + const horizontalScrollbarVisible = this.currentScrollWidth > this.diffEditor.getModifiedEditor().getLayoutInfo().contentWidth; + const scrollbarHeight = this.diffEditor.getModifiedEditor().getLayoutInfo().horizontalScrollbarHeight; + const bottomPadding = horizontalScrollbarVisible ? + Math.max(defaultCodeblockPadding - scrollbarHeight, 2) : + defaultCodeblockPadding; + this.diffEditor.updateOptions({ padding: { top: defaultCodeblockPadding, bottom: bottomPadding } }); + } + + private _configureForScreenReader(): void { + const toolbarElt = this.toolbar.getElement(); + if (this.accessibilityService.isScreenReaderOptimized()) { + toolbarElt.style.display = 'block'; + toolbarElt.ariaLabel = this.configurationService.getValue(AccessibilityVerbositySettingId.Chat) ? localize('chat.codeBlock.toolbarVerbose', 'Toolbar for code block which can be reached via tab') : localize('chat.codeBlock.toolbar', 'Code block toolbar'); + } else { + toolbarElt.style.display = ''; + } + } + + private getEditorOptionsFromConfig(): IEditorOptions { + return { + wordWrap: this.options.configuration.resultEditor.wordWrap, + fontLigatures: this.options.configuration.resultEditor.fontLigatures, + bracketPairColorization: this.options.configuration.resultEditor.bracketPairColorization, + fontFamily: this.options.configuration.resultEditor.fontFamily === 'default' ? + EDITOR_FONT_DEFAULTS.fontFamily : + this.options.configuration.resultEditor.fontFamily, + fontSize: this.options.configuration.resultEditor.fontSize, + fontWeight: this.options.configuration.resultEditor.fontWeight, + lineHeight: this.options.configuration.resultEditor.lineHeight, + }; + } + + layout(width: number): void { + const contentHeight = this.getContentHeight(); + const editorBorder = 2; + const dimension = { width: width - editorBorder, height: contentHeight }; + this.element.style.height = `${dimension.height}px`; + this.element.style.width = `${dimension.width}px`; + this.diffEditor.layout(dimension); + 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) { + this.contextKeyService.updateParent(data.parentContextKeyService); + } + + if (this.options.configuration.resultEditor.wordWrap === 'on') { + // Initialize the editor with the new proper width so that getContentHeight + // will be computed correctly in the next call to layout() + this.layout(width); + } + + await this.updateEditor(data, token); + + this.layout(width); + this.diffEditor.updateOptions({ ariaLabel: localize('chat.compareCodeBlockLabel', "Code Edits") }); + + this.resourceLabel.element.setFile(data.edit.uri, { + fileKind: FileKind.FILE, + fileDecorations: { colors: true, badges: false } + }); + } + + reset() { + this.clearWidgets(); + } + + private clearWidgets() { + ContentHoverController.get(this.diffEditor.getOriginalEditor())?.hideContentHover(); + ContentHoverController.get(this.diffEditor.getModifiedEditor())?.hideContentHover(); + GlyphHoverController.get(this.diffEditor.getOriginalEditor())?.hideContentHover(); + GlyphHoverController.get(this.diffEditor.getModifiedEditor())?.hideContentHover(); + } + + private async updateEditor(data: ICodeCompareBlockData, token: CancellationToken): Promise { + + if (!isResponseVM(data.element)) { + return; + } + + const isEditApplied = Boolean(data.edit.state?.applied ?? 0); + + CONTEXT_CHAT_EDIT_APPLIED.bindTo(this.contextKeyService).set(isEditApplied); + + this.element.classList.toggle('no-diff', isEditApplied); + + if (isEditApplied) { + assertType(data.edit.state?.applied); + + const uriLabel = this.labelService.getUriLabel(data.edit.uri, { relative: true, noPrefix: true }); + + let template: string; + if (data.edit.state.applied === 1) { + template = localize('chat.edits.1', "Made 1 change in [[``{0}``]]", uriLabel); + } else if (data.edit.state.applied < 0) { + template = localize('chat.edits.rejected', "Edits in [[``{0}``]] have been rejected", uriLabel); + } else { + template = localize('chat.edits.N', "Made {0} changes in [[``{1}``]]", data.edit.state.applied, uriLabel); + } + + const message = renderFormattedText(template, { + renderCodeSegments: true, + actionHandler: { + callback: () => { + this.openerService.open(data.edit.uri, { fromUserGesture: true, allowCommands: false }); + }, + disposables: this._store, + } + }); + + dom.reset(this.messageElement, message); + } + + const diffData = await data.diffData; + + if (!isEditApplied && diffData) { + const viewModel = this.diffEditor.createViewModel({ + original: diffData.original, + modified: diffData.modified + }); + + await viewModel.waitForDiff(); + + if (token.isCancellationRequested) { + return; + } + + const listener = Event.any(diffData.original.onWillDispose, diffData.modified.onWillDispose)(() => { + // this a bit weird and basically duplicates https://github.com/microsoft/vscode/blob/7cbcafcbcc88298cfdcd0238018fbbba8eb6853e/src/vs/editor/browser/widget/diffEditor/diffEditorWidget.ts#L328 + // which cannot call `setModel(null)` without first complaining + this.diffEditor.setModel(null); + }); + this.diffEditor.setModel(viewModel); + this._lastDiffEditorViewModel.value = combinedDisposable(listener, viewModel); + + } else { + this.diffEditor.setModel(null); + this._lastDiffEditorViewModel.value = undefined; + this._onDidChangeContentHeight.fire(); + } + + this.toolbar.context = { + edit: data.edit, + element: data.element, + diffEditor: this.diffEditor, + } satisfies ICodeCompareBlockActionContext; + } +} + +export class DefaultChatTextEditor { + + private readonly _sha1 = new DefaultModelSHA1Computer(); + + constructor( + @ITextModelService private readonly modelService: ITextModelService, + @ICodeEditorService private readonly editorService: ICodeEditorService, + @IDialogService private readonly dialogService: IDialogService, + ) { } + + async apply(response: IChatResponseModel | IChatResponseViewModel, item: IChatTextEditGroup, diffEditor: IDiffEditor | undefined): Promise { + + if (!response.response.value.includes(item)) { + // bogous item + return; + } + + if (item.state?.applied) { + // already applied + return; + } + + if (!diffEditor) { + for (const candidate of this.editorService.listDiffEditors()) { + if (!candidate.getContainerDomNode().isConnected) { + continue; + } + const model = candidate.getModel(); + if (!model || !isEqual(model.original.uri, item.uri) || model.modified.uri.scheme !== Schemas.vscodeAideAgentCodeCompareBlock) { + diffEditor = candidate; + break; + } + } + } + + const edits = diffEditor + ? await this._applyWithDiffEditor(diffEditor, item) + : await this._apply(item); + + response.setEditApplied(item, edits); + } + + private async _applyWithDiffEditor(diffEditor: IDiffEditor, item: IChatTextEditGroup) { + const model = diffEditor.getModel(); + if (!model) { + return 0; + } + + const diff = diffEditor.getDiffComputationResult(); + if (!diff || diff.identical) { + return 0; + } + + + if (!await this._checkSha1(model.original, item)) { + return 0; + } + + const modified = new TextModelText(model.modified); + const edits = diff.changes2.map(i => i.toRangeMapping().toTextEdit(modified).toSingleEditOperation()); + + model.original.pushStackElement(); + model.original.pushEditOperations(null, edits, () => null); + model.original.pushStackElement(); + + return edits.length; + } + + private async _apply(item: IChatTextEditGroup) { + const ref = await this.modelService.createModelReference(item.uri); + try { + + if (!await this._checkSha1(ref.object.textEditorModel, item)) { + return 0; + } + + ref.object.textEditorModel.pushStackElement(); + let total = 0; + for (const group of item.edits) { + const edits = group.map(TextEdit.asEditOperation); + ref.object.textEditorModel.pushEditOperations(null, edits, () => null); + total += edits.length; + } + ref.object.textEditorModel.pushStackElement(); + return total; + + } finally { + ref.dispose(); + } + } + + private async _checkSha1(model: ITextModel, item: IChatTextEditGroup) { + if (item.state?.sha1 && this._sha1.computeSHA1(model) && this._sha1.computeSHA1(model) !== item.state.sha1) { + const result = await this.dialogService.confirm({ + message: localize('interactive.compare.apply.confirm', "The original file has been modified."), + detail: localize('interactive.compare.apply.confirm.detail', "Do you want to apply the changes anyway?"), + }); + + if (!result.confirmed) { + return false; + } + } + return true; + } + + discard(response: IChatResponseModel | IChatResponseViewModel, item: IChatTextEditGroup) { + if (!response.response.value.includes(item)) { + // bogous item + return; + } + + if (item.state?.applied) { + // already applied + return; + } + + response.setEditApplied(item, -1); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/contrib/aideAgentContextAttachments.ts b/src/vs/workbench/contrib/aideAgent/browser/contrib/aideAgentContextAttachments.ts new file mode 100644 index 00000000000..ab82fc61167 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/contrib/aideAgentContextAttachments.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IChatWidget } from '../aideAgent.js'; +import { ChatWidget, IChatWidgetContrib } from '../aideAgentWidget.js'; +import { IChatRequestVariableEntry } from '../../common/aideAgentModel.js'; + +export class ChatContextAttachments extends Disposable implements IChatWidgetContrib { + + private _attachedContext = new Set(); + + public static readonly ID = 'chatContextAttachments'; + + get id() { + return ChatContextAttachments.ID; + } + + constructor(readonly widget: IChatWidget) { + super(); + + this._register(this.widget.onDidChangeContext((e) => { + if (e.removed) { + this._removeContext(e.removed); + } + })); + + this._register(this.widget.onDidSubmitAgent(() => { + this._clearAttachedContext(); + })); + } + + getInputState(): IChatRequestVariableEntry[] { + return [...this._attachedContext.values()]; + } + + setInputState(s: any): void { + if (!Array.isArray(s)) { + s = []; + } + + this._attachedContext.clear(); + for (const attachment of s) { + this._attachedContext.add(attachment); + } + + this.widget.setContext(true, ...s); + } + + getContext() { + return new Set([...this._attachedContext.values()].map((v) => v.id)); + } + + setContext(overwrite: boolean, ...attachments: IChatRequestVariableEntry[]) { + if (overwrite) { + this._attachedContext.clear(); + } + for (const attachment of attachments) { + this._attachedContext.add(attachment); + } + + this.widget.setContext(overwrite, ...attachments); + } + + private _removeContext(attachments: IChatRequestVariableEntry[]) { + if (attachments.length) { + attachments.forEach(this._attachedContext.delete, this._attachedContext); + } + } + + private _clearAttachedContext() { + this._attachedContext.clear(); + } +} + +ChatWidget.CONTRIBS.push(ChatContextAttachments); diff --git a/src/vs/workbench/contrib/aideAgent/browser/contrib/aideAgentDynamicVariables.ts b/src/vs/workbench/contrib/aideAgent/browser/contrib/aideAgentDynamicVariables.ts new file mode 100644 index 00000000000..e0db800dcbb --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/contrib/aideAgentDynamicVariables.ts @@ -0,0 +1,184 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { coalesce } from '../../../../../base/common/arrays.js'; +import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IRange, Range } from '../../../../../editor/common/core/range.js'; +import { IDecorationOptions } from '../../../../../editor/common/editorCommon.js'; +import { Command } from '../../../../../editor/common/languages.js'; +import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IChatRequestVariableValue, IDynamicVariable } from '../../common/aideAgentVariables.js'; +import { IChatWidget } from '../aideAgent.js'; +import { ChatWidget, IChatWidgetContrib } from '../aideAgentWidget.js'; + +export const dynamicVariableDecorationType = 'chat-dynamic-variable'; + +export const FileReferenceCompletionProviderName = 'chatInplaceFileReferenceCompletionProvider'; +export const CodeSymbolCompletionProviderName = 'chatInplaceCodeCompletionProvider'; + +export class ChatDynamicVariableModel extends Disposable implements IChatWidgetContrib { + public static readonly ID = 'chatDynamicVariableModel'; + + private _variables: IDynamicVariable[] = []; + get variables(): ReadonlyArray { + return [...this._variables]; + } + + get id() { + return ChatDynamicVariableModel.ID; + } + + constructor( + private readonly widget: IChatWidget, + @ILabelService private readonly labelService: ILabelService, + ) { + super(); + this._register(widget.inputEditor.onDidChangeModelContent(e => { + e.changes.forEach(c => { + // Don't mutate entries in _variables, since they will be returned from the getter + this._variables = coalesce(this._variables.map(ref => { + const intersection = Range.intersectRanges(ref.range, c.range); + if (intersection && !intersection.isEmpty()) { + // The reference text was changed, it's broken. + // But if the whole reference range was deleted (eg history navigation) then don't try to change the editor. + if (!Range.containsRange(c.range, ref.range)) { + const rangeToDelete = new Range(ref.range.startLineNumber, ref.range.startColumn, ref.range.endLineNumber, ref.range.endColumn - 1); + this.widget.inputEditor.executeEdits(this.id, [{ + range: rangeToDelete, + text: '', + }]); + } + return null; + } else if (Range.compareRangesUsingStarts(ref.range, c.range) > 0) { + const delta = c.text.length - c.rangeLength; + return { + ...ref, + range: { + startLineNumber: ref.range.startLineNumber, + startColumn: ref.range.startColumn + delta, + endLineNumber: ref.range.endLineNumber, + endColumn: ref.range.endColumn + delta + } + }; + } + + return ref; + })); + }); + + this.updateDecorations(); + })); + } + + getInputState(): any { + return this.variables; + } + + setInputState(s: any): void { + if (!Array.isArray(s)) { + s = []; + } + + this._variables = s; + this.updateDecorations(); + } + + addReference(ref: IDynamicVariable): void { + this._variables.push(ref); + this.updateDecorations(); + } + + private updateDecorations(): void { + this.widget.inputEditor.setDecorationsByType('chat', dynamicVariableDecorationType, this._variables.map((r): IDecorationOptions => ({ + range: r.range, + hoverMessage: this.getHoverForReference(r) + }))); + } + + private getHoverForReference(ref: IDynamicVariable): IMarkdownString | undefined { + const value = ref.data; + if (URI.isUri(value)) { + return new MarkdownString(this.labelService.getUriLabel(value, { relative: true })); + } else { + return undefined; + } + } +} + +ChatWidget.CONTRIBS.push(ChatDynamicVariableModel); + +export interface IAddDynamicVariableContext { + id: string; + widget: IChatWidget; + range: IRange; + variableData: IChatRequestVariableValue; + command?: Command; +} + +function isAddDynamicVariableContext(context: any): context is IAddDynamicVariableContext { + return 'widget' in context && + 'range' in context && + 'variableData' in context; +} + +export class AddDynamicVariableAction extends Action2 { + static readonly ID = 'workbench.action.aideAgent.addDynamicVariable'; + + constructor() { + super({ + id: AddDynamicVariableAction.ID, + title: '' // not displayed + }); + } + + async run(accessor: ServicesAccessor, ...args: any[]) { + const context = args[0]; + if (!isAddDynamicVariableContext(context)) { + return; + } + + let range = context.range; + const variableData = context.variableData; + + const doCleanup = () => { + // Failed, remove the dangling variable prefix + context.widget.inputEditor.executeEdits('chatInsertDynamicVariableWithArguments', [{ range: context.range, text: `` }]); + }; + + // If this completion item has no command, return it directly + if (context.command) { + // Invoke the command on this completion item along with its args and return the result + const commandService = accessor.get(ICommandService); + const selection: string | undefined = await commandService.executeCommand(context.command.id, ...(context.command.arguments ?? [])); + if (!selection) { + doCleanup(); + return; + } + + // Compute new range and variableData + const insertText = ':' + selection; + const insertRange = new Range(range.startLineNumber, range.endColumn, range.endLineNumber, range.endColumn + insertText.length); + range = new Range(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn + insertText.length); + const editor = context.widget.inputEditor; + const success = editor.executeEdits('chatInsertDynamicVariableWithArguments', [{ range: insertRange, text: insertText + ' ' }]); + if (!success) { + doCleanup(); + return; + } + } + + context.widget.getContrib(ChatDynamicVariableModel.ID)?.addReference({ + id: context.id, + range: range, + data: variableData + }); + } +} +registerAction2(AddDynamicVariableAction); diff --git a/src/vs/workbench/contrib/aideAgent/browser/contrib/aideAgentInputCompletions.ts b/src/vs/workbench/contrib/aideAgent/browser/contrib/aideAgentInputCompletions.ts new file mode 100644 index 00000000000..2cd04be5ad8 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/contrib/aideAgentInputCompletions.ts @@ -0,0 +1,742 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { isPatternInWord } from '../../../../../base/common/filters.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { ResourceSet } from '../../../../../base/common/map.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { Position } from '../../../../../editor/common/core/position.js'; +import { IRange, Range } from '../../../../../editor/common/core/range.js'; +import { IWordAtPosition, getWordAtText } from '../../../../../editor/common/core/wordHelper.js'; +import { CompletionContext, CompletionItem, CompletionItemKind, CompletionList, CompletionTriggerKind } from '../../../../../editor/common/languages.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; +import { SuggestController } from '../../../../../editor/contrib/suggest/browser/suggestController.js'; +import { localize } from '../../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from '../../../../common/contributions.js'; +import { IHistoryService } from '../../../../services/history/common/history.js'; +import { LifecyclePhase } from '../../../../services/lifecycle/common/lifecycle.js'; +import { QueryBuilder } from '../../../../services/search/common/queryBuilder.js'; +import { IFileMatch, ISearchService } from '../../../../services/search/common/search.js'; +import { ISymbolQuickPickItem, SymbolsQuickAccessProvider } from '../../../search/browser/symbolsQuickAccess.js'; +import { ChatAgentLocation, IAideAgentAgentNameService, IAideAgentAgentService, IChatAgentData, getFullyQualifiedId } from '../../common/aideAgentAgents.js'; +import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestTextPart, ChatRequestToolPart, ChatRequestVariablePart, chatAgentLeader, chatSubcommandLeader, chatVariableLeader } from '../../common/aideAgentParserTypes.js'; +import { IAideAgentSlashCommandService } from '../../common/aideAgentSlashCommands.js'; +import { IAideAgentVariablesService, IDynamicVariable } from '../../common/aideAgentVariables.js'; +import { IAideAgentLMToolsService } from '../../common/languageModelToolsService.js'; +import { SubmitAction } from '../actions/aideAgentExecuteActions.js'; +import { IAideAgentWidgetService, IChatWidget } from '../aideAgent.js'; +import { ChatInputPart } from '../aideAgentInputPart.js'; +import { IChatWidgetCompletionContext } from '../aideAgentWidget.js'; +import { ChatDynamicVariableModel } from './aideAgentDynamicVariables.js'; + +const chatDynamicCompletions = 'chatDynamicCompletions'; + +class SlashCommandCompletions extends Disposable { + constructor( + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @IAideAgentWidgetService private readonly chatWidgetService: IAideAgentWidgetService, + @IAideAgentSlashCommandService private readonly chatSlashCommandService: IAideAgentSlashCommandService + ) { + super(); + + this._register(this.languageFeaturesService.completionProvider.register({ scheme: ChatInputPart.INPUT_SCHEME, hasAccessToAllModels: true }, { + _debugDisplayName: 'globalSlashCommands', + triggerCharacters: [chatSubcommandLeader], + provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { + const widget = this.chatWidgetService.getWidgetByInputUri(model.uri); + if (!widget || !widget.viewModel) { + return null; + } + + const range = computeCompletionRanges(model, position, /\/\w*/g); + if (!range) { + return null; + } + + const parsedRequest = widget.parsedInput.parts; + const usedAgent = parsedRequest.find(p => p instanceof ChatRequestAgentPart); + if (usedAgent) { + // No (classic) global slash commands when an agent is used + return; + } + + const slashCommands = this.chatSlashCommandService.getCommands(widget.location); + if (!slashCommands) { + return null; + } + + return { + suggestions: slashCommands.map((c, i): CompletionItem => { + const withSlash = `/${c.command}`; + return { + label: withSlash, + insertText: c.executeImmediately ? '' : `${withSlash} `, + detail: c.detail, + range: new Range(1, 1, 1, 1), + sortText: c.sortText ?? 'a'.repeat(i + 1), + kind: CompletionItemKind.Text, // The icons are disabled here anyway, + command: c.executeImmediately ? { id: SubmitAction.ID, title: withSlash, arguments: [{ widget, inputValue: `${withSlash} ` }] } : undefined, + }; + }) + }; + } + })); + } +} + +Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(SlashCommandCompletions, LifecyclePhase.Eventually); + +class AgentCompletions extends Disposable { + constructor( + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @IAideAgentWidgetService private readonly chatWidgetService: IAideAgentWidgetService, + @IAideAgentAgentService private readonly chatAgentService: IAideAgentAgentService, + @IAideAgentAgentNameService private readonly chatAgentNameService: IAideAgentAgentNameService, + ) { + super(); + + this._register(this.languageFeaturesService.completionProvider.register({ scheme: ChatInputPart.INPUT_SCHEME, hasAccessToAllModels: true }, { + _debugDisplayName: 'chatAgent', + triggerCharacters: [chatAgentLeader], + provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { + const widget = this.chatWidgetService.getWidgetByInputUri(model.uri); + if (!widget || !widget.viewModel) { + return null; + } + + const parsedRequest = widget.parsedInput.parts; + const usedAgent = parsedRequest.find(p => p instanceof ChatRequestAgentPart); + if (usedAgent && !Range.containsPosition(usedAgent.editorRange, position)) { + // Only one agent allowed + return; + } + + const range = computeCompletionRanges(model, position, /@\w*/g); + if (!range) { + return null; + } + + const agents = this.chatAgentService.getAgents() + .filter(a => !a.isDefault) + .filter(a => a.locations.includes(widget.location)); + + return { + suggestions: agents.map((agent, i): CompletionItem => { + const { label: agentLabel, isDupe } = this.getAgentCompletionDetails(agent); + return { + // Leading space is important because detail has no space at the start by design + label: isDupe ? + { label: agentLabel, description: agent.description, detail: ` (${agent.publisherDisplayName})` } : + agentLabel, + insertText: `${agentLabel} `, + detail: agent.description, + range: new Range(1, 1, 1, 1), + command: { id: AssignSelectedAgentAction.ID, title: AssignSelectedAgentAction.ID, arguments: [{ agent: agent, widget } satisfies AssignSelectedAgentActionArgs] }, + kind: CompletionItemKind.Text, // The icons are disabled here anyway + }; + }) + }; + } + })); + + this._register(this.languageFeaturesService.completionProvider.register({ scheme: ChatInputPart.INPUT_SCHEME, hasAccessToAllModels: true }, { + _debugDisplayName: 'chatAgentSubcommand', + triggerCharacters: [chatSubcommandLeader], + provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, token: CancellationToken) => { + const widget = this.chatWidgetService.getWidgetByInputUri(model.uri); + if (!widget || !widget.viewModel) { + return; + } + + const range = computeCompletionRanges(model, position, /\/\w*/g); + if (!range) { + return null; + } + + const parsedRequest = widget.parsedInput.parts; + const usedAgentIdx = parsedRequest.findIndex((p): p is ChatRequestAgentPart => p instanceof ChatRequestAgentPart); + if (usedAgentIdx < 0) { + return; + } + + const usedSubcommand = parsedRequest.find(p => p instanceof ChatRequestAgentSubcommandPart); + if (usedSubcommand) { + // Only one allowed + return; + } + + for (const partAfterAgent of parsedRequest.slice(usedAgentIdx + 1)) { + // Could allow text after 'position' + if (!(partAfterAgent instanceof ChatRequestTextPart) || !partAfterAgent.text.trim().match(/^(\/\w*)?$/)) { + // No text allowed between agent and subcommand + return; + } + } + + const usedAgent = parsedRequest[usedAgentIdx] as ChatRequestAgentPart; + return { + suggestions: usedAgent.agent.slashCommands.map((c, i): CompletionItem => { + const withSlash = `/${c.name}`; + return { + label: withSlash, + insertText: `${withSlash} `, + detail: c.description, + range, + kind: CompletionItemKind.Text, // The icons are disabled here anyway + }; + }) + }; + } + })); + + // list subcommands when the query is empty, insert agent+subcommand + this._register(this.languageFeaturesService.completionProvider.register({ scheme: ChatInputPart.INPUT_SCHEME, hasAccessToAllModels: true }, { + _debugDisplayName: 'chatAgentAndSubcommand', + triggerCharacters: [chatSubcommandLeader], + provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, token: CancellationToken) => { + const widget = this.chatWidgetService.getWidgetByInputUri(model.uri); + const viewModel = widget?.viewModel; + if (!widget || !viewModel) { + return; + } + + const range = computeCompletionRanges(model, position, /\/\w*/g); + if (!range) { + return null; + } + + const agents = this.chatAgentService.getAgents() + .filter(a => a.locations.includes(widget.location)); + + // When the input is only `/`, items are sorted by sortText. + // When typing, filterText is used to score and sort. + // The same list is refiltered/ranked while typing. + const getFilterText = (agent: IChatAgentData, command: string) => { + // This is hacking the filter algorithm to make @terminal /explain match worse than @workspace /explain by making its match index later in the string. + // When I type `/exp`, the workspace one should be sorted over the terminal one. + const dummyPrefix = agent.id === 'github.copilot.terminalPanel' ? `0000` : ``; + return `${chatSubcommandLeader}${dummyPrefix}${agent.name}.${command}`; + }; + + const justAgents: CompletionItem[] = agents + .filter(a => !a.isDefault) + .map(agent => { + const { label: agentLabel, isDupe } = this.getAgentCompletionDetails(agent); + const detail = agent.description; + + return { + label: isDupe ? + { label: agentLabel, description: agent.description, detail: ` (${agent.publisherDisplayName})` } : + agentLabel, + detail, + filterText: `${chatSubcommandLeader}${agent.name}`, + insertText: `${agentLabel} `, + range: new Range(1, 1, 1, 1), + kind: CompletionItemKind.Text, + sortText: `${chatSubcommandLeader}${agent.name}`, + command: { id: AssignSelectedAgentAction.ID, title: AssignSelectedAgentAction.ID, arguments: [{ agent, widget } satisfies AssignSelectedAgentActionArgs] }, + }; + }); + + return { + suggestions: justAgents.concat( + agents.flatMap(agent => agent.slashCommands.map((c, i) => { + const { label: agentLabel, isDupe } = this.getAgentCompletionDetails(agent); + const withSlash = `${chatSubcommandLeader}${c.name}`; + const item: CompletionItem = { + label: { label: withSlash, description: agentLabel, detail: isDupe ? ` (${agent.publisherDisplayName})` : undefined }, + filterText: getFilterText(agent, c.name), + commitCharacters: [' '], + insertText: `${agentLabel} ${withSlash} `, + detail: `(${agentLabel}) ${c.description ?? ''}`, + range: new Range(1, 1, 1, 1), + kind: CompletionItemKind.Text, // The icons are disabled here anyway + sortText: `${chatSubcommandLeader}${agent.name}${c.name}`, + command: { id: AssignSelectedAgentAction.ID, title: AssignSelectedAgentAction.ID, arguments: [{ agent, widget } satisfies AssignSelectedAgentActionArgs] }, + }; + + if (agent.isDefault) { + // default agent isn't mentioned nor inserted + item.label = withSlash; + item.insertText = `${withSlash} `; + item.detail = c.description; + } + + return item; + }))) + }; + } + })); + } + + private getAgentCompletionDetails(agent: IChatAgentData): { label: string; isDupe: boolean } { + const isAllowed = this.chatAgentNameService.getAgentNameRestriction(agent); + const agentLabel = `${chatAgentLeader}${isAllowed ? agent.name : getFullyQualifiedId(agent)}`; + const isDupe = isAllowed && this.chatAgentService.agentHasDupeName(agent.id); + return { label: agentLabel, isDupe }; + } +} +Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(AgentCompletions, LifecyclePhase.Eventually); + +interface AssignSelectedAgentActionArgs { + agent: IChatAgentData; + widget: IChatWidget; +} + +class AssignSelectedAgentAction extends Action2 { + static readonly ID = 'workbench.action.aideAgent.assignSelectedAgent'; + + constructor() { + super({ + id: AssignSelectedAgentAction.ID, + title: '' // not displayed + }); + } + + async run(accessor: ServicesAccessor, ...args: any[]) { + const arg: AssignSelectedAgentActionArgs = args[0]; + if (!arg || !arg.widget || !arg.agent) { + return; + } + + arg.widget.lastSelectedAgent = arg.agent; + } +} +registerAction2(AssignSelectedAgentAction); + + +class ReferenceArgument { + constructor( + readonly widget: IChatWidget, + readonly variable: IDynamicVariable + ) { } +} + +class BuiltinDynamicCompletions extends Disposable { + public static readonly addReferenceCommand = '_addReferenceCmd'; + public static readonly VariableNameDef = new RegExp(`${chatVariableLeader}\\w*`, 'g'); // MUST be using `g`-flag + private readonly workspaceSymbolsQuickAccess: SymbolsQuickAccessProvider; + + private readonly queryBuilder: QueryBuilder; + private cacheKey?: { key: string; time: number }; + + private readonly cacheScheduler: RunOnceScheduler; + private lastPattern?: string; + private fileEntries: IFileMatch[] = []; + private codeEntries: ISymbolQuickPickItem[] = []; + + + constructor( + @IHistoryService private readonly historyService: IHistoryService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @ISearchService private readonly searchService: ISearchService, + @ILabelService private readonly labelService: ILabelService, + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @IAideAgentWidgetService private readonly chatWidgetService: IAideAgentWidgetService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + this.cacheScheduler = this._register(new RunOnceScheduler(() => { + this.cacheFileEntries(); + this.cacheCodeEntries(); + }, 0)); + + this._register(this.languageFeaturesService.completionProvider.register({ scheme: ChatInputPart.INPUT_SCHEME, hasAccessToAllModels: true }, { + _debugDisplayName: chatDynamicCompletions, + triggerCharacters: [chatVariableLeader], + provideCompletionItems: async (model: ITextModel, position: Position, context: CompletionContext, token: CancellationToken) => { + const widget = this.chatWidgetService.getWidgetByInputUri(model.uri); + if (!widget || !widget.supportsFileReferences) { + return null; + } + + const range = computeCompletionRanges(model, position, BuiltinDynamicCompletions.VariableNameDef, true); + if (!range) { + return null; + } + + const result: CompletionList = { suggestions: [] }; + let pattern: string = ''; + if (range.varWord?.word && range.varWord.word.startsWith(chatVariableLeader)) { + pattern = range.varWord.word.toLowerCase().slice(1); // remove leading @ + } + + // We currently trigger the same completion provider when the user selects one of the predefined + // options like "File" or "Code". In order to support this, we set the completion context on the widget, + // and reset it when the user is done with the current completion context and starts over again. + const currentCompletionContext = widget.completionContext; + if ( + currentCompletionContext !== 'default' + && pattern.length === 0 + && context.triggerKind === CompletionTriggerKind.TriggerCharacter + ) { + widget.completionContext = 'default'; + } + + if (currentCompletionContext === 'default' && ( + pattern.length === 0 + || isPatternInWord(pattern.toLowerCase(), 0, pattern.length, 'file', 0, 4) + || isPatternInWord(pattern.toLowerCase(), 0, pattern.length, 'code', 0, 4) + )) { + this.addStaticFileEntry(widget, range, result); + this.addStaticCodeEntry(widget, range, result); + } else if (currentCompletionContext === 'default') { + await this.addFileEntries(pattern, widget, result, range, token); + await this.addCodeEntries(pattern, widget, result, range, token); + } else if (currentCompletionContext === 'files') { + await this.addFileEntries(pattern, widget, result, range, token); + } else if (currentCompletionContext === 'code') { + await this.addCodeEntries(pattern, widget, result, range, token); + } + + this.lastPattern = pattern; + // mark results as incomplete because further typing might yield + // in more search results + result.incomplete = true; + + // cache the entries for the next completion + this.cacheScheduler.schedule(); + + return result; + } + })); + + this._register(CommandsRegistry.registerCommand(BuiltinDynamicCompletions.addReferenceCommand, (_services, arg) => this.cmdAddReference(arg))); + this.queryBuilder = this.instantiationService.createInstance(QueryBuilder); + this.workspaceSymbolsQuickAccess = this.instantiationService.createInstance(SymbolsQuickAccessProvider); + this.cacheScheduler.schedule(); + } + + private addStaticFileEntry(widget: IChatWidget, range: { insert: Range; replace: Range; varWord: IWordAtPosition | null }, result: CompletionList) { + result.suggestions.push({ + label: 'File', + filterText: `${chatVariableLeader}file`, + insertText: `${chatVariableLeader}`, + detail: localize('pickFileLabel', "Pick a file"), + range, + kind: CompletionItemKind.File, + sortText: 'z', + command: { id: TriggerSecondaryChatWidgetCompletionAction.ID, title: TriggerSecondaryChatWidgetCompletionAction.ID, arguments: [{ widget, range: range.replace, pick: 'files' } satisfies TriggerSecondaryChatWidgetCompletionContext] } + }); + } + + private addStaticCodeEntry(widget: IChatWidget, range: { insert: Range; replace: Range; varWord: IWordAtPosition | null }, result: CompletionList) { + result.suggestions.push({ + label: 'Code', + filterText: `${chatVariableLeader}code`, + insertText: `${chatVariableLeader}`, + detail: localize('pickCodeSymbolLabel', "Pick a code symbol"), + range, + kind: CompletionItemKind.Reference, + sortText: 'z', + command: { id: TriggerSecondaryChatWidgetCompletionAction.ID, title: TriggerSecondaryChatWidgetCompletionAction.ID, arguments: [{ widget, range: range.replace, pick: 'code' } satisfies TriggerSecondaryChatWidgetCompletionContext] } + }); + } + + + private async cacheFileEntries() { + if (this.cacheKey && Date.now() - this.cacheKey.time > 60000) { + this.searchService.clearCache(this.cacheKey.key); + this.cacheKey = undefined; + } + + if (!this.cacheKey) { + this.cacheKey = { + key: generateUuid(), + time: Date.now() + }; + } + + this.cacheKey.time = Date.now(); + + const query = this.queryBuilder.file(this.workspaceContextService.getWorkspace().folders, { + filePattern: this.lastPattern, + sortByScore: true, + maxResults: 250, + cacheKey: this.cacheKey.key + }); + + const data = await this.searchService.fileSearch(query, CancellationToken.None); + this.fileEntries = data.results; + } + + private async cacheCodeEntries() { + const editorSymbolPicks = await this.workspaceSymbolsQuickAccess.getSymbolPicks(this.lastPattern ?? '', undefined, CancellationToken.None); + this.codeEntries = editorSymbolPicks; + } + + private async addFileEntries(pattern: string, widget: IChatWidget, result: CompletionList, info: { insert: Range; replace: Range; varWord: IWordAtPosition | null }, token: CancellationToken) { + const makeFileCompletionItem = (resource: URI): CompletionItem => { + const basename = this.labelService.getUriBasenameLabel(resource); + const insertText = `${chatVariableLeader}${basename}`; + + return { + label: { label: basename, description: this.labelService.getUriLabel(resource, { relative: true }) }, + filterText: `${chatVariableLeader}${basename}`, + insertText, + range: info, + kind: CompletionItemKind.File, + sortText: '{', // after `z` + command: { + id: BuiltinDynamicCompletions.addReferenceCommand, title: '', arguments: [new ReferenceArgument(widget, { + id: 'vscode.file', + range: { startLineNumber: info.replace.startLineNumber, startColumn: info.replace.startColumn, endLineNumber: info.replace.endLineNumber, endColumn: info.replace.startColumn + insertText.length }, + data: resource + })] + } + }; + }; + + const seen = new ResourceSet(); + const len = result.suggestions.length; + + // HISTORY + // always take the last N items + for (const item of this.historyService.getHistory()) { + if (!item.resource || !this.workspaceContextService.getWorkspaceFolder(item.resource)) { + // ignore "forgein" editors + continue; + } + + if (pattern) { + // use pattern if available + const basename = this.labelService.getUriBasenameLabel(item.resource).toLowerCase(); + if (!isPatternInWord(pattern, 0, pattern.length, basename, 0, basename.length)) { + continue; + } + } + + seen.add(item.resource); + const newLen = result.suggestions.push(makeFileCompletionItem(item.resource)); + if (newLen - len >= 5) { + break; + } + } + + // SEARCH + // use file search when having a pattern + if (pattern) { + for (const match of this.fileEntries) { + if (seen.has(match.resource)) { + // already included via history + continue; + } + result.suggestions.push(makeFileCompletionItem(match.resource)); + } + } + } + + private async addCodeEntries(pattern: string, widget: IChatWidget, result: CompletionList, info: { insert: Range; replace: Range; varWord: IWordAtPosition | null }, token: CancellationToken) { + let entries = this.codeEntries; + if (pattern.length > 0) { + const editorSymbolPicks = this.codeEntries = await this.workspaceSymbolsQuickAccess.getSymbolPicks(pattern, { skipSorting: true }, token); + entries = editorSymbolPicks; + } + + for (const pick of entries) { + const label = pick.label; + // label looks like `$(symbol-type) symbol-name`, but we want to insert `@symbol-name`. + const insertText = `${chatVariableLeader}${label.replace(/^\$\([^)]+\) /, '')}`; + result.suggestions.push({ + label: pick, + filterText: `${chatVariableLeader}${pick.label}`, + insertText, + range: info, + kind: CompletionItemKind.Text, + sortText: '{', // after `z` + command: { + id: BuiltinDynamicCompletions.addReferenceCommand, title: '', arguments: [new ReferenceArgument(widget, { + id: 'vscode.code', + range: { startLineNumber: info.replace.startLineNumber, startColumn: info.replace.startColumn, endLineNumber: info.replace.endLineNumber, endColumn: info.replace.startColumn + insertText.length }, + data: pick + })] + } + }); + } + } + + private cmdAddReference(arg: ReferenceArgument) { + // invoked via the completion command + arg.widget.getContrib(ChatDynamicVariableModel.ID)?.addReference(arg.variable); + } +} + +Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(BuiltinDynamicCompletions, LifecyclePhase.Eventually); + +interface TriggerSecondaryChatWidgetCompletionContext { + widget: IChatWidget; + range: IRange; + pick: IChatWidgetCompletionContext; +} + +function isTriggerSecondaryChatWidgetCompletionContext(context: any): context is TriggerSecondaryChatWidgetCompletionContext { + return 'widget' in context && 'range' in context && 'pick' in context; +} + +export class TriggerSecondaryChatWidgetCompletionAction extends Action2 { + static readonly ID = 'workbench.action.aideAgent.triggerSecondaryChatWidgetCompletion'; + + constructor() { + super({ + id: TriggerSecondaryChatWidgetCompletionAction.ID, + title: '' // not displayed + }); + } + + async run(accessor: ServicesAccessor, ...args: any[]) { + const languageFeaturesService = accessor.get(ILanguageFeaturesService); + + const context = args[0]; + if (!isTriggerSecondaryChatWidgetCompletionContext(context)) { + return; + } + + const widget = context.widget; + if (!widget.supportsFileReferences) { + return; + } + widget.completionContext = context.pick; + + const inputEditor = widget.inputEditor; + + const suggestController = SuggestController.get(inputEditor); + if (!suggestController) { + return; + } + + const completionProviders = languageFeaturesService.completionProvider.getForAllLanguages(); + const completionProvider = completionProviders.find(provider => provider._debugDisplayName === chatDynamicCompletions); + if (!completionProvider) { + return; + } + + suggestController.triggerSuggest(new Set([completionProvider])); + } +} +registerAction2(TriggerSecondaryChatWidgetCompletionAction); + +function computeCompletionRanges(model: ITextModel, position: Position, reg: RegExp, onlyOnWordStart = false): { insert: Range; replace: Range; varWord: IWordAtPosition | null } | undefined { + const varWord = getWordAtText(position.column, reg, model.getLineContent(position.lineNumber), 0); + if (!varWord && model.getWordUntilPosition(position).word) { + // inside a "normal" word + return; + } + if (varWord && onlyOnWordStart) { + const wordBefore = model.getWordUntilPosition({ lineNumber: position.lineNumber, column: varWord.startColumn }); + if (wordBefore.word) { + // inside a word + return; + } + } + + let insert: Range; + let replace: Range; + if (!varWord) { + insert = replace = Range.fromPositions(position); + } else { + insert = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, position.column); + replace = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, varWord.endColumn); + } + + return { insert, replace, varWord }; +} + +class VariableCompletions extends Disposable { + + private static readonly VariableNameDef = new RegExp(`${chatVariableLeader}\\w*`, 'g'); // MUST be using `g`-flag + + constructor( + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @IAideAgentWidgetService private readonly chatWidgetService: IAideAgentWidgetService, + @IAideAgentVariablesService private readonly chatVariablesService: IAideAgentVariablesService, + @IConfigurationService configService: IConfigurationService, + @IAideAgentLMToolsService toolsService: IAideAgentLMToolsService + ) { + super(); + + this._register(this.languageFeaturesService.completionProvider.register({ scheme: ChatInputPart.INPUT_SCHEME, hasAccessToAllModels: true }, { + _debugDisplayName: 'chatVariables', + triggerCharacters: [chatVariableLeader], + provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { + const locations = new Set(); + locations.add(ChatAgentLocation.Panel); + + for (const value of Object.values(ChatAgentLocation)) { + if (typeof value === 'string' && configService.getValue(`chat.experimental.variables.${value}`)) { + locations.add(value); + } + } + + const widget = this.chatWidgetService.getWidgetByInputUri(model.uri); + if (!widget || !locations.has(widget.location)) { + return null; + } + + const range = computeCompletionRanges(model, position, VariableCompletions.VariableNameDef, true); + if (!range) { + return null; + } + + const usedAgent = widget.parsedInput.parts.find(p => p instanceof ChatRequestAgentPart); + const slowSupported = usedAgent ? usedAgent.agent.metadata.supportsSlowVariables : true; + + const usedVariables = widget.parsedInput.parts.filter((p): p is ChatRequestVariablePart => p instanceof ChatRequestVariablePart); + const usedVariableNames = new Set(usedVariables.map(v => v.variableName)); + const variableItems = Array.from(this.chatVariablesService.getVariables(widget.location)) + // This doesn't look at dynamic variables like `file`, where multiple makes sense. + .filter(v => !usedVariableNames.has(v.name)) + .filter(v => !v.isSlow || slowSupported) + .map((v): CompletionItem => { + const withLeader = `${chatVariableLeader}${v.name}`; + return { + label: withLeader, + range, + insertText: withLeader + ' ', + detail: v.description, + kind: CompletionItemKind.Text, // The icons are disabled here anyway + sortText: 'z' + }; + }); + + const usedTools = widget.parsedInput.parts.filter((p): p is ChatRequestToolPart => p instanceof ChatRequestToolPart); + const usedToolNames = new Set(usedTools.map(v => v.toolName)); + const toolItems: CompletionItem[] = []; + if (!usedAgent || usedAgent.agent.supportsToolReferences) { + toolItems.push(...Array.from(toolsService.getTools()) + .filter(t => t.canBeInvokedManually) + .filter(t => !usedToolNames.has(t.name ?? '')) + .map((t): CompletionItem => { + const withLeader = `${chatVariableLeader}${t.name}`; + return { + label: withLeader, + range, + insertText: withLeader + ' ', + detail: t.userDescription, + kind: CompletionItemKind.Text, + sortText: 'z' + }; + })); + } + + return { + suggestions: [...variableItems, ...toolItems] + }; + } + })); + } +} + +Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(VariableCompletions, LifecyclePhase.Eventually); diff --git a/src/vs/workbench/contrib/aideAgent/browser/contrib/aideAgentInputEditorContrib.ts b/src/vs/workbench/contrib/aideAgent/browser/contrib/aideAgentInputEditorContrib.ts new file mode 100644 index 00000000000..f6a1573301b --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/contrib/aideAgentInputEditorContrib.ts @@ -0,0 +1,333 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { ICodeEditorService } from '../../../../../editor/browser/services/codeEditorService.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { IDecorationOptions } from '../../../../../editor/common/editorCommon.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { inputPlaceholderForeground } from '../../../../../platform/theme/common/colorRegistry.js'; +import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { IChatWidget } from '../aideAgent.js'; +import { ChatWidget } from '../aideAgentWidget.js'; +import { dynamicVariableDecorationType } from './aideAgentDynamicVariables.js'; +import { IChatAgentCommand, IChatAgentData, IAideAgentAgentService } from '../../common/aideAgentAgents.js'; +import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../common/aideAgentColors.js'; +import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, ChatRequestToolPart, ChatRequestVariablePart, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader } from '../../common/aideAgentParserTypes.js'; +import { ChatRequestParser } from '../../common/aideAgentRequestParser.js'; + +const decorationDescription = 'chat'; +const placeholderDecorationType = 'chat-session-detail'; +const slashCommandTextDecorationType = 'chat-session-text'; +const variableTextDecorationType = 'chat-variable-text'; + +function agentAndCommandToKey(agent: IChatAgentData, subcommand: string | undefined): string { + return subcommand ? `${agent.id}__${subcommand}` : agent.id; +} + +class InputEditorDecorations extends Disposable { + + public readonly id = 'inputEditorDecorations'; + + private readonly previouslyUsedAgents = new Set(); + + private readonly viewModelDisposables = this._register(new MutableDisposable()); + + constructor( + private readonly widget: IChatWidget, + @ICodeEditorService private readonly codeEditorService: ICodeEditorService, + @IThemeService private readonly themeService: IThemeService, + @IAideAgentAgentService private readonly chatAgentService: IAideAgentAgentService, + ) { + super(); + + this.codeEditorService.registerDecorationType(decorationDescription, placeholderDecorationType, {}); + + this._register(this.themeService.onDidColorThemeChange(() => this.updateRegisteredDecorationTypes())); + this.updateRegisteredDecorationTypes(); + + this.updateInputEditorDecorations(); + this._register(this.widget.inputEditor.onDidChangeModelContent(() => this.updateInputEditorDecorations())); + this._register(this.widget.onDidChangeParsedInput(() => this.updateInputEditorDecorations())); + this._register(this.widget.onDidChangeViewModel(() => { + this.registerViewModelListeners(); + this.previouslyUsedAgents.clear(); + this.updateInputEditorDecorations(); + })); + this._register(this.widget.onDidSubmitAgent((e) => { + this.previouslyUsedAgents.add(agentAndCommandToKey(e.agent, e.slashCommand?.name)); + })); + this._register(this.chatAgentService.onDidChangeAgents(() => this.updateInputEditorDecorations())); + + this.registerViewModelListeners(); + } + + private registerViewModelListeners(): void { + this.viewModelDisposables.value = this.widget.viewModel?.onDidChange(e => { + if (e?.kind === 'changePlaceholder' || e?.kind === 'initialize') { + this.updateInputEditorDecorations(); + } + }); + } + + private updateRegisteredDecorationTypes() { + this.codeEditorService.removeDecorationType(variableTextDecorationType); + this.codeEditorService.removeDecorationType(dynamicVariableDecorationType); + this.codeEditorService.removeDecorationType(slashCommandTextDecorationType); + + const theme = this.themeService.getColorTheme(); + this.codeEditorService.registerDecorationType(decorationDescription, slashCommandTextDecorationType, { + color: theme.getColor(chatSlashCommandForeground)?.toString(), + backgroundColor: theme.getColor(chatSlashCommandBackground)?.toString(), + borderRadius: '3px' + }); + this.codeEditorService.registerDecorationType(decorationDescription, variableTextDecorationType, { + color: theme.getColor(chatSlashCommandForeground)?.toString(), + backgroundColor: theme.getColor(chatSlashCommandBackground)?.toString(), + borderRadius: '3px' + }); + this.codeEditorService.registerDecorationType(decorationDescription, dynamicVariableDecorationType, { + color: theme.getColor(chatSlashCommandForeground)?.toString(), + backgroundColor: theme.getColor(chatSlashCommandBackground)?.toString(), + borderRadius: '3px' + }); + this.updateInputEditorDecorations(); + } + + private getPlaceholderColor(): string | undefined { + const theme = this.themeService.getColorTheme(); + const transparentForeground = theme.getColor(inputPlaceholderForeground); + return transparentForeground?.toString(); + } + + private async updateInputEditorDecorations() { + const inputValue = this.widget.inputEditor.getValue(); + + const viewModel = this.widget.viewModel; + if (!viewModel) { + return; + } + + if (!inputValue) { + const defaultAgent = this.chatAgentService.getDefaultAgent(this.widget.location); + const decoration: IDecorationOptions[] = [ + { + range: { + startLineNumber: 1, + endLineNumber: 1, + startColumn: 1, + endColumn: 1000 + }, + renderOptions: { + after: { + contentText: viewModel.inputPlaceholder || (defaultAgent?.description ?? ''), + color: this.getPlaceholderColor() + } + } + } + ]; + this.widget.inputEditor.setDecorationsByType(decorationDescription, placeholderDecorationType, decoration); + return; + } + + const parsedRequest = this.widget.parsedInput.parts; + + let placeholderDecoration: IDecorationOptions[] | undefined; + const agentPart = parsedRequest.find((p): p is ChatRequestAgentPart => p instanceof ChatRequestAgentPart); + const agentSubcommandPart = parsedRequest.find((p): p is ChatRequestAgentSubcommandPart => p instanceof ChatRequestAgentSubcommandPart); + const slashCommandPart = parsedRequest.find((p): p is ChatRequestSlashCommandPart => p instanceof ChatRequestSlashCommandPart); + + const exactlyOneSpaceAfterPart = (part: IParsedChatRequestPart): boolean => { + const partIdx = parsedRequest.indexOf(part); + if (parsedRequest.length > partIdx + 2) { + return false; + } + + const nextPart = parsedRequest[partIdx + 1]; + return nextPart && nextPart instanceof ChatRequestTextPart && nextPart.text === ' '; + }; + + const getRangeForPlaceholder = (part: IParsedChatRequestPart) => ({ + startLineNumber: part.editorRange.startLineNumber, + endLineNumber: part.editorRange.endLineNumber, + startColumn: part.editorRange.endColumn + 1, + endColumn: 1000 + }); + + const onlyAgentAndWhitespace = agentPart && parsedRequest.every(p => p instanceof ChatRequestTextPart && !p.text.trim().length || p instanceof ChatRequestAgentPart); + if (onlyAgentAndWhitespace) { + // Agent reference with no other text - show the placeholder + const isFollowupSlashCommand = this.previouslyUsedAgents.has(agentAndCommandToKey(agentPart.agent, undefined)); + const shouldRenderFollowupPlaceholder = isFollowupSlashCommand && agentPart.agent.metadata.followupPlaceholder; + if (agentPart.agent.description && exactlyOneSpaceAfterPart(agentPart)) { + placeholderDecoration = [{ + range: getRangeForPlaceholder(agentPart), + renderOptions: { + after: { + contentText: shouldRenderFollowupPlaceholder ? agentPart.agent.metadata.followupPlaceholder : agentPart.agent.description, + color: this.getPlaceholderColor(), + } + } + }]; + } + } + + const onlyAgentAndAgentCommandAndWhitespace = agentPart && agentSubcommandPart && parsedRequest.every(p => p instanceof ChatRequestTextPart && !p.text.trim().length || p instanceof ChatRequestAgentPart || p instanceof ChatRequestAgentSubcommandPart); + if (onlyAgentAndAgentCommandAndWhitespace) { + // Agent reference and subcommand with no other text - show the placeholder + const isFollowupSlashCommand = this.previouslyUsedAgents.has(agentAndCommandToKey(agentPart.agent, agentSubcommandPart.command.name)); + const shouldRenderFollowupPlaceholder = isFollowupSlashCommand && agentSubcommandPart.command.followupPlaceholder; + if (agentSubcommandPart?.command.description && exactlyOneSpaceAfterPart(agentSubcommandPart)) { + placeholderDecoration = [{ + range: getRangeForPlaceholder(agentSubcommandPart), + renderOptions: { + after: { + contentText: shouldRenderFollowupPlaceholder ? agentSubcommandPart.command.followupPlaceholder : agentSubcommandPart.command.description, + color: this.getPlaceholderColor(), + } + } + }]; + } + } + + const onlyAgentCommandAndWhitespace = agentSubcommandPart && parsedRequest.every(p => p instanceof ChatRequestTextPart && !p.text.trim().length || p instanceof ChatRequestAgentSubcommandPart); + if (onlyAgentCommandAndWhitespace) { + // Agent subcommand with no other text - show the placeholder + if (agentSubcommandPart?.command.description && exactlyOneSpaceAfterPart(agentSubcommandPart)) { + placeholderDecoration = [{ + range: getRangeForPlaceholder(agentSubcommandPart), + renderOptions: { + after: { + contentText: agentSubcommandPart.command.description, + color: this.getPlaceholderColor(), + } + } + }]; + } + } + + this.widget.inputEditor.setDecorationsByType(decorationDescription, placeholderDecorationType, placeholderDecoration ?? []); + + const textDecorations: IDecorationOptions[] | undefined = []; + if (agentPart) { + textDecorations.push({ range: agentPart.editorRange }); + } + if (agentSubcommandPart) { + textDecorations.push({ range: agentSubcommandPart.editorRange, hoverMessage: new MarkdownString(agentSubcommandPart.command.description) }); + } + + if (slashCommandPart) { + textDecorations.push({ range: slashCommandPart.editorRange }); + } + + this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandTextDecorationType, textDecorations); + + const varDecorations: IDecorationOptions[] = []; + const variableParts = parsedRequest.filter((p): p is ChatRequestVariablePart => p instanceof ChatRequestVariablePart); + for (const variable of variableParts) { + varDecorations.push({ range: variable.editorRange }); + } + + const toolParts = parsedRequest.filter((p): p is ChatRequestToolPart => p instanceof ChatRequestToolPart); + for (const tool of toolParts) { + varDecorations.push({ range: tool.editorRange }); + } + + this.widget.inputEditor.setDecorationsByType(decorationDescription, variableTextDecorationType, varDecorations); + } +} + +class InputEditorSlashCommandMode extends Disposable { + public readonly id = 'InputEditorSlashCommandMode'; + + constructor( + private readonly widget: IChatWidget + ) { + super(); + this._register(this.widget.onDidChangeAgent(e => { + if (e.slashCommand && e.slashCommand.isSticky || !e.slashCommand && e.agent.metadata.isSticky) { + this.repopulateAgentCommand(e.agent, e.slashCommand); + } + })); + this._register(this.widget.onDidSubmitAgent(e => { + this.repopulateAgentCommand(e.agent, e.slashCommand); + })); + } + + private async repopulateAgentCommand(agent: IChatAgentData, slashCommand: IChatAgentCommand | undefined) { + // Make sure we don't repopulate if the user already has something in the input + if (this.widget.inputEditor.getValue().trim()) { + return; + } + + let value: string | undefined; + if (slashCommand && slashCommand.isSticky) { + value = `${chatAgentLeader}${agent.name} ${chatSubcommandLeader}${slashCommand.name} `; + } else if (agent.metadata.isSticky) { + value = `${chatAgentLeader}${agent.name} `; + } + + if (value) { + this.widget.inputEditor.setValue(value); + this.widget.inputEditor.setPosition({ lineNumber: 1, column: value.length + 1 }); + } + } +} + +ChatWidget.CONTRIBS.push(InputEditorDecorations, InputEditorSlashCommandMode); + +class ChatTokenDeleter extends Disposable { + + public readonly id = 'chatTokenDeleter'; + + constructor( + private readonly widget: IChatWidget, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + const parser = this.instantiationService.createInstance(ChatRequestParser); + const inputValue = this.widget.inputEditor.getValue(); + let previousInputValue: string | undefined; + let previousSelectedAgent: IChatAgentData | undefined; + + // A simple heuristic to delete the previous token when the user presses backspace. + // The sophisticated way to do this would be to have a parse tree that can be updated incrementally. + this._register(this.widget.inputEditor.onDidChangeModelContent(e => { + if (!previousInputValue) { + previousInputValue = inputValue; + previousSelectedAgent = this.widget.lastSelectedAgent; + } + + // Don't try to handle multicursor edits right now + const change = e.changes[0]; + + // If this was a simple delete, try to find out whether it was inside a token + if (!change.text && this.widget.viewModel) { + const previousParsedValue = parser.parseChatRequest(this.widget.viewModel.sessionId, previousInputValue, widget.location, { selectedAgent: previousSelectedAgent }); + + // For dynamic variables, this has to happen in ChatDynamicVariableModel with the other bookkeeping + const deletableTokens = previousParsedValue.parts.filter(p => p instanceof ChatRequestAgentPart || p instanceof ChatRequestAgentSubcommandPart || p instanceof ChatRequestSlashCommandPart || p instanceof ChatRequestVariablePart || p instanceof ChatRequestToolPart); + deletableTokens.forEach(token => { + const deletedRangeOfToken = Range.intersectRanges(token.editorRange, change.range); + // Part of this token was deleted, or the space after it was deleted, and the deletion range doesn't go off the front of the token, for simpler math + if (deletedRangeOfToken && Range.compareRangesUsingStarts(token.editorRange, change.range) < 0) { + // Assume single line tokens + const length = deletedRangeOfToken.endColumn - deletedRangeOfToken.startColumn; + const rangeToDelete = new Range(token.editorRange.startLineNumber, token.editorRange.startColumn, token.editorRange.endLineNumber, token.editorRange.endColumn - length); + this.widget.inputEditor.executeEdits(this.id, [{ + range: rangeToDelete, + text: '', + }]); + } + }); + } + + previousInputValue = this.widget.inputEditor.getValue(); + previousSelectedAgent = this.widget.lastSelectedAgent; + })); + } +} +ChatWidget.CONTRIBS.push(ChatTokenDeleter); diff --git a/src/vs/workbench/contrib/aideAgent/browser/contrib/editorHoverWrapper.ts b/src/vs/workbench/contrib/aideAgent/browser/contrib/editorHoverWrapper.ts new file mode 100644 index 00000000000..01f5adf945d --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/contrib/editorHoverWrapper.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/editorHoverWrapper.css'; +import * as dom from '../../../../../base/browser/dom.js'; +import { IHoverAction } from '../../../../../base/browser/ui/hover/hover.js'; +import { HoverAction } from '../../../../../base/browser/ui/hover/hoverWidget.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; + +const $ = dom.$; +const h = dom.h; + +/** + * This borrows some of HoverWidget so that a chat editor hover can be rendered in the same way as a workbench hover. + * Maybe it can be reusable in a generic way. + */ +export class ChatEditorHoverWrapper { + public readonly domNode: HTMLElement; + + constructor( + hoverContentElement: HTMLElement, + actions: IHoverAction[] | undefined, + @IKeybindingService private readonly keybindingService: IKeybindingService, + ) { + const hoverElement = h( + '.chat-editor-hover-wrapper@root', + [h('.chat-editor-hover-wrapper-content@content')]); + this.domNode = hoverElement.root; + hoverElement.content.appendChild(hoverContentElement); + + if (actions && actions.length > 0) { + const statusBarElement = $('.hover-row.status-bar'); + const actionsElement = $('.actions'); + actions.forEach(action => { + const keybinding = this.keybindingService.lookupKeybinding(action.commandId); + const keybindingLabel = keybinding ? keybinding.getLabel() : null; + HoverAction.render(actionsElement, { + label: action.label, + commandId: action.commandId, + run: e => { + action.run(e); + }, + iconClass: action.iconClass + }, keybindingLabel); + }); + statusBarElement.appendChild(actionsElement); + this.domNode.appendChild(statusBarElement); + } + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/contrib/media/editorHoverWrapper.css b/src/vs/workbench/contrib/aideAgent/browser/contrib/media/editorHoverWrapper.css new file mode 100644 index 00000000000..d95fd395255 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/contrib/media/editorHoverWrapper.css @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-editor-hover-wrapper-content { + padding: 2px 8px; +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/media/aideAgent.css b/src/vs/workbench/contrib/aideAgent/browser/media/aideAgent.css new file mode 100644 index 00000000000..b0a6f979742 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/media/aideAgent.css @@ -0,0 +1,958 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.interactive-session { + max-width: 850px; + margin: auto; +} + +#workbench\.panel\.aideAgentSidebar .pane-body { + position: relative; +} + +.interactive-list > .monaco-list > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row > .monaco-tl-row > .monaco-tl-twistie { + /* Hide twisties from chat tree rows, but not from nested trees within a chat response */ + display: none !important; +} + +.interactive-item-container { + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 8px; + color: var(--vscode-interactive-session-foreground); + + cursor: default; + user-select: text; + -webkit-user-select: text; +} + +.interactive-item-container .header { + display: flex; + align-items: center; + justify-content: space-between; + position: relative; +} + +.interactive-item-container .header.hidden { + display: none; +} + +.interactive-item-container .header .user { + display: flex; + align-items: center; + gap: 8px; + + /* + Rendering the avatar icon as round makes it a little larger than the .user container. + Add padding so that the focus outline doesn't run into it, and counteract it with a negative margin so it doesn't actually take up any extra space */ + padding: 2px; + margin: -2px; +} + +.interactive-item-container .header .username { + margin: 0; + font-size: 13px; + font-weight: 600; +} + +.interactive-item-container .detail-container { + font-size: 12px; + color: var(--vscode-descriptionForeground); + overflow: hidden; +} + +.interactive-item-container .detail-container .detail .agentOrSlashCommandDetected A { + cursor: pointer; + color: var(--vscode-textLink-foreground); +} + +.interactive-item-container .chat-animated-ellipsis { + display: inline-block; + width: 11px; +} + +.interactive-item-container:not(.show-detail-progress) .chat-animated-ellipsis { + display: none; +} + +@keyframes ellipsis { + 0% { + content: ""; + } + 25% { + content: "."; + } + 50% { + content: ".."; + } + 75% { + content: "..."; + } + 100% { + content: ""; + } +} + +.interactive-item-container .chat-animated-ellipsis::after { + content: ''; + white-space: nowrap; + overflow: hidden; + width: 3em; + animation: ellipsis steps(4, end) 1s infinite; +} + +.interactive-item-container .header .avatar-container { + display: flex; + pointer-events: none; + user-select: none; +} + +.interactive-item-container .header .avatar { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + outline: 1px solid var(--vscode-chat-requestBorder); +} + +.interactive-item-container .header .avatar.codicon-avatar { + background: var(--vscode-chat-avatarBackground); +} + +.interactive-item-container .header .avatar+.avatar { + margin-left: -8px; +} + +.interactive-item-container .header .avatar .icon { + width: 24px; + height: 24px; + border-radius: 50%; + background-color: var(--vscode-chat-list-background); +} + +.interactive-item-container .header .avatar .codicon { + color: var(--vscode-chat-avatarForeground) !important; + font-size: 14px; +} + +.monaco-list-row:not(.focused) .interactive-item-container:not(:hover) .header .monaco-toolbar, +.monaco-list:not(:focus-within) .monaco-list-row .interactive-item-container:not(:hover) .header .monaco-toolbar, +.monaco-list-row:not(.focused) .interactive-item-container:not(:hover) .header .monaco-toolbar .action-label, +.monaco-list:not(:focus-within) .monaco-list-row .interactive-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. */ + display: none; +} + +.interactive-item-container .header .monaco-toolbar .monaco-action-bar .actions-container { + gap: 4px; +} + +.interactive-item-container .header .monaco-toolbar .action-label { + border: 1px solid transparent; + padding: 2px; +} + +.interactive-item-container .header .monaco-toolbar { + position: absolute; + right: 0px; + background-color: var(--vscode-chat-list-background); +} + +.interactive-item-container.interactive-request .header .monaco-toolbar { + /* Take the partially-transparent background color override for request rows */ + background-color: inherit; +} + +.interactive-item-container .header .monaco-toolbar .checked.action-label, +.interactive-item-container .header .monaco-toolbar .checked.action-label:hover { + color: var(--vscode-inputOption-activeForeground) !important; + border-color: var(--vscode-inputOption-activeBorder); + background-color: var(--vscode-inputOption-activeBackground); +} + +.interactive-item-container .value { + width: 100%; +} + +.interactive-item-container > .value .chat-used-context { + margin-bottom: 8px; +} + +.interactive-item-container .value .rendered-markdown blockquote { + margin: 0px; + padding: 0px 16px 0 10px; + border-left-width: 5px; + border-left-style: solid; + border-radius: 2px; + background: var(--vscode-textBlockQuote-background); + border-color: var(--vscode-textBlockQuote-border); +} + +.interactive-item-container .value .rendered-markdown table { + width: 100%; + text-align: left; + margin-bottom: 16px; +} + +.interactive-item-container .value .rendered-markdown table, +.interactive-item-container .value .rendered-markdown table td, +.interactive-item-container .value .rendered-markdown table th { + border: 1px solid var(--vscode-chat-requestBorder); + border-collapse: collapse; + padding: 4px 6px; +} + +.interactive-item-container .value .rendered-markdown a, +.interactive-item-container .value .interactive-session-followups, +.interactive-item-container .value .rendered-markdown a code { + color: var(--vscode-textLink-foreground); +} + +.interactive-item-container .value .rendered-markdown a { + user-select: text; +} + +.interactive-item-container .value .rendered-markdown a:hover, +.interactive-item-container .value .rendered-markdown a:active { + color: var(--vscode-textLink-activeForeground); +} + +.hc-black .interactive-item-container .value .rendered-markdown a code, +.hc-light .interactive-item-container .value .rendered-markdown a code { + color: var(--vscode-textPreformat-foreground); +} + +.interactive-list { + overflow: hidden; +} + +.interactive-request { + border-bottom: 1px solid var(--vscode-chat-requestBorder); + border-top: 1px solid var(--vscode-chat-requestBorder); +} + +.hc-black .interactive-request, +.hc-light .interactive-request { + border-left: 3px solid var(--vscode-chat-requestBorder); + border-right: 3px solid var(--vscode-chat-requestBorder); +} + +.interactive-item-container .value { + white-space: normal; + overflow-wrap: anywhere; +} + +.interactive-item-container .value > :last-child.rendered-markdown > :last-child { + margin-bottom: 0px; +} + +.interactive-item-container .value .rendered-markdown hr { + border-color: rgba(0, 0, 0, 0.18); +} + +.vs-dark .interactive-item-container .value .rendered-markdown hr { + border-color: rgba(255, 255, 255, 0.18); +} + +.interactive-item-container .value .rendered-markdown h1 { + font-size: 20px; + font-weight: 600; + margin: 16px 0; + +} + +.interactive-item-container .value .rendered-markdown h2 { + font-size: 16px; + font-weight: 600; + margin: 16px 0; +} + +.interactive-item-container .value .rendered-markdown h3 { + font-size: 14px; + font-weight: 600; + margin: 16px 0; +} + +.interactive-item-container .value .rendered-markdown p { + line-height: 1.5em; +} + +.interactive-item-container .value > .rendered-markdown p { + margin: 0 0 16px 0; +} + +.interactive-item-container .value > .rendered-markdown li > p { + margin: 0; +} + +/* #region list indent rules */ +.interactive-item-container .value .rendered-markdown ul { + /* Keep this in sync with the values for dedented codeblocks below */ + padding-inline-start: 24px; +} + +.interactive-item-container .value .rendered-markdown ol { + /* Keep this in sync with the values for dedented codeblocks below */ + padding-inline-start: 28px; +} + +/* NOTE- We want to dedent codeblocks in lists specifically to give them the full width. No more elegant way to do this, these values +have to be updated for changes to the rules above, or to support more deeply nested lists. */ +.interactive-item-container .value .rendered-markdown ul .interactive-result-code-block { + margin-left: -24px; +} + +.interactive-item-container .value .rendered-markdown ul ul .interactive-result-code-block { + margin-left: -48px; +} + +.interactive-item-container .value .rendered-markdown ol .interactive-result-code-block { + margin-left: -28px; +} + +.interactive-item-container .value .rendered-markdown ol ol .interactive-result-code-block { + margin-left: -56px; +} + +.interactive-item-container .value .rendered-markdown ol ul .interactive-result-code-block, +.interactive-item-container .value .rendered-markdown ul ol .interactive-result-code-block { + margin-left: -52px; +} + +/* #endregion list indent rules */ + +.interactive-item-container .value .rendered-markdown li { + line-height: 1.3rem; +} + +.interactive-item-container .value .rendered-markdown img { + max-width: 100%; +} + +.interactive-item-container .monaco-tokenized-source, +.interactive-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; +} + +.interactive-item-container.interactive-item-compact { + padding: 8px 20px; +} + +.interactive-item-container.interactive-item-compact.no-padding { + padding: unset; + gap: unset; +} + +.interactive-item-container.interactive-item-compact .header { + height: 16px; +} + +.interactive-item-container.interactive-item-compact .header .avatar { + width: 18px; + height: 18px; +} + +.interactive-item-container.interactive-item-compact .header .avatar .icon { + width: 16px; + height: 16px; +} + +.interactive-item-container.interactive-item-compact .header .codicon-avatar .codicon { + font-size: 12px; +} + +.interactive-item-container.interactive-item-compact .header .avatar+.avatar { + margin-left: -4px; +} + +.interactive-item-container.interactive-item-compact .value { + min-height: 0; +} + +.interactive-item-container.interactive-item-compact .value > .rendered-markdown p { + margin: 0 0 8px 0; +} + +.interactive-item-container.interactive-item-compact .value > .rendered-markdown li > p { + margin: 0; +} + +.interactive-item-container.interactive-item-compact .value .rendered-markdown h1 { + margin: 8px 0; + +} + +.interactive-item-container.interactive-item-compact .value .rendered-markdown h2 { + margin: 8px 0; +} + +.interactive-item-container.interactive-item-compact .value .rendered-markdown h3 { + margin: 8px 0; +} + +.interactive-item-container.minimal { + flex-direction: row; +} + +.interactive-item-container.minimal .column.left { + padding-top: 2px; + display: inline-block; + flex-grow: 0; +} + +.interactive-item-container.minimal .column.right { + display: inline-block; + flex-grow: 1; +} + +.interactive-item-container.minimal .user > .username { + display: none; +} + +.interactive-item-container.minimal .detail-container { + font-size: unset; +} + +.interactive-item-container.minimal > .header { + position: absolute; + right: 0; +} + +.interactive-session .chat-dnd-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + + display: none; +} + +.interactive-session .chat-dnd-overlay.visible { + display: flex; + align-items: center; + justify-content: center; +} + +.interactive-session .chat-dnd-overlay .attach-context-overlay-text { + padding: 0.6em; + margin: 0.2em; + line-height: 12px; + height: 12px; + display: flex; + align-items: center; + text-align: center; +} + +.interactive-session .chat-dnd-overlay .attach-context-overlay-text .codicon { + height: 12px; + font-size: 12px; + margin-right: 3px; +} + +.interactive-session .chat-input-container { + box-sizing: border-box; + cursor: text; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 4px; + padding: 0 6px 6px 6px; /* top padding is inside the editor widget */ +} + +.interactive-session .interactive-input-part.compact .chat-input-container { + display: flex; + justify-content: space-between; + padding-bottom: 0; + border-radius: 2px; +} + +.interactive-session .interactive-input-and-side-toolbar { + display: flex; + gap: 4px; + align-items: center; +} + +.interactive-session .chat-input-container.focused { + border-color: var(--vscode-focusBorder); +} + +.chat-editor-container .monaco-editor .mtk1 { + color: var(--vscode-input-foreground); +} + +.interactive-session .chat-editor-container .monaco-editor, +.interactive-session .chat-editor-container .monaco-editor .monaco-editor-background { + background-color: var(--vscode-input-background) !important; +} + +.interactive-session .chat-editor-container .monaco-editor .cursors-layer { + padding-left: 4px; +} + +.interactive-session .chat-input-toolbars { + display: flex; +} + +.interactive-session .chat-input-toolbars :first-child { + margin-right: auto; +} + +.interactive-session .chat-input-toolbars .agentmode-picker-item .action-label { + font-size: 12px; + height: 16px; + padding: 3px 0px 3px 6px; + display: flex; + align-items: center; +} + +.interactive-session .chat-input-toolbars .agentmode-picker-item .action-label .codicon-chevron-down { + font-size: 12px; + margin-left: 2px; +} + +.interactive-session .chat-input-toolbars .monaco-action-bar .actions-container { + display: flex; + gap: 4px; +} + +.interactive-session .chat-input-toolbars .codicon-debug-stop { + color: var(--vscode-icon-foreground) !important; +} + +.interactive-response .interactive-result-code-block .interactive-result-editor .monaco-editor, +.interactive-response .interactive-result-code-block .interactive-result-editor .monaco-editor .margin, +.interactive-response .interactive-result-code-block .interactive-result-editor .monaco-editor .monaco-editor-background { + background-color: var(--vscode-interactive-result-editor-background-color) !important; +} + +.interactive-item-compact .interactive-result-code-block { + margin: 0 0 8px 0; +} + +.interactive-item-container .interactive-result-code-block .monaco-toolbar .monaco-action-bar .actions-container { + padding-inline-start: unset; +} + +.chat-notification-widget .chat-info-codicon, +.chat-notification-widget .chat-error-codicon, +.chat-notification-widget .chat-warning-codicon { + display: flex; + align-items: start; + gap: 8px; +} + +.interactive-item-container .value .chat-notification-widget .rendered-markdown p { + margin: 0; +} + +.interactive-response .interactive-response-error-details { + display: flex; + align-items: start; + gap: 6px; +} + +.interactive-response .interactive-response-error-details .rendered-markdown :last-child { + margin-bottom: 0px; +} + +.chat-notification-widget .chat-info-codicon .codicon, +.chat-notification-widget .chat-error-codicon .codicon, +.chat-notification-widget .chat-warning-codicon .codicon { + margin-top: 2px; +} + +.interactive-response .interactive-response-error-details .codicon { + margin-top: 1px; +} + +.chat-used-context-list .codicon-warning { + color: var(--vscode-notificationsWarningIcon-foreground); /* Have to override default styles which apply to all lists */ +} + +.chat-used-context-list .monaco-icon-label-container { + color: var(--vscode-interactive-session-foreground); +} + +.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); +} + +.chat-attached-context .chat-attached-context-attachment.show-file-icons.warning { + border-color: var(--vscode-notificationsWarningIcon-foreground); +} + +.chat-notification-widget .chat-warning-codicon .codicon-warning { + color: var(--vscode-notificationsWarningIcon-foreground) !important; /* Have to override default styles which apply to all lists */ +} + +.chat-notification-widget .chat-error-codicon .codicon-error, +.interactive-response .interactive-response-error-details .codicon-error { + color: var(--vscode-errorForeground) !important; /* Have to override default styles which apply to all lists */ +} + +.chat-notification-widget .chat-info-codicon .codicon-info, +.interactive-response .interactive-response-error-details .codicon-info { + color: var(--vscode-notificationsInfoIcon-foreground) !important; /* Have to override default styles which apply to all lists */ +} + +.interactive-session .interactive-input-part { + margin: 0px 16px; + padding: 12px 0px; + display: flex; + flex-direction: column; +} + +.interactive-session .interactive-input-part.compact { + margin: 0; + padding: 8px 0 0 0 +} + +.interactive-session .chat-attached-context .chat-attached-context-attachment { + display: flex; + gap: 4px; +} + +.interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-button:hover { + cursor: pointer; +} + +.interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-button { + display: flex; + align-items: center; +} + +.interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-icon-label-container { + display: flex; +} + +.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 .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 .chat-attached-context .chat-attached-context-attachment .monaco-icon-label .codicon { + padding-left: 4px; +} + +.interactive-session .chat-attached-context { + padding: 0 0 8px 0; + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.interactive-session .interactive-input-part.compact .chat-attached-context { + padding-top: 8px; + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.interactive-session .interactive-item-container.interactive-request .chat-attached-context { + margin-top: -8px; +} + +.interactive-session .chat-attached-context .chat-attached-context-attachment { + padding: 2px; + border: 1px solid var(--vscode-chat-requestBorder, var(--vscode-input-background, transparent)); + border-radius: 4px; + height: 18px; + max-width: 100%; +} + +.interactive-session .interactive-item-container.interactive-request .chat-attached-context .chat-attached-context-attachment { + padding-right: 6px; +} + +.interactive-session-followups { + display: flex; + flex-direction: column; + gap: 6px; + align-items: start; +} + +.interactive-session-followups .monaco-button { + text-align: left; + width: initial; +} + +.interactive-session-followups .monaco-button .codicon { + margin-left: 0; + margin-top: 1px; +} + +.interactive-item-container .interactive-response-followups .monaco-button { + 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 .monaco-button { + display: block; + color: var(--vscode-textLink-foreground); + font-size: 12px; + + /* clamp to max 3 lines */ + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.interactive-session .interactive-input-part .interactive-input-followups .interactive-session-followups code { + font-family: var(--monaco-monospace-font); + font-size: 11px; +} + +.interactive-session .interactive-input-part .interactive-input-followups .interactive-session-followups .monaco-button .codicon-sparkle { + float: left; +} + +.interactive-session-followups .monaco-button.interactive-followup-reply { + padding: 0px; + border: none; +} + +.interactive-welcome .value .interactive-session-followups { + margin-bottom: 16px; +} + +.interactive-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; +} + +/* #region Quick Chat */ + +.quick-input-widget .interactive-session .interactive-input-part { + padding: 8px 6px 6px 6px; + margin: 0 3px; +} + +.quick-input-widget .interactive-session .interactive-input-part .chat-input-toolbars { + margin-bottom: 1px; +} + +.quick-input-widget .interactive-session .chat-input-container { + margin: 0; + border-radius: 2px; + padding: 0 4px 0 6px; +} + +.quick-input-widget .interactive-list { + border-bottom-right-radius: 6px; + border-bottom-left-radius: 6px; +} + +.quick-input-widget .interactive-response { + min-height: 86px; +} + +/* #endregion */ + +.interactive-response-progress-tree .monaco-list-row:not(.selected) .monaco-tl-row:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.interactive-response-progress-tree { + margin: 16px 0px; +} + +.interactive-response-progress-tree.focused { + border-color: var(--vscode-focusBorder, transparent); +} + +.interactive-item-container .value .interactive-response-placeholder-codicon .codicon { + color: var(--vscode-editorGhostText-foreground); +} + +.interactive-item-container .value .interactive-response-placeholder-content { + color: var(--vscode-editorGhostText-foreground); + font-size: 12px; + margin-bottom: 16px; +} + +.interactive-item-container .value .interactive-response-placeholder-content p { + margin: 0; +} + +.interactive-response .interactive-response-codicon-details { + display: flex; + align-items: start; + gap: 6px; +} + +.chat-used-context-list .monaco-list { + border: none; + border-radius: 4px; + width: auto; +} + +.interactive-item-container .chat-resource-widget { + background-color: var(--vscode-chat-slashCommandBackground); + color: var(--vscode-chat-slashCommandForeground); +} + +.interactive-item-container .chat-resource-widget, +.interactive-item-container .chat-agent-widget .monaco-button { + border-radius: 4px; + padding: 1px 3px; +} + +.interactive-item-container .chat-agent-widget .monaco-text-button { + display: inline; + border: none; +} + +.interactive-session .chat-used-context.chat-used-context-collapsed .chat-used-context-list { + display: none; +} + +.interactive-session .chat-used-context { + display: flex; + flex-direction: column; + gap: 2px; +} + +.interactive-response-progress-tree, +.interactive-item-container .chat-notification-widget, +.interactive-session .chat-used-context-list { + border: 1px solid var(--vscode-chat-requestBorder); + border-radius: 4px; + margin-bottom: 8px; + padding: 4px 6px; +} + +.interactive-item-container .chat-notification-widget { + padding: 8px 12px; +} + +.interactive-session .chat-used-context-list .monaco-list .monaco-list-row { + border-radius: 2px; +} + +.interactive-session .chat-used-context-label { + font-size: 12px; + color: var(--vscode-descriptionForeground); + user-select: none; +} + +.interactive-session .chat-used-context-label:hover { + opacity: unset; +} + +.interactive-session .chat-used-context-label .monaco-button { + /* unset Button styles */ + display: inline-flex; + gap: 4px; + width: fit-content; + border: none; + border-radius: 4px; + padding: 3px 8px 3px 0; + text-align: initial; + justify-content: initial; +} + +.interactive-session .chat-used-context-label .monaco-button:hover { + background-color: var(--vscode-list-hoverBackground); + color: var(--vscode-foreground); + +} + +.interactive-session .chat-used-context-label .monaco-text-button:focus { + outline: none; +} + +.interactive-session .chat-used-context-label .monaco-text-button:focus-visible { + outline: 1px solid var(--vscode-focusBorder); +} + +.interactive-session .chat-used-context .chat-used-context-label .monaco-button .codicon { + margin: 0 0 0 4px; +} + +.interactive-item-container .rendered-markdown.progress-step { + display: flex; + margin-left: 4px; + white-space: normal; +} + +.interactive-item-container .rendered-markdown.progress-step > p { + color: var(--vscode-descriptionForeground); + font-size: 12px; + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 6px; +} + +.interactive-item-container .rendered-markdown.progress-step > p .codicon { + /* Very aggressive list styles try to apply focus colors to every codicon in a list row. */ + color: var(--vscode-icon-foreground) !important; +} + +.interactive-item-container .rendered-markdown.progress-step > p .codicon.codicon-check { + color: var(--vscode-debugIcon-startForeground) !important; +} + +.interactive-item-container .chat-command-button { + display: flex; + margin-bottom: 16px; +} + +.interactive-item-container .chat-notification-widget { + display: flex; + flex-direction: row; + gap: 6px; +} + +.interactive-item-container .chat-command-button .monaco-button, +.chat-confirmation-widget .chat-confirmation-buttons-container .monaco-button { + text-align: left; + width: initial; + padding: 4px 8px; +} + +.interactive-item-container .chat-command-button .monaco-button .codicon { + margin-left: 0; + margin-top: 1px; +} + +.chat-code-citation-label { + opacity: 0.7; + white-space: pre-wrap; +} + +.chat-code-citation-button-container { + display: inline; +} + +.chat-code-citation-button-container .monaco-button { + display: inline; + border: none; + padding: 0; + color: var(--vscode-textLink-foreground); +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/media/aideAgentInlineAnchorWidget.css b/src/vs/workbench/contrib/aideAgent/browser/media/aideAgentInlineAnchorWidget.css new file mode 100644 index 00000000000..0057dbd6223 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/media/aideAgentInlineAnchorWidget.css @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-inline-anchor-widget { + border: 1px solid var(--vscode-chat-requestBorder, var(--vscode-input-background, transparent)); + border-radius: 4px; + margin: 0 1px; + padding: 1px 3px; + text-wrap: nowrap; +} + +.interactive-item-container .value .rendered-markdown .chat-inline-anchor-widget { + color: inherit; +} + +.chat-inline-anchor-widget:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.chat-inline-anchor-widget .icon { + display: inline-block; + vertical-align: middle; +} + +.chat-inline-anchor-widget .icon::before { + display: inline-block; + background-size: 100%; + background-position: left center; + background-repeat: no-repeat; + width: 0.8em !important; + height: 0.8em; + margin-right: 4px; +} + +.chat-inline-anchor-widget .file-icon::before { + font-size: 120%; +} + +.chat-inline-anchor-widget .icon-label { + text-wrap: wrap; +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/media/aideControls.css b/src/vs/workbench/contrib/aideAgent/browser/media/aideControls.css deleted file mode 100644 index 0f3ecca1f2f..00000000000 --- a/src/vs/workbench/contrib/aideAgent/browser/media/aideControls.css +++ /dev/null @@ -1,86 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.aide-controls { - display: flex; - margin: auto; - position: relative; - padding: 0px 14px 0px 28px; - box-sizing: border-box; - border-top: 1px solid var(--vscode-panel-border); - background-color: var(--vscode-sideBar-background) !important; -} - -.aide-controls:focus-within { - border-color: var(--vscode-peekView-border); -} - -.aide-controls :is(.monaco-editor, .monaco-editor-background) { - background-color: transparent; -} - -.aide-controls .aide-controls-settings { - display: flex; - align-items: center; - width: 22px; - margin-right: 22px; -} - -.aide-controls .aide-controls-settings .aide-controls-edit-focus { - display: flex; - align-items: center; - position: relative; -} - -.aide-controls .aide-controls-settings .aide-controls-edit-focus::before { - position: absolute; - top: 50%; - transform: translateY(-50%) translateX(20%); /* I have no scientific explanation for this */ - width: 16px; -} - -.aide-controls .aide-controls-settings .aide-controls-edit-focus::after { - content: ''; /* Hide the default chevron */ -} - -.aide-controls .aide-controls-settings .aide-controls-edit-focus select.monaco-select-box { - height: 22px; - width: 22px; - appearance: none; - color: transparent !important; - background-color: transparent !important; - border: none; - text-overflow: clip; - z-index: 1; -} - -.aide-controls .aide-controls-settings .aide-controls-edit-focus .monaco-select-box { - padding: 3px; - border-radius: 5px; -} - -.aide-controls .aide-controls-settings .aide-controls-edit-focus .monaco-select-box:hover { - background-color: var(--vscode-toolbar-hoverBackground) !important; -} - -.aide-controls-input-container { - display: flex; - align-items: start; -} - -.aide-controls-toolbar { - display: flex; - align-items: center; -} - -.aide-controls-submit-button { - padding: 8px; - display: flex; -} - -.aide-controls-submit-button:not(.disabled):hover { - background-color: var(--vscode-list-activeSelectionBackground); - cursor: pointer; -} diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgent.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgent.ts deleted file mode 100644 index c3b890ef1bb..00000000000 --- a/src/vs/workbench/contrib/aideAgent/common/aideAgent.ts +++ /dev/null @@ -1,16 +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 { CancellationToken } from '../../../../base/common/cancellation.js'; -import { IAgentTriggerPayload } from './aideAgentModel.js'; -import { IAgentResponseProgress } from './aideAgentService.js'; - -export interface IAgentTriggerComplete { - errorDetails?: string; -} - -export interface IAideAgentImplementation { - trigger: (request: IAgentTriggerPayload, progress: (part: IAgentResponseProgress) => Promise, token: CancellationToken) => Promise; -} diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentAgents.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentAgents.ts new file mode 100644 index 00000000000..b0c71b2f4aa --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentAgents.ts @@ -0,0 +1,691 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { findLast } from '../../../../base/common/arraysFind.js'; +import { timeout } from '../../../../base/common/async.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { IMarkdownString } from '../../../../base/common/htmlContent.js'; +import { Iterable } from '../../../../base/common/iterator.js'; +import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { revive } from '../../../../base/common/marshalling.js'; +import { IObservable, observableValue } 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 { Command, ProviderResult } from '../../../../editor/common/languages.js'; +import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { asJson, IRequestService } from '../../../../platform/request/common/request.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { CONTEXT_CHAT_ENABLED, CONTEXT_CHAT_PANEL_PARTICIPANT_REGISTERED } from './aideAgentContextKeys.js'; +import { AgentMode, IChatProgressResponseContent, IChatRequestVariableData, ISerializableChatAgentData } from './aideAgentModel.js'; +import { IRawChatCommandContribution, RawChatParticipantLocation } from './aideAgentParticipantContribTypes.js'; +import { IChatFollowup, IChatLocationData, IChatResponseErrorDetails, IChatTaskDto } from './aideAgentService.js'; + +//#region agent service, commands etc + +export interface IChatAgentHistoryEntry { + request: IChatAgentRequest; + response: ReadonlyArray; + result: IChatAgentResult; +} + +export enum ChatAgentLocation { + Panel = 'panel', + Terminal = 'terminal', + Notebook = 'notebook', + Editor = 'editor' +} + +export namespace ChatAgentLocation { + export function fromRaw(value: RawChatParticipantLocation | string): ChatAgentLocation { + switch (value) { + case 'panel': return ChatAgentLocation.Panel; + case 'terminal': return ChatAgentLocation.Terminal; + case 'notebook': return ChatAgentLocation.Notebook; + case 'editor': return ChatAgentLocation.Editor; + } + return ChatAgentLocation.Panel; + } +} + +export interface IChatAgentData { + id: string; + name: string; + fullName?: string; + description?: string; + supportsModelPicker?: boolean; + when?: string; + extensionId: ExtensionIdentifier; + extensionPublisherId: string; + /** This is the extension publisher id, or, in the case of a dynamically registered participant (remote agent), whatever publisher name we have for it */ + publisherDisplayName?: string; + extensionDisplayName: string; + /** The agent invoked when no agent is specified */ + isDefault?: boolean; + /** This agent is not contributed in package.json, but is registered dynamically */ + isDynamic?: boolean; + metadata: IChatAgentMetadata; + slashCommands: IChatAgentCommand[]; + locations: ChatAgentLocation[]; + disambiguation: { category: string; description: string; examples: string[] }[]; + supportsToolReferences?: boolean; +} + +export interface IChatAgentImplementation { + initSession(sessionId: string): void; + invoke(request: IChatAgentRequest, token: CancellationToken): Promise; + provideFollowups?(request: IChatAgentRequest, result: IChatAgentResult, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise; + provideWelcomeMessage?(location: ChatAgentLocation, token: CancellationToken): ProviderResult<(string | IMarkdownString)[] | undefined>; + provideChatTitle?: (history: IChatAgentHistoryEntry[], token: CancellationToken) => Promise; + provideSampleQuestions?(location: ChatAgentLocation, token: CancellationToken): ProviderResult; +} + +export interface IChatParticipantDetectionResult { + participant: string; + command?: string; +} + +export interface IChatParticipantMetadata { + participant: string; + command?: string; + disambiguation: { category: string; description: string; examples: string[] }[]; +} + +export interface IChatParticipantDetectionProvider { + provideParticipantDetection(request: IChatAgentRequest, history: IChatAgentHistoryEntry[], options: { location: ChatAgentLocation; participants: IChatParticipantMetadata[] }, token: CancellationToken): Promise; +} + +export type IChatAgent = IChatAgentData & IChatAgentImplementation; + +export interface IChatAgentCommand extends IRawChatCommandContribution { + followupPlaceholder?: string; +} + +export interface IChatRequesterInformation { + name: string; + + /** + * A full URI for the icon of the requester. + */ + icon?: URI; +} + +export interface IChatAgentMetadata { + helpTextPrefix?: string | IMarkdownString; + helpTextVariablesPrefix?: string | IMarkdownString; + helpTextPostfix?: string | IMarkdownString; + isSecondary?: boolean; // Invoked by ctrl/cmd+enter + icon?: URI; + iconDark?: URI; + themeIcon?: ThemeIcon; + sampleRequest?: string; + supportIssueReporting?: boolean; + followupPlaceholder?: string; + isSticky?: boolean; + requester?: IChatRequesterInformation; + supportsSlowVariables?: boolean; +} + + +export interface IChatAgentRequest { + mode: AgentMode; + sessionId: string; + requestId: string; + agentId: string; + command?: string; + message: string; + attempt?: number; + enableCommandDetection?: boolean; + isParticipantDetected?: boolean; + variables: IChatRequestVariableData; + location: ChatAgentLocation; + locationData?: IChatLocationData; + acceptedConfirmationData?: any[]; + rejectedConfirmationData?: any[]; + userSelectedModelId?: string; +} + +export interface IChatQuestion { + readonly prompt: string; + readonly participant?: string; + readonly command?: string; +} + +export interface IChatAgentResultTimings { + firstProgress?: number; + totalElapsed: number; +} + +export interface IChatAgentResult { + errorDetails?: IChatResponseErrorDetails; + /** Extra properties that the agent can use to identify a result */ + readonly metadata?: { readonly [key: string]: any }; + nextQuestion?: IChatQuestion; +} + +export const IAideAgentAgentService = createDecorator('aideAgentAgentService'); + +interface IChatAgentEntry { + data: IChatAgentData; + impl?: IChatAgentImplementation; +} + +export interface IChatAgentCompletionItem { + id: string; + name?: string; + fullName?: string; + icon?: ThemeIcon; + value: unknown; + command?: Command; +} + +export interface IAideAgentAgentService { + _serviceBrand: undefined; + /** + * undefined when an agent was removed IChatAgent + */ + readonly onDidChangeAgents: Event; + registerAgent(id: string, data: IChatAgentData): IDisposable; + registerAgentImplementation(id: string, agent: IChatAgentImplementation): IDisposable; + registerDynamicAgent(data: IChatAgentData, agentImpl: IChatAgentImplementation): IDisposable; + registerAgentCompletionProvider(id: string, provider: (query: string, token: CancellationToken) => Promise): IDisposable; + getAgentCompletionItems(id: string, query: string, token: CancellationToken): Promise; + registerChatParticipantDetectionProvider(handle: number, provider: IChatParticipantDetectionProvider): IDisposable; + detectAgentOrCommand(request: IChatAgentRequest, history: IChatAgentHistoryEntry[], options: { location: ChatAgentLocation }, token: CancellationToken): Promise<{ agent: IChatAgentData; command?: IChatAgentCommand } | undefined>; + hasChatParticipantDetectionProviders(): boolean; + initSession(id: string, sessionId: string): void; + invokeAgent(agent: string, request: IChatAgentRequest, token: CancellationToken): Promise; + getFollowups(id: string, request: IChatAgentRequest, result: IChatAgentResult, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise; + getChatTitle(id: string, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise; + getAgent(id: string): IChatAgentData | undefined; + getAgentByFullyQualifiedId(id: string): IChatAgentData | undefined; + getAgents(): IChatAgentData[]; + getActivatedAgents(): Array; + getAgentsByName(name: string): IChatAgentData[]; + agentHasDupeName(id: string): boolean; + + /** + * Get the default agent (only if activated) + */ + getDefaultAgent(location: ChatAgentLocation): IChatAgent | undefined; + + /** + * Get the default agent data that has been contributed (may not be activated yet) + */ + getContributedDefaultAgent(location: ChatAgentLocation): IChatAgentData | undefined; + getSecondaryAgent(): IChatAgentData | undefined; + updateAgent(id: string, updateMetadata: IChatAgentMetadata): void; +} + +export class ChatAgentService implements IAideAgentAgentService { + + public static readonly AGENT_LEADER = '@'; + + declare _serviceBrand: undefined; + + private _agents = new Map(); + + private readonly _onDidChangeAgents = new Emitter(); + readonly onDidChangeAgents: Event = this._onDidChangeAgents.event; + + private readonly _hasDefaultAgent: IContextKey; + private readonly _defaultAgentRegistered: IContextKey; + + constructor( + @IContextKeyService private readonly contextKeyService: IContextKeyService, + ) { + this._hasDefaultAgent = CONTEXT_CHAT_ENABLED.bindTo(this.contextKeyService); + this._defaultAgentRegistered = CONTEXT_CHAT_PANEL_PARTICIPANT_REGISTERED.bindTo(this.contextKeyService); + } + + registerAgent(id: string, data: IChatAgentData): IDisposable { + const existingAgent = this.getAgent(id); + if (existingAgent) { + throw new Error(`Agent already registered: ${JSON.stringify(id)}`); + } + + if (data.isDefault) { + this._defaultAgentRegistered.set(true); + } + + const that = this; + const commands = data.slashCommands; + data = { + ...data, + get slashCommands() { + return commands.filter(c => !c.when || that.contextKeyService.contextMatchesRules(ContextKeyExpr.deserialize(c.when))); + } + }; + const entry = { data }; + this._agents.set(id, entry); + this._onDidChangeAgents.fire(undefined); + return toDisposable(() => { + this._agents.delete(id); + if (data.isDefault) { + this._defaultAgentRegistered.set(false); + } + + this._onDidChangeAgents.fire(undefined); + }); + } + + registerAgentImplementation(id: string, agentImpl: IChatAgentImplementation): IDisposable { + const entry = this._agents.get(id); + if (!entry) { + throw new Error(`Unknown agent: ${JSON.stringify(id)}`); + } + + if (entry.impl) { + throw new Error(`Agent already has implementation: ${JSON.stringify(id)}`); + } + + if (entry.data.isDefault) { + this._hasDefaultAgent.set(true); + } + + entry.impl = agentImpl; + this._onDidChangeAgents.fire(new MergedChatAgent(entry.data, agentImpl)); + + return toDisposable(() => { + entry.impl = undefined; + this._onDidChangeAgents.fire(undefined); + + if (entry.data.isDefault) { + this._hasDefaultAgent.set(false); + } + }); + } + + registerDynamicAgent(data: IChatAgentData, agentImpl: IChatAgentImplementation): IDisposable { + data.isDynamic = true; + const agent = { data, impl: agentImpl }; + this._agents.set(data.id, agent); + this._onDidChangeAgents.fire(new MergedChatAgent(data, agentImpl)); + + return toDisposable(() => { + this._agents.delete(data.id); + this._onDidChangeAgents.fire(undefined); + }); + } + + private _agentCompletionProviders = new Map Promise>(); + + registerAgentCompletionProvider(id: string, provider: (query: string, token: CancellationToken) => Promise) { + this._agentCompletionProviders.set(id, provider); + return { + dispose: () => { this._agentCompletionProviders.delete(id); } + }; + } + + async getAgentCompletionItems(id: string, query: string, token: CancellationToken) { + return await this._agentCompletionProviders.get(id)?.(query, token) ?? []; + } + + updateAgent(id: string, updateMetadata: IChatAgentMetadata): void { + const agent = this._agents.get(id); + if (!agent?.impl) { + throw new Error(`No activated agent with id ${JSON.stringify(id)} registered`); + } + agent.data.metadata = { ...agent.data.metadata, ...updateMetadata }; + this._onDidChangeAgents.fire(new MergedChatAgent(agent.data, agent.impl)); + } + + getDefaultAgent(location: ChatAgentLocation): IChatAgent | undefined { + return findLast(this.getActivatedAgents(), a => !!a.isDefault && a.locations.includes(location)); + } + + getContributedDefaultAgent(location: ChatAgentLocation): IChatAgentData | undefined { + return this.getAgents().find(a => !!a.isDefault && a.locations.includes(location)); + } + + getSecondaryAgent(): IChatAgentData | undefined { + // TODO also static + return Iterable.find(this._agents.values(), a => !!a.data.metadata.isSecondary)?.data; + } + + getAgent(id: string): IChatAgentData | undefined { + if (!this._agentIsEnabled(id)) { + return; + } + + return this._agents.get(id)?.data; + } + + private _agentIsEnabled(id: string): boolean { + const entry = this._agents.get(id); + return !entry?.data.when || this.contextKeyService.contextMatchesRules(ContextKeyExpr.deserialize(entry.data.when)); + } + + getAgentByFullyQualifiedId(id: string): IChatAgentData | undefined { + const agent = Iterable.find(this._agents.values(), a => getFullyQualifiedId(a.data) === id)?.data; + if (agent && !this._agentIsEnabled(agent.id)) { + return; + } + + return agent; + } + + /** + * Returns all agent datas that exist- static registered and dynamic ones. + */ + getAgents(): IChatAgentData[] { + return Array.from(this._agents.values()) + .map(entry => entry.data) + .filter(a => this._agentIsEnabled(a.id)); + } + + getActivatedAgents(): IChatAgent[] { + return Array.from(this._agents.values()) + .filter(a => !!a.impl) + .filter(a => this._agentIsEnabled(a.data.id)) + .map(a => new MergedChatAgent(a.data, a.impl!)); + } + + getAgentsByName(name: string): IChatAgentData[] { + return this.getAgents().filter(a => a.name === name); + } + + agentHasDupeName(id: string): boolean { + const agent = this.getAgent(id); + if (!agent) { + return false; + } + + return this.getAgentsByName(agent.name) + .filter(a => a.extensionId.value !== agent.extensionId.value).length > 0; + } + + initSession(id: string, sessionId: string): void { + const data = this._agents.get(id); + if (!data?.impl) { + throw new Error(`No activated agent with id "${id}"`); + } + + data.impl.initSession(sessionId); + } + + async invokeAgent(id: string, request: IChatAgentRequest, token: CancellationToken): Promise { + const data = this._agents.get(id); + if (!data?.impl) { + throw new Error(`No activated agent with id "${id}"`); + } + + return await data.impl.invoke(request, token); + } + + async getFollowups(id: string, request: IChatAgentRequest, result: IChatAgentResult, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { + const data = this._agents.get(id); + if (!data?.impl) { + throw new Error(`No activated agent with id "${id}"`); + } + + if (!data.impl?.provideFollowups) { + return []; + } + + return data.impl.provideFollowups(request, result, history, token); + } + + async getChatTitle(id: string, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { + const data = this._agents.get(id); + if (!data?.impl) { + throw new Error(`No activated agent with id "${id}"`); + } + + if (!data.impl?.provideChatTitle) { + return undefined; + } + + return data.impl.provideChatTitle(history, token); + } + + private _chatParticipantDetectionProviders = new Map(); + registerChatParticipantDetectionProvider(handle: number, provider: IChatParticipantDetectionProvider) { + this._chatParticipantDetectionProviders.set(handle, provider); + return toDisposable(() => { + this._chatParticipantDetectionProviders.delete(handle); + }); + } + + hasChatParticipantDetectionProviders() { + return this._chatParticipantDetectionProviders.size > 0; + } + + async detectAgentOrCommand(request: IChatAgentRequest, history: IChatAgentHistoryEntry[], options: { location: ChatAgentLocation }, token: CancellationToken): Promise<{ agent: IChatAgentData; command?: IChatAgentCommand } | undefined> { + // TODO@joyceerhl should we have a selector to be able to narrow down which provider to use + const provider = Iterable.first(this._chatParticipantDetectionProviders.values()); + if (!provider) { + return; + } + + const participants = this.getAgents().reduce((acc, a) => { + acc.push({ participant: a.id, disambiguation: a.disambiguation ?? [] }); + for (const command of a.slashCommands) { + acc.push({ participant: a.id, command: command.name, disambiguation: command.disambiguation ?? [] }); + } + return acc; + }, []); + + const result = await provider.provideParticipantDetection(request, history, { ...options, participants }, token); + if (!result) { + return; + } + + const agent = this.getAgent(result.participant); + if (!agent) { + // Couldn't find a participant matching the participant detection result + return; + } + + if (!result.command) { + return { agent }; + } + + const command = agent?.slashCommands.find(c => c.name === result.command); + if (!command) { + // Couldn't find a slash command matching the participant detection result + return; + } + + return { agent, command }; + } +} + +export class MergedChatAgent implements IChatAgent { + constructor( + private readonly data: IChatAgentData, + private readonly impl: IChatAgentImplementation + ) { } + when?: string | undefined; + publisherDisplayName?: string | undefined; + isDynamic?: boolean | undefined; + + get id(): string { return this.data.id; } + get name(): string { return this.data.name ?? ''; } + get fullName(): string { return this.data.fullName ?? ''; } + get description(): string { return this.data.description ?? ''; } + get extensionId(): ExtensionIdentifier { return this.data.extensionId; } + get extensionPublisherId(): string { return this.data.extensionPublisherId; } + get extensionPublisherDisplayName() { return this.data.publisherDisplayName; } + get extensionDisplayName(): string { return this.data.extensionDisplayName; } + get isDefault(): boolean | undefined { return this.data.isDefault; } + get metadata(): IChatAgentMetadata { return this.data.metadata; } + get slashCommands(): IChatAgentCommand[] { return this.data.slashCommands; } + get locations(): ChatAgentLocation[] { return this.data.locations; } + get disambiguation(): { category: string; description: string; examples: string[] }[] { return this.data.disambiguation; } + + initSession(sessionId: string): void { + return this.impl.initSession(sessionId); + } + + async invoke(request: IChatAgentRequest, token: CancellationToken): Promise { + return this.impl.invoke(request, token); + } + + async provideFollowups(request: IChatAgentRequest, result: IChatAgentResult, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { + if (this.impl.provideFollowups) { + return this.impl.provideFollowups(request, result, history, token); + } + + return []; + } + + provideWelcomeMessage(location: ChatAgentLocation, token: CancellationToken): ProviderResult<(string | IMarkdownString)[] | undefined> { + if (this.impl.provideWelcomeMessage) { + return this.impl.provideWelcomeMessage(location, token); + } + + return undefined; + } + + provideSampleQuestions(location: ChatAgentLocation, token: CancellationToken): ProviderResult { + if (this.impl.provideSampleQuestions) { + return this.impl.provideSampleQuestions(location, token); + } + + return undefined; + } + + toJSON(): IChatAgentData { + return this.data; + } +} + +export const IAideAgentAgentNameService = createDecorator('aideAgentAgentNameService'); + +type IChatParticipantRegistry = { [name: string]: string[] }; + +interface IChatParticipantRegistryResponse { + readonly version: number; + readonly restrictedChatParticipants: IChatParticipantRegistry; +} + +export interface IAideAgentAgentNameService { + _serviceBrand: undefined; + getAgentNameRestriction(chatAgentData: IChatAgentData): boolean; +} + +export class ChatAgentNameService implements IAideAgentAgentNameService { + + private static readonly StorageKey = 'chat.participantNameRegistry'; + + declare _serviceBrand: undefined; + + private readonly url!: string; + private registry = observableValue(this, Object.create(null)); + private disposed = false; + + constructor( + @IProductService productService: IProductService, + @IRequestService private readonly requestService: IRequestService, + @ILogService private readonly logService: ILogService, + @IStorageService private readonly storageService: IStorageService + ) { + if (!productService.chatParticipantRegistry) { + return; + } + + this.url = productService.chatParticipantRegistry; + + const raw = storageService.get(ChatAgentNameService.StorageKey, StorageScope.APPLICATION); + + try { + this.registry.set(JSON.parse(raw ?? '{}'), undefined); + } catch (err) { + storageService.remove(ChatAgentNameService.StorageKey, StorageScope.APPLICATION); + } + + this.refresh(); + } + + private refresh(): void { + if (this.disposed) { + return; + } + + this.update() + .catch(err => this.logService.warn('Failed to fetch chat participant registry', err)) + .then(() => timeout(5 * 60 * 1000)) // every 5 minutes + .then(() => this.refresh()); + } + + private async update(): Promise { + const context = await this.requestService.request({ type: 'GET', url: this.url }, CancellationToken.None); + + if (context.res.statusCode !== 200) { + throw new Error('Could not get extensions report.'); + } + + const result = await asJson(context); + + if (!result || result.version !== 1) { + throw new Error('Unexpected chat participant registry response.'); + } + + const registry = result.restrictedChatParticipants; + this.registry.set(registry, undefined); + this.storageService.store(ChatAgentNameService.StorageKey, JSON.stringify(registry), StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + /** + * Returns true if the agent is allowed to use this name + */ + getAgentNameRestriction(chatAgentData: IChatAgentData): boolean { + // TODO would like to use observables here but nothing uses it downstream and I'm not sure how to combine these two + const nameAllowed = this.checkAgentNameRestriction(chatAgentData.name, chatAgentData).get(); + const fullNameAllowed = !chatAgentData.fullName || this.checkAgentNameRestriction(chatAgentData.fullName.replace(/\s/g, ''), chatAgentData).get(); + return nameAllowed && fullNameAllowed; + } + + private checkAgentNameRestriction(name: string, chatAgentData: IChatAgentData): IObservable { + // Registry is a map of name to an array of extension publisher IDs or extension IDs that are allowed to use it. + // Look up the list of extensions that are allowed to use this name + const allowList = this.registry.map(registry => registry[name.toLowerCase()]); + return allowList.map(allowList => { + if (!allowList) { + return true; + } + + return allowList.some(id => equalsIgnoreCase(id, id.includes('.') ? chatAgentData.extensionId.value : chatAgentData.extensionPublisherId)); + }); + } + + dispose() { + this.disposed = true; + } +} + +export function getFullyQualifiedId(chatAgentData: IChatAgentData): string { + return `${chatAgentData.extensionId.value}.${chatAgentData.id}`; +} + +export function reviveSerializedAgent(raw: ISerializableChatAgentData): IChatAgentData { + const agent = 'name' in raw ? + raw : + { + ...(raw as any), + name: (raw as any).id, + }; + + // Fill in required fields that may be missing from old data + if (!('extensionPublisherId' in agent)) { + agent.extensionPublisherId = agent.extensionPublisher ?? ''; + } + + if (!('extensionDisplayName' in agent)) { + agent.extensionDisplayName = ''; + } + + if (!('extensionId' in agent)) { + agent.extensionId = new ExtensionIdentifier(''); + } + + return revive(agent); +} diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentCodeMapperService.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentCodeMapperService.ts new file mode 100644 index 00000000000..4fa2e9d4c4b --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentCodeMapperService.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { TextEdit } from '../../../../editor/common/languages.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + + +export interface ICodeMapperResponse { + textEdit: (resource: URI, textEdit: TextEdit[]) => void; +} + +export interface ICodeMapperCodeBlock { + code: string; + resource: URI; +} + +export interface ConversationRequest { + readonly type: 'request'; + readonly message: string; +} + +export interface ConversationResponse { + readonly type: 'response'; + readonly message: string; + // readonly references?: DocumentContextItem[]; +} + +export interface ICodeMapperRequest { + codeBlocks: ICodeMapperCodeBlock[]; + conversation: (ConversationRequest | ConversationResponse)[]; +} + +export interface ICodeMapperResult { + errorMessage?: string; +} + +export interface ICodeMapperProvider { + mapCode(request: ICodeMapperRequest, response: ICodeMapperResponse, token: CancellationToken): Promise; +} + +export const IAideAgentCodeMapperService = createDecorator('aideAgentcodeMapperService'); + +export interface IAideAgentCodeMapperService { + readonly _serviceBrand: undefined; + registerCodeMapperProvider(handle: number, provider: ICodeMapperProvider): IDisposable; + mapCode(request: ICodeMapperRequest, response: ICodeMapperResponse, token: CancellationToken): Promise; +} + +export class CodeMapperService implements IAideAgentCodeMapperService { + _serviceBrand: undefined; + + private readonly providers: ICodeMapperProvider[] = []; + + registerCodeMapperProvider(handle: number, provider: ICodeMapperProvider): IDisposable { + this.providers.push(provider); + return { + dispose: () => { + const index = this.providers.indexOf(provider); + if (index >= 0) { + this.providers.splice(index, 1); + } + } + }; + } + + async mapCode(request: ICodeMapperRequest, response: ICodeMapperResponse, token: CancellationToken) { + for (const provider of this.providers) { + const result = await provider.mapCode(request, response, token); + if (result) { + return result; + } + } + return undefined; + } +} diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentColors.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentColors.ts new file mode 100644 index 00000000000..b8ce6697597 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentColors.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Color, RGBA } from '../../../../base/common/color.js'; +import { localize } from '../../../../nls.js'; +import { badgeBackground, badgeForeground, contrastBorder, editorBackground, editorWidgetBackground, foreground, registerColor, transparent } from '../../../../platform/theme/common/colorRegistry.js'; + +export const chatRequestBorder = registerColor( + 'chat.requestBorder', + { dark: new Color(new RGBA(255, 255, 255, 0.10)), light: new Color(new RGBA(0, 0, 0, 0.10)), hcDark: contrastBorder, hcLight: contrastBorder, }, + localize('chat.requestBorder', 'The border color of a chat request.') +); + +export const chatRequestBackground = registerColor( + 'chat.requestBackground', + { dark: transparent(editorBackground, 0.62), light: transparent(editorBackground, 0.62), hcDark: editorWidgetBackground, hcLight: null }, + localize('chat.requestBackground', 'The background color of a chat request.') +); + +export const chatSlashCommandBackground = registerColor( + 'chat.slashCommandBackground', + { dark: '#34414b8f', light: '#d2ecff99', hcDark: Color.white, hcLight: badgeBackground }, + localize('chat.slashCommandBackground', 'The background color of a chat slash command.') +); + +export const chatSlashCommandForeground = registerColor( + 'chat.slashCommandForeground', + { dark: '#40A6FF', light: '#306CA2', hcDark: Color.black, hcLight: badgeForeground }, + localize('chat.slashCommandForeground', 'The foreground color of a chat slash command.') +); + +export const chatAvatarBackground = registerColor( + 'chat.avatarBackground', + { dark: '#1f1f1f', light: '#f2f2f2', hcDark: Color.black, hcLight: Color.white, }, + localize('chat.avatarBackground', 'The background color of a chat avatar.') +); + +export const chatAvatarForeground = registerColor( + 'chat.avatarForeground', + foreground, + localize('chat.avatarForeground', 'The foreground color of a chat avatar.') +); diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentContextKeys.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentContextKeys.ts new file mode 100644 index 00000000000..98e06494f83 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentContextKeys.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ChatAgentLocation } from './aideAgentAgents.js'; + +export const CONTEXT_RESPONSE_VOTE = new RawContextKey('aideAgentSessionResponseVote', '', { type: 'string', description: localize('interactiveSessionResponseVote', "When the response has been voted up, is set to 'up'. When voted down, is set to 'down'. Otherwise an empty string.") }); +export const CONTEXT_VOTE_UP_ENABLED = new RawContextKey('aideAgentVoteUpEnabled', false, { type: 'boolean', description: localize('chatVoteUpEnabled', "True when the chat vote up action is enabled.") }); +export const CONTEXT_RESPONSE_DETECTED_AGENT_COMMAND = new RawContextKey('aideAgentSessionResponseDetectedAgentOrCommand', false, { type: 'boolean', description: localize('chatSessionResponseDetectedAgentOrCommand', "When the agent or command was automatically detected") }); +export const CONTEXT_CHAT_RESPONSE_SUPPORT_ISSUE_REPORTING = new RawContextKey('aideAgentResponseSupportsIssueReporting', false, { type: 'boolean', description: localize('chatResponseSupportsIssueReporting', "True when the current chat response supports issue reporting.") }); +export const CONTEXT_RESPONSE_FILTERED = 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_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") }); + +export const CONTEXT_CHAT_EDIT_APPLIED = new RawContextKey('aideAgentEditApplied', false, { type: 'boolean', description: localize('chatEditApplied', "True when the chat text edits have been applied.") }); + +export const CONTEXT_CHAT_INPUT_HAS_TEXT = new RawContextKey('aideAgentInputHasText', false, { type: 'boolean', description: localize('interactiveInputHasText', "True when the chat input has text.") }); +export const CONTEXT_CHAT_INPUT_HAS_FOCUS = new RawContextKey('aideAgentInputHasFocus', false, { type: 'boolean', description: localize('interactiveInputHasFocus', "True when the chat input has focus.") }); +export const CONTEXT_IN_CHAT_INPUT = new RawContextKey('inAideAgentInput', false, { type: 'boolean', description: localize('inInteractiveInput', "True when focus is in the chat input, false otherwise.") }); +export const CONTEXT_IN_CHAT_SESSION = new RawContextKey('inAideAgent', false, { type: 'boolean', description: localize('inChat', "True when focus is in the chat widget, false otherwise.") }); + +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_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); +export const CONTEXT_CHAT_LOCATION = new RawContextKey('aideAgentLocation', undefined); + +export const CONTEXT_LANGUAGE_MODELS_ARE_USER_SELECTABLE = new RawContextKey('aideAgentModelsAreUserSelectable', false, { type: 'boolean', description: localize('chatModelsAreUserSelectable', "True when the chat model can be selected manually by the user.") }); +export const CONTEXT_PARTICIPANT_SUPPORTS_MODEL_PICKER = new RawContextKey('aideAgentParticipantSupportsModelPicker', true, { type: 'boolean', description: localize('chatParticipantSupportsModelPicker', "True when the current chat participant supports picking the model manually.") }); 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..dea7d8f0b3d --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentEditingService.ts @@ -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. + *--------------------------------------------------------------------------------------------*/ + +import { IObservable, ITransaction } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { TextEdit } from '../../../../editor/common/languages.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export const IAideAgentEditingService = createDecorator('aideAgentEditingService'); + +export interface IAideAgentEditingService { + _serviceBrand: undefined; + + readonly currentEditingSession: IChatEditingSession | null; + + createEditingSession(builder: (stream: IChatEditingSessionStream) => Promise): Promise; +} + +export interface IChatEditingSession { + readonly state: IObservable; + readonly entries: IObservable; + +} + +export interface IModifiedFileEntry { + readonly originalURI: URI; + readonly modifiedURI: URI; + accept(transaction: ITransaction | undefined): Promise; +} + +export interface IChatEditingSessionStream { + textEdits(resource: URI, textEdits: TextEdit[]): void; +} + +export const enum ChatEditingSessionState { + StreamingEdits = 1, + Idle = 2 +} diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentModel.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentModel.ts index 390daf81fec..18cc357aade 100644 --- a/src/vs/workbench/contrib/aideAgent/common/aideAgentModel.ts +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentModel.ts @@ -3,145 +3,1285 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IMarkdownString } from '../../../../base/common/htmlContent.js'; +import { asArray } from '../../../../base/common/arrays.js'; +import { DeferredPromise } from '../../../../base/common/async.js'; +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 { equals } from '../../../../base/common/objects.js'; +import { basename, isEqual } from '../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI, UriComponents, UriDto, isUriComponents } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; -import { IAgentResponseProgress } from './aideAgentService.js'; +import { IOffsetRange, OffsetRange } from '../../../../editor/common/core/offsetRange.js'; +import { IRange } from '../../../../editor/common/core/range.js'; +import { TextEdit } from '../../../../editor/common/languages.js'; +import { localize } from '../../../../nls.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { ChatAgentLocation, IAideAgentAgentService, IChatAgentCommand, IChatAgentData, IChatAgentResult, reviveSerializedAgent } from './aideAgentAgents.js'; +import { ChatRequestTextPart, IParsedChatRequest, reviveParsedChatRequest } from './aideAgentParserTypes.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatFollowup, IChatLocationData, IChatMarkdownContent, IChatProgress, IChatProgressMessage, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatTask, IChatTextEdit, IChatTreeData, IChatUsedContext, IChatWarningMessage, isIUsedContext } from './aideAgentService.js'; +import { IChatRequestVariableValue } from './aideAgentVariables.js'; -export enum AideAgentScope { - Selection = 'Selection', - PinnedContext = 'PinnedContext', - WholeCodebase = 'WholeCodebase', +export function isRequestModel(item: unknown): item is IChatRequestModel { + return !!item && typeof item === 'object' && 'message' in item; } -export interface IAgentTriggerPayload { +export function isResponseModel(item: unknown): item is IChatResponseModel { + return !!item && typeof (item as IChatResponseModel).setVote !== 'undefined'; +} + +export function isWelcomeModel(item: unknown): item is IChatWelcomeMessageModel { + return !!item && typeof item === 'object' && 'content' in item; +} + +export interface IChatRequestVariableEntry { + id: string; + fullName?: string; + icon?: ThemeIcon; + name: string; + modelDescription?: string; + range?: IOffsetRange; + value: IChatRequestVariableValue; + references?: IChatContentReference[]; + + // TODO are these just a 'kind'? + isDynamic?: boolean; + isFile?: boolean; + isTool?: boolean; +} + +export interface IChatRequestVariableData { + variables: IChatRequestVariableEntry[]; +} + +export interface IChatRequestModel { readonly id: string; - readonly message: string; - readonly scope: AideAgentScope; + readonly username: string; + readonly avatarIconUri?: URI; + readonly session: IChatModel; + readonly message: IParsedChatRequest; + readonly attempt: number; + readonly variableData: IChatRequestVariableData; + readonly confirmation?: string; + readonly locationData?: IChatLocationData; + readonly attachedContext?: IChatRequestVariableEntry[]; +} + +export type IChatExchangeModel = IChatRequestModel | IChatResponseModel; + +export interface IChatTextEditGroupState { + sha1: string; + applied: number; } -export interface IAgentContentModel { - readonly kind: 'content'; - readonly exchangeId: string; - readonly message: string | IMarkdownString; +export interface IChatTextEditGroup { + uri: URI; + edits: TextEdit[][]; + state?: IChatTextEditGroupState; + kind: 'textEditGroup'; } -export class AgentContentModel implements IAgentContentModel { - declare kind: 'content'; - readonly exchangeId: string; +export type IChatProgressResponseContent = + | IChatMarkdownContent + | IChatAgentMarkdownContentWithVulnerability + | IChatResponseCodeblockUriPart + | IChatTreeData + | IChatContentInlineReference + | IChatProgressMessage + | IChatCommandButton + | IChatWarningMessage + | IChatTask + | IChatTextEditGroup + | IChatConfirmation; + +export type IChatProgressRenderableResponseContent = Exclude; + +export interface IResponse { + readonly value: ReadonlyArray; + toMarkdown(): string; + toString(): string; +} + +export interface IChatResponseModel { + readonly onDidChange: Event; + readonly id: string; + // readonly requestId: string; + readonly username: string; + readonly avatarIcon?: ThemeIcon | URI; + readonly session: IChatModel; + readonly agent?: IChatAgentData; + readonly usedContext: IChatUsedContext | undefined; + readonly contentReferences: ReadonlyArray; + readonly codeCitations: ReadonlyArray; + readonly progressMessages: ReadonlyArray; + readonly slashCommand?: IChatAgentCommand; + readonly agentOrSlashCommandDetected: boolean; + readonly response: IResponse; + readonly isComplete: boolean; + readonly isCanceled: 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 vote: ChatAgentVoteDirection | undefined; + readonly voteDownReason: ChatAgentVoteDownReason | undefined; + readonly followups?: IChatFollowup[] | undefined; + readonly result?: IChatAgentResult; + setVote(vote: ChatAgentVoteDirection): void; + setVoteDownReason(reason: ChatAgentVoteDownReason | undefined): void; + setEditApplied(edit: IChatTextEditGroup, editCount: number): boolean; +} + +export class ChatRequestModel implements IChatRequestModel { + private static nextId = 0; + + public readonly id: string; + + public get session() { + return this._session; + } + + public get username(): string { + return this.session.requesterUsername; + } + + public get avatarIconUri(): URI | undefined { + return this.session.requesterAvatarIconUri; + } + + public get attempt(): number { + return this._attempt; + } + + public get variableData(): IChatRequestVariableData { + return this._variableData; + } + + public set variableData(v: IChatRequestVariableData) { + this._variableData = v; + } + + public get confirmation(): string | undefined { + return this._confirmation; + } + + public get locationData(): IChatLocationData | undefined { + return this._locationData; + } + + public get attachedContext(): IChatRequestVariableEntry[] | undefined { + return this._attachedContext; + } constructor( - public readonly message: string | IMarkdownString + private _session: ChatModel, + public readonly message: IParsedChatRequest, + private _variableData: IChatRequestVariableData, + private _attempt: number = 0, + private _confirmation?: string, + private _locationData?: IChatLocationData, + private _attachedContext?: IChatRequestVariableEntry[] ) { - this.exchangeId = generateUuid(); + this.id = 'request_' + ChatRequestModel.nextId++; } } -export interface IAgentActionModel { - readonly kind: 'action'; - readonly exchangeId: string; -} +export class Response extends Disposable implements IResponse { + private _onDidChangeValue = this._register(new Emitter()); + public get onDidChangeValue() { + return this._onDidChangeValue.event; + } -export class AgentActionModel implements IAgentActionModel { - declare kind: 'action'; - readonly exchangeId: string; + private _responseParts: IChatProgressResponseContent[]; - constructor() { - this.exchangeId = generateUuid(); + /** + * A stringified representation of response data which might be presented to a screenreader or used when copying a response. + */ + private _responseRepr = ''; + + /** + * Just the markdown content of the response, used for determining the rendering rate of markdown + */ + private _markdownContent = ''; + + private _citations: IChatCodeCitation[] = []; + + get value(): IChatProgressResponseContent[] { + return this._responseParts; } -} -export type IAgentExchangeData = IAgentContentModel | IAgentActionModel; + constructor(value: IMarkdownString | ReadonlyArray) { + super(); + this._responseParts = asArray(value).map((v) => (isMarkdownString(v) ? + { content: v, kind: 'markdownContent' } satisfies IChatMarkdownContent : + 'kind' in v ? v : { kind: 'treeData', treeData: v })); + + this._updateRepr(true); + } -export interface IAgentExchangeBlock { - readonly exchanges: IAgentExchangeData[]; - next?: IAgentExchangeBlock; -} + override toString(): string { + return this._responseRepr; + } -class AgentExchangeSequence { - private _first: IAgentExchangeBlock; + toMarkdown(): string { + return this._markdownContent; + } - constructor() { - this._first = { exchanges: [] }; + clear(): void { + this._responseParts = []; + this._updateRepr(true); } - addTrigger(exchange: IAgentExchangeData): void { - let current = this._first; - while (current.next) { - current = current.next; + updateContent(progress: IChatProgressResponseContent | IChatTextEdit | IChatTask, quiet?: boolean): void { + if (progress.kind === 'markdownContent') { + const responsePartLength = this._responseParts.length - 1; + const lastResponsePart = this._responseParts[responsePartLength]; + + 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); + } + 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] + }); + } + this._updateRepr(quiet); + } + } else if (progress.kind === 'progressTask') { + // Add a new resolving part + const responsePosition = this._responseParts.push(progress) - 1; + this._updateRepr(quiet); + + const disp = progress.onDidAddProgress(() => { + this._updateRepr(false); + }); + + progress.task?.().then((content) => { + // Stop listening for progress updates once the task settles + disp.dispose(); + + // Replace the resolving part's content with the resolved response + if (typeof content === 'string') { + (this._responseParts[responsePosition] as IChatTask).content = new MarkdownString(content); + } + this._updateRepr(false); + }); + + } else { + this._responseParts.push(progress); + this._updateRepr(quiet); } + } - current.next = { exchanges: [exchange] }; + public addCitation(citation: IChatCodeCitation) { + this._citations.push(citation); + this._updateRepr(); } - addExchange(parentId: string, exchange: IAgentExchangeData): void { - let current = this._first; - while (current) { - if (current.exchanges.some(ex => ex.exchangeId === parentId)) { - const newBlock: IAgentExchangeBlock = { exchanges: [exchange] }; - newBlock.next = current.next; + 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 { + return part.content.value; } + }) + .filter(s => s.length > 0) + .join('\n\n'); + + this._responseRepr += this._citations.length ? '\n\n' + getCodeCitationsMessage(this._citations) : ''; - if (!current.next) { - current.next = { exchanges: [] }; + this._markdownContent = this._responseParts.map(part => { + if (part.kind === 'inlineReference') { + return inlineRefToRepr(part); + } else if (part.kind === 'markdownContent' || part.kind === 'markdownVuln') { + return part.content.value; + } else { + return ''; } + }) + .filter(s => s.length > 0) + .join('\n\n'); - current = current.next; + if (!quiet) { + this._onDidChangeValue.fire(); } } +} + +export class ChatResponseModel extends Disposable implements IChatResponseModel { + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + private static nextId = 0; + + public readonly id: string; + + public get session() { + return this._session; + } + + public get isComplete(): boolean { + return this._isComplete; + } + + public get isCanceled(): boolean { + return this._isCanceled; + } + + public get vote(): ChatAgentVoteDirection | undefined { + return this._vote; + } + + public get voteDownReason(): ChatAgentVoteDownReason | undefined { + return this._voteDownReason; + } + + public get followups(): IChatFollowup[] | undefined { + return this._followups; + } + + private _response: Response; + public get response(): IResponse { + return this._response; + } + + public get result(): IChatAgentResult | undefined { + return this._result; + } + + public get username(): string { + return this.session.responderUsername; + } + + public get avatarIcon(): ThemeIcon | URI | undefined { + return this.session.responderAvatarIcon; + } + + private _followups?: IChatFollowup[]; - [Symbol.iterator](): Iterator { - let current: IAgentExchangeBlock | undefined = this._first; + public get agent(): IChatAgentData | undefined { + return this._agent; + } + + public get slashCommand(): IChatAgentCommand | undefined { + return this._slashCommand; + } + + private _agentOrSlashCommandDetected: boolean | undefined; + public get agentOrSlashCommandDetected(): boolean { + return this._agentOrSlashCommandDetected ?? false; + } + + private _usedContext: IChatUsedContext | undefined; + public get usedContext(): IChatUsedContext | undefined { + return this._usedContext; + } + + private readonly _contentReferences: IChatContentReference[] = []; + public get contentReferences(): ReadonlyArray { + return this._contentReferences; + } + + private readonly _codeCitations: IChatCodeCitation[] = []; + public get codeCitations(): ReadonlyArray { + return this._codeCitations; + } + + private readonly _progressMessages: IChatProgressMessage[] = []; + public get progressMessages(): ReadonlyArray { + return this._progressMessages; + } + + private _isStale: boolean = false; + public get isStale(): boolean { + return this._isStale; + } + + constructor( + _response: IMarkdownString | ReadonlyArray, + private _session: ChatModel, + private _agent: IChatAgentData | undefined, + private _slashCommand: IChatAgentCommand | undefined, + // public readonly requestId: string, + private _isComplete: boolean = false, + private _isCanceled = false, + private _vote?: ChatAgentVoteDirection, + private _voteDownReason?: ChatAgentVoteDownReason, + private _result?: IChatAgentResult, + followups?: ReadonlyArray + ) { + super(); + + // If we are creating a response with some existing content, consider it stale + this._isStale = Array.isArray(_response) && (_response.length !== 0 || isMarkdownString(_response) && _response.value.length !== 0); + + this._followups = followups ? [...followups] : undefined; + this._response = this._register(new Response(_response)); + this._register(this._response.onDidChangeValue(() => this._onDidChange.fire())); + this.id = 'response_' + ChatResponseModel.nextId++; + } + + /** + * Apply a progress update to the actual response content. + */ + updateContent(responsePart: IChatProgressResponseContent | IChatTextEdit, quiet?: boolean) { + this._response.updateContent(responsePart, quiet); + } + + /** + * Apply one of the progress updates that are not part of the actual response content. + */ + applyReference(progress: IChatUsedContext | IChatContentReference) { + if (progress.kind === 'usedContext') { + this._usedContext = progress; + } else if (progress.kind === 'reference') { + this._contentReferences.push(progress); + this._onDidChange.fire(); + } + } + + applyCodeCitation(progress: IChatCodeCitation) { + this._codeCitations.push(progress); + this._response.addCitation(progress); + this._onDidChange.fire(); + } + + setAgent(agent: IChatAgentData, slashCommand?: IChatAgentCommand) { + this._agent = agent; + this._slashCommand = slashCommand; + this._agentOrSlashCommandDetected = true; + this._onDidChange.fire(); + } + + setResult(result: IChatAgentResult): void { + this._result = result; + this._onDidChange.fire(); + } + + complete(): void { + if (this._result?.errorDetails?.responseIsRedacted) { + this._response.clear(); + } + + this._isComplete = true; + this._onDidChange.fire(); + } + + cancel(): void { + this._isComplete = true; + this._isCanceled = true; + this._onDidChange.fire(); + } + + setFollowups(followups: IChatFollowup[] | undefined): void { + this._followups = followups; + this._onDidChange.fire(); // Fire so that command followups get rendered on the row + } + + setVote(vote: ChatAgentVoteDirection): void { + this._vote = vote; + this._onDidChange.fire(); + } + + setVoteDownReason(reason: ChatAgentVoteDownReason | undefined): void { + this._voteDownReason = reason; + this._onDidChange.fire(); + } + + setEditApplied(edit: IChatTextEditGroup, editCount: number): boolean { + if (!this.response.value.includes(edit)) { + return false; + } + if (!edit.state) { + return false; + } + edit.state.applied = editCount; // must not be edit.edits.length + this._onDidChange.fire(); + return true; + } +} + +export interface IChatModel { + readonly onDidDispose: Event; + readonly onDidChange: Event; + readonly sessionId: string; + readonly initState: ChatModelInitState; + readonly initialLocation: ChatAgentLocation; + readonly title: string; + readonly welcomeMessage: IChatWelcomeMessageModel | undefined; + readonly requestInProgress: boolean; + readonly inputPlaceholder?: string; + getExchanges(): IChatExchangeModel[]; + toExport(): IExportableChatData; + toJSON(): ISerializableChatData; +} + +export interface ISerializableChatsData { + [sessionId: string]: ISerializableChatData; +} + +export type ISerializableChatAgentData = UriDto; + +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; + response: ReadonlyArray | undefined; + agent?: ISerializableChatAgentData; + slashCommand?: IChatAgentCommand; + // responseErrorDetails: IChatResponseErrorDetails | undefined; + result?: IChatAgentResult; // Optional for backcompat + followups: ReadonlyArray | undefined; + isCanceled: boolean | undefined; + vote: ChatAgentVoteDirection | undefined; + voteDownReason?: ChatAgentVoteDownReason; + /** For backward compat: should be optional */ + usedContext?: IChatUsedContext; + contentReferences?: ReadonlyArray; + codeCitations?: ReadonlyArray; +} + +export interface IExportableChatData { + initialLocation: ChatAgentLocation | undefined; + welcomeMessage: (string | IChatFollowup[])[] | undefined; + requests: ISerializableChatRequestData[]; + requesterUsername: string; + responderUsername: string; + requesterAvatarIconUri: UriComponents | undefined; + responderAvatarIconUri: ThemeIcon | UriComponents | undefined; // Keeping Uri name for backcompat +} + +/* + NOTE: every time the serialized data format is updated, we need to create a new interface, because we may need to handle any old data format when parsing. +*/ + +export interface ISerializableChatData1 extends IExportableChatData { + sessionId: string; + creationDate: number; + isImported: boolean; + + /** Indicates that this session was created in this window. Is cleared after the chat has been written to storage once. Needed to sync chat creations/deletions between empty windows. */ + isNew?: boolean; +} + +export interface ISerializableChatData2 extends ISerializableChatData1 { + version: 2; + lastMessageDate: number; + computedTitle: string | undefined; +} + +export interface ISerializableChatData3 extends Omit { + version: 3; + customTitle: string | undefined; +} + +/** + * Chat data that has been parsed and normalized to the current format. + */ +export type ISerializableChatData = ISerializableChatData3; + +/** + * Chat data that has been loaded but not normalized, and could be any format + */ +export type ISerializableChatDataIn = ISerializableChatData1 | ISerializableChatData2 | ISerializableChatData3; + +/** + * Normalize chat data from storage to the current format. + * TODO- ChatModel#_deserialize and reviveSerializedAgent also still do some normalization and maybe that should be done in here too. + */ +export function normalizeSerializableChatData(raw: ISerializableChatDataIn): ISerializableChatData { + normalizeOldFields(raw); + + if (!('version' in raw)) { return { - next(): IteratorResult { - if (!current) { - return { done: true, value: undefined }; - } + version: 3, + ...raw, + lastMessageDate: raw.creationDate, + customTitle: undefined, + }; + } - const value = current; - current = current.next; - return { done: false, value }; - } + if (raw.version === 2) { + return { + ...raw, + version: 3, + customTitle: raw.computedTitle }; } + + return raw; +} + +function normalizeOldFields(raw: ISerializableChatDataIn): void { + // Fill in fields that very old chat data may be missing + if (!raw.sessionId) { + raw.sessionId = generateUuid(); + } + + if (!raw.creationDate) { + raw.creationDate = getLastYearDate(); + } + + if ('version' in raw && (raw.version === 2 || raw.version === 3)) { + if (!raw.lastMessageDate) { + // A bug led to not porting creationDate properly, and that was copied to lastMessageDate, so fix that up if missing. + raw.lastMessageDate = getLastYearDate(); + } + } } -export interface IAideAgentModel { - readonly sessionId: string; - getExchanges(): Array; +function getLastYearDate(): number { + const lastYearDate = new Date(); + lastYearDate.setFullYear(lastYearDate.getFullYear() - 1); + return lastYearDate.getTime(); +} + +export function isExportableSessionData(obj: unknown): obj is IExportableChatData { + const data = obj as IExportableChatData; + return typeof data === 'object' && + typeof data.requesterUsername === 'string'; } -export class AideAgentModel extends Disposable implements IAideAgentModel { - private readonly _sessionId: string; +export function isSerializableSessionData(obj: unknown): obj is ISerializableChatData { + const data = obj as ISerializableChatData; + return isExportableSessionData(obj) && + typeof data.creationDate === 'number' && + typeof data.sessionId === 'string' && + obj.requests.every((request: ISerializableChatRequestData) => + !request.usedContext /* for backward compat allow missing usedContext */ || isIUsedContext(request.usedContext) + ); +} + +export type IChatChangeEvent = + | IChatInitEvent + | IChatAddRequestEvent | IChatChangedRequestEvent | IChatRemoveRequestEvent + | IChatAddResponseEvent + | IChatSetAgentEvent + | IChatMoveEvent + ; + +export interface IChatAddRequestEvent { + kind: 'addRequest'; + request: IChatRequestModel; +} + +export interface IChatChangedRequestEvent { + kind: 'changedRequest'; + request: IChatRequestModel; +} + +export interface IChatAddResponseEvent { + kind: 'addResponse'; + response: IChatResponseModel; +} + +export const enum ChatRequestRemovalReason { + /** + * "Normal" remove + */ + Removal, + + /** + * Removed because the request will be resent + */ + Resend, +} + +export interface IChatRemoveRequestEvent { + kind: 'removeRequest'; + requestId: string; + responseId?: string; + reason: ChatRequestRemovalReason; +} + +export interface IChatMoveEvent { + kind: 'move'; + target: URI; + range: IRange; +} + +export interface IChatSetAgentEvent { + kind: 'setAgent'; + agent: IChatAgentData; + command?: IChatAgentCommand; +} + +export interface IChatInitEvent { + kind: 'initialize'; +} + +export enum ChatModelInitState { + Created, + Initializing, + Initialized +} + +export enum AgentMode { + Chat = 'Chat', + Edit = 'Edit' +} + +export class ChatModel extends Disposable implements IChatModel { + static getDefaultTitle(requests: (ISerializableChatRequestData | IChatExchangeModel)[]): string { + const firstRequestMessage = requests.find(r => isRequestModel(r)); + const message = firstRequestMessage?.message.text ?? 'Session'; + return message.split('\n')[0].substring(0, 50); + } + + private readonly _onDidDispose = this._register(new Emitter()); + readonly onDidDispose = this._onDidDispose.event; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + private _exchanges: IChatExchangeModel[]; + private _initState: ChatModelInitState = ChatModelInitState.Created; + private _isInitializedDeferred = new DeferredPromise(); + + private _welcomeMessage: ChatWelcomeMessageModel | undefined; + get welcomeMessage(): ChatWelcomeMessageModel | undefined { + return this._welcomeMessage; + } + + // TODO to be clear, this is not the same as the id from the session object, which belongs to the provider. + // It's easier to be able to identify this model before its async initialization is complete + private _sessionId: string; get sessionId(): string { return this._sessionId; } - private readonly _exchanges: AgentExchangeSequence; - getExchanges(): Array { - return Array.from(this._exchanges); + get requestInProgress(): boolean { + const lastExchange = this._exchanges[this._exchanges.length - 1]; + return !!lastExchange && 'response' in lastExchange && !lastExchange.isComplete; + } + + get hasRequests(): boolean { + return this._exchanges.length > 0; + } + + get lastExchange(): IChatExchangeModel | undefined { + return this._exchanges.at(-1); + } + + private _creationDate: number; + get creationDate(): number { + return this._creationDate; + } + + private _lastMessageDate: number; + get lastMessageDate(): number { + return this._lastMessageDate; + } + + private get _defaultAgent() { + return this.chatAgentService.getDefaultAgent(ChatAgentLocation.Panel); + } + + get requesterUsername(): string { + return this._defaultAgent?.metadata.requester?.name ?? + this.initialData?.requesterUsername ?? ''; + } + + get responderUsername(): string { + return this._defaultAgent?.fullName ?? + this.initialData?.responderUsername ?? ''; } - constructor() { + private readonly _initialRequesterAvatarIconUri: URI | undefined; + get requesterAvatarIconUri(): URI | undefined { + return this._defaultAgent?.metadata.requester?.icon ?? + this._initialRequesterAvatarIconUri; + } + + private readonly _initialResponderAvatarIconUri: ThemeIcon | URI | undefined; + get responderAvatarIcon(): ThemeIcon | URI | undefined { + return this._defaultAgent?.metadata.themeIcon ?? + this._initialResponderAvatarIconUri; + } + + get initState(): ChatModelInitState { + return this._initState; + } + + private _isImported = false; + get isImported(): boolean { + return this._isImported; + } + + private _customTitle: string | undefined; + get customTitle(): string | undefined { + return this._customTitle; + } + + get title(): string { + return this._customTitle || ChatModel.getDefaultTitle(this._exchanges); + } + + get initialLocation() { + return this._initialLocation; + } + + constructor( + private readonly initialData: ISerializableChatData | IExportableChatData | undefined, + private readonly _initialLocation: ChatAgentLocation, + @ILogService private readonly logService: ILogService, + @IAideAgentAgentService private readonly chatAgentService: IAideAgentAgentService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { super(); - this._sessionId = generateUuid(); - this._exchanges = new AgentExchangeSequence(); + this._isImported = (!!initialData && !isSerializableSessionData(initialData)) || (initialData?.isImported ?? false); + this._sessionId = (isSerializableSessionData(initialData) && initialData.sessionId) || generateUuid(); + this._exchanges = initialData ? this._deserialize(initialData) : []; + this._creationDate = (isSerializableSessionData(initialData) && initialData.creationDate) || Date.now(); + this._lastMessageDate = (isSerializableSessionData(initialData) && initialData.lastMessageDate) || this._creationDate; + this._customTitle = isSerializableSessionData(initialData) ? initialData.customTitle : undefined; + + this._initialRequesterAvatarIconUri = initialData?.requesterAvatarIconUri && URI.revive(initialData.requesterAvatarIconUri); + this._initialResponderAvatarIconUri = isUriComponents(initialData?.responderAvatarIconUri) ? URI.revive(initialData.responderAvatarIconUri) : initialData?.responderAvatarIconUri; + } + + private _deserialize(obj: IExportableChatData): ChatRequestModel[] { + const requests = obj.requests; + if (!Array.isArray(requests)) { + this.logService.error(`Ignoring malformed session data: ${JSON.stringify(obj)}`); + return []; + } + + if (obj.welcomeMessage) { + const content = obj.welcomeMessage.map(item => typeof item === 'string' ? new MarkdownString(item) : item); + this._welcomeMessage = this.instantiationService.createInstance(ChatWelcomeMessageModel, content, []); + } + + try { + return requests.map((raw: ISerializableChatRequestData) => { + const parsedRequest = + typeof raw.message === 'string' + ? this.getParsedRequestFromString(raw.message) + : reviveParsedChatRequest(raw.message); + + // Old messages don't have variableData, or have it in the wrong (non-array) shape + const variableData: IChatRequestVariableData = this.reviveVariableData(raw.variableData); + const request = new ChatRequestModel(this, parsedRequest, variableData); + 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 + reviveSerializedAgent(raw.agent) : undefined; + + // Port entries from old format + const result = 'responseErrorDetails' in raw ? + // eslint-disable-next-line local/code-no-dangerous-type-assertions + { 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(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)); + } + + raw.contentReferences?.forEach(r => response.applyReference(revive(r))); + raw.codeCitations?.forEach(c => response.applyCodeCitation(revive(c))); + } + return request; + }); + } catch (error) { + this.logService.error('Failed to parse chat data', error); + return []; + } } - addTrigger(message: string): IAgentExchangeData { - const trigger = new AgentContentModel(message); - this._exchanges.addTrigger(trigger); - return trigger; + private reviveVariableData(raw: IChatRequestVariableData): IChatRequestVariableData { + const variableData = raw && Array.isArray(raw.variables) + ? raw : + { variables: [] }; + + variableData.variables = variableData.variables.map((v): IChatRequestVariableEntry => { + // Old variables format + if (v && 'values' in v && Array.isArray(v.values)) { + return { + id: v.id ?? '', + name: v.name, + value: v.values[0]?.value, + range: v.range, + modelDescription: v.modelDescription, + references: v.references + }; + } else { + return v; + } + }); + + return variableData; } - async acceptProgress(trigger: IAgentExchangeData, progress: IAgentResponseProgress): Promise { - if (progress.kind === 'markdownContent') { - const content = new AgentContentModel(progress.content); - this._exchanges.addExchange(trigger.exchangeId, content); - } else if (progress.kind === 'textEdit') { - const action = new AgentActionModel(); - this._exchanges.addExchange(trigger.exchangeId, action); + private getParsedRequestFromString(message: string): IParsedChatRequest { + // TODO These offsets won't be used, but chat replies need to go through the parser as well + const parts = [new ChatRequestTextPart(new OffsetRange(0, message.length), { startColumn: 1, startLineNumber: 1, endColumn: 1, endLineNumber: 1 }, message)]; + return { + text: message, + parts + }; + } + + startInitialize(): void { + if (this.initState !== ChatModelInitState.Created) { + throw new Error(`ChatModel is in the wrong state for startInitialize: ${ChatModelInitState[this.initState]}`); + } + this._initState = ChatModelInitState.Initializing; + } + + deinitialize(): void { + this._initState = ChatModelInitState.Created; + this._isInitializedDeferred = new DeferredPromise(); + } + + initialize(welcomeMessage: ChatWelcomeMessageModel | undefined): void { + if (this.initState !== ChatModelInitState.Initializing) { + // Must call startInitialize before initialize, and only call it once + throw new Error(`ChatModel is in the wrong state for initialize: ${ChatModelInitState[this.initState]}`); + } + + this._initState = ChatModelInitState.Initialized; + if (!this._welcomeMessage) { + // Could also have loaded the welcome message from persisted data + this._welcomeMessage = welcomeMessage; + } + + this._isInitializedDeferred.complete(); + this._onDidChange.fire({ kind: 'initialize' }); + } + + setInitializationError(error: Error): void { + if (this.initState !== ChatModelInitState.Initializing) { + throw new Error(`ChatModel is in the wrong state for setInitializationError: ${ChatModelInitState[this.initState]}`); + } + + if (!this._isInitializedDeferred.isSettled) { + this._isInitializedDeferred.error(error); } } + + waitForInitialization(): Promise { + return this._isInitializedDeferred.p; + } + + getExchanges(): IChatExchangeModel[] { + return this._exchanges; + } + + addRequest(message: IParsedChatRequest, variableData: IChatRequestVariableData, attempt: number, chatAgent?: IChatAgentData, slashCommand?: IChatAgentCommand, confirmation?: string, locationData?: IChatLocationData, attachments?: IChatRequestVariableEntry[]): ChatRequestModel { + const request = new ChatRequestModel(this, message, variableData, attempt, confirmation, locationData, attachments); + const response = new ChatResponseModel([], this, chatAgent, slashCommand); + + this._exchanges.push(request, response); + this._lastMessageDate = Date.now(); + this._onDidChange.fire({ kind: 'addRequest', request }); + return request; + } + + addResponse(): ChatResponseModel { + 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 }); + return response; + } + + setCustomTitle(title: string): void { + this._customTitle = title; + } + + updateRequest(request: ChatRequestModel, variableData: IChatRequestVariableData) { + request.variableData = variableData; + this._onDidChange.fire({ kind: 'changedRequest', request }); + } + + acceptResponseProgress(response: ChatResponseModel | undefined, progress: IChatProgress, quiet?: boolean): void { + /* + if (!request.response) { + request.response = new ChatResponseModel([], this, undefined, undefined, request.id); + } + + if (request.response.isComplete) { + throw new Error('acceptResponseProgress: Adding progress to a completed response'); + } + */ + // TODO(@ghostwriternr): This will break, because this node is not added to the exchanges. + if (!response) { + response = new ChatResponseModel([], this, undefined, undefined); + } + + if (progress.kind === 'markdownContent' || + progress.kind === 'treeData' || + progress.kind === 'inlineReference' || + progress.kind === 'codeblockUri' || + progress.kind === 'markdownVuln' || + progress.kind === 'progressMessage' || + progress.kind === 'command' || + progress.kind === 'textEdit' || + progress.kind === 'warning' || + progress.kind === 'progressTask' || + progress.kind === 'confirmation' + ) { + response.updateContent(progress, quiet); + } else if (progress.kind === 'usedContext' || progress.kind === 'reference') { + response.applyReference(progress); + } else if (progress.kind === 'agentDetection') { + const agent = this.chatAgentService.getAgent(progress.agentId); + if (agent) { + response.setAgent(agent, progress.command); + this._onDidChange.fire({ kind: 'setAgent', agent, command: progress.command }); + } + } else if (progress.kind === 'codeCitation') { + response.applyCodeCitation(progress); + } else if (progress.kind === 'move') { + this._onDidChange.fire({ kind: 'move', target: progress.uri, range: progress.range }); + } else { + this.logService.error(`Couldn't handle progress: ${JSON.stringify(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]; + + if (index !== -1) { + this._onDidChange.fire({ kind: 'removeRequest', requestId: request.id, responseId: request.response?.id, reason }); + this._exchanges.splice(index, 1); + request.response?.dispose(); + } + } + */ + + /* TODO(@ghostwriternr): How should a user cancel a request in the async response world? Revisit this. + cancelRequest(request: ChatRequestModel): void { + if (request.response) { + request.response.cancel(); + } + } + */ + + /* TODO(@ghostwriternr): This method was used to link a response with a request. We may need this, but I'm assuming the shape will be a bit different? + setResponse(request: ChatRequestModel, result: IChatAgentResult): void { + if (!request.response) { + request.response = new ChatResponseModel([], this, undefined, undefined); + } + + request.response.setResult(result); + } + */ + + completeResponse(response: ChatResponseModel): void { + if (!response) { + throw new Error('Call setResponse before completeResponse'); + } + + response.complete(); + } + + /* TODO(@ghostwriternr): Honestly, don't care about followups at the moment. + setFollowups(request: ChatRequestModel, followups: IChatFollowup[] | undefined): void { + if (!request.response) { + // Maybe something went wrong? + return; + } + + request.response.setFollowups(followups); + } + */ + + toExport(): IExportableChatData { + return { + requesterUsername: this.requesterUsername, + requesterAvatarIconUri: this.requesterAvatarIconUri, + responderUsername: this.responderUsername, + responderAvatarIconUri: this.responderAvatarIcon, + initialLocation: this.initialLocation, + welcomeMessage: this._welcomeMessage?.content.map(c => { + if (Array.isArray(c)) { + return c; + } else { + return c.value; + } + }), + // TODO(@ghostwriternr): Don't want to deal with this for now. + requests: [], + /* + requests: this._exchanges.map((r): ISerializableChatRequestData => { + const message = { + ...r.message, + parts: r.message.parts.map(p => p && 'toJSON' in p ? (p.toJSON as Function)() : p) + }; + const agent = r.response?.agent; + const agentJson = agent && 'toJSON' in agent ? (agent.toJSON as Function)() : + agent ? { ...agent } : undefined; + return { + message, + variableData: r.variableData, + response: r.response ? + r.response.response.value.map(item => { + // Keeping the shape of the persisted data the same for back compat + if (item.kind === 'treeData') { + return item.treeData; + } else if (item.kind === 'markdownContent') { + return item.content; + } else { + return item as any; // TODO + } + }) + : undefined, + result: r.response?.result, + followups: r.response?.followups, + isCanceled: r.response?.isCanceled, + vote: r.response?.vote, + voteDownReason: r.response?.voteDownReason, + agent: agentJson, + slashCommand: r.response?.slashCommand, + usedContext: r.response?.usedContext, + contentReferences: r.response?.contentReferences, + codeCitations: r.response?.codeCitations + }; + }), + */ + }; + } + + toJSON(): ISerializableChatData { + return { + version: 3, + ...this.toExport(), + sessionId: this.sessionId, + creationDate: this._creationDate, + isImported: this._isImported, + lastMessageDate: this._lastMessageDate, + customTitle: this._customTitle + }; + } + + override dispose() { + this._exchanges.forEach(r => r instanceof ChatResponseModel ? r.dispose() : undefined); + this._onDidDispose.fire(); + + super.dispose(); + } +} + +export type IChatWelcomeMessageContent = IMarkdownString | IChatFollowup[]; + +export interface IChatWelcomeMessageModel { + readonly id: string; + readonly content: IChatWelcomeMessageContent[]; + readonly sampleQuestions: IChatFollowup[]; + readonly username: string; + readonly avatarIcon?: URI; +} + +export class ChatWelcomeMessageModel implements IChatWelcomeMessageModel { + private static nextId = 0; + + private _id: string; + public get id(): string { + return this._id; + } + + constructor( + public readonly content: IChatWelcomeMessageContent[], + public readonly sampleQuestions: IChatFollowup[], + @IAideAgentAgentService private readonly chatAgentService: IAideAgentAgentService, + ) { + this._id = 'welcome_' + ChatWelcomeMessageModel.nextId++; + } + + public get username(): string { + return this.chatAgentService.getContributedDefaultAgent(ChatAgentLocation.Panel)?.fullName ?? ''; + } + + public get avatarIcon(): URI | undefined { + return this.chatAgentService.getDefaultAgent(ChatAgentLocation.Panel)?.metadata.icon; + } +} + +export function updateRanges(variableData: IChatRequestVariableData, diff: number): IChatRequestVariableData { + return { + variables: variableData.variables.map(v => ({ + ...v, + range: v.range && { + start: v.range.start - diff, + endExclusive: v.range.endExclusive - diff + } + })) + }; +} + +export function canMergeMarkdownStrings(md1: IMarkdownString, md2: IMarkdownString): boolean { + if (md1.baseUri && md2.baseUri) { + const baseUriEquals = md1.baseUri.scheme === md2.baseUri.scheme + && md1.baseUri.authority === md2.baseUri.authority + && md1.baseUri.path === md2.baseUri.path + && md1.baseUri.query === md2.baseUri.query + && md1.baseUri.fragment === md2.baseUri.fragment; + if (!baseUriEquals) { + return false; + } + } else if (md1.baseUri || md2.baseUri) { + return false; + } + + return equals(md1.isTrusted, md2.isTrusted) && + md1.supportHtml === md2.supportHtml && + md1.supportThemeIcons === md2.supportThemeIcons; +} + +export function appendMarkdownString(md1: IMarkdownString, md2: IMarkdownString | string): IMarkdownString { + const appendedValue = typeof md2 === 'string' ? md2 : md2.value; + return { + value: md1.value + appendedValue, + isTrusted: md1.isTrusted, + supportThemeIcons: md1.supportThemeIcons, + supportHtml: md1.supportHtml, + baseUri: md1.baseUri + }; +} + +export function getCodeCitationsMessage(citations: ReadonlyArray): string { + if (citations.length === 0) { + return ''; + } + + const licenseTypes = citations.reduce((set, c) => set.add(c.license), new Set()); + const label = licenseTypes.size === 1 ? + localize('codeCitation', "Similar code found with 1 license type", licenseTypes.size) : + localize('codeCitations', "Similar code found with {0} license types", licenseTypes.size); + return label; } diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentParserTypes.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentParserTypes.ts new file mode 100644 index 00000000000..45709640b97 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentParserTypes.ts @@ -0,0 +1,237 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { revive } from '../../../../base/common/marshalling.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'; + +// These are in a separate file to avoid circular dependencies with the dependencies of the parser + +export interface IParsedChatRequest { + readonly parts: ReadonlyArray; + readonly text: string; +} + +export interface IParsedChatRequestPart { + readonly kind: string; // for serialization + readonly range: IOffsetRange; + readonly editorRange: IRange; + readonly text: string; + /** How this part is represented in the prompt going to the agent */ + readonly promptText: string; +} + +export function getPromptText(request: IParsedChatRequest): { message: string; diff: number } { + const message = request.parts.map(r => r.promptText).join('').trimStart(); + const diff = request.text.length - message.length; + + return { message, diff }; +} + +export class ChatRequestTextPart implements IParsedChatRequestPart { + static readonly Kind = 'text'; + readonly kind = ChatRequestTextPart.Kind; + constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly text: string) { } + + get promptText(): string { + return this.text; + } +} + +// warning, these also show up in a regex in the parser +export const chatVariableLeader = '@'; +export const chatAgentLeader = '#'; +export const chatSubcommandLeader = '/'; + +/** + * An invocation of a static variable that can be resolved by the variable service + */ +export class ChatRequestVariablePart implements IParsedChatRequestPart { + static readonly Kind = 'var'; + readonly kind = ChatRequestVariablePart.Kind; + constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly variableName: string, readonly variableArg: string, readonly variableId: string) { } + + get text(): string { + const argPart = this.variableArg ? `:${this.variableArg}` : ''; + return `${chatVariableLeader}${this.variableName}${argPart}`; + } + + get promptText(): string { + return this.text; + } +} + +/** + * An invocation of a tool + */ +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) { } + + get text(): string { + return `${chatVariableLeader}${this.toolName}`; + } + + get promptText(): string { + return this.text; + } +} + +/** + * An invocation of an agent that can be resolved by the agent service + */ +export class ChatRequestAgentPart implements IParsedChatRequestPart { + static readonly Kind = 'agent'; + readonly kind = ChatRequestAgentPart.Kind; + constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly agent: IChatAgentData) { } + + get text(): string { + return `${chatAgentLeader}${this.agent.name}`; + } + + get promptText(): string { + return ''; + } +} + +/** + * An invocation of an agent's subcommand + */ +export class ChatRequestAgentSubcommandPart implements IParsedChatRequestPart { + static readonly Kind = 'subcommand'; + readonly kind = ChatRequestAgentSubcommandPart.Kind; + constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly command: IChatAgentCommand) { } + + get text(): string { + return `${chatSubcommandLeader}${this.command.name}`; + } + + get promptText(): string { + return ''; + } +} + +/** + * An invocation of a standalone slash command + */ +export class ChatRequestSlashCommandPart implements IParsedChatRequestPart { + static readonly Kind = 'slash'; + readonly kind = ChatRequestSlashCommandPart.Kind; + constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly slashCommand: IChatSlashData) { } + + get text(): string { + return `${chatSubcommandLeader}${this.slashCommand.command}`; + } + + get promptText(): string { + return `${chatSubcommandLeader}${this.slashCommand.command}`; + } +} + +/** + * An invocation of a dynamic reference like '@file:' + */ +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) { } + + get referenceText(): string { + return this.text.replace(chatVariableLeader, ''); + } + + get promptText(): string { + return this.text; + } +} + +export function reviveParsedChatRequest(serialized: IParsedChatRequest): IParsedChatRequest { + return { + text: serialized.text, + parts: serialized.parts.map(part => { + if (part.kind === ChatRequestTextPart.Kind) { + return new ChatRequestTextPart( + new OffsetRange(part.range.start, part.range.endExclusive), + part.editorRange, + part.text + ); + } else if (part.kind === ChatRequestVariablePart.Kind) { + return new ChatRequestVariablePart( + new OffsetRange(part.range.start, part.range.endExclusive), + part.editorRange, + (part as ChatRequestVariablePart).variableName, + (part as ChatRequestVariablePart).variableArg, + (part as ChatRequestVariablePart).variableId || '', + ); + } else if (part.kind === ChatRequestToolPart.Kind) { + return new ChatRequestToolPart( + new OffsetRange(part.range.start, part.range.endExclusive), + part.editorRange, + (part as ChatRequestToolPart).toolName, + (part as ChatRequestToolPart).toolId + ); + } else if (part.kind === ChatRequestAgentPart.Kind) { + let agent = (part as ChatRequestAgentPart).agent; + agent = reviveSerializedAgent(agent); + + return new ChatRequestAgentPart( + new OffsetRange(part.range.start, part.range.endExclusive), + part.editorRange, + agent + ); + } else if (part.kind === ChatRequestAgentSubcommandPart.Kind) { + return new ChatRequestAgentSubcommandPart( + new OffsetRange(part.range.start, part.range.endExclusive), + part.editorRange, + (part as ChatRequestAgentSubcommandPart).command + ); + } else if (part.kind === ChatRequestSlashCommandPart.Kind) { + return new ChatRequestSlashCommandPart( + new OffsetRange(part.range.start, part.range.endExclusive), + part.editorRange, + (part as ChatRequestSlashCommandPart).slashCommand + ); + } else if (part.kind === ChatRequestDynamicVariablePart.Kind) { + return new ChatRequestDynamicVariablePart( + new OffsetRange(part.range.start, part.range.endExclusive), + part.editorRange, + (part as ChatRequestDynamicVariablePart).text, + (part as ChatRequestDynamicVariablePart).id, + (part as ChatRequestDynamicVariablePart).modelDescription, + revive((part as ChatRequestDynamicVariablePart).data) + ); + } else { + throw new Error(`Unknown chat request part: ${part.kind}`); + } + }) + }; +} + +export function extractAgentAndCommand(parsed: IParsedChatRequest): { agentPart: ChatRequestAgentPart | undefined; commandPart: ChatRequestAgentSubcommandPart | undefined } { + const agentPart = parsed.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart); + const commandPart = parsed.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart); + return { agentPart, commandPart }; +} + +export function formatChatQuestion(chatAgentService: IAideAgentAgentService, location: ChatAgentLocation, prompt: string, participant: string | null = null, command: string | null = null): string | undefined { + let question = ''; + if (participant && participant !== chatAgentService.getDefaultAgent(location)?.id) { + const agent = chatAgentService.getAgent(participant); + if (!agent) { + // Refers to agent that doesn't exist + return undefined; + } + + question += `${chatAgentLeader}${agent.name} `; + if (command) { + question += `${chatSubcommandLeader}${command} `; + } + } + return question + prompt; +} diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentParticipantContribTypes.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentParticipantContribTypes.ts new file mode 100644 index 00000000000..87d4d9f1fc3 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentParticipantContribTypes.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface IRawChatCommandContribution { + name: string; + description: string; + sampleRequest?: string; + isSticky?: boolean; + when?: string; + defaultImplicitVariables?: string[]; + disambiguation?: { category: string; categoryName?: string /** Deprecated */; description: string; examples: string[] }[]; +} + +export type RawChatParticipantLocation = 'panel' | 'terminal' | 'notebook'; + +export interface IRawChatParticipantContribution { + id: string; + name: string; + fullName: string; + when?: string; + description?: string; + supportsModelPicker?: boolean; + isDefault?: boolean; + isSticky?: boolean; + sampleRequest?: string; + commands?: IRawChatCommandContribution[]; + defaultImplicitVariables?: string[]; + locations?: RawChatParticipantLocation[]; + disambiguation?: { category: string; categoryName?: string /** Deprecated */; description: string; examples: string[] }[]; + supportsToolReferences?: boolean; +} + +export const CHAT_PROVIDER_ID = 'aide'; diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentRequestParser.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentRequestParser.ts new file mode 100644 index 00000000000..78488fd26c6 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentRequestParser.ts @@ -0,0 +1,236 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { OffsetRange } from '../../../../editor/common/core/offsetRange.js'; +import { IPosition, Position } from '../../../../editor/common/core/position.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { ChatAgentLocation, IChatAgentData, IAideAgentAgentService } from './aideAgentAgents.js'; +import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestTextPart, ChatRequestToolPart, ChatRequestVariablePart, IParsedChatRequest, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader, chatVariableLeader } from './aideAgentParserTypes.js'; +import { IAideAgentSlashCommandService } from './aideAgentSlashCommands.js'; +import { IAideAgentVariablesService, IDynamicVariable } from './aideAgentVariables.js'; +import { IAideAgentLMToolsService } from './languageModelToolsService.js'; + +const agentReg = /^#([\w_\-\.]+)(?=(\s|$|\b))/i; // An #-agent +const variableReg = /^@([\w_\-]+)(:\d+)?(?=(\s|$|\b))/i; // A @-variable with an optional numeric : arg (@response:2) +const slashReg = /\/([\w_\-]+)(?=(\s|$|\b))/i; // A / command + +export interface IChatParserContext { + /** Used only as a disambiguator, when the query references an agent that has a duplicate with the same name. */ + selectedAgent?: IChatAgentData; +} + +export class ChatRequestParser { + constructor( + @IAideAgentAgentService private readonly agentService: IAideAgentAgentService, + @IAideAgentVariablesService private readonly variableService: IAideAgentVariablesService, + @IAideAgentSlashCommandService private readonly slashCommandService: IAideAgentSlashCommandService, + @IAideAgentLMToolsService private readonly toolsService: IAideAgentLMToolsService, + ) { } + + parseChatRequest(sessionId: string, message: string, location: ChatAgentLocation = ChatAgentLocation.Panel, context?: IChatParserContext): IParsedChatRequest { + const parts: IParsedChatRequestPart[] = []; + const references = this.variableService.getDynamicVariables(sessionId); // must access this list before any async calls + + let lineNumber = 1; + let column = 1; + for (let i = 0; i < message.length; i++) { + const previousChar = message.charAt(i - 1); + const char = message.charAt(i); + let newPart: IParsedChatRequestPart | undefined; + if (previousChar.match(/\s/) || i === 0) { + if (char === chatVariableLeader) { + newPart = this.tryToParseVariable(message.slice(i), i, new Position(lineNumber, column), parts); + } else if (char === chatAgentLeader) { + newPart = this.tryToParseAgent(message.slice(i), message, i, new Position(lineNumber, column), parts, location, context); + } else if (char === chatSubcommandLeader) { + newPart = this.tryToParseSlashCommand(message.slice(i), message, i, new Position(lineNumber, column), parts, location); + } + + if (!newPart) { + newPart = this.tryToParseDynamicVariable(message.slice(i), i, new Position(lineNumber, column), references); + } + } + + if (newPart) { + if (i !== 0) { + // Insert a part for all the text we passed over, then insert the new parsed part + const previousPart = parts.at(-1); + const previousPartEnd = previousPart?.range.endExclusive ?? 0; + const previousPartEditorRangeEndLine = previousPart?.editorRange.endLineNumber ?? 1; + const previousPartEditorRangeEndCol = previousPart?.editorRange.endColumn ?? 1; + parts.push(new ChatRequestTextPart( + new OffsetRange(previousPartEnd, i), + new Range(previousPartEditorRangeEndLine, previousPartEditorRangeEndCol, lineNumber, column), + message.slice(previousPartEnd, i))); + } + + parts.push(newPart); + } + + if (char === '\n') { + lineNumber++; + column = 1; + } else { + column++; + } + } + + const lastPart = parts.at(-1); + const lastPartEnd = lastPart?.range.endExclusive ?? 0; + if (lastPartEnd < message.length) { + parts.push(new ChatRequestTextPart( + new OffsetRange(lastPartEnd, message.length), + new Range(lastPart?.editorRange.endLineNumber ?? 1, lastPart?.editorRange.endColumn ?? 1, lineNumber, column), + message.slice(lastPartEnd, message.length))); + } + + return { + parts, + text: message, + }; + } + + private tryToParseAgent(message: string, fullMessage: string, offset: number, position: IPosition, parts: Array, location: ChatAgentLocation, context: IChatParserContext | undefined): ChatRequestAgentPart | ChatRequestVariablePart | undefined { + const nextAgentMatch = message.match(agentReg); + if (!nextAgentMatch) { + return; + } + + const [full, name] = nextAgentMatch; + const agentRange = new OffsetRange(offset, offset + full.length); + const agentEditorRange = new Range(position.lineNumber, position.column, position.lineNumber, position.column + full.length); + + let agents = this.agentService.getAgentsByName(name); + if (!agents.length) { + const fqAgent = this.agentService.getAgentByFullyQualifiedId(name); + if (fqAgent) { + agents = [fqAgent]; + } + } + + // If there is more than one agent with this name, and the user picked it from the suggest widget, then the selected agent should be in the + // context and we use that one. + const agent = agents.length > 1 && context?.selectedAgent ? + context.selectedAgent : + agents.find((a) => a.locations.includes(location)); + if (!agent) { + return; + } + + if (parts.some(p => p instanceof ChatRequestAgentPart)) { + // Only one agent allowed + return; + } + + // The agent must come first + if (parts.some(p => (p instanceof ChatRequestTextPart && p.text.trim() !== '') || !(p instanceof ChatRequestAgentPart))) { + return; + } + + const previousPart = parts.at(-1); + const previousPartEnd = previousPart?.range.endExclusive ?? 0; + const textSincePreviousPart = fullMessage.slice(previousPartEnd, offset); + if (textSincePreviousPart.trim() !== '') { + return; + } + + return new ChatRequestAgentPart(agentRange, agentEditorRange, agent); + } + + private tryToParseVariable(message: string, offset: number, position: IPosition, parts: ReadonlyArray): ChatRequestAgentPart | ChatRequestVariablePart | ChatRequestToolPart | undefined { + const nextVariableMatch = message.match(variableReg); + if (!nextVariableMatch) { + return; + } + + const [full, name] = nextVariableMatch; + const variableArg = nextVariableMatch[2] ?? ''; + const varRange = new OffsetRange(offset, offset + full.length); + const varEditorRange = new Range(position.lineNumber, position.column, position.lineNumber, position.column + full.length); + const usedAgent = parts.find((p): p is ChatRequestAgentPart => p instanceof ChatRequestAgentPart); + const allowSlow = !usedAgent || usedAgent.agent.metadata.supportsSlowVariables; + + // TODO - not really handling duplicate variables names yet + const variable = this.variableService.getVariable(name); + if (variable && (!variable.isSlow || allowSlow)) { + return new ChatRequestVariablePart(varRange, varEditorRange, name, variableArg, variable.id); + } + + const tool = this.toolsService.getToolByName(name); + if (tool && tool.canBeInvokedManually && (!usedAgent || usedAgent.agent.supportsToolReferences)) { + return new ChatRequestToolPart(varRange, varEditorRange, name, tool.id); + } + + return; + } + + private tryToParseSlashCommand(remainingMessage: string, fullMessage: string, offset: number, position: IPosition, parts: ReadonlyArray, location: ChatAgentLocation): ChatRequestSlashCommandPart | ChatRequestAgentSubcommandPart | undefined { + const nextSlashMatch = remainingMessage.match(slashReg); + if (!nextSlashMatch) { + return; + } + + if (parts.some(p => p instanceof ChatRequestSlashCommandPart)) { + // Only one slash command allowed + return; + } + + const [full, command] = nextSlashMatch; + const slashRange = new OffsetRange(offset, offset + full.length); + const slashEditorRange = new Range(position.lineNumber, position.column, position.lineNumber, position.column + full.length); + + const usedAgent = parts.find((p): p is ChatRequestAgentPart => p instanceof ChatRequestAgentPart); + if (usedAgent) { + // The slash command must come immediately after the agent + if (parts.some(p => (p instanceof ChatRequestTextPart && p.text.trim() !== '') || !(p instanceof ChatRequestAgentPart) && !(p instanceof ChatRequestTextPart))) { + return; + } + + const previousPart = parts.at(-1); + const previousPartEnd = previousPart?.range.endExclusive ?? 0; + const textSincePreviousPart = fullMessage.slice(previousPartEnd, offset); + if (textSincePreviousPart.trim() !== '') { + return; + } + + const subCommand = usedAgent.agent.slashCommands.find(c => c.name === command); + if (subCommand) { + // Valid agent subcommand + return new ChatRequestAgentSubcommandPart(slashRange, slashEditorRange, subCommand); + } + } else { + const slashCommands = this.slashCommandService.getCommands(location); + const slashCommand = slashCommands.find(c => c.command === command); + if (slashCommand) { + // Valid standalone slash command + return new ChatRequestSlashCommandPart(slashRange, slashEditorRange, slashCommand); + } else { + // check for with default agent for this location + const defaultAgent = this.agentService.getDefaultAgent(location); + const subCommand = defaultAgent?.slashCommands.find(c => c.name === command); + if (subCommand) { + // Valid default agent subcommand + return new ChatRequestAgentSubcommandPart(slashRange, slashEditorRange, subCommand); + } + } + } + + return; + } + + private tryToParseDynamicVariable(message: string, offset: number, position: IPosition, references: ReadonlyArray): ChatRequestDynamicVariablePart | undefined { + const refAtThisPosition = references.find(r => + r.range.startLineNumber === position.lineNumber && + r.range.startColumn === position.column); + if (refAtThisPosition) { + const length = refAtThisPosition.range.endColumn - refAtThisPosition.range.startColumn; + const text = message.substring(0, length); + const range = new OffsetRange(offset, offset + length); + return new ChatRequestDynamicVariablePart(range, refAtThisPosition.range, text, refAtThisPosition.id, refAtThisPosition.modelDescription, refAtThisPosition.data); + } + + return; + } +} diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentService.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentService.ts index e565b618dcf..91126f90014 100644 --- a/src/vs/workbench/contrib/aideAgent/common/aideAgentService.ts +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentService.ts @@ -4,66 +4,439 @@ *--------------------------------------------------------------------------------------------*/ import { DeferredPromise } from '../../../../base/common/async.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Event } from '../../../../base/common/event.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; -import { WorkspaceEdit } from '../../../../editor/common/languages.js'; +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 } from '../../../../editor/common/languages.js'; +import { FileType } from '../../../../platform/files/common/files.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { IAideAgentImplementation } from './aideAgent.js'; -import { AideAgentModel, AideAgentScope } from './aideAgentModel.js'; +import { IWorkspaceSymbol } from '../../search/common/search.js'; +import { ChatAgentLocation, IChatAgentCommand, IChatAgentData, IChatAgentResult } from './aideAgentAgents.js'; +import { AgentMode, ChatModel, IChatModel, IChatRequestVariableData, IChatRequestVariableEntry, IChatResponseModel, IExportableChatData, ISerializableChatData } from './aideAgentModel.js'; +import { IParsedChatRequest } from './aideAgentParserTypes.js'; +import { IChatParserContext } from './aideAgentRequestParser.js'; +import { IChatRequestVariableValue } from './aideAgentVariables.js'; -export interface IAgentMarkdownContent { - kind: 'markdownContent'; - content: IMarkdownString; +export interface IChatRequest { + message: string; + variables: Record; } -export interface IAgentTextEdit { - kind: 'textEdit'; - edits: WorkspaceEdit; +export interface IChatResponseErrorDetails { + message: string; + responseIsIncomplete?: boolean; + responseIsFiltered?: boolean; + responseIsRedacted?: boolean; } -export interface IAgentTaskDto { - kind: 'progressTask'; +export interface IChatResponseProgressFileTreeData { + label: string; + uri: URI; + type?: FileType; + children?: IChatResponseProgressFileTreeData[]; +} + +export type IDocumentContext = { + uri: URI; + version: number; + ranges: IRange[]; +}; + +export function isIDocumentContext(obj: unknown): obj is IDocumentContext { + return ( + !!obj && + typeof obj === 'object' && + 'uri' in obj && obj.uri instanceof URI && + 'version' in obj && typeof obj.version === 'number' && + 'ranges' in obj && Array.isArray(obj.ranges) && obj.ranges.every(Range.isIRange) + ); +} + +export interface IChatUsedContext { + documents: IDocumentContext[]; + kind: 'usedContext'; +} + +export function isIUsedContext(obj: unknown): obj is IChatUsedContext { + return ( + !!obj && + typeof obj === 'object' && + 'documents' in obj && + Array.isArray(obj.documents) && + obj.documents.every(isIDocumentContext) + ); +} + +export interface IChatContentVariableReference { + variableName: string; + value?: URI | Location; +} + +export enum ChatResponseReferencePartStatusKind { + Complete = 1, + Partial = 2, + Omitted = 3 +} + +export interface IChatContentReference { + reference: URI | Location | IChatContentVariableReference | string; + iconPath?: ThemeIcon | { light: URI; dark?: URI }; + options?: { status?: { description: string; kind: ChatResponseReferencePartStatusKind } }; + kind: 'reference'; +} + +export interface IChatCodeCitation { + value: URI; + license: string; + snippet: string; + kind: 'codeCitation'; +} + +export interface IChatContentInlineReference { + inlineReference: URI | Location | IWorkspaceSymbol; + name?: string; + kind: 'inlineReference'; +} + +export interface IChatAgentDetection { + agentId: string; + command?: IChatAgentCommand; + kind: 'agentDetection'; +} + +export interface IChatMarkdownContent { content: IMarkdownString; + kind: 'markdownContent'; } -export interface IAgentTaskResult { - kind: 'progressTaskResult'; - content: IMarkdownString | void; +export interface IChatTreeData { + treeData: IChatResponseProgressFileTreeData; + kind: 'treeData'; } -export interface IAgentWarningMessage { - kind: 'warning'; +export interface IChatProgressMessage { content: IMarkdownString; + kind: 'progressMessage'; } -export interface IAgentTask extends IAgentTaskDto { +export interface IChatTask extends IChatTaskDto { deferred: DeferredPromise; - progress: (IAgentWarningMessage)[]; - onDidAddProgress: Event; - add(progress: IAgentWarningMessage): void; + progress: (IChatWarningMessage | IChatContentReference)[]; + onDidAddProgress: Event; + add(progress: IChatWarningMessage | IChatContentReference): void; complete: (result: string | void) => void; task: () => Promise; isSettled: () => boolean; } -export type IAgentResponseProgress = - | IAgentMarkdownContent - | IAgentTextEdit - | IAgentTask - | IAgentTaskResult - | IAgentWarningMessage; +export interface IChatTaskDto { + content: IMarkdownString; + kind: 'progressTask'; +} + +export interface IChatTaskResult { + content: IMarkdownString | void; + kind: 'progressTaskResult'; +} + +export interface IChatWarningMessage { + content: IMarkdownString; + kind: 'warning'; +} + +export interface IChatAgentVulnerabilityDetails { + title: string; + description: string; +} + +export interface IChatResponseCodeblockUriPart { + kind: 'codeblockUri'; + uri: URI; +} + +export interface IChatAgentMarkdownContentWithVulnerability { + content: IMarkdownString; + vulnerabilities: IChatAgentVulnerabilityDetails[]; + kind: 'markdownVuln'; +} + +export interface IChatCommandButton { + command: Command; + kind: 'command'; +} + +export interface IChatMoveMessage { + uri: URI; + range: IRange; + kind: 'move'; +} + +export interface IChatTextEdit { + uri: URI; + edits: TextEdit[]; + kind: 'textEdit'; +} + +export interface IChatConfirmation { + title: string; + message: string; + data: any; + buttons?: string[]; + isUsed?: boolean; + kind: 'confirmation'; +} + +export interface IChatEndResponse { + kind: 'endResponse'; +} + +export type IChatProgress = + | IChatMarkdownContent + | IChatAgentMarkdownContentWithVulnerability + | IChatTreeData + | IChatUsedContext + | IChatContentReference + | IChatContentInlineReference + | IChatCodeCitation + | IChatAgentDetection + | IChatProgressMessage + | IChatTask + | IChatTaskResult + | IChatCommandButton + | IChatWarningMessage + | IChatTextEdit + | IChatMoveMessage + | IChatResponseCodeblockUriPart + | IChatConfirmation + | IChatEndResponse; + +export interface IChatFollowup { + kind: 'reply'; + message: string; + agentId: string; + subCommand?: string; + title?: string; + tooltip?: string; +} + +export enum ChatAgentVoteDirection { + Down = 0, + Up = 1 +} + +export enum ChatAgentVoteDownReason { + IncorrectCode = 'incorrectCode', + DidNotFollowInstructions = 'didNotFollowInstructions', + IncompleteCode = 'incompleteCode', + MissingContext = 'missingContext', + PoorlyWrittenOrFormatted = 'poorlyWrittenOrFormatted', + RefusedAValidRequest = 'refusedAValidRequest', + OffensiveOrUnsafe = 'offensiveOrUnsafe', + Other = 'other', + WillReportIssue = 'willReportIssue' +} + +export interface IChatVoteAction { + kind: 'vote'; + direction: ChatAgentVoteDirection; + reason: ChatAgentVoteDownReason | undefined; +} + +export enum ChatCopyKind { + // Keyboard shortcut or context menu + Action = 1, + Toolbar = 2 +} + +export interface IChatCopyAction { + kind: 'copy'; + codeBlockIndex: number; + copyKind: ChatCopyKind; + copiedCharacters: number; + totalCharacters: number; + copiedText: string; +} + +export interface IChatInsertAction { + kind: 'insert'; + codeBlockIndex: number; + totalCharacters: number; + newFile?: boolean; +} + +export interface IChatApplyAction { + kind: 'apply'; + codeBlockIndex: number; + totalCharacters: number; + newFile?: boolean; + codeMapper?: string; + editsProposed: boolean; +} + + +export interface IChatTerminalAction { + kind: 'runInTerminal'; + codeBlockIndex: number; + languageId?: string; +} + +export interface IChatCommandAction { + kind: 'command'; + commandButton: IChatCommandButton; +} + +export interface IChatFollowupAction { + kind: 'followUp'; + followup: IChatFollowup; +} + +export interface IChatBugReportAction { + kind: 'bug'; +} + +export interface IChatInlineChatCodeAction { + kind: 'inlineChat'; + action: 'accepted' | 'discarded'; +} + +export type ChatUserAction = IChatVoteAction | IChatCopyAction | IChatInsertAction | IChatApplyAction | IChatTerminalAction | IChatCommandAction | IChatFollowupAction | IChatBugReportAction | IChatInlineChatCodeAction; + +export interface IChatUserActionEvent { + action: ChatUserAction; + agentId: string | undefined; + command: string | undefined; + sessionId: string; + requestId: string; + result: IChatAgentResult | undefined; +} + +export interface IChatDynamicRequest { + /** + * The message that will be displayed in the UI + */ + message: string; -export const IAideAgentService = createDecorator('aideAgentService'); + /** + * Any extra metadata/context that will go to the provider. + */ + metadata?: any; +} + +export interface IChatCompleteResponse { + message: string | ReadonlyArray; + result?: IChatAgentResult; + followups?: IChatFollowup[]; +} + +export interface IChatDetail { + sessionId: string; + title: string; + lastMessageDate: number; + isActive: boolean; +} + +export interface IChatProviderInfo { + id: string; +} + +export interface IChatTransferredSessionData { + sessionId: string; + inputValue: string; +} + +export interface IChatSendRequestResponseState { + responseCreatedPromise: Promise; + responseCompletePromise: Promise; +} + +export interface IChatSendRequestData extends IChatSendRequestResponseState { + agent: IChatAgentData; + slashCommand?: IChatAgentCommand; +} + +export interface IChatEditorLocationData { + type: ChatAgentLocation.Editor; + document: URI; + selection: ISelection; + wholeRange: IRange; +} + +export interface IChatNotebookLocationData { + type: ChatAgentLocation.Notebook; + sessionInputUri: URI; +} + +export interface IChatTerminalLocationData { + type: ChatAgentLocation.Terminal; + // TBD +} + +export type IChatLocationData = IChatEditorLocationData | IChatNotebookLocationData | IChatTerminalLocationData; + +export interface IChatSendRequestOptions { + agentMode?: AgentMode; + userSelectedModelId?: string; + location?: ChatAgentLocation; + locationData?: IChatLocationData; + parserContext?: IChatParserContext; + attempt?: number; + noCommandDetection?: boolean; + acceptedConfirmationData?: any[]; + rejectedConfirmationData?: any[]; + attachedContext?: IChatRequestVariableEntry[]; + + /** The target agent ID can be specified with this property instead of using @ in 'message' */ + agentId?: string; + slashCommand?: string; + + /** + * The label of the confirmation action that was selected. + */ + confirmation?: string; +} + +export const IAideAgentService = createDecorator('IAideAgentService'); export interface IAideAgentService { _serviceBrand: undefined; - registerAgentProvider(resolver: IAideAgentImplementation): void; + transferredSessionData: IChatTransferredSessionData | undefined; + + isEnabled(location: ChatAgentLocation): boolean; + hasSessions(): boolean; + startSession(location: ChatAgentLocation, token: CancellationToken): ChatModel | undefined; + getSession(sessionId: string): IChatModel | undefined; + getOrRestoreSession(sessionId: string): IChatModel | undefined; + loadSessionFromContent(data: IExportableChatData | ISerializableChatData): IChatModel | undefined; + + /** + * Returns whether the request was accepted. + */ + 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; + cancelCurrentRequestForSession(sessionId: string): void; - onDidChangeScope: Event; - scope: AideAgentScope; - readonly scopeSelection: number; + initiateResponse(sessionId: string): Promise<{ responseId: string; callback: (p: IChatProgress) => void }>; - startSession(): AideAgentModel | undefined; - trigger(message: string): void; + clearSession(sessionId: string): void; + addCompleteRequest(sessionId: string, message: IParsedChatRequest | string, variableData: IChatRequestVariableData | undefined, attempt: number | undefined, response: IChatCompleteResponse): void; + getHistory(): IChatDetail[]; + setChatSessionTitle(sessionId: string, title: string): void; + clearAllHistoryEntries(): void; + removeHistoryEntry(sessionId: string): void; + + onDidPerformUserAction: Event; + notifyUserAction(event: IChatUserActionEvent): void; + onDidDisposeSession: Event<{ sessionId: string; reason: 'initializationFailed' | 'cleared' }>; + + transferChatSession(transferredSessionData: IChatTransferredSessionData, toWorkspace: URI): void; } + +export const KEYWORD_ACTIVIATION_SETTING_ID = 'accessibility.voice.keywordActivation'; diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentServiceImpl.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentServiceImpl.ts index 1efe3296644..473529ee301 100644 --- a/src/vs/workbench/contrib/aideAgent/common/aideAgentServiceImpl.ts +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentServiceImpl.ts @@ -3,128 +3,809 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { Disposable, DisposableMap, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { toErrorMessage } from '../../../../base/common/errorMessage.js'; +import { ErrorNoTelemetry } from '../../../../base/common/errors.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { Iterable } from '../../../../base/common/iterator.js'; +import { Disposable, DisposableMap, IDisposable } from '../../../../base/common/lifecycle.js'; +import { revive } from '../../../../base/common/marshalling.js'; +import { URI, UriComponents } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IAgentTriggerComplete, IAideAgentImplementation } from './aideAgent.js'; -import { AideAgentModel, AideAgentScope, IAgentExchangeData, IAgentTriggerPayload } from './aideAgentModel.js'; -import { IAgentResponseProgress, IAideAgentService } from './aideAgentService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchAssignmentService } from '../../../services/assignment/common/assignmentService.js'; +import { IExtensionService } from '../../../services/extensions/common/extensions.js'; +import { ChatAgentLocation, IAideAgentAgentService, IChatAgent, IChatAgentCommand, IChatAgentData, IChatAgentRequest, IChatAgentResult } from './aideAgentAgents.js'; +import { CONTEXT_VOTE_UP_ENABLED } from './aideAgentContextKeys.js'; +import { AgentMode, ChatModel, ChatRequestModel, ChatResponseModel, ChatWelcomeMessageModel, IChatModel, IChatRequestVariableData, IChatResponseModel, IExportableChatData, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, normalizeSerializableChatData, 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'; +import { ChatServiceTelemetry } from './aideAgentServiceTelemetry.js'; +import { IAideAgentSlashCommandService } from './aideAgentSlashCommands.js'; +import { IAideAgentVariablesService } from './aideAgentVariables.js'; +const serializedChatKey = 'interactive.sessions'; -export class AideAgentService extends Disposable implements IAideAgentService { - declare _serviceBrand: undefined; +const globalChatKey = 'chat.workspaceTransfer'; +interface IChatTransfer { + toWorkspace: UriComponents; + timestampInMilliseconds: number; + chat: ISerializableChatData; + inputValue: string; +} +const SESSION_TRANSFER_EXPIRATION_IN_MILLISECONDS = 1000 * 60; - private agentModel: AideAgentModel | undefined; - private agentProvider: IAideAgentImplementation | undefined; - private readonly _pendingRequests = this._register(new DisposableMap()); - private _scope: AideAgentScope = AideAgentScope.Selection; - private _onDidChangeScope = this._register(new Emitter()); - readonly onDidChangeScope = this._onDidChangeScope.event; +const maxPersistedSessions = 25; + +class CancellableRequest implements IDisposable { + constructor( + public readonly cancellationTokenSource: CancellationTokenSource, + public requestId?: string | undefined + ) { } - get scope() { - return this._scope; + dispose() { + this.cancellationTokenSource.dispose(); } - set scope(scope: AideAgentScope) { - this._scope = scope; - this._onDidChangeScope.fire(scope); + cancel() { + this.cancellationTokenSource.cancel(); } +} - get scopeSelection(): Readonly { - if (this._scope === AideAgentScope.Selection) { - return 0; - } else if (this._scope === AideAgentScope.PinnedContext) { - return 1; - } else { - return 2; - } +export class ChatService extends Disposable implements IAideAgentService { + declare _serviceBrand: undefined; + + private readonly _sessionModels = this._register(new DisposableMap()); + // TODO(@ghostwriternr): Does this continue to make sense? How do we interpret 'pending requests' when we're no longer using a request-response model? + private readonly _pendingRequests = this._register(new DisposableMap()); + private _persistedSessions: ISerializableChatsData; + + /** Just for empty windows, need to enforce that a chat was deleted, even though other windows still have it */ + private _deletedChatIds = new Set(); + + private _transferredSessionData: IChatTransferredSessionData | undefined; + public get transferredSessionData(): IChatTransferredSessionData | undefined { + return this._transferredSessionData; } + private readonly _onDidPerformUserAction = this._register(new Emitter()); + public readonly onDidPerformUserAction: Event = this._onDidPerformUserAction.event; + + private readonly _onDidDisposeSession = this._register(new Emitter<{ sessionId: string; reason: 'initializationFailed' | 'cleared' }>()); + public readonly onDidDisposeSession = this._onDidDisposeSession.event; + + private readonly _sessionFollowupCancelTokens = this._register(new DisposableMap()); + private readonly _chatServiceTelemetry: ChatServiceTelemetry; + constructor( + @IStorageService private readonly storageService: IStorageService, + @ILogService private readonly logService: ILogService, + @IExtensionService private readonly extensionService: IExtensionService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IAideAgentSlashCommandService private readonly chatSlashCommandService: IAideAgentSlashCommandService, + @IAideAgentVariablesService private readonly chatVariablesService: IAideAgentVariablesService, + @IAideAgentAgentService private readonly chatAgentService: IAideAgentAgentService, + @IWorkbenchAssignmentService workbenchAssignmentService: IWorkbenchAssignmentService, + @IContextKeyService contextKeyService: IContextKeyService, ) { super(); + + this._chatServiceTelemetry = this.instantiationService.createInstance(ChatServiceTelemetry); + const isEmptyWindow = !workspaceContextService.getWorkspace().folders.length; + const sessionData = storageService.get(serializedChatKey, isEmptyWindow ? StorageScope.APPLICATION : StorageScope.WORKSPACE, ''); + if (sessionData) { + this._persistedSessions = this.deserializeChats(sessionData); + const countsForLog = Object.keys(this._persistedSessions).length; + if (countsForLog > 0) { + this.trace('constructor', `Restored ${countsForLog} persisted sessions`); + } + } else { + this._persistedSessions = {}; + } + + const transferredData = this.getTransferredSessionData(); + const transferredChat = transferredData?.chat; + if (transferredChat) { + this.trace('constructor', `Transferred session ${transferredChat.sessionId}`); + this._persistedSessions[transferredChat.sessionId] = transferredChat; + this._transferredSessionData = { sessionId: transferredChat.sessionId, inputValue: transferredData.inputValue }; + } + + this._register(storageService.onWillSaveState(() => this.saveState())); + + const voteUpEnabled = CONTEXT_VOTE_UP_ENABLED.bindTo(contextKeyService); + workbenchAssignmentService.getTreatment('chatVoteUpEnabled') + .then(value => voteUpEnabled.set(!!value)); + } + + isEnabled(location: ChatAgentLocation): boolean { + return this.chatAgentService.getContributedDefaultAgent(location) !== undefined; } - registerAgentProvider(resolver: IAideAgentImplementation): IDisposable { - if (this.agentProvider) { - throw new Error('Aide agent provider already registered'); + private saveState(): void { + const liveChats = Array.from(this._sessionModels.values()) + .filter(session => session.initialLocation === ChatAgentLocation.Panel) + .filter(session => session.getExchanges().length > 0); + + const isEmptyWindow = !this.workspaceContextService.getWorkspace().folders.length; + if (isEmptyWindow) { + this.syncEmptyWindowChats(liveChats); + } else { + let allSessions: (ChatModel | ISerializableChatData)[] = liveChats; + allSessions = allSessions.concat( + Object.values(this._persistedSessions) + .filter(session => !this._sessionModels.has(session.sessionId)) + .filter(session => session.requests.length)); + allSessions.sort((a, b) => (b.creationDate ?? 0) - (a.creationDate ?? 0)); + allSessions = allSessions.slice(0, maxPersistedSessions); + if (allSessions.length) { + this.trace('onWillSaveState', `Persisting ${allSessions.length} sessions`); + } + + const serialized = JSON.stringify(allSessions); + + if (allSessions.length) { + this.trace('onWillSaveState', `Persisting ${serialized.length} chars`); + } + + this.storageService.store(serializedChatKey, serialized, StorageScope.WORKSPACE, StorageTarget.MACHINE); } - this.agentProvider = resolver; - return toDisposable(() => { - this.agentProvider = undefined; + this._deletedChatIds.clear(); + } + + private syncEmptyWindowChats(thisWindowChats: ChatModel[]): void { + // Note- an unavoidable race condition exists here. If there are multiple empty windows open, and the user quits the application, then the focused + // window may lose active chats, because all windows are reading and writing to storageService at the same time. This can't be fixed without some + // kind of locking, but in reality, the focused window will likely have run `saveState` at some point, like on a window focus change, and it will + // generally be fine. + const sessionData = this.storageService.get(serializedChatKey, StorageScope.APPLICATION, ''); + + const originalPersistedSessions = this._persistedSessions; + let persistedSessions: ISerializableChatsData; + if (sessionData) { + persistedSessions = this.deserializeChats(sessionData); + const countsForLog = Object.keys(persistedSessions).length; + if (countsForLog > 0) { + this.trace('constructor', `Restored ${countsForLog} persisted sessions`); + } + } else { + persistedSessions = {}; + } + + this._deletedChatIds.forEach(id => delete persistedSessions[id]); + + // Has the chat in this window been updated, and then closed? Overwrite the old persisted chats. + Object.values(originalPersistedSessions).forEach(session => { + const persistedSession = persistedSessions[session.sessionId]; + if (persistedSession && session.requests.length > persistedSession.requests.length) { + // We will add a 'modified date' at some point, but comparing the number of requests is good enough + persistedSessions[session.sessionId] = session; + } else if (!persistedSession && session.isNew) { + // This session was created in this window, and hasn't been persisted yet + session.isNew = false; + persistedSessions[session.sessionId] = session; + } }); + + this._persistedSessions = persistedSessions; + + // Add this window's active chat models to the set to persist. + // Having the same session open in two empty windows at the same time can lead to data loss, this is acceptable + const allSessions: Record = { ...this._persistedSessions }; + for (const chat of thisWindowChats) { + allSessions[chat.sessionId] = chat; + } + + let sessionsList = Object.values(allSessions); + sessionsList.sort((a, b) => (b.creationDate ?? 0) - (a.creationDate ?? 0)); + sessionsList = sessionsList.slice(0, maxPersistedSessions); + const data = JSON.stringify(sessionsList); + this.storageService.store(serializedChatKey, data, StorageScope.APPLICATION, StorageTarget.MACHINE); } - startSession(): AideAgentModel | undefined { - this.agentModel = this.instantiationService.createInstance(AideAgentModel); - return this.agentModel; + notifyUserAction(action: IChatUserActionEvent): void { + this._chatServiceTelemetry.notifyUserAction(action); + this._onDidPerformUserAction.fire(action); } - trigger(message: string): void { - const model = this.agentModel; - if (!model || !this.agentProvider) { + setChatSessionTitle(sessionId: string, title: string): void { + const model = this._sessionModels.get(sessionId); + if (model) { + model.setCustomTitle(title); return; } - if (this._pendingRequests.has(model.sessionId)) { - return; + const session = this._persistedSessions[sessionId]; + if (session) { + session.customTitle = title; } + } - let triggerModel: IAgentExchangeData; + private trace(method: string, message?: string): void { + if (message) { + this.logService.trace(`ChatService#${method}: ${message}`); + } else { + this.logService.trace(`ChatService#${method}`); + } + } - const cts = new CancellationTokenSource(); - const token = cts.token; - const triggerAgentInternal = async () => { - const progressCallback = async (progress: IAgentResponseProgress) => { - if (token.isCancellationRequested) { - return; + private error(method: string, message: string): void { + this.logService.error(`ChatService#${method} ${message}`); + } + + private deserializeChats(sessionData: string): ISerializableChatsData { + try { + const arrayOfSessions: ISerializableChatDataIn[] = revive(JSON.parse(sessionData)); // Revive serialized URIs in session data + if (!Array.isArray(arrayOfSessions)) { + throw new Error('Expected array'); + } + + const sessions = arrayOfSessions.reduce((acc, session) => { + // Revive serialized markdown strings in response data + for (const request of session.requests) { + if (Array.isArray(request.response)) { + request.response = request.response.map((response) => { + if (typeof response === 'string') { + return new MarkdownString(response); + } + return response; + }); + } else if (typeof request.response === 'string') { + request.response = [new MarkdownString(request.response)]; + } } - model.acceptProgress(triggerModel, progress); - }; + acc[session.sessionId] = normalizeSerializableChatData(session); + return acc; + }, {}); + return sessions; + } catch (err) { + this.error('deserializeChats', `Malformed session data: ${err}. [${sessionData.substring(0, 20)}${sessionData.length > 20 ? '...' : ''}]`); + return {}; + } + } + + private getTransferredSessionData(): IChatTransfer | undefined { + const data: IChatTransfer[] = this.storageService.getObject(globalChatKey, StorageScope.PROFILE, []); + const workspaceUri = this.workspaceContextService.getWorkspace().folders[0]?.uri; + if (!workspaceUri) { + return; + } + + const thisWorkspace = workspaceUri.toString(); + const currentTime = Date.now(); + // Only use transferred data if it was created recently + const transferred = data.find(item => URI.revive(item.toWorkspace).toString() === thisWorkspace && (currentTime - item.timestampInMilliseconds < SESSION_TRANSFER_EXPIRATION_IN_MILLISECONDS)); + // Keep data that isn't for the current workspace and that hasn't expired yet + const filtered = data.filter(item => URI.revive(item.toWorkspace).toString() !== thisWorkspace && (currentTime - item.timestampInMilliseconds < SESSION_TRANSFER_EXPIRATION_IN_MILLISECONDS)); + this.storageService.store(globalChatKey, JSON.stringify(filtered), StorageScope.PROFILE, StorageTarget.MACHINE); + return transferred; + } + + /** + * Returns an array of chat details for all persisted chat sessions that have at least one request. + * The array is sorted by creation date in descending order. + * Chat sessions that have already been loaded into the chat view are excluded from the result. + * Imported chat sessions are also excluded from the result. + */ + getHistory(): IChatDetail[] { + const persistedSessions = Object.values(this._persistedSessions) + .filter(session => session.requests.length > 0) + .filter(session => !this._sessionModels.has(session.sessionId)); + + const persistedSessionItems = persistedSessions + .filter(session => !session.isImported) + .map(session => { + const title = session.customTitle ?? ChatModel.getDefaultTitle(session.requests); + return { + sessionId: session.sessionId, + title, + lastMessageDate: session.lastMessageDate, + isActive: false, + } satisfies IChatDetail; + }); + const liveSessionItems = Array.from(this._sessionModels.values()) + .filter(session => !session.isImported) + .map(session => { + const title = session.title || localize('newChat', "New Chat"); + return { + sessionId: session.sessionId, + title, + lastMessageDate: session.lastMessageDate, + isActive: true, + } satisfies IChatDetail; + }); + return [...liveSessionItems, ...persistedSessionItems]; + } + + removeHistoryEntry(sessionId: string): void { + if (this._persistedSessions[sessionId]) { + this._deletedChatIds.add(sessionId); + delete this._persistedSessions[sessionId]; + this.saveState(); + } + } + + clearAllHistoryEntries(): void { + Object.values(this._persistedSessions).forEach(session => this._deletedChatIds.add(session.sessionId)); + this._persistedSessions = {}; + this.saveState(); + } + + startSession(location: ChatAgentLocation, token: CancellationToken): ChatModel { + this.trace('startSession'); + return this._startSession(undefined, location, token); + } + + private _startSession(someSessionHistory: IExportableChatData | ISerializableChatData | undefined, location: ChatAgentLocation, token: CancellationToken): ChatModel { + const model = this.instantiationService.createInstance(ChatModel, someSessionHistory, location); + this._sessionModels.set(model.sessionId, model); + this.initializeSession(model, token); + return model; + } + + private progressCallback(model: ChatModel, response: ChatResponseModel | undefined, progress: IChatProgress, token: CancellationToken): void { + if (token.isCancellationRequested) { + return; + } + + if (progress.kind === 'endResponse' && response) { + model.completeResponse(response); + return; + } + + if (progress.kind === 'markdownContent') { + this.trace('sendRequest', `Provider returned progress for session ${model.sessionId}, ${progress.content.value.length} chars`); + } else { + this.trace('sendRequest', `Provider returned progress: ${JSON.stringify(progress)}`); + } + + model.acceptResponseProgress(response, progress); + } + + private async initializeSession(model: ChatModel, token: CancellationToken): Promise { + try { + this.trace('initializeSession', `Initialize session ${model.sessionId}`); + model.startInitialize(); + + await this.extensionService.whenInstalledExtensionsRegistered(); + const defaultAgentData = this.chatAgentService.getContributedDefaultAgent(model.initialLocation) ?? this.chatAgentService.getContributedDefaultAgent(ChatAgentLocation.Panel); + if (!defaultAgentData) { + throw new ErrorNoTelemetry('No default agent contributed'); + } + + await this.extensionService.activateByEvent(`onAideAgent:${defaultAgentData.id}`); + + const defaultAgent = this.chatAgentService.getActivatedAgents().find(agent => agent.id === defaultAgentData.id); + if (!defaultAgent) { + throw new ErrorNoTelemetry('No default agent registered'); + } + + this.chatAgentService.initSession(defaultAgent.id, model.sessionId); + + const welcomeMessage = model.welcomeMessage ? undefined : await defaultAgent.provideWelcomeMessage?.(model.initialLocation, token) ?? undefined; + const welcomeModel = welcomeMessage && this.instantiationService.createInstance( + ChatWelcomeMessageModel, + welcomeMessage.map(item => typeof item === 'string' ? new MarkdownString(item) : item), + await defaultAgent.provideSampleQuestions?.(model.initialLocation, token) ?? [] + ); + + model.initialize(welcomeModel); + } catch (err) { + this.trace('startSession', `initializeSession failed: ${err}`); + model.setInitializationError(err); + this._sessionModels.deleteAndDispose(model.sessionId); + this._onDidDisposeSession.fire({ sessionId: model.sessionId, reason: 'initializationFailed' }); + } + } + + getSession(sessionId: string): IChatModel | undefined { + return this._sessionModels.get(sessionId); + } + + getOrRestoreSession(sessionId: string): ChatModel | undefined { + this.trace('getOrRestoreSession', `sessionId: ${sessionId}`); + const model = this._sessionModels.get(sessionId); + if (model) { + return model; + } + + const sessionData = revive(this._persistedSessions[sessionId]); + if (!sessionData) { + return undefined; + } + + if (sessionId === this.transferredSessionData?.sessionId) { + this._transferredSessionData = undefined; + } + + return this._startSession(sessionData, sessionData.initialLocation ?? ChatAgentLocation.Panel, CancellationToken.None); + } + + loadSessionFromContent(data: IExportableChatData | ISerializableChatData): IChatModel | undefined { + return this._startSession(data, data.initialLocation ?? ChatAgentLocation.Panel, CancellationToken.None); + } + + /* TODO(@ghostwriternr): This method already seems unused. Remove it? + async resendRequest(request: IChatRequestModel, options?: IChatSendRequestOptions): Promise { + const model = this._sessionModels.get(request.session.sessionId); + if (!model && model !== request.session) { + throw new Error(`Unknown session: ${request.session.sessionId}`); + } + + await model.waitForInitialization(); + + const cts = this._pendingRequests.get(request.session.sessionId); + if (cts) { + this.trace('resendRequest', `Session ${request.session.sessionId} already has a pending request, cancelling...`); + cts.cancel(); + } + + const location = options?.location ?? model.initialLocation; + const attempt = options?.attempt ?? 0; + const enableCommandDetection = !options?.noCommandDetection; + const defaultAgent = this.chatAgentService.getDefaultAgent(location)!; + + model.removeRequest(request.id, ChatRequestRemovalReason.Resend); + + const resendOptions: IChatSendRequestOptions = { + ...options, + locationData: request.locationData, + attachedContext: request.attachedContext, + }; + await this._sendRequestAsync(model, model.sessionId, request.message, attempt, enableCommandDetection, defaultAgent, location, resendOptions).responseCompletePromise; + } + */ + + async sendRequest(sessionId: string, request: string, options?: IChatSendRequestOptions): Promise { + this.trace('sendRequest', `sessionId: ${sessionId}, message: ${request.substring(0, 20)}${request.length > 20 ? '[...]' : ''}}`); + if (!request.trim() && !options?.slashCommand && !options?.agentId) { + this.trace('sendRequest', 'Rejected empty message'); + return; + } + + const model = this._sessionModels.get(sessionId); + if (!model) { + throw new Error(`Unknown session: ${sessionId}`); + } + + await model.waitForInitialization(); + + if (this._pendingRequests.has(sessionId)) { + this.trace('sendRequest', `Session ${sessionId} already has a pending request`); + return; + } + + const location = options?.location ?? model.initialLocation; + const attempt = options?.attempt ?? 0; + const defaultAgent = this.chatAgentService.getDefaultAgent(location)!; + + const parsedRequest = this.parseChatRequest(sessionId, request, location, options); + const agent = parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart)?.agent ?? defaultAgent; + const agentSlashCommandPart = parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart); + + // This method is only returning whether the request was accepted - don't block on the actual request + return { + ...this._sendRequestAsync(model, sessionId, parsedRequest, attempt, !options?.noCommandDetection, defaultAgent, location, options), + agent, + slashCommand: agentSlashCommandPart?.command, + }; + } + + private parseChatRequest(sessionId: string, request: string, location: ChatAgentLocation, options: IChatSendRequestOptions | undefined): IParsedChatRequest { + let parserContext = options?.parserContext; + if (options?.agentId) { + const agent = this.chatAgentService.getAgent(options.agentId); + if (!agent) { + throw new Error(`Unknown agent: ${options.agentId}`); + } + parserContext = { selectedAgent: agent }; + const commandPart = options.slashCommand ? ` ${chatSubcommandLeader}${options.slashCommand}` : ''; + request = `${chatAgentLeader}${agent.name}${commandPart} ${request}`; + } + + const parsedRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(sessionId, request, location, parserContext); + return parsedRequest; + } + + private refreshFollowupsCancellationToken(sessionId: string): CancellationToken { + this._sessionFollowupCancelTokens.get(sessionId)?.cancel(); + const newTokenSource = new CancellationTokenSource(); + this._sessionFollowupCancelTokens.set(sessionId, newTokenSource); + + return newTokenSource.token; + } + + private _sendRequestAsync(model: ChatModel, sessionId: string, parsedRequest: IParsedChatRequest, attempt: number, enableCommandDetection: boolean, defaultAgent: IChatAgent, location: ChatAgentLocation, options?: IChatSendRequestOptions): IChatSendRequestResponseState { + const followupsCancelToken = this.refreshFollowupsCancellationToken(sessionId); + let request: ChatRequestModel; + const agentPart = 'kind' in parsedRequest ? undefined : parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart); + const agentSlashCommandPart = 'kind' in parsedRequest ? undefined : parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart); + const commandPart = 'kind' in parsedRequest ? undefined : parsedRequest.parts.find((r): r is ChatRequestSlashCommandPart => r instanceof ChatRequestSlashCommandPart); + + const responseCreated = new DeferredPromise(); + // let responseCreatedComplete = false; + function completeResponseCreated(): void { + /* TODO(@ghostwriternr): Debug this when something breaks (this comment sounds useless because I don't yet know what will break, I just know something will) + if (!responseCreatedComplete && request?.response) { + responseCreated.complete(request.response); + responseCreatedComplete = true; + } + */ + } + + const source = new CancellationTokenSource(); + const token = source.token; + const sendRequestInternal = async () => { + let detectedAgent: IChatAgentData | undefined; + let detectedCommand: IChatAgentCommand | undefined; const listener = token.onCancellationRequested(() => { - // TODO(@ghostwriternr): Implement cancelRequest - // model.cancelRequest(triggerModel); + this.trace('sendRequest', `Request for session ${model.sessionId} was cancelled`); + // TODO(@ghostwriternr): How should a user cancel a request in the async response world? Revisit this. + // model.cancelRequest(request); }); try { - let rawResult: void | IAgentTriggerComplete | undefined; + let rawResult: IChatAgentResult | null | undefined; + let agentOrCommandFollowups: Promise | undefined = undefined; + let chatTitlePromise: Promise | undefined; + + if (agentPart || (defaultAgent && !commandPart)) { + 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); - triggerModel = model.addTrigger(message); - const requestProps: IAgentTriggerPayload = { - id: triggerModel.exchangeId, - message: message, - scope: this._scope, - }; - const agentResult = await this.agentProvider?.trigger(requestProps, progressCallback, token); - rawResult = agentResult; + // Variables may have changed if the agent and slash command changed, so resolve them again even if we already had a chatRequest + // TODO(@ghostwriternr): Do we still need this? The lifecycle of the request object is unclear, and the cancellation token too. + const variableData = await this.chatVariablesService.resolveVariables(parsedRequest, request.attachedContext, model, (part) => this.progressCallback(model, undefined, part, token), token); + model.updateRequest(request, variableData); + const promptTextResult = getPromptText(request.message); + const updatedVariableData = updateRanges(variableData, promptTextResult.diff); // TODO bit of a hack + + return { + mode: options?.agentMode ?? AgentMode.Chat, + sessionId, + requestId: request.id, + agentId: agent.id, + message: promptTextResult.message, + command: command?.name, + variables: updatedVariableData, + enableCommandDetection, + isParticipantDetected, + attempt, + location, + locationData: request.locationData, + acceptedConfirmationData: options?.acceptedConfirmationData, + rejectedConfirmationData: options?.rejectedConfirmationData, + } satisfies IChatAgentRequest; + }; + + /* TODO(@ghostwriternr): Not really a TODO, just marking this code as stuff we don't want to do + if (this.configurationService.getValue('chat.experimental.detectParticipant.enabled') !== false && this.chatAgentService.hasChatParticipantDetectionProviders() && !agentPart && !commandPart && enableCommandDetection) { + // Prepare the request object that we will send to the participant detection provider + const chatAgentRequest = await prepareChatAgentRequest(defaultAgent, agentSlashCommandPart?.command, enableCommandDetection, undefined, false); + + const result = await this.chatAgentService.detectAgentOrCommand(chatAgentRequest, [], { location }, token); + if (result && this.chatAgentService.getAgent(result.agent.id)?.locations?.includes(location)) { + // Update the response in the ChatModel to reflect the detected agent and command + request.response?.setAgent(result.agent, result.command); + detectedAgent = result.agent; + detectedCommand = result.command; + } + } + */ + + const agent = (detectedAgent ?? agentPart?.agent ?? defaultAgent)!; + const command = detectedCommand ?? agentSlashCommandPart?.command; + await this.extensionService.activateByEvent(`onAideAgent:${agent.id}`); + + const requestProps = await prepareChatAgentRequest(agent, command, enableCommandDetection, request /* Reuse the request object if we already created it for participant detection */, !!detectedAgent); + requestProps.userSelectedModelId = options?.userSelectedModelId; + const pendingRequest = this._pendingRequests.get(sessionId); + if (pendingRequest && !pendingRequest.requestId) { + pendingRequest.requestId = requestProps.requestId; + } + completeResponseCreated(); + const agentResult = await this.chatAgentService.invokeAgent(agent.id, requestProps, token); + rawResult = agentResult; + agentOrCommandFollowups = this.chatAgentService.getFollowups(agent.id, requestProps, agentResult, [], followupsCancelToken); + chatTitlePromise = model.getExchanges().length === 1 && !model.customTitle ? this.chatAgentService.getChatTitle(defaultAgent.id, [], CancellationToken.None) : undefined; + } else if (commandPart && this.chatSlashCommandService.hasCommand(commandPart.slashCommand.command)) { + request = model.addRequest(parsedRequest, { variables: [] }, attempt); + completeResponseCreated(); + // contributed slash commands + // TODO: spell this out in the UI + /* TODO(@ghostwriternr): Investigate if commenting this block out breaks slash commands (which we aren't currently using anyway) + const history: IChatMessage[] = []; + for (const request of model.getExchanges()) { + if (!request.response) { + continue; + } + history.push({ role: ChatMessageRole.User, content: [{ type: 'text', value: request.message.text }] }); + history.push({ role: ChatMessageRole.Assistant, content: [{ type: 'text', value: request.response.response.toString() }] }); + } + const message = parsedRequest.text; + const commandResult = await this.chatSlashCommandService.executeCommand(commandPart.slashCommand.command, message.substring(commandPart.slashCommand.command.length + 1).trimStart(), new Progress(p => { + progressCallback(p); + }), history, location, token); + agentOrCommandFollowups = Promise.resolve(commandResult?.followUp); + */ + rawResult = {}; + } else { + throw new Error(`Cannot handle request`); + } if (token.isCancellationRequested) { return; } else { if (!rawResult) { - rawResult = { errorDetails: localize('emptyResponse', "Provider returned null response") }; + this.trace('sendRequest', `Provider returned no response for session ${model.sessionId}`); + rawResult = { errorDetails: { message: localize('emptyResponse', "Provider returned null response") } }; } - // TODO(@ghostwriternr): Implement setResponse - // model.setResponse(triggerModel, rawResult); + const commandForTelemetry = agentSlashCommandPart ? agentSlashCommandPart.command.name : commandPart?.slashCommand.command; + // model.setResponse(request, rawResult); + completeResponseCreated(); + this.trace('sendRequest', `Provider returned response for session ${model.sessionId}`); + + // model.completeResponse(request); + if (agentOrCommandFollowups) { + agentOrCommandFollowups.then(followups => { + // model.setFollowups(request, followups); + this._chatServiceTelemetry.retrievedFollowups(agentPart?.agent.id ?? '', commandForTelemetry, followups?.length ?? 0); + }); + } + chatTitlePromise?.then(title => { + if (title) { + model.setCustomTitle(title); + } + }); + } + } catch (err) { + this.logService.error(`Error while handling chat request: ${toErrorMessage(err, true)}`); + if (request) { + // const rawResult: IChatAgentResult = { errorDetails: { message: err.message } }; + // model.setResponse(request, rawResult); + completeResponseCreated(); + // model.completeResponse(request); } - } catch (error) { - console.log(error); } finally { listener.dispose(); } }; - - const rawResponsePromise = triggerAgentInternal(); + const rawResponsePromise = sendRequestInternal(); + this._pendingRequests.set(model.sessionId, new CancellableRequest(source)); rawResponsePromise.finally(() => { - // cleanup + this._pendingRequests.deleteAndDispose(model.sessionId); + }); + return { + responseCreatedPromise: responseCreated.p, + responseCompletePromise: rawResponsePromise, + }; + } + + /* TODO(@ghostwriternr): Remove this if we no longer need to remove requests. + async removeRequest(sessionId: string, requestId: string): Promise { + const model = this._sessionModels.get(sessionId); + if (!model) { + throw new Error(`Unknown session: ${sessionId}`); + } + + await model.waitForInitialization(); + + const pendingRequest = this._pendingRequests.get(sessionId); + if (pendingRequest?.requestId === requestId) { + pendingRequest.cancel(); + this._pendingRequests.deleteAndDispose(sessionId); + } + + model.removeRequest(requestId); + } + */ + + async initiateResponse(sessionId: string): Promise<{ responseId: string; callback: (p: IChatProgress) => void }> { + const model = this._sessionModels.get(sessionId); + if (!model) { + throw new Error(`Unknown session: ${sessionId}`); + } + + await model.waitForInitialization(); + + const response = model.addResponse(); + const progressCallback = (p: IChatProgress) => { + // TODO(@ghostwriternr): Figure out the right cancellation token to use here + this.progressCallback(model, response, p, CancellationToken.None); + }; + return { responseId: response.id, callback: progressCallback }; + } + + async addCompleteRequest(_sessionId: string, message: IParsedChatRequest | string, _variableData: IChatRequestVariableData | undefined, _attempt: number | undefined, _response: IChatCompleteResponse): Promise { + this.trace('addCompleteRequest', `message: ${message}`); + + /* TODO(@ghostwriternr): Come back to debug this when restoring a session inevitably fails + const model = this._sessionModels.get(sessionId); + if (!model) { + throw new Error(`Unknown session: ${sessionId}`); + } + + await model.waitForInitialization(); + const parsedRequest = typeof message === 'string' ? + this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(sessionId, message) : + message; + const request = model.addRequest(parsedRequest, variableData || { variables: [] }, attempt ?? 0); + if (typeof response.message === 'string') { + // TODO is this possible? + model.acceptResponseProgress(request, { content: new MarkdownString(response.message), kind: 'markdownContent' }); + } else { + for (const part of response.message) { + model.acceptResponseProgress(request, part, true); + } + } + model.setResponse(request, response.result || {}); + if (response.followups !== undefined) { + model.setFollowups(request, response.followups); + } + model.completeResponse(request); + */ + } + + cancelCurrentRequestForSession(sessionId: string): void { + this.trace('cancelCurrentRequestForSession', `sessionId: ${sessionId}`); + this._pendingRequests.get(sessionId)?.cancel(); + this._pendingRequests.deleteAndDispose(sessionId); + } + + clearSession(sessionId: string): void { + this.trace('clearSession', `sessionId: ${sessionId}`); + const model = this._sessionModels.get(sessionId); + if (!model) { + throw new Error(`Unknown session: ${sessionId}`); + } + + if (model.initialLocation === ChatAgentLocation.Panel) { + // Turn all the real objects into actual JSON, otherwise, calling 'revive' may fail when it tries to + // assign values to properties that are getters- microsoft/vscode-copilot-release#1233 + const sessionData: ISerializableChatData = JSON.parse(JSON.stringify(model)); + sessionData.isNew = true; + this._persistedSessions[sessionId] = sessionData; + } + + this._sessionModels.deleteAndDispose(sessionId); + this._pendingRequests.get(sessionId)?.cancel(); + this._pendingRequests.deleteAndDispose(sessionId); + this._onDidDisposeSession.fire({ sessionId, reason: 'cleared' }); + } + + public hasSessions(): boolean { + return !!Object.values(this._persistedSessions); + } + + transferChatSession(transferredSessionData: IChatTransferredSessionData, toWorkspace: URI): void { + const model = Iterable.find(this._sessionModels.values(), model => model.sessionId === transferredSessionData.sessionId); + if (!model) { + throw new Error(`Failed to transfer session. Unknown session ID: ${transferredSessionData.sessionId}`); + } + + const existingRaw: IChatTransfer[] = this.storageService.getObject(globalChatKey, StorageScope.PROFILE, []); + existingRaw.push({ + chat: model.toJSON(), + timestampInMilliseconds: Date.now(), + toWorkspace: toWorkspace, + inputValue: transferredSessionData.inputValue, }); + + this.storageService.store(globalChatKey, JSON.stringify(existingRaw), StorageScope.PROFILE, StorageTarget.MACHINE); + this.trace('transferChatSession', `Transferred session ${model.sessionId} to workspace ${toWorkspace.toString()}`); } } diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentServiceTelemetry.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentServiceTelemetry.ts new file mode 100644 index 00000000000..7712a30c3e5 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentServiceTelemetry.ts @@ -0,0 +1,189 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IChatUserActionEvent, ChatAgentVoteDirection, ChatCopyKind } from './aideAgentService.js'; + +type ChatVoteEvent = { + direction: 'up' | 'down'; + agentId: string; + command: string | undefined; + reason: string | undefined; +}; + +type ChatVoteClassification = { + direction: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the user voted up or down.' }; + agentId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the chat agent that this vote is for.' }; + command: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the slash command that this vote is for.' }; + reason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The reason selected by the user for voting down.' }; + owner: 'roblourens'; + comment: 'Provides insight into the performance of Chat agents.'; +}; + +type ChatCopyEvent = { + copyKind: 'action' | 'toolbar'; + agentId: string; + command: string | undefined; +}; + +type ChatCopyClassification = { + copyKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the copy was initiated.' }; + agentId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the chat agent that the copy acted on.' }; + command: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the slash command the copy acted on.' }; + owner: 'roblourens'; + comment: 'Provides insight into the usage of Chat features.'; +}; + +type ChatInsertEvent = { + newFile: boolean; + agentId: string; + command: string | undefined; +}; + +type ChatInsertClassification = { + newFile: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the code was inserted into a new untitled file.' }; + agentId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the chat agent that this insertion is for.' }; + command: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the slash command that this insertion is for.' }; + owner: 'roblourens'; + comment: 'Provides insight into the usage of Chat features.'; +}; + +type ChatApplyEvent = { + newFile: boolean; + agentId: string; + command: string | undefined; + codeMapper: string | undefined; + editsProposed: boolean; +}; + +type ChatApplyClassification = { + newFile: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the code was inserted into a new untitled file.' }; + agentId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the chat agent that this insertion is for.' }; + command: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the slash command that this insertion is for.' }; + codeMapper: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The code mapper that wa used to compute the edit.' }; + editsProposed: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether there was a change proposed to the user.' }; + owner: 'aeschli'; + comment: 'Provides insight into the usage of Chat features.'; +}; + +type ChatCommandEvent = { + commandId: string; + agentId: string; + command: string | undefined; +}; + +type ChatCommandClassification = { + commandId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The id of the command that was executed.' }; + agentId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the related chat agent.' }; + command: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the related slash command.' }; + owner: 'roblourens'; + comment: 'Provides insight into the usage of Chat features.'; +}; + +type ChatFollowupEvent = { + agentId: string; + command: string | undefined; +}; + +type ChatFollowupClassification = { + agentId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the related chat agent.' }; + command: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the related slash command.' }; + owner: 'roblourens'; + comment: 'Provides insight into the usage of Chat features.'; +}; + +type ChatTerminalEvent = { + languageId: string; + agentId: string; + command: string | undefined; +}; + +type ChatTerminalClassification = { + languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language of the code that was run in the terminal.' }; + agentId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the related chat agent.' }; + command: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the related slash command.' }; + owner: 'roblourens'; + comment: 'Provides insight into the usage of Chat features.'; +}; + +type ChatFollowupsRetrievedEvent = { + agentId: string; + command: string | undefined; + numFollowups: number; +}; + +type ChatFollowupsRetrievedClassification = { + agentId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the related chat agent.' }; + command: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the related slash command.' }; + numFollowups: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of followup prompts returned by the agent.' }; + owner: 'roblourens'; + comment: 'Provides insight into the usage of Chat features.'; +}; + +export class ChatServiceTelemetry { + constructor( + @ITelemetryService private readonly telemetryService: ITelemetryService, + ) { } + + notifyUserAction(action: IChatUserActionEvent): void { + if (action.action.kind === 'vote') { + this.telemetryService.publicLog2('interactiveSessionVote', { + direction: action.action.direction === ChatAgentVoteDirection.Up ? 'up' : 'down', + agentId: action.agentId ?? '', + command: action.command, + reason: action.action.reason, + }); + } else if (action.action.kind === 'copy') { + this.telemetryService.publicLog2('interactiveSessionCopy', { + copyKind: action.action.copyKind === ChatCopyKind.Action ? 'action' : 'toolbar', + agentId: action.agentId ?? '', + command: action.command, + }); + } else if (action.action.kind === 'insert') { + this.telemetryService.publicLog2('interactiveSessionInsert', { + newFile: !!action.action.newFile, + agentId: action.agentId ?? '', + command: action.command, + }); + } else if (action.action.kind === 'apply') { + this.telemetryService.publicLog2('interactiveSessionApply', { + newFile: !!action.action.newFile, + codeMapper: action.action.codeMapper, + agentId: action.agentId ?? '', + command: action.command, + editsProposed: !!action.action.editsProposed, + }); + } else if (action.action.kind === 'command') { + // TODO not currently called + const command = CommandsRegistry.getCommand(action.action.commandButton.command.id); + const commandId = command ? action.action.commandButton.command.id : 'INVALID'; + this.telemetryService.publicLog2('interactiveSessionCommand', { + commandId, + agentId: action.agentId ?? '', + command: action.command, + }); + } else if (action.action.kind === 'runInTerminal') { + this.telemetryService.publicLog2('interactiveSessionRunInTerminal', { + languageId: action.action.languageId ?? '', + agentId: action.agentId ?? '', + command: action.command, + }); + } else if (action.action.kind === 'followUp') { + this.telemetryService.publicLog2('chatFollowupClicked', { + agentId: action.agentId ?? '', + command: action.command, + }); + } + } + + retrievedFollowups(agentId: string, command: string | undefined, numFollowups: number): void { + this.telemetryService.publicLog2('chatFollowupsRetrieved', { + agentId, + command, + numFollowups, + }); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentSlashCommands.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentSlashCommands.ts new file mode 100644 index 00000000000..4a7ac0b052d --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentSlashCommands.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IProgress } from '../../../../platform/progress/common/progress.js'; +import { IChatMessage } from './languageModels.js'; +import { IChatFollowup, IChatProgress, IChatResponseProgressFileTreeData } from './aideAgentService.js'; +import { IExtensionService } from '../../../services/extensions/common/extensions.js'; +import { ChatAgentLocation } from './aideAgentAgents.js'; + +//#region slash service, commands etc + +export interface IChatSlashData { + command: string; + detail: string; + sortText?: string; + /** + * Whether the command should execute as soon + * as it is entered. Defaults to `false`. + */ + executeImmediately?: boolean; + locations: ChatAgentLocation[]; +} + +export interface IChatSlashFragment { + content: string | { treeData: IChatResponseProgressFileTreeData }; +} +export type IChatSlashCallback = { (prompt: string, progress: IProgress, history: IChatMessage[], location: ChatAgentLocation, token: CancellationToken): Promise<{ followUp: IChatFollowup[] } | void> }; + +export const IAideAgentSlashCommandService = createDecorator('aideAgentSlashCommandService'); + +/** + * This currently only exists to drive /clear and /help + */ +export interface IAideAgentSlashCommandService { + _serviceBrand: undefined; + readonly onDidChangeCommands: Event; + registerSlashCommand(data: IChatSlashData, command: IChatSlashCallback): IDisposable; + executeCommand(id: string, prompt: string, progress: IProgress, history: IChatMessage[], location: ChatAgentLocation, token: CancellationToken): Promise<{ followUp: IChatFollowup[] } | void>; + getCommands(location: ChatAgentLocation): Array; + hasCommand(id: string): boolean; +} + +type Tuple = { data: IChatSlashData; command?: IChatSlashCallback }; + +export class ChatSlashCommandService extends Disposable implements IAideAgentSlashCommandService { + + declare _serviceBrand: undefined; + + private readonly _commands = new Map(); + + private readonly _onDidChangeCommands = this._register(new Emitter()); + readonly onDidChangeCommands: Event = this._onDidChangeCommands.event; + + constructor(@IExtensionService private readonly _extensionService: IExtensionService) { + super(); + } + + override dispose(): void { + super.dispose(); + this._commands.clear(); + } + + registerSlashCommand(data: IChatSlashData, command: IChatSlashCallback): IDisposable { + if (this._commands.has(data.command)) { + throw new Error(`Already registered a command with id ${data.command}}`); + } + + this._commands.set(data.command, { data, command }); + this._onDidChangeCommands.fire(); + + return toDisposable(() => { + if (this._commands.delete(data.command)) { + this._onDidChangeCommands.fire(); + } + }); + } + + getCommands(location: ChatAgentLocation): Array { + return Array.from(this._commands.values(), v => v.data).filter(c => c.locations.includes(location)); + } + + hasCommand(id: string): boolean { + return this._commands.has(id); + } + + async executeCommand(id: string, prompt: string, progress: IProgress, history: IChatMessage[], location: ChatAgentLocation, token: CancellationToken): Promise<{ followUp: IChatFollowup[] } | void> { + const data = this._commands.get(id); + if (!data) { + throw new Error('No command with id ${id} NOT registered'); + } + if (!data.command) { + await this._extensionService.activateByEvent(`onSlash:${id}`); + } + if (!data.command) { + throw new Error(`No command with id ${id} NOT resolved`); + } + + return await data.command(prompt, progress, history, location, token); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentVariables.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentVariables.ts new file mode 100644 index 00000000000..d266db92377 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentVariables.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IRange } from '../../../../editor/common/core/range.js'; +import { Location } from '../../../../editor/common/languages.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ChatAgentLocation } from './aideAgentAgents.js'; +import { IChatModel, IChatRequestVariableData, IChatRequestVariableEntry } from './aideAgentModel.js'; +import { IParsedChatRequest } from './aideAgentParserTypes.js'; +import { IChatContentReference, IChatProgressMessage } from './aideAgentService.js'; + +export interface IChatVariableData { + id: string; + name: string; + icon?: ThemeIcon; + fullName?: string; + description: string; + modelDescription?: string; + isSlow?: boolean; + canTakeArgument?: boolean; +} + +export type IChatRequestVariableValue = string | URI | Location | unknown; + +export type IChatVariableResolverProgress = + | IChatContentReference + | IChatProgressMessage; + +export interface IChatVariableResolver { + (messageText: string, arg: string | undefined, model: IChatModel, progress: (part: IChatVariableResolverProgress) => void, token: CancellationToken): Promise; +} + +export const IAideAgentVariablesService = createDecorator('IAideAgentVariablesService'); + +export interface IAideAgentVariablesService { + _serviceBrand: undefined; + registerVariable(data: IChatVariableData, resolver: IChatVariableResolver): IDisposable; + hasVariable(name: string): boolean; + getVariable(name: string): IChatVariableData | undefined; + getVariables(location: ChatAgentLocation): Iterable>; + getDynamicVariables(sessionId: string): ReadonlyArray; // should be its own service? + attachContext(name: string, value: string | URI | Location | unknown, location: ChatAgentLocation): void; + + /** + * Resolves all variables that occur in `prompt` + */ + resolveVariables(prompt: IParsedChatRequest, attachedContextVariables: IChatRequestVariableEntry[] | undefined, model: IChatModel, progress: (part: IChatVariableResolverProgress) => void, token: CancellationToken): Promise; + resolveVariable(variableName: string, promptText: string, model: IChatModel, progress: (part: IChatVariableResolverProgress) => void, token: CancellationToken): Promise; +} + +export interface IDynamicVariable { + range: IRange; + id: string; + fullName?: string; + icon?: ThemeIcon; + prefix?: string; + modelDescription?: string; + data: IChatRequestVariableValue; +} diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentViewModel.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentViewModel.ts new file mode 100644 index 00000000000..c5fff241e25 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentViewModel.ts @@ -0,0 +1,586 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { IMarkdownString } from '../../../../base/common/htmlContent.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import * as marked from '../../../../base/common/marked/marked.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { annotateVulnerabilitiesInText } from './annotations.js'; +import { getFullyQualifiedId, IChatAgentCommand, IChatAgentData, IAideAgentAgentNameService, IChatAgentResult } from './aideAgentAgents.js'; +import { ChatModelInitState, IChatModel, IChatProgressRenderableResponseContent, IChatRequestModel, IChatRequestVariableEntry, IChatResponseModel, IChatTextEditGroup, IChatWelcomeMessageContent, IResponse } from './aideAgentModel.js'; +import { IParsedChatRequest } from './aideAgentParserTypes.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IChatCodeCitation, IChatContentReference, IChatFollowup, IChatProgressMessage, IChatResponseErrorDetails, IChatTask, IChatUsedContext } from './aideAgentService.js'; +import { countWords } from './aideAgentWordCounter.js'; +import { CodeBlockModelCollection } from './codeBlockModelCollection.js'; +import { hash } from '../../../../base/common/hash.js'; + +export function isRequestVM(item: unknown): item is IChatRequestViewModel { + return !!item && typeof item === 'object' && 'message' in item; +} + +export function isResponseVM(item: unknown): item is IChatResponseViewModel { + return !!item && typeof (item as IChatResponseViewModel).setVote !== 'undefined'; +} + +export function isWelcomeVM(item: unknown): item is IChatWelcomeMessageViewModel { + return !!item && typeof item === 'object' && 'content' in item; +} + +export type IChatViewModelChangeEvent = IChatAddRequestEvent | IChangePlaceholderEvent | IChatSessionInitEvent | null; + +export interface IChatAddRequestEvent { + kind: 'addRequest'; +} + +export interface IChangePlaceholderEvent { + kind: 'changePlaceholder'; +} + +export interface IChatSessionInitEvent { + kind: 'initialize'; +} + +export interface IChatViewModel { + readonly model: IChatModel; + readonly initState: ChatModelInitState; + readonly sessionId: string; + readonly onDidDisposeModel: Event; + readonly onDidChange: Event; + readonly requestInProgress: boolean; + readonly inputPlaceholder?: string; + getItems(): (IChatRequestViewModel | IChatResponseViewModel | IChatWelcomeMessageViewModel)[]; + setInputPlaceholder(text: string): void; + resetInputPlaceholder(): void; +} + +export interface IChatRequestViewModel { + readonly id: string; + readonly sessionId: string; + /** This ID updates every time the underlying data changes */ + readonly dataId: string; + readonly username: string; + readonly avatarIcon?: URI | ThemeIcon; + readonly message: IParsedChatRequest | IChatFollowup; + readonly messageText: string; + readonly attempt: number; + readonly variables: IChatRequestVariableEntry[]; + currentRenderedHeight: number | undefined; + readonly contentReferences?: ReadonlyArray; + readonly confirmation?: string; +} + +export interface IChatResponseMarkdownRenderData { + renderedWordCount: number; + lastRenderTime: number; + isFullyRendered: boolean; + originalMarkdown: IMarkdownString; +} + +export interface IChatResponseMarkdownRenderData2 { + renderedWordCount: number; + lastRenderTime: number; + isFullyRendered: boolean; + originalMarkdown: IMarkdownString; +} + +export interface IChatProgressMessageRenderData { + progressMessage: IChatProgressMessage; + + /** + * Indicates whether this is part of a group of progress messages that are at the end of the response. + * (Not whether this particular item is the very last one in the response). + * Need to re-render and add to partsToRender when this changes. + */ + isAtEndOfResponse: boolean; + + /** + * Whether this progress message the very last item in the response. + * Need to re-render to update spinner vs check when this changes. + */ + isLast: boolean; +} + +export interface IChatTaskRenderData { + task: IChatTask; + isSettled: boolean; + progressLength: number; +} + +export interface IChatResponseRenderData { + renderedParts: IChatRendererContent[]; + + renderedWordCount: number; + lastRenderTime: number; +} + +/** + * Content type for references used during rendering, not in the model + */ +export interface IChatReferences { + references: ReadonlyArray; + kind: 'references'; +} + +/** + * Content type for citations used during rendering, not in the model + */ +export interface IChatCodeCitations { + citations: ReadonlyArray; + kind: 'codeCitations'; +} + +/** + * Type for content parts rendered by IChatListRenderer + */ +export type IChatRendererContent = IChatProgressRenderableResponseContent | IChatReferences | IChatCodeCitations; + +export interface IChatLiveUpdateData { + firstWordTime: number; + lastUpdateTime: number; + impliedWordLoadRate: number; + lastWordCount: number; +} + +export interface IChatResponseViewModel { + readonly model: IChatResponseModel; + readonly id: string; + readonly sessionId: string; + /** This ID updates every time the underlying data changes */ + readonly dataId: string; + /** The ID of the associated IChatRequestViewModel */ + // readonly requestId: string; + readonly username: string; + readonly avatarIcon?: URI | ThemeIcon; + readonly agent?: IChatAgentData; + readonly slashCommand?: IChatAgentCommand; + readonly agentOrSlashCommandDetected: boolean; + readonly response: IResponse; + readonly usedContext: IChatUsedContext | undefined; + readonly contentReferences: ReadonlyArray; + readonly codeCitations: ReadonlyArray; + readonly progressMessages: ReadonlyArray; + readonly isComplete: boolean; + readonly isCanceled: boolean; + readonly isStale: boolean; + readonly vote: ChatAgentVoteDirection | undefined; + readonly voteDownReason: ChatAgentVoteDownReason | undefined; + readonly replyFollowups?: IChatFollowup[]; + readonly errorDetails?: IChatResponseErrorDetails; + readonly result?: IChatAgentResult; + readonly contentUpdateTimings?: IChatLiveUpdateData; + renderData?: IChatResponseRenderData; + currentRenderedHeight: number | undefined; + setVote(vote: ChatAgentVoteDirection): void; + setVoteDownReason(reason: ChatAgentVoteDownReason | undefined): void; + usedReferencesExpanded?: boolean; + vulnerabilitiesListExpanded: boolean; + setEditApplied(edit: IChatTextEditGroup, editCount: number): void; +} + +export class ChatViewModel extends Disposable implements IChatViewModel { + + private readonly _onDidDisposeModel = this._register(new Emitter()); + readonly onDidDisposeModel = this._onDidDisposeModel.event; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + private readonly _items: (ChatRequestViewModel | ChatResponseViewModel)[] = []; + + private _inputPlaceholder: string | undefined = undefined; + get inputPlaceholder(): string | undefined { + return this._inputPlaceholder; + } + + get model(): IChatModel { + return this._model; + } + + setInputPlaceholder(text: string): void { + this._inputPlaceholder = text; + this._onDidChange.fire({ kind: 'changePlaceholder' }); + } + + resetInputPlaceholder(): void { + this._inputPlaceholder = undefined; + this._onDidChange.fire({ kind: 'changePlaceholder' }); + } + + get sessionId() { + return this._model.sessionId; + } + + get requestInProgress(): boolean { + return this._model.requestInProgress; + } + + get initState() { + return this._model.initState; + } + + constructor( + private readonly _model: IChatModel, + public readonly codeBlockModelCollection: CodeBlockModelCollection, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + + _model.getExchanges().forEach((exchange, i) => { + if ('message' in exchange) { + const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, exchange); + this._items.push(requestModel); + this.updateCodeBlockTextModels(requestModel); + } else if ('response' in exchange) { + this.onAddResponse(exchange); + } + }); + + this._register(_model.onDidDispose(() => this._onDidDisposeModel.fire())); + this._register(_model.onDidChange(e => { + if (e.kind === 'addRequest') { + const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, e.request); + this._items.push(requestModel); + this.updateCodeBlockTextModels(requestModel); + + /* TODO(@ghostwriternr): Why do we need to do this? + if (e.request.response) { + this.onAddResponse(e.request.response); + } + */ + } 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); + if (requestIdx >= 0) { + this._items.splice(requestIdx, 1); + } + + const responseIdx = e.responseId && this._items.findIndex(item => isResponseVM(item) && item.id === e.responseId); + if (typeof responseIdx === 'number' && responseIdx >= 0) { + const items = this._items.splice(responseIdx, 1); + const item = items[0]; + if (item instanceof ChatResponseViewModel) { + item.dispose(); + } + } + } + + const modelEventToVmEvent: IChatViewModelChangeEvent = e.kind === 'addRequest' ? { kind: 'addRequest' } : + e.kind === 'initialize' ? { kind: 'initialize' } : + null; + this._onDidChange.fire(modelEventToVmEvent); + })); + } + + private onAddResponse(responseModel: IChatResponseModel) { + const response = this.instantiationService.createInstance(ChatResponseViewModel, responseModel); + this._register(response.onDidChange(() => { + if (response.isComplete) { + this.updateCodeBlockTextModels(response); + } + return this._onDidChange.fire(null); + })); + this._items.push(response); + this.updateCodeBlockTextModels(response); + } + + getItems(): (IChatRequestViewModel | IChatResponseViewModel | IChatWelcomeMessageViewModel)[] { + return [...(this._model.welcomeMessage ? [this._model.welcomeMessage] : []), ...this._items]; + } + + override dispose() { + super.dispose(); + this._items + .filter((item): item is ChatResponseViewModel => item instanceof ChatResponseViewModel) + .forEach((item: ChatResponseViewModel) => item.dispose()); + } + + updateCodeBlockTextModels(model: IChatRequestViewModel | IChatResponseViewModel) { + let content: string; + if (isRequestVM(model)) { + content = model.messageText; + } else { + content = annotateVulnerabilitiesInText(model.response.value).map(x => x.content.value).join(''); + } + + let codeBlockIndex = 0; + marked.walkTokens(marked.lexer(content), token => { + if (token.type === 'code') { + const lang = token.lang || ''; + const text = token.text; + this.codeBlockModelCollection.update(this._model.sessionId, model, codeBlockIndex++, { text, languageId: lang }); + } + }); + } +} + +export class ChatRequestViewModel implements IChatRequestViewModel { + get id() { + return this._model.id; + } + + get dataId() { + return this.id + `_${ChatModelInitState[this._model.session.initState]}_${hash(this.variables)}`; + } + + get sessionId() { + return this._model.session.sessionId; + } + + get username() { + return this._model.username; + } + + get avatarIcon() { + return this._model.avatarIconUri; + } + + get message() { + return this._model.message; + } + + get messageText() { + return this.message.text; + } + + get attempt() { + return this._model.attempt; + } + + get variables() { + return this._model.variableData.variables; + } + + get contentReferences() { + // TODO(@ghostwriternr): This seems useful, but I don't want to fix this yet. + // return this._model.response?.contentReferences; + return []; + } + + get confirmation() { + return this._model.confirmation; + } + + currentRenderedHeight: number | undefined; + + constructor( + private readonly _model: IChatRequestModel, + ) { } +} + +export class ChatResponseViewModel extends Disposable implements IChatResponseViewModel { + private _modelChangeCount = 0; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + get model() { + return this._model; + } + + get id() { + return this._model.id; + } + + get dataId() { + return this._model.id + `_${this._modelChangeCount}` + `_${ChatModelInitState[this._model.session.initState]}`; + } + + get sessionId() { + return this._model.session.sessionId; + } + + get username() { + if (this.agent) { + const isAllowed = this.chatAgentNameService.getAgentNameRestriction(this.agent); + if (isAllowed) { + return this.agent.fullName || this.agent.name; + } else { + return getFullyQualifiedId(this.agent); + } + } + + return this._model.username; + } + + get avatarIcon() { + return this._model.avatarIcon; + } + + get agent() { + return this._model.agent; + } + + get slashCommand() { + return this._model.slashCommand; + } + + get agentOrSlashCommandDetected() { + return this._model.agentOrSlashCommandDetected; + } + + get response(): IResponse { + return this._model.response; + } + + get usedContext(): IChatUsedContext | undefined { + return this._model.usedContext; + } + + get contentReferences(): ReadonlyArray { + return this._model.contentReferences; + } + + get codeCitations(): ReadonlyArray { + return this._model.codeCitations; + } + + get progressMessages(): ReadonlyArray { + return this._model.progressMessages; + } + + get isComplete() { + return this._model.isComplete; + } + + get isCanceled() { + return this._model.isCanceled; + } + + get replyFollowups() { + return this._model.followups?.filter((f): f is IChatFollowup => f.kind === 'reply'); + } + + get result() { + return this._model.result; + } + + get errorDetails(): IChatResponseErrorDetails | undefined { + return this.result?.errorDetails; + } + + get vote() { + return this._model.vote; + } + + get voteDownReason() { + return this._model.voteDownReason; + } + + /* TODO(@ghostwriternr): Once we have a clear picture of how requests and responses are going to be linked, we can remove this entirely. + get requestId() { + return this._model.requestId; + } + */ + + get isStale() { + return this._model.isStale; + } + + renderData: IChatResponseRenderData | undefined = undefined; + currentRenderedHeight: number | undefined; + + private _usedReferencesExpanded: boolean | undefined; + get usedReferencesExpanded(): boolean | undefined { + if (typeof this._usedReferencesExpanded === 'boolean') { + return this._usedReferencesExpanded; + } + + return this.response.value.length === 0; + } + + set usedReferencesExpanded(v: boolean) { + this._usedReferencesExpanded = v; + } + + private _vulnerabilitiesListExpanded: boolean = false; + get vulnerabilitiesListExpanded(): boolean { + return this._vulnerabilitiesListExpanded; + } + + set vulnerabilitiesListExpanded(v: boolean) { + this._vulnerabilitiesListExpanded = v; + } + + private _contentUpdateTimings: IChatLiveUpdateData | undefined = undefined; + get contentUpdateTimings(): IChatLiveUpdateData | undefined { + return this._contentUpdateTimings; + } + + constructor( + private readonly _model: IChatResponseModel, + @ILogService private readonly logService: ILogService, + @IAideAgentAgentNameService private readonly chatAgentNameService: IAideAgentAgentNameService, + ) { + super(); + + if (!_model.isComplete) { + this._contentUpdateTimings = { + firstWordTime: 0, + lastUpdateTime: Date.now(), + impliedWordLoadRate: 0, + lastWordCount: 0 + }; + } + + this._register(_model.onDidChange(() => { + // This should be true, if the model is changing + if (this._contentUpdateTimings) { + const now = Date.now(); + const wordCount = countWords(_model.response.toString()); + + // Apply a min time difference, or the rate is typically too high for first few words + const timeDiff = Math.max(now - this._contentUpdateTimings.firstWordTime, 250); + const impliedWordLoadRate = this._contentUpdateTimings.lastWordCount / (timeDiff / 1000); + this.trace('onDidChange', `Update- got ${this._contentUpdateTimings.lastWordCount} words over last ${timeDiff}ms = ${impliedWordLoadRate} words/s. ${wordCount} words are now available.`); + this._contentUpdateTimings = { + firstWordTime: this._contentUpdateTimings.firstWordTime === 0 && this.response.value.some(v => v.kind === 'markdownContent') ? now : this._contentUpdateTimings.firstWordTime, + lastUpdateTime: now, + impliedWordLoadRate, + lastWordCount: wordCount + }; + } else { + this.logService.warn('ChatResponseViewModel#onDidChange: got model update but contentUpdateTimings is not initialized'); + } + + // new data -> new id, new content to render + this._modelChangeCount++; + + this._onDidChange.fire(); + })); + } + + private trace(tag: string, message: string) { + this.logService.trace(`ChatResponseViewModel#${tag}: ${message}`); + } + + setVote(vote: ChatAgentVoteDirection): void { + this._modelChangeCount++; + this._model.setVote(vote); + } + + setVoteDownReason(reason: ChatAgentVoteDownReason | undefined): void { + this._modelChangeCount++; + this._model.setVoteDownReason(reason); + } + + setEditApplied(edit: IChatTextEditGroup, editCount: number) { + this._modelChangeCount++; + this._model.setEditApplied(edit, editCount); + } +} + +export interface IChatWelcomeMessageViewModel { + readonly id: string; + readonly username: string; + readonly avatarIcon?: URI | ThemeIcon; + readonly content: IChatWelcomeMessageContent[]; + readonly sampleQuestions: IChatFollowup[]; + currentRenderedHeight?: number; +} diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentWidgetHistoryService.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentWidgetHistoryService.ts new file mode 100644 index 00000000000..60c2f4a2503 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentWidgetHistoryService.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { Memento } from '../../../common/memento.js'; +import { ChatAgentLocation } from './aideAgentAgents.js'; +import { CHAT_PROVIDER_ID } from './aideAgentParticipantContribTypes.js'; + +export interface IChatHistoryEntry { + text: string; + state?: any; +} + +export const IAideAgentWidgetHistoryService = createDecorator('IAideAgentWidgetHistoryService'); +export interface IAideAgentWidgetHistoryService { + _serviceBrand: undefined; + + readonly onDidClearHistory: Event; + + clearHistory(): void; + getHistory(location: ChatAgentLocation): IChatHistoryEntry[]; + saveHistory(location: ChatAgentLocation, history: IChatHistoryEntry[]): void; +} + +interface IChatHistory { + history: { [providerId: string]: IChatHistoryEntry[] }; +} + +export class ChatWidgetHistoryService implements IAideAgentWidgetHistoryService { + _serviceBrand: undefined; + + private memento: Memento; + private viewState: IChatHistory; + + private readonly _onDidClearHistory = new Emitter(); + readonly onDidClearHistory: Event = this._onDidClearHistory.event; + + constructor( + @IStorageService storageService: IStorageService + ) { + this.memento = new Memento('aide-agent-session', storageService); + const loadedState = this.memento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE) as IChatHistory; + for (const provider in loadedState.history) { + // Migration from old format + loadedState.history[provider] = loadedState.history[provider].map(entry => typeof entry === 'string' ? { text: entry } : entry); + } + + this.viewState = loadedState; + } + + getHistory(location: ChatAgentLocation): IChatHistoryEntry[] { + const key = this.getKey(location); + return this.viewState.history?.[key] ?? []; + } + + private getKey(location: ChatAgentLocation): string { + // Preserve history for panel by continuing to use the same old provider id. Use the location as a key for other chat locations. + return location === ChatAgentLocation.Panel ? CHAT_PROVIDER_ID : location; + } + + saveHistory(location: ChatAgentLocation, history: IChatHistoryEntry[]): void { + if (!this.viewState.history) { + this.viewState.history = {}; + } + + const key = this.getKey(location); + this.viewState.history[key] = history; + this.memento.saveMemento(); + } + + clearHistory(): void { + this.viewState.history = {}; + this.memento.saveMemento(); + this._onDidClearHistory.fire(); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentWordCounter.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentWordCounter.ts new file mode 100644 index 00000000000..c1989d4f997 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentWordCounter.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface IWordCountResult { + value: string; + returnedWordCount: number; + totalWordCount: number; + isFullString: boolean; +} + +const r = String.raw; + +/** + * Matches `[text](link title?)` or `[text]( title?)` + * + * Taken from vscode-markdown-languageservice + */ +const linkPattern = + r`(? + /**/r`(?:` + + /*****/r`[^\[\]\\]|` + // Non-bracket chars, or... + /*****/r`\\.|` + // Escaped char, or... + /*****/r`\[[^\[\]]*\]` + // Matched bracket pair + /**/r`)*` + + r`\])` + // <-- close prefix match + + // Destination + r`(\(\s*)` + // Pre href + /**/r`(` + + /*****/r`[^\s\(\)<](?:[^\s\(\)]|\([^\s\(\)]*?\))*|` + // Link without whitespace, or... + /*****/r`<(?:\\[<>]|[^<>])+>` + // In angle brackets + /**/r`)` + + + // Title + /**/r`\s*(?:"[^"]*"|'[^']*'|\([^\(\)]*\))?\s*` + + r`\)`; + +export function getNWords(str: string, numWordsToCount: number): IWordCountResult { + // This regex matches each word and skips over whitespace and separators. A word is: + // A markdown link + // One chinese character + // One or more + - =, handled so that code like "a=1+2-3" is broken up better + // One or more characters that aren't whitepace or any of the above + const allWordMatches = Array.from(str.matchAll(new RegExp(linkPattern + r`|\p{sc=Han}|=+|\++|-+|[^\s\|\p{sc=Han}|=|\+|\-]+`, 'gu'))); + + const targetWords = allWordMatches.slice(0, numWordsToCount); + + const endIndex = numWordsToCount > allWordMatches.length + ? str.length // Reached end of string + : targetWords.length ? targetWords.at(-1)!.index + targetWords.at(-1)![0].length : 0; + + const value = str.substring(0, endIndex); + return { + value, + returnedWordCount: targetWords.length === 0 ? (value.length ? 1 : 0) : targetWords.length, + isFullString: endIndex >= str.length, + totalWordCount: allWordMatches.length + }; +} + +export function countWords(str: string): number { + const result = getNWords(str, Number.MAX_SAFE_INTEGER); + return result.returnedWordCount; +} diff --git a/src/vs/workbench/contrib/aideAgent/common/annotations.ts b/src/vs/workbench/contrib/aideAgent/common/annotations.ts new file mode 100644 index 00000000000..8f4254254e2 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/annotations.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { basename } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IRange } from '../../../../editor/common/core/range.js'; +import { IWorkspaceSymbol } from '../../search/common/search.js'; +import { IChatProgressRenderableResponseContent, IChatProgressResponseContent, appendMarkdownString, canMergeMarkdownStrings } from './aideAgentModel.js'; +import { IChatAgentVulnerabilityDetails, IChatMarkdownContent } from './aideAgentService.js'; + +export const contentRefUrl = 'http://_vscodecontentref_'; // must be lowercase for URI + +export type ContentRefData = + | { readonly kind: 'symbol'; readonly symbol: IWorkspaceSymbol } + | { + readonly kind?: undefined; + readonly uri: URI; + readonly range?: IRange; + }; + +export function annotateSpecialMarkdownContent(response: ReadonlyArray): IChatProgressRenderableResponseContent[] { + const result: IChatProgressRenderableResponseContent[] = []; + for (const item of response) { + const previousItem = result[result.length - 1]; + if (item.kind === 'inlineReference') { + const location: ContentRefData = 'uri' in item.inlineReference + ? item.inlineReference + : 'name' in item.inlineReference + ? { kind: 'symbol', symbol: item.inlineReference } + : { uri: item.inlineReference }; + + const printUri = URI.parse(contentRefUrl).with({ fragment: JSON.stringify(location) }); + let label: string | undefined = item.name; + if (!label) { + if (location.kind === 'symbol') { + label = location.symbol.name; + } else { + label = basename(location.uri); + } + } + + const markdownText = `[${label}](${printUri.toString()})`; + if (previousItem?.kind === 'markdownContent') { + const merged = appendMarkdownString(previousItem.content, new MarkdownString(markdownText)); + result[result.length - 1] = { content: merged, kind: 'markdownContent' }; + } else { + result.push({ content: new MarkdownString(markdownText), kind: 'markdownContent' }); + } + } else if (item.kind === 'markdownContent' && previousItem?.kind === 'markdownContent' && canMergeMarkdownStrings(previousItem.content, item.content)) { + const merged = appendMarkdownString(previousItem.content, item.content); + result[result.length - 1] = { content: merged, kind: 'markdownContent' }; + } else if (item.kind === 'markdownVuln') { + const vulnText = encodeURIComponent(JSON.stringify(item.vulnerabilities)); + const markdownText = `${item.content.value}`; + if (previousItem?.kind === 'markdownContent') { + // Since this is inside a codeblock, it needs to be merged into the previous markdown content. + const merged = appendMarkdownString(previousItem.content, new MarkdownString(markdownText)); + result[result.length - 1] = { content: merged, kind: 'markdownContent' }; + } else { + result.push({ content: new MarkdownString(markdownText), kind: 'markdownContent' }); + } + } else if (item.kind === 'codeblockUri') { + if (previousItem?.kind === 'markdownContent') { + const markdownText = `${item.uri.toString()}`; + const merged = appendMarkdownString(previousItem.content, new MarkdownString(markdownText)); + result[result.length - 1] = { content: merged, kind: 'markdownContent' }; + } + } else { + result.push(item); + } + } + + return result; +} + +export interface IMarkdownVulnerability { + readonly title: string; + readonly description: string; + readonly range: IRange; +} + +export function annotateVulnerabilitiesInText(response: ReadonlyArray): readonly IChatMarkdownContent[] { + const result: IChatMarkdownContent[] = []; + for (const item of response) { + const previousItem = result[result.length - 1]; + if (item.kind === 'markdownContent') { + if (previousItem?.kind === 'markdownContent') { + result[result.length - 1] = { content: new MarkdownString(previousItem.content.value + item.content.value, { isTrusted: previousItem.content.isTrusted }), kind: 'markdownContent' }; + } else { + result.push(item); + } + } else if (item.kind === 'markdownVuln') { + const vulnText = encodeURIComponent(JSON.stringify(item.vulnerabilities)); + const markdownText = `${item.content.value}`; + if (previousItem?.kind === 'markdownContent') { + result[result.length - 1] = { content: new MarkdownString(previousItem.content.value + markdownText, { isTrusted: previousItem.content.isTrusted }), kind: 'markdownContent' }; + } else { + result.push({ content: new MarkdownString(markdownText), kind: 'markdownContent' }); + } + } + } + + return result; +} + +export function extractCodeblockUrisFromText(text: string): { uri: URI; textWithoutResult: string } | undefined { + const match = /(.*?)<\/vscode_codeblock_uri>/ms.exec(text); + if (match && match[1]) { + const result = URI.parse(match[1]); + const textWithoutResult = text.substring(0, match.index) + text.substring(match.index + match[0].length); + return { uri: result, textWithoutResult }; + } + return undefined; +} + +export function extractVulnerabilitiesFromText(text: string): { newText: string; vulnerabilities: IMarkdownVulnerability[] } { + const vulnerabilities: IMarkdownVulnerability[] = []; + let newText = text; + let match: RegExpExecArray | null; + while ((match = /(.*?)<\/vscode_annotation>/ms.exec(newText)) !== null) { + const [full, details, content] = match; + const start = match.index; + const textBefore = newText.substring(0, start); + const linesBefore = textBefore.split('\n').length - 1; + const linesInside = content.split('\n').length - 1; + + const previousNewlineIdx = textBefore.lastIndexOf('\n'); + const startColumn = start - (previousNewlineIdx + 1) + 1; + const endPreviousNewlineIdx = (textBefore + content).lastIndexOf('\n'); + const endColumn = start + content.length - (endPreviousNewlineIdx + 1) + 1; + + try { + const vulnDetails: IChatAgentVulnerabilityDetails[] = JSON.parse(decodeURIComponent(details)); + vulnDetails.forEach(({ title, description }) => vulnerabilities.push({ + title, description, range: { startLineNumber: linesBefore + 1, startColumn, endLineNumber: linesBefore + linesInside + 1, endColumn } + })); + } catch (err) { + // Something went wrong with encoding this text, just ignore it + } + newText = newText.substring(0, start) + content + newText.substring(start + full.length); + } + + return { newText, vulnerabilities }; +} diff --git a/src/vs/workbench/contrib/aideAgent/common/codeBlockModelCollection.ts b/src/vs/workbench/contrib/aideAgent/common/codeBlockModelCollection.ts new file mode 100644 index 00000000000..57da970d824 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/codeBlockModelCollection.ts @@ -0,0 +1,196 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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 { IResolvedTextEditorModel, ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { extractCodeblockUrisFromText, extractVulnerabilitiesFromText, IMarkdownVulnerability } from './annotations.js'; +import { IChatRequestViewModel, IChatResponseViewModel, isResponseVM } from './aideAgentViewModel.js'; + + +export class CodeBlockModelCollection extends Disposable { + + private readonly _models = new ResourceMap<{ + readonly model: Promise>; + vulns: readonly IMarkdownVulnerability[]; + codemapperUri?: URI; + }>(); + + /** + * Max number of models to keep in memory. + * + * Currently always maintains the most recently created models. + */ + private readonly maxModelCount = 100; + + constructor( + @ILanguageService private readonly languageService: ILanguageService, + @ITextModelService private readonly textModelService: ITextModelService + ) { + super(); + } + + public override dispose(): void { + super.dispose(); + this.clear(); + } + + get(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, 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); + if (!entry) { + return; + } + return { model: entry.model.then(ref => ref.object), vulns: entry.vulns, codemapperUri: entry.codemapperUri }; + } + + getOrCreate(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, codeBlockIndex: number): { model: Promise; readonly vulns: readonly IMarkdownVulnerability[]; readonly codemapperUri?: URI } { + 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 }); + + while (this._models.size > this.maxModelCount) { + const first = Array.from(this._models.keys()).at(0); + if (!first) { + break; + } + this.delete(first); + } + + return { model: ref.then(ref => ref.object), vulns: [], codemapperUri: undefined }; + } + + private delete(codeBlockUri: URI) { + const entry = this._models.get(codeBlockUri); + if (!entry) { + return; + } + + entry.model.then(ref => ref.dispose()); + this._models.delete(codeBlockUri); + } + + clear(): void { + this._models.forEach(async entry => (await entry.model).dispose()); + this._models.clear(); + } + + async update(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, codeBlockIndex: number, content: { text: string; languageId?: string }) { + const entry = this.getOrCreate(sessionId, chat, codeBlockIndex); + + const extractedVulns = extractVulnerabilitiesFromText(content.text); + let newText = fixCodeText(extractedVulns.newText, content.languageId); + this.setVulns(sessionId, chat, codeBlockIndex, extractedVulns.vulnerabilities); + + const codeblockUri = extractCodeblockUrisFromText(newText); + if (codeblockUri) { + this.setCodemapperUri(sessionId, chat, codeBlockIndex, codeblockUri.uri); + newText = codeblockUri.textWithoutResult; + } + + const textModel = (await entry.model).textEditorModel; + if (content.languageId) { + const vscodeLanguageId = this.languageService.getLanguageIdByLanguageName(content.languageId); + if (vscodeLanguageId && vscodeLanguageId !== textModel.getLanguageId()) { + textModel.setLanguage(vscodeLanguageId); + } + } + + const currentText = textModel.getValue(EndOfLinePreference.LF); + if (newText === currentText) { + return entry; + } + + if (newText.startsWith(currentText)) { + const text = newText.slice(currentText.length); + const lastLine = textModel.getLineCount(); + const lastCol = textModel.getLineMaxColumn(lastLine); + textModel.applyEdits([{ range: new Range(lastLine, lastCol, lastLine, lastCol), text }]); + } else { + // console.log(`Failed to optimize setText`); + textModel.setValue(newText); + } + + return entry; + } + + private setCodemapperUri(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, codeBlockIndex: number, codemapperUri: URI) { + const uri = this.getUri(sessionId, chat, codeBlockIndex); + const entry = this._models.get(uri); + if (entry) { + entry.codemapperUri = codemapperUri; + } + } + + private setVulns(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, codeBlockIndex: number, vulnerabilities: IMarkdownVulnerability[]) { + const uri = this.getUri(sessionId, chat, codeBlockIndex); + const entry = this._models.get(uri); + if (entry) { + entry.vulns = vulnerabilities; + } + } + + private getUri(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel, index: number): URI { + const metadata = this.getUriMetaData(chat); + return URI.from({ + scheme: Schemas.vscodeAideAgentCodeBlock, + authority: sessionId, + path: `/${chat.id}/${index}`, + fragment: metadata ? JSON.stringify(metadata) : undefined, + }); + } + + private getUriMetaData(chat: IChatRequestViewModel | IChatResponseViewModel) { + if (!isResponseVM(chat)) { + return undefined; + } + + return { + references: chat.contentReferences.map(ref => { + if (typeof ref.reference === 'string') { + return; + } + + const uriOrLocation = 'variableName' in ref.reference ? + ref.reference.value : + ref.reference; + if (!uriOrLocation) { + return; + } + + if (URI.isUri(uriOrLocation)) { + return { + uri: uriOrLocation.toJSON() + }; + } + + return { + uri: uriOrLocation.uri.toJSON(), + range: uriOrLocation.range, + }; + }) + }; + } +} + +function fixCodeText(text: string, languageId: string | undefined): string { + if (languageId === 'php') { + if (!text.trim().startsWith('<')) { + return `('IAideAgentLMStatsService'); + +export interface IAideAgentLMStatsService { + readonly _serviceBrand: undefined; + + update(model: string, extensionId: ExtensionIdentifier, agent: string | undefined, tokenCount: number | undefined): Promise; +} + +interface LanguageModelStats { + extensions: { + extensionId: string; + requestCount: number; + tokenCount: number; + participants: { + id: string; + requestCount: number; + tokenCount: number; + }[]; + }[]; +} + +export class LanguageModelStatsService extends Disposable implements IAideAgentLMStatsService { + + private static readonly MODEL_STATS_STORAGE_KEY_PREFIX = 'languageModelStats.'; + private static readonly MODEL_ACCESS_STORAGE_KEY_PREFIX = 'languageModelAccess.'; + + declare _serviceBrand: undefined; + + private readonly _onDidChangeStats = this._register(new Emitter()); + readonly onDidChangeLanguageMoelStats = this._onDidChangeStats.event; + + private readonly sessionStats = new Map(); + + constructor( + @IExtensionFeaturesManagementService private readonly extensionFeaturesManagementService: IExtensionFeaturesManagementService, + @IStorageService private readonly _storageService: IStorageService, + ) { + super(); + this._register(_storageService.onDidChangeValue(StorageScope.APPLICATION, undefined, this._store)(e => { + const model = this.getModel(e.key); + if (model) { + this._onDidChangeStats.fire(model); + } + })); + } + + hasAccessedModel(extensionId: string, model: string): boolean { + return this.getAccessExtensions(model).includes(extensionId.toLowerCase()); + } + + async update(model: string, extensionId: ExtensionIdentifier, agent: string | undefined, tokenCount: number | undefined): Promise { + await this.extensionFeaturesManagementService.getAccess(extensionId, 'languageModels'); + + // update model access + this.addAccess(model, extensionId.value); + + // update session stats + let sessionStats = this.sessionStats.get(model); + if (!sessionStats) { + sessionStats = { extensions: [] }; + this.sessionStats.set(model, sessionStats); + } + this.add(sessionStats, extensionId.value, agent, tokenCount); + + this.write(model, extensionId.value, agent, tokenCount); + this._onDidChangeStats.fire(model); + } + + private addAccess(model: string, extensionId: string): void { + extensionId = extensionId.toLowerCase(); + const extensions = this.getAccessExtensions(model); + if (!extensions.includes(extensionId)) { + extensions.push(extensionId); + this._storageService.store(this.getAccessKey(model), JSON.stringify(extensions), StorageScope.APPLICATION, StorageTarget.USER); + } + } + + private getAccessExtensions(model: string): string[] { + const key = this.getAccessKey(model); + const data = this._storageService.get(key, StorageScope.APPLICATION); + try { + if (data) { + const parsed = JSON.parse(data); + if (Array.isArray(parsed)) { + return parsed; + } + } + } catch (e) { + // ignore + } + return []; + + } + + private async write(model: string, extensionId: string, participant: string | undefined, tokenCount: number | undefined): Promise { + const modelStats = await this.read(model); + this.add(modelStats, extensionId, participant, tokenCount); + this._storageService.store(this.getKey(model), JSON.stringify(modelStats), StorageScope.APPLICATION, StorageTarget.USER); + } + + private add(modelStats: LanguageModelStats, extensionId: string, participant: string | undefined, tokenCount: number | undefined): void { + let extensionStats = modelStats.extensions.find(e => ExtensionIdentifier.equals(e.extensionId, extensionId)); + if (!extensionStats) { + extensionStats = { extensionId, requestCount: 0, tokenCount: 0, participants: [] }; + modelStats.extensions.push(extensionStats); + } + if (participant) { + let participantStats = extensionStats.participants.find(p => p.id === participant); + if (!participantStats) { + participantStats = { id: participant, requestCount: 0, tokenCount: 0 }; + extensionStats.participants.push(participantStats); + } + participantStats.requestCount++; + participantStats.tokenCount += tokenCount ?? 0; + } else { + extensionStats.requestCount++; + extensionStats.tokenCount += tokenCount ?? 0; + } + } + + private async read(model: string): Promise { + try { + const value = this._storageService.get(this.getKey(model), StorageScope.APPLICATION); + if (value) { + return JSON.parse(value); + } + } catch (error) { + // ignore + } + return { extensions: [] }; + } + + private getModel(key: string): string | undefined { + if (key.startsWith(LanguageModelStatsService.MODEL_STATS_STORAGE_KEY_PREFIX)) { + return key.substring(LanguageModelStatsService.MODEL_STATS_STORAGE_KEY_PREFIX.length); + } + return undefined; + } + + private getKey(model: string): string { + return `${LanguageModelStatsService.MODEL_STATS_STORAGE_KEY_PREFIX}${model}`; + } + + private getAccessKey(model: string): string { + return `${LanguageModelStatsService.MODEL_ACCESS_STORAGE_KEY_PREFIX}${model}`; + } +} + +Registry.as(Extensions.ExtensionFeaturesRegistry).registerExtensionFeature({ + id: 'aideAgentLMs', + label: localize('Language Models', "Language Models"), + description: localize('languageModels', "Language models usage statistics of this extension."), + access: { + canToggle: false + }, +}); diff --git a/src/vs/workbench/contrib/aideAgent/common/languageModelToolsService.ts b/src/vs/workbench/contrib/aideAgent/common/languageModelToolsService.ts new file mode 100644 index 00000000000..bc06d5fe073 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/languageModelToolsService.ts @@ -0,0 +1,183 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Iterable } from '../../../../base/common/iterator.js'; +import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; +import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ContextKeyExpression, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IExtensionService } from '../../../services/extensions/common/extensions.js'; + +export interface IToolData { + id: string; + name?: string; + icon?: { dark: URI; light?: URI } | ThemeIcon; + when?: ContextKeyExpression; + displayName?: string; + userDescription?: string; + modelDescription: string; + parametersSchema?: IJSONSchema; + canBeInvokedManually?: boolean; +} + +interface IToolEntry { + data: IToolData; + impl?: IToolImpl; +} + +export interface IToolInvocation { + callId: string; + toolId: string; + parameters: any; + tokenBudget?: number; + context: IToolInvocationContext | undefined; +} + +export interface IToolInvocationContext { + sessionId: string; +} + +export interface IToolResult { + [contentType: string]: any; + string: string; +} + +export interface IToolImpl { + invoke(dto: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise; +} + +export const IAideAgentLMToolsService = createDecorator('IAideAgentLMToolsService'); + +export type CountTokensCallback = (input: string, token: CancellationToken) => Promise; + +export interface IAideAgentLMToolsService { + _serviceBrand: undefined; + onDidChangeTools: Event; + registerToolData(toolData: IToolData): IDisposable; + registerToolImplementation(name: string, tool: IToolImpl): IDisposable; + getTools(): Iterable>; + getTool(id: string): IToolData | undefined; + getToolByName(name: string): IToolData | undefined; + invokeTool(dto: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise; +} + +export class LanguageModelToolsService extends Disposable implements IAideAgentLMToolsService { + _serviceBrand: undefined; + + private _onDidChangeTools = new Emitter(); + readonly onDidChangeTools = this._onDidChangeTools.event; + + /** Throttle tools updates because it sends all tools and runs on context key updates */ + private _onDidChangeToolsScheduler = new RunOnceScheduler(() => this._onDidChangeTools.fire(), 750); + + private _tools = new Map(); + private _toolContextKeys = new Set(); + + constructor( + @IExtensionService private readonly _extensionService: IExtensionService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + ) { + super(); + + this._register(this._contextKeyService.onDidChangeContext(e => { + if (e.affectsSome(this._toolContextKeys)) { + // Not worth it to compute a delta here unless we have many tools changing often + this._onDidChangeToolsScheduler.schedule(); + } + })); + } + + registerToolData(toolData: IToolData): IDisposable { + if (this._tools.has(toolData.id)) { + throw new Error(`Tool "${toolData.id}" is already registered.`); + } + + this._tools.set(toolData.id, { data: toolData }); + this._onDidChangeToolsScheduler.schedule(); + + toolData.when?.keys().forEach(key => this._toolContextKeys.add(key)); + + return toDisposable(() => { + this._tools.delete(toolData.id); + this._refreshAllToolContextKeys(); + this._onDidChangeToolsScheduler.schedule(); + }); + } + + private _refreshAllToolContextKeys() { + this._toolContextKeys.clear(); + for (const tool of this._tools.values()) { + tool.data.when?.keys().forEach(key => this._toolContextKeys.add(key)); + } + } + + registerToolImplementation(name: string, tool: IToolImpl): IDisposable { + const entry = this._tools.get(name); + if (!entry) { + throw new Error(`Tool "${name}" was not contributed.`); + } + + if (entry.impl) { + throw new Error(`Tool "${name}" already has an implementation.`); + } + + entry.impl = tool; + return toDisposable(() => { + entry.impl = undefined; + }); + } + + getTools(): Iterable> { + const toolDatas = Iterable.map(this._tools.values(), i => i.data); + return Iterable.filter(toolDatas, toolData => !toolData.when || this._contextKeyService.contextMatchesRules(toolData.when)); + } + + getTool(id: string): IToolData | undefined { + return this._getToolEntry(id)?.data; + } + + private _getToolEntry(id: string): IToolEntry | undefined { + const entry = this._tools.get(id); + if (entry && (!entry.data.when || this._contextKeyService.contextMatchesRules(entry.data.when))) { + return entry; + } else { + return undefined; + } + } + + getToolByName(name: string): IToolData | undefined { + for (const toolData of this.getTools()) { + if (toolData.name === name) { + return toolData; + } + } + return undefined; + } + + async invokeTool(dto: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise { + // When invoking a tool, don't validate the "when" clause. An extension may have invoked a tool just as it was becoming disabled, and just let it go through rather than throw and break the chat. + let tool = this._tools.get(dto.toolId); + if (!tool) { + throw new Error(`Tool ${dto.toolId} was not contributed`); + } + + if (!tool.impl) { + await this._extensionService.activateByEvent(`onAideAgentLMTool:${dto.toolId}`); + + // Extension should activate and register the tool implementation + tool = this._tools.get(dto.toolId); + if (!tool?.impl) { + throw new Error(`Tool ${dto.toolId} does not have an implementation registered.`); + } + } + + return tool.impl.invoke(dto, countTokens, token); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/common/languageModels.ts b/src/vs/workbench/contrib/aideAgent/common/languageModels.ts new file mode 100644 index 00000000000..ee92074bf8c --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/languageModels.ts @@ -0,0 +1,318 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Iterable } from '../../../../base/common/iterator.js'; +import { IJSONSchema } from '../../../../base/common/jsonSchema.js'; +import { DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { isFalsyOrWhitespace } from '../../../../base/common/strings.js'; +import { localize } from '../../../../nls.js'; +import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IExtensionService, isProposedApiEnabled } from '../../../services/extensions/common/extensions.js'; +import { ExtensionsRegistry } from '../../../services/extensions/common/extensionsRegistry.js'; +import { CONTEXT_LANGUAGE_MODELS_ARE_USER_SELECTABLE } from './aideAgentContextKeys.js'; + +export const enum ChatMessageRole { + System, + User, + Assistant, +} + +export interface IChatMessageTextPart { + type: 'text'; + value: string; +} + +export interface IChatMessageToolResultPart { + type: 'tool_result'; + toolCallId: string; + value: any; + isError?: boolean; +} + +export type IChatMessagePart = IChatMessageTextPart | IChatMessageToolResultPart | IChatResponseToolUsePart; + +export interface IChatMessage { + readonly name?: string | undefined; + readonly role: ChatMessageRole; + readonly content: IChatMessagePart[]; +} + +export interface IChatResponseTextPart { + type: 'text'; + value: string; +} + +export interface IChatResponseToolUsePart { + type: 'tool_use'; + name: string; + toolCallId: string; + parameters: any; +} + +export type IChatResponsePart = IChatResponseTextPart | IChatResponseToolUsePart; + +export interface IChatResponseFragment { + index: number; + part: IChatResponsePart; +} + +export interface ILanguageModelChatMetadata { + readonly extension: ExtensionIdentifier; + + readonly name: string; + readonly id: string; + readonly vendor: string; + readonly version: string; + readonly family: string; + readonly maxInputTokens: number; + readonly maxOutputTokens: number; + readonly targetExtensions?: string[]; + + readonly isDefault?: boolean; + readonly isUserSelectable?: boolean; + readonly auth?: { + readonly providerLabel: string; + readonly accountLabel?: string; + }; +} + +export interface ILanguageModelChatResponse { + stream: AsyncIterable; + result: Promise; +} + +export interface ILanguageModelChat { + metadata: ILanguageModelChatMetadata; + sendChatRequest(messages: IChatMessage[], from: ExtensionIdentifier, options: { [name: string]: any }, token: CancellationToken): Promise; + provideTokenCount(message: string | IChatMessage, token: CancellationToken): Promise; +} + +export interface ILanguageModelChatSelector { + readonly name?: string; + readonly identifier?: string; + readonly vendor?: string; + readonly version?: string; + readonly family?: string; + readonly tokens?: number; + readonly extension?: ExtensionIdentifier; +} + +export const IAideAgentLMService = createDecorator('IAideAgentLMService'); + +export interface ILanguageModelsChangeEvent { + added?: { + identifier: string; + metadata: ILanguageModelChatMetadata; + }[]; + removed?: string[]; +} + +export interface IAideAgentLMService { + + readonly _serviceBrand: undefined; + + onDidChangeLanguageModels: Event; + + getLanguageModelIds(): string[]; + + lookupLanguageModel(identifier: string): ILanguageModelChatMetadata | undefined; + + selectLanguageModels(selector: ILanguageModelChatSelector): Promise; + + registerLanguageModelChat(identifier: string, provider: ILanguageModelChat): IDisposable; + + sendChatRequest(identifier: string, from: ExtensionIdentifier, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise; + + computeTokenLength(identifier: string, message: string | IChatMessage, token: CancellationToken): Promise; +} + +const languageModelType: IJSONSchema = { + type: 'object', + properties: { + vendor: { + type: 'string', + description: localize('vscode.extension.contributes.languageModels.vendor', "A globally unique vendor of language models.") + } + } +}; + +interface IUserFriendlyLanguageModel { + vendor: string; +} + +export const languageModelExtensionPoint = ExtensionsRegistry.registerExtensionPoint({ + extensionPoint: 'aideAgentLMs', + jsonSchema: { + description: localize('vscode.extension.contributes.languageModels', "Contribute language models of a specific vendor."), + oneOf: [ + languageModelType, + { + type: 'array', + items: languageModelType + } + ] + }, + activationEventsGenerator: (contribs: IUserFriendlyLanguageModel[], result: { push(item: string): void }) => { + for (const contrib of contribs) { + result.push(`onAideAgentLMChat:${contrib.vendor}`); + } + } +}); + +export class LanguageModelsService implements IAideAgentLMService { + + readonly _serviceBrand: undefined; + + private readonly _store = new DisposableStore(); + + private readonly _providers = new Map(); + private readonly _vendors = new Set(); + + private readonly _onDidChangeProviders = this._store.add(new Emitter()); + readonly onDidChangeLanguageModels: Event = this._onDidChangeProviders.event; + + private readonly _hasUserSelectableModels: IContextKey; + + constructor( + @IExtensionService private readonly _extensionService: IExtensionService, + @ILogService private readonly _logService: ILogService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + ) { + this._hasUserSelectableModels = CONTEXT_LANGUAGE_MODELS_ARE_USER_SELECTABLE.bindTo(this._contextKeyService); + + this._store.add(languageModelExtensionPoint.setHandler((extensions) => { + + this._vendors.clear(); + + for (const extension of extensions) { + + if (!isProposedApiEnabled(extension.description, 'chatProvider')) { + extension.collector.error(localize('vscode.extension.contributes.languageModels.chatProviderRequired', "This contribution point requires the 'chatProvider' proposal.")); + continue; + } + + for (const item of Iterable.wrap(extension.value)) { + if (this._vendors.has(item.vendor)) { + extension.collector.error(localize('vscode.extension.contributes.languageModels.vendorAlreadyRegistered', "The vendor '{0}' is already registered and cannot be registered twice", item.vendor)); + continue; + } + if (isFalsyOrWhitespace(item.vendor)) { + extension.collector.error(localize('vscode.extension.contributes.languageModels.emptyVendor', "The vendor field cannot be empty.")); + continue; + } + if (item.vendor.trim() !== item.vendor) { + extension.collector.error(localize('vscode.extension.contributes.languageModels.whitespaceVendor', "The vendor field cannot start or end with whitespace.")); + continue; + } + this._vendors.add(item.vendor); + } + } + + const removed: string[] = []; + for (const [identifier, value] of this._providers) { + if (!this._vendors.has(value.metadata.vendor)) { + this._providers.delete(identifier); + removed.push(identifier); + } + } + if (removed.length > 0) { + this._onDidChangeProviders.fire({ removed }); + } + })); + } + + dispose() { + this._store.dispose(); + this._providers.clear(); + } + + getLanguageModelIds(): string[] { + return Array.from(this._providers.keys()); + } + + lookupLanguageModel(identifier: string): ILanguageModelChatMetadata | undefined { + return this._providers.get(identifier)?.metadata; + } + + async selectLanguageModels(selector: ILanguageModelChatSelector): Promise { + + if (selector.vendor) { + // selective activation + await this._extensionService.activateByEvent(`onAideAgentLMChat:${selector.vendor}}`); + } else { + // activate all extensions that do language models + const all = Array.from(this._vendors).map(vendor => this._extensionService.activateByEvent(`onAideAgentLMChat:${vendor}`)); + await Promise.all(all); + } + + const result: string[] = []; + + for (const [identifier, model] of this._providers) { + + if ((selector.vendor === undefined || model.metadata.vendor === selector.vendor) + && (selector.family === undefined || model.metadata.family === selector.family) + && (selector.version === undefined || model.metadata.version === selector.version) + && (selector.identifier === undefined || model.metadata.id === selector.identifier) + && (!model.metadata.targetExtensions || model.metadata.targetExtensions.some(candidate => ExtensionIdentifier.equals(candidate, selector.extension))) + ) { + result.push(identifier); + } + } + + this._logService.trace('[LM] selected language models', selector, result); + + return result; + } + + registerLanguageModelChat(identifier: string, provider: ILanguageModelChat): IDisposable { + + this._logService.trace('[LM] registering language model chat', identifier, provider.metadata); + + if (!this._vendors.has(provider.metadata.vendor)) { + throw new Error(`Chat response provider uses UNKNOWN vendor ${provider.metadata.vendor}.`); + } + if (this._providers.has(identifier)) { + throw new Error(`Chat response provider with identifier ${identifier} is already registered.`); + } + this._providers.set(identifier, provider); + this._onDidChangeProviders.fire({ added: [{ identifier, metadata: provider.metadata }] }); + this.updateUserSelectableModelsContext(); + return toDisposable(() => { + this.updateUserSelectableModelsContext(); + if (this._providers.delete(identifier)) { + this._onDidChangeProviders.fire({ removed: [identifier] }); + this._logService.trace('[LM] UNregistered language model chat', identifier, provider.metadata); + } + }); + } + + private updateUserSelectableModelsContext() { + // This context key to enable the picker is set when there is a default model, and there is at least one other model that is user selectable + const hasUserSelectableModels = Array.from(this._providers.values()).some(p => p.metadata.isUserSelectable && !p.metadata.isDefault); + const hasDefaultModel = Array.from(this._providers.values()).some(p => p.metadata.isDefault); + this._hasUserSelectableModels.set(hasUserSelectableModels && hasDefaultModel); + } + + async sendChatRequest(identifier: string, from: ExtensionIdentifier, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise { + const provider = this._providers.get(identifier); + if (!provider) { + throw new Error(`Chat response provider with identifier ${identifier} is not registered.`); + } + return provider.sendChatRequest(messages, from, options, token); + } + + computeTokenLength(identifier: string, message: string | IChatMessage, token: CancellationToken): Promise { + const provider = this._providers.get(identifier); + if (!provider) { + throw new Error(`Chat response provider with identifier ${identifier} is not registered.`); + } + return provider.provideTokenCount(message, token); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/common/tools/languageModelToolsContribution.ts b/src/vs/workbench/contrib/aideAgent/common/tools/languageModelToolsContribution.ts new file mode 100644 index 00000000000..dc0f1efdfd1 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/tools/languageModelToolsContribution.ts @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { IJSONSchema } from '../../../../../base/common/jsonSchema.js'; +import { DisposableMap } from '../../../../../base/common/lifecycle.js'; +import { joinPath } from '../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { localize } from '../../../../../nls.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { IAideAgentLMToolsService, IToolData } from '../languageModelToolsService.js'; +import * as extensionsRegistry from '../../../../services/extensions/common/extensionsRegistry.js'; + +interface IRawToolContribution { + id: string; + name?: string; + icon?: string | { light: string; dark: string }; + when?: string; + displayName?: string; + userDescription?: string; + modelDescription: string; + parametersSchema?: IJSONSchema; + canBeInvokedManually?: boolean; +} + +const languageModelToolsExtensionPoint = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint({ + extensionPoint: 'aideAgentLMTools', + activationEventsGenerator: (contributions: IRawToolContribution[], result) => { + for (const contrib of contributions) { + result.push(`onAideAgentLMTool:${contrib.id}`); + } + }, + jsonSchema: { + description: localize('vscode.extension.contributes.tools', 'Contributes a tool that can be invoked by a language model.'), + type: 'array', + items: { + additionalProperties: false, + type: 'object', + defaultSnippets: [{ body: { name: '', description: '' } }], + required: ['id', 'modelDescription'], + properties: { + id: { + description: localize('toolId', "A unique id for this tool."), + type: 'string', + // Borrow OpenAI's requirement for tool names + pattern: '^[\\w-]+$' + }, + name: { + description: localize('toolName', "If {0} is enabled for this tool, the user may use '@' with this name to invoke the tool in a query. Otherwise, the name is not required. Name must not contain whitespace.", '`canBeInvokedManually`'), + type: 'string', + pattern: '^[\\w-]+$' + }, + displayName: { + description: localize('toolDisplayName', "A human-readable name for this tool that may be used to describe it in the UI."), + type: 'string' + }, + userDescription: { + description: localize('toolUserDescription', "A description of this tool that may be shown to the user."), + type: 'string' + }, + modelDescription: { + description: localize('toolModelDescription', "A description of this tool that may be passed to a language model."), + type: 'string' + }, + parametersSchema: { + description: localize('parametersSchema', "A JSON schema for the parameters this tool accepts."), + type: 'object', + $ref: 'http://json-schema.org/draft-07/schema#' + }, + canBeInvokedManually: { + description: localize('canBeInvokedManually', "Whether this tool can be invoked manually by the user through the chat UX."), + type: 'boolean' + }, + icon: { + description: localize('icon', "An icon that represents this tool. Either a file path, an object with file paths for dark and light themes, or a theme icon reference, like `\\$(zap)`"), + anyOf: [{ + type: 'string' + }, + { + type: 'object', + properties: { + light: { + description: localize('icon.light', 'Icon path when a light theme is used'), + type: 'string' + }, + dark: { + description: localize('icon.dark', 'Icon path when a dark theme is used'), + type: 'string' + } + } + }] + }, + when: { + markdownDescription: localize('condition', "Condition which must be true for this tool to be enabled. Note that a tool may still be invoked by another extension even when its `when` condition is false."), + type: 'string' + } + } + } + } +}); + +function toToolKey(extensionIdentifier: ExtensionIdentifier, toolName: string) { + return `${extensionIdentifier.value}/${toolName}`; +} + +export class LanguageModelToolsExtensionPointHandler implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.aideAgentLMToolsExtensionPointHandler'; + + private _registrationDisposables = new DisposableMap(); + + constructor( + @IAideAgentLMToolsService languageModelToolsService: IAideAgentLMToolsService, + @ILogService logService: ILogService, + ) { + languageModelToolsExtensionPoint.setHandler((extensions, delta) => { + for (const extension of delta.added) { + for (const rawTool of extension.value) { + if (!rawTool.id || !rawTool.modelDescription) { + logService.error(`Extension '${extension.description.identifier.value}' CANNOT register tool without name and modelDescription: ${JSON.stringify(rawTool)}`); + continue; + } + + if (!rawTool.id.match(/^[\w-]+$/)) { + logService.error(`Extension '${extension.description.identifier.value}' CANNOT register tool with invalid id: ${rawTool.id}. The id must match /^[\\w-]+$/.`); + continue; + } + + if (rawTool.canBeInvokedManually && !rawTool.name) { + logService.error(`Extension '${extension.description.identifier.value}' CANNOT register tool with 'canBeInvokedManually' set without a name: ${JSON.stringify(rawTool)}`); + continue; + } + + const rawIcon = rawTool.icon; + let icon: IToolData['icon'] | undefined; + if (typeof rawIcon === 'string') { + icon = ThemeIcon.fromString(rawIcon) ?? { + dark: joinPath(extension.description.extensionLocation, rawIcon), + light: joinPath(extension.description.extensionLocation, rawIcon) + }; + } else if (rawIcon) { + icon = { + dark: joinPath(extension.description.extensionLocation, rawIcon.dark), + light: joinPath(extension.description.extensionLocation, rawIcon.light) + }; + } + + const tool: IToolData = { + ...rawTool, + icon, + when: rawTool.when ? ContextKeyExpr.deserialize(rawTool.when) : undefined, + }; + const disposable = languageModelToolsService.registerToolData(tool); + this._registrationDisposables.set(toToolKey(extension.description.identifier, rawTool.id), disposable); + } + } + + for (const extension of delta.removed) { + for (const tool of extension.value) { + this._registrationDisposables.deleteAndDispose(toToolKey(extension.description.identifier, tool.id)); + } + } + }); + } +} diff --git a/src/vs/workbench/contrib/astNavigation/browser/astNavigationServiceImpl.ts b/src/vs/workbench/contrib/astNavigation/browser/astNavigationServiceImpl.ts index 8b8c9fd1882..cffbcf9be98 100644 --- a/src/vs/workbench/contrib/astNavigation/browser/astNavigationServiceImpl.ts +++ b/src/vs/workbench/contrib/astNavigation/browser/astNavigationServiceImpl.ts @@ -21,6 +21,7 @@ import { CONTEXT_AST_NAVIGATION_MODE, CONTEXT_CAN_AST_NAVIGATE } from '../../../ import { IASTNavigationService } from '../../../../workbench/contrib/astNavigation/common/astNavigationService.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { IOutline, IOutlineService, OutlineTarget } from '../../../../workbench/services/outline/browser/outline.js'; +import './media/astNavigation.css'; class ASTNode { constructor( @@ -86,7 +87,9 @@ export class ASTNavigationService extends Disposable implements IASTNavigationSe return; } this.activeEditorDisposables.add(editor.onDidChangeCursorPosition(e => { - this.handleCursorPosition(e.position); + if (e.source !== 'previewNode') { + this.handleCursorPosition(e.position); + } })); const model = editor.getModel(); @@ -125,33 +128,44 @@ export class ASTNavigationService extends Disposable implements IASTNavigationSe } private constructTree(ranges: IRange[]): ASTNode { - ranges.sort(Range.compareRangesUsingStarts); - ranges = ranges.filter((range, index) => index === 0 || !Range.equalsRange(range, ranges[index - 1])); - const mergedRanges: IRange[] = []; - let currentRange: IRange | undefined = undefined; - - for (const range of ranges) { - if (!currentRange) { - currentRange = range; - } else if (currentRange.startLineNumber === range.startLineNumber) { - if (range.endLineNumber > currentRange.endLineNumber) { - currentRange = { - startLineNumber: currentRange.startLineNumber, - startColumn: currentRange.startColumn, - endLineNumber: range.endLineNumber, - endColumn: range.endColumn - }; - } - } else { - mergedRanges.push(currentRange); - currentRange = range; + // Sort ranges as before + ranges.sort((a, b) => { + const aStartLineNumber = Number(a.startLineNumber); + const bStartLineNumber = Number(b.startLineNumber); + const aStartColumn = Number(a.startColumn); + const bStartColumn = Number(b.startColumn); + const aEndLineNumber = Number(a.endLineNumber); + const bEndLineNumber = Number(b.endLineNumber); + const aEndColumn = Number(a.endColumn); + const bEndColumn = Number(b.endColumn); + + let diff = aStartLineNumber - bStartLineNumber; + if (diff !== 0) { + return diff; } - } - if (currentRange) { - mergedRanges.push(currentRange); - } + diff = aStartColumn - bStartColumn; + if (diff !== 0) { + return diff; + } + // For the same start position, sort by decreasing end position + diff = bEndLineNumber - aEndLineNumber; + if (diff !== 0) { + return diff; + } + + diff = bEndColumn - aEndColumn; + return diff; + }); + + // Remove exact duplicates + ranges = ranges.filter((range, index) => index === 0 || !Range.equalsRange(range, ranges[index - 1])); + + // **Skip merging ranges to preserve all ranges** + const mergedRanges = ranges; + + // Create root node covering all ranges const root = new ASTNode({ startLineNumber: mergedRanges[0].startLineNumber, startColumn: 0, @@ -160,40 +174,21 @@ export class ASTNavigationService extends Disposable implements IASTNavigationSe }); const stack: ASTNode[] = [root]; - const nodeMap = new Map(); for (const range of mergedRanges) { - const rangeKey = `${range.startLineNumber}-${range.startColumn}-${range.endLineNumber}-${range.endColumn}`; - let currentNode = nodeMap.get(rangeKey); - - if (!currentNode) { - currentNode = new ASTNode(range); - nodeMap.set(rangeKey, currentNode); - } - - let parentNode: ASTNode | null = null; + const currentNode = new ASTNode(range); while (stack.length > 0) { - const topNode = stack[stack.length - 1]; - - if (Range.containsRange(topNode.range, range)) { - const existingChild = topNode.children.find(child => child.range.startLineNumber === range.startLineNumber && child.range.endLineNumber === range.endLineNumber); - if (existingChild) { - currentNode = existingChild; - break; - } - parentNode = topNode; - break; - } else if (range.endLineNumber < topNode.range.startLineNumber) { + const parent = stack[stack.length - 1]; + if (Range.containsRange(parent.range, range) && !Range.equalsRange(parent.range, range)) { + parent.addChild(currentNode); break; } else { stack.pop(); } } - if (parentNode) { - parentNode.addChild(currentNode); - } else { + if (stack.length === 0) { root.addChild(currentNode); } @@ -279,14 +274,14 @@ export class ASTNavigationService extends Disposable implements IASTNavigationSe range: this.currentNode.range, options: { description: 'document-symbols-outline-range-highlight', - className: 'selected-text', + className: 'selected-ast', isWholeLine: true } }]); this.previewDisposable = toDisposable(() => decorationsCollection.clear()); if (isCodeEditor(editor)) { - editor.setSelection(this.currentNode.range); + editor.setSelection(this.currentNode.range, 'previewNode'); } } diff --git a/src/vs/workbench/contrib/astNavigation/browser/media/astNavigation.css b/src/vs/workbench/contrib/astNavigation/browser/media/astNavigation.css new file mode 100644 index 00000000000..0fd6f7d2ab7 --- /dev/null +++ b/src/vs/workbench/contrib/astNavigation/browser/media/astNavigation.css @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-editor .selected-ast { + background-color: var(--vscode-editor-inactiveSelectionBackground); +} diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 1cb2bf0ef01..a46f6746058 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -171,6 +171,7 @@ export class CancelAction extends Action2 { group: 'navigation', }, keybinding: { + when: CONTEXT_IN_CHAT_INPUT, weight: KeybindingWeight.WorkbenchContrib, primary: KeyMod.CtrlCmd | KeyCode.Escape, win: { primary: KeyMod.Alt | KeyCode.Backspace }, diff --git a/src/vs/workbench/contrib/chat/browser/chatParticipantContributions.ts b/src/vs/workbench/contrib/chat/browser/chatParticipantContributions.ts index 7ad325bb30b..7c955ea94aa 100644 --- a/src/vs/workbench/contrib/chat/browser/chatParticipantContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatParticipantContributions.ts @@ -5,10 +5,10 @@ import { coalesce, isNonEmptyArray } from '../../../../base/common/arrays.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; import * as strings from '../../../../base/common/strings.js'; import { localize, localize2 } from '../../../../nls.js'; -import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -17,16 +17,16 @@ import { IProductService } from '../../../../platform/product/common/productServ import { Registry } from '../../../../platform/registry/common/platform.js'; import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; -import { IViewContainersRegistry, IViewDescriptor, IViewsRegistry, ViewContainer, ViewContainerLocation, Extensions as ViewExtensions } from '../../../common/views.js'; +import { IViewContainersRegistry, IViewsRegistry, ViewContainer, ViewContainerLocation, Extensions as ViewExtensions } from '../../../common/views.js'; import { isProposedApiEnabled } from '../../../services/extensions/common/extensions.js'; import * as extensionsRegistry from '../../../services/extensions/common/extensionsRegistry.js'; import { showExtensionsWithIdsCommandId } from '../../extensions/browser/extensionsActions.js'; import { IExtensionsWorkbenchService } from '../../extensions/common/extensions.js'; import { ChatAgentLocation, IChatAgentData, IChatAgentService } from '../common/chatAgents.js'; -import { CONTEXT_CHAT_EXTENSION_INVALID, CONTEXT_CHAT_PANEL_PARTICIPANT_REGISTERED } from '../common/chatContextKeys.js'; +import { CONTEXT_CHAT_EXTENSION_INVALID } from '../common/chatContextKeys.js'; import { IRawChatParticipantContribution } from '../common/chatParticipantContribTypes.js'; import { CHAT_VIEW_ID } from './chat.js'; -import { CHAT_SIDEBAR_PANEL_ID, ChatViewPane } from './chatViewPane.js'; +import { CHAT_SIDEBAR_PANEL_ID } from './chatViewPane.js'; const chatParticipantExtensionPoint = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint({ extensionPoint: 'chatParticipants', @@ -166,15 +166,16 @@ export class ChatExtensionPointHandler implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.chatExtensionPointHandler'; - private _viewContainer: ViewContainer; + // private _viewContainer: ViewContainer; private _participantRegistrationDisposables = new DisposableMap(); constructor( @IChatAgentService private readonly _chatAgentService: IChatAgentService, @ILogService private readonly logService: ILogService ) { - this._viewContainer = this.registerViewContainer(); - this.registerDefaultParticipantView(); + this.registerViewContainer(); + // this._viewContainer = this.registerViewContainer(); + // this.registerDefaultParticipantView(); this.handleAndRegisterChatExtensions(); } @@ -296,6 +297,7 @@ export class ChatExtensionPointHandler implements IWorkbenchContribution { return viewContainer; } + /* private registerDefaultParticipantView(): IDisposable { // Register View. Name must be hardcoded because we want to show it even when the extension fails to load due to an API version incompatibility. const name = 'GitHub Copilot'; @@ -316,6 +318,7 @@ export class ChatExtensionPointHandler implements IWorkbenchContribution { Registry.as(ViewExtensions.ViewsRegistry).deregisterViews(viewDescriptor, this._viewContainer); }); } + */ } function getParticipantKey(extensionId: ExtensionIdentifier, participantName: string): string { diff --git a/src/vs/workbench/contrib/pinnedContext/browser/pinnedContext.contribution.ts b/src/vs/workbench/contrib/pinnedContext/browser/pinnedContext.contribution.ts index 92c3633a5a7..10b6546f6fc 100644 --- a/src/vs/workbench/contrib/pinnedContext/browser/pinnedContext.contribution.ts +++ b/src/vs/workbench/contrib/pinnedContext/browser/pinnedContext.contribution.ts @@ -23,8 +23,7 @@ export class PinnedContextPaneDescriptor implements IViewDescriptor { readonly name: ILocalizedString = PinnedContextPane.TITLE; readonly containerIcon = pinnedContextIcon; readonly ctorDescriptor = new SyncDescriptor(PinnedContextPane); - readonly order = 1; - readonly weight = 100; + readonly order = 2; readonly collapsed = false; readonly canToggleVisibility = false; readonly hideByDefault = false; diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index adf3c4c1211..f3806c5202c 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -199,6 +199,7 @@ import './contrib/chat/browser/chat.contribution.js'; import './contrib/inlineChat/browser/inlineChat.contribution.js'; // Aide +import './contrib/aideAgent/browser/aideAgentParticipantContributions.js'; // This is done so the container will be registered before views are restored import './contrib/aideAgent/browser/aideAgent.contribution.js'; // Pinned context diff --git a/src/vscode-dts/vscode.proposed.aideAgent.d.ts b/src/vscode-dts/vscode.proposed.aideAgent.d.ts index c9a273384b9..ed2d88a5e8b 100644 --- a/src/vscode-dts/vscode.proposed.aideAgent.d.ts +++ b/src/vscode-dts/vscode.proposed.aideAgent.d.ts @@ -4,34 +4,38 @@ *--------------------------------------------------------------------------------------------*/ declare module 'vscode' { - export type AideAgentScope = 'Selection' | 'PinnedContext' | 'WholeCodebase'; - - export interface AgentTrigger { - readonly id: string; - readonly message: string; - readonly scope: AideAgentScope; + export enum AideAgentMode { + Edit = 1, + Chat = 2 } - export interface AideAgentTextEdit { - // TODO(@ghostwriternr): Get rid of the iterationId - readonly iterationId: string; - readonly edits: WorkspaceEdit; + export interface AideAgentRequest extends ChatRequest { + id: string; + mode: AideAgentMode; } - export interface AgentResponseStream { - markdown(value: string | MarkdownString): void; - codeEdit(value: AideAgentTextEdit): void; + export interface AideAgentResponseStream extends ChatResponseStream { + close(): void; } - export interface AgentTriggerComplete { - readonly errorDetails?: string; + export type AideSessionHandler = (id: string) => void; + export type AideSessionEventHandler = (event: AideAgentRequest, token: CancellationToken) => ProviderResult; + export type AideSessionEventSender = (sessionId: string) => Thenable; + + export interface AideSessionParticipant { + newSession: AideSessionHandler; + handleEvent: AideSessionEventHandler; } - export interface AideAgentProvider { - provideTriggerResponse(request: AgentTrigger, response: AgentResponseStream, token: CancellationToken): ProviderResult; + interface AideSessionAgent extends Omit { + requestHandler: AideSessionEventHandler; + readonly initResponse: AideSessionEventSender; } export namespace aideAgent { - export function registerAideAgentProvider(id: string, provider: AideAgentProvider): Disposable; + export function createChatParticipant(id: string, resolver: AideSessionParticipant): AideSessionAgent; + export function registerChatParticipantDetectionProvider(participantDetectionProvider: ChatParticipantDetectionProvider): Disposable; + export function registerChatVariableResolver(id: string, name: string, userDescription: string, modelDescription: string | undefined, isSlow: boolean | undefined, resolver: ChatVariableResolver, fullName?: string, icon?: ThemeIcon): Disposable; + export function registerMappedEditsProvider2(provider: MappedEditsProvider2): Disposable; } }