Path: blob/main/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { DeferredPromise, isThenable } from '../../../../../base/common/async.js';6import { CancellationTokenSource } from '../../../../../base/common/cancellation.js';7import { Codicon } from '../../../../../base/common/codicons.js';8import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js';9import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js';10import { Schemas } from '../../../../../base/common/network.js';11import { autorun, observableValue } from '../../../../../base/common/observable.js';12import { ThemeIcon } from '../../../../../base/common/themables.js';13import { isObject } from '../../../../../base/common/types.js';14import { URI } from '../../../../../base/common/uri.js';15import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js';16import { Range } from '../../../../../editor/common/core/range.js';17import { EditorContextKeys } from '../../../../../editor/common/editorContextKeys.js';18import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';19import { AbstractGotoSymbolQuickAccessProvider, IGotoSymbolQuickPickItem } from '../../../../../editor/contrib/quickAccess/browser/gotoSymbolQuickAccess.js';20import { localize, localize2 } from '../../../../../nls.js';21import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js';22import { ICommandService } from '../../../../../platform/commands/common/commands.js';23import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';24import { IFileService } from '../../../../../platform/files/common/files.js';25import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';26import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js';27import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js';28import { IListService } from '../../../../../platform/list/browser/listService.js';29import { ILogService } from '../../../../../platform/log/common/log.js';30import { AnythingQuickAccessProviderRunOptions } from '../../../../../platform/quickinput/common/quickAccess.js';31import { IQuickInputService, IQuickPickItem, IQuickPickItemWithResource, QuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js';32import { resolveCommandsContext } from '../../../../browser/parts/editor/editorCommandsContext.js';33import { ResourceContextKey } from '../../../../common/contextkeys.js';34import { EditorResourceAccessor, isEditorCommandsContext, SideBySideEditor } from '../../../../common/editor.js';35import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js';36import { IEditorService } from '../../../../services/editor/common/editorService.js';37import { IViewsService } from '../../../../services/views/common/viewsService.js';38import { ExplorerFolderContext } from '../../../files/common/files.js';39import { AnythingQuickAccessProvider } from '../../../search/browser/anythingQuickAccess.js';40import { isSearchTreeFileMatch, isSearchTreeMatch } from '../../../search/browser/searchTreeModel/searchTreeCommon.js';41import { ISymbolQuickPickItem, SymbolsQuickAccessProvider } from '../../../search/browser/symbolsQuickAccess.js';42import { SearchContext } from '../../../search/common/constants.js';43import { ChatContextKeys } from '../../common/chatContextKeys.js';44import { IChatRequestVariableEntry, OmittedState } from '../../common/chatVariableEntries.js';45import { ChatAgentLocation } from '../../common/constants.js';46import { IChatWidget, IChatWidgetService, IQuickChatService, showChatView } from '../chat.js';47import { IChatContextPickerItem, IChatContextPickService, IChatContextValueItem, isChatContextPickerPickItem } from '../chatContextPickService.js';48import { isQuickChat } from '../chatWidget.js';49import { resizeImage } from '../imageUtils.js';50import { registerPromptActions } from '../promptSyntax/promptFileActions.js';51import { CHAT_CATEGORY } from './chatActions.js';5253export function registerChatContextActions() {54registerAction2(AttachContextAction);55registerAction2(AttachFileToChatAction);56registerAction2(AttachFolderToChatAction);57registerAction2(AttachSelectionToChatAction);58registerAction2(AttachSearchResultAction);59registerPromptActions();60}6162async function withChatView(accessor: ServicesAccessor): Promise<IChatWidget | undefined> {63const viewsService = accessor.get(IViewsService);64const chatWidgetService = accessor.get(IChatWidgetService);6566const lastFocusedWidget = chatWidgetService.lastFocusedWidget;67if (!lastFocusedWidget || lastFocusedWidget.location === ChatAgentLocation.Panel) {68return showChatView(viewsService); // only show chat view if we either have no chat view or its located in view container69}70return lastFocusedWidget;71}7273abstract class AttachResourceAction extends Action2 {7475override async run(accessor: ServicesAccessor, ...args: any[]): Promise<void> {76const instaService = accessor.get(IInstantiationService);77const widget = await instaService.invokeFunction(withChatView);78if (!widget) {79return;80}81return instaService.invokeFunction(this.runWithWidget.bind(this), widget, ...args);82}8384abstract runWithWidget(accessor: ServicesAccessor, widget: IChatWidget, ...args: any[]): Promise<void>;8586protected _getResources(accessor: ServicesAccessor, ...args: any[]): URI[] {87const editorService = accessor.get(IEditorService);8889const contexts = isEditorCommandsContext(args[1]) ? this._getEditorResources(accessor, args) : Array.isArray(args[1]) ? args[1] : [args[0]];90const files = [];91for (const context of contexts) {92let uri;93if (URI.isUri(context)) {94uri = context;95} else if (isSearchTreeFileMatch(context)) {96uri = context.resource;97} else if (isSearchTreeMatch(context)) {98uri = context.parent().resource;99} else if (!context && editorService.activeTextEditorControl) {100uri = EditorResourceAccessor.getCanonicalUri(editorService.activeEditor, { supportSideBySide: SideBySideEditor.PRIMARY });101}102103if (uri && [Schemas.file, Schemas.vscodeRemote, Schemas.untitled].includes(uri.scheme)) {104files.push(uri);105}106}107108return files;109}110111private _getEditorResources(accessor: ServicesAccessor, ...args: any[]): URI[] {112const resolvedContext = resolveCommandsContext(args, accessor.get(IEditorService), accessor.get(IEditorGroupsService), accessor.get(IListService));113114return resolvedContext.groupedEditors115.flatMap(groupedEditor => groupedEditor.editors)116.map(editor => EditorResourceAccessor.getCanonicalUri(editor, { supportSideBySide: SideBySideEditor.PRIMARY }))117.filter(uri => uri !== undefined);118}119}120121class AttachFileToChatAction extends AttachResourceAction {122123static readonly ID = 'workbench.action.chat.attachFile';124125constructor() {126super({127id: AttachFileToChatAction.ID,128title: localize2('workbench.action.chat.attachFile.label', "Add File to Chat"),129category: CHAT_CATEGORY,130precondition: ChatContextKeys.enabled,131f1: true,132menu: [{133id: MenuId.SearchContext,134group: 'z_chat',135order: 1,136when: ContextKeyExpr.and(ChatContextKeys.enabled, SearchContext.FileMatchOrMatchFocusKey, SearchContext.SearchResultHeaderFocused.negate()),137}, {138id: MenuId.ExplorerContext,139group: '5_chat',140order: 1,141when: ContextKeyExpr.and(142ChatContextKeys.enabled,143ExplorerFolderContext.negate(),144ContextKeyExpr.or(145ResourceContextKey.Scheme.isEqualTo(Schemas.file),146ResourceContextKey.Scheme.isEqualTo(Schemas.vscodeRemote)147)148),149}, {150id: MenuId.EditorTitleContext,151group: '2_chat',152order: 1,153when: ContextKeyExpr.and(154ChatContextKeys.enabled,155ContextKeyExpr.or(156ResourceContextKey.Scheme.isEqualTo(Schemas.file),157ResourceContextKey.Scheme.isEqualTo(Schemas.vscodeRemote)158)159),160}, {161id: MenuId.EditorContext,162group: '1_chat',163order: 2,164when: ContextKeyExpr.and(165ChatContextKeys.enabled,166ContextKeyExpr.or(167ResourceContextKey.Scheme.isEqualTo(Schemas.file),168ResourceContextKey.Scheme.isEqualTo(Schemas.vscodeRemote),169ResourceContextKey.Scheme.isEqualTo(Schemas.untitled),170ResourceContextKey.Scheme.isEqualTo(Schemas.vscodeUserData)171)172)173}]174});175}176177override async runWithWidget(accessor: ServicesAccessor, widget: IChatWidget, ...args: any[]): Promise<void> {178const files = this._getResources(accessor, ...args);179if (!files.length) {180return;181}182if (widget) {183widget.focusInput();184for (const file of files) {185widget.attachmentModel.addFile(file);186}187}188}189}190191class AttachFolderToChatAction extends AttachResourceAction {192193static readonly ID = 'workbench.action.chat.attachFolder';194195constructor() {196super({197id: AttachFolderToChatAction.ID,198title: localize2('workbench.action.chat.attachFolder.label', "Add Folder to Chat"),199category: CHAT_CATEGORY,200f1: false,201menu: {202id: MenuId.ExplorerContext,203group: '5_chat',204order: 1,205when: ContextKeyExpr.and(206ChatContextKeys.enabled,207ExplorerFolderContext,208ContextKeyExpr.or(209ResourceContextKey.Scheme.isEqualTo(Schemas.file),210ResourceContextKey.Scheme.isEqualTo(Schemas.vscodeRemote)211)212)213}214});215}216217override async runWithWidget(accessor: ServicesAccessor, widget: IChatWidget, ...args: any[]): Promise<void> {218const folders = this._getResources(accessor, ...args);219if (!folders.length) {220return;221}222if (widget) {223widget.focusInput();224for (const folder of folders) {225widget.attachmentModel.addFolder(folder);226}227}228}229}230231class AttachSelectionToChatAction extends Action2 {232233static readonly ID = 'workbench.action.chat.attachSelection';234235constructor() {236super({237id: AttachSelectionToChatAction.ID,238title: localize2('workbench.action.chat.attachSelection.label', "Add Selection to Chat"),239category: CHAT_CATEGORY,240f1: true,241precondition: ChatContextKeys.enabled,242menu: {243id: MenuId.EditorContext,244group: '1_chat',245order: 1,246when: ContextKeyExpr.and(247ChatContextKeys.enabled,248EditorContextKeys.hasNonEmptySelection,249ContextKeyExpr.or(250ResourceContextKey.Scheme.isEqualTo(Schemas.file),251ResourceContextKey.Scheme.isEqualTo(Schemas.vscodeRemote),252ResourceContextKey.Scheme.isEqualTo(Schemas.untitled),253ResourceContextKey.Scheme.isEqualTo(Schemas.vscodeUserData)254)255)256}257});258}259260override async run(accessor: ServicesAccessor, ...args: any[]): Promise<void> {261const editorService = accessor.get(IEditorService);262263const widget = await accessor.get(IInstantiationService).invokeFunction(withChatView);264if (!widget) {265return;266}267268const [_, matches] = args;269// If we have search matches, it means this is coming from the search widget270if (matches && matches.length > 0) {271const uris = new Map<URI, Range | undefined>();272for (const match of matches) {273if (isSearchTreeFileMatch(match)) {274uris.set(match.resource, undefined);275} else {276const context = { uri: match._parent.resource, range: match._range };277const range = uris.get(context.uri);278if (!range ||279range.startLineNumber !== context.range.startLineNumber && range.endLineNumber !== context.range.endLineNumber) {280uris.set(context.uri, context.range);281widget.attachmentModel.addFile(context.uri, context.range);282}283}284}285// Add the root files for all of the ones that didn't have a match286for (const uri of uris) {287const [resource, range] = uri;288if (!range) {289widget.attachmentModel.addFile(resource);290}291}292} else {293const activeEditor = editorService.activeTextEditorControl;294const activeUri = EditorResourceAccessor.getCanonicalUri(editorService.activeEditor, { supportSideBySide: SideBySideEditor.PRIMARY });295if (activeEditor && activeUri && [Schemas.file, Schemas.vscodeRemote, Schemas.untitled].includes(activeUri.scheme)) {296const selection = activeEditor.getSelection();297if (selection) {298widget.focusInput();299const range = selection.isEmpty() ? new Range(selection.startLineNumber, 1, selection.startLineNumber + 1, 1) : selection;300widget.attachmentModel.addFile(activeUri, range);301}302}303}304}305}306307export class AttachSearchResultAction extends Action2 {308309private static readonly Name = 'searchResults';310311constructor() {312super({313id: 'workbench.action.chat.insertSearchResults',314title: localize2('chat.insertSearchResults', 'Add Search Results to Chat'),315category: CHAT_CATEGORY,316f1: false,317menu: [{318id: MenuId.SearchContext,319group: 'z_chat',320order: 3,321when: ContextKeyExpr.and(322ChatContextKeys.enabled,323SearchContext.SearchResultHeaderFocused),324}]325});326}327async run(accessor: ServicesAccessor) {328const logService = accessor.get(ILogService);329const widget = await accessor.get(IInstantiationService).invokeFunction(withChatView);330331if (!widget) {332logService.trace('InsertSearchResultAction: no chat view available');333return;334}335336const editor = widget.inputEditor;337const originalRange = editor.getSelection() ?? editor.getModel()?.getFullModelRange().collapseToEnd();338339if (!originalRange) {340logService.trace('InsertSearchResultAction: no selection');341return;342}343344let insertText = `#${AttachSearchResultAction.Name}`;345const varRange = new Range(originalRange.startLineNumber, originalRange.startColumn, originalRange.endLineNumber, originalRange.startLineNumber + insertText.length);346// check character before the start of the range. If it's not a space, add a space347const model = editor.getModel();348if (model && model.getValueInRange(new Range(originalRange.startLineNumber, originalRange.startColumn - 1, originalRange.startLineNumber, originalRange.startColumn)) !== ' ') {349insertText = ' ' + insertText;350}351const success = editor.executeEdits('chatInsertSearch', [{ range: varRange, text: insertText + ' ' }]);352if (!success) {353logService.trace(`InsertSearchResultAction: failed to insert "${insertText}"`);354return;355}356}357}358359/** This is our type */360interface IContextPickItemItem extends IQuickPickItem {361kind: 'contextPick';362item: IChatContextValueItem | IChatContextPickerItem;363}364365/** These are the types we get from "platform QP" */366type IQuickPickServicePickItem = IGotoSymbolQuickPickItem | ISymbolQuickPickItem | IQuickPickItemWithResource;367368function isIContextPickItemItem(obj: unknown): obj is IContextPickItemItem {369return (370isObject(obj)371&& typeof (<IContextPickItemItem>obj).kind === 'string'372&& (<IContextPickItemItem>obj).kind === 'contextPick'373);374}375376function isIGotoSymbolQuickPickItem(obj: unknown): obj is IGotoSymbolQuickPickItem {377return (378isObject(obj)379&& typeof (obj as IGotoSymbolQuickPickItem).symbolName === 'string'380&& !!(obj as IGotoSymbolQuickPickItem).uri381&& !!(obj as IGotoSymbolQuickPickItem).range);382}383384function isIQuickPickItemWithResource(obj: unknown): obj is IQuickPickItemWithResource {385return (386isObject(obj)387&& URI.isUri((obj as IQuickPickItemWithResource).resource));388}389390391export class AttachContextAction extends Action2 {392393constructor() {394super({395id: 'workbench.action.chat.attachContext',396title: localize2('workbench.action.chat.attachContext.label.2', "Add Context..."),397icon: Codicon.attach,398category: CHAT_CATEGORY,399keybinding: {400when: ContextKeyExpr.and(ChatContextKeys.inChatInput, ChatContextKeys.location.isEqualTo(ChatAgentLocation.Panel)),401primary: KeyMod.CtrlCmd | KeyCode.Slash,402weight: KeybindingWeight.EditorContrib403},404menu: {405when: ContextKeyExpr.and(406ChatContextKeys.location.isEqualTo(ChatAgentLocation.Panel),407ChatContextKeys.lockedToCodingAgent.negate()408),409id: MenuId.ChatInputAttachmentToolbar,410group: 'navigation',411order: 3412},413});414}415416override async run(accessor: ServicesAccessor, ...args: any[]): Promise<void> {417418const instantiationService = accessor.get(IInstantiationService);419const widgetService = accessor.get(IChatWidgetService);420const contextKeyService = accessor.get(IContextKeyService);421const keybindingService = accessor.get(IKeybindingService);422const contextPickService = accessor.get(IChatContextPickService);423424const context: { widget?: IChatWidget; placeholder?: string } | undefined = args[0];425const widget = context?.widget ?? widgetService.lastFocusedWidget;426if (!widget) {427return;428}429430const quickPickItems: IContextPickItemItem[] = [];431432for (const item of contextPickService.items) {433434if (item.isEnabled && !await item.isEnabled(widget)) {435continue;436}437438quickPickItems.push({439kind: 'contextPick',440item,441label: item.label,442iconClass: ThemeIcon.asClassName(item.icon),443keybinding: item.commandId ? keybindingService.lookupKeybinding(item.commandId, contextKeyService) : undefined,444});445}446447instantiationService.invokeFunction(this._show.bind(this), widget, quickPickItems, context?.placeholder);448}449450private _show(accessor: ServicesAccessor, widget: IChatWidget, additionPicks: IContextPickItemItem[] | undefined, placeholder?: string) {451const quickInputService = accessor.get(IQuickInputService);452const quickChatService = accessor.get(IQuickChatService);453const instantiationService = accessor.get(IInstantiationService);454const commandService = accessor.get(ICommandService);455456const providerOptions: AnythingQuickAccessProviderRunOptions = {457additionPicks,458handleAccept: async (item: IQuickPickServicePickItem | IContextPickItemItem, isBackgroundAccept: boolean) => {459460if (isIContextPickItemItem(item)) {461462let isDone = true;463if (item.item.type === 'valuePick') {464this._handleContextPick(item.item, widget);465466} else if (item.item.type === 'pickerPick') {467isDone = await this._handleContextPickerItem(quickInputService, commandService, item.item, widget);468}469470if (!isDone) {471// restart picker when sub-picker didn't return anything472instantiationService.invokeFunction(this._show.bind(this), widget, additionPicks, placeholder);473return;474}475476} else {477instantiationService.invokeFunction(this._handleQPPick.bind(this), widget, isBackgroundAccept, item);478}479if (isQuickChat(widget)) {480quickChatService.open();481}482}483};484485quickInputService.quickAccess.show('', {486enabledProviderPrefixes: [487AnythingQuickAccessProvider.PREFIX,488SymbolsQuickAccessProvider.PREFIX,489AbstractGotoSymbolQuickAccessProvider.PREFIX490],491placeholder: placeholder ?? localize('chatContext.attach.placeholder', 'Search attachments'),492providerOptions,493});494}495496private async _handleQPPick(accessor: ServicesAccessor, widget: IChatWidget, isInBackground: boolean, pick: IQuickPickServicePickItem) {497const fileService = accessor.get(IFileService);498const textModelService = accessor.get(ITextModelService);499500const toAttach: IChatRequestVariableEntry[] = [];501502if (isIQuickPickItemWithResource(pick) && pick.resource) {503if (/\.(png|jpg|jpeg|bmp|gif|tiff)$/i.test(pick.resource.path)) {504// checks if the file is an image505if (URI.isUri(pick.resource)) {506// read the image and attach a new file context.507const readFile = await fileService.readFile(pick.resource);508const resizedImage = await resizeImage(readFile.value.buffer);509toAttach.push({510id: pick.resource.toString(),511name: pick.label,512fullName: pick.label,513value: resizedImage,514kind: 'image',515references: [{ reference: pick.resource, kind: 'reference' }]516});517}518} else {519let omittedState = OmittedState.NotOmitted;520try {521const createdModel = await textModelService.createModelReference(pick.resource);522createdModel.dispose();523} catch {524omittedState = OmittedState.Full;525}526527toAttach.push({528kind: 'file',529id: pick.resource.toString(),530value: pick.resource,531name: pick.label,532omittedState533});534}535} else if (isIGotoSymbolQuickPickItem(pick) && pick.uri && pick.range) {536toAttach.push({537kind: 'generic',538id: JSON.stringify({ uri: pick.uri, range: pick.range.decoration }),539value: { uri: pick.uri, range: pick.range.decoration },540fullName: pick.label,541name: pick.symbolName!,542});543}544545546widget.attachmentModel.addContext(...toAttach);547548if (!isInBackground) {549// Set focus back into the input once the user is done attaching items550// so that the user can start typing their message551widget.focusInput();552}553}554555private async _handleContextPick(item: IChatContextValueItem, widget: IChatWidget) {556557const value = await item.asAttachment(widget);558if (Array.isArray(value)) {559widget.attachmentModel.addContext(...value);560} else if (value) {561widget.attachmentModel.addContext(value);562}563}564565private async _handleContextPickerItem(quickInputService: IQuickInputService, commandService: ICommandService, item: IChatContextPickerItem, widget: IChatWidget): Promise<boolean> {566567const pickerConfig = item.asPicker(widget);568569const store = new DisposableStore();570571const goBackItem: IQuickPickItem = {572label: localize('goBack', 'Go back ↩'),573alwaysShow: true574};575const configureItem = pickerConfig.configure ? {576label: pickerConfig.configure.label,577commandId: pickerConfig.configure.commandId,578alwaysShow: true579} : undefined;580const extraPicks: QuickPickItem[] = [{ type: 'separator' }];581if (configureItem) {582extraPicks.push(configureItem);583}584extraPicks.push(goBackItem);585586const qp = store.add(quickInputService.createQuickPick({ useSeparators: true }));587588const cts = new CancellationTokenSource();589store.add(qp.onDidHide(() => cts.cancel()));590store.add(toDisposable(() => cts.dispose(true)));591592qp.placeholder = pickerConfig.placeholder;593qp.matchOnDescription = true;594qp.matchOnDetail = true;595// qp.ignoreFocusOut = true;596qp.canAcceptInBackground = true;597qp.busy = true;598qp.show();599600if (isThenable(pickerConfig.picks)) {601const items = await (pickerConfig.picks.then(value => {602return ([] as QuickPickItem[]).concat(value, extraPicks);603}));604605qp.items = items;606qp.busy = false;607} else {608const query = observableValue<string>('attachContext.query', qp.value);609store.add(qp.onDidChangeValue(() => query.set(qp.value, undefined)));610611const picksObservable = pickerConfig.picks(query, cts.token);612store.add(autorun(reader => {613const { busy, picks } = picksObservable.read(reader);614qp.items = ([] as QuickPickItem[]).concat(picks, extraPicks);615qp.busy = busy;616}));617}618619if (cts.token.isCancellationRequested) {620return true; // picker got hidden already621}622623const defer = new DeferredPromise<boolean>();624const addPromises: Promise<void>[] = [];625626store.add(qp.onDidAccept(e => {627const [selected] = qp.selectedItems;628if (isChatContextPickerPickItem(selected)) {629const attachment = selected.asAttachment();630if (isThenable(attachment)) {631addPromises.push(attachment.then(v => widget.attachmentModel.addContext(v)));632} else {633widget.attachmentModel.addContext(attachment);634}635}636if (selected === goBackItem) {637defer.complete(false);638}639if (selected === configureItem) {640defer.complete(true);641commandService.executeCommand(configureItem.commandId);642}643if (!e.inBackground) {644defer.complete(true);645}646}));647648store.add(qp.onDidHide(() => {649defer.complete(true);650}));651652try {653const result = await defer.p;654qp.busy = true; // if still visible655await Promise.all(addPromises);656return result;657} finally {658store.dispose();659}660}661}662663664