Path: blob/main/src/vs/workbench/contrib/chat/browser/chatContentParts/chatToolInputOutputContentPart.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 * as dom from '../../../../../base/browser/dom.js';6import { ButtonWithIcon } from '../../../../../base/browser/ui/button/button.js';7import { Codicon } from '../../../../../base/common/codicons.js';8import { Emitter } from '../../../../../base/common/event.js';9import { IMarkdownString } from '../../../../../base/common/htmlContent.js';10import { Disposable } from '../../../../../base/common/lifecycle.js';11import { autorun, ISettableObservable, observableValue } from '../../../../../base/common/observable.js';12import { basename, joinPath } from '../../../../../base/common/resources.js';13import { ThemeIcon } from '../../../../../base/common/themables.js';14import { URI } from '../../../../../base/common/uri.js';15import { generateUuid } from '../../../../../base/common/uuid.js';16import { MarkdownRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js';17import { ITextModel } from '../../../../../editor/common/model.js';18import { localize, localize2 } from '../../../../../nls.js';19import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js';20import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js';21import { ICommandService } from '../../../../../platform/commands/common/commands.js';22import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';23import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js';24import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js';25import { IFileService } from '../../../../../platform/files/common/files.js';26import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';27import { ILabelService } from '../../../../../platform/label/common/label.js';28import { INotificationService } from '../../../../../platform/notification/common/notification.js';29import { IProgressService, ProgressLocation } from '../../../../../platform/progress/common/progress.js';30import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';31import { REVEAL_IN_EXPLORER_COMMAND_ID } from '../../../files/browser/fileConstants.js';32import { getAttachableImageExtension } from '../../common/chatModel.js';33import { IChatRequestVariableEntry } from '../../common/chatVariableEntries.js';34import { IChatRendererContent } from '../../common/chatViewModel.js';35import { ChatTreeItem, IChatCodeBlockInfo } from '../chat.js';36import { CodeBlockPart, ICodeBlockData, ICodeBlockRenderOptions } from '../codeBlockPart.js';37import { ChatAttachmentsContentPart } from './chatAttachmentsContentPart.js';38import { IDisposableReference } from './chatCollections.js';39import { ChatQueryTitlePart } from './chatConfirmationWidget.js';40import { IChatContentPartRenderContext } from './chatContentParts.js';41import { EditorPool } from './chatMarkdownContentPart.js';4243export interface IChatCollapsibleIOCodePart {44kind: 'code';45textModel: ITextModel;46languageId: string;47options: ICodeBlockRenderOptions;48codeBlockInfo: IChatCodeBlockInfo;49}5051export interface IChatCollapsibleIODataPart {52kind: 'data';53value?: Uint8Array;54mimeType: string | undefined;55uri: URI;56}5758export type ChatCollapsibleIOPart = IChatCollapsibleIOCodePart | IChatCollapsibleIODataPart;5960export interface IChatCollapsibleInputData extends IChatCollapsibleIOCodePart { }61export interface IChatCollapsibleOutputData {62parts: ChatCollapsibleIOPart[];63}6465export class ChatCollapsibleInputOutputContentPart extends Disposable {66private readonly _onDidChangeHeight = this._register(new Emitter<void>());67public readonly onDidChangeHeight = this._onDidChangeHeight.event;6869private _currentWidth: number = 0;70private readonly _editorReferences: IDisposableReference<CodeBlockPart>[] = [];71private readonly _titlePart: ChatQueryTitlePart;72public readonly domNode: HTMLElement;7374readonly codeblocks: IChatCodeBlockInfo[] = [];7576public set title(s: string | IMarkdownString) {77this._titlePart.title = s;78}7980public get title(): string | IMarkdownString {81return this._titlePart.title;82}8384private readonly _expanded: ISettableObservable<boolean>;8586public get expanded(): boolean {87return this._expanded.get();88}8990constructor(91title: IMarkdownString | string,92subtitle: string | IMarkdownString | undefined,93private readonly context: IChatContentPartRenderContext,94private readonly editorPool: EditorPool,95private readonly input: IChatCollapsibleInputData,96private readonly output: IChatCollapsibleOutputData | undefined,97isError: boolean,98initiallyExpanded: boolean,99width: number,100@IContextKeyService private readonly contextKeyService: IContextKeyService,101@IInstantiationService private readonly _instantiationService: IInstantiationService,102@IContextMenuService private readonly _contextMenuService: IContextMenuService,103) {104super();105this._currentWidth = width;106107const titleEl = dom.h('.chat-confirmation-widget-title-inner');108const iconEl = dom.h('.chat-confirmation-widget-title-icon');109const elements = dom.h('.chat-confirmation-widget');110this.domNode = elements.root;111112const titlePart = this._titlePart = this._register(_instantiationService.createInstance(113ChatQueryTitlePart,114titleEl.root,115title,116subtitle,117_instantiationService.createInstance(MarkdownRenderer, {}),118));119this._register(titlePart.onDidChangeHeight(() => this._onDidChangeHeight.fire()));120121const spacer = document.createElement('span');122spacer.style.flexGrow = '1';123124const btn = this._register(new ButtonWithIcon(elements.root, {}));125btn.element.classList.add('chat-confirmation-widget-title', 'monaco-text-button');126btn.labelElement.append(titleEl.root, iconEl.root);127128const check = dom.h(isError129? ThemeIcon.asCSSSelector(Codicon.error)130: output131? ThemeIcon.asCSSSelector(Codicon.check)132: ThemeIcon.asCSSSelector(ThemeIcon.modify(Codicon.loading, 'spin'))133);134iconEl.root.appendChild(check.root);135136const expanded = this._expanded = observableValue(this, initiallyExpanded);137this._register(autorun(r => {138const value = expanded.read(r);139btn.icon = value ? Codicon.chevronDown : Codicon.chevronRight;140elements.root.classList.toggle('collapsed', !value);141this._onDidChangeHeight.fire();142}));143144const toggle = (e: Event) => {145if (!e.defaultPrevented) {146const value = expanded.get();147expanded.set(!value, undefined);148e.preventDefault();149}150};151152this._register(btn.onDidClick(toggle));153154const message = dom.h('.chat-confirmation-widget-message');155message.root.appendChild(this.createMessageContents());156elements.root.appendChild(message.root);157}158159private createMessageContents() {160const contents = dom.h('div', [161dom.h('h3@inputTitle'),162dom.h('div@input'),163dom.h('h3@outputTitle'),164dom.h('div@output'),165]);166167const { input, output } = this;168169contents.inputTitle.textContent = localize('chat.input', "Input");170this.addCodeBlock(input, contents.input);171172if (!output) {173contents.output.remove();174contents.outputTitle.remove();175} else {176contents.outputTitle.textContent = localize('chat.output', "Output");177for (let i = 0; i < output.parts.length; i++) {178const part = output.parts[i];179if (part.kind === 'code') {180this.addCodeBlock(part, contents.output);181continue;182}183184const group: IChatCollapsibleIODataPart[] = [];185for (let k = i; k < output.parts.length; k++) {186const part = output.parts[k];187if (part.kind !== 'data') {188break;189}190group.push(part);191}192193this.addResourceGroup(group, contents.output);194i += group.length - 1; // Skip the parts we just added195}196}197198return contents.root;199}200201private addResourceGroup(parts: IChatCollapsibleIODataPart[], container: HTMLElement) {202const el = dom.h('.chat-collapsible-io-resource-group', [203dom.h('.chat-collapsible-io-resource-items@items'),204dom.h('.chat-collapsible-io-resource-actions@actions'),205]);206207const entries = parts.map((part): IChatRequestVariableEntry => {208if (part.mimeType && getAttachableImageExtension(part.mimeType)) {209return { kind: 'image', id: generateUuid(), name: basename(part.uri), value: part.value, mimeType: part.mimeType, isURL: false, references: [{ kind: 'reference', reference: part.uri }] };210} else {211return { kind: 'file', id: generateUuid(), name: basename(part.uri), fullName: part.uri.path, value: part.uri };212}213});214215const attachments = this._register(this._instantiationService.createInstance(216ChatAttachmentsContentPart,217entries,218undefined,219undefined,220));221222attachments.contextMenuHandler = (attachment, event) => {223const index = entries.indexOf(attachment);224const part = parts[index];225if (part) {226event.preventDefault();227event.stopPropagation();228229this._contextMenuService.showContextMenu({230menuId: MenuId.ChatToolOutputResourceContext,231menuActionOptions: { shouldForwardArgs: true },232getAnchor: () => ({ x: event.pageX, y: event.pageY }),233getActionsContext: () => ({ parts: [part] } satisfies IChatToolOutputResourceToolbarContext),234});235}236};237238el.items.appendChild(attachments.domNode!);239240const toolbar = this._register(this._instantiationService.createInstance(MenuWorkbenchToolBar, el.actions, MenuId.ChatToolOutputResourceToolbar, {241menuOptions: {242shouldForwardArgs: true,243},244}));245toolbar.context = { parts } satisfies IChatToolOutputResourceToolbarContext;246247container.appendChild(el.root);248}249250private addCodeBlock(part: IChatCollapsibleIOCodePart, container: HTMLElement) {251const data: ICodeBlockData = {252languageId: part.languageId,253textModel: Promise.resolve(part.textModel),254codeBlockIndex: part.codeBlockInfo.codeBlockIndex,255codeBlockPartIndex: 0,256element: this.context.element,257parentContextKeyService: this.contextKeyService,258renderOptions: part.options,259chatSessionId: this.context.element.sessionId,260};261const editorReference = this._register(this.editorPool.get());262editorReference.object.render(data, this._currentWidth || 300);263this._register(editorReference.object.onDidChangeContentHeight(() => this._onDidChangeHeight.fire()));264container.appendChild(editorReference.object.element);265this._editorReferences.push(editorReference);266}267268hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean {269// For now, we consider content different unless it's exactly the same instance270return false;271}272273layout(width: number): void {274this._currentWidth = width;275this._editorReferences.forEach(r => r.object.layout(width));276}277}278279interface IChatToolOutputResourceToolbarContext {280parts: IChatCollapsibleIODataPart[];281}282283class SaveResourcesAction extends Action2 {284public static readonly ID = 'chat.toolOutput.save';285constructor() {286super({287id: SaveResourcesAction.ID,288title: localize2('chat.saveResources', "Save As..."),289icon: Codicon.cloudDownload,290menu: [{291id: MenuId.ChatToolOutputResourceToolbar,292group: 'navigation',293order: 1294}, {295id: MenuId.ChatToolOutputResourceContext,296}]297});298}299300async run(accessor: ServicesAccessor, context: IChatToolOutputResourceToolbarContext) {301const fileDialog = accessor.get(IFileDialogService);302const fileService = accessor.get(IFileService);303const notificationService = accessor.get(INotificationService);304const progressService = accessor.get(IProgressService);305const workspaceContextService = accessor.get(IWorkspaceContextService);306const commandService = accessor.get(ICommandService);307const labelService = accessor.get(ILabelService);308const defaultFilepath = await fileDialog.defaultFilePath();309310const savePart = async (part: IChatCollapsibleIODataPart, isFolder: boolean, uri: URI) => {311const target = isFolder ? joinPath(uri, basename(part.uri)) : uri;312try {313if (part.kind === 'data') {314await fileService.copy(part.uri, target, true);315} else {316// MCP doesn't support streaming data, so no sense trying317const contents = await fileService.readFile(part.uri);318await fileService.writeFile(target, contents.value);319}320} catch (e) {321notificationService.error(localize('chat.saveResources.error', "Failed to save {0}: {1}", basename(part.uri), e));322}323};324325const withProgress = async (thenReveal: URI, todo: (() => Promise<void>)[]) => {326await progressService.withProgress({327location: ProgressLocation.Notification,328delay: 5_000,329title: localize('chat.saveResources.progress', "Saving resources..."),330}, async report => {331for (const task of todo) {332await task();333report.report({ increment: 1, total: todo.length });334}335});336337if (workspaceContextService.isInsideWorkspace(thenReveal)) {338commandService.executeCommand(REVEAL_IN_EXPLORER_COMMAND_ID, thenReveal);339} else {340notificationService.info(localize('chat.saveResources.reveal', "Saved resources to {0}", labelService.getUriLabel(thenReveal)));341}342};343344if (context.parts.length === 1) {345const part = context.parts[0];346const uri = await fileDialog.pickFileToSave(joinPath(defaultFilepath, basename(part.uri)));347if (!uri) {348return;349}350await withProgress(uri, [() => savePart(part, false, uri)]);351} else {352const uris = await fileDialog.showOpenDialog({353title: localize('chat.saveResources.title', "Pick folder to save resources"),354canSelectFiles: false,355canSelectFolders: true,356canSelectMany: false,357defaultUri: workspaceContextService.getWorkspace().folders[0]?.uri,358});359360if (!uris?.length) {361return;362}363364await withProgress(uris[0], context.parts.map(part => () => savePart(part, true, uris[0])));365}366}367}368369registerAction2(SaveResourcesAction);370371372