Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatContentParts/chatToolInputOutputContentPart.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 { ButtonWithIcon } from '../../../../../base/browser/ui/button/button.js';
8
import { Codicon } from '../../../../../base/common/codicons.js';
9
import { Emitter } from '../../../../../base/common/event.js';
10
import { IMarkdownString } from '../../../../../base/common/htmlContent.js';
11
import { Disposable } from '../../../../../base/common/lifecycle.js';
12
import { autorun, ISettableObservable, observableValue } from '../../../../../base/common/observable.js';
13
import { basename, joinPath } from '../../../../../base/common/resources.js';
14
import { ThemeIcon } from '../../../../../base/common/themables.js';
15
import { URI } from '../../../../../base/common/uri.js';
16
import { generateUuid } from '../../../../../base/common/uuid.js';
17
import { MarkdownRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js';
18
import { ITextModel } from '../../../../../editor/common/model.js';
19
import { localize, localize2 } from '../../../../../nls.js';
20
import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js';
21
import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js';
22
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
23
import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
24
import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js';
25
import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
26
import { IFileService } from '../../../../../platform/files/common/files.js';
27
import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';
28
import { ILabelService } from '../../../../../platform/label/common/label.js';
29
import { INotificationService } from '../../../../../platform/notification/common/notification.js';
30
import { IProgressService, ProgressLocation } from '../../../../../platform/progress/common/progress.js';
31
import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';
32
import { REVEAL_IN_EXPLORER_COMMAND_ID } from '../../../files/browser/fileConstants.js';
33
import { getAttachableImageExtension } from '../../common/chatModel.js';
34
import { IChatRequestVariableEntry } from '../../common/chatVariableEntries.js';
35
import { IChatRendererContent } from '../../common/chatViewModel.js';
36
import { ChatTreeItem, IChatCodeBlockInfo } from '../chat.js';
37
import { CodeBlockPart, ICodeBlockData, ICodeBlockRenderOptions } from '../codeBlockPart.js';
38
import { ChatAttachmentsContentPart } from './chatAttachmentsContentPart.js';
39
import { IDisposableReference } from './chatCollections.js';
40
import { ChatQueryTitlePart } from './chatConfirmationWidget.js';
41
import { IChatContentPartRenderContext } from './chatContentParts.js';
42
import { EditorPool } from './chatMarkdownContentPart.js';
43
44
export interface IChatCollapsibleIOCodePart {
45
kind: 'code';
46
textModel: ITextModel;
47
languageId: string;
48
options: ICodeBlockRenderOptions;
49
codeBlockInfo: IChatCodeBlockInfo;
50
}
51
52
export interface IChatCollapsibleIODataPart {
53
kind: 'data';
54
value?: Uint8Array;
55
mimeType: string | undefined;
56
uri: URI;
57
}
58
59
export type ChatCollapsibleIOPart = IChatCollapsibleIOCodePart | IChatCollapsibleIODataPart;
60
61
export interface IChatCollapsibleInputData extends IChatCollapsibleIOCodePart { }
62
export interface IChatCollapsibleOutputData {
63
parts: ChatCollapsibleIOPart[];
64
}
65
66
export class ChatCollapsibleInputOutputContentPart extends Disposable {
67
private readonly _onDidChangeHeight = this._register(new Emitter<void>());
68
public readonly onDidChangeHeight = this._onDidChangeHeight.event;
69
70
private _currentWidth: number = 0;
71
private readonly _editorReferences: IDisposableReference<CodeBlockPart>[] = [];
72
private readonly _titlePart: ChatQueryTitlePart;
73
public readonly domNode: HTMLElement;
74
75
readonly codeblocks: IChatCodeBlockInfo[] = [];
76
77
public set title(s: string | IMarkdownString) {
78
this._titlePart.title = s;
79
}
80
81
public get title(): string | IMarkdownString {
82
return this._titlePart.title;
83
}
84
85
private readonly _expanded: ISettableObservable<boolean>;
86
87
public get expanded(): boolean {
88
return this._expanded.get();
89
}
90
91
constructor(
92
title: IMarkdownString | string,
93
subtitle: string | IMarkdownString | undefined,
94
private readonly context: IChatContentPartRenderContext,
95
private readonly editorPool: EditorPool,
96
private readonly input: IChatCollapsibleInputData,
97
private readonly output: IChatCollapsibleOutputData | undefined,
98
isError: boolean,
99
initiallyExpanded: boolean,
100
width: number,
101
@IContextKeyService private readonly contextKeyService: IContextKeyService,
102
@IInstantiationService private readonly _instantiationService: IInstantiationService,
103
@IContextMenuService private readonly _contextMenuService: IContextMenuService,
104
) {
105
super();
106
this._currentWidth = width;
107
108
const titleEl = dom.h('.chat-confirmation-widget-title-inner');
109
const iconEl = dom.h('.chat-confirmation-widget-title-icon');
110
const elements = dom.h('.chat-confirmation-widget');
111
this.domNode = elements.root;
112
113
const titlePart = this._titlePart = this._register(_instantiationService.createInstance(
114
ChatQueryTitlePart,
115
titleEl.root,
116
title,
117
subtitle,
118
_instantiationService.createInstance(MarkdownRenderer, {}),
119
));
120
this._register(titlePart.onDidChangeHeight(() => this._onDidChangeHeight.fire()));
121
122
const spacer = document.createElement('span');
123
spacer.style.flexGrow = '1';
124
125
const btn = this._register(new ButtonWithIcon(elements.root, {}));
126
btn.element.classList.add('chat-confirmation-widget-title', 'monaco-text-button');
127
btn.labelElement.append(titleEl.root, iconEl.root);
128
129
const check = dom.h(isError
130
? ThemeIcon.asCSSSelector(Codicon.error)
131
: output
132
? ThemeIcon.asCSSSelector(Codicon.check)
133
: ThemeIcon.asCSSSelector(ThemeIcon.modify(Codicon.loading, 'spin'))
134
);
135
iconEl.root.appendChild(check.root);
136
137
const expanded = this._expanded = observableValue(this, initiallyExpanded);
138
this._register(autorun(r => {
139
const value = expanded.read(r);
140
btn.icon = value ? Codicon.chevronDown : Codicon.chevronRight;
141
elements.root.classList.toggle('collapsed', !value);
142
this._onDidChangeHeight.fire();
143
}));
144
145
const toggle = (e: Event) => {
146
if (!e.defaultPrevented) {
147
const value = expanded.get();
148
expanded.set(!value, undefined);
149
e.preventDefault();
150
}
151
};
152
153
this._register(btn.onDidClick(toggle));
154
155
const message = dom.h('.chat-confirmation-widget-message');
156
message.root.appendChild(this.createMessageContents());
157
elements.root.appendChild(message.root);
158
}
159
160
private createMessageContents() {
161
const contents = dom.h('div', [
162
dom.h('h3@inputTitle'),
163
dom.h('div@input'),
164
dom.h('h3@outputTitle'),
165
dom.h('div@output'),
166
]);
167
168
const { input, output } = this;
169
170
contents.inputTitle.textContent = localize('chat.input', "Input");
171
this.addCodeBlock(input, contents.input);
172
173
if (!output) {
174
contents.output.remove();
175
contents.outputTitle.remove();
176
} else {
177
contents.outputTitle.textContent = localize('chat.output', "Output");
178
for (let i = 0; i < output.parts.length; i++) {
179
const part = output.parts[i];
180
if (part.kind === 'code') {
181
this.addCodeBlock(part, contents.output);
182
continue;
183
}
184
185
const group: IChatCollapsibleIODataPart[] = [];
186
for (let k = i; k < output.parts.length; k++) {
187
const part = output.parts[k];
188
if (part.kind !== 'data') {
189
break;
190
}
191
group.push(part);
192
}
193
194
this.addResourceGroup(group, contents.output);
195
i += group.length - 1; // Skip the parts we just added
196
}
197
}
198
199
return contents.root;
200
}
201
202
private addResourceGroup(parts: IChatCollapsibleIODataPart[], container: HTMLElement) {
203
const el = dom.h('.chat-collapsible-io-resource-group', [
204
dom.h('.chat-collapsible-io-resource-items@items'),
205
dom.h('.chat-collapsible-io-resource-actions@actions'),
206
]);
207
208
const entries = parts.map((part): IChatRequestVariableEntry => {
209
if (part.mimeType && getAttachableImageExtension(part.mimeType)) {
210
return { kind: 'image', id: generateUuid(), name: basename(part.uri), value: part.value, mimeType: part.mimeType, isURL: false, references: [{ kind: 'reference', reference: part.uri }] };
211
} else {
212
return { kind: 'file', id: generateUuid(), name: basename(part.uri), fullName: part.uri.path, value: part.uri };
213
}
214
});
215
216
const attachments = this._register(this._instantiationService.createInstance(
217
ChatAttachmentsContentPart,
218
entries,
219
undefined,
220
undefined,
221
));
222
223
attachments.contextMenuHandler = (attachment, event) => {
224
const index = entries.indexOf(attachment);
225
const part = parts[index];
226
if (part) {
227
event.preventDefault();
228
event.stopPropagation();
229
230
this._contextMenuService.showContextMenu({
231
menuId: MenuId.ChatToolOutputResourceContext,
232
menuActionOptions: { shouldForwardArgs: true },
233
getAnchor: () => ({ x: event.pageX, y: event.pageY }),
234
getActionsContext: () => ({ parts: [part] } satisfies IChatToolOutputResourceToolbarContext),
235
});
236
}
237
};
238
239
el.items.appendChild(attachments.domNode!);
240
241
const toolbar = this._register(this._instantiationService.createInstance(MenuWorkbenchToolBar, el.actions, MenuId.ChatToolOutputResourceToolbar, {
242
menuOptions: {
243
shouldForwardArgs: true,
244
},
245
}));
246
toolbar.context = { parts } satisfies IChatToolOutputResourceToolbarContext;
247
248
container.appendChild(el.root);
249
}
250
251
private addCodeBlock(part: IChatCollapsibleIOCodePart, container: HTMLElement) {
252
const data: ICodeBlockData = {
253
languageId: part.languageId,
254
textModel: Promise.resolve(part.textModel),
255
codeBlockIndex: part.codeBlockInfo.codeBlockIndex,
256
codeBlockPartIndex: 0,
257
element: this.context.element,
258
parentContextKeyService: this.contextKeyService,
259
renderOptions: part.options,
260
chatSessionId: this.context.element.sessionId,
261
};
262
const editorReference = this._register(this.editorPool.get());
263
editorReference.object.render(data, this._currentWidth || 300);
264
this._register(editorReference.object.onDidChangeContentHeight(() => this._onDidChangeHeight.fire()));
265
container.appendChild(editorReference.object.element);
266
this._editorReferences.push(editorReference);
267
}
268
269
hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean {
270
// For now, we consider content different unless it's exactly the same instance
271
return false;
272
}
273
274
layout(width: number): void {
275
this._currentWidth = width;
276
this._editorReferences.forEach(r => r.object.layout(width));
277
}
278
}
279
280
interface IChatToolOutputResourceToolbarContext {
281
parts: IChatCollapsibleIODataPart[];
282
}
283
284
class SaveResourcesAction extends Action2 {
285
public static readonly ID = 'chat.toolOutput.save';
286
constructor() {
287
super({
288
id: SaveResourcesAction.ID,
289
title: localize2('chat.saveResources', "Save As..."),
290
icon: Codicon.cloudDownload,
291
menu: [{
292
id: MenuId.ChatToolOutputResourceToolbar,
293
group: 'navigation',
294
order: 1
295
}, {
296
id: MenuId.ChatToolOutputResourceContext,
297
}]
298
});
299
}
300
301
async run(accessor: ServicesAccessor, context: IChatToolOutputResourceToolbarContext) {
302
const fileDialog = accessor.get(IFileDialogService);
303
const fileService = accessor.get(IFileService);
304
const notificationService = accessor.get(INotificationService);
305
const progressService = accessor.get(IProgressService);
306
const workspaceContextService = accessor.get(IWorkspaceContextService);
307
const commandService = accessor.get(ICommandService);
308
const labelService = accessor.get(ILabelService);
309
const defaultFilepath = await fileDialog.defaultFilePath();
310
311
const savePart = async (part: IChatCollapsibleIODataPart, isFolder: boolean, uri: URI) => {
312
const target = isFolder ? joinPath(uri, basename(part.uri)) : uri;
313
try {
314
if (part.kind === 'data') {
315
await fileService.copy(part.uri, target, true);
316
} else {
317
// MCP doesn't support streaming data, so no sense trying
318
const contents = await fileService.readFile(part.uri);
319
await fileService.writeFile(target, contents.value);
320
}
321
} catch (e) {
322
notificationService.error(localize('chat.saveResources.error', "Failed to save {0}: {1}", basename(part.uri), e));
323
}
324
};
325
326
const withProgress = async (thenReveal: URI, todo: (() => Promise<void>)[]) => {
327
await progressService.withProgress({
328
location: ProgressLocation.Notification,
329
delay: 5_000,
330
title: localize('chat.saveResources.progress', "Saving resources..."),
331
}, async report => {
332
for (const task of todo) {
333
await task();
334
report.report({ increment: 1, total: todo.length });
335
}
336
});
337
338
if (workspaceContextService.isInsideWorkspace(thenReveal)) {
339
commandService.executeCommand(REVEAL_IN_EXPLORER_COMMAND_ID, thenReveal);
340
} else {
341
notificationService.info(localize('chat.saveResources.reveal', "Saved resources to {0}", labelService.getUriLabel(thenReveal)));
342
}
343
};
344
345
if (context.parts.length === 1) {
346
const part = context.parts[0];
347
const uri = await fileDialog.pickFileToSave(joinPath(defaultFilepath, basename(part.uri)));
348
if (!uri) {
349
return;
350
}
351
await withProgress(uri, [() => savePart(part, false, uri)]);
352
} else {
353
const uris = await fileDialog.showOpenDialog({
354
title: localize('chat.saveResources.title', "Pick folder to save resources"),
355
canSelectFiles: false,
356
canSelectFolders: true,
357
canSelectMany: false,
358
defaultUri: workspaceContextService.getWorkspace().folders[0]?.uri,
359
});
360
361
if (!uris?.length) {
362
return;
363
}
364
365
await withProgress(uris[0], context.parts.map(part => () => savePart(part, true, uris[0])));
366
}
367
}
368
}
369
370
registerAction2(SaveResourcesAction);
371
372