Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatAttachmentWidgets.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as dom from '../../../../base/browser/dom.js';
7
import { $ } from '../../../../base/browser/dom.js';
8
import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
9
import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js';
10
import { Button } from '../../../../base/browser/ui/button/button.js';
11
import { IManagedHoverTooltipMarkdownString } from '../../../../base/browser/ui/hover/hover.js';
12
import { IHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate.js';
13
import { Codicon } from '../../../../base/common/codicons.js';
14
import * as event from '../../../../base/common/event.js';
15
import { Iterable } from '../../../../base/common/iterator.js';
16
import { KeyCode } from '../../../../base/common/keyCodes.js';
17
import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';
18
import { basename, dirname } from '../../../../base/common/path.js';
19
import { ThemeIcon } from '../../../../base/common/themables.js';
20
import { URI } from '../../../../base/common/uri.js';
21
import { IRange } from '../../../../editor/common/core/range.js';
22
import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js';
23
import { LanguageFeatureRegistry } from '../../../../editor/common/languageFeatureRegistry.js';
24
import { Location, SymbolKind } from '../../../../editor/common/languages.js';
25
import { ILanguageService } from '../../../../editor/common/languages/language.js';
26
import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js';
27
import { IModelService } from '../../../../editor/common/services/model.js';
28
import { ITextModelService } from '../../../../editor/common/services/resolverService.js';
29
import { localize } from '../../../../nls.js';
30
import { getFlatContextMenuActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js';
31
import { IMenuService, MenuId } from '../../../../platform/actions/common/actions.js';
32
import { ICommandService } from '../../../../platform/commands/common/commands.js';
33
import { IContextKey, IContextKeyService, IScopedContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
34
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
35
import { fillInSymbolsDragData } from '../../../../platform/dnd/browser/dnd.js';
36
import { ITextEditorOptions } from '../../../../platform/editor/common/editor.js';
37
import { FileKind, IFileService } from '../../../../platform/files/common/files.js';
38
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
39
import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
40
import { ILabelService } from '../../../../platform/label/common/label.js';
41
import { IOpenerService, OpenInternalOptions } from '../../../../platform/opener/common/opener.js';
42
import { FolderThemeIcon, IThemeService } from '../../../../platform/theme/common/themeService.js';
43
import { fillEditorsDragData } from '../../../browser/dnd.js';
44
import { IFileLabelOptions, IResourceLabel, ResourceLabels } from '../../../browser/labels.js';
45
import { ResourceContextKey } from '../../../common/contextkeys.js';
46
import { IEditorService } from '../../../services/editor/common/editorService.js';
47
import { IPreferencesService } from '../../../services/preferences/common/preferences.js';
48
import { revealInSideBarCommand } from '../../files/browser/fileActions.contribution.js';
49
import { CellUri } from '../../notebook/common/notebookCommon.js';
50
import { INotebookService } from '../../notebook/common/notebookService.js';
51
import { getHistoryItemEditorTitle, getHistoryItemHoverContent } from '../../scm/browser/util.js';
52
import { IChatContentReference } from '../common/chatService.js';
53
import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry, IElementVariableEntry, INotebookOutputVariableEntry, IPromptFileVariableEntry, IPromptTextVariableEntry, ISCMHistoryItemVariableEntry, OmittedState, PromptFileVariableKind, ChatRequestToolReferenceEntry } from '../common/chatVariableEntries.js';
54
import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../common/languageModels.js';
55
import { ILanguageModelToolsService, ToolSet } from '../common/languageModelToolsService.js';
56
import { getCleanPromptName } from '../common/promptSyntax/config/promptFileLocations.js';
57
58
abstract class AbstractChatAttachmentWidget extends Disposable {
59
public readonly element: HTMLElement;
60
public readonly label: IResourceLabel;
61
62
private readonly _onDidDelete: event.Emitter<Event> = this._register(new event.Emitter<Event>());
63
get onDidDelete(): event.Event<Event> {
64
return this._onDidDelete.event;
65
}
66
67
private readonly _onDidOpen: event.Emitter<void> = this._register(new event.Emitter<void>());
68
get onDidOpen(): event.Event<void> {
69
return this._onDidOpen.event;
70
}
71
72
constructor(
73
private readonly attachment: IChatRequestVariableEntry,
74
private readonly options: { shouldFocusClearButton: boolean; supportsDeletion: boolean },
75
container: HTMLElement,
76
contextResourceLabels: ResourceLabels,
77
protected readonly hoverDelegate: IHoverDelegate,
78
protected readonly currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined,
79
@ICommandService protected readonly commandService: ICommandService,
80
@IOpenerService protected readonly openerService: IOpenerService,
81
) {
82
super();
83
this.element = dom.append(container, $('.chat-attached-context-attachment.show-file-icons'));
84
this.label = contextResourceLabels.create(this.element, { supportIcons: true, hoverDelegate, hoverTargetOverride: this.element });
85
this._register(this.label);
86
this.element.tabIndex = 0;
87
this.element.role = 'button';
88
89
// Add middle-click support for removal
90
this._register(dom.addDisposableListener(this.element, dom.EventType.AUXCLICK, (e: MouseEvent) => {
91
if (e.button === 1 /* Middle Button */ && this.options.supportsDeletion && !this.attachment.range) {
92
e.preventDefault();
93
e.stopPropagation();
94
this._onDidDelete.fire(e);
95
}
96
}));
97
}
98
99
protected modelSupportsVision() {
100
return modelSupportsVision(this.currentLanguageModel);
101
}
102
103
protected attachClearButton() {
104
105
if (this.attachment.range || !this.options.supportsDeletion) {
106
// no clear button for attachments with ranges because range means
107
// referenced from prompt
108
return;
109
}
110
111
const clearButton = new Button(this.element, {
112
supportIcons: true,
113
hoverDelegate: this.hoverDelegate,
114
title: localize('chat.attachment.clearButton', "Remove from context")
115
});
116
clearButton.element.tabIndex = -1;
117
clearButton.icon = Codicon.close;
118
this._register(clearButton);
119
this._register(event.Event.once(clearButton.onDidClick)((e) => {
120
this._onDidDelete.fire(e);
121
}));
122
this._register(dom.addStandardDisposableListener(this.element, dom.EventType.KEY_DOWN, e => {
123
if (e.keyCode === KeyCode.Backspace || e.keyCode === KeyCode.Delete) {
124
this._onDidDelete.fire(e.browserEvent);
125
}
126
}));
127
}
128
129
protected addResourceOpenHandlers(resource: URI, range: IRange | undefined): void {
130
this.element.style.cursor = 'pointer';
131
this._register(dom.addDisposableListener(this.element, dom.EventType.CLICK, async (e: MouseEvent) => {
132
dom.EventHelper.stop(e, true);
133
if (this.attachment.kind === 'directory') {
134
await this.openResource(resource, true);
135
} else {
136
await this.openResource(resource, false, range);
137
}
138
}));
139
140
this._register(dom.addDisposableListener(this.element, dom.EventType.KEY_DOWN, async (e: KeyboardEvent) => {
141
const event = new StandardKeyboardEvent(e);
142
if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) {
143
dom.EventHelper.stop(e, true);
144
if (this.attachment.kind === 'directory') {
145
await this.openResource(resource, true);
146
} else {
147
await this.openResource(resource, false, range);
148
}
149
}
150
}));
151
}
152
153
protected async openResource(resource: URI, isDirectory: true): Promise<void>;
154
protected async openResource(resource: URI, isDirectory: false, range: IRange | undefined): Promise<void>;
155
protected async openResource(resource: URI, isDirectory?: boolean, range?: IRange): Promise<void> {
156
if (isDirectory) {
157
// Reveal Directory in explorer
158
this.commandService.executeCommand(revealInSideBarCommand.id, resource);
159
return;
160
}
161
162
// Open file in editor
163
const openTextEditorOptions: ITextEditorOptions | undefined = range ? { selection: range } : undefined;
164
const options: OpenInternalOptions = {
165
fromUserGesture: true,
166
editorOptions: { ...openTextEditorOptions, preserveFocus: true },
167
};
168
await this.openerService.open(resource, options);
169
this._onDidOpen.fire();
170
this.element.focus();
171
}
172
}
173
174
function modelSupportsVision(currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined) {
175
return currentLanguageModel?.metadata.capabilities?.vision ?? false;
176
}
177
178
export class FileAttachmentWidget extends AbstractChatAttachmentWidget {
179
180
constructor(
181
resource: URI,
182
range: IRange | undefined,
183
attachment: IChatRequestVariableEntry,
184
correspondingContentReference: IChatContentReference | undefined,
185
currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined,
186
options: { shouldFocusClearButton: boolean; supportsDeletion: boolean },
187
container: HTMLElement,
188
contextResourceLabels: ResourceLabels,
189
hoverDelegate: IHoverDelegate,
190
@ICommandService commandService: ICommandService,
191
@IOpenerService openerService: IOpenerService,
192
@IThemeService private readonly themeService: IThemeService,
193
@IHoverService private readonly hoverService: IHoverService,
194
@ILanguageModelsService private readonly languageModelsService: ILanguageModelsService,
195
@IInstantiationService private readonly instantiationService: IInstantiationService,
196
) {
197
super(attachment, options, container, contextResourceLabels, hoverDelegate, currentLanguageModel, commandService, openerService);
198
199
const fileBasename = basename(resource.path);
200
const fileDirname = dirname(resource.path);
201
const friendlyName = `${fileBasename} ${fileDirname}`;
202
let 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);
203
204
if (attachment.omittedState === OmittedState.Full) {
205
ariaLabel = localize('chat.omittedFileAttachment', "Omitted this file: {0}", attachment.name);
206
this.renderOmittedWarning(friendlyName, ariaLabel, hoverDelegate);
207
} else {
208
const fileOptions: IFileLabelOptions = { hidePath: true, title: correspondingContentReference?.options?.status?.description };
209
this.label.setFile(resource, attachment.kind === 'file' ? {
210
...fileOptions,
211
fileKind: FileKind.FILE,
212
range,
213
} : {
214
...fileOptions,
215
fileKind: FileKind.FOLDER,
216
icon: !this.themeService.getFileIconTheme().hasFolderIcons ? FolderThemeIcon : undefined
217
});
218
}
219
220
this.element.ariaLabel = ariaLabel;
221
222
this.instantiationService.invokeFunction(accessor => {
223
this._register(hookUpResourceAttachmentDragAndContextMenu(accessor, this.element, resource));
224
});
225
this.addResourceOpenHandlers(resource, range);
226
227
this.attachClearButton();
228
}
229
230
private renderOmittedWarning(friendlyName: string, ariaLabel: string, hoverDelegate: IHoverDelegate) {
231
const pillIcon = dom.$('div.chat-attached-context-pill', {}, dom.$('span.codicon.codicon-warning'));
232
const textLabel = dom.$('span.chat-attached-context-custom-text', {}, friendlyName);
233
this.element.appendChild(pillIcon);
234
this.element.appendChild(textLabel);
235
236
const hoverElement = dom.$('div.chat-attached-context-hover');
237
hoverElement.setAttribute('aria-label', ariaLabel);
238
this.element.classList.add('warning');
239
240
hoverElement.textContent = localize('chat.fileAttachmentHover', "{0} does not support this file type.", this.currentLanguageModel ? this.languageModelsService.lookupLanguageModel(this.currentLanguageModel.identifier)?.name : this.currentLanguageModel ?? 'This model');
241
this._register(this.hoverService.setupManagedHover(hoverDelegate, this.element, hoverElement, { trapFocus: true }));
242
}
243
}
244
245
export class ImageAttachmentWidget extends AbstractChatAttachmentWidget {
246
247
constructor(
248
resource: URI | undefined,
249
attachment: IChatRequestVariableEntry,
250
currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined,
251
options: { shouldFocusClearButton: boolean; supportsDeletion: boolean },
252
container: HTMLElement,
253
contextResourceLabels: ResourceLabels,
254
hoverDelegate: IHoverDelegate,
255
@ICommandService commandService: ICommandService,
256
@IOpenerService openerService: IOpenerService,
257
@IHoverService private readonly hoverService: IHoverService,
258
@ILanguageModelsService private readonly languageModelsService: ILanguageModelsService,
259
@IInstantiationService instantiationService: IInstantiationService,
260
@ILabelService private readonly labelService: ILabelService,
261
) {
262
super(attachment, options, container, contextResourceLabels, hoverDelegate, currentLanguageModel, commandService, openerService);
263
264
let ariaLabel: string;
265
if (attachment.omittedState === OmittedState.Full) {
266
ariaLabel = localize('chat.omittedImageAttachment', "Omitted this image: {0}", attachment.name);
267
} else if (attachment.omittedState === OmittedState.Partial) {
268
ariaLabel = localize('chat.partiallyOmittedImageAttachment', "Partially omitted this image: {0}", attachment.name);
269
} else {
270
ariaLabel = localize('chat.imageAttachment', "Attached image, {0}", attachment.name);
271
}
272
273
const ref = attachment.references?.[0]?.reference;
274
resource = ref && URI.isUri(ref) ? ref : undefined;
275
const clickHandler = async () => {
276
if (resource) {
277
await this.openResource(resource, false, undefined);
278
}
279
};
280
281
const currentLanguageModelName = this.currentLanguageModel ? this.languageModelsService.lookupLanguageModel(this.currentLanguageModel.identifier)?.name ?? this.currentLanguageModel.identifier : 'Current model';
282
283
const fullName = resource ? this.labelService.getUriLabel(resource) : (attachment.fullName || attachment.name);
284
this._register(createImageElements(resource, attachment.name, fullName, this.element, attachment.value as Uint8Array, this.hoverService, ariaLabel, currentLanguageModelName, clickHandler, this.currentLanguageModel, attachment.omittedState));
285
286
if (resource) {
287
this.addResourceOpenHandlers(resource, undefined);
288
instantiationService.invokeFunction(accessor => {
289
this._register(hookUpResourceAttachmentDragAndContextMenu(accessor, this.element, resource));
290
});
291
}
292
293
this.attachClearButton();
294
}
295
}
296
297
function createImageElements(resource: URI | undefined, name: string, fullName: string,
298
element: HTMLElement,
299
buffer: ArrayBuffer | Uint8Array,
300
hoverService: IHoverService, ariaLabel: string,
301
currentLanguageModelName: string | undefined,
302
clickHandler: () => void,
303
currentLanguageModel?: ILanguageModelChatMetadataAndIdentifier,
304
omittedState?: OmittedState): IDisposable {
305
306
const disposable = new DisposableStore();
307
if (omittedState === OmittedState.Partial) {
308
element.classList.add('partial-warning');
309
}
310
311
element.ariaLabel = ariaLabel;
312
element.style.position = 'relative';
313
314
if (resource) {
315
element.style.cursor = 'pointer';
316
disposable.add(dom.addDisposableListener(element, 'click', clickHandler));
317
}
318
const supportsVision = modelSupportsVision(currentLanguageModel);
319
const pillIcon = dom.$('div.chat-attached-context-pill', {}, dom.$(supportsVision ? 'span.codicon.codicon-file-media' : 'span.codicon.codicon-warning'));
320
const textLabel = dom.$('span.chat-attached-context-custom-text', {}, name);
321
element.appendChild(pillIcon);
322
element.appendChild(textLabel);
323
324
const hoverElement = dom.$('div.chat-attached-context-hover');
325
hoverElement.setAttribute('aria-label', ariaLabel);
326
327
if ((!supportsVision && currentLanguageModel) || omittedState === OmittedState.Full) {
328
element.classList.add('warning');
329
hoverElement.textContent = localize('chat.imageAttachmentHover', "{0} does not support images.", currentLanguageModelName ?? 'This model');
330
disposable.add(hoverService.setupDelayedHover(element, { content: hoverElement, appearance: { showPointer: true } }));
331
} else {
332
disposable.add(hoverService.setupDelayedHover(element, { content: hoverElement, appearance: { showPointer: true } }));
333
334
const blob = new Blob([buffer as Uint8Array<ArrayBuffer>], { type: 'image/png' });
335
const url = URL.createObjectURL(blob);
336
const pillImg = dom.$('img.chat-attached-context-pill-image', { src: url, alt: '' });
337
const pill = dom.$('div.chat-attached-context-pill', {}, pillImg);
338
339
const existingPill = element.querySelector('.chat-attached-context-pill');
340
if (existingPill) {
341
existingPill.replaceWith(pill);
342
}
343
344
const hoverImage = dom.$('img.chat-attached-context-image', { src: url, alt: '' });
345
const imageContainer = dom.$('div.chat-attached-context-image-container', {}, hoverImage);
346
hoverElement.appendChild(imageContainer);
347
348
if (resource) {
349
const urlContainer = dom.$('a.chat-attached-context-url', {}, omittedState === OmittedState.Partial ? localize('chat.imageAttachmentWarning', "This GIF was partially omitted - current frame will be sent.") : fullName);
350
const separator = dom.$('div.chat-attached-context-url-separator');
351
disposable.add(dom.addDisposableListener(urlContainer, 'click', () => clickHandler()));
352
hoverElement.append(separator, urlContainer);
353
}
354
355
hoverImage.onload = () => { URL.revokeObjectURL(url); };
356
hoverImage.onerror = () => {
357
// reset to original icon on error or invalid image
358
const pillIcon = dom.$('div.chat-attached-context-pill', {}, dom.$('span.codicon.codicon-file-media'));
359
const pill = dom.$('div.chat-attached-context-pill', {}, pillIcon);
360
const existingPill = element.querySelector('.chat-attached-context-pill');
361
if (existingPill) {
362
existingPill.replaceWith(pill);
363
}
364
};
365
}
366
return disposable;
367
}
368
369
export class PasteAttachmentWidget extends AbstractChatAttachmentWidget {
370
371
constructor(
372
attachment: IChatRequestPasteVariableEntry,
373
currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined,
374
options: { shouldFocusClearButton: boolean; supportsDeletion: boolean },
375
container: HTMLElement,
376
contextResourceLabels: ResourceLabels,
377
hoverDelegate: IHoverDelegate,
378
@ICommandService commandService: ICommandService,
379
@IOpenerService openerService: IOpenerService,
380
@IHoverService private readonly hoverService: IHoverService,
381
@IInstantiationService private readonly instantiationService: IInstantiationService,
382
) {
383
super(attachment, options, container, contextResourceLabels, hoverDelegate, currentLanguageModel, commandService, openerService);
384
385
const ariaLabel = localize('chat.attachment', "Attached context, {0}", attachment.name);
386
this.element.ariaLabel = ariaLabel;
387
388
const classNames = ['file-icon', `${attachment.language}-lang-file-icon`];
389
let resource: URI | undefined;
390
let range: IRange | undefined;
391
392
if (attachment.copiedFrom) {
393
resource = attachment.copiedFrom.uri;
394
range = attachment.copiedFrom.range;
395
const filename = basename(resource.path);
396
this.label.setLabel(filename, undefined, { extraClasses: classNames });
397
} else {
398
this.label.setLabel(attachment.fileName, undefined, { extraClasses: classNames });
399
}
400
this.element.appendChild(dom.$('span.attachment-additional-info', {}, `Pasted ${attachment.pastedLines}`));
401
402
this.element.style.position = 'relative';
403
404
const sourceUri = attachment.copiedFrom?.uri;
405
const hoverContent: IManagedHoverTooltipMarkdownString = {
406
markdown: {
407
value: `${sourceUri ? this.instantiationService.invokeFunction(accessor => accessor.get(ILabelService).getUriLabel(sourceUri, { relative: true })) : attachment.fileName}\n\n---\n\n\`\`\`${attachment.language}\n\n${attachment.code}\n\`\`\``,
408
},
409
markdownNotSupportedFallback: attachment.code,
410
};
411
this._register(this.hoverService.setupManagedHover(hoverDelegate, this.element, hoverContent, { trapFocus: true }));
412
413
const copiedFromResource = attachment.copiedFrom?.uri;
414
if (copiedFromResource) {
415
this._register(this.instantiationService.invokeFunction(hookUpResourceAttachmentDragAndContextMenu, this.element, copiedFromResource));
416
this.addResourceOpenHandlers(copiedFromResource, range);
417
}
418
419
this.attachClearButton();
420
}
421
}
422
423
export class DefaultChatAttachmentWidget extends AbstractChatAttachmentWidget {
424
constructor(
425
resource: URI | undefined,
426
range: IRange | undefined,
427
attachment: IChatRequestVariableEntry,
428
correspondingContentReference: IChatContentReference | undefined,
429
currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined,
430
options: { shouldFocusClearButton: boolean; supportsDeletion: boolean },
431
container: HTMLElement,
432
contextResourceLabels: ResourceLabels,
433
hoverDelegate: IHoverDelegate,
434
@ICommandService commandService: ICommandService,
435
@IOpenerService openerService: IOpenerService,
436
@IContextKeyService private readonly contextKeyService: IContextKeyService,
437
@IInstantiationService private readonly instantiationService: IInstantiationService,
438
) {
439
super(attachment, options, container, contextResourceLabels, hoverDelegate, currentLanguageModel, commandService, openerService);
440
441
const attachmentLabel = attachment.fullName ?? attachment.name;
442
const withIcon = attachment.icon?.id ? `$(${attachment.icon.id})\u00A0${attachmentLabel}` : attachmentLabel;
443
this.label.setLabel(withIcon, correspondingContentReference?.options?.status?.description);
444
this.element.ariaLabel = localize('chat.attachment', "Attached context, {0}", attachment.name);
445
446
if (attachment.kind === 'diagnostic') {
447
if (attachment.filterUri) {
448
resource = attachment.filterUri ? URI.revive(attachment.filterUri) : undefined;
449
range = attachment.filterRange;
450
} else {
451
this.element.style.cursor = 'pointer';
452
this._register(dom.addDisposableListener(this.element, dom.EventType.CLICK, () => {
453
this.commandService.executeCommand('workbench.panel.markers.view.focus');
454
}));
455
}
456
}
457
458
if (attachment.kind === 'symbol') {
459
const scopedContextKeyService = this._register(this.contextKeyService.createScoped(this.element));
460
this._register(this.instantiationService.invokeFunction(hookUpSymbolAttachmentDragAndContextMenu, this.element, scopedContextKeyService, { ...attachment, kind: attachment.symbolKind }, MenuId.ChatInputSymbolAttachmentContext));
461
}
462
463
if (resource) {
464
this.addResourceOpenHandlers(resource, range);
465
}
466
467
this.attachClearButton();
468
}
469
}
470
471
export class PromptFileAttachmentWidget extends AbstractChatAttachmentWidget {
472
473
private hintElement: HTMLElement;
474
475
constructor(
476
attachment: IPromptFileVariableEntry,
477
currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined,
478
options: { shouldFocusClearButton: boolean; supportsDeletion: boolean },
479
container: HTMLElement,
480
contextResourceLabels: ResourceLabels,
481
hoverDelegate: IHoverDelegate,
482
@ICommandService commandService: ICommandService,
483
@IOpenerService openerService: IOpenerService,
484
@ILabelService private readonly labelService: ILabelService,
485
@IInstantiationService private readonly instantiationService: IInstantiationService,
486
) {
487
super(attachment, options, container, contextResourceLabels, hoverDelegate, currentLanguageModel, commandService, openerService);
488
489
490
this.hintElement = dom.append(this.element, dom.$('span.prompt-type'));
491
492
this.updateLabel(attachment);
493
494
this.instantiationService.invokeFunction(accessor => {
495
this._register(hookUpResourceAttachmentDragAndContextMenu(accessor, this.element, attachment.value));
496
});
497
this.addResourceOpenHandlers(attachment.value, undefined);
498
499
this.attachClearButton();
500
}
501
502
private updateLabel(attachment: IPromptFileVariableEntry) {
503
const resource = attachment.value;
504
const fileBasename = basename(resource.path);
505
const fileDirname = dirname(resource.path);
506
const friendlyName = `${fileBasename} ${fileDirname}`;
507
const isPrompt = attachment.id.startsWith(PromptFileVariableKind.PromptFile);
508
const ariaLabel = isPrompt
509
? localize('chat.promptAttachment', "Prompt file, {0}", friendlyName)
510
: localize('chat.instructionsAttachment', "Instructions attachment, {0}", friendlyName);
511
const typeLabel = isPrompt
512
? localize('prompt', "Prompt")
513
: localize('instructions', "Instructions");
514
515
const title = this.labelService.getUriLabel(resource) + (attachment.originLabel ? `\n${attachment.originLabel}` : '');
516
517
//const { topError } = this.promptFile;
518
this.element.classList.remove('warning', 'error');
519
520
// if there are some errors/warning during the process of resolving
521
// attachment references (including all the nested child references),
522
// add the issue details in the hover title for the attachment, one
523
// error/warning at a time because there is a limited space available
524
// if (topError) {
525
// const { errorSubject: subject } = topError;
526
// const isError = (subject === 'root');
527
// this.element.classList.add((isError) ? 'error' : 'warning');
528
529
// const severity = (isError)
530
// ? localize('error', "Error")
531
// : localize('warning', "Warning");
532
533
// title += `\n[${severity}]: ${topError.localizedMessage}`;
534
// }
535
536
const fileWithoutExtension = getCleanPromptName(resource);
537
this.label.setFile(URI.file(fileWithoutExtension), {
538
fileKind: FileKind.FILE,
539
hidePath: true,
540
range: undefined,
541
title,
542
icon: ThemeIcon.fromId(Codicon.bookmark.id),
543
extraClasses: [],
544
});
545
546
this.hintElement.innerText = typeLabel;
547
548
549
this.element.ariaLabel = ariaLabel;
550
}
551
}
552
553
export class PromptTextAttachmentWidget extends AbstractChatAttachmentWidget {
554
555
constructor(
556
attachment: IPromptTextVariableEntry,
557
currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined,
558
options: { shouldFocusClearButton: boolean; supportsDeletion: boolean },
559
container: HTMLElement,
560
contextResourceLabels: ResourceLabels,
561
hoverDelegate: IHoverDelegate,
562
@ICommandService commandService: ICommandService,
563
@IOpenerService openerService: IOpenerService,
564
@IPreferencesService preferencesService: IPreferencesService,
565
@IHoverService hoverService: IHoverService
566
) {
567
super(attachment, options, container, contextResourceLabels, hoverDelegate, currentLanguageModel, commandService, openerService);
568
569
if (attachment.settingId) {
570
const openSettings = () => preferencesService.openSettings({ jsonEditor: false, query: `@id:${attachment.settingId}` });
571
572
this.element.style.cursor = 'pointer';
573
this._register(dom.addDisposableListener(this.element, dom.EventType.CLICK, async (e: MouseEvent) => {
574
dom.EventHelper.stop(e, true);
575
openSettings();
576
}));
577
578
this._register(dom.addDisposableListener(this.element, dom.EventType.KEY_DOWN, async (e: KeyboardEvent) => {
579
const event = new StandardKeyboardEvent(e);
580
if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) {
581
dom.EventHelper.stop(e, true);
582
openSettings();
583
}
584
}));
585
}
586
this.label.setLabel(localize('instructions.label', 'Additional Instructions'), undefined, undefined);
587
588
this._register(hoverService.setupManagedHover(hoverDelegate, this.element, attachment.value, { trapFocus: true }));
589
590
}
591
}
592
593
594
export class ToolSetOrToolItemAttachmentWidget extends AbstractChatAttachmentWidget {
595
constructor(
596
attachment: ChatRequestToolReferenceEntry,
597
currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined,
598
options: { shouldFocusClearButton: boolean; supportsDeletion: boolean },
599
container: HTMLElement,
600
contextResourceLabels: ResourceLabels,
601
hoverDelegate: IHoverDelegate,
602
@ILanguageModelToolsService toolsService: ILanguageModelToolsService,
603
@ICommandService commandService: ICommandService,
604
@IOpenerService openerService: IOpenerService,
605
@IHoverService hoverService: IHoverService
606
) {
607
super(attachment, options, container, contextResourceLabels, hoverDelegate, currentLanguageModel, commandService, openerService);
608
609
610
const toolOrToolSet = Iterable.find(toolsService.getTools(), tool => tool.id === attachment.id) ?? Iterable.find(toolsService.toolSets.get(), toolSet => toolSet.id === attachment.id);
611
612
let name = attachment.name;
613
const icon = attachment.icon ?? Codicon.tools;
614
615
if (toolOrToolSet instanceof ToolSet) {
616
name = toolOrToolSet.referenceName;
617
} else if (toolOrToolSet) {
618
name = toolOrToolSet.toolReferenceName ?? name;
619
}
620
621
this.label.setLabel(`$(${icon.id})\u00A0${name}`, undefined);
622
623
this.element.style.cursor = 'pointer';
624
this.element.ariaLabel = localize('chat.attachment', "Attached context, {0}", name);
625
626
let hoverContent: string | undefined;
627
628
if (toolOrToolSet instanceof ToolSet) {
629
hoverContent = localize('toolset', "{0} - {1}", toolOrToolSet.description ?? toolOrToolSet.referenceName, toolOrToolSet.source.label);
630
} else if (toolOrToolSet) {
631
hoverContent = localize('tool', "{0} - {1}", toolOrToolSet.userDescription ?? toolOrToolSet.modelDescription, toolOrToolSet.source.label);
632
}
633
634
if (hoverContent) {
635
this._register(hoverService.setupManagedHover(hoverDelegate, this.element, hoverContent, { trapFocus: true }));
636
}
637
638
this.attachClearButton();
639
}
640
641
642
}
643
644
export class NotebookCellOutputChatAttachmentWidget extends AbstractChatAttachmentWidget {
645
constructor(
646
resource: URI,
647
attachment: INotebookOutputVariableEntry,
648
currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined,
649
options: { shouldFocusClearButton: boolean; supportsDeletion: boolean },
650
container: HTMLElement,
651
contextResourceLabels: ResourceLabels,
652
hoverDelegate: IHoverDelegate,
653
@ICommandService commandService: ICommandService,
654
@IOpenerService openerService: IOpenerService,
655
@IHoverService private readonly hoverService: IHoverService,
656
@ILanguageModelsService private readonly languageModelsService: ILanguageModelsService,
657
@INotebookService private readonly notebookService: INotebookService,
658
@IInstantiationService private readonly instantiationService: IInstantiationService,
659
) {
660
super(attachment, options, container, contextResourceLabels, hoverDelegate, currentLanguageModel, commandService, openerService);
661
662
switch (attachment.mimeType) {
663
case 'application/vnd.code.notebook.error': {
664
this.renderErrorOutput(resource, attachment);
665
break;
666
}
667
case 'image/png':
668
case 'image/jpeg':
669
case 'image/svg': {
670
this.renderImageOutput(resource, attachment);
671
break;
672
}
673
default: {
674
this.renderGenericOutput(resource, attachment);
675
}
676
}
677
678
this.instantiationService.invokeFunction(accessor => {
679
this._register(hookUpResourceAttachmentDragAndContextMenu(accessor, this.element, resource));
680
});
681
this.addResourceOpenHandlers(resource, undefined);
682
this.attachClearButton();
683
}
684
getAriaLabel(attachment: INotebookOutputVariableEntry): string {
685
return localize('chat.NotebookImageAttachment', "Attached Notebook output, {0}", attachment.name);
686
}
687
private renderErrorOutput(resource: URI, attachment: INotebookOutputVariableEntry) {
688
const attachmentLabel = attachment.name;
689
const withIcon = attachment.icon?.id ? `$(${attachment.icon.id})\u00A0${attachmentLabel}` : attachmentLabel;
690
const buffer = this.getOutputItem(resource, attachment)?.data.buffer ?? new Uint8Array();
691
let title: string | undefined = undefined;
692
try {
693
const error = JSON.parse(new TextDecoder().decode(buffer)) as Error;
694
if (error.name && error.message) {
695
title = `${error.name}: ${error.message}`;
696
}
697
} catch {
698
//
699
}
700
this.label.setLabel(withIcon, undefined, { title });
701
this.element.ariaLabel = this.getAriaLabel(attachment);
702
}
703
private renderGenericOutput(resource: URI, attachment: INotebookOutputVariableEntry) {
704
this.element.ariaLabel = this.getAriaLabel(attachment);
705
this.label.setFile(resource, { hidePath: true, icon: ThemeIcon.fromId('output') });
706
}
707
private renderImageOutput(resource: URI, attachment: INotebookOutputVariableEntry) {
708
let ariaLabel: string;
709
if (attachment.omittedState === OmittedState.Full) {
710
ariaLabel = localize('chat.omittedNotebookImageAttachment', "Omitted this Notebook ouput: {0}", attachment.name);
711
} else if (attachment.omittedState === OmittedState.Partial) {
712
ariaLabel = localize('chat.partiallyOmittedNotebookImageAttachment', "Partially omitted this Notebook output: {0}", attachment.name);
713
} else {
714
ariaLabel = this.getAriaLabel(attachment);
715
}
716
717
const clickHandler = async () => await this.openResource(resource, false, undefined);
718
const currentLanguageModelName = this.currentLanguageModel ? this.languageModelsService.lookupLanguageModel(this.currentLanguageModel.identifier)?.name ?? this.currentLanguageModel.identifier : undefined;
719
const buffer = this.getOutputItem(resource, attachment)?.data.buffer ?? new Uint8Array();
720
this._register(createImageElements(resource, attachment.name, attachment.name, this.element, buffer, this.hoverService, ariaLabel, currentLanguageModelName, clickHandler, this.currentLanguageModel, attachment.omittedState));
721
}
722
723
private getOutputItem(resource: URI, attachment: INotebookOutputVariableEntry) {
724
const parsedInfo = CellUri.parseCellOutputUri(resource);
725
if (!parsedInfo || typeof parsedInfo.cellHandle !== 'number' || typeof parsedInfo.outputIndex !== 'number') {
726
return undefined;
727
}
728
const notebook = this.notebookService.getNotebookTextModel(parsedInfo.notebook);
729
if (!notebook) {
730
return undefined;
731
}
732
const cell = notebook.cells.find(c => c.handle === parsedInfo.cellHandle);
733
if (!cell) {
734
return undefined;
735
}
736
const output = cell.outputs.length > parsedInfo.outputIndex ? cell.outputs[parsedInfo.outputIndex] : undefined;
737
return output?.outputs.find(o => o.mime === attachment.mimeType);
738
}
739
740
}
741
742
export class ElementChatAttachmentWidget extends AbstractChatAttachmentWidget {
743
constructor(
744
attachment: IElementVariableEntry,
745
currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined,
746
options: { shouldFocusClearButton: boolean; supportsDeletion: boolean },
747
container: HTMLElement,
748
contextResourceLabels: ResourceLabels,
749
hoverDelegate: IHoverDelegate,
750
@ICommandService commandService: ICommandService,
751
@IOpenerService openerService: IOpenerService,
752
@IEditorService editorService: IEditorService,
753
) {
754
super(attachment, options, container, contextResourceLabels, hoverDelegate, currentLanguageModel, commandService, openerService);
755
756
const ariaLabel = localize('chat.elementAttachment', "Attached element, {0}", attachment.name);
757
this.element.ariaLabel = ariaLabel;
758
759
this.element.style.position = 'relative';
760
this.element.style.cursor = 'pointer';
761
const attachmentLabel = attachment.name;
762
const withIcon = attachment.icon?.id ? `$(${attachment.icon.id})\u00A0${attachmentLabel}` : attachmentLabel;
763
this.label.setLabel(withIcon, undefined, { title: localize('chat.clickToViewContents', "Click to view the contents of: {0}", attachmentLabel) });
764
765
this._register(dom.addDisposableListener(this.element, dom.EventType.CLICK, async () => {
766
const content = attachment.value?.toString() || '';
767
await editorService.openEditor({
768
resource: undefined,
769
contents: content,
770
options: {
771
pinned: true
772
}
773
});
774
}));
775
776
this.attachClearButton();
777
}
778
}
779
780
export class SCMHistoryItemAttachmentWidget extends AbstractChatAttachmentWidget {
781
constructor(
782
attachment: ISCMHistoryItemVariableEntry,
783
currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined,
784
options: { shouldFocusClearButton: boolean; supportsDeletion: boolean },
785
container: HTMLElement,
786
contextResourceLabels: ResourceLabels,
787
hoverDelegate: IHoverDelegate,
788
@ICommandService commandService: ICommandService,
789
@IHoverService hoverService: IHoverService,
790
@IOpenerService openerService: IOpenerService,
791
@IThemeService themeService: IThemeService
792
) {
793
super(attachment, options, container, contextResourceLabels, hoverDelegate, currentLanguageModel, commandService, openerService);
794
795
this.label.setLabel(attachment.name, undefined);
796
797
this.element.style.cursor = 'pointer';
798
this.element.ariaLabel = localize('chat.attachment', "Attached context, {0}", attachment.name);
799
800
this._store.add(hoverService.setupManagedHover(hoverDelegate, this.element, () => getHistoryItemHoverContent(themeService, attachment.historyItem), { trapFocus: true }));
801
802
this._store.add(dom.addDisposableListener(this.element, dom.EventType.CLICK, (e: MouseEvent) => {
803
dom.EventHelper.stop(e, true);
804
this._openAttachment(attachment);
805
}));
806
807
this._store.add(dom.addDisposableListener(this.element, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => {
808
const event = new StandardKeyboardEvent(e);
809
if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) {
810
dom.EventHelper.stop(e, true);
811
this._openAttachment(attachment);
812
}
813
}));
814
815
this.attachClearButton();
816
}
817
818
private async _openAttachment(attachment: ISCMHistoryItemVariableEntry): Promise<void> {
819
await this.commandService.executeCommand('_workbench.openMultiDiffEditor', {
820
title: getHistoryItemEditorTitle(attachment.historyItem), multiDiffSourceUri: attachment.value
821
});
822
}
823
}
824
825
export function hookUpResourceAttachmentDragAndContextMenu(accessor: ServicesAccessor, widget: HTMLElement, resource: URI): IDisposable {
826
const contextKeyService = accessor.get(IContextKeyService);
827
const instantiationService = accessor.get(IInstantiationService);
828
829
const store = new DisposableStore();
830
831
// Context
832
const scopedContextKeyService = store.add(contextKeyService.createScoped(widget));
833
store.add(setResourceContext(accessor, scopedContextKeyService, resource));
834
835
// Drag and drop
836
widget.draggable = true;
837
store.add(dom.addDisposableListener(widget, 'dragstart', e => {
838
instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, [resource], e));
839
e.dataTransfer?.setDragImage(widget, 0, 0);
840
}));
841
842
// Context menu
843
store.add(addBasicContextMenu(accessor, widget, scopedContextKeyService, MenuId.ChatInputResourceAttachmentContext, resource));
844
845
return store;
846
}
847
848
export function hookUpSymbolAttachmentDragAndContextMenu(accessor: ServicesAccessor, widget: HTMLElement, scopedContextKeyService: IScopedContextKeyService, attachment: { name: string; value: Location; kind: SymbolKind }, contextMenuId: MenuId): IDisposable {
849
const instantiationService = accessor.get(IInstantiationService);
850
const languageFeaturesService = accessor.get(ILanguageFeaturesService);
851
const textModelService = accessor.get(ITextModelService);
852
853
const store = new DisposableStore();
854
855
// Context
856
store.add(setResourceContext(accessor, scopedContextKeyService, attachment.value.uri));
857
858
const chatResourceContext = chatAttachmentResourceContextKey.bindTo(scopedContextKeyService);
859
chatResourceContext.set(attachment.value.uri.toString());
860
861
// Drag and drop
862
widget.draggable = true;
863
store.add(dom.addDisposableListener(widget, 'dragstart', e => {
864
instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, [{ resource: attachment.value.uri, selection: attachment.value.range }], e));
865
866
fillInSymbolsDragData([{
867
fsPath: attachment.value.uri.fsPath,
868
range: attachment.value.range,
869
name: attachment.name,
870
kind: attachment.kind,
871
}], e);
872
873
e.dataTransfer?.setDragImage(widget, 0, 0);
874
}));
875
876
// Context menu
877
const providerContexts: ReadonlyArray<[IContextKey<boolean>, LanguageFeatureRegistry<unknown>]> = [
878
[EditorContextKeys.hasDefinitionProvider.bindTo(scopedContextKeyService), languageFeaturesService.definitionProvider],
879
[EditorContextKeys.hasReferenceProvider.bindTo(scopedContextKeyService), languageFeaturesService.referenceProvider],
880
[EditorContextKeys.hasImplementationProvider.bindTo(scopedContextKeyService), languageFeaturesService.implementationProvider],
881
[EditorContextKeys.hasTypeDefinitionProvider.bindTo(scopedContextKeyService), languageFeaturesService.typeDefinitionProvider],
882
];
883
884
const updateContextKeys = async () => {
885
const modelRef = await textModelService.createModelReference(attachment.value.uri);
886
try {
887
const model = modelRef.object.textEditorModel;
888
for (const [contextKey, registry] of providerContexts) {
889
contextKey.set(registry.has(model));
890
}
891
} finally {
892
modelRef.dispose();
893
}
894
};
895
store.add(addBasicContextMenu(accessor, widget, scopedContextKeyService, contextMenuId, attachment.value, updateContextKeys));
896
897
return store;
898
}
899
900
function setResourceContext(accessor: ServicesAccessor, scopedContextKeyService: IScopedContextKeyService, resource: URI) {
901
const fileService = accessor.get(IFileService);
902
const languageService = accessor.get(ILanguageService);
903
const modelService = accessor.get(IModelService);
904
905
const resourceContextKey = new ResourceContextKey(scopedContextKeyService, fileService, languageService, modelService);
906
resourceContextKey.set(resource);
907
return resourceContextKey;
908
}
909
910
function addBasicContextMenu(accessor: ServicesAccessor, widget: HTMLElement, scopedContextKeyService: IScopedContextKeyService, menuId: MenuId, arg: any, updateContextKeys?: () => Promise<void>): IDisposable {
911
const contextMenuService = accessor.get(IContextMenuService);
912
const menuService = accessor.get(IMenuService);
913
914
return dom.addDisposableListener(widget, dom.EventType.CONTEXT_MENU, async domEvent => {
915
const event = new StandardMouseEvent(dom.getWindow(domEvent), domEvent);
916
dom.EventHelper.stop(domEvent, true);
917
918
try {
919
await updateContextKeys?.();
920
} catch (e) {
921
console.error(e);
922
}
923
924
contextMenuService.showContextMenu({
925
contextKeyService: scopedContextKeyService,
926
getAnchor: () => event,
927
getActions: () => {
928
const menu = menuService.getMenuActions(menuId, scopedContextKeyService, { arg });
929
return getFlatContextMenuActions(menu);
930
},
931
});
932
});
933
}
934
935
export const chatAttachmentResourceContextKey = new RawContextKey<string>('chatAttachmentResource', undefined, { type: 'URI', description: localize('resource', "The full value of the chat attachment resource, including scheme and path") });
936
937