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 { + 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) { 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/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 { 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(', ')); } diff --git a/src/vs/platform/quickinput/browser/quickInput.ts b/src/vs/platform/quickinput/browser/quickInput.ts index fcba3594b60..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'; @@ -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'; @@ -103,7 +103,7 @@ export interface QuickInputUI { widget: HTMLElement; rightActionBar: ActionBar; inlineActionBar: ActionBar; - checkAll: Checkbox; + checkAll: TriStateCheckbox; inputContainer: HTMLElement; filterContainer: HTMLElement; inputBox: QuickInputBox; @@ -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..824df3c37d1 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'; @@ -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'... @@ -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()) { @@ -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 => { 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(); /** diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 18affa06c97..683f1f100d7 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'; import './mainThreadChatSessions.js'; import './mainThreadDataChannels.js'; diff --git a/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts b/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts new file mode 100644 index 00000000000..81565e37339 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadChatOutputRenderer.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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'; + +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, + @IChatOutputRendererService private readonly _rendererService: IChatOutputRendererService, + ) { + super(); + this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostChatOutputRenderer); + } + + override dispose(): void { + super.dispose(); + + this.registeredRenderers.forEach(disposable => disposable.dispose()); + this.registeredRenderers.clear(); + } + + $registerChatOutputRenderer(mime: string, extensionId: ExtensionIdentifier, extensionLocation: UriComponents): 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.$renderChatOutput(mime, VSBuffer.wrap(data), webviewHandle, token); + }, + }, { + extension: { id: extensionId, location: URI.revive(extensionLocation) } + }); + } + + $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 d0b48f7b717..f9d72d68c2a 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -113,6 +113,7 @@ import { IExtHostWindow } from './extHostWindow.js'; import { IExtHostWorkspace } from './extHostWorkspace.js'; import { ExtHostAiSettingsSearch } from './extHostAiSettingsSearch.js'; import { ExtHostChatSessions } from './extHostChatSessions.js'; +import { ExtHostChatOutputRenderer } from './extHostChatOutputRenderer.js'; export interface IExtensionRegistries { mine: ExtensionDescriptionRegistry; @@ -220,6 +221,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)); @@ -1505,10 +1507,14 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatParticipantPrivate'); return _asExtensionEvent(extHostChatAgents2.onDidDisposeChatSession)(listeners, thisArgs, disposables); }, - registerChatSessionItemProvider(chatSessionType: string, provider: vscode.ChatSessionItemProvider) { + registerChatSessionItemProvider: (chatSessionType: string, provider: vscode.ChatSessionItemProvider) => { checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.registerChatSessionItemProvider(chatSessionType, provider); }, + registerChatOutputRenderer: (mime: string, renderer: vscode.ChatOutputRenderer) => { + checkProposedApiEnabled(extension, 'chatOutputRenderer'); + return extHostChatOutputRenderer.registerChatOutputRenderer(extension, mime, renderer); + }, }; // namespace: lm diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index b7309f1560d..9d44541b930 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1452,6 +1452,15 @@ export interface ExtHostUriOpenersShape { $openUri(id: string, context: { resolvedUri: UriComponents; sourceUri: UriComponents }, token: CancellationToken): Promise; } +export interface MainThreadChatOutputRendererShape extends IDisposable { + $registerChatOutputRenderer(mime: string, extensionId: ExtensionIdentifier, extensionLocation: UriComponents): void; + $unregisterChatOutputRenderer(mime: string): void; +} + +export interface ExtHostChatOutputRendererShape { + $renderChatOutput(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; @@ -3188,6 +3197,7 @@ export const MainContext = { MainThreadAiSettingsSearch: createProxyIdentifier('MainThreadAiSettingsSearch'), MainThreadDataChannels: createProxyIdentifier('MainThreadDataChannels'), MainThreadChatSessions: createProxyIdentifier('MainThreadChatSessions'), + MainThreadChatOutputRenderer: createProxyIdentifier('MainThreadChatOutputRenderer'), }; export const ExtHostContext = { @@ -3233,6 +3243,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..b4b7b1209de --- /dev/null +++ b/src/vs/workbench/api/common/extHostChatOutputRenderer.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * 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 output renderer already registered for mime type: ${mime}`); + } + + this._renderers.set(mime, { extension, renderer }); + this._proxy.$registerChatOutputRenderer(mime, extension.identifier, extension.extensionLocation); + + return new Disposable(() => { + this._renderers.delete(mime); + this._proxy.$unregisterChatOutputRenderer(mime); + }); + } + + async $renderChatOutput(mime: string, valueData: VSBuffer, webviewHandle: string, token: CancellationToken): Promise { + const entry = this._renderers.get(mime); + if (!entry) { + throw new Error(`No chat output renderer registered for mime type: ${mime}`); + } + + const webview = this.webviews.createNewWebview(webviewHandle, {}, entry.extension); + 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 1dade50a1bb..d6c79754328 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 { IToolResult, ToolDataSource } from '../../contrib/chat/common/languageModelToolsService.js'; +import { IToolResult, IToolResultInputOutputDetails, IToolResultOutputDetails, ToolDataSource } 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'; @@ -3346,12 +3346,30 @@ 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'); } 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.toolResultDetails2) { + detailsDto = { + output: { + type: 'data', + mimeType: (result.toolResultDetails2 as vscode.ToolResultDataOutput).mime, + value: VSBuffer.wrap((result.toolResultDetails2 as vscode.ToolResultDataOutput).value), + } + } satisfies IToolResultOutputDetails; + hasBuffers = true; + } + } + const dto: Dto = { content: result.content.map(item => { if (item instanceof types.LanguageModelTextPart) { @@ -3378,7 +3396,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/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, diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 133fc0c676b..e8a4a7a966b 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -115,6 +115,7 @@ import { ChatAttachmentResolveService, IChatAttachmentResolveService } from './c import { registerLanguageModelActions } from './actions/chatLanguageModelActions.js'; import { PromptUrlHandler } from './promptSyntax/promptUrlHandler.js'; import { ChatTaskServiceImpl, IChatTasksService } from '../common/chatTasksService.js'; +import { ChatOutputRendererService, IChatOutputRendererService } from './chatOutputItemRenderer.js'; // Register configuration const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); @@ -803,6 +804,7 @@ registerSingleton(IChatContextPickService, ChatContextPickService, Instantiation registerSingleton(IChatModeService, ChatModeService, InstantiationType.Delayed); registerSingleton(IChatAttachmentResolveService, ChatAttachmentResolveService, InstantiationType.Delayed); registerSingleton(IChatTasksService, ChatTaskServiceImpl, InstantiationType.Delayed); +registerSingleton(IChatOutputRendererService, ChatOutputRendererService, InstantiationType.Delayed); registerWorkbenchContribution2(ChatEditingNotebookFileSystemProviderContrib.ID, ChatEditingNotebookFileSystemProviderContrib, WorkbenchPhase.BlockStartup); 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 15de8bb8124..5dd0d53a7a2 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'; import { ChatTaskListSubPart } from './chatTaskListSubPart.js'; @@ -104,6 +105,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, 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..6f66c6b712b --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatToolOutputPart.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { decodeBase64 } from '../../../../../../base/common/buffer.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'; +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'; + +// 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, + @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).output.mimeType, + value: decodeBase64((toolInvocation.resultDetails as IToolResultOutputDetailsSerialized).output.base64Data), + }, + }; + + 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'; + + 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); + + // 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(); + + this._onDidChangeHeight.fire(); + 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/vs/workbench/contrib/chat/browser/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts index 583b0c767d4..029c877dfca 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts @@ -31,7 +31,7 @@ import { URI } from '../../../../base/common/uri.js'; import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; import { CodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; -import { EditorOptions } from '../../../../editor/common/config/editorOptions.js'; +import { EditorOptions, IEditorOptions } from '../../../../editor/common/config/editorOptions.js'; import { IDimension } from '../../../../editor/common/core/2d/dimension.js'; import { IPosition } from '../../../../editor/common/core/position.js'; import { Range } from '../../../../editor/common/core/range.js'; @@ -428,9 +428,15 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._register(this.historyService.onDidClearHistory(() => 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/chat/browser/chatOutputItemRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts new file mode 100644 index 00000000000..abb202db350 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatOutputItemRenderer.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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'; +import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.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, webview: IWebview, token: CancellationToken): Promise; +} + +export const IChatOutputRendererService = createDecorator('chatOutputRendererService'); + +interface RegisterOptions { + readonly extension?: { + readonly id: ExtensionIdentifier; + readonly location: URI; + }; +} + +export interface RenderedOutputPart extends IDisposable { + readonly onDidChangeHeight: Event; +} + +export interface IChatOutputRendererService { + readonly _serviceBrand: undefined; + + registerRenderer(mime: string, renderer: IChatOutputItemRenderer, options: RegisterOptions): IDisposable; + + renderOutputPart(mime: string, data: Uint8Array, parent: HTMLElement, token: CancellationToken): Promise; +} + +export class ChatOutputRendererService extends Disposable implements IChatOutputRendererService { + _serviceBrand: undefined; + + private readonly _renderers = new Map(); + + constructor( + @IWebviewService private readonly _webviewService: IWebviewService, + @IExtensionService private readonly _extensionService: IExtensionService, + ) { + super(); + } + + registerRenderer(mime: string, renderer: IChatOutputItemRenderer, options: RegisterOptions): IDisposable { + this._renderers.set(mime, { renderer, options }); + return { + dispose: () => { + this._renderers.delete(mime); + } + }; + } + + 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}`); + + const rendererData = this._renderers.get(mime); + if (!rendererData) { + throw new Error(`No renderer registered for mime type: ${mime}`); + } + + const store = new DisposableStore(); + + const webview = store.add(this._webviewService.createWebviewElement({ + title: '', + origin: generateUuid(), + options: { + enableFindWidget: false, + purpose: WebviewContentPurpose.ChatOutputItem, + tryRestoreScrollPosition: false, + }, + 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: () => { + store.dispose(); + } + }; + } +} + +interface IChatOutputRendererContribution { + readonly mimeTypes: readonly string[]; +} + +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', + 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/chat/browser/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts index 508af860eab..02a6bbb30c2 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/chat/common/chatProgressTypes/chatToolInvocation.ts b/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts index cada4d86786..59b161a5907 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, IChatToolInputInvocationData, IChatTasksContent, IChatToolInvocation, IChatToolInvocationSerialized, type IChatTerminalToolInvocationData } 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'; @@ -106,7 +107,9 @@ export class ChatToolInvocation implements IChatToolInvocation { originMessage: this.originMessage, isConfirmed: this._isConfirmed, isComplete: this._isComplete, - resultDetails: this._resultDetails, + resultDetails: isToolResultOutputDetails(this._resultDetails) + ? { output: { 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 27e4aed2354..98f439c603d 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; @@ -277,6 +277,14 @@ export interface IChatToolInvocation { kind: 'toolInvocation'; } +export interface IToolResultOutputDetailsSerialized { + output: { + 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. */ @@ -286,7 +294,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 7da716fc2f6..4f92e938b75 100644 --- a/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts @@ -156,14 +156,22 @@ 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)); } +export function isToolResultOutputDetails(obj: any): obj is IToolResultOutputDetails { + return typeof obj === 'object' && typeof obj?.output === 'object' && typeof obj?.output?.mimeType === 'string' && obj?.output?.type === 'data'; +} + export interface IToolResult { content: (IToolResultPromptTsxPart | IToolResultTextPart | IToolResultDataPart)[]; toolResultMessage?: string | IMarkdownString; - toolResultDetails?: Array | IToolResultInputOutputDetails; + toolResultDetails?: Array | IToolResultInputOutputDetails | IToolResultOutputDetails; toolResultError?: string; } 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/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, 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); 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 { 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.')); + }); }); }); 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({ diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalTool.ts index ddb98793737..38523fd4fb0 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalTool.ts @@ -238,33 +238,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 | 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: 'terminal', - commandLine: { - original: args.command, - toolEdited: toolEditedCommand - }, - language - }; - } - this._logService.debug(`RunInTerminalTool: Invoking with options ${JSON.stringify(args)}`); + const toolSpecificData = invocation.toolSpecificData as IChatTerminalToolInvocationData | 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'); 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 { + 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(); } } 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); + } + } +}; + 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 }); }; diff --git a/src/vs/workbench/contrib/webview/browser/webview.ts b/src/vs/workbench/contrib/webview/browser/webview.ts index 3fd6b0b4952..826a24684ee 100644 --- a/src/vs/workbench/contrib/webview/browser/webview.ts +++ b/src/vs/workbench/contrib/webview/browser/webview.ts @@ -85,6 +85,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/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' } 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; 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..6789cf2cacd --- /dev/null +++ b/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * 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 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; + } + + export interface ChatOutputRenderer { + /** + * Given an output, render it into the provided webview. + * + * 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. + * @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 webview has been initialized and is ready to be presented to the user. + */ + renderChatOutput(data: Uint8Array, webview: Webview, ctx: {}, token: CancellationToken): Thenable; + } + + export namespace chat { + /** + * 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: + * + * ```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. + */ + export function registerChatOutputRenderer(mime: string, renderer: ChatOutputRenderer): Disposable; + } +}