From 50d370bd3be2a23651acf62206c2625f96e62eb6 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Wed, 9 Jul 2025 18:08:20 -0700 Subject: [PATCH 01/27] Add basic API for custom rendering inside of chat output First sketch for a simple API that lets extensions render content in chat using a webview Right now this is targeting results from tool calls but we could potentially extend this to work with a more generic version of our chat response image part --- .../common/extensionsApiProposals.ts | 3 + .../api/browser/extensionHost.contribution.ts | 1 + .../browser/mainThreadChatOutputRenderer.ts | 54 +++++++++++ .../api/browser/mainThreadWebviewManager.ts | 4 + .../api/browser/mainThreadWebviews.ts | 10 +- .../workbench/api/common/extHost.api.impl.ts | 6 ++ .../workbench/api/common/extHost.protocol.ts | 11 +++ .../api/common/extHostChatOutputRenderer.ts | 52 ++++++++++ .../api/common/extHostTypeConverters.ts | 33 +++++-- .../chat/browser/chatOutputItemRenderer.ts | 94 +++++++++++++++++++ .../chatProgressTypes/chatToolInvocation.ts | 12 +++ .../chat/common/languageModelToolsService.ts | 6 +- .../vscode.proposed.chatOutputRenderer.d.ts | 59 ++++++++++++ 13 files changed, 332 insertions(+), 13 deletions(-) create mode 100644 src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts create mode 100644 src/vs/workbench/api/common/extHostChatOutputRenderer.ts create mode 100644 src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts create mode 100644 src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 4592b9f0b96..94b00eded81 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -37,6 +37,9 @@ const _allApiProposals = { chatEditing: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatEditing.d.ts', }, + chatOutputRenderer: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts', + }, chatParticipantAdditions: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts', }, diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index d5430634469..23b72b11831 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -91,6 +91,7 @@ import './mainThreadAiEmbeddingVector.js'; import './mainThreadAiSettingsSearch.js'; import './mainThreadMcp.js'; import './mainThreadChatStatus.js'; +import './mainThreadChatOutputRenderer.js'; export class ExtensionPoints implements IWorkbenchContribution { diff --git a/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts b/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts new file mode 100644 index 00000000000..0ab2ac63466 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../base/common/buffer.js'; +import { Disposable, IDisposable } from '../../../base/common/lifecycle.js'; +import { IChatOutputItemRendererService } from '../../contrib/chat/browser/chatOutputItemRenderer.js'; +import { IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; +import { ExtHostChatOutputRendererShape, ExtHostContext, MainThreadChatOutputRendererShape } from '../common/extHost.protocol.js'; +import { MainThreadWebviews } from './mainThreadWebviews.js'; + +export class MainThreadChatOutputRenderer extends Disposable implements MainThreadChatOutputRendererShape { + + private readonly _proxy: ExtHostChatOutputRendererShape; + + private _webviewHandlePool = 0; + + private readonly registeredRenderers = new Map(); + + constructor( + extHostContext: IExtHostContext, + private readonly _mainThreadWebview: MainThreadWebviews, + @IChatOutputItemRendererService private readonly _rendererService: IChatOutputItemRendererService, + ) { + super(); + this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostChatOutputRenderer); + } + + override dispose(): void { + super.dispose(); + + this.registeredRenderers.forEach(disposable => disposable.dispose()); + this.registeredRenderers.clear(); + } + + $registerChatOutputRenderer(mime: string): void { + this._rendererService.registerRenderer(mime, { + renderOutputPart: async (mime, data, webview, token) => { + const webviewHandle = `chat-output-${++this._webviewHandlePool}`; + + this._mainThreadWebview.addWebview(webviewHandle, webview, { + serializeBuffersForPostMessage: true, + }); + + this._proxy.$renderChatPart(mime, VSBuffer.wrap(data), webviewHandle, token); + }, + }); + } + + $unregisterChatOutputRenderer(mime: string): void { + this.registeredRenderers.get(mime)?.dispose(); + } +} diff --git a/src/vs/workbench/api/browser/mainThreadWebviewManager.ts b/src/vs/workbench/api/browser/mainThreadWebviewManager.ts index 406b222a739..6e3da06ea67 100644 --- a/src/vs/workbench/api/browser/mainThreadWebviewManager.ts +++ b/src/vs/workbench/api/browser/mainThreadWebviewManager.ts @@ -11,6 +11,7 @@ import { MainThreadWebviews } from './mainThreadWebviews.js'; import { MainThreadWebviewsViews } from './mainThreadWebviewViews.js'; import * as extHostProtocol from '../common/extHost.protocol.js'; import { extHostCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; +import { MainThreadChatOutputRenderer } from './mainThreadChatOutputRenderer.js'; @extHostCustomer export class MainThreadWebviewManager extends Disposable { @@ -31,5 +32,8 @@ export class MainThreadWebviewManager extends Disposable { const webviewViews = this._register(instantiationService.createInstance(MainThreadWebviewsViews, context, webviews)); context.set(extHostProtocol.MainContext.MainThreadWebviewViews, webviewViews); + + const chatOutputRenderers = this._register(instantiationService.createInstance(MainThreadChatOutputRenderer, context, webviews)); + context.set(extHostProtocol.MainContext.MainThreadChatOutputRenderer, chatOutputRenderers); } } diff --git a/src/vs/workbench/api/browser/mainThreadWebviews.ts b/src/vs/workbench/api/browser/mainThreadWebviews.ts index ed37c0b468a..51fb615d502 100644 --- a/src/vs/workbench/api/browser/mainThreadWebviews.ts +++ b/src/vs/workbench/api/browser/mainThreadWebviews.ts @@ -13,11 +13,11 @@ import { localize } from '../../../nls.js'; import { ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; import { IOpenerService } from '../../../platform/opener/common/opener.js'; import { IProductService } from '../../../platform/product/common/productService.js'; -import * as extHostProtocol from '../common/extHost.protocol.js'; -import { deserializeWebviewMessage, serializeWebviewMessage } from '../common/extHostWebviewMessaging.js'; -import { IOverlayWebview, IWebview, WebviewContentOptions, WebviewExtensionDescription } from '../../contrib/webview/browser/webview.js'; +import { IWebview, WebviewContentOptions, WebviewExtensionDescription } from '../../contrib/webview/browser/webview.js'; import { IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { SerializableObjectWithBuffers } from '../../services/extensions/common/proxyIdentifier.js'; +import * as extHostProtocol from '../common/extHost.protocol.js'; +import { deserializeWebviewMessage, serializeWebviewMessage } from '../common/extHostWebviewMessaging.js'; export class MainThreadWebviews extends Disposable implements extHostProtocol.MainThreadWebviewsShape { @@ -43,7 +43,7 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma this._proxy = context.getProxy(extHostProtocol.ExtHostContext.ExtHostWebviews); } - public addWebview(handle: extHostProtocol.WebviewHandle, webview: IOverlayWebview, options: { serializeBuffersForPostMessage: boolean }): void { + public addWebview(handle: extHostProtocol.WebviewHandle, webview: IWebview, options: { serializeBuffersForPostMessage: boolean }): void { if (this._webviews.has(handle)) { throw new Error('Webview already registered'); } @@ -72,7 +72,7 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma return webview.postMessage(message, arrayBuffers); } - private hookupWebviewEventDelegate(handle: extHostProtocol.WebviewHandle, webview: IOverlayWebview, options: { serializeBuffersForPostMessage: boolean }) { + private hookupWebviewEventDelegate(handle: extHostProtocol.WebviewHandle, webview: IWebview, options: { serializeBuffersForPostMessage: boolean }) { const disposables = new DisposableStore(); disposables.add(webview.onDidClickLink((uri) => this.onDidClickLink(handle, uri))); diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 87fc94481a3..8cda58e08cc 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -111,6 +111,7 @@ import { ExtHostWebviewViews } from './extHostWebviewView.js'; import { IExtHostWindow } from './extHostWindow.js'; import { IExtHostWorkspace } from './extHostWorkspace.js'; import { ExtHostAiSettingsSearch } from './extHostAiSettingsSearch.js'; +import { ExtHostChatOutputRenderer } from './extHostChatOutputRenderer.js'; export interface IExtensionRegistries { mine: ExtensionDescriptionRegistry; @@ -216,6 +217,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostTesting = rpcProtocol.set(ExtHostContext.ExtHostTesting, accessor.get(IExtHostTesting)); const extHostUriOpeners = rpcProtocol.set(ExtHostContext.ExtHostUriOpeners, new ExtHostUriOpeners(rpcProtocol)); const extHostProfileContentHandlers = rpcProtocol.set(ExtHostContext.ExtHostProfileContentHandlers, new ExtHostProfileContentHandlers(rpcProtocol)); + const extHostChatOutputRenderer = rpcProtocol.set(ExtHostContext.ExtHostChatOutputRenderer, new ExtHostChatOutputRenderer(rpcProtocol, extHostWebviews)); rpcProtocol.set(ExtHostContext.ExtHostInteractive, new ExtHostInteractive(rpcProtocol, extHostNotebook, extHostDocumentsAndEditors, extHostCommands, extHostLogService)); const extHostLanguageModelTools = rpcProtocol.set(ExtHostContext.ExtHostLanguageModelTools, new ExtHostLanguageModelTools(rpcProtocol, extHostLanguageModels)); const extHostChatAgents2 = rpcProtocol.set(ExtHostContext.ExtHostChatAgents2, new ExtHostChatAgents2(rpcProtocol, extHostLogService, extHostCommands, extHostDocuments, extHostLanguageModels, extHostDiagnostics, extHostLanguageModelTools)); @@ -1482,6 +1484,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I onDidDisposeChatSession: (listeners, thisArgs?, disposables?) => { checkProposedApiEnabled(extension, 'chatParticipantPrivate'); return _asExtensionEvent(extHostChatAgents2.onDidDisposeChatSession)(listeners, thisArgs, disposables); + }, + registerChatOutputRenderer: (mime: string, renderer: vscode.ChatOutputRenderer) => { + checkProposedApiEnabled(extension, 'chatOutputRenderer'); + return extHostChatOutputRenderer.registerChatOutputRenderer(extension, mime, renderer); } }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 24c0472ee97..a5e17b9ac73 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1463,6 +1463,15 @@ export interface ExtHostUriOpenersShape { $openUri(id: string, context: { resolvedUri: UriComponents; sourceUri: UriComponents }, token: CancellationToken): Promise; } +export interface MainThreadChatOutputRendererShape extends IDisposable { + $registerChatOutputRenderer(mime: string): void; + $unregisterChatOutputRenderer(mime: string): void; +} + +export interface ExtHostChatOutputRendererShape { + $renderChatPart(mime: string, valueData: VSBuffer, webviewHandle: string, token: CancellationToken): Promise; +} + export interface MainThreadProfileContentHandlersShape { $registerProfileContentHandler(id: string, name: string, description: string | undefined, extensionId: string): Promise; $unregisterProfileContentHandler(id: string): Promise; @@ -3180,6 +3189,7 @@ export const MainContext = { MainThreadAiEmbeddingVector: createProxyIdentifier('MainThreadAiEmbeddingVector'), MainThreadChatStatus: createProxyIdentifier('MainThreadChatStatus'), MainThreadAiSettingsSearch: createProxyIdentifier('MainThreadAiSettingsSearch'), + MainThreadChatOutputRenderer: createProxyIdentifier('MainThreadChatOutputRenderer'), }; export const ExtHostContext = { @@ -3225,6 +3235,7 @@ export const ExtHostContext = { ExtHostStorage: createProxyIdentifier('ExtHostStorage'), ExtHostUrls: createProxyIdentifier('ExtHostUrls'), ExtHostUriOpeners: createProxyIdentifier('ExtHostUriOpeners'), + ExtHostChatOutputRenderer: createProxyIdentifier('ExtHostChatOutputRenderer'), ExtHostProfileContentHandlers: createProxyIdentifier('ExtHostProfileContentHandlers'), ExtHostOutputService: createProxyIdentifier('ExtHostOutputService'), ExtHostLabelService: createProxyIdentifier('ExtHostLabelService'), diff --git a/src/vs/workbench/api/common/extHostChatOutputRenderer.ts b/src/vs/workbench/api/common/extHostChatOutputRenderer.ts new file mode 100644 index 00000000000..a3212a97fa2 --- /dev/null +++ b/src/vs/workbench/api/common/extHostChatOutputRenderer.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { ExtHostChatOutputRendererShape, IMainContext, MainContext, MainThreadChatOutputRendererShape } from './extHost.protocol.js'; +import { Disposable } from './extHostTypes.js'; +import { ExtHostWebviews } from './extHostWebview.js'; +import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; + +export class ExtHostChatOutputRenderer implements ExtHostChatOutputRendererShape { + + private readonly _proxy: MainThreadChatOutputRendererShape; + + private readonly _renderers = new Map(); + + constructor( + mainContext: IMainContext, + private readonly webviews: ExtHostWebviews, + ) { + this._proxy = mainContext.getProxy(MainContext.MainThreadChatOutputRenderer); + } + + registerChatOutputRenderer(extension: IExtensionDescription, mime: string, renderer: vscode.ChatOutputRenderer): vscode.Disposable { + if (this._renderers.has(mime)) { + throw new Error(`Chat response output renderer already registered for mime type: ${mime}`); + } + + this._renderers.set(mime, { extension, renderer }); + this._proxy.$registerChatOutputRenderer(mime); + + return new Disposable(() => { + this._renderers.delete(mime); + this._proxy.$unregisterChatOutputRenderer(mime); + }); + } + + async $renderChatPart(mime: string, valueData: VSBuffer, webviewHandle: string, token: CancellationToken): Promise { + const entry = this._renderers.get(mime); + if (!entry) { + throw new Error(`No chat response output renderer registered for mime type: ${mime}`); + } + + const webview = this.webviews.createNewWebview(webviewHandle, {}, entry.extension); + + const part = Object.freeze({ mime, value: valueData.buffer }); + return entry.renderer.renderChatOutput(part, webview, token); + } +} diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index ccc7a33bfc8..33f44ae6334 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -42,7 +42,7 @@ import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/c import { IChatRequestDraft } from '../../contrib/chat/common/chatEditingService.js'; import { IChatRequestVariableEntry, isImageVariableEntry } from '../../contrib/chat/common/chatVariableEntries.js'; import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatFollowup, IChatMarkdownContent, IChatMoveMessage, IChatPrepareToolInvocationPart, IChatProgressMessage, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTextEdit, IChatTreeData, IChatUserActionEvent, IChatWarningMessage } from '../../contrib/chat/common/chatService.js'; -import { IToolData, IToolResult } from '../../contrib/chat/common/languageModelToolsService.js'; +import { IToolData, IToolResult, IToolResultInputOutputDetails, IToolResultOutputDetails } from '../../contrib/chat/common/languageModelToolsService.js'; import * as chatProvider from '../../contrib/chat/common/languageModels.js'; import { IChatMessageDataPart, IChatResponseDataPart, IChatResponsePromptTsxPart, IChatResponseTextPart } from '../../contrib/chat/common/languageModels.js'; import { DebugTreeItemCollapsibleState, IDebugVisualizationTreeItem } from '../../contrib/debug/common/debug.js'; @@ -3335,11 +3335,12 @@ export namespace LanguageModelToolResult2 { if (item.kind === 'text') { return new types.LanguageModelTextPart(item.value); } else if (item.kind === 'data') { - const mimeType = Object.values(types.ChatImageMimeType).includes(item.value.mimeType as types.ChatImageMimeType) ? item.value.mimeType as types.ChatImageMimeType : undefined; - if (!mimeType) { - throw new Error('Invalid MIME type'); - } - return new types.LanguageModelDataPart(item.value.data.buffer, mimeType); + // TODO: make sure we can remove this + // const mimeType = Object.values(types.ChatImageMimeType).includes(item.value.mimeType as types.ChatImageMimeType) ? item.value.mimeType as types.ChatImageMimeType : undefined; + // // if (!mimeType) { + // // throw new Error('Invalid MIME type'); + // // } + return new types.LanguageModelDataPart(item.value.data.buffer, item.value.mimeType); } else { return new types.LanguageModelPromptTsxPart(item.value); } @@ -3352,6 +3353,24 @@ export namespace LanguageModelToolResult2 { } let hasBuffers = false; + let detailsDto: Dto | IToolResultInputOutputDetails | IToolResultOutputDetails | undefined> = undefined; + if (Array.isArray(result.toolResultDetails)) { + detailsDto = result.toolResultDetails?.map(detail => { + return URI.isUri(detail) ? detail : Location.from(detail as vscode.Location); + }); + } else { + if (result.toolResultDetails) { + detailsDto = { + output: { + type: 'data', + mimeType: (result.toolResultDetails as { mime: string; value: Uint8Array }).mime, + value: VSBuffer.wrap((result.toolResultDetails as { mime: string; value: Uint8Array }).value), + } + } satisfies IToolResultOutputDetails; + hasBuffers = true; + } + } + const dto: Dto = { content: result.content.map(item => { if (item instanceof types.LanguageModelTextPart) { @@ -3378,7 +3397,7 @@ export namespace LanguageModelToolResult2 { } }), toolResultMessage: MarkdownString.fromStrict(result.toolResultMessage), - toolResultDetails: result.toolResultDetails?.map(detail => URI.isUri(detail) ? detail : Location.from(detail as vscode.Location)), + toolResultDetails: detailsDto, }; return hasBuffers ? new SerializableObjectWithBuffers(dto) : dto; diff --git a/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts new file mode 100644 index 00000000000..f00c357e11c --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getWindow } from '../../../../base/browser/dom.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IWebview, IWebviewService } from '../../../contrib/webview/browser/webview.js'; + +export interface IChatOutputItemRenderer { + renderOutputPart(mime: string, data: Uint8Array, webivew: IWebview, token: CancellationToken): Promise; +} + +export const IChatOutputItemRendererService = createDecorator('chatOutputItemRendererService'); + +/** + * Service for rendering chat output items with special MIME types using registered renderers from extensions. + */ +export interface IChatOutputItemRendererService { + readonly _serviceBrand: undefined; + + registerRenderer(mime: string, renderer: IChatOutputItemRenderer): IDisposable; + + renderOutputPart(mime: string, data: Uint8Array, parent: HTMLElement, token: CancellationToken): Promise; +} + +/** + * Implementation of the IChatOutputItemRendererService. + * This service connects with the MainThreadChatResponseOutputRenderer to render output parts + * in chat responses using extension-provided renderers. + */ +export class ChatOutputItemRendererService extends Disposable implements IChatOutputItemRendererService { + _serviceBrand: undefined; + + private readonly _renderers = new Map(); + + constructor( + @ILogService private readonly _logService: ILogService, + @IWebviewService private readonly _webviewService: IWebviewService, + ) { + super(); + this._logService.debug('ChatOutputItemRendererService: Created'); + } + + registerRenderer(mime: string, renderer: IChatOutputItemRenderer): IDisposable { + this._renderers.set(mime, renderer); + this._logService.debug(`ChatOutputItemRendererService: Registered renderer for MIME type ${mime}`); + return { + dispose: () => { + this._renderers.delete(mime); + } + }; + } + + async renderOutputPart(mime: string, data: Uint8Array, parent: HTMLElement, token: CancellationToken): Promise { + const renderer = this._renderers.get(mime); + if (!renderer) { + throw new Error(`No renderer registered for mime type: ${mime}`); + } + + const webview = this._webviewService.createWebviewElement({ + title: 'My fancy chat renderer', + origin: generateUuid(), + options: { + + }, + contentOptions: { + localResourceRoots: [], + allowScripts: true, + }, + extension: { id: new ExtensionIdentifier('xxx.yyy'), location: URI.file('/') } + }); + + parent.style = 'max-height: 80vh; width: 100%;'; + webview.mountTo(parent, getWindow(parent)); + + await renderer.renderOutputPart(mime, data, webview, token); + + return { + dispose: () => { } + }; + } +} + +// Register the service +registerSingleton(IChatOutputItemRendererService, ChatOutputItemRendererService, InstantiationType.Delayed); + diff --git a/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts b/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts index 10e6658cc4f..7d125840e4a 100644 --- a/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts +++ b/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts @@ -82,6 +82,18 @@ export class ChatToolInvocation implements IChatToolInvocation { } this._resultDetails = result?.toolResultDetails; + + // Hack to convert data part over + const data = result?.content.find((part) => part.kind === 'data'); + if (data) { + this._resultDetails = { + output: { + type: 'data', + mimeType: data.value.mimeType, + value: data.value.data, + } + }; + } this._isCompleteDeferred.complete(); } diff --git a/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts index f1df32784ce..7471fa3c945 100644 --- a/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts @@ -146,6 +146,10 @@ export interface IToolResultInputOutputDetails { readonly isError?: boolean; } +export interface IToolResultOutputDetails { + readonly output: { type: 'data'; mimeType: string; value: VSBuffer }; +} + export function isToolResultInputOutputDetails(obj: any): obj is IToolResultInputOutputDetails { return typeof obj === 'object' && typeof obj?.input === 'string' && (typeof obj?.output === 'string' || Array.isArray(obj?.output)); } @@ -153,7 +157,7 @@ export function isToolResultInputOutputDetails(obj: any): obj is IToolResultInpu export interface IToolResult { content: (IToolResultPromptTsxPart | IToolResultTextPart | IToolResultDataPart)[]; toolResultMessage?: string | IMarkdownString; - toolResultDetails?: Array | IToolResultInputOutputDetails; + toolResultDetails?: Array | IToolResultInputOutputDetails | IToolResultOutputDetails; toolResultError?: string; } diff --git a/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts b/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts new file mode 100644 index 00000000000..033f54b0128 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + /** + * Data returned from a tool. + * + * This is an opaque binary blob that can be rendered by a {@link ChatOutputRenderer}. + */ + export interface ToolResultDataOutput { + /** + * The MIME type of the data. + */ + mime: string; + + /** + * The contents of the data. + */ + value: Uint8Array; + } + + export interface ExtendedLanguageModelToolResult2 extends ExtendedLanguageModelToolResult { + // Temporary to allow `toolResultDetails` to return a ToolResultDataOutput + // TODO: Should we allow multiple per tool result? + toolResultDetails2?: Array | ToolResultDataOutput; + } + + export interface ChatOutputRenderer { + /** + * Given an output, render it into the provided webview. + * + * TODO: Should we make this more generic so that we could support inputs besides tool outputs? + * For example, a generic `ChatResponseDataPart` type. + * + * @param data The data to render. + * @param webview The webview to render the data into. + * @param token A cancellation token that is cancelled if we no longer care about the rendering before this + * call completes. + * + * @returns A promise that resolves when the rendering is complete. + */ + renderChatOutput(data: ToolResultDataOutput, webview: Webview, token: CancellationToken): Thenable; + } + + export namespace chat { + /** + * Registers a new renderer for a given mime type. + * + * TODO: needs contribution point so we know which mimes are available. + * + * @param mime The MIME type of the output that this renderer can handle. + * @param renderer The renderer to register. + */ + export function registerChatOutputRenderer(mime: string, renderer: ChatOutputRenderer): Disposable; + } +} From 487f3305196fba84af768ea9c2b03d775618a958 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Sat, 19 Jul 2025 15:06:38 -0700 Subject: [PATCH 02/27] Continue work --- .../browser/mainThreadChatOutputRenderer.ts | 12 ++- .../workbench/api/common/extHost.protocol.ts | 4 +- .../api/common/extHostChatOutputRenderer.ts | 17 ++-- .../contrib/chat/browser/chat.contribution.ts | 2 + .../chat/browser/chatOutputItemRenderer.ts | 88 ++++++++++++++----- .../contrib/webview/browser/webview.ts | 1 + .../vscode.proposed.chatOutputRenderer.d.ts | 17 +++- 7 files changed, 100 insertions(+), 41 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts b/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts index 0ab2ac63466..81565e37339 100644 --- a/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts +++ b/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts @@ -5,7 +5,9 @@ import { VSBuffer } from '../../../base/common/buffer.js'; import { Disposable, IDisposable } from '../../../base/common/lifecycle.js'; -import { IChatOutputItemRendererService } from '../../contrib/chat/browser/chatOutputItemRenderer.js'; +import { URI, UriComponents } from '../../../base/common/uri.js'; +import { ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; +import { IChatOutputRendererService } from '../../contrib/chat/browser/chatOutputItemRenderer.js'; import { IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { ExtHostChatOutputRendererShape, ExtHostContext, MainThreadChatOutputRendererShape } from '../common/extHost.protocol.js'; import { MainThreadWebviews } from './mainThreadWebviews.js'; @@ -21,7 +23,7 @@ export class MainThreadChatOutputRenderer extends Disposable implements MainThre constructor( extHostContext: IExtHostContext, private readonly _mainThreadWebview: MainThreadWebviews, - @IChatOutputItemRendererService private readonly _rendererService: IChatOutputItemRendererService, + @IChatOutputRendererService private readonly _rendererService: IChatOutputRendererService, ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostChatOutputRenderer); @@ -34,7 +36,7 @@ export class MainThreadChatOutputRenderer extends Disposable implements MainThre this.registeredRenderers.clear(); } - $registerChatOutputRenderer(mime: string): void { + $registerChatOutputRenderer(mime: string, extensionId: ExtensionIdentifier, extensionLocation: UriComponents): void { this._rendererService.registerRenderer(mime, { renderOutputPart: async (mime, data, webview, token) => { const webviewHandle = `chat-output-${++this._webviewHandlePool}`; @@ -43,8 +45,10 @@ export class MainThreadChatOutputRenderer extends Disposable implements MainThre serializeBuffersForPostMessage: true, }); - this._proxy.$renderChatPart(mime, VSBuffer.wrap(data), webviewHandle, token); + this._proxy.$renderChatOutput(mime, VSBuffer.wrap(data), webviewHandle, token); }, + }, { + extension: { id: extensionId, location: URI.revive(extensionLocation) } }); } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index d572c24a1cc..8732f5d13f7 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1453,12 +1453,12 @@ export interface ExtHostUriOpenersShape { } export interface MainThreadChatOutputRendererShape extends IDisposable { - $registerChatOutputRenderer(mime: string): void; + $registerChatOutputRenderer(mime: string, extensionId: ExtensionIdentifier, extensionLocation: UriComponents): void; $unregisterChatOutputRenderer(mime: string): void; } export interface ExtHostChatOutputRendererShape { - $renderChatPart(mime: string, valueData: VSBuffer, webviewHandle: string, token: CancellationToken): Promise; + $renderChatOutput(mime: string, valueData: VSBuffer, webviewHandle: string, token: CancellationToken): Promise; } export interface MainThreadProfileContentHandlersShape { diff --git a/src/vs/workbench/api/common/extHostChatOutputRenderer.ts b/src/vs/workbench/api/common/extHostChatOutputRenderer.ts index a3212a97fa2..31a5d5298df 100644 --- a/src/vs/workbench/api/common/extHostChatOutputRenderer.ts +++ b/src/vs/workbench/api/common/extHostChatOutputRenderer.ts @@ -15,7 +15,10 @@ export class ExtHostChatOutputRenderer implements ExtHostChatOutputRendererShape private readonly _proxy: MainThreadChatOutputRendererShape; - private readonly _renderers = new Map(); + private readonly _renderers = new Map(); constructor( mainContext: IMainContext, @@ -26,11 +29,11 @@ export class ExtHostChatOutputRenderer implements ExtHostChatOutputRendererShape registerChatOutputRenderer(extension: IExtensionDescription, mime: string, renderer: vscode.ChatOutputRenderer): vscode.Disposable { if (this._renderers.has(mime)) { - throw new Error(`Chat response output renderer already registered for mime type: ${mime}`); + throw new Error(`Chat output renderer already registered for mime type: ${mime}`); } this._renderers.set(mime, { extension, renderer }); - this._proxy.$registerChatOutputRenderer(mime); + this._proxy.$registerChatOutputRenderer(mime, extension.identifier, extension.extensionLocation); return new Disposable(() => { this._renderers.delete(mime); @@ -38,15 +41,13 @@ export class ExtHostChatOutputRenderer implements ExtHostChatOutputRendererShape }); } - async $renderChatPart(mime: string, valueData: VSBuffer, webviewHandle: string, token: CancellationToken): Promise { + async $renderChatOutput(mime: string, valueData: VSBuffer, webviewHandle: string, token: CancellationToken): Promise { const entry = this._renderers.get(mime); if (!entry) { - throw new Error(`No chat response output renderer registered for mime type: ${mime}`); + throw new Error(`No chat output renderer registered for mime type: ${mime}`); } const webview = this.webviews.createNewWebview(webviewHandle, {}, entry.extension); - - const part = Object.freeze({ mime, value: valueData.buffer }); - return entry.renderer.renderChatOutput(part, webview, token); + return entry.renderer.renderChatOutput(valueData.buffer, webview, token); } } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index d0687927aa2..f6b69cc382f 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -114,6 +114,7 @@ import { ChatDynamicVariableModel } from './contrib/chatDynamicVariables.js'; import { ChatAttachmentResolveService, IChatAttachmentResolveService } from './chatAttachmentResolveService.js'; import { registerLanguageModelActions } from './actions/chatLanguageModelActions.js'; import { PromptUrlHandler } from './promptSyntax/promptUrlHandler.js'; +import { ChatOutputRendererService, IChatOutputRendererService } from './chatOutputItemRenderer.js'; // Register configuration const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); @@ -782,6 +783,7 @@ registerSingleton(IPromptsService, PromptsService, InstantiationType.Delayed); registerSingleton(IChatContextPickService, ChatContextPickService, InstantiationType.Delayed); registerSingleton(IChatModeService, ChatModeService, InstantiationType.Delayed); registerSingleton(IChatAttachmentResolveService, ChatAttachmentResolveService, InstantiationType.Delayed); +registerSingleton(IChatOutputRendererService, ChatOutputRendererService, InstantiationType.Delayed); registerWorkbenchContribution2(ChatEditingNotebookFileSystemProviderContrib.ID, ChatEditingNotebookFileSystemProviderContrib, WorkbenchPhase.BlockStartup); diff --git a/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts index f00c357e11c..feafafb6683 100644 --- a/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts @@ -8,25 +8,34 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; +import * as nls from '../../../../nls.js'; import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; -import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { IWebview, IWebviewService } from '../../../contrib/webview/browser/webview.js'; +import { IWebview, IWebviewService, WebviewContentPurpose } from '../../../contrib/webview/browser/webview.js'; +import { IExtensionService } from '../../../services/extensions/common/extensions.js'; +import { ExtensionsRegistry } from '../../../services/extensions/common/extensionsRegistry.js'; export interface IChatOutputItemRenderer { - renderOutputPart(mime: string, data: Uint8Array, webivew: IWebview, token: CancellationToken): Promise; + renderOutputPart(mime: string, data: Uint8Array, webview: IWebview, token: CancellationToken): Promise; } -export const IChatOutputItemRendererService = createDecorator('chatOutputItemRendererService'); +export const IChatOutputRendererService = createDecorator('chatOutputRendererService'); + +interface RegisterOptions { + readonly extension?: { + readonly id: ExtensionIdentifier; + readonly location: URI; + }; +} /** * Service for rendering chat output items with special MIME types using registered renderers from extensions. */ -export interface IChatOutputItemRendererService { +export interface IChatOutputRendererService { readonly _serviceBrand: undefined; - registerRenderer(mime: string, renderer: IChatOutputItemRenderer): IDisposable; + registerRenderer(mime: string, renderer: IChatOutputItemRenderer, options: RegisterOptions): IDisposable; renderOutputPart(mime: string, data: Uint8Array, parent: HTMLElement, token: CancellationToken): Promise; } @@ -36,21 +45,25 @@ export interface IChatOutputItemRendererService { * This service connects with the MainThreadChatResponseOutputRenderer to render output parts * in chat responses using extension-provided renderers. */ -export class ChatOutputItemRendererService extends Disposable implements IChatOutputItemRendererService { +export class ChatOutputRendererService extends Disposable implements IChatOutputRendererService { _serviceBrand: undefined; - private readonly _renderers = new Map(); + private readonly _renderers = new Map(); constructor( @ILogService private readonly _logService: ILogService, @IWebviewService private readonly _webviewService: IWebviewService, + @IExtensionService private readonly _extensionService: IExtensionService, ) { super(); this._logService.debug('ChatOutputItemRendererService: Created'); } - registerRenderer(mime: string, renderer: IChatOutputItemRenderer): IDisposable { - this._renderers.set(mime, renderer); + registerRenderer(mime: string, renderer: IChatOutputItemRenderer, options: RegisterOptions): IDisposable { + this._renderers.set(mime, { renderer, options }); this._logService.debug(`ChatOutputItemRendererService: Registered renderer for MIME type ${mime}`); return { dispose: () => { @@ -60,35 +73,62 @@ export class ChatOutputItemRendererService extends Disposable implements IChatOu } async renderOutputPart(mime: string, data: Uint8Array, parent: HTMLElement, token: CancellationToken): Promise { - const renderer = this._renderers.get(mime); - if (!renderer) { + // Activate extensions that contribute to chatOutputRenderer for this mime type + await this._extensionService.activateByEvent(`onChatOutputRenderer:${mime}`); + + const rendererData = this._renderers.get(mime); + if (!rendererData) { throw new Error(`No renderer registered for mime type: ${mime}`); } const webview = this._webviewService.createWebviewElement({ - title: 'My fancy chat renderer', + title: '', origin: generateUuid(), options: { - + enableFindWidget: false, + purpose: WebviewContentPurpose.ChatOutputItem, + tryRestoreScrollPosition: false, }, - contentOptions: { - localResourceRoots: [], - allowScripts: true, - }, - extension: { id: new ExtensionIdentifier('xxx.yyy'), location: URI.file('/') } + contentOptions: {}, + extension: rendererData.options.extension ? rendererData.options.extension : undefined, }); - parent.style = 'max-height: 80vh; width: 100%;'; webview.mountTo(parent, getWindow(parent)); - await renderer.renderOutputPart(mime, data, webview, token); + await rendererData.renderer.renderOutputPart(mime, data, webview, token); return { - dispose: () => { } + dispose: () => { + webview.dispose(); + } }; } } -// Register the service -registerSingleton(IChatOutputItemRendererService, ChatOutputItemRendererService, InstantiationType.Delayed); +interface IChatOutputRendererContribution { + readonly mimeTypes: readonly string[]; +} + +ExtensionsRegistry.registerExtensionPoint({ + extensionPoint: 'chatOutputRenderer', + jsonSchema: { + description: nls.localize('vscode.extension.contributes.chatOutputRenderer', 'Contributes a renderer for specific MIME types in chat outputs'), + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['mimeTypes'], + properties: { + mimeTypes: { + description: nls.localize('chatOutputRenderer.mimeTypes', 'MIME types that this renderer can handle'), + type: 'array', + items: { + type: 'string' + } + } + } + } + } +}); + diff --git a/src/vs/workbench/contrib/webview/browser/webview.ts b/src/vs/workbench/contrib/webview/browser/webview.ts index 9f05edb89d6..346c1606b11 100644 --- a/src/vs/workbench/contrib/webview/browser/webview.ts +++ b/src/vs/workbench/contrib/webview/browser/webview.ts @@ -84,6 +84,7 @@ export const enum WebviewContentPurpose { NotebookRenderer = 'notebookRenderer', CustomEditor = 'customEditor', WebviewView = 'webviewView', + ChatOutputItem = 'chatOutputItem', } export type WebviewStyles = { readonly [key: string]: string | number }; diff --git a/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts b/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts index 033f54b0128..c251f01688b 100644 --- a/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts +++ b/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts @@ -40,16 +40,27 @@ declare module 'vscode' { * @param token A cancellation token that is cancelled if we no longer care about the rendering before this * call completes. * - * @returns A promise that resolves when the rendering is complete. + * @returns A promise that resolves when the webview has been initialized and is ready to be presented to the user. */ - renderChatOutput(data: ToolResultDataOutput, webview: Webview, token: CancellationToken): Thenable; + renderChatOutput(data: Uint8Array, webview: Webview, token: CancellationToken): Thenable; } export namespace chat { /** * Registers a new renderer for a given mime type. * - * TODO: needs contribution point so we know which mimes are available. + * Note: To use this API, you should also add a contribution point in your extension's + * package.json: + * + * ```json + * "contributes": { + * "chatOutputRenderer": [ + * { + * "mimeTypes": ["application/your-mime-type"] + * } + * ] + * } + * ``` * * @param mime The MIME type of the output that this renderer can handle. * @param renderer The renderer to register. From cd476de99a946d76e2f52f3c09f1b4efb3b66319 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Sat, 19 Jul 2025 15:30:58 -0700 Subject: [PATCH 03/27] Add simple test impl --- .../markdown-language-features/package.json | 12 ++++++ .../src/extension.ts | 39 ++++++++++++++++++ .../markdown-language-features/tsconfig.json | 4 +- .../chatToolInvocationPart.ts | 7 +++- .../toolInvocationParts/chatToolOutputPart.ts | 41 +++++++++++++++++++ .../chat/common/languageModelToolsService.ts | 4 ++ 6 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolOutputPart.ts diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 543d2e12719..6ac0b527a99 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -15,6 +15,11 @@ "categories": [ "Programming Languages" ], + "enabledApiProposals": [ + "chatParticipantAdditions", + "chatParticipantPrivate", + "chatOutputRenderer" + ], "activationEvents": [ "onLanguage:markdown", "onLanguage:prompt", @@ -35,6 +40,13 @@ } }, "contributes": { + "languageModelTools": [ + { + "name": "test-renderer", + "displayName": "Test renderer", + "modelDescription": "Renders test data to the output" + } + ], "notebookRenderer": [ { "id": "vscode.markdown-it-renderer", diff --git a/extensions/markdown-language-features/src/extension.ts b/extensions/markdown-language-features/src/extension.ts index 98ea87df069..773fb865664 100644 --- a/extensions/markdown-language-features/src/extension.ts +++ b/extensions/markdown-language-features/src/extension.ts @@ -24,6 +24,8 @@ export async function activate(context: vscode.ExtensionContext) { const client = await startServer(context, engine); context.subscriptions.push(client); activateShared(context, client, engine, logger, contributions); + + registerTestOutputRenderer(context); } function startServer(context: vscode.ExtensionContext, parser: IMdParser): Promise { @@ -54,3 +56,40 @@ function startServer(context: vscode.ExtensionContext, parser: IMdParser): Promi return new LanguageClient(id, name, serverOptions, clientOptions); }, parser); } + + +function registerTestOutputRenderer(context: vscode.ExtensionContext) { + vscode.lm.registerTool('test-renderer', { + invoke: (options, token) => { + const result = new vscode.ExtendedLanguageModelToolResult([]); + + (result as vscode.ExtendedLanguageModelToolResult2).toolResultDetails2 = { + mime: 'application/vnd.test-output', + value: new Uint8Array(Buffer.from('This is a test output rendered by the test renderer.')) + }; + + return result; + }, + }); + + vscode.chat.registerChatOutputRenderer('application/vnd.test-output', { + async renderChatOutput(data, webview, _token) { + const decodedData = new TextDecoder().decode(data); + + webview.html = ` + + + + + Document + + + ${decodedData} + +`; + + + }, + }); +} + diff --git a/extensions/markdown-language-features/tsconfig.json b/extensions/markdown-language-features/tsconfig.json index fcd79775de5..a121df54009 100644 --- a/extensions/markdown-language-features/tsconfig.json +++ b/extensions/markdown-language-features/tsconfig.json @@ -5,6 +5,8 @@ }, "include": [ "src/**/*", - "../../src/vscode-dts/vscode.d.ts" + "../../src/vscode-dts/vscode.d.ts", + "../../src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts", + "../../src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts" ] } diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts index 8262acf1355..3da14cfc14b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts @@ -11,7 +11,7 @@ import { IInstantiationService } from '../../../../../../platform/instantiation/ import { IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService.js'; import { IChatRendererContent } from '../../../common/chatViewModel.js'; import { CodeBlockModelCollection } from '../../../common/codeBlockModelCollection.js'; -import { isToolResultInputOutputDetails } from '../../../common/languageModelToolsService.js'; +import { isToolResultInputOutputDetails, isToolResultOutputDetails } from '../../../common/languageModelToolsService.js'; import { ChatTreeItem, IChatCodeBlockInfo } from '../../chat.js'; import { IChatContentPart, IChatContentPartRenderContext } from '../chatContentParts.js'; import { EditorPool } from '../chatMarkdownContentPart.js'; @@ -23,6 +23,7 @@ import { ChatTerminalMarkdownProgressPart } from './chatTerminalMarkdownProgress import { TerminalConfirmationWidgetSubPart } from './chatTerminalToolSubPart.js'; import { ToolConfirmationSubPart } from './chatToolConfirmationSubPart.js'; import { BaseChatToolInvocationSubPart } from './chatToolInvocationSubPart.js'; +import { ChatToolOutputSubPart } from './chatToolOutputPart.js'; import { ChatToolProgressSubPart } from './chatToolProgressPart.js'; export class ChatToolInvocationPart extends Disposable implements IChatContentPart { @@ -132,6 +133,10 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa ); } + if (isToolResultOutputDetails(this.toolInvocation.resultDetails)) { + return this.instantiationService.createInstance(ChatToolOutputSubPart, this.toolInvocation, this.toolInvocation.resultDetails, this.context); + } + return this.instantiationService.createInstance(ChatToolProgressSubPart, this.toolInvocation, this.context, this.renderer); } diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolOutputPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolOutputPart.ts new file mode 100644 index 00000000000..a6f8cabbfb8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolOutputPart.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../base/browser/dom.js'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService.js'; +import { IToolResultOutputDetails } from '../../../common/languageModelToolsService.js'; +import { IChatCodeBlockInfo } from '../../chat.js'; +import { IChatOutputRendererService } from '../../chatOutputItemRenderer.js'; +import { IChatContentPartRenderContext } from '../chatContentParts.js'; +import { BaseChatToolInvocationSubPart } from './chatToolInvocationSubPart.js'; + +export class ChatToolOutputSubPart extends BaseChatToolInvocationSubPart { + public readonly domNode: HTMLElement; + + public override readonly codeblocks: IChatCodeBlockInfo[] = []; + + constructor( + toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, + output: IToolResultOutputDetails, + _context: IChatContentPartRenderContext, + @IChatOutputRendererService private readonly chatOutputItemRendererService: IChatOutputRendererService, + ) { + super(toolInvocation); + + this.domNode = this.createOutputPart(output); + } + + private createOutputPart(detauls: IToolResultOutputDetails): HTMLElement { + const parent = dom.$('div.webview-output'); + parent.style.maxHeight = '80vh'; + + this.chatOutputItemRendererService.renderOutputPart(detauls.output.mimeType, detauls.output.value.buffer, parent, CancellationToken.None).then((disposable) => { + this._register(disposable); + this._onDidChangeHeight.fire(); + }); + return parent; + } +} diff --git a/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts index a2656dc9081..5abef30fdb2 100644 --- a/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts @@ -156,6 +156,10 @@ export function isToolResultInputOutputDetails(obj: any): obj is IToolResultInpu return typeof obj === 'object' && typeof obj?.input === 'string' && (typeof obj?.output === 'string' || Array.isArray(obj?.output)); } +export function isToolResultOutputDetails(obj: any): obj is IToolResultOutputDetails { + return typeof obj === 'object' && (typeof obj?.output === 'object'); +} + export interface IToolResult { content: (IToolResultPromptTsxPart | IToolResultTextPart | IToolResultDataPart)[]; toolResultMessage?: string | IMarkdownString; From c5543a9f2e9754d9a56f2ce84ff83fa94468988c Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Mon, 21 Jul 2025 10:47:58 -0700 Subject: [PATCH 04/27] End to end --- .../markdown-language-features/package.json | 24 +++++++++++--- .../src/extension.ts | 5 ++- .../api/common/extHostTypeConverters.ts | 8 ++--- .../chatToolInvocationPart.ts | 8 ++--- .../toolInvocationParts/chatToolOutputPart.ts | 30 ++++++++++++++---- .../chat/browser/chatOutputItemRenderer.ts | 31 ++++++++++++++----- .../chatProgressTypes/chatToolInvocation.ts | 19 +++--------- .../contrib/chat/common/chatService.ts | 10 ++++-- .../chat/common/languageModelToolsService.ts | 2 +- .../contrib/webview/browser/pre/index.html | 6 ++-- 10 files changed, 95 insertions(+), 48 deletions(-) diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 6ac0b527a99..3203d90da63 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -27,7 +27,8 @@ "onLanguage:chatmode", "onCommand:markdown.api.render", "onCommand:markdown.api.reloadPlugins", - "onWebviewPanel:markdown.preview" + "onWebviewPanel:markdown.preview", + "onChatOutputRenderer:application/vnd.test-output" ], "capabilities": { "virtualWorkspaces": true, @@ -42,9 +43,24 @@ "contributes": { "languageModelTools": [ { - "name": "test-renderer", - "displayName": "Test renderer", - "modelDescription": "Renders test data to the output" + "name": "renderMarkdown", + "displayName": "Markdown Renderer", + "toolReferenceName": "renderMarkdown", + "modelDescription": "Renders markdown to the output", + "userDescription": "Renders markdown to the output", + "canBeReferencedInPrompt": true, + "inputSchema": { + "type": "object", + "properties": { + "markdown": { + "type": "string", + "description": "The markdown content to render." + } + }, + "required": [ + "markdown" + ] + } } ], "notebookRenderer": [ diff --git a/extensions/markdown-language-features/src/extension.ts b/extensions/markdown-language-features/src/extension.ts index 773fb865664..45ad8044c37 100644 --- a/extensions/markdown-language-features/src/extension.ts +++ b/extensions/markdown-language-features/src/extension.ts @@ -18,14 +18,13 @@ export async function activate(context: vscode.ExtensionContext) { const logger = new VsCodeOutputLogger(); context.subscriptions.push(logger); - const engine = new MarkdownItEngine(contributions, githubSlugifier, logger); + registerTestOutputRenderer(context); const client = await startServer(context, engine); context.subscriptions.push(client); activateShared(context, client, engine, logger, contributions); - registerTestOutputRenderer(context); } function startServer(context: vscode.ExtensionContext, parser: IMdParser): Promise { @@ -59,7 +58,7 @@ function startServer(context: vscode.ExtensionContext, parser: IMdParser): Promi function registerTestOutputRenderer(context: vscode.ExtensionContext) { - vscode.lm.registerTool('test-renderer', { + vscode.lm.registerTool('renderMarkdown', { invoke: (options, token) => { const result = new vscode.ExtendedLanguageModelToolResult([]); diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 0365d6b3734..ceff29d3ceb 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -3347,7 +3347,7 @@ export namespace LanguageModelToolResult2 { })); } - export function from(result: vscode.ExtendedLanguageModelToolResult, extension: IExtensionDescription): Dto | SerializableObjectWithBuffers> { + export function from(result: vscode.ExtendedLanguageModelToolResult2, extension: IExtensionDescription): Dto | SerializableObjectWithBuffers> { if (result.toolResultMessage) { checkProposedApiEnabled(extension, 'chatParticipantPrivate'); } @@ -3359,12 +3359,12 @@ export namespace LanguageModelToolResult2 { return URI.isUri(detail) ? detail : Location.from(detail as vscode.Location); }); } else { - if (result.toolResultDetails) { + if (result.toolResultDetails2) { detailsDto = { output: { type: 'data', - mimeType: (result.toolResultDetails as { mime: string; value: Uint8Array }).mime, - value: VSBuffer.wrap((result.toolResultDetails as { mime: string; value: Uint8Array }).value), + mimeType: (result.toolResultDetails2 as vscode.ToolResultDataOutput).mime, + value: VSBuffer.wrap((result.toolResultDetails2 as vscode.ToolResultDataOutput).value), } } satisfies IToolResultOutputDetails; hasBuffers = true; diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts index 3da14cfc14b..62c1878d3ed 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts @@ -101,6 +101,10 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa return this.instantiationService.createInstance(ChatResultListSubPart, this.toolInvocation, this.context, this.toolInvocation.pastTenseMessage ?? this.toolInvocation.invocationMessage, this.toolInvocation.resultDetails, this.listPool); } + if (isToolResultOutputDetails(this.toolInvocation.resultDetails)) { + return this.instantiationService.createInstance(ChatToolOutputSubPart, this.toolInvocation, this.context); + } + if (isToolResultInputOutputDetails(this.toolInvocation.resultDetails)) { return this.instantiationService.createInstance( ChatInputOutputMarkdownProgressPart, @@ -133,10 +137,6 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa ); } - if (isToolResultOutputDetails(this.toolInvocation.resultDetails)) { - return this.instantiationService.createInstance(ChatToolOutputSubPart, this.toolInvocation, this.toolInvocation.resultDetails, this.context); - } - return this.instantiationService.createInstance(ChatToolProgressSubPart, this.toolInvocation, this.context, this.renderer); } diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolOutputPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolOutputPart.ts index a6f8cabbfb8..2a0964e37af 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolOutputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolOutputPart.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../../base/browser/dom.js'; +import { decodeBase64 } from '../../../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService.js'; +import { IChatToolInvocation, IChatToolInvocationSerialized, IToolResultOutputDetailsSerialized } from '../../../common/chatService.js'; import { IToolResultOutputDetails } from '../../../common/languageModelToolsService.js'; import { IChatCodeBlockInfo } from '../../chat.js'; import { IChatOutputRendererService } from '../../chatOutputItemRenderer.js'; @@ -19,23 +20,40 @@ export class ChatToolOutputSubPart extends BaseChatToolInvocationSubPart { constructor( toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, - output: IToolResultOutputDetails, _context: IChatContentPartRenderContext, @IChatOutputRendererService private readonly chatOutputItemRendererService: IChatOutputRendererService, ) { super(toolInvocation); - this.domNode = this.createOutputPart(output); + + const details: IToolResultOutputDetails = toolInvocation.kind === 'toolInvocation' + ? toolInvocation.resultDetails as IToolResultOutputDetails + : { + output: { + type: 'data', + mimeType: (toolInvocation.resultDetails as IToolResultOutputDetailsSerialized).mimeType, + value: decodeBase64((toolInvocation.resultDetails as IToolResultOutputDetailsSerialized).base64Data), + }, + }; + + this.domNode = this.createOutputPart(details); } - private createOutputPart(detauls: IToolResultOutputDetails): HTMLElement { + private createOutputPart(details: IToolResultOutputDetails): HTMLElement { + // TODO: Show progress while rendering + const parent = dom.$('div.webview-output'); parent.style.maxHeight = '80vh'; - this.chatOutputItemRendererService.renderOutputPart(detauls.output.mimeType, detauls.output.value.buffer, parent, CancellationToken.None).then((disposable) => { - this._register(disposable); + this.chatOutputItemRendererService.renderOutputPart(details.output.mimeType, details.output.value.buffer, parent, CancellationToken.None).then((renderedItem) => { + this._register(renderedItem); + this._onDidChangeHeight.fire(); + this._register(renderedItem.onDidChangeHeight(() => { + this._onDidChangeHeight.fire(); + })); }); + return parent; } } diff --git a/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts index feafafb6683..9a2d449af49 100644 --- a/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts @@ -5,7 +5,9 @@ import { getWindow } from '../../../../base/browser/dom.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import * as nls from '../../../../nls.js'; @@ -29,6 +31,10 @@ interface RegisterOptions { }; } +export interface RenderedOutputPart extends IDisposable { + readonly onDidChangeHeight: Event; +} + /** * Service for rendering chat output items with special MIME types using registered renderers from extensions. */ @@ -37,7 +43,7 @@ export interface IChatOutputRendererService { registerRenderer(mime: string, renderer: IChatOutputItemRenderer, options: RegisterOptions): IDisposable; - renderOutputPart(mime: string, data: Uint8Array, parent: HTMLElement, token: CancellationToken): Promise; + renderOutputPart(mime: string, data: Uint8Array, parent: HTMLElement, token: CancellationToken): Promise; } /** @@ -72,7 +78,7 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput }; } - async renderOutputPart(mime: string, data: Uint8Array, parent: HTMLElement, token: CancellationToken): Promise { + async renderOutputPart(mime: string, data: Uint8Array, parent: HTMLElement, token: CancellationToken): Promise { // Activate extensions that contribute to chatOutputRenderer for this mime type await this._extensionService.activateByEvent(`onChatOutputRenderer:${mime}`); @@ -81,7 +87,9 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput throw new Error(`No renderer registered for mime type: ${mime}`); } - const webview = this._webviewService.createWebviewElement({ + const store = new DisposableStore(); + + const webview = store.add(this._webviewService.createWebviewElement({ title: '', origin: generateUuid(), options: { @@ -91,15 +99,24 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput }, contentOptions: {}, extension: rendererData.options.extension ? rendererData.options.extension : undefined, - }); + })); + + const onDidChangeHeight = store.add(new Emitter()); + store.add(autorun(reader => { + const height = reader.readObservable(webview.intrinsicContentSize); + if (height) { + onDidChangeHeight.fire(height.height); + parent.style.height = `${height.height}px`; + } + })); webview.mountTo(parent, getWindow(parent)); - await rendererData.renderer.renderOutputPart(mime, data, webview, token); return { + onDidChangeHeight: onDidChangeHeight.event, dispose: () => { - webview.dispose(); + store.dispose(); } }; } diff --git a/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts b/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts index 69fda1c561f..58af55ad3e1 100644 --- a/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts +++ b/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts @@ -4,11 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { DeferredPromise } from '../../../../../base/common/async.js'; +import { encodeBase64 } from '../../../../../base/common/buffer.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { localize } from '../../../../../nls.js'; import { IChatExtensionsContent, IChatTerminalToolInvocationData, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, type IChatTerminalToolInvocationData2 } from '../chatService.js'; -import { IPreparedToolInvocation, IToolConfirmationMessages, IToolData, IToolProgressStep, IToolResult } from '../languageModelToolsService.js'; +import { IPreparedToolInvocation, isToolResultOutputDetails, IToolConfirmationMessages, IToolData, IToolProgressStep, IToolResult } from '../languageModelToolsService.js'; export class ChatToolInvocation implements IChatToolInvocation { public readonly kind: 'toolInvocation' = 'toolInvocation'; @@ -82,18 +83,6 @@ export class ChatToolInvocation implements IChatToolInvocation { } this._resultDetails = result?.toolResultDetails; - - // Hack to convert data part over - const data = result?.content.find((part) => part.kind === 'data'); - if (data) { - this._resultDetails = { - output: { - type: 'data', - mimeType: data.value.mimeType, - value: data.value.data, - } - }; - } this._isCompleteDeferred.complete(); } @@ -118,7 +107,9 @@ export class ChatToolInvocation implements IChatToolInvocation { originMessage: this.originMessage, isConfirmed: this._isConfirmed, isComplete: this._isComplete, - resultDetails: this._resultDetails, + resultDetails: isToolResultOutputDetails(this._resultDetails) + ? { type: 'data', mimeType: this._resultDetails.output.mimeType, base64Data: encodeBase64(this._resultDetails.output.value) } + : this._resultDetails, toolSpecificData: this.toolSpecificData, toolCallId: this.toolCallId, toolId: this.toolId, diff --git a/src/vs/workbench/contrib/chat/common/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService.ts index bbb3294fa7d..dddf3eeb88f 100644 --- a/src/vs/workbench/contrib/chat/common/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService.ts @@ -24,7 +24,7 @@ import { IChatParserContext } from './chatRequestParser.js'; import { IChatRequestVariableEntry } from './chatVariableEntries.js'; import { IChatRequestVariableValue } from './chatVariables.js'; import { ChatAgentLocation, ChatModeKind } from './constants.js'; -import { IPreparedToolInvocation, IToolConfirmationMessages, IToolResult } from './languageModelToolsService.js'; +import { IPreparedToolInvocation, IToolConfirmationMessages, IToolResult, IToolResultInputOutputDetails } from './languageModelToolsService.js'; export interface IChatRequest { message: string; @@ -281,6 +281,12 @@ export interface IChatToolInvocation { kind: 'toolInvocation'; } +export interface IToolResultOutputDetailsSerialized { + type: 'data'; + mimeType: string; + base64Data: string; +} + /** * This is a IChatToolInvocation that has been serialized, like after window reload, so it is no longer an active tool invocation. */ @@ -290,7 +296,7 @@ export interface IChatToolInvocationSerialized { invocationMessage: string | IMarkdownString; originMessage: string | IMarkdownString | undefined; pastTenseMessage: string | IMarkdownString | undefined; - resultDetails: IToolResult['toolResultDetails']; + resultDetails?: Array | IToolResultInputOutputDetails | IToolResultOutputDetailsSerialized; isConfirmed: boolean | undefined; isComplete: boolean; toolCallId: string; diff --git a/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts index 5abef30fdb2..1434e724055 100644 --- a/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts @@ -157,7 +157,7 @@ export function isToolResultInputOutputDetails(obj: any): obj is IToolResultInpu } export function isToolResultOutputDetails(obj: any): obj is IToolResultOutputDetails { - return typeof obj === 'object' && (typeof obj?.output === 'object'); + return typeof obj === 'object' && typeof obj?.mimeType === 'string' && obj?.type === 'data'; } export interface IToolResult { diff --git a/src/vs/workbench/contrib/webview/browser/pre/index.html b/src/vs/workbench/contrib/webview/browser/pre/index.html index d7bf59e4e7f..6e113ec7f1b 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/index.html +++ b/src/vs/workbench/contrib/webview/browser/pre/index.html @@ -5,7 +5,7 @@ + content="default-src 'none'; script-src 'sha256-qzQMf4WjRXHohkk4Hg1T0LJIElTDtjITLXbR/RuGA/Q=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> { hostMessaging.postMessage('updated-intrinsic-content-size', { - width: docEl.scrollWidth, - height: docEl.scrollHeight + width: docEl.offsetWidth, + height: docEl.offsetHeight }); }; From 07e43ac95599d11ada7b7905777bfdc85922e456 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Mon, 21 Jul 2025 11:34:55 -0700 Subject: [PATCH 05/27] Update test provider --- extensions/markdown-language-features/src/extension.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/markdown-language-features/src/extension.ts b/extensions/markdown-language-features/src/extension.ts index 45ad8044c37..11f2d9b4566 100644 --- a/extensions/markdown-language-features/src/extension.ts +++ b/extensions/markdown-language-features/src/extension.ts @@ -19,7 +19,7 @@ export async function activate(context: vscode.ExtensionContext) { const logger = new VsCodeOutputLogger(); context.subscriptions.push(logger); const engine = new MarkdownItEngine(contributions, githubSlugifier, logger); - registerTestOutputRenderer(context); + registerTestOutputRenderer(); const client = await startServer(context, engine); context.subscriptions.push(client); @@ -57,9 +57,9 @@ function startServer(context: vscode.ExtensionContext, parser: IMdParser): Promi } -function registerTestOutputRenderer(context: vscode.ExtensionContext) { +function registerTestOutputRenderer() { vscode.lm.registerTool('renderMarkdown', { - invoke: (options, token) => { + invoke: (_options, _token) => { const result = new vscode.ExtendedLanguageModelToolResult([]); (result as vscode.ExtendedLanguageModelToolResult2).toolResultDetails2 = { From 68899887dd94cec95fd457e9103bdc4ff38c97b7 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Mon, 21 Jul 2025 13:39:27 -0700 Subject: [PATCH 06/27] More fixes --- .../src/extension.ts | 13 ++++++++++++ .../toolInvocationParts/chatToolOutputPart.ts | 20 ++++++++++++++----- .../chatProgressTypes/chatToolInvocation.ts | 2 +- .../contrib/chat/common/chatService.ts | 8 +++++--- .../chat/common/languageModelToolsService.ts | 2 +- 5 files changed, 35 insertions(+), 10 deletions(-) diff --git a/extensions/markdown-language-features/src/extension.ts b/extensions/markdown-language-features/src/extension.ts index 11f2d9b4566..1e20d6e0316 100644 --- a/extensions/markdown-language-features/src/extension.ts +++ b/extensions/markdown-language-features/src/extension.ts @@ -75,6 +75,10 @@ function registerTestOutputRenderer() { async renderChatOutput(data, webview, _token) { const decodedData = new TextDecoder().decode(data); + webview.options = { + enableScripts: true, + }; + webview.html = ` @@ -84,6 +88,15 @@ function registerTestOutputRenderer() { ${decodedData} + + `; diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolOutputPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolOutputPart.ts index 2a0964e37af..56c98d223bd 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolOutputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolOutputPart.ts @@ -6,11 +6,16 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { decodeBase64 } from '../../../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { localize } from '../../../../../../nls.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IChatToolInvocation, IChatToolInvocationSerialized, IToolResultOutputDetailsSerialized } from '../../../common/chatService.js'; import { IToolResultOutputDetails } from '../../../common/languageModelToolsService.js'; import { IChatCodeBlockInfo } from '../../chat.js'; import { IChatOutputRendererService } from '../../chatOutputItemRenderer.js'; import { IChatContentPartRenderContext } from '../chatContentParts.js'; +import { ChatCustomProgressPart } from '../chatProgressContentPart.js'; import { BaseChatToolInvocationSubPart } from './chatToolInvocationSubPart.js'; export class ChatToolOutputSubPart extends BaseChatToolInvocationSubPart { @@ -22,17 +27,17 @@ export class ChatToolOutputSubPart extends BaseChatToolInvocationSubPart { toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, _context: IChatContentPartRenderContext, @IChatOutputRendererService private readonly chatOutputItemRendererService: IChatOutputRendererService, + @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(toolInvocation); - const details: IToolResultOutputDetails = toolInvocation.kind === 'toolInvocation' ? toolInvocation.resultDetails as IToolResultOutputDetails : { output: { type: 'data', - mimeType: (toolInvocation.resultDetails as IToolResultOutputDetailsSerialized).mimeType, - value: decodeBase64((toolInvocation.resultDetails as IToolResultOutputDetailsSerialized).base64Data), + mimeType: (toolInvocation.resultDetails as IToolResultOutputDetailsSerialized).output.mimeType, + value: decodeBase64((toolInvocation.resultDetails as IToolResultOutputDetailsSerialized).output.base64Data), }, }; @@ -40,14 +45,19 @@ export class ChatToolOutputSubPart extends BaseChatToolInvocationSubPart { } private createOutputPart(details: IToolResultOutputDetails): HTMLElement { - // TODO: Show progress while rendering - const parent = dom.$('div.webview-output'); parent.style.maxHeight = '80vh'; + const progressMessage = dom.$('span'); + progressMessage.textContent = localize('loading', 'Rendering tool output...'); + const progressPart = this.instantiationService.createInstance(ChatCustomProgressPart, progressMessage, ThemeIcon.modify(Codicon.loading, 'spin')); + parent.appendChild(progressPart.domNode); + this.chatOutputItemRendererService.renderOutputPart(details.output.mimeType, details.output.value.buffer, parent, CancellationToken.None).then((renderedItem) => { this._register(renderedItem); + progressPart.domNode.remove(); + this._onDidChangeHeight.fire(); this._register(renderedItem.onDidChangeHeight(() => { this._onDidChangeHeight.fire(); diff --git a/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts b/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts index 58af55ad3e1..82a3c0ef0be 100644 --- a/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts +++ b/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts @@ -108,7 +108,7 @@ export class ChatToolInvocation implements IChatToolInvocation { isConfirmed: this._isConfirmed, isComplete: this._isComplete, resultDetails: isToolResultOutputDetails(this._resultDetails) - ? { type: 'data', mimeType: this._resultDetails.output.mimeType, base64Data: encodeBase64(this._resultDetails.output.value) } + ? { output: { type: 'data', mimeType: this._resultDetails.output.mimeType, base64Data: encodeBase64(this._resultDetails.output.value) } } : this._resultDetails, toolSpecificData: this.toolSpecificData, toolCallId: this.toolCallId, diff --git a/src/vs/workbench/contrib/chat/common/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService.ts index dddf3eeb88f..d711a18b486 100644 --- a/src/vs/workbench/contrib/chat/common/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService.ts @@ -282,9 +282,11 @@ export interface IChatToolInvocation { } export interface IToolResultOutputDetailsSerialized { - type: 'data'; - mimeType: string; - base64Data: string; + output: { + type: 'data'; + mimeType: string; + base64Data: string; + }; } /** diff --git a/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts index 1434e724055..c849686b58f 100644 --- a/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts @@ -157,7 +157,7 @@ export function isToolResultInputOutputDetails(obj: any): obj is IToolResultInpu } export function isToolResultOutputDetails(obj: any): obj is IToolResultOutputDetails { - return typeof obj === 'object' && typeof obj?.mimeType === 'string' && obj?.type === 'data'; + return typeof obj === 'object' && typeof obj?.output === 'object' && typeof obj?.output?.mimeType === 'string' && obj?.output?.type === 'data'; } export interface IToolResult { From cc770538bd0874fca760946c11f3148c38083280 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Mon, 21 Jul 2025 13:46:32 -0700 Subject: [PATCH 07/27] Revert test renderer --- .../markdown-language-features/package.json | 30 +---------- .../src/extension.ts | 52 ------------------- .../markdown-language-features/tsconfig.json | 4 +- 3 files changed, 2 insertions(+), 84 deletions(-) diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 3203d90da63..543d2e12719 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -15,11 +15,6 @@ "categories": [ "Programming Languages" ], - "enabledApiProposals": [ - "chatParticipantAdditions", - "chatParticipantPrivate", - "chatOutputRenderer" - ], "activationEvents": [ "onLanguage:markdown", "onLanguage:prompt", @@ -27,8 +22,7 @@ "onLanguage:chatmode", "onCommand:markdown.api.render", "onCommand:markdown.api.reloadPlugins", - "onWebviewPanel:markdown.preview", - "onChatOutputRenderer:application/vnd.test-output" + "onWebviewPanel:markdown.preview" ], "capabilities": { "virtualWorkspaces": true, @@ -41,28 +35,6 @@ } }, "contributes": { - "languageModelTools": [ - { - "name": "renderMarkdown", - "displayName": "Markdown Renderer", - "toolReferenceName": "renderMarkdown", - "modelDescription": "Renders markdown to the output", - "userDescription": "Renders markdown to the output", - "canBeReferencedInPrompt": true, - "inputSchema": { - "type": "object", - "properties": { - "markdown": { - "type": "string", - "description": "The markdown content to render." - } - }, - "required": [ - "markdown" - ] - } - } - ], "notebookRenderer": [ { "id": "vscode.markdown-it-renderer", diff --git a/extensions/markdown-language-features/src/extension.ts b/extensions/markdown-language-features/src/extension.ts index 1e20d6e0316..3c5eaeb539c 100644 --- a/extensions/markdown-language-features/src/extension.ts +++ b/extensions/markdown-language-features/src/extension.ts @@ -19,12 +19,10 @@ export async function activate(context: vscode.ExtensionContext) { const logger = new VsCodeOutputLogger(); context.subscriptions.push(logger); const engine = new MarkdownItEngine(contributions, githubSlugifier, logger); - registerTestOutputRenderer(); const client = await startServer(context, engine); context.subscriptions.push(client); activateShared(context, client, engine, logger, contributions); - } function startServer(context: vscode.ExtensionContext, parser: IMdParser): Promise { @@ -55,53 +53,3 @@ function startServer(context: vscode.ExtensionContext, parser: IMdParser): Promi return new LanguageClient(id, name, serverOptions, clientOptions); }, parser); } - - -function registerTestOutputRenderer() { - vscode.lm.registerTool('renderMarkdown', { - invoke: (_options, _token) => { - const result = new vscode.ExtendedLanguageModelToolResult([]); - - (result as vscode.ExtendedLanguageModelToolResult2).toolResultDetails2 = { - mime: 'application/vnd.test-output', - value: new Uint8Array(Buffer.from('This is a test output rendered by the test renderer.')) - }; - - return result; - }, - }); - - vscode.chat.registerChatOutputRenderer('application/vnd.test-output', { - async renderChatOutput(data, webview, _token) { - const decodedData = new TextDecoder().decode(data); - - webview.options = { - enableScripts: true, - }; - - webview.html = ` - - - - - Document - - - ${decodedData} - - - -`; - - - }, - }); -} - diff --git a/extensions/markdown-language-features/tsconfig.json b/extensions/markdown-language-features/tsconfig.json index a121df54009..fcd79775de5 100644 --- a/extensions/markdown-language-features/tsconfig.json +++ b/extensions/markdown-language-features/tsconfig.json @@ -5,8 +5,6 @@ }, "include": [ "src/**/*", - "../../src/vscode-dts/vscode.d.ts", - "../../src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts", - "../../src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts" + "../../src/vscode-dts/vscode.d.ts" ] } From 90a8b4930a510222d15d3e7c62e130e1969694e5 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Mon, 21 Jul 2025 13:58:09 -0700 Subject: [PATCH 08/27] Cleanup --- .../markdown-language-features/src/extension.ts | 1 + .../contrib/chat/browser/chatOutputItemRenderer.ts | 8 -------- .../vscode.proposed.chatOutputRenderer.d.ts | 13 ++----------- 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/extensions/markdown-language-features/src/extension.ts b/extensions/markdown-language-features/src/extension.ts index 3c5eaeb539c..98ea87df069 100644 --- a/extensions/markdown-language-features/src/extension.ts +++ b/extensions/markdown-language-features/src/extension.ts @@ -18,6 +18,7 @@ export async function activate(context: vscode.ExtensionContext) { const logger = new VsCodeOutputLogger(); context.subscriptions.push(logger); + const engine = new MarkdownItEngine(contributions, githubSlugifier, logger); const client = await startServer(context, engine); diff --git a/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts index 9a2d449af49..e2a3ade25bb 100644 --- a/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts @@ -35,9 +35,6 @@ export interface RenderedOutputPart extends IDisposable { readonly onDidChangeHeight: Event; } -/** - * Service for rendering chat output items with special MIME types using registered renderers from extensions. - */ export interface IChatOutputRendererService { readonly _serviceBrand: undefined; @@ -46,11 +43,6 @@ export interface IChatOutputRendererService { renderOutputPart(mime: string, data: Uint8Array, parent: HTMLElement, token: CancellationToken): Promise; } -/** - * Implementation of the IChatOutputItemRendererService. - * This service connects with the MainThreadChatResponseOutputRenderer to render output parts - * in chat responses using extension-provided renderers. - */ export class ChatOutputRendererService extends Disposable implements IChatOutputRendererService { _serviceBrand: undefined; diff --git a/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts b/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts index c251f01688b..ea26688260c 100644 --- a/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts +++ b/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts @@ -49,18 +49,9 @@ declare module 'vscode' { /** * Registers a new renderer for a given mime type. * - * Note: To use this API, you should also add a contribution point in your extension's - * package.json: + * Note: make sure to use the `onChatOutputRenderer:mime` activation event in your extension's `package.json` to ensure that the renderer is registered. * - * ```json - * "contributes": { - * "chatOutputRenderer": [ - * { - * "mimeTypes": ["application/your-mime-type"] - * } - * ] - * } - * ``` + * TODO:should this be a contribution instead? * * @param mime The MIME type of the output that this renderer can handle. * @param renderer The renderer to register. From 69aae036639f9c9f1adfc0e33aaa0f314491059b Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Mon, 21 Jul 2025 14:04:24 -0700 Subject: [PATCH 09/27] Generate activation event automatically --- .../contrib/chat/browser/chatOutputItemRenderer.ts | 13 +++++++------ .../vscode.proposed.chatOutputRenderer.d.ts | 13 +++++++++++-- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts index e2a3ade25bb..abb202db350 100644 --- a/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts @@ -13,7 +13,6 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import * as nls from '../../../../nls.js'; import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; import { IWebview, IWebviewService, WebviewContentPurpose } from '../../../contrib/webview/browser/webview.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { ExtensionsRegistry } from '../../../services/extensions/common/extensionsRegistry.js'; @@ -52,17 +51,14 @@ export class ChatOutputRendererService extends Disposable implements IChatOutput }>(); constructor( - @ILogService private readonly _logService: ILogService, @IWebviewService private readonly _webviewService: IWebviewService, @IExtensionService private readonly _extensionService: IExtensionService, ) { super(); - this._logService.debug('ChatOutputItemRendererService: Created'); } registerRenderer(mime: string, renderer: IChatOutputItemRenderer, options: RegisterOptions): IDisposable { this._renderers.set(mime, { renderer, options }); - this._logService.debug(`ChatOutputItemRendererService: Registered renderer for MIME type ${mime}`); return { dispose: () => { this._renderers.delete(mime); @@ -120,6 +116,13 @@ interface IChatOutputRendererContribution { ExtensionsRegistry.registerExtensionPoint({ extensionPoint: 'chatOutputRenderer', + activationEventsGenerator: (contributions: IChatOutputRendererContribution[], result) => { + for (const contrib of contributions) { + for (const mime of contrib.mimeTypes) { + result.push(`onChatOutputRenderer:${mime}`); + } + } + }, jsonSchema: { description: nls.localize('vscode.extension.contributes.chatOutputRenderer', 'Contributes a renderer for specific MIME types in chat outputs'), type: 'array', @@ -139,5 +142,3 @@ ExtensionsRegistry.registerExtensionPoint({ } } }); - - diff --git a/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts b/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts index ea26688260c..c251f01688b 100644 --- a/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts +++ b/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts @@ -49,9 +49,18 @@ declare module 'vscode' { /** * Registers a new renderer for a given mime type. * - * Note: make sure to use the `onChatOutputRenderer:mime` activation event in your extension's `package.json` to ensure that the renderer is registered. + * Note: To use this API, you should also add a contribution point in your extension's + * package.json: * - * TODO:should this be a contribution instead? + * ```json + * "contributes": { + * "chatOutputRenderer": [ + * { + * "mimeTypes": ["application/your-mime-type"] + * } + * ] + * } + * ``` * * @param mime The MIME type of the output that this renderer can handle. * @param renderer The renderer to register. From 57c278ada7d3b6ddfa4c32d8a73efcba1d9a51c3 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Mon, 21 Jul 2025 17:37:05 -0700 Subject: [PATCH 10/27] PR comment follow up Adding a few notes and small fixes --- .../api/common/extHostChatOutputRenderer.ts | 2 +- .../api/common/extHostTypeConverters.ts | 11 +++++----- .../toolInvocationParts/chatToolOutputPart.ts | 20 +++++++++++++++++-- .../vscode.proposed.chatOutputRenderer.d.ts | 9 ++++++--- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/vs/workbench/api/common/extHostChatOutputRenderer.ts b/src/vs/workbench/api/common/extHostChatOutputRenderer.ts index 31a5d5298df..b4b7b1209de 100644 --- a/src/vs/workbench/api/common/extHostChatOutputRenderer.ts +++ b/src/vs/workbench/api/common/extHostChatOutputRenderer.ts @@ -48,6 +48,6 @@ export class ExtHostChatOutputRenderer implements ExtHostChatOutputRendererShape } const webview = this.webviews.createNewWebview(webviewHandle, {}, entry.extension); - return entry.renderer.renderChatOutput(valueData.buffer, webview, token); + return entry.renderer.renderChatOutput(valueData.buffer, webview, {}, token); } } diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index ceff29d3ceb..ee6cea21e0b 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -3335,12 +3335,11 @@ export namespace LanguageModelToolResult2 { if (item.kind === 'text') { return new types.LanguageModelTextPart(item.value); } else if (item.kind === 'data') { - // TODO: make sure we can remove this - // const mimeType = Object.values(types.ChatImageMimeType).includes(item.value.mimeType as types.ChatImageMimeType) ? item.value.mimeType as types.ChatImageMimeType : undefined; - // // if (!mimeType) { - // // throw new Error('Invalid MIME type'); - // // } - return new types.LanguageModelDataPart(item.value.data.buffer, item.value.mimeType); + const mimeType = Object.values(types.ChatImageMimeType).includes(item.value.mimeType as types.ChatImageMimeType) ? item.value.mimeType as types.ChatImageMimeType : undefined; + if (!mimeType) { + throw new Error('Invalid MIME type'); + } + return new types.LanguageModelDataPart(item.value.data.buffer, mimeType); } else { return new types.LanguageModelPromptTsxPart(item.value); } diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolOutputPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolOutputPart.ts index 56c98d223bd..6f66c6b712b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolOutputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolOutputPart.ts @@ -5,7 +5,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { decodeBase64 } from '../../../../../../base/common/buffer.js'; -import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize } from '../../../../../../nls.js'; @@ -18,11 +18,14 @@ import { IChatContentPartRenderContext } from '../chatContentParts.js'; import { ChatCustomProgressPart } from '../chatProgressContentPart.js'; import { BaseChatToolInvocationSubPart } from './chatToolInvocationSubPart.js'; +// TODO: see if we can reuse existing types instead of adding ChatToolOutputSubPart export class ChatToolOutputSubPart extends BaseChatToolInvocationSubPart { public readonly domNode: HTMLElement; public override readonly codeblocks: IChatCodeBlockInfo[] = []; + private readonly _disposeCts = this._register(new CancellationTokenSource()); + constructor( toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, _context: IChatContentPartRenderContext, @@ -44,6 +47,11 @@ export class ChatToolOutputSubPart extends BaseChatToolInvocationSubPart { this.domNode = this.createOutputPart(details); } + public override dispose(): void { + this._disposeCts.dispose(true); + super.dispose(); + } + private createOutputPart(details: IToolResultOutputDetails): HTMLElement { const parent = dom.$('div.webview-output'); parent.style.maxHeight = '80vh'; @@ -53,7 +61,12 @@ export class ChatToolOutputSubPart extends BaseChatToolInvocationSubPart { const progressPart = this.instantiationService.createInstance(ChatCustomProgressPart, progressMessage, ThemeIcon.modify(Codicon.loading, 'spin')); parent.appendChild(progressPart.domNode); - this.chatOutputItemRendererService.renderOutputPart(details.output.mimeType, details.output.value.buffer, parent, CancellationToken.None).then((renderedItem) => { + // TODO: we also need to show the tool output in the UI + this.chatOutputItemRendererService.renderOutputPart(details.output.mimeType, details.output.value.buffer, parent, this._disposeCts.token).then((renderedItem) => { + if (this._disposeCts.token.isCancellationRequested) { + return; + } + this._register(renderedItem); progressPart.domNode.remove(); @@ -62,6 +75,9 @@ export class ChatToolOutputSubPart extends BaseChatToolInvocationSubPart { this._register(renderedItem.onDidChangeHeight(() => { this._onDidChangeHeight.fire(); })); + }, (error) => { + // TODO: show error in UI too + console.error('Error rendering tool output:', error); }); return parent; diff --git a/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts b/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts index c251f01688b..6789cf2cacd 100644 --- a/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts +++ b/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts @@ -24,6 +24,7 @@ declare module 'vscode' { export interface ExtendedLanguageModelToolResult2 extends ExtendedLanguageModelToolResult { // Temporary to allow `toolResultDetails` to return a ToolResultDataOutput + // TODO: Should this live here? Or should we be able to mark each `content` items as user/lm specific? // TODO: Should we allow multiple per tool result? toolResultDetails2?: Array | ToolResultDataOutput; } @@ -32,8 +33,10 @@ declare module 'vscode' { /** * Given an output, render it into the provided webview. * - * TODO: Should we make this more generic so that we could support inputs besides tool outputs? - * For example, a generic `ChatResponseDataPart` type. + * TODO:Should this take an object instead of Uint8Array? That would let you get the original mime. Useful + * if we ever support registering for multiple mime types or using image/*. + * + * TODO: Figure out what to pass as context? * * @param data The data to render. * @param webview The webview to render the data into. @@ -42,7 +45,7 @@ declare module 'vscode' { * * @returns A promise that resolves when the webview has been initialized and is ready to be presented to the user. */ - renderChatOutput(data: Uint8Array, webview: Webview, token: CancellationToken): Thenable; + renderChatOutput(data: Uint8Array, webview: Webview, ctx: {}, token: CancellationToken): Thenable; } export namespace chat { From 5700a76c28f6735cc38693e7ff1c3d27859d07c0 Mon Sep 17 00:00:00 2001 From: gjsjohnmurray Date: Tue, 22 Jul 2025 08:29:02 +0100 Subject: [PATCH 11/27] Fix an `@param` typo --- src/vscode-dts/vscode.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vscode-dts/vscode.d.ts b/src/vscode-dts/vscode.d.ts index 16cb98ca7c9..0011a542fe4 100644 --- a/src/vscode-dts/vscode.d.ts +++ b/src/vscode-dts/vscode.d.ts @@ -18835,7 +18835,7 @@ declare module 'vscode' { * Creates a {@link FileCoverage} instance with counts filled in from * the coverage details. * @param uri Covered file URI - * @param detailed Detailed coverage information + * @param details Detailed coverage information */ static fromDetails(uri: Uri, details: readonly FileCoverageDetail[]): FileCoverage; From 195594f674de9821d97b88331987c55449ad275a Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 22 Jul 2025 08:50:21 -0700 Subject: [PATCH 12/27] Always set toolSpecificData Fixes #257272 --- .../chat/browser/languageModelToolsService.ts | 4 +-- .../browser/runInTerminalTool.ts | 30 ++++--------------- 2 files changed, 7 insertions(+), 27 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts index 146bb473931..ae32701d859 100644 --- a/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts @@ -276,6 +276,8 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo model.acceptResponseProgress(request, toolInvocation); + dto.toolSpecificData = toolInvocation?.toolSpecificData; + if (prepared?.confirmationMessages) { if (!toolInvocation.isConfirmed && !autoConfirmed) { this.playAccessibilitySignal([toolInvocation]); @@ -285,8 +287,6 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo throw new CancellationError(); } - dto.toolSpecificData = toolInvocation?.toolSpecificData; - if (dto.toolSpecificData?.kind === 'input') { dto.parameters = dto.toolSpecificData.rawInput; dto.toolSpecificData = undefined; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalTool.ts index 804c8c33672..4480d1e5ded 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalTool.ts @@ -239,33 +239,13 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const args = invocation.parameters as IRunInTerminalInputParams; - // Tool specific data is not provided when the invocation is auto-approved. Re-calculate it - // if needed - let toolSpecificData = invocation.toolSpecificData as IChatTerminalToolInvocationData | IChatTerminalToolInvocationData2 | undefined; - if (toolSpecificData === undefined) { - const os = await this._osBackend; - const shell = await this._terminalProfileResolverService.getDefaultShell({ - os, - remoteAuthority: this._remoteAgentService.getConnection()?.remoteAuthority - }); - const language = os === OperatingSystem.Windows ? 'pwsh' : 'sh'; - const instance = invocation.context?.sessionId ? this._sessionTerminalAssociations.get(invocation.context!.sessionId)?.instance : undefined; - let toolEditedCommand: string | undefined = await this._rewriteCommandIfNeeded(args, instance, shell); - if (toolEditedCommand === args.command) { - toolEditedCommand = undefined; - } - toolSpecificData = { - kind: 'terminal2', - commandLine: { - original: args.command, - toolEdited: toolEditedCommand - }, - language - }; - } - this._logService.debug(`RunInTerminalTool: Invoking with options ${JSON.stringify(args)}`); + const toolSpecificData = invocation.toolSpecificData as IChatTerminalToolInvocationData | IChatTerminalToolInvocationData2 | undefined; + if (!toolSpecificData) { + throw new Error('toolSpecificData must be provided for this tool'); + } + const chatSessionId = invocation.context?.sessionId; if (!invocation.context || chatSessionId === undefined) { throw new Error('A chat session ID is required for this tool'); From 06c17eafde24ebf5e01074d9c428ae66d7220454 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Tue, 22 Jul 2025 10:00:32 -0700 Subject: [PATCH 13/27] Be explicit that this is the list experience (#257300) To make way for full tree experience --- src/vs/platform/quickinput/browser/quickInput.ts | 4 ++-- src/vs/platform/quickinput/browser/quickInputController.ts | 4 ++-- .../browser/{quickInputTree.ts => quickInputList.ts} | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) rename src/vs/platform/quickinput/browser/{quickInputTree.ts => quickInputList.ts} (99%) diff --git a/src/vs/platform/quickinput/browser/quickInput.ts b/src/vs/platform/quickinput/browser/quickInput.ts index fcba3594b60..fbf9a4d0e2c 100644 --- a/src/vs/platform/quickinput/browser/quickInput.ts +++ b/src/vs/platform/quickinput/browser/quickInput.ts @@ -30,7 +30,7 @@ import { QuickInputBox } from './quickInputBox.js'; import { quickInputButtonToAction, renderQuickInputDescription } from './quickInputUtils.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IHoverService, WorkbenchHoverDelegate } from '../../hover/browser/hover.js'; -import { QuickInputTree } from './quickInputTree.js'; +import { QuickInputList } from './quickInputList.js'; import type { IHoverOptions } from '../../../base/browser/ui/hover/hover.js'; import { ContextKeyExpr, RawContextKey } from '../../contextkey/common/contextkey.js'; @@ -117,7 +117,7 @@ export interface QuickInputUI { customButtonContainer: HTMLElement; customButton: Button; progressBar: ProgressBar; - list: QuickInputTree; + list: QuickInputList; onDidAccept: Event; onDidCustom: Event; onDidTriggerButton: Event; diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index 9c4fac6cc16..b2a93dbc9fd 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -23,7 +23,7 @@ import { QuickInputUI, Writeable, IQuickInputStyles, IQuickInputOptions, QuickPi import { ILayoutService } from '../../layout/browser/layoutService.js'; import { mainWindow } from '../../../base/browser/window.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; -import { QuickInputTree } from './quickInputTree.js'; +import { QuickInputList } from './quickInputList.js'; import { IContextKey, IContextKeyService } from '../../contextkey/common/contextkey.js'; import './quickInputActions.js'; import { autorun, observableValue } from '../../../base/common/observable.js'; @@ -210,7 +210,7 @@ export class QuickInputController extends Disposable { const description1 = dom.append(container, $('.quick-input-description')); const listId = this.idPrefix + 'list'; - const list = this._register(this.instantiationService.createInstance(QuickInputTree, container, this.options.hoverDelegate, this.options.linkOpenerDelegate, listId)); + const list = this._register(this.instantiationService.createInstance(QuickInputList, container, this.options.hoverDelegate, this.options.linkOpenerDelegate, listId)); inputBox.setAttribute('aria-controls', listId); this._register(list.onDidChangeFocus(() => { if (inputBox.hasFocus()) { diff --git a/src/vs/platform/quickinput/browser/quickInputTree.ts b/src/vs/platform/quickinput/browser/quickInputList.ts similarity index 99% rename from src/vs/platform/quickinput/browser/quickInputTree.ts rename to src/vs/platform/quickinput/browser/quickInputList.ts index c760a4f20a0..7de1922163b 100644 --- a/src/vs/platform/quickinput/browser/quickInputTree.ts +++ b/src/vs/platform/quickinput/browser/quickInputList.ts @@ -658,9 +658,9 @@ class QuickPickSeparatorElementRenderer extends BaseQuickInputListRenderer(); /** From b604df10806bb3e3d25b5b9475620fb6ad74c0ef Mon Sep 17 00:00:00 2001 From: Wang Chong <306289287@qq.com> Date: Wed, 23 Jul 2025 01:16:06 +0800 Subject: [PATCH 14/27] fix(gettingStarted): remove duplicated "can be" in hover description (#254412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: remove duplicated word in hover description ("can be can be" → "can be") --- .../welcomeGettingStarted/common/gettingStartedContent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts b/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts index a7a2eb28265..6815074ee06 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts @@ -509,7 +509,7 @@ export const walkthroughs: GettingStartedWalkthroughContent = [ { id: 'hover', title: localize('gettingStarted.hover.title', "Access the hover in the editor to get more information on a variable or symbol"), - description: localize('gettingStarted.hover.description.interpolated', "While focus is in the editor on a variable or symbol, a hover can be can be focused with the Show or Open Hover command.\n{0}", Button(localize('showOrFocusHover', "Show or Focus Hover"), 'command:editor.action.showHover')), + description: localize('gettingStarted.hover.description.interpolated', "While focus is in the editor on a variable or symbol, a hover can be focused with the Show or Open Hover command.\n{0}", Button(localize('showOrFocusHover', "Show or Focus Hover"), 'command:editor.action.showHover')), media: { type: 'markdown', path: 'empty' } From fc30493ed91dc822652a06deb498bde16fcfd17f Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Tue, 22 Jul 2025 13:20:12 -0400 Subject: [PATCH 15/27] Fix overloaded property (#257302) --- .../editTelemetry/browser/telemetry/arcTelemetrySender.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetrySender.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetrySender.ts index ea33c4b1a3b..ca350922b25 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetrySender.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/arcTelemetrySender.ts @@ -121,7 +121,7 @@ export class ChatArcTelemetrySender extends Disposable { extensionId: string | undefined; extensionVersion: string | undefined; opportunityId: string | undefined; - sessionId: string | undefined; + editSessionId: string | undefined; requestId: string | undefined; modelId: string | undefined; @@ -141,7 +141,7 @@ export class ChatArcTelemetrySender extends Disposable { extensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension id (copilot or copilot-chat); which provided this inline completion.' }; extensionVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The version of the extension.' }; opportunityId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Unique identifier for an opportunity to show an inline completion or NES.' }; - sessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session id.' }; + editSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session id.' }; requestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The request id.' }; modelId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model id.' }; @@ -164,7 +164,7 @@ export class ChatArcTelemetrySender extends Disposable { extensionId: data.props.$extensionId, extensionVersion: data.props.$extensionVersion, opportunityId: data.props.$$requestUuid, - sessionId: data.props.$$sessionId, + editSessionId: data.props.$$sessionId, requestId: data.props.$$requestId, modelId: data.props.$modelId, From 93f7f89c3416270aa8adb1140408709246bc67f5 Mon Sep 17 00:00:00 2001 From: James Yang <26634873@qq.com> Date: Tue, 22 Jul 2025 04:24:05 +0000 Subject: [PATCH 16/27] feat(terminal): add test of getBufferReverseIterator fix(terminal): xterm getBufferReverseIterator bug --- .../terminal/browser/xterm/xtermTerminal.ts | 2 +- .../test/browser/xterm/xtermTerminal.test.ts | 22 ++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 7abe1b03225..aad2cb24393 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -327,7 +327,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach } *getBufferReverseIterator(): IterableIterator { - for (let i = this.raw.buffer.active.length; i >= 0; i--) { + for (let i = this.raw.buffer.active.length - 1; i >= 0; i--) { const { lineData, lineIndex } = getFullBufferLineAsString(i, this.raw.buffer.active); if (lineData) { i = lineIndex; diff --git a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts index 588b0934aa5..27f9a9404dd 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts @@ -79,7 +79,7 @@ const defaultTerminalConfig: Partial = { fontWeight: 'normal', fontWeightBold: 'normal', gpuAcceleration: 'off', - scrollback: 1000, + scrollback: 10, fastScrollSensitivity: 2, mouseWheelScrollSensitivity: 1, unicodeVersion: '6' @@ -252,6 +252,26 @@ suite('XtermTerminal', () => { }); }); + suite('getBufferReverseIterator', () => { + test('should get text properly within scrollback limit', async () => { + const text = 'line 1\r\nline 2\r\nline 3\r\nline 4\r\nline 5'; + await write(text); + + const result = [...xterm.getBufferReverseIterator()].reverse().join('\r\n'); + strictEqual(text, result, 'Should equal original text'); + }); + test('should get text properly when exceed scrollback limit', async () => { + // max buffer lines(40) = rows(30) + scrollback(10) + const text = 'line 1\r\nline 2\r\nline 3\r\nline 4\r\nline 5\r\n'.repeat(8).trim(); + await write(text); + await write('\r\nline more'); + + const result = [...xterm.getBufferReverseIterator()].reverse().join('\r\n'); + const expect = text.slice(8) + '\r\nline more'; + strictEqual(expect, result, 'Should equal original text without line 1'); + }); + }); + suite('theme', () => { test('should apply correct background color based on getBackgroundColor', () => { themeService.setTheme(new TestColorTheme({ From 3cbd82fcff7c9c6e9ed3ac7a87c2493061106893 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 22 Jul 2025 11:23:08 -0700 Subject: [PATCH 17/27] Change icon for remote job action (#257320) Updated icon for CreateRemoteAgentJobAction to sendToRemoteAgent. --- .../contrib/chat/browser/actions/chatExecuteActions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 8580b8f319f..3dfadb15cbd 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -528,7 +528,7 @@ export class CreateRemoteAgentJobAction extends Action2 { id: CreateRemoteAgentJobAction.ID, // TODO(joshspicer): Generalize title, pull from contribution title: localize2('actions.chat.createRemoteJob', "Delegate to coding agent"), - icon: Codicon.cloudUpload, + icon: Codicon.sendToRemoteAgent, precondition, toggled: { condition: ChatContextKeys.remoteJobCreating, From e8ddbe4aed3f225d0ba82aa758548daea3ece51b Mon Sep 17 00:00:00 2001 From: Ninglo <48613687+Ninglo@users.noreply.github.com> Date: Wed, 23 Jul 2025 02:27:54 +0800 Subject: [PATCH 18/27] Fix `editor.wordSegmenterLocales` configuration don't take effect in simpleWidget editors (like chat or SCM input Editor) (#223921) * chore: Improve config schema of `editor.wordSegmenterLocales` * fix: config `editor.wordSegmenterLocales` don't take effect in simple editor * fix: update config `editor.wordSegmenterLocales` don't take effect in SCM and chat input editors * fix: handle case of multi configuration change * fix diffs post merge conflicts --------- Co-authored-by: Alexandru Dima --- src/vs/editor/common/config/editorOptions.ts | 7 ++++++- src/vs/workbench/contrib/chat/browser/chatInputPart.ts | 10 ++++++++-- .../contrib/codeEditor/browser/simpleEditorOptions.ts | 1 + src/vs/workbench/contrib/scm/browser/scmViewPane.ts | 4 +++- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index b5b2c976f83..7c9ac51d416 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -5305,7 +5305,12 @@ class WordSegmenterLocales extends BaseEditorOption this.history = new HistoryNavigator2([{ text: '', state: this.getInputState() }], ChatInputHistoryMaxEntries, historyKeyFn))); this._register(this.configurationService.onDidChangeConfiguration(e => { + const newOptions: IEditorOptions = {}; if (e.affectsConfiguration(AccessibilityVerbositySettingId.Chat)) { - this.inputEditor.updateOptions({ ariaLabel: this._getAriaLabel() }); + newOptions.ariaLabel = this._getAriaLabel(); } + if (e.affectsConfiguration('editor.wordSegmenterLocales')) { + newOptions.wordSegmenterLocales = this.configurationService.getValue('editor.wordSegmenterLocales'); + } + + this.inputEditor.updateOptions(newOptions); })); this._chatEditsListPool = this._register(this.instantiationService.createInstance(CollapsibleListPool, this._onDidChangeVisibility.event, MenuId.ChatEditingWidgetModifiedFilesToolbar, { verticalScrollMode: ScrollbarVisibility.Visible })); diff --git a/src/vs/workbench/contrib/codeEditor/browser/simpleEditorOptions.ts b/src/vs/workbench/contrib/codeEditor/browser/simpleEditorOptions.ts index 07af7312d21..88d469aa3a8 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/simpleEditorOptions.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/simpleEditorOptions.ts @@ -45,6 +45,7 @@ export function getSimpleEditorOptions(configurationService: IConfigurationServi guides: { indentation: false }, + wordSegmenterLocales: configurationService.getValue('editor.wordSegmenterLocales'), accessibilitySupport: configurationService.getValue<'auto' | 'off' | 'on'>('editor.accessibilitySupport'), cursorBlinking: configurationService.getValue<'blink' | 'smooth' | 'phase' | 'expand' | 'solid'>('editor.cursorBlinking'), editContext: configurationService.getValue('editor.editContext'), diff --git a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts index f19473963a8..1b457df81ae 100644 --- a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts @@ -1549,6 +1549,7 @@ class SCMInputWidgetEditorOptions { e.affectsConfiguration('editor.fontFamily') || e.affectsConfiguration('editor.rulers') || e.affectsConfiguration('editor.wordWrap') || + e.affectsConfiguration('editor.wordSegmenterLocales') || e.affectsConfiguration('scm.inputFontFamily') || e.affectsConfiguration('scm.inputFontSize'); }, @@ -1583,13 +1584,14 @@ class SCMInputWidgetEditorOptions { const fontFamily = this._getEditorFontFamily(); const fontSize = this._getEditorFontSize(); const lineHeight = this._getEditorLineHeight(fontSize); + const wordSegmenterLocales = this.configurationService.getValue('editor.wordSegmenterLocales'); const accessibilitySupport = this.configurationService.getValue<'auto' | 'off' | 'on'>('editor.accessibilitySupport'); const cursorBlinking = this.configurationService.getValue<'blink' | 'smooth' | 'phase' | 'expand' | 'solid'>('editor.cursorBlinking'); const cursorStyle = this.configurationService.getValue('editor.cursorStyle'); const cursorWidth = this.configurationService.getValue('editor.cursorWidth') ?? 1; const emptySelectionClipboard = this.configurationService.getValue('editor.emptySelectionClipboard') === true; - return { ...this._getEditorLanguageConfiguration(), accessibilitySupport, cursorBlinking, cursorStyle, cursorWidth, fontFamily, fontSize, lineHeight, emptySelectionClipboard }; + return { ...this._getEditorLanguageConfiguration(), accessibilitySupport, cursorBlinking, cursorStyle, cursorWidth, fontFamily, fontSize, lineHeight, emptySelectionClipboard, wordSegmenterLocales }; } private _getEditorFontFamily(): string { From a9b77f0eaee4ccc7501ae580fca654cae83f708a Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 22 Jul 2025 11:48:42 -0700 Subject: [PATCH 19/27] mcp: fix empty file causing parse error and requiring restart (#257330) --- src/vs/platform/mcp/common/mcpManagementService.ts | 7 +++++-- src/vs/platform/mcp/common/mcpResourceScannerService.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/mcp/common/mcpManagementService.ts b/src/vs/platform/mcp/common/mcpManagementService.ts index 1cd0ad98704..1ba0aa47e8c 100644 --- a/src/vs/platform/mcp/common/mcpManagementService.ts +++ b/src/vs/platform/mcp/common/mcpManagementService.ts @@ -84,8 +84,11 @@ export abstract class AbstractMcpResourceManagementService extends Disposable { private initialize(): Promise { if (!this.initializePromise) { this.initializePromise = (async () => { - this.local = await this.populateLocalServers(); - this.startWatching(); + try { + this.local = await this.populateLocalServers(); + } finally { + this.startWatching(); + } })(); } return this.initializePromise; diff --git a/src/vs/platform/mcp/common/mcpResourceScannerService.ts b/src/vs/platform/mcp/common/mcpResourceScannerService.ts index 6439b2d7e04..69a0f04f499 100644 --- a/src/vs/platform/mcp/common/mcpResourceScannerService.ts +++ b/src/vs/platform/mcp/common/mcpResourceScannerService.ts @@ -100,7 +100,7 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc try { const content = await this.fileService.readFile(mcpResource); const errors: ParseError[] = []; - const result = parse(content.value.toString(), errors, { allowTrailingComma: true, allowEmptyContent: true }); + const result = parse(content.value.toString(), errors, { allowTrailingComma: true, allowEmptyContent: true }) || {}; if (errors.length > 0) { throw new Error('Failed to parse scanned MCP servers: ' + errors.join(', ')); } From b7fcf927b0ff516f1bbc8a145e3e87bebbe2616e Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:24:16 -0700 Subject: [PATCH 20/27] Add TriStateCheckbox (#257297) and use it in quick pick to setup Quick Tree. --- src/vs/base/browser/ui/toggle/toggle.css | 5 - src/vs/base/browser/ui/toggle/toggle.ts | 116 ++++++++++++++---- .../platform/quickinput/browser/quickInput.ts | 4 +- .../browser/quickInputController.ts | 7 +- 4 files changed, 98 insertions(+), 34 deletions(-) diff --git a/src/vs/base/browser/ui/toggle/toggle.css b/src/vs/base/browser/ui/toggle/toggle.css index 6244ed4a434..e2d206d3aef 100644 --- a/src/vs/base/browser/ui/toggle/toggle.css +++ b/src/vs/base/browser/ui/toggle/toggle.css @@ -67,8 +67,3 @@ .monaco-action-bar .checkbox-action-item > .checkbox-label { font-size: 12px; } - -/* hide check when unchecked */ -.monaco-custom-toggle.monaco-checkbox:not(.checked)::before { - visibility: hidden; -} diff --git a/src/vs/base/browser/ui/toggle/toggle.ts b/src/vs/base/browser/ui/toggle/toggle.ts index 5e0aabe51c8..8c653627a13 100644 --- a/src/vs/base/browser/ui/toggle/toggle.ts +++ b/src/vs/base/browser/ui/toggle/toggle.ts @@ -258,27 +258,20 @@ export class Toggle extends Widget { } } -export class Checkbox extends Widget { +abstract class BaseCheckbox extends Widget { static readonly CLASS_NAME = 'monaco-checkbox'; - private readonly _onChange = this._register(new Emitter()); + protected readonly _onChange = this._register(new Emitter()); readonly onChange: Event = this._onChange.event; - private checkbox: Toggle; - private styles: ICheckboxStyles; - - readonly domNode: HTMLElement; - - constructor(private title: string, private isChecked: boolean, styles: ICheckboxStyles) { + constructor( + protected readonly checkbox: Toggle, + readonly domNode: HTMLElement, + protected readonly styles: ICheckboxStyles + ) { super(); - this.checkbox = this._register(new Toggle({ title: this.title, isChecked: this.isChecked, icon: Codicon.check, actionClassName: Checkbox.CLASS_NAME, hoverDelegate: styles.hoverDelegate, ...unthemedToggleStyles })); - - this.domNode = this.checkbox.domNode; - - this.styles = styles; - this.applyStyles(); this._register(this.checkbox.onChange(keyboard => { @@ -287,20 +280,10 @@ export class Checkbox extends Widget { })); } - get checked(): boolean { - return this.checkbox.checked; - } - get enabled(): boolean { return this.checkbox.enabled; } - set checked(newIsChecked: boolean) { - this.checkbox.checked = newIsChecked; - - this.applyStyles(); - } - focus(): void { this.domNode.focus(); } @@ -336,6 +319,91 @@ export class Checkbox extends Widget { } } +export class Checkbox extends BaseCheckbox { + constructor(title: string, isChecked: boolean, styles: ICheckboxStyles) { + const toggle = new Toggle({ title, isChecked, icon: Codicon.check, actionClassName: BaseCheckbox.CLASS_NAME, hoverDelegate: styles.hoverDelegate, ...unthemedToggleStyles }); + + super(toggle, toggle.domNode, styles); + this._register(toggle); + + this.applyStyles(); + + this._register(this.checkbox.onChange(keyboard => { + this.applyStyles(); + this._onChange.fire(keyboard); + })); + } + + get checked(): boolean { + return this.checkbox.checked; + } + + set checked(newIsChecked: boolean) { + this.checkbox.checked = newIsChecked; + if (newIsChecked) { + this.checkbox.setIcon(Codicon.check); + } else { + this.checkbox.setIcon(undefined); + } + this.applyStyles(); + } +} + +export class TriStateCheckbox extends BaseCheckbox { + constructor(title: string, initialState: boolean | 'partial', styles: ICheckboxStyles) { + let icon: ThemeIcon | undefined; + switch (initialState) { + case true: + icon = Codicon.check; + break; + case 'partial': + icon = Codicon.dash; + break; + case false: + icon = undefined; + break; + } + const checkbox = new Toggle({ + title, + isChecked: initialState === true, + icon, + actionClassName: Checkbox.CLASS_NAME, + hoverDelegate: styles.hoverDelegate, + ...unthemedToggleStyles + }); + + super( + checkbox, + checkbox.domNode, + styles + ); + this._register(checkbox); + } + + get checked(): boolean | 'partial' { + return this.checkbox.checked; + } + + set checked(newState: boolean | 'partial') { + const checked = newState === true; + this.checkbox.checked = checked; + + switch (newState) { + case true: + this.checkbox.setIcon(Codicon.check); + break; + case 'partial': + this.checkbox.setIcon(Codicon.dash); + break; + case false: + this.checkbox.setIcon(undefined); + break; + } + + this.applyStyles(); + } +} + export interface ICheckboxActionViewItemOptions extends IActionViewItemOptions { checkboxStyles: ICheckboxStyles; } diff --git a/src/vs/platform/quickinput/browser/quickInput.ts b/src/vs/platform/quickinput/browser/quickInput.ts index fbf9a4d0e2c..fd903e3db74 100644 --- a/src/vs/platform/quickinput/browser/quickInput.ts +++ b/src/vs/platform/quickinput/browser/quickInput.ts @@ -13,7 +13,7 @@ import { IInputBoxStyles } from '../../../base/browser/ui/inputbox/inputBox.js'; import { IKeybindingLabelStyles } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; import { IListStyles } from '../../../base/browser/ui/list/listWidget.js'; import { IProgressBarStyles, ProgressBar } from '../../../base/browser/ui/progressbar/progressbar.js'; -import { Checkbox, IToggleStyles, Toggle } from '../../../base/browser/ui/toggle/toggle.js'; +import { IToggleStyles, Toggle, TriStateCheckbox } from '../../../base/browser/ui/toggle/toggle.js'; import { equals } from '../../../base/common/arrays.js'; import { TimeoutTimer } from '../../../base/common/async.js'; import { Codicon } from '../../../base/common/codicons.js'; @@ -103,7 +103,7 @@ export interface QuickInputUI { widget: HTMLElement; rightActionBar: ActionBar; inlineActionBar: ActionBar; - checkAll: Checkbox; + checkAll: TriStateCheckbox; inputContainer: HTMLElement; filterContainer: HTMLElement; inputBox: QuickInputBox; diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index b2a93dbc9fd..824df3c37d1 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -33,7 +33,7 @@ import { IConfigurationService } from '../../configuration/common/configuration. import { Platform, platform } from '../../../base/common/platform.js'; import { getWindowControlsStyle, WindowControlsStyle } from '../../window/common/window.js'; import { getZoomFactor } from '../../../base/browser/browser.js'; -import { Checkbox } from '../../../base/browser/ui/toggle/toggle.js'; +import { TriStateCheckbox } from '../../../base/browser/ui/toggle/toggle.js'; import { defaultCheckboxStyles } from '../../theme/browser/defaultStyles.js'; const $ = dom.$; @@ -154,11 +154,11 @@ export class QuickInputController extends Disposable { const headerContainer = dom.append(container, $('.quick-input-header')); - const checkAll = this._register(new Checkbox(localize('quickInput.checkAll', "Toggle all checkboxes"), false, { ...defaultCheckboxStyles, size: 15 })); + const checkAll = this._register(new TriStateCheckbox(localize('quickInput.checkAll', "Toggle all checkboxes"), false, { ...defaultCheckboxStyles, size: 15 })); dom.append(headerContainer, checkAll.domNode); this._register(checkAll.onChange(() => { const checked = checkAll.checked; - list.setAllVisibleChecked(checked); + list.setAllVisibleChecked(checked === true); })); this._register(dom.addDisposableListener(checkAll.domNode, dom.EventType.CLICK, e => { if (e.x || e.y) { // Avoid 'click' triggered by 'space'... @@ -218,6 +218,7 @@ export class QuickInputController extends Disposable { } })); this._register(list.onChangedAllVisibleChecked(checked => { + // TODO: Support tri-state checkbox when we remove the .indent property that is faking tree structure. checkAll.checked = checked; })); this._register(list.onChangedVisibleCount(c => { From e824cbf40f485b36d6cb82987dfca85182930d01 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 22 Jul 2025 15:36:00 -0400 Subject: [PATCH 21/27] fix remaining run task and task output issues (#257306) --- .../task.chatAgentTools.contribution.ts | 9 ++- .../browser/task/getTaskOutputTool.ts | 14 ++-- .../browser/task/runTaskTool.ts | 17 ++-- .../browser/task/taskHelpers.ts | 80 +++++++++++++------ .../terminalChatAgentToolsConfiguration.ts | 2 +- 5 files changed, 81 insertions(+), 41 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/task.chatAgentTools.contribution.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/task.chatAgentTools.contribution.ts index 908c1274488..e8f85523651 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/task.chatAgentTools.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/task.chatAgentTools.contribution.ts @@ -4,10 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { registerWorkbenchContribution2, WorkbenchPhase, type IWorkbenchContribution } from '../../../../common/contributions.js'; -import { ILanguageModelToolsService } from '../../../chat/common/languageModelToolsService.js'; +import { ILanguageModelToolsService, ToolDataSource } from '../../../chat/common/languageModelToolsService.js'; import { TerminalChatAgentToolsSettingId } from '../common/terminalChatAgentToolsConfiguration.js'; import { GetTaskOutputTool, GetTaskOutputToolData } from './task/getTaskOutputTool.js'; import { RunTaskTool, RunTaskToolData } from './task/runTaskTool.js'; @@ -32,6 +33,12 @@ class ChatAgentToolsContribution extends Disposable implements IWorkbenchContrib const getTaskOutputTool = instantiationService.createInstance(GetTaskOutputTool); this._register(toolsService.registerToolData(GetTaskOutputToolData)); this._register(toolsService.registerToolImplementation(GetTaskOutputToolData.id, getTaskOutputTool)); + + const toolSet = this._register(toolsService.createToolSet(ToolDataSource.Internal, 'runTaskGetOutput', 'runTaskGetOutput', { + description: localize('toolset.runTaskGetOutput', 'Runs tasks and gets their output for your workspace') + })); + toolSet.addTool(RunTaskToolData); + toolSet.addTool(GetTaskOutputToolData); } } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/task/getTaskOutputTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/task/getTaskOutputTool.ts index f2f0d2c4edd..bca01081e95 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/task/getTaskOutputTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/task/getTaskOutputTool.ts @@ -7,6 +7,7 @@ import type { CancellationToken } from '../../../../../../base/common/cancellati import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../../nls.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../../chat/common/languageModelToolsService.js'; import { ITaskService } from '../../../../tasks/common/taskService.js'; import { ITerminalService } from '../../../../terminal/browser/terminal.js'; @@ -48,6 +49,7 @@ export class GetTaskOutputTool extends Disposable implements IToolImpl { constructor( @ITaskService private readonly _tasksService: ITaskService, @ITerminalService private readonly _terminalService: ITerminalService, + @IConfigurationService private readonly _configurationService: IConfigurationService, ) { super(); } @@ -55,7 +57,7 @@ export class GetTaskOutputTool extends Disposable implements IToolImpl { const args = context.parameters as IGetTaskOutputInputParams; const taskDefinition = getTaskDefinition(args.id); - const task = await getTaskForTool(args.id, taskDefinition, args.workspaceFolder, this._tasksService); + const task = await getTaskForTool(args.id, taskDefinition, args.workspaceFolder, this._configurationService, this._tasksService); if (!task) { return { invocationMessage: new MarkdownString(localize('copilotChat.taskNotFound', 'Task not found: `{0}`', args.id)) }; } @@ -65,23 +67,23 @@ export class GetTaskOutputTool extends Disposable implements IToolImpl { } return { - invocationMessage: new MarkdownString(localize('copilotChat.checkingTerminalOutput', 'Checking terminal output for `{0}`', taskDefinition.taskLabel)), - pastTenseMessage: new MarkdownString(localize('copilotChat.checkedTerminalOutput', 'Checked terminal output for `{0}`', taskDefinition.taskLabel)), + invocationMessage: new MarkdownString(localize('copilotChat.checkingTerminalOutput', 'Checking output for task `{0}`', taskDefinition.taskLabel)), + pastTenseMessage: new MarkdownString(localize('copilotChat.checkedTerminalOutput', 'Checked output for task `{0}`', taskDefinition.taskLabel)), }; } async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise { const args = invocation.parameters as IGetTaskOutputInputParams; const taskDefinition = getTaskDefinition(args.id); - const task = await getTaskForTool(args.id, taskDefinition, args.workspaceFolder, this._tasksService); + const task = await getTaskForTool(args.id, taskDefinition, args.workspaceFolder, this._configurationService, this._tasksService); if (!task) { - return { content: [], toolResultMessage: new MarkdownString(localize('copilotChat.taskNotFound', 'Task not found: `{0}`', args.id)) }; + return { content: [{ kind: 'text', value: localize('copilotChat.taskNotFound', 'Task not found: `{0}`', args.id) }], toolResultMessage: new MarkdownString(localize('copilotChat.taskNotFound', 'Task not found: `{0}`', args.id)) }; } const resource = this._tasksService.getTerminalForTask(task); const terminal = this._terminalService.instances.find(t => t.resource.path === resource?.path && t.resource.scheme === resource.scheme); if (!terminal) { - return { content: [], toolResultMessage: new MarkdownString(localize('copilotChat.terminalNotFound', 'Terminal not found for task `{0}`', taskDefinition?.taskLabel)) }; + return { content: [{ kind: 'text', value: localize('copilotChat.terminalNotFound', 'Terminal not found for task `{0}`', taskDefinition?.taskLabel) }], toolResultMessage: new MarkdownString(localize('copilotChat.terminalNotFound', 'Terminal not found for task `{0}`', taskDefinition?.taskLabel)) }; } return { content: [{ diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/task/runTaskTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/task/runTaskTool.ts index 25d1541192a..82582d898c1 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/task/runTaskTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/task/runTaskTool.ts @@ -16,6 +16,7 @@ import { pollForOutputAndIdle, promptForMorePolling } from '../bufferOutputPolli import { getOutput } from '../outputHelpers.js'; import { getTaskDefinition, getTaskForTool } from './taskHelpers.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; type RunTaskToolClassification = { taskId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the task.' }; @@ -43,25 +44,27 @@ export class RunTaskTool implements IToolImpl { @ITerminalService private readonly _terminalService: ITerminalService, @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @IChatService private readonly _chatService: IChatService, + @IConfigurationService private readonly _configurationService: IConfigurationService, ) { } async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise { const args = invocation.parameters as IRunTaskToolInput; if (!invocation.context) { - return { content: [], toolResultMessage: `No invocation context` }; + return { content: [{ kind: 'text', value: `No invocation context` }], toolResultMessage: `No invocation context` }; } const taskDefinition = getTaskDefinition(args.id); - const task = await getTaskForTool(args.id, taskDefinition, args.workspaceFolder, this._tasksService); + + const task = await getTaskForTool(args.id, taskDefinition, args.workspaceFolder, this._configurationService, this._tasksService); if (!task) { - return { content: [], toolResultMessage: new MarkdownString(localize('copilotChat.taskNotFound', 'Task not found: `{0}`', args.id)) }; + return { content: [{ kind: 'text', value: localize('copilotChat.taskNotFound', 'Task not found: `{0}`', args.id) }], toolResultMessage: new MarkdownString(localize('copilotChat.taskNotFound', 'Task not found: `{0}`', args.id)) }; } const activeTasks = await this._tasksService.getActiveTasks(); if (activeTasks.includes(task)) { - return { content: [], toolResultMessage: new MarkdownString(localize('copilotChat.taskAlreadyRunning', 'The task `{0}` is already running.', taskDefinition.taskLabel)) }; + return { content: [{ kind: 'text', value: localize('copilotChat.taskAlreadyRunning', 'The task `{0}` is already running.', taskDefinition.taskLabel) }], toolResultMessage: new MarkdownString(localize('copilotChat.taskAlreadyRunning', 'The task `{0}` is already running.', taskDefinition.taskLabel)) }; } const raceResult = await Promise.race([this._tasksService.run(task), timeout(3000)]); @@ -70,7 +73,7 @@ export class RunTaskTool implements IToolImpl { const resource = this._tasksService.getTerminalForTask(task); const terminal = this._terminalService.instances.find(t => t.resource.path === resource?.path && t.resource.scheme === resource.scheme); if (!terminal) { - return { content: [], toolResultMessage: new MarkdownString(localize('copilotChat.noTerminal', 'Task started but no terminal was found for: `{0}`', taskDefinition.taskLabel)) }; + return { content: [{ kind: 'text', value: localize('copilotChat.noTerminal', 'Task started but no terminal was found for: `{0}`', taskDefinition.taskLabel) }], toolResultMessage: new MarkdownString(localize('copilotChat.noTerminal', 'Task started but no terminal was found for: `{0}`', taskDefinition.taskLabel)) }; } _progress.report({ message: new MarkdownString(localize('copilotChat.checkingOutput', 'Checking output for `{0}`', taskDefinition.taskLabel)) }); @@ -109,9 +112,9 @@ export class RunTaskTool implements IToolImpl { async prepareToolInvocation(context: IToolInvocationPreparationContext, token: CancellationToken): Promise { const args = context.parameters as IRunTaskToolInput; - const taskDefinition = getTaskDefinition(args.id); - const task = await getTaskForTool(args.id, taskDefinition, args.workspaceFolder, this._tasksService); + + const task = await getTaskForTool(args.id, taskDefinition, args.workspaceFolder, this._configurationService, this._tasksService); if (!task) { return { invocationMessage: new MarkdownString(localize('copilotChat.taskNotFound', 'Task not found: `{0}`', args.id)) }; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/task/taskHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/task/taskHelpers.ts index 1b37db09b7f..979d8c754cb 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/task/taskHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/task/taskHelpers.ts @@ -5,14 +5,14 @@ import { IStringDictionary } from '../../../../../../base/common/collections.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ConfiguringTask, Task } from '../../../../tasks/common/tasks.js'; import { ITaskService } from '../../../../tasks/common/taskService.js'; - export function getTaskDefinition(id: string) { const idx = id.indexOf(': '); const taskType = id.substring(0, idx); - let taskLabel = id.substring(idx + 2); + let taskLabel = idx > 0 ? id.substring(idx + 2) : id; if (/^\d+$/.test(taskLabel)) { taskLabel = id; @@ -22,39 +22,67 @@ export function getTaskDefinition(id: string) { } -export function getTaskRepresentation(task: Task): string { - const taskDefinition = task.getDefinition(true); - if (!taskDefinition) { - return ''; - } - if ('label' in taskDefinition) { - return taskDefinition.label; - } else if ('script' in taskDefinition) { - return taskDefinition.script; - } else if ('command' in taskDefinition) { - return taskDefinition.command; +export function getTaskRepresentation(task: IConfiguredTask): string { + if (task.label) { + return task.label; + } else if (task.script) { + return task.script; + } else if (task.command) { + return task.command; } return ''; } -export async function getTaskForTool(id: string, taskDefinition: { taskLabel?: string; taskType?: string }, workspaceFolder: string, taskService: ITaskService): Promise { +export async function getTaskForTool(id: string, taskDefinition: { taskLabel?: string; taskType?: string }, workspaceFolder: string, configurationService: IConfigurationService, taskService: ITaskService): Promise { let index = 0; - let task; - const workspaceTasks: IStringDictionary | undefined = (await taskService.getWorkspaceTasks())?.get(URI.file(workspaceFolder).toString())?.configurations?.byIdentifier; - for (const workspaceTask of Object.values(workspaceTasks ?? {})) { - if ((!workspaceTask.type || workspaceTask.type === taskDefinition?.taskType) && - ((workspaceTask._label === taskDefinition?.taskLabel) - || (id === workspaceTask._label))) { - task = workspaceTask; + let task: IConfiguredTask | undefined; + const configTasks: IConfiguredTask[] = (configurationService.getValue('tasks') as { tasks: IConfiguredTask[] }).tasks ?? []; + for (const configTask of configTasks) { + if ((configTask.type && taskDefinition.taskType ? configTask.type === taskDefinition.taskType : true) && + ((getTaskRepresentation(configTask) === taskDefinition?.taskLabel) || (id === configTask.label))) { + task = configTask; break; - } else if (id === `${workspaceTask.type}: ${index}`) { - task = workspaceTask; + } else if (id === `${configTask.type}: ${index}`) { + task = configTask; break; } index++; } - if (task) { - return taskService.tryResolveTask(task); + if (!task) { + return; } - return undefined; + const configuringTasks: IStringDictionary | undefined = (await taskService.getWorkspaceTasks())?.get(URI.file(workspaceFolder).toString())?.configurations?.byIdentifier; + const configuredTask: ConfiguringTask | undefined = Object.values(configuringTasks ?? {}).find(t => { + return t.type === task.type && (t._label === task.label || t._label === `${task.type}: ${getTaskRepresentation(task)}`); + }); + let resolvedTask: Task | undefined; + if (configuredTask) { + resolvedTask = await taskService.tryResolveTask(configuredTask); + } + if (!resolvedTask) { + const customTasks: Task[] | undefined = (await taskService.getWorkspaceTasks())?.get(URI.file(workspaceFolder).toString())?.set?.tasks; + resolvedTask = customTasks?.find(t => task.label === t._label || task.label === t._label); + + } + return resolvedTask; +} + +/** + * Represents a configured task in the system. + * + * This interface is used to define tasks that can be executed within the workspace. + * It includes optional properties for identifying and describing the task. + * + * Properties: + * - `type`: (optional) The type of the task, which categorizes it (e.g., "build", "test"). + * - `label`: (optional) A user-facing label for the task, typically used for display purposes. + * - `script`: (optional) A script associated with the task, if applicable. + * - `command`: (optional) A command associated with the task, if applicable. + * + */ +interface IConfiguredTask { + label?: string; + type?: string; + script?: string; + command?: string; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index e0a3a180dc6..c61fcc543ce 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -35,7 +35,7 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary Date: Tue, 22 Jul 2025 12:50:31 -0700 Subject: [PATCH 22/27] Use _provider.resolveCompletionItem to wait lsp completion items in terminal suggest (#256783) * Wait for completions * adapt to new pylance debugdisplay name * Clean up, use ITerminalLogService * Dont use any :) * Take feedback for LogService clean up * Lazily provide doc, except doesnt work until scroll down and back up * reference suggestWidget to have docs show up with suggestWidget.list.setFocus --- .../browser/lspCompletionProviderAddon.ts | 26 ++++---- .../browser/terminal.suggest.contribution.ts | 3 +- .../suggest/browser/terminalCompletionItem.ts | 56 ++++++++++++++++- .../suggest/browser/terminalSuggestAddon.ts | 63 ++++++++++++++++++- 4 files changed, 132 insertions(+), 16 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/lspCompletionProviderAddon.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/lspCompletionProviderAddon.ts index 67afbf754c9..2844d0aba98 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/lspCompletionProviderAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/lspCompletionProviderAddon.ts @@ -51,28 +51,32 @@ export class LspCompletionProviderAddon extends Disposable implements ITerminalA const lineNum = this._textVirtualModel.object.textEditorModel.getLineCount(); const positionVirtualDocument = new Position(lineNum, column); - - // TODO: Scan back to start of nearest word like other providers? Is this needed for `ILanguageFeaturesService`? const completions: ITerminalCompletion[] = []; if (this._provider && this._provider._debugDisplayName !== 'wordbasedCompletions') { const result = await this._provider.provideCompletionItems(this._textVirtualModel.object.textEditorModel, positionVirtualDocument, { triggerKind: CompletionTriggerKind.TriggerCharacter }, token); - - completions.push(...(result?.suggestions || []).map((e: any) => { + for (const item of (result?.suggestions || [])) { // TODO: Support more terminalCompletionItemKind for [different LSP providers](https://github.com/microsoft/vscode/issues/249479) - const convertedKind = e.kind ? mapLspKindToTerminalKind(e.kind) : TerminalCompletionItemKind.Method; + const convertedKind = item.kind ? mapLspKindToTerminalKind(item.kind) : TerminalCompletionItemKind.Method; const completionItemTemp = createCompletionItemPython(cursorPosition, textBeforeCursor, convertedKind, 'lspCompletionItem', undefined); - - return { - label: e.insertText, + const terminalCompletion: ITerminalCompletion = { + label: item.label, provider: `lsp:${this._provider._debugDisplayName}`, - detail: e.detail, - documentation: e.documentation, + detail: item.detail, + documentation: item.documentation, kind: convertedKind, replacementIndex: completionItemTemp.replacementIndex, replacementLength: completionItemTemp.replacementLength, }; - })); + + // Store unresolved item and provider for lazy resolution if needed + if (this._provider.resolveCompletionItem && (!item.detail || !item.documentation)) { + terminalCompletion._unresolvedItem = item; + terminalCompletion._resolveProvider = this._provider; + } + + completions.push(terminalCompletion); + } } return completions; diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts index f29f8a178f4..dd217449536 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts @@ -190,7 +190,8 @@ class TerminalSuggestContribution extends DisposableStore implements ITerminalCo this.add(textVirtualModel); const virtualProviders = this._languageFeaturesService.completionProvider.all(textVirtualModel.object.textEditorModel); - const provider = virtualProviders.find(p => p._debugDisplayName === PYLANCE_DEBUG_DISPLAY_NAME); + // TODO: Remove hard-coded filter for Python REPL. + const provider = virtualProviders.find(p => p._debugDisplayName === PYLANCE_DEBUG_DISPLAY_NAME || p._debugDisplayName === `ms-python.vscode-pylance(.["')`); if (provider) { const lspCompletionProviderAddon = this._lspAddon.value = this._instantiationService.createInstance(LspCompletionProviderAddon, provider, textVirtualModel, this._lspModelProvider.value); diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionItem.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionItem.ts index 143ed8f6d8b..d2bbe5f5705 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionItem.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionItem.ts @@ -3,9 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { basename } from '../../../../../base/common/path.js'; import { isWindows } from '../../../../../base/common/platform.js'; -import { CompletionItemKind } from '../../../../../editor/common/languages.js'; +import { CompletionItem, CompletionItemKind, CompletionItemProvider } from '../../../../../editor/common/languages.js'; import { ISimpleCompletion, SimpleCompletionItem } from '../../../../services/suggest/browser/simpleCompletionItem.js'; export enum TerminalCompletionItemKind { @@ -71,6 +72,17 @@ export interface ITerminalCompletion extends ISimpleCompletion { * Whether the completion is a keyword. */ isKeyword?: boolean; + + /** + * Unresolved completion item from the language server provider/ + */ + _unresolvedItem?: CompletionItem; + + /** + * Provider that can resolve this item + */ + _resolveProvider?: CompletionItemProvider; + } export class TerminalCompletionItem extends SimpleCompletionItem { @@ -96,6 +108,11 @@ export class TerminalCompletionItem extends SimpleCompletionItem { */ punctuationPenalty: 0 | 1 = 0; + /** + * Completion items details (such as docs) can be lazily resolved when focused. + */ + resolveCache?: Promise; + constructor( override readonly completion: ITerminalCompletion ) { @@ -128,6 +145,43 @@ export class TerminalCompletionItem extends SimpleCompletionItem { this.punctuationPenalty = shouldPenalizeForPunctuation(this.labelLowExcludeFileExt) ? 1 : 0; } + + /** + * Resolves the completion item's details lazily when needed. + */ + async resolve(token: CancellationToken): Promise { + + if (this.resolveCache) { + return this.resolveCache; + } + + const unresolvedItem = this.completion._unresolvedItem; + const provider = this.completion._resolveProvider; + + if (!unresolvedItem || !provider || !provider.resolveCompletionItem) { + return; + } + + this.resolveCache = (async () => { + try { + const resolved = await provider.resolveCompletionItem!(unresolvedItem, token); + if (resolved) { + // Update the completion with resolved details + if (resolved.detail) { + this.completion.detail = resolved.detail; + } + if (resolved.documentation) { + this.completion.documentation = resolved.documentation; + } + } + } catch (error) { + return; + } + })(); + + return this.resolveCache; + } + } function isFile(completion: ITerminalCompletion): boolean { diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts index d2df1f96f67..1d2a13e3804 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts @@ -21,8 +21,9 @@ import { terminalSuggestConfigSection, TerminalSuggestSettingId, type ITerminalS import { LineContext } from '../../../../services/suggest/browser/simpleCompletionModel.js'; import { ISimpleSelectedSuggestion, SimpleSuggestWidget } from '../../../../services/suggest/browser/simpleSuggestWidget.js'; import { ITerminalCompletionService } from './terminalCompletionService.js'; -import { TerminalSettingId, TerminalShellType, PosixShellType, WindowsShellType, GeneralShellType } from '../../../../../platform/terminal/common/terminal.js'; +import { TerminalSettingId, TerminalShellType, PosixShellType, WindowsShellType, GeneralShellType, ITerminalLogService } from '../../../../../platform/terminal/common/terminal.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { createCancelablePromise, CancelablePromise, IntervalTimer, TimeoutTimer } from '../../../../../base/common/async.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; @@ -31,7 +32,6 @@ import { ITerminalConfigurationService } from '../../../terminal/browser/termina import { GOLDEN_LINE_HEIGHT_RATIO, MINIMUM_LINE_HEIGHT } from '../../../../../editor/common/config/fontInfo.js'; import { TerminalCompletionModel } from './terminalCompletionModel.js'; import { TerminalCompletionItem, TerminalCompletionItemKind, type ITerminalCompletion } from './terminalCompletionItem.js'; -import { IntervalTimer, TimeoutTimer } from '../../../../../base/common/async.js'; import { localize } from '../../../../../nls.js'; import { TerminalSuggestTelemetry } from './terminalSuggestTelemetry.js'; import { terminalSymbolAliasIcon, terminalSymbolArgumentIcon, terminalSymbolEnumMember, terminalSymbolFileIcon, terminalSymbolFlagIcon, terminalSymbolInlineSuggestionIcon, terminalSymbolMethodIcon, terminalSymbolOptionIcon, terminalSymbolFolderIcon, terminalSymbolSymbolicLinkFileIcon, terminalSymbolSymbolicLinkFolderIcon } from './terminalSymbolIcons.js'; @@ -89,6 +89,11 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest private _discoverability: TerminalSuggestShownTracker | undefined; + // Terminal suggest resolution tracking (similar to editor's suggest widget) + private _currentSuggestionDetails?: CancelablePromise; + private _focusedItem?: TerminalCompletionItem; + private _ignoreFocusEvents: boolean = false; + isPasting: boolean = false; shellType: TerminalShellType | undefined; private readonly _shellTypeInit: Promise; @@ -161,7 +166,8 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest @IConfigurationService private readonly _configurationService: IConfigurationService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IExtensionService private readonly _extensionService: IExtensionService, - @ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService + @ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService, + @ITerminalLogService private readonly _logService: ITerminalLogService, ) { super(); @@ -753,6 +759,53 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest } } )); + + this._register(this._suggestWidget.onDidFocus(async e => { + if (this._ignoreFocusEvents) { + return; + } + + const focusedItem = e.item; + const focusedIndex = e.index; + + if (focusedItem === this._focusedItem) { + return; + } + + // Cancel any previous resolution + this._currentSuggestionDetails?.cancel(); + this._currentSuggestionDetails = undefined; + this._focusedItem = focusedItem; + + // Check if the item needs resolution and hasn't been resolved yet + if (focusedItem && (!focusedItem.completion.documentation || !focusedItem.completion.detail)) { + + this._currentSuggestionDetails = createCancelablePromise(async token => { + try { + await focusedItem.resolve(token); + } catch (error) { + // Silently fail - the item is still usable without details + this._logService.warn(`Failed to resolve suggestion details for item ${focusedItem} at index ${focusedIndex}`, error); + } + }); + + this._currentSuggestionDetails.then(() => { + // Check if this is still the focused item and it's still in the list + if (focusedItem !== this._focusedItem || !this._suggestWidget?.list || focusedIndex >= this._suggestWidget.list.length) { + return; + } + + // Re-render the specific item to show resolved details (like editor does) + this._ignoreFocusEvents = true; + // Use splice to replace the item and trigger re-render + this._suggestWidget.list.splice(focusedIndex, 1, [focusedItem]); + this._suggestWidget.list.setFocus([focusedIndex]); + this._ignoreFocusEvents = false; + }); + } + + })); + const element = this._terminal?.element?.querySelector('.xterm-helper-textarea'); if (element) { this._register(dom.addDisposableListener(dom.getActiveDocument(), 'click', (event) => { @@ -912,9 +965,13 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest if (cancelAnyRequest) { this._cancellationTokenSource?.cancel(); this._cancellationTokenSource = undefined; + // Also cancel any pending resolution requests + this._currentSuggestionDetails?.cancel(); + this._currentSuggestionDetails = undefined; } this._currentPromptInputState = undefined; this._leadingLineContent = undefined; + this._focusedItem = undefined; this._suggestWidget?.hide(); } } From 57562a894b793a807ea57861572e21d52b302f9c Mon Sep 17 00:00:00 2001 From: Aaron Munger <2019016+amunger@users.noreply.github.com> Date: Tue, 22 Jul 2025 13:08:27 -0700 Subject: [PATCH 23/27] set notebook inline chat height based on full notebook editor height (#257332) * notebook inline chat height based on full notebook editor height * just pass optional param --- .../browser/inlineChatController.ts | 20 +++++++++++-------- .../browser/inlineChatZoneWidget.ts | 11 ++++++---- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index eb264de31db..c26f84ea791 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -63,6 +63,7 @@ import { IFileService } from '../../../../platform/files/common/files.js'; import { IChatAttachmentResolveService } from '../../chat/browser/chatAttachmentResolveService.js'; import { INotebookService } from '../../notebook/common/notebookService.js'; import { ICellEditOperation } from '../../notebook/common/notebookCommon.js'; +import { INotebookEditor } from '../../notebook/browser/notebookBrowser.js'; export const enum State { CREATE_SESSION = 'CREATE_SESSION', @@ -242,16 +243,18 @@ export class InlineChatController1 implements IEditorContribution { // check if this editor is part of a notebook editor // and iff so, use the notebook location but keep the resolveData // talk about editor data - for (const notebookEditor of notebookEditorService.listNotebookEditors()) { - for (const [, codeEditor] of notebookEditor.codeEditors) { + let notebookEditor: INotebookEditor | undefined; + for (const editor of notebookEditorService.listNotebookEditors()) { + for (const [, codeEditor] of editor.codeEditors) { if (codeEditor === this._editor) { + notebookEditor = editor; location.location = ChatAgentLocation.Notebook; break; } } } - const zone = _instaService.createInstance(InlineChatZoneWidget, location, undefined, this._editor); + const zone = _instaService.createInstance(InlineChatZoneWidget, location, undefined, { editor: this._editor, notebookEditor }); this._store.add(zone); this._store.add(zone.widget.chatWidget.onDidClear(async () => { const r = this.joinCurrentRun(); @@ -1260,12 +1263,13 @@ export class InlineChatController2 implements IEditorContribution { // inline chat in notebooks // check if this editor is part of a notebook editor - // and iff so, use the notebook location but keep the resolveData - // talk about editor data - for (const notebookEditor of this._notebookEditorService.listNotebookEditors()) { - for (const [, codeEditor] of notebookEditor.codeEditors) { + // if so, update the location and use the notebook specific widget + let notebookEditor: INotebookEditor | undefined; + for (const editor of this._notebookEditorService.listNotebookEditors()) { + for (const [, codeEditor] of editor.codeEditors) { if (codeEditor === this._editor) { location.location = ChatAgentLocation.Notebook; + notebookEditor = editor; break; } } @@ -1279,7 +1283,7 @@ export class InlineChatController2 implements IEditorContribution { renderTextEditsAsSummary: _uri => true } }, - this._editor + { editor: this._editor, notebookEditor }, ); result.domNode.classList.add('inline-chat-2'); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts index 83836042939..85a7c8b047b 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts @@ -22,6 +22,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { IChatWidgetViewOptions } from '../../chat/browser/chat.js'; import { IChatWidgetLocationOptions } from '../../chat/browser/chatWidget.js'; import { isResponseVM } from '../../chat/common/chatViewModel.js'; +import { INotebookEditor } from '../../notebook/browser/notebookBrowser.js'; import { ACTION_REGENERATE_RESPONSE, ACTION_REPORT_ISSUE, ACTION_TOGGLE_DIFF, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, MENU_INLINE_CHAT_SIDE, MENU_INLINE_CHAT_WIDGET_SECONDARY, MENU_INLINE_CHAT_WIDGET_STATUS } from '../common/inlineChat.js'; import { EditorBasedInlineChatWidget } from './inlineChatWidget.js'; @@ -45,16 +46,18 @@ export class InlineChatZoneWidget extends ZoneWidget { private readonly _scrollUp = this._disposables.add(new ScrollUpState(this.editor)); private readonly _ctxCursorPosition: IContextKey<'above' | 'below' | ''>; private _dimension?: Dimension; + private notebookEditor?: INotebookEditor; constructor( location: IChatWidgetLocationOptions, options: IChatWidgetViewOptions | undefined, - editor: ICodeEditor, + editors: { editor: ICodeEditor; notebookEditor?: INotebookEditor }, @IInstantiationService private readonly _instaService: IInstantiationService, @ILogService private _logService: ILogService, @IContextKeyService contextKeyService: IContextKeyService, ) { - super(editor, InlineChatZoneWidget._options); + super(editors.editor, InlineChatZoneWidget._options); + this.notebookEditor = editors.notebookEditor; this._ctxCursorPosition = CTX_INLINE_CHAT_OUTER_CURSOR_POSITION.bindTo(contextKeyService); @@ -87,7 +90,7 @@ export class InlineChatZoneWidget extends ZoneWidget { rendererOptions: { renderTextEditsAsSummary: (uri) => { // render when dealing with the current file in the editor - return isEqual(uri, editor.getModel()?.uri); + return isEqual(uri, editors.editor.getModel()?.uri); }, renderDetectedCommandsWithRequest: true, ...options?.rendererOptions @@ -165,7 +168,7 @@ export class InlineChatZoneWidget extends ZoneWidget { private _computeHeight(): { linesValue: number; pixelsValue: number } { const chatContentHeight = this.widget.contentHeight; - const editorHeight = this.editor.getLayoutInfo().height; + const editorHeight = this.notebookEditor?.getLayoutInfo().height ?? this.editor.getLayoutInfo().height; const contentHeight = this._decoratingElementsHeight() + Math.min(chatContentHeight, Math.max(this.widget.minHeight, editorHeight * 0.42)); const heightInLines = contentHeight / this.editor.getOption(EditorOption.lineHeight); From ae0312188010b22a410eb5d6cd5038676172842f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Jul 2025 13:32:01 -0700 Subject: [PATCH 24/27] Fix crash when problemMatcher has empty pattern array (#256612) * Initial plan * Fix empty pattern array crash - add guard clauses Co-authored-by: anthonykim1 <62267334+anthonykim1@users.noreply.github.com> * Address review feedback: Add undefined checks to array validation Co-authored-by: anthonykim1 <62267334+anthonykim1@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: anthonykim1 <62267334+anthonykim1@users.noreply.github.com> --- src/vs/workbench/contrib/tasks/common/problemMatcher.ts | 8 ++++++++ .../contrib/tasks/test/common/problemMatcher.test.ts | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/src/vs/workbench/contrib/tasks/common/problemMatcher.ts b/src/vs/workbench/contrib/tasks/common/problemMatcher.ts index 4f3842a5670..16b5b849647 100644 --- a/src/vs/workbench/contrib/tasks/common/problemMatcher.ts +++ b/src/vs/workbench/contrib/tasks/common/problemMatcher.ts @@ -987,6 +987,10 @@ export class ProblemPatternParser extends Parser { } result.push(pattern); } + if (!result || result.length === 0) { + this.error(localize('ProblemPatternParser.problemPattern.emptyPattern', 'The problem pattern is invalid. It must contain at least one pattern.')); + return null; + } if (result[0].kind === undefined) { result[0].kind = ProblemLocationKind.Location; } @@ -1042,6 +1046,10 @@ export class ProblemPatternParser extends Parser { } private validateProblemPattern(values: IProblemPattern[]): boolean { + if (!values || values.length === 0) { + this.error(localize('ProblemPatternParser.problemPattern.emptyPattern', 'The problem pattern is invalid. It must contain at least one pattern.')); + return false; + } let file: boolean = false, message: boolean = false, location: boolean = false, line: boolean = false; const locationKind = (values[0].kind === undefined) ? ProblemLocationKind.Location : values[0].kind; diff --git a/src/vs/workbench/contrib/tasks/test/common/problemMatcher.test.ts b/src/vs/workbench/contrib/tasks/test/common/problemMatcher.test.ts index 70dab691107..0b14df78ffe 100644 --- a/src/vs/workbench/contrib/tasks/test/common/problemMatcher.test.ts +++ b/src/vs/workbench/contrib/tasks/test/common/problemMatcher.test.ts @@ -256,5 +256,13 @@ suite('ProblemPatternParser', () => { assert.strictEqual(ValidationState.Error, reporter.state); assert(reporter.hasMessage('The problem pattern is invalid. It must have at least have a file and a message.')); }); + + test('empty pattern array should be handled gracefully', () => { + const problemPattern: matchers.Config.MultiLineProblemPattern = []; + const parsed = parser.parse(problemPattern); + assert.strictEqual(null, parsed); + assert.strictEqual(ValidationState.Error, reporter.state); + assert(reporter.hasMessage('The problem pattern is invalid. It must contain at least one pattern.')); + }); }); }); From 90072b47cf68321a916c4fbb04ed927b01e50af4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Jul 2025 13:35:24 -0700 Subject: [PATCH 25/27] Fix serve-web port randomization when --port 0 is specified (#254676) * Initial plan * Fix serve-web port randomization when --port 0 is specified Co-authored-by: connor4312 <2230985+connor4312@users.noreply.github.com> * Fix serve-web to display actual bound port instead of 0 When --port 0 is specified, the OS assigns a random port but the logging was showing port 0 instead of the actual assigned port. Fixed by reading the local_addr() from the server builder after binding. Co-authored-by: connor4312 <2230985+connor4312@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: connor4312 <2230985+connor4312@users.noreply.github.com> --- cli/src/commands/serve_web.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/serve_web.rs b/cli/src/commands/serve_web.rs index a8b09b82838..8fdb4a40737 100644 --- a/cli/src/commands/serve_web.rs +++ b/cli/src/commands/serve_web.rs @@ -127,7 +127,9 @@ pub async fn serve_web(ctx: CommandContext, mut args: ServeWebArgs) -> Result Date: Tue, 22 Jul 2025 15:10:03 -0700 Subject: [PATCH 26/27] worktree error handling in creation of worktrees and checking out branches (#257328) * worktree error handling in creation of worktrees and checking out branches * code clean up --- extensions/git/src/commands.ts | 71 ++++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index ec2223c7f17..224494fd164 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -2917,10 +2917,15 @@ export class CommandCenter { try { await item.run(repository, opts); } catch (err) { - if (err.gitErrorCode !== GitErrorCodes.DirtyWorkTree) { + if (err.gitErrorCode !== GitErrorCodes.DirtyWorkTree && err.gitErrorCode !== GitErrorCodes.WorktreeAlreadyExists) { throw err; } + if (err.gitErrorCode === GitErrorCodes.WorktreeAlreadyExists) { + this.handleWorktreeError(err); + return false; + } + const stash = l10n.t('Stash & Checkout'); const migrate = l10n.t('Migrate Changes'); const force = l10n.t('Force Checkout'); @@ -3458,39 +3463,47 @@ export class CommandCenter { try { await repository.worktree({ name: name, path: worktreePath }); } catch (err) { - if (err.gitErrorCode === GitErrorCodes.WorktreeAlreadyExists) { - const errorMessage = err.stderr; - const match = errorMessage.match(/worktree at '([^']+)'/) || errorMessage.match(/'([^']+)'/); - const path = match ? match[1] : undefined; - - if (!path) { - return; - } - - const openWorktree = l10n.t('Open in current window'); - const openWorktreeInNewWindow = l10n.t('Open in new window'); - const message = l10n.t(errorMessage || 'A worktree for branch \'{0}\' already exists at \'{1}\'.', name, path); - const choice = await window.showWarningMessage(message, { modal: true }, openWorktree, openWorktreeInNewWindow); - - const worktreeRepository = this.model.getRepository(path) || this.model.getRepository(Uri.file(path)); - - if (!worktreeRepository) { - return; - } - - if (choice === openWorktree) { - await this.openWorktreeInCurrentWindow(worktreeRepository); - } else if (choice === openWorktreeInNewWindow) { - await this.openWorktreeInNewWindow(worktreeRepository); - } - - return; + if (err.gitErrorCode !== GitErrorCodes.WorktreeAlreadyExists) { + throw err; } - throw err; + this.handleWorktreeError(err); + return; + } } + private async handleWorktreeError(err: any): Promise { + const errorMessage = err.stderr; + const match = errorMessage.match(/worktree at '([^']+)'/) || errorMessage.match(/'([^']+)'/); + const path = match ? match[1] : undefined; + + if (!path) { + return; + } + + const worktreeRepository = this.model.getRepository(path) || this.model.getRepository(Uri.file(path)); + + if (!worktreeRepository) { + return; + } + + const openWorktree = l10n.t('Open in current window'); + const openWorktreeInNewWindow = l10n.t('Open in new window'); + const message = l10n.t(errorMessage); + const choice = await window.showWarningMessage(message, { modal: true }, openWorktree, openWorktreeInNewWindow); + + + + if (choice === openWorktree) { + await this.openWorktreeInCurrentWindow(worktreeRepository); + } else if (choice === openWorktreeInNewWindow) { + await this.openWorktreeInNewWindow(worktreeRepository); + } + + return; + } + @command('git.deleteWorktree', { repository: true }) async deleteWorktree(repository: Repository): Promise { if (!repository.dotGit.commonPath) { From ff6f70b9c3fef3dd1087d61e49b0efbc67582c0b Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 22 Jul 2025 16:36:59 -0700 Subject: [PATCH 27/27] testing: move tests tool to core (#257350) Allows it to be more reliable and also do incremental progress updates with proper cancellation. Closes https://github.com/microsoft/vscode-copilot-release/issues/9899 Closes https://github.com/microsoft/vscode-copilot-release/issues/10843 Closes https://github.com/microsoft/vscode-copilot-release/issues/6757 Closes https://github.com/microsoft/vscode/issues/256324 Closes https://github.com/microsoft/vscode/issues/252179 (probably) Closes https://github.com/microsoft/vscode/issues/250145 (probably) --- .../testing/browser/testing.contribution.ts | 31 +- .../testing/browser/testingExplorerView.ts | 2 +- .../testing/browser/testingOutputPeek.ts | 2 + .../browser/testingProgressUiService.ts | 64 +--- .../contrib/testing/common/testService.ts | 4 +- .../contrib/testing/common/testServiceImpl.ts | 1 + .../testing/common/testingChatAgentTool.ts | 320 ++++++++++++++++++ .../testing/common/testingContentProvider.ts | 2 + .../testing/common/testingProgressMessages.ts | 63 ++++ 9 files changed, 413 insertions(+), 76 deletions(-) create mode 100644 src/vs/workbench/contrib/testing/common/testingChatAgentTool.ts create mode 100644 src/vs/workbench/contrib/testing/common/testingProgressMessages.ts diff --git a/src/vs/workbench/contrib/testing/browser/testing.contribution.ts b/src/vs/workbench/contrib/testing/browser/testing.contribution.ts index 986b6385629..d38d7328456 100644 --- a/src/vs/workbench/contrib/testing/browser/testing.contribution.ts +++ b/src/vs/workbench/contrib/testing/browser/testing.contribution.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { URI } from '../../../../base/common/uri.js'; import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; import { localize, localize2 } from '../../../../nls.js'; import { registerAction2 } from '../../../../platform/actions/common/actions.js'; @@ -17,17 +18,10 @@ import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IProgressService } from '../../../../platform/progress/common/progress.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js'; -import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from '../../../common/contributions.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { IViewContainersRegistry, IViewsRegistry, Extensions as ViewContainerExtensions, ViewContainerLocation } from '../../../common/views.js'; +import { IViewsService } from '../../../services/views/common/viewsService.js'; import { REVEAL_IN_EXPLORER_COMMAND_ID } from '../../files/browser/fileConstants.js'; -import { CodeCoverageDecorations } from './codeCoverageDecorations.js'; -import { testingResultsIcon, testingViewIcon } from './icons.js'; -import { TestCoverageView } from './testCoverageView.js'; -import { TestingDecorationService, TestingDecorations } from './testingDecorations.js'; -import { TestingExplorerView } from './testingExplorerView.js'; -import { CloseTestPeek, CollapsePeekStack, GoToNextMessageAction, GoToPreviousMessageAction, OpenMessageInEditorAction, TestResultsView, TestingOutputPeekController, TestingPeekOpener, ToggleTestingPeekHistory } from './testingOutputPeek.js'; -import { TestingProgressTrigger } from './testingProgressUiService.js'; -import { TestingViewPaneContainer } from './testingViewPaneContainer.js'; import { testingConfiguration } from '../common/configuration.js'; import { TestCommandId, Testing } from '../common/constants.js'; import { ITestCoverageService, TestCoverageService } from '../common/testCoverageService.js'; @@ -39,16 +33,22 @@ import { ITestResultStorage, TestResultStorage } from '../common/testResultStora import { ITestService } from '../common/testService.js'; import { TestService } from '../common/testServiceImpl.js'; import { ITestItem, ITestRunProfileReference, TestRunProfileBitset } from '../common/testTypes.js'; +import { TestingChatAgentToolContribution } from '../common/testingChatAgentTool.js'; import { TestingContentProvider } from '../common/testingContentProvider.js'; import { TestingContextKeys } from '../common/testingContextKeys.js'; import { ITestingContinuousRunService, TestingContinuousRunService } from '../common/testingContinuousRunService.js'; import { ITestingDecorationsService } from '../common/testingDecorations.js'; import { ITestingPeekOpener } from '../common/testingPeekOpener.js'; -import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; -import { IViewsService } from '../../../services/views/common/viewsService.js'; +import { CodeCoverageDecorations } from './codeCoverageDecorations.js'; +import { testingResultsIcon, testingViewIcon } from './icons.js'; +import { TestCoverageView } from './testCoverageView.js'; import { allTestActions, discoverAndRunTests } from './testExplorerActions.js'; import './testingConfigurationUi.js'; -import { URI } from '../../../../base/common/uri.js'; +import { TestingDecorations, TestingDecorationService } from './testingDecorations.js'; +import { TestingExplorerView } from './testingExplorerView.js'; +import { CloseTestPeek, CollapsePeekStack, GoToNextMessageAction, GoToPreviousMessageAction, OpenMessageInEditorAction, TestingOutputPeekController, TestingPeekOpener, TestResultsView, ToggleTestingPeekHistory } from './testingOutputPeek.js'; +import { TestingProgressTrigger } from './testingProgressUiService.js'; +import { TestingViewPaneContainer } from './testingViewPaneContainer.js'; registerSingleton(ITestService, TestService, InstantiationType.Delayed); registerSingleton(ITestResultStorage, TestResultStorage, InstantiationType.Delayed); @@ -139,9 +139,10 @@ registerAction2(CloseTestPeek); registerAction2(ToggleTestingPeekHistory); registerAction2(CollapsePeekStack); -Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(TestingContentProvider, LifecyclePhase.Restored); -Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(TestingPeekOpener, LifecyclePhase.Eventually); -Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(TestingProgressTrigger, LifecyclePhase.Eventually); +registerWorkbenchContribution2(TestingContentProvider.ID, TestingContentProvider, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(TestingPeekOpener.ID, TestingPeekOpener, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(TestingProgressTrigger.ID, TestingProgressTrigger, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(TestingChatAgentToolContribution.ID, TestingChatAgentToolContribution, WorkbenchPhase.Eventually); registerEditorContribution(Testing.OutputPeekContributionId, TestingOutputPeekController, EditorContributionInstantiation.AfterFirstRender); registerEditorContribution(Testing.DecorationsContributionId, TestingDecorations, EditorContributionInstantiation.AfterFirstRender); diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts index 46adb0aff16..89d49ff6eea 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts @@ -71,6 +71,7 @@ import { ITestRunProfile, InternalTestItem, TestControllerCapability, TestItemEx import { TestingContextKeys } from '../common/testingContextKeys.js'; import { ITestingContinuousRunService } from '../common/testingContinuousRunService.js'; import { ITestingPeekOpener } from '../common/testingPeekOpener.js'; +import { CountSummary, collectTestStateCounts, getTestProgressText } from '../common/testingProgressMessages.js'; import { cmpPriority, isFailedState, isStateWithResult, statesInOrder } from '../common/testingStates.js'; import { ITestTreeProjection, TestExplorerTreeElement, TestItemTreeElement, TestTreeErrorMessage } from './explorerProjections/index.js'; import { ListProjection } from './explorerProjections/listProjection.js'; @@ -82,7 +83,6 @@ import * as icons from './icons.js'; import './media/testing.css'; import { DebugLastRun, ReRunLastRun } from './testExplorerActions.js'; import { TestingExplorerFilter } from './testingExplorerFilter.js'; -import { CountSummary, collectTestStateCounts, getTestProgressText } from './testingProgressUiService.js'; const enum LastFocusState { Input, diff --git a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts index 788ed44ccd0..c5a6ccbe54e 100644 --- a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts +++ b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts @@ -111,6 +111,8 @@ function messageItReferenceToUri({ result, test, taskIndex, messageIndex }: IMes type TestUriWithDocument = ParsedTestUri & { documentUri: URI }; export class TestingPeekOpener extends Disposable implements ITestingPeekOpener { + public static readonly ID = 'workbench.contrib.testing.peekOpener'; + declare _serviceBrand: undefined; private lastUri?: TestUriWithDocument; diff --git a/src/vs/workbench/contrib/testing/browser/testingProgressUiService.ts b/src/vs/workbench/contrib/testing/browser/testingProgressUiService.ts index dae33a8369d..f1ccdeb1f03 100644 --- a/src/vs/workbench/contrib/testing/browser/testingProgressUiService.ts +++ b/src/vs/workbench/contrib/testing/browser/testingProgressUiService.ts @@ -5,20 +5,20 @@ import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; -import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { ExplorerTestCoverageBars } from './testCoverageBars.js'; +import { IViewsService } from '../../../services/views/common/viewsService.js'; import { AutoOpenTesting, getTestingConfiguration, TestingConfigKeys } from '../common/configuration.js'; import { Testing } from '../common/constants.js'; import { ITestCoverageService } from '../common/testCoverageService.js'; import { isFailedState } from '../common/testingStates.js'; -import { ITestResult, LiveTestResult, TestResultItemChangeReason } from '../common/testResult.js'; +import { LiveTestResult, TestResultItemChangeReason } from '../common/testResult.js'; import { ITestResultService } from '../common/testResultService.js'; -import { TestResultState } from '../common/testTypes.js'; -import { IViewsService } from '../../../services/views/common/viewsService.js'; +import { ExplorerTestCoverageBars } from './testCoverageBars.js'; /** Workbench contribution that triggers updates in the TestingProgressUi service */ export class TestingProgressTrigger extends Disposable { + public static readonly ID = 'workbench.contrib.testing.progressTrigger'; + constructor( @ITestResultService resultService: ITestResultService, @ITestCoverageService testCoverageService: ITestCoverageService, @@ -83,57 +83,3 @@ export class TestingProgressTrigger extends Disposable { this.viewsService.openView(Testing.ResultsViewId, false); } } - -export type CountSummary = ReturnType; - -export const collectTestStateCounts = (isRunning: boolean, results: ReadonlyArray) => { - let passed = 0; - let failed = 0; - let skipped = 0; - let running = 0; - let queued = 0; - - for (const result of results) { - const count = result.counts; - failed += count[TestResultState.Errored] + count[TestResultState.Failed]; - passed += count[TestResultState.Passed]; - skipped += count[TestResultState.Skipped]; - running += count[TestResultState.Running]; - queued += count[TestResultState.Queued]; - } - - return { - isRunning, - passed, - failed, - runSoFar: passed + failed, - totalWillBeRun: passed + failed + queued + running, - skipped, - }; -}; - -export const getTestProgressText = ({ isRunning, passed, runSoFar, totalWillBeRun, skipped, failed }: CountSummary) => { - let percent = passed / runSoFar * 100; - if (failed > 0) { - // fix: prevent from rounding to 100 if there's any failed test - percent = Math.min(percent, 99.9); - } else if (runSoFar === 0) { - percent = 0; - } - - if (isRunning) { - if (runSoFar === 0) { - return localize('testProgress.runningInitial', 'Running tests...'); - } else if (skipped === 0) { - return localize('testProgress.running', 'Running tests, {0}/{1} passed ({2}%)', passed, totalWillBeRun, percent.toPrecision(3)); - } else { - return localize('testProgressWithSkip.running', 'Running tests, {0}/{1} tests passed ({2}%, {3} skipped)', passed, totalWillBeRun, percent.toPrecision(3), skipped); - } - } else { - if (skipped === 0) { - return localize('testProgress.completed', '{0}/{1} tests passed ({2}%)', passed, runSoFar, percent.toPrecision(3)); - } else { - return localize('testProgressWithSkip.completed', '{0}/{1} tests passed ({2}%, {3} skipped)', passed, runSoFar, percent.toPrecision(3), skipped); - } - } -}; diff --git a/src/vs/workbench/contrib/testing/common/testService.ts b/src/vs/workbench/contrib/testing/common/testService.ts index 643a04d2697..35769fc7126 100644 --- a/src/vs/workbench/contrib/testing/common/testService.ts +++ b/src/vs/workbench/contrib/testing/common/testService.ts @@ -153,7 +153,7 @@ export const expandAndGetTestById = async (collection: IMainThreadTestCollection /** * Waits for the test to no longer be in the "busy" state. */ -const waitForTestToBeIdle = (testService: ITestService, test: IncrementalTestCollectionItem) => { +export const waitForTestToBeIdle = (testService: ITestService, test: IncrementalTestCollectionItem) => { if (!test.item.busy) { return; } @@ -318,6 +318,8 @@ export interface AmbiguousRunTestsRequest { exclude?: InternalTestItem[]; /** Whether this was triggered from an auto run. */ continuous?: boolean; + /** Whether this was trigged by a user action in UI. Default=true */ + preserveFocus?: boolean; } export interface ITestFollowup { diff --git a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts index 67febf8cb4a..5ff7b8c97ac 100644 --- a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts +++ b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts @@ -179,6 +179,7 @@ export class TestService extends Disposable implements ITestService { group: req.group, exclude: req.exclude?.map(t => t.item.extId), continuous: req.continuous, + preserveFocus: req.preserveFocus, }; // If no tests are covered by the defaults, just use whatever the defaults diff --git a/src/vs/workbench/contrib/testing/common/testingChatAgentTool.ts b/src/vs/workbench/contrib/testing/common/testingChatAgentTool.ts new file mode 100644 index 00000000000..c95e5424c53 --- /dev/null +++ b/src/vs/workbench/contrib/testing/common/testingChatAgentTool.ts @@ -0,0 +1,320 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { disposableTimeout, RunOnceScheduler } from '../../../../base/common/async.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { basename, isAbsolute } from '../../../../base/common/path.js'; +import { isDefined } from '../../../../base/common/types.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { + CountTokensCallback, + ILanguageModelToolsService, + IPreparedToolInvocation, + IToolData, + IToolImpl, + IToolInvocation, + IToolInvocationPreparationContext, + IToolResult, + ToolDataSource, + ToolProgress, +} from '../../chat/common/languageModelToolsService.js'; +import { TestId } from './testId.js'; +import { TestingContextKeys } from './testingContextKeys.js'; +import { getTestProgressText, collectTestStateCounts } from './testingProgressMessages.js'; +import { isFailedState } from './testingStates.js'; +import { LiveTestResult } from './testResult.js'; +import { ITestResultService } from './testResultService.js'; +import { ITestService, testsInFile, waitForTestToBeIdle } from './testService.js'; +import { IncrementalTestCollectionItem, TestItemExpandState, TestMessageType, TestResultState, TestRunProfileBitset } from './testTypes.js'; + +export class TestingChatAgentToolContribution extends Disposable implements IWorkbenchContribution { + public static readonly ID = 'workbench.contrib.testing.chatAgentTool'; + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @ILanguageModelToolsService toolsService: ILanguageModelToolsService, + @IContextKeyService contextKeyService: IContextKeyService + ) { + super(); + const runInTerminalTool = instantiationService.createInstance(RunTestTool); + this._register(toolsService.registerToolData(RunTestTool.DEFINITION)); + this._register( + toolsService.registerToolImplementation(RunTestTool.ID, runInTerminalTool) + ); + + // todo@connor4312: temporary for 1.103 release during changeover + contextKeyService.createKey('chat.coreTestFailureToolEnabled', true).set(true); + } +} + +interface IRunTestToolParams { + files?: string[]; + testNames?: string[]; +} + +class RunTestTool extends Disposable implements IToolImpl { + public static readonly ID = 'runTests'; + public static readonly DEFINITION: IToolData = { + id: this.ID, + toolReferenceName: 'runTests', + canBeReferencedInPrompt: true, + when: TestingContextKeys.hasRunnableTests, + displayName: 'Run tests', + modelDescription: 'Runs unit tests in files. Use this tool if the user asks to run tests or when you want to validate changes using unit tests. When possible, always try to provide `files` paths containing the relevant unit tests in order to avoid unnecessarily long test runs.', + inputSchema: { + type: 'object', + properties: { + files: { + type: 'array', + items: { + type: 'string', + }, + description: 'Absolute paths to the test files to run. If not provided, all test files will be run.', + }, + testNames: { + type: 'array', + items: { + type: 'string', + }, + description: 'An array of test suites, test classes, or test cases to run. If not provided, all tests in the files will be run.', + } + }, + }, + userDescription: localize('runTestTool.userDescription', 'Runs unit tests'), + source: ToolDataSource.Internal, + tags: [ + 'vscode_editing_with_tests', + 'enable_other_tool_copilot_readFile', + 'enable_other_tool_copilot_listDirectory', + 'enable_other_tool_copilot_findFiles', + 'enable_other_tool_copilot_runTests', + ], + }; + + constructor( + @ITestService private readonly _testService: ITestService, + @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @ITestResultService private readonly _testResultService: ITestResultService, + ) { + super(); + } + + async invoke(invocation: IToolInvocation, countTokens: CountTokensCallback, progress: ToolProgress, token: CancellationToken): Promise { + const params: IRunTestToolParams = invocation.parameters; + const testFiles = await this._getFileTestsToRun(params, progress); + const testCases = await this._getTestCasesToRun(params, testFiles, progress); + if (!testCases.length) { + return { + content: [{ kind: 'text', value: 'No tests found in the files. Ensure the correct absolute paths are passed to the tool.' }], + toolResultError: localize('runTestTool.noTests', 'No tests found in the files'), + }; + } + + progress.report({ message: localize('runTestTool.invoke.progress', 'Starting test run...') }); + + const result = await this._captureTestResult(testCases, token); + if (!result) { + return { + content: [{ kind: 'text', value: 'No test run was started. Instruct the user to ensure their test runner is correctly configured' }], + toolResultError: localize('runTestTool.noRunStarted', 'No test run was started. This may be an issue with your test runner or extension.'), + }; + } + + await this._monitorRunProgress(result, progress, token); + + if (token.isCancellationRequested) { + this._testService.cancelTestRun(result.id); + return { + content: [{ kind: 'text', value: localize('runTestTool.invoke.cancelled', 'Test run was cancelled.') }], + toolResultMessage: localize('runTestTool.invoke.cancelled', 'Test run was cancelled.'), + }; + } + + return { + content: [{ kind: 'text', value: this._makeModelTestResults(result) }], + toolResultMessage: getTestProgressText(collectTestStateCounts(true, [result])), + }; + } + + private _makeModelTestResults(result: LiveTestResult) { + const failures = result.counts[TestResultState.Errored] + result.counts[TestResultState.Failed]; + let str = ``; + if (failures === 0) { + return str; + } + + for (const failure of result.tests) { + if (!isFailedState(failure.ownComputedState)) { + continue; + } + + const [, ...testPath] = TestId.split(failure.item.extId); + const testName = testPath.pop(); + str += ` '))}>\n`; + str += failure.tasks.flatMap(t => t.messages.filter(m => m.type === TestMessageType.Error)).join('\n\n'); + str += `\n\n`; + } + + return str; + } + + /** Updates the UI progress as the test runs, resolving when the run is finished. */ + private async _monitorRunProgress(result: LiveTestResult, progress: ToolProgress, token: CancellationToken): Promise { + const store = new DisposableStore(); + + const update = () => { + const counts = collectTestStateCounts(!result.completedAt, [result]); + const text = getTestProgressText(counts); + progress.report({ message: text, increment: counts.runSoFar - lastSoFar, total: counts.totalWillBeRun }); + lastSoFar = counts.runSoFar; + }; + + let lastSoFar = 0; + const throttler = store.add(new RunOnceScheduler(update, 500)); + + return new Promise(resolve => { + store.add(result.onChange(() => { + if (!throttler.isScheduled) { + throttler.schedule(); + } + })); + + store.add(token.onCancellationRequested(() => { + this._testService.cancelTestRun(result.id); + resolve(); + })); + + store.add(result.onComplete(() => { + update(); + resolve(); + })); + }).finally(() => store.dispose()); + } + + /** + * Captures the test result. This is a little tricky because some extensions + * trigger an 'out of bound' test run, so we actually wait for the first + * test run to come in that contains one or more tasks and treat that as the + * one we're looking for. + */ + private async _captureTestResult(testCases: IncrementalTestCollectionItem[], token: CancellationToken): Promise { + const store = new DisposableStore(); + const onDidTimeout = store.add(new Emitter()); + + return new Promise(resolve => { + store.add(onDidTimeout.event(() => { + resolve(undefined); + })); + + store.add(this._testResultService.onResultsChanged(ev => { + if ('started' in ev) { + store.add(ev.started.onNewTask(() => { + store.dispose(); + resolve(ev.started); + })); + } + })); + + this._testService.runTests({ + group: TestRunProfileBitset.Run, + tests: testCases, + preserveFocus: true, + }, token).then(() => { + if (!store.isDisposed) { + store.add(disposableTimeout(() => onDidTimeout.fire(), 5_000)); + } + }); + }).finally(() => store.dispose()); + } + + /** Filters the test files to individual test cases based on the provided parameters. */ + private async _getTestCasesToRun(params: IRunTestToolParams, tests: IncrementalTestCollectionItem[], progress: ToolProgress): Promise { + if (!params.testNames?.length) { + return tests; + } + + progress.report({ message: localize('runTestTool.invoke.filterProgress', 'Filtering tests...') }); + + const testNames = params.testNames.map(t => t.toLowerCase().trim()); + const filtered: IncrementalTestCollectionItem[] = []; + const doFilter = async (test: IncrementalTestCollectionItem) => { + const name = test.item.label.toLowerCase().trim(); + if (testNames.some(tn => name.includes(tn))) { + filtered.push(test); + return; + } + + if (test.expand === TestItemExpandState.Expandable) { + await this._testService.collection.expand(test.item.extId, 1); + } + await waitForTestToBeIdle(this._testService, test); + await Promise.all([...test.children].map(async id => { + const item = this._testService.collection.getNodeById(id); + if (item) { + await doFilter(item); + } + })); + }; + + await Promise.all(tests.map(doFilter)); + return filtered; + } + + /** Gets the file tests to run based on the provided parameters. */ + private async _getFileTestsToRun(params: IRunTestToolParams, progress: ToolProgress): Promise { + if (!params.files?.length) { + return [...this._testService.collection.rootItems]; + } + + progress.report({ message: localize('runTestTool.invoke.filesProgress', 'Discovering tests...') }); + + const firstWorkspaceFolder = this._workspaceContextService.getWorkspace().folders.at(0)?.uri; + const uris = params.files.map(f => { + if (isAbsolute(f)) { + return URI.file(f); + } else if (firstWorkspaceFolder) { + return URI.joinPath(firstWorkspaceFolder, f); + } else { + return undefined; + } + }).filter(isDefined); + + const tests: IncrementalTestCollectionItem[] = []; + for (const uri of uris) { + for await (const file of testsInFile(this._testService, this._uriIdentityService, uri, undefined, false)) { + tests.push(file); + } + } + + return tests; + } + + prepareToolInvocation(context: IToolInvocationPreparationContext, token: CancellationToken): Promise { + const params: IRunTestToolParams = context.parameters; + const title = localize('runTestTool.confirm.title', 'Allow test run?'); + const inFiles = params.files?.map((f: string) => '`' + basename(f) + '`'); + + return Promise.resolve({ + invocationMessage: localize('runTestTool.confirm.invocation', 'Running tests...'), + confirmationMessages: { + title, + message: inFiles?.length + ? new MarkdownString().appendMarkdown(localize('runTestTool.confirm.message', 'The model wants to run tests in {0}.', inFiles.join(', '))) + : localize('runTestTool.confirm.all', 'The model wants to run all tests.'), + allowAutoConfirm: true, + }, + }); + } +} diff --git a/src/vs/workbench/contrib/testing/common/testingContentProvider.ts b/src/vs/workbench/contrib/testing/common/testingContentProvider.ts index 3ef6281ac64..dc5d6d8d017 100644 --- a/src/vs/workbench/contrib/testing/common/testingContentProvider.ts +++ b/src/vs/workbench/contrib/testing/common/testingContentProvider.ts @@ -22,6 +22,8 @@ import { TEST_DATA_SCHEME, TestUriType, parseTestUri } from './testingUri.js'; * in the inline peek view. */ export class TestingContentProvider implements IWorkbenchContribution, ITextModelContentProvider { + public static readonly ID = 'workbench.contrib.testing.contentProvider'; + constructor( @ITextModelService textModelResolverService: ITextModelService, @ILanguageService private readonly languageService: ILanguageService, diff --git a/src/vs/workbench/contrib/testing/common/testingProgressMessages.ts b/src/vs/workbench/contrib/testing/common/testingProgressMessages.ts new file mode 100644 index 00000000000..229da57b6ee --- /dev/null +++ b/src/vs/workbench/contrib/testing/common/testingProgressMessages.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { ITestResult } from './testResult.js'; +import { TestResultState } from './testTypes.js'; + +export type CountSummary = ReturnType; + +export const collectTestStateCounts = (isRunning: boolean, results: ReadonlyArray) => { + let passed = 0; + let failed = 0; + let skipped = 0; + let running = 0; + let queued = 0; + + for (const result of results) { + const count = result.counts; + failed += count[TestResultState.Errored] + count[TestResultState.Failed]; + passed += count[TestResultState.Passed]; + skipped += count[TestResultState.Skipped]; + running += count[TestResultState.Running]; + queued += count[TestResultState.Queued]; + } + + return { + isRunning, + passed, + failed, + runSoFar: passed + failed, + totalWillBeRun: passed + failed + queued + running, + skipped, + }; +}; + +export const getTestProgressText = ({ isRunning, passed, runSoFar, totalWillBeRun, skipped, failed }: CountSummary) => { + let percent = passed / runSoFar * 100; + if (failed > 0) { + // fix: prevent from rounding to 100 if there's any failed test + percent = Math.min(percent, 99.9); + } else if (runSoFar === 0) { + percent = 0; + } + + if (isRunning) { + if (runSoFar === 0) { + return localize('testProgress.runningInitial', 'Running tests...'); + } else if (skipped === 0) { + return localize('testProgress.running', 'Running tests, {0}/{1} passed ({2}%)', passed, totalWillBeRun, percent.toPrecision(3)); + } else { + return localize('testProgressWithSkip.running', 'Running tests, {0}/{1} tests passed ({2}%, {3} skipped)', passed, totalWillBeRun, percent.toPrecision(3), skipped); + } + } else { + if (skipped === 0) { + return localize('testProgress.completed', '{0}/{1} tests passed ({2}%)', passed, runSoFar, percent.toPrecision(3)); + } else { + return localize('testProgressWithSkip.completed', '{0}/{1} tests passed ({2}%, {3} skipped)', passed, runSoFar, percent.toPrecision(3), skipped); + } + } +}; +