diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 9be15a6afa1..3fec7ae24bc 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/devcontainers/typescript-node:20-bookworm +FROM mcr.microsoft.com/devcontainers/typescript-node:22-bookworm ADD install-vscode.sh /root/ RUN /root/install-vscode.sh 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/.ssh/id_ed25519': - const file = argv[6]?.replace(/^["']+|["':]+$/g, ''); + const file = extractFilePathFromArgs(argv, 6); this.logger.trace(`[Askpass][handleSSHAskpass] request: ${request}, file: ${file}`); diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index ec2223c7f17..47c519f753d 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -2917,10 +2917,15 @@ export class CommandCenter { try { await item.run(repository, opts); } catch (err) { - if (err.gitErrorCode !== GitErrorCodes.DirtyWorkTree) { + if (err.gitErrorCode !== GitErrorCodes.DirtyWorkTree && err.gitErrorCode !== GitErrorCodes.WorktreeAlreadyExists) { throw err; } + if (err.gitErrorCode === GitErrorCodes.WorktreeAlreadyExists) { + this.handleWorktreeError(err); + return false; + } + const stash = l10n.t('Stash & Checkout'); const migrate = l10n.t('Migrate Changes'); const force = l10n.t('Force Checkout'); @@ -3388,38 +3393,52 @@ export class CommandCenter { @command('git.createWorktree', { repository: true, repositoryFilter: ['repository', 'submodule'] }) async createWorktree(repository: Repository): Promise { - await this._createWorktree(repository, undefined, undefined); + await this._createWorktree(repository); } - private async _createWorktree(repository: Repository, worktreePath?: string, name?: string): Promise { + private async _createWorktree(repository: Repository, worktreePath?: string, name?: string, newBranch?: boolean): Promise { const config = workspace.getConfiguration('git'); + const branchPrefix = config.get('branchPrefix')!; const showRefDetails = config.get('showReferenceDetails') === true; if (!name) { + const createBranch = new CreateBranchItem(); const getBranchPicks = async () => { - const refs = await repository.getRefs({ - pattern: 'refs/heads', - includeCommitDetails: showRefDetails - }); - const processors = [new RefProcessor(RefType.Head, BranchItem)]; - const itemsProcessor = new RefItemsProcessor(repository, processors); - return itemsProcessor.processRefs(refs); + const refs = await repository.getRefs({ includeCommitDetails: showRefDetails }); + const itemsProcessor = new RefItemsProcessor(repository, [ + new RefProcessor(RefType.Head), + new RefProcessor(RefType.RemoteHead), + new RefProcessor(RefType.Tag) + ]); + const branchItems = itemsProcessor.processRefs(refs); + return [createBranch, { label: '', kind: QuickPickItemKind.Separator }, ...branchItems]; }; const placeHolder = l10n.t('Select a branch to create the new worktree from'); const choice = await this.pickRef(getBranchPicks(), placeHolder); - if (!(choice instanceof BranchItem) || !choice.refName) { + if (choice === createBranch) { + const branchName = await this.promptForBranchName(repository); + + if (!branchName) { + return; + } + + newBranch = true; + name = branchName; + } else if (choice instanceof RefItem && choice.refName) { + name = choice.refName; + } else { return; } - name = choice.refName; } const disposables: Disposable[] = []; const inputBox = window.createInputBox(); + inputBox.placeholder = l10n.t('Worktree name'); inputBox.prompt = l10n.t('Please provide a worktree name'); - inputBox.value = name || ''; + inputBox.value = name.startsWith(branchPrefix) ? name.substring(branchPrefix.length) : name; inputBox.show(); const worktreeName = await new Promise((resolve) => { @@ -3456,41 +3475,49 @@ export class CommandCenter { worktreePath = path.join(uris[0].fsPath, worktreeName); try { - await repository.worktree({ name: name, path: worktreePath }); + await repository.worktree({ name: name, path: worktreePath, newBranch: newBranch }); } catch (err) { - if (err.gitErrorCode === GitErrorCodes.WorktreeAlreadyExists) { - const errorMessage = err.stderr; - const match = errorMessage.match(/worktree at '([^']+)'/) || errorMessage.match(/'([^']+)'/); - const path = match ? match[1] : undefined; - - if (!path) { - return; - } - - const openWorktree = l10n.t('Open in current window'); - const openWorktreeInNewWindow = l10n.t('Open in new window'); - const message = l10n.t(errorMessage || 'A worktree for branch \'{0}\' already exists at \'{1}\'.', name, path); - const choice = await window.showWarningMessage(message, { modal: true }, openWorktree, openWorktreeInNewWindow); - - const worktreeRepository = this.model.getRepository(path) || this.model.getRepository(Uri.file(path)); - - if (!worktreeRepository) { - return; - } - - if (choice === openWorktree) { - await this.openWorktreeInCurrentWindow(worktreeRepository); - } else if (choice === openWorktreeInNewWindow) { - await this.openWorktreeInNewWindow(worktreeRepository); - } - - return; + if (err.gitErrorCode !== GitErrorCodes.WorktreeAlreadyExists) { + throw err; } - throw err; + this.handleWorktreeError(err); + return; + } } + private async handleWorktreeError(err: any): Promise { + const errorMessage = err.stderr; + const match = errorMessage.match(/worktree at '([^']+)'/) || errorMessage.match(/'([^']+)'/); + const path = match ? match[1] : undefined; + + if (!path) { + return; + } + + const worktreeRepository = this.model.getRepository(path) || this.model.getRepository(Uri.file(path)); + + if (!worktreeRepository) { + return; + } + + const openWorktree = l10n.t('Open in current window'); + const openWorktreeInNewWindow = l10n.t('Open in new window'); + const message = l10n.t(errorMessage); + const choice = await window.showWarningMessage(message, { modal: true }, openWorktree, openWorktreeInNewWindow); + + + + if (choice === openWorktree) { + await this.openWorktreeInCurrentWindow(worktreeRepository); + } else if (choice === openWorktreeInNewWindow) { + await this.openWorktreeInNewWindow(worktreeRepository); + } + + return; + } + @command('git.deleteWorktree', { repository: true }) async deleteWorktree(repository: Repository): Promise { if (!repository.dotGit.commonPath) { diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 4e1cf98b676..78338a5cac0 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -2037,8 +2037,19 @@ export class Repository { await this.exec(args); } - async worktree(options: { path: string; name: string }): Promise { - const args = ['worktree', 'add', options.path, options.name]; + async worktree(options: { path: string; name: string; newBranch?: boolean }): Promise { + const args = ['worktree', 'add']; + + if (options?.newBranch) { + args.push('-b', options.name); + } + + args.push(options.path); + + if (!options.newBranch) { + args.push(options.name); + } + await this.exec(args); } diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 793c0cc71d4..2a6ce87046f 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -1728,7 +1728,7 @@ export class Repository implements Disposable { await this.run(Operation.DeleteTag, () => this.repository.deleteTag(name)); } - async worktree(options: { path: string; name: string }): Promise { + async worktree(options: { path: string; name: string; newBranch?: boolean }): Promise { await this.run(Operation.Worktree, () => this.repository.worktree(options)); } diff --git a/extensions/git/src/util.ts b/extensions/git/src/util.ts index 28d8c27fbc5..a4c5036255f 100644 --- a/extensions/git/src/util.ts +++ b/extensions/git/src/util.ts @@ -790,3 +790,35 @@ export function toDiagnosticSeverity(value: DiagnosticSeverityConfig): Diagnosti ? DiagnosticSeverity.Information : DiagnosticSeverity.Hint; } + +export function extractFilePathFromArgs(argv: string[], startIndex: number): string { + // Argument doesn't start with a quote + const firstArg = argv[startIndex]; + if (!firstArg.match(/^["']/)) { + return firstArg.replace(/^["']+|["':]+$/g, ''); + } + + // If it starts with a quote, we need to find the matching closing + // quote which might be in a later argument if the path contains + // spaces + const quote = firstArg[0]; + + // If the first argument ends with the same quote, it's complete + if (firstArg.endsWith(quote) && firstArg.length > 1) { + return firstArg.slice(1, -1); + } + + // Concatenate arguments until we find the closing quote + let path = firstArg; + for (let i = startIndex + 1; i < argv.length; i++) { + path = `${path} ${argv[i]}`; + if (argv[i].endsWith(quote)) { + // Found the matching quote + return path.slice(1, -1); + } + } + + // If no closing quote was found, remove + // leading quote and return the path as-is + return path.slice(1); +} diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index 1de7b92c398..5d18356693c 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -458,15 +458,6 @@ "default": true, "markdownDescription": "%configuration.updateImportsOnPaste%" }, - "typescript.experimental.expandableHover": { - "type": "boolean", - "default": true, - "description": "%configuration.expandableHover%", - "scope": "window", - "tags": [ - "experimental" - ] - }, "js/ts.hover.maximumLength": { "type": "number", "default": 500, diff --git a/extensions/typescript-language-features/package.nls.json b/extensions/typescript-language-features/package.nls.json index 5de96a8e6db..e59234f36e2 100644 --- a/extensions/typescript-language-features/package.nls.json +++ b/extensions/typescript-language-features/package.nls.json @@ -225,7 +225,6 @@ "configuration.tsserver.web.typeAcquisition.enabled": "Enable/disable package acquisition on the web. This enables IntelliSense for imported packages. Requires `#typescript.tsserver.web.projectWideIntellisense.enabled#`. Currently not supported for Safari.", "configuration.tsserver.nodePath": "Run TS Server on a custom Node installation. This can be a path to a Node executable, or 'node' if you want VS Code to detect a Node installation.", "configuration.updateImportsOnPaste": "Automatically update imports when pasting code. Requires TypeScript 5.6+.", - "configuration.expandableHover": "Enable expanding/contracting the hover to reveal more/less information from the TS server. Requires TypeScript 5.9+.", "configuration.hover.maximumLength": "The maximum number of characters in a hover. If the hover is longer than this, it will be truncated. Requires TypeScript 5.9+.", "walkthroughs.nodejsWelcome.title": "Get started with JavaScript and Node.js", "walkthroughs.nodejsWelcome.description": "Make the most of Visual Studio Code's first-class JavaScript experience.", diff --git a/extensions/typescript-language-features/src/languageFeatures/hover.ts b/extensions/typescript-language-features/src/languageFeatures/hover.ts index cc08b7f5b94..1d6c6dcf0bc 100644 --- a/extensions/typescript-language-features/src/languageFeatures/hover.ts +++ b/extensions/typescript-language-features/src/languageFeatures/hover.ts @@ -33,9 +33,8 @@ class TypeScriptHoverProvider implements vscode.HoverProvider { return undefined; } - const enableExpandableHover = vscode.workspace.getConfiguration('typescript').get('experimental.expandableHover', true); let verbosityLevel: number | undefined; - if (enableExpandableHover && this.client.apiVersion.gte(API.v590)) { + if (this.client.apiVersion.gte(API.v590)) { verbosityLevel = Math.max(0, this.getPreviousLevel(context?.previousHover) + (context?.verbosityDelta ?? 0)); } const args = { ...typeConverters.Position.toFileLocationRequestArgs(filepath, position), verbosityLevel }; diff --git a/src/vs/base/browser/ui/toggle/toggle.css b/src/vs/base/browser/ui/toggle/toggle.css index 6244ed4a434..e2d206d3aef 100644 --- a/src/vs/base/browser/ui/toggle/toggle.css +++ b/src/vs/base/browser/ui/toggle/toggle.css @@ -67,8 +67,3 @@ .monaco-action-bar .checkbox-action-item > .checkbox-label { font-size: 12px; } - -/* hide check when unchecked */ -.monaco-custom-toggle.monaco-checkbox:not(.checked)::before { - visibility: hidden; -} diff --git a/src/vs/base/browser/ui/toggle/toggle.ts b/src/vs/base/browser/ui/toggle/toggle.ts index 5e0aabe51c8..8c653627a13 100644 --- a/src/vs/base/browser/ui/toggle/toggle.ts +++ b/src/vs/base/browser/ui/toggle/toggle.ts @@ -258,27 +258,20 @@ export class Toggle extends Widget { } } -export class Checkbox extends Widget { +abstract class BaseCheckbox extends Widget { static readonly CLASS_NAME = 'monaco-checkbox'; - private readonly _onChange = this._register(new Emitter()); + protected readonly _onChange = this._register(new Emitter()); readonly onChange: Event = this._onChange.event; - private checkbox: Toggle; - private styles: ICheckboxStyles; - - readonly domNode: HTMLElement; - - constructor(private title: string, private isChecked: boolean, styles: ICheckboxStyles) { + constructor( + protected readonly checkbox: Toggle, + readonly domNode: HTMLElement, + protected readonly styles: ICheckboxStyles + ) { super(); - this.checkbox = this._register(new Toggle({ title: this.title, isChecked: this.isChecked, icon: Codicon.check, actionClassName: Checkbox.CLASS_NAME, hoverDelegate: styles.hoverDelegate, ...unthemedToggleStyles })); - - this.domNode = this.checkbox.domNode; - - this.styles = styles; - this.applyStyles(); this._register(this.checkbox.onChange(keyboard => { @@ -287,20 +280,10 @@ export class Checkbox extends Widget { })); } - get checked(): boolean { - return this.checkbox.checked; - } - get enabled(): boolean { return this.checkbox.enabled; } - set checked(newIsChecked: boolean) { - this.checkbox.checked = newIsChecked; - - this.applyStyles(); - } - focus(): void { this.domNode.focus(); } @@ -336,6 +319,91 @@ export class Checkbox extends Widget { } } +export class Checkbox extends BaseCheckbox { + constructor(title: string, isChecked: boolean, styles: ICheckboxStyles) { + const toggle = new Toggle({ title, isChecked, icon: Codicon.check, actionClassName: BaseCheckbox.CLASS_NAME, hoverDelegate: styles.hoverDelegate, ...unthemedToggleStyles }); + + super(toggle, toggle.domNode, styles); + this._register(toggle); + + this.applyStyles(); + + this._register(this.checkbox.onChange(keyboard => { + this.applyStyles(); + this._onChange.fire(keyboard); + })); + } + + get checked(): boolean { + return this.checkbox.checked; + } + + set checked(newIsChecked: boolean) { + this.checkbox.checked = newIsChecked; + if (newIsChecked) { + this.checkbox.setIcon(Codicon.check); + } else { + this.checkbox.setIcon(undefined); + } + this.applyStyles(); + } +} + +export class TriStateCheckbox extends BaseCheckbox { + constructor(title: string, initialState: boolean | 'partial', styles: ICheckboxStyles) { + let icon: ThemeIcon | undefined; + switch (initialState) { + case true: + icon = Codicon.check; + break; + case 'partial': + icon = Codicon.dash; + break; + case false: + icon = undefined; + break; + } + const checkbox = new Toggle({ + title, + isChecked: initialState === true, + icon, + actionClassName: Checkbox.CLASS_NAME, + hoverDelegate: styles.hoverDelegate, + ...unthemedToggleStyles + }); + + super( + checkbox, + checkbox.domNode, + styles + ); + this._register(checkbox); + } + + get checked(): boolean | 'partial' { + return this.checkbox.checked; + } + + set checked(newState: boolean | 'partial') { + const checked = newState === true; + this.checkbox.checked = checked; + + switch (newState) { + case true: + this.checkbox.setIcon(Codicon.check); + break; + case 'partial': + this.checkbox.setIcon(Codicon.dash); + break; + case false: + this.checkbox.setIcon(undefined); + break; + } + + this.applyStyles(); + } +} + export interface ICheckboxActionViewItemOptions extends IActionViewItemOptions { checkboxStyles: ICheckboxStyles; } diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index da7beb20d73..a87dd454243 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -37,6 +37,9 @@ const _allApiProposals = { chatEditing: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatEditing.d.ts', }, + chatOutputRenderer: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts', + }, chatParticipantAdditions: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts', }, diff --git a/src/vs/platform/mcp/common/mcpManagement.ts b/src/vs/platform/mcp/common/mcpManagement.ts index e43e74bf680..8ce49b0aa85 100644 --- a/src/vs/platform/mcp/common/mcpManagement.ts +++ b/src/vs/platform/mcp/common/mcpManagement.ts @@ -19,7 +19,6 @@ export interface ILocalMcpServer { readonly version?: string; readonly mcpResource: URI; readonly location?: URI; - readonly id?: string; readonly displayName?: string; readonly url?: string; readonly description?: string; diff --git a/src/vs/platform/mcp/common/mcpManagementService.ts b/src/vs/platform/mcp/common/mcpManagementService.ts index 1cd0ad98704..be5f1a64f08 100644 --- a/src/vs/platform/mcp/common/mcpManagementService.ts +++ b/src/vs/platform/mcp/common/mcpManagementService.ts @@ -84,8 +84,11 @@ export abstract class AbstractMcpResourceManagementService extends Disposable { private initialize(): Promise { if (!this.initializePromise) { this.initializePromise = (async () => { - this.local = await this.populateLocalServers(); - this.startWatching(); + try { + this.local = await this.populateLocalServers(); + } finally { + this.startWatching(); + } })(); } return this.initializePromise; @@ -178,7 +181,6 @@ export abstract class AbstractMcpResourceManagementService extends Disposable { mcpResource: this.mcpResource, version: mcpServerInfo.version, location: mcpServerInfo.location, - id: mcpServerInfo.id, displayName: mcpServerInfo.displayName, description: mcpServerInfo.description, publisher: mcpServerInfo.publisher, 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 fbf9a4d0e2c..fd903e3db74 100644 --- a/src/vs/platform/quickinput/browser/quickInput.ts +++ b/src/vs/platform/quickinput/browser/quickInput.ts @@ -13,7 +13,7 @@ import { IInputBoxStyles } from '../../../base/browser/ui/inputbox/inputBox.js'; import { IKeybindingLabelStyles } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; import { IListStyles } from '../../../base/browser/ui/list/listWidget.js'; import { IProgressBarStyles, ProgressBar } from '../../../base/browser/ui/progressbar/progressbar.js'; -import { Checkbox, IToggleStyles, Toggle } from '../../../base/browser/ui/toggle/toggle.js'; +import { IToggleStyles, Toggle, TriStateCheckbox } from '../../../base/browser/ui/toggle/toggle.js'; import { equals } from '../../../base/common/arrays.js'; import { TimeoutTimer } from '../../../base/common/async.js'; import { Codicon } from '../../../base/common/codicons.js'; @@ -103,7 +103,7 @@ export interface QuickInputUI { widget: HTMLElement; rightActionBar: ActionBar; inlineActionBar: ActionBar; - checkAll: Checkbox; + checkAll: TriStateCheckbox; inputContainer: HTMLElement; filterContainer: HTMLElement; inputBox: QuickInputBox; diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index b2a93dbc9fd..824df3c37d1 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -33,7 +33,7 @@ import { IConfigurationService } from '../../configuration/common/configuration. import { Platform, platform } from '../../../base/common/platform.js'; import { getWindowControlsStyle, WindowControlsStyle } from '../../window/common/window.js'; import { getZoomFactor } from '../../../base/browser/browser.js'; -import { Checkbox } from '../../../base/browser/ui/toggle/toggle.js'; +import { TriStateCheckbox } from '../../../base/browser/ui/toggle/toggle.js'; import { defaultCheckboxStyles } from '../../theme/browser/defaultStyles.js'; const $ = dom.$; @@ -154,11 +154,11 @@ export class QuickInputController extends Disposable { const headerContainer = dom.append(container, $('.quick-input-header')); - const checkAll = this._register(new Checkbox(localize('quickInput.checkAll', "Toggle all checkboxes"), false, { ...defaultCheckboxStyles, size: 15 })); + const checkAll = this._register(new TriStateCheckbox(localize('quickInput.checkAll', "Toggle all checkboxes"), false, { ...defaultCheckboxStyles, size: 15 })); dom.append(headerContainer, checkAll.domNode); this._register(checkAll.onChange(() => { const checked = checkAll.checked; - list.setAllVisibleChecked(checked); + list.setAllVisibleChecked(checked === true); })); this._register(dom.addDisposableListener(checkAll.domNode, dom.EventType.CLICK, e => { if (e.x || e.y) { // Avoid 'click' triggered by 'space'... @@ -218,6 +218,7 @@ export class QuickInputController extends Disposable { } })); this._register(list.onChangedAllVisibleChecked(checked => { + // TODO: Support tri-state checkbox when we remove the .indent property that is faking tree structure. checkAll.checked = checked; })); this._register(list.onChangedVisibleCount(c => { diff --git a/src/vs/platform/userDataSync/common/userDataSyncStoreService.ts b/src/vs/platform/userDataSync/common/userDataSyncStoreService.ts index afe276827e4..427dd90253e 100644 --- a/src/vs/platform/userDataSync/common/userDataSyncStoreService.ts +++ b/src/vs/platform/userDataSync/common/userDataSyncStoreService.ts @@ -487,10 +487,6 @@ export class UserDataSyncStoreClient extends Disposable { throw new UserDataSyncStoreError('Server returned ' + context.res.statusCode, url, UserDataSyncErrorCode.EmptyResponse, context.res.statusCode, context.res.headers[HEADER_OPERATION_ID]); } - if (hasNoContent(context)) { - throw new UserDataSyncStoreError('Empty response', url, UserDataSyncErrorCode.EmptyResponse, context.res.statusCode, context.res.headers[HEADER_OPERATION_ID]); - } - const serverData = await asJson(context); if (!serverData) { return null; 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 52d2ac845a5..9212abdd5ca 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)); @@ -1509,14 +1511,18 @@ 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(extension, chatSessionType, provider); }, registerChatSessionContentProvider(chatSessionType: string, provider: vscode.ChatSessionContentProvider) { checkProposedApiEnabled(extension, 'chatSessionsProvider'); return extHostChatSessions.registerChatSessionContentProvider(extension, chatSessionType, provider); - } + }, + registerChatOutputRenderer: (mime: string, renderer: vscode.ChatOutputRenderer) => { + checkProposedApiEnabled(extension, 'chatOutputRenderer'); + return extHostChatOutputRenderer.registerChatOutputRenderer(extension, mime, renderer); + }, }; // namespace: lm @@ -1875,7 +1881,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I LanguageModelToolExtensionSource: extHostTypes.LanguageModelToolExtensionSource, LanguageModelToolMCPSource: extHostTypes.LanguageModelToolMCPSource, ExtendedLanguageModelToolResult: extHostTypes.ExtendedLanguageModelToolResult, - PreparedTerminalToolInvocation: extHostTypes.PreparedTerminalToolInvocation, LanguageModelChatToolMode: extHostTypes.LanguageModelChatToolMode, LanguageModelPromptTsxPart: extHostTypes.LanguageModelPromptTsxPart, NewSymbolName: extHostTypes.NewSymbolName, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 7688c0de159..881a42ebc33 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1454,6 +1454,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; @@ -3212,6 +3221,7 @@ export const MainContext = { MainThreadAiSettingsSearch: createProxyIdentifier('MainThreadAiSettingsSearch'), MainThreadDataChannels: createProxyIdentifier('MainThreadDataChannels'), MainThreadChatSessions: createProxyIdentifier('MainThreadChatSessions'), + MainThreadChatOutputRenderer: createProxyIdentifier('MainThreadChatOutputRenderer'), }; export const ExtHostContext = { @@ -3257,6 +3267,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/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index 30ad980e881..1646f8ee1b9 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -184,10 +184,6 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape options.chatRequestId = dto.chatRequestId; options.chatInteractionId = dto.chatInteractionId; options.chatSessionId = dto.context?.sessionId; - - if (dto.toolSpecificData?.kind === 'terminal') { - options.terminalCommand = dto.toolSpecificData.command; - } } if (isProposedApiEnabled(item.extension, 'chatParticipantAdditions') && dto.modelId) { @@ -251,25 +247,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape chatSessionId: context.chatSessionId, chatInteractionId: context.chatInteractionId }; - if (isProposedApiEnabled(item.extension, 'chatParticipantPrivate') && item.tool.prepareInvocation2) { - const result = await item.tool.prepareInvocation2(options, token); - if (!result) { - return undefined; - } - - return { - confirmationMessages: result.confirmationMessages ? { - title: typeof result.confirmationMessages.title === 'string' ? result.confirmationMessages.title : typeConvert.MarkdownString.from(result.confirmationMessages.title), - message: typeof result.confirmationMessages.message === 'string' ? result.confirmationMessages.message : typeConvert.MarkdownString.from(result.confirmationMessages.message), - } : undefined, - toolSpecificData: { - kind: 'terminal', - language: result.language, - command: result.command, - }, - presentation: result.presentation - }; - } else if (item.tool.prepareInvocation) { + if (item.tool.prepareInvocation) { const result = await item.tool.prepareInvocation(options, token); if (!result) { return undefined; diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 294c8917f2f..bd117330281 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'; @@ -3433,12 +3433,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) { @@ -3465,7 +3483,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/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index f413e73ff00..ed7f6496bdc 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -4883,15 +4883,6 @@ export class LanguageModelToolResultPart2 implements vscode.LanguageModelToolRes } } -export class PreparedTerminalToolInvocation { - constructor( - public readonly command: string, - public readonly language: string, - public readonly confirmationMessages?: vscode.LanguageModelToolConfirmationMessages, - public readonly presentation?: 'hidden' - ) { } -} - export enum ChatErrorLevel { Info = 0, Warning = 1, diff --git a/src/vs/workbench/browser/actions/layoutActions.ts b/src/vs/workbench/browser/actions/layoutActions.ts index 805cf4da73d..1f720c92dd0 100644 --- a/src/vs/workbench/browser/actions/layoutActions.ts +++ b/src/vs/workbench/browser/actions/layoutActions.ts @@ -7,6 +7,7 @@ import { ILocalizedString, localize, localize2 } from '../../../nls.js'; import { MenuId, MenuRegistry, registerAction2, Action2 } from '../../../platform/actions/common/actions.js'; import { Categories } from '../../../platform/action/common/actionCommonCategories.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; +import { alert } from '../../../base/browser/ui/aria/aria.js'; import { EditorActionsLocation, EditorTabsMode, IWorkbenchLayoutService, LayoutSettings, Parts, Position, ZenModeSettings, positionToString } from '../../services/layout/browser/layoutService.js'; import { ServicesAccessor, IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { KeyMod, KeyCode, KeyChord } from '../../../base/common/keyCodes.js'; @@ -319,8 +320,15 @@ export class ToggleSidebarVisibilityAction extends Action2 { run(accessor: ServicesAccessor): void { const layoutService = accessor.get(IWorkbenchLayoutService); + const isCurrentlyVisible = layoutService.isVisible(Parts.SIDEBAR_PART); - layoutService.setPartHidden(layoutService.isVisible(Parts.SIDEBAR_PART), Parts.SIDEBAR_PART); + layoutService.setPartHidden(isCurrentlyVisible, Parts.SIDEBAR_PART); + + // Announce visibility change to screen readers + const alertMessage = isCurrentlyVisible + ? localize('sidebarHidden', "Primary Side Bar hidden") + : localize('sidebarVisible', "Primary Side Bar shown"); + alert(alertMessage); } } diff --git a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts index b42e6e6b705..15adc24dd63 100644 --- a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts +++ b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts @@ -9,6 +9,7 @@ import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../plat import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; +import { alert } from '../../../../base/browser/ui/aria/aria.js'; import { AuxiliaryBarMaximizedContext, AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext } from '../../../common/contextkeys.js'; import { ViewContainerLocation, ViewContainerLocationToString } from '../../../common/views.js'; import { ActivityBarPosition, IWorkbenchLayoutService, LayoutSettings, Parts } from '../../../services/layout/browser/layoutService.js'; @@ -69,7 +70,15 @@ export class ToggleAuxiliaryBarAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const layoutService = accessor.get(IWorkbenchLayoutService); - layoutService.setPartHidden(layoutService.isVisible(Parts.AUXILIARYBAR_PART), Parts.AUXILIARYBAR_PART); + const isCurrentlyVisible = layoutService.isVisible(Parts.AUXILIARYBAR_PART); + + layoutService.setPartHidden(isCurrentlyVisible, Parts.AUXILIARYBAR_PART); + + // Announce visibility change to screen readers + const alertMessage = isCurrentlyVisible + ? localize('auxiliaryBarHidden', "Secondary Side Bar hidden") + : localize('auxiliaryBarVisible', "Secondary Side Bar shown"); + alert(alertMessage); } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 9ce96a553e4..84a36b9dad9 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -112,6 +112,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('chatEditing.discardAllFiles', '- Undo All Edits{0}.', '')); content.push(localize('chatEditing.openFileInDiff', '- Open File in Diff{0}.', '')); content.push(localize('chatEditing.viewChanges', '- View Changes{0}.', '')); + content.push(localize('chatEditing.viewPreviousEdits', '- View Previous Edits{0}.', '')); } else { content.push(localize('inlineChat.overview', "Inline chat occurs within a code editor and takes into account the current selection. It is useful for making changes to the current editor. For example, fixing diagnostics, documenting or refactoring code. Keep in mind that AI generated code may be incorrect.")); diff --git a/src/vs/workbench/contrib/chat/browser/actions/manageModelsActions.ts b/src/vs/workbench/contrib/chat/browser/actions/manageModelsActions.ts index e02d2e0b26e..a1d0b1a51de 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/manageModelsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/manageModelsActions.ts @@ -81,9 +81,10 @@ export class ManageModelsAction extends Action2 { })); store.add(quickPick.onDidTriggerItemButton(async (event) => { - const managementCommand = (event.item as IVendorQuickPickItem).managementCommand; + const selectedItem = event.item as IVendorQuickPickItem; + const managementCommand = selectedItem.managementCommand; if (managementCommand) { - commandService.executeCommand(managementCommand); + commandService.executeCommand(managementCommand, selectedItem.vendor); } })); @@ -106,6 +107,11 @@ export class ManageModelsAction extends Action2 { picked: model.metadata.isUserSelectable })); + if (modelItems.length === 0) { + store.dispose(); + return; + } + const quickPick = quickInputService.createQuickPick(); quickPick.items = modelItems; quickPick.title = 'Manage Language Models'; diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 7f764c0c565..e25b88c41f5 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/chatAccessibilityProvider.ts b/src/vs/workbench/contrib/chat/browser/chatAccessibilityProvider.ts index 2b825c0efc5..956536dd152 100644 --- a/src/vs/workbench/contrib/chat/browser/chatAccessibilityProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/chatAccessibilityProvider.ts @@ -28,8 +28,6 @@ export const getToolConfirmationAlert = (accessor: ServicesAccessor, toolInvocat let input = ''; if (v.toolSpecificData) { if (v.toolSpecificData.kind === 'terminal') { - input = v.toolSpecificData.command; - } else if (v.toolSpecificData.kind === 'terminal2') { input = v.toolSpecificData.commandLine.toolEdited ?? v.toolSpecificData.commandLine.original; } else if (v.toolSpecificData.kind === 'extensions') { input = JSON.stringify(v.toolSpecificData.extensions); diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatElicitationContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatElicitationContentPart.ts index e46483ec782..45a0eedba36 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatElicitationContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatElicitationContentPart.ts @@ -31,6 +31,8 @@ export class ChatElicitationContentPart extends Disposable implements IChatConte const confirmationWidget = this._register(this.instantiationService.createInstance(ChatConfirmationWidget, elicitation.title, elicitation.originMessage, this.getMessageToRender(elicitation), buttons, context.container)); confirmationWidget.setShowButtons(elicitation.state === 'pending'); + this._register(elicitation.onDidRequestHide(() => this.domNode.remove())); + this._register(confirmationWidget.onDidChangeHeight(() => this._onDidChangeHeight.fire())); this._register(confirmationWidget.onDidClick(async e => { diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatTerminalMarkdownProgressPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatTerminalMarkdownProgressPart.ts index 9f3f5357fa4..06aa274a532 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatTerminalMarkdownProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatTerminalMarkdownProgressPart.ts @@ -8,7 +8,7 @@ import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { MarkdownRenderer } from '../../../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { IChatMarkdownContent, IChatTerminalToolInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, type IChatTerminalToolInvocationData2 } from '../../../common/chatService.js'; +import { IChatMarkdownContent, IChatToolInvocation, IChatToolInvocationSerialized, type IChatTerminalToolInvocationData } from '../../../common/chatService.js'; import { CodeBlockModelCollection } from '../../../common/codeBlockModelCollection.js'; import { IChatCodeBlockInfo } from '../../chat.js'; import { ICodeBlockRenderOptions } from '../../codeBlockPart.js'; @@ -27,7 +27,7 @@ export class ChatTerminalMarkdownProgressPart extends BaseChatToolInvocationSubP constructor( toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, - terminalData: IChatTerminalToolInvocationData | IChatTerminalToolInvocationData2, + terminalData: IChatTerminalToolInvocationData, context: IChatContentPartRenderContext, renderer: MarkdownRenderer, editorPool: EditorPool, @@ -38,9 +38,7 @@ export class ChatTerminalMarkdownProgressPart extends BaseChatToolInvocationSubP ) { super(toolInvocation); - const command = terminalData.kind === 'terminal' - ? terminalData.command - : terminalData.commandLine.userEdited ?? terminalData.commandLine.toolEdited ?? terminalData.commandLine.original; + const command = terminalData.commandLine.userEdited ?? terminalData.commandLine.toolEdited ?? terminalData.commandLine.original; const content = new MarkdownString(`\`\`\`${terminalData.language}\n${command}\n\`\`\``); const chatMarkdownContent: IChatMarkdownContent = { diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolSubPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolSubPart.ts index 23a38a4a3e6..81901f92741 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolSubPart.ts @@ -17,7 +17,7 @@ import { IContextKeyService } from '../../../../../../platform/contextkey/common import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; import { ChatContextKeys } from '../../../common/chatContextKeys.js'; -import { IChatTerminalToolInvocationData, IChatToolInvocation, type IChatTerminalToolInvocationData2 } from '../../../common/chatService.js'; +import { IChatToolInvocation, type IChatTerminalToolInvocationData } from '../../../common/chatService.js'; import { CancelChatActionId } from '../../actions/chatExecuteActions.js'; import { AcceptToolConfirmationActionId } from '../../actions/chatToolActions.js'; import { IChatCodeBlockInfo, IChatWidgetService } from '../../chat.js'; @@ -33,7 +33,7 @@ export class TerminalConfirmationWidgetSubPart extends BaseChatToolInvocationSub constructor( toolInvocation: IChatToolInvocation, - terminalData: IChatTerminalToolInvocationData | IChatTerminalToolInvocationData2, + terminalData: IChatTerminalToolInvocationData, private readonly context: IChatContentPartRenderContext, private readonly renderer: MarkdownRenderer, private readonly editorPool: EditorPool, @@ -90,7 +90,7 @@ export class TerminalConfirmationWidgetSubPart extends BaseChatToolInvocationSub }; const langId = this.languageService.getLanguageIdByLanguageName(terminalData.language ?? 'sh') ?? 'shellscript'; const model = this.modelService.createModel( - terminalData.kind === 'terminal' ? terminalData.command : terminalData.commandLine.toolEdited ?? terminalData.commandLine.original, + terminalData.commandLine.toolEdited ?? terminalData.commandLine.original, this.languageService.createById(langId), this._getUniqueCodeBlockUri(), true @@ -122,11 +122,7 @@ export class TerminalConfirmationWidgetSubPart extends BaseChatToolInvocationSub this._onDidChangeHeight.fire(); })); this._register(model.onDidChangeContent(e => { - if (terminalData.kind === 'terminal') { - terminalData.command = model.getValue(); - } else { - terminalData.commandLine.userEdited = model.getValue(); - } + terminalData.commandLine.userEdited = model.getValue(); })); const element = dom.$(''); dom.append(element, editor.object.element); 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 de32c9d28cb..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'; @@ -88,7 +89,7 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa return this.instantiationService.createInstance(ChatTaskListSubPart, this.toolInvocation, this.toolInvocation.toolSpecificData); } if (this.toolInvocation.confirmationMessages) { - if (this.toolInvocation.toolSpecificData?.kind === 'terminal' || this.toolInvocation.toolSpecificData?.kind === 'terminal2') { + if (this.toolInvocation.toolSpecificData?.kind === 'terminal') { return this.instantiationService.createInstance(TerminalConfirmationWidgetSubPart, this.toolInvocation, this.toolInvocation.toolSpecificData, this.context, this.renderer, this.editorPool, this.currentWidthDelegate, this.codeBlockStartIndex); } else { return this.instantiationService.createInstance(ToolConfirmationSubPart, this.toolInvocation, this.context, this.renderer, this.editorPool, this.currentWidthDelegate, this.codeBlockModelCollection, this.codeBlockStartIndex); @@ -96,7 +97,7 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa } } - if (this.toolInvocation.toolSpecificData?.kind === 'terminal' || this.toolInvocation.toolSpecificData?.kind === 'terminal2') { + if (this.toolInvocation.toolSpecificData?.kind === 'terminal') { return this.instantiationService.createInstance(ChatTerminalMarkdownProgressPart, this.toolInvocation, this.toolInvocation.toolSpecificData, this.context, this.renderer, this.editorPool, this.currentWidthDelegate, this.codeBlockStartIndex, this.codeBlockModelCollection); } @@ -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/chatEditing/chatEditingActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts index e2c75f50715..f17671edb34 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts @@ -724,9 +724,9 @@ export class ViewPreviousEditsAction extends EditingSessionAction { constructor() { super({ id: ViewPreviousEditsAction.Id, - title: ViewPreviousEditsAction.Label, + title: localize2('chatEditing.viewPreviousEdits', 'View Previous Edits'), tooltip: ViewPreviousEditsAction.Label, - f1: false, + f1: true, icon: Codicon.diffMultiple, precondition: hasUndecidedChatEditingResourceContextKey.negate(), menu: [ diff --git a/src/vs/workbench/contrib/chat/browser/chatElicitationRequestPart.ts b/src/vs/workbench/contrib/chat/browser/chatElicitationRequestPart.ts index acf3f80728d..f993b671618 100644 --- a/src/vs/workbench/contrib/chat/browser/chatElicitationRequestPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatElicitationRequestPart.ts @@ -3,14 +3,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Emitter } from '../../../../base/common/event.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; import { IChatElicitationRequest } from '../common/chatService.js'; -export class ChatElicitationRequestPart implements IChatElicitationRequest { +export class ChatElicitationRequestPart extends Disposable implements IChatElicitationRequest { public readonly kind = 'elicitation'; public state: 'pending' | 'accepted' | 'rejected' = 'pending'; public acceptedResult?: Record; + private _onDidRequestHide = this._register(new Emitter()); + public readonly onDidRequestHide = this._onDidRequestHide.event; + constructor( public readonly title: string | IMarkdownString, public readonly message: string | IMarkdownString, @@ -19,7 +24,13 @@ export class ChatElicitationRequestPart implements IChatElicitationRequest { public readonly rejectButtonLabel: string, public readonly accept: () => Promise, public readonly reject: () => Promise, - ) { } + ) { + super(); + } + + hide(): void { + this._onDidRequestHide.fire(); + } public toJSON() { return { 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/chatResponseAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/chatResponseAccessibleView.ts index 7473752df1f..b3e27d63203 100644 --- a/src/vs/workbench/contrib/chat/browser/chatResponseAccessibleView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatResponseAccessibleView.ts @@ -75,14 +75,12 @@ class ChatResponseAccessibleProvider extends Disposable implements IAccessibleVi let input = ''; if (toolInvocation.toolSpecificData) { input = toolInvocation.toolSpecificData?.kind === 'terminal' - ? toolInvocation.toolSpecificData.command - : toolInvocation.toolSpecificData?.kind === 'terminal2' - ? toolInvocation.toolSpecificData.commandLine.userEdited ?? toolInvocation.toolSpecificData.commandLine.toolEdited ?? toolInvocation.toolSpecificData.commandLine.original - : toolInvocation.toolSpecificData?.kind === 'extensions' - ? JSON.stringify(toolInvocation.toolSpecificData.extensions) - : toolInvocation.toolSpecificData?.kind === 'tasks' - ? JSON.stringify(toolInvocation.toolSpecificData.tasks) - : JSON.stringify(toolInvocation.toolSpecificData.rawInput); + ? toolInvocation.toolSpecificData.commandLine.userEdited ?? toolInvocation.toolSpecificData.commandLine.toolEdited ?? toolInvocation.toolSpecificData.commandLine.original + : toolInvocation.toolSpecificData?.kind === 'extensions' + ? JSON.stringify(toolInvocation.toolSpecificData.extensions) + : toolInvocation.toolSpecificData?.kind === 'tasks' + ? JSON.stringify(toolInvocation.toolSpecificData.tasks) + : JSON.stringify(toolInvocation.toolSpecificData.rawInput); } responseContent += `${title}`; if (input) { diff --git a/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts index ae32701d859..02a6bbb30c2 100644 --- a/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/languageModelToolsService.ts @@ -363,7 +363,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo : undefined; if (prepared?.confirmationMessages) { - if (prepared.toolSpecificData?.kind !== 'terminal' && prepared.toolSpecificData?.kind !== 'terminal2' && typeof prepared.confirmationMessages.allowAutoConfirm !== 'boolean') { + if (prepared.toolSpecificData?.kind !== 'terminal' && typeof prepared.confirmationMessages.allowAutoConfirm !== 'boolean') { prepared.confirmationMessages.allowAutoConfirm = true; } diff --git a/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts b/src/vs/workbench/contrib/chat/common/chatProgressTypes/chatToolInvocation.ts index 99944895915..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, IChatTerminalToolInvocationData, IChatToolInputInvocationData, IChatTasksContent, IChatToolInvocation, IChatToolInvocationSerialized, type IChatTerminalToolInvocationData2 } from '../chatService.js'; -import { IPreparedToolInvocation, IToolConfirmationMessages, IToolData, IToolProgressStep, IToolResult } from '../languageModelToolsService.js'; +import { IChatExtensionsContent, IChatToolInputInvocationData, IChatTasksContent, IChatToolInvocation, IChatToolInvocationSerialized, type IChatTerminalToolInvocationData } from '../chatService.js'; +import { IPreparedToolInvocation, isToolResultOutputDetails, IToolConfirmationMessages, IToolData, IToolProgressStep, IToolResult } from '../languageModelToolsService.js'; export class ChatToolInvocation implements IChatToolInvocation { public readonly kind: 'toolInvocation' = 'toolInvocation'; @@ -45,7 +46,7 @@ export class ChatToolInvocation implements IChatToolInvocation { public readonly presentation: IPreparedToolInvocation['presentation']; public readonly toolId: string; - public readonly toolSpecificData?: IChatTerminalToolInvocationData | IChatTerminalToolInvocationData2 | IChatToolInputInvocationData | IChatExtensionsContent | IChatTasksContent; + public readonly toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTasksContent; public readonly progress = observableValue<{ message?: string | IMarkdownString; progress: number }>(this, { progress: 0 }); @@ -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 c26bcbad9e4..9cbc34302e2 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; @@ -238,16 +238,11 @@ export interface IChatElicitationRequest { acceptedResult?: Record; accept(): Promise; reject(): Promise; + onDidRequestHide: Event; } export interface IChatTerminalToolInvocationData { kind: 'terminal'; - command: string; - language: string; -} - -export interface IChatTerminalToolInvocationData2 { - kind: 'terminal2'; commandLine: { original: string; userEdited?: string; @@ -263,7 +258,7 @@ export interface IChatToolInputInvocationData { export interface IChatToolInvocation { presentation: IPreparedToolInvocation['presentation']; - toolSpecificData?: IChatTerminalToolInvocationData | IChatTerminalToolInvocationData2 | IChatToolInputInvocationData | IChatExtensionsContent | IChatTasksContent; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTasksContent; /** Presence of this property says that confirmation is required */ confirmationMessages?: IToolConfirmationMessages; confirmed: DeferredPromise; @@ -283,16 +278,24 @@ 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. */ export interface IChatToolInvocationSerialized { presentation: IPreparedToolInvocation['presentation']; - toolSpecificData?: IChatTerminalToolInvocationData | IChatTerminalToolInvocationData2 | IChatToolInputInvocationData | IChatExtensionsContent | IChatTasksContent; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTasksContent; 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 c3ca172499a..4f92e938b75 100644 --- a/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/languageModelToolsService.ts @@ -16,7 +16,7 @@ import { ContextKeyExpression } from '../../../../platform/contextkey/common/con import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IProgress } from '../../../../platform/progress/common/progress.js'; -import { IChatExtensionsContent, IChatTerminalToolInvocationData, IChatToolInputInvocationData, IChatTasksContent, type IChatTerminalToolInvocationData2 } from './chatService.js'; +import { IChatExtensionsContent, IChatToolInputInvocationData, IChatTasksContent, type IChatTerminalToolInvocationData } from './chatService.js'; import { PromptElementJSON, stringifyPromptElementJSON } from './tools/promptTsxTypes.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { derived, IObservable, IReader, ITransaction, ObservableSet } from '../../../../base/common/observable.js'; @@ -113,7 +113,7 @@ export interface IToolInvocation { context: IToolInvocationContext | undefined; chatRequestId?: string; chatInteractionId?: string; - toolSpecificData?: IChatTerminalToolInvocationData | IChatTerminalToolInvocationData2 | IChatToolInputInvocationData | IChatExtensionsContent | IChatTasksContent; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTasksContent; modelId?: string; } @@ -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; } @@ -206,7 +214,7 @@ export interface IPreparedToolInvocation { originMessage?: string | IMarkdownString; confirmationMessages?: IToolConfirmationMessages; presentation?: 'hidden' | undefined; - toolSpecificData?: IChatTerminalToolInvocationData | IChatTerminalToolInvocationData2 | IChatToolInputInvocationData | IChatExtensionsContent | IChatTasksContent; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTasksContent; } export interface IToolImpl { 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/mcp/browser/mcpServerActions.ts b/src/vs/workbench/contrib/mcp/browser/mcpServerActions.ts index b22045f95d7..425e11d9d3f 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpServerActions.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpServerActions.ts @@ -314,7 +314,7 @@ export class StartServerAction extends McpServerAction { if (!this.mcpServer.local) { return; } - return this.mcpService.servers.get().find(s => s.definition.label === this.mcpServer?.name); + return this.mcpService.servers.get().find(s => s.definition.id === this.mcpServer?.id); } } @@ -361,7 +361,7 @@ export class StopServerAction extends McpServerAction { if (!this.mcpServer.local) { return; } - return this.mcpService.servers.get().find(s => s.definition.label === this.mcpServer?.name); + return this.mcpService.servers.get().find(s => s.definition.id === this.mcpServer?.id); } } @@ -410,7 +410,7 @@ export class RestartServerAction extends McpServerAction { if (!this.mcpServer.local) { return; } - return this.mcpService.servers.get().find(s => s.definition.label === this.mcpServer?.name); + return this.mcpService.servers.get().find(s => s.definition.id === this.mcpServer?.id); } } @@ -483,7 +483,7 @@ export class AuthServerAction extends McpServerAction { if (!this.mcpServer.local) { return; } - return this.mcpService.servers.get().find(s => s.definition.label === this.mcpServer?.name); + return this.mcpService.servers.get().find(s => s.definition.id === this.mcpServer?.id); } private getAccountQuery(): IAccountQuery | undefined { @@ -550,7 +550,7 @@ export class ShowServerOutputAction extends McpServerAction { if (!this.mcpServer.local) { return; } - return this.mcpService.servers.get().find(s => s.definition.label === this.mcpServer?.name); + return this.mcpService.servers.get().find(s => s.definition.id === this.mcpServer?.id); } } @@ -626,7 +626,7 @@ export class ConfigureModelAccessAction extends McpServerAction { if (!this.mcpServer.local) { return; } - return this.mcpService.servers.get().find(s => s.definition.label === this.mcpServer?.name); + return this.mcpService.servers.get().find(s => s.definition.id === this.mcpServer?.id); } } @@ -680,7 +680,7 @@ export class ShowSamplingRequestsAction extends McpServerAction { if (!this.mcpServer.local) { return; } - return this.mcpService.servers.get().find(s => s.definition.label === this.mcpServer?.name); + return this.mcpService.servers.get().find(s => s.definition.id === this.mcpServer?.id); } } @@ -731,7 +731,7 @@ export class BrowseResourcesAction extends McpServerAction { if (!this.mcpServer.local) { return; } - return this.mcpService.servers.get().find(s => s.definition.label === this.mcpServer?.name); + return this.mcpService.servers.get().find(s => s.definition.id === this.mcpServer?.id); } } @@ -769,7 +769,7 @@ export class McpServerStatusAction extends McpServerAction { return; } - if (this.mcpServer.installState === McpServerInstallState.Uninstalled) { + if ((this.mcpServer.gallery || this.mcpServer.installable) && this.mcpServer.installState === McpServerInstallState.Uninstalled) { const result = this.mcpWorkbenchService.canInstall(this.mcpServer); if (result !== true) { this.updateStatus({ icon: warningIcon, message: result }, true); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts b/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts index 56627d31324..aead1ce764d 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts @@ -146,7 +146,7 @@ export class McpServersListView extends AbstractExtensionsListView local.name === e.element!.name) || e.element + const extension = e.element ? this.mcpWorkbenchService.local.find(local => local.id === e.element!.id) || e.element : e.element; manageExtensionAction.mcpServer = extension; let groups: IAction[][] = []; @@ -278,7 +278,7 @@ export class McpServersListView extends AbstractExtensionsListView e.name === previousMcpServerInNew.name); + index = oldMcpServers.findIndex(e => e.id === previousMcpServerInNew.id); if (index === -1) { return findPreviousMcpServerIndex(from - 1); } @@ -289,7 +289,7 @@ export class McpServersListView extends AbstractExtensionsListView r.name !== mcpServer.name)) { + if (mcpServers.every(r => r.id !== mcpServer.id)) { hasChanged = true; mcpServers.splice(findPreviousMcpServerIndex(index - 1) + 1, 0, mcpServer); } @@ -393,7 +393,10 @@ class McpServerRenderer implements IListRenderer { - const disabled = mcpServer.installState === McpServerInstallState.Installed && !!mcpServer.local && this.allowedMcpServersService.isAllowed(mcpServer.local) !== true; + const disabled = !!mcpServer.local && + (mcpServer.installState === McpServerInstallState.Installed + ? this.allowedMcpServersService.isAllowed(mcpServer.local) !== true + : mcpServer.installState === McpServerInstallState.Uninstalled); data.root.classList.toggle('disabled', disabled); }; updateEnablement(); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts index af20419030a..610bad173f9 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts @@ -18,7 +18,8 @@ import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; -import { DidUninstallMcpServerEvent, IGalleryMcpServer, IMcpGalleryService, InstallMcpServerResult, IQueryOptions, IInstallableMcpServer, IMcpServerManifest, ILocalMcpServer } from '../../../../platform/mcp/common/mcpManagement.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IGalleryMcpServer, IMcpGalleryService, IQueryOptions, IInstallableMcpServer, IMcpServerManifest, ILocalMcpServer } from '../../../../platform/mcp/common/mcpManagement.js'; import { IMcpServerConfiguration, IMcpServerVariable, IMcpStdioServerConfiguration, McpServerType } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { StorageScope } from '../../../../platform/storage/common/storage.js'; @@ -30,7 +31,7 @@ import { IWorkbenchContribution } from '../../../common/contributions.js'; import { MCP_CONFIGURATION_KEY, WORKSPACE_STANDALONE_CONFIGURATIONS } from '../../../services/configuration/common/configuration.js'; import { ACTIVE_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; -import { IWorkbenchLocalMcpServer, IWorkbenchMcpManagementService, LocalMcpServerScope } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; +import { DidUninstallWorkbenchMcpServerEvent, IWorkbenchLocalMcpServer, IWorkbenchMcpManagementService, IWorkbenchMcpServerInstallResult, LocalMcpServerScope, REMOTE_USER_CONFIG_ID, USER_CONFIG_ID, WORKSPACE_CONFIG_ID, WORKSPACE_FOLDER_CONFIG_ID_PREFIX } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; import { mcpConfigurationSection } from '../common/mcpConfiguration.js'; import { HasInstalledMcpServersContext, IMcpConfigPath, IMcpWorkbenchService, IWorkbenchMcpServer, McpCollectionSortOrder, McpServerInstallState, McpServersGalleryEnabledContext } from '../common/mcpTypes.js'; @@ -54,7 +55,7 @@ class McpWorkbenchServer implements IWorkbenchMcpServer { } get id(): string { - return this.gallery?.id ?? this.local?.id ?? this.installable?.name ?? ''; + return this.local?.id ?? this.gallery?.id ?? this.installable?.name ?? this.name; } get name(): string { @@ -167,6 +168,7 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ @IProductService private readonly productService: IProductService, @IRemoteAgentService private readonly remoteAgentService: IRemoteAgentService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @ILogService private readonly logService: ILogService, @IURLService urlService: IURLService, ) { super(); @@ -184,19 +186,28 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ this._onReset.fire(); } - private onDidUninstallMcpServer(e: DidUninstallMcpServerEvent) { + private areSameMcpServers(a: { name: string; scope: LocalMcpServerScope } | undefined, b: { name: string; scope: LocalMcpServerScope } | undefined): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + return a.name === b.name && a.scope === b.scope; + } + + private onDidUninstallMcpServer(e: DidUninstallWorkbenchMcpServerEvent) { if (e.error) { return; } - const server = this._local.find(server => server.local?.name === e.name); - if (server) { - this._local = this._local.filter(server => server.local?.name !== e.name); - server.local = undefined; - this._onChange.fire(server); + const uninstalled = this._local.find(server => this.areSameMcpServers(server.local, e)); + if (uninstalled) { + this._local = this._local.filter(server => server !== uninstalled); + this._onChange.fire(uninstalled); } } - private onDidInstallMcpServers(e: readonly InstallMcpServerResult[]) { + private onDidInstallMcpServers(e: readonly IWorkbenchMcpServerInstallResult[]) { const servers: IWorkbenchMcpServer[] = []; for (const result of e) { if (!result.local) { @@ -210,25 +221,25 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ } private onDidInstallMcpServer(local: IWorkbenchLocalMcpServer, gallery?: IGalleryMcpServer): IWorkbenchMcpServer { - let server = this.installing.find(server => server.name === local.name); + let server = this.installing.find(server => server.local ? this.areSameMcpServers(server.local, local) : server.name === local.name); this.installing = server ? this.installing.filter(e => e !== server) : this.installing; if (server) { server.local = local; } else { server = this.instantiationService.createInstance(McpWorkbenchServer, e => this.getInstallState(e), local, gallery, undefined); } - this._local = this._local.filter(e => e.name !== local.name); + this._local = this._local.filter(server => !this.areSameMcpServers(server.local, local)); this._local.push(server); this._onChange.fire(server); return server; } - private onDidUpdateMcpServers(e: readonly InstallMcpServerResult[]) { + private onDidUpdateMcpServers(e: readonly IWorkbenchMcpServerInstallResult[]) { for (const result of e) { if (!result.local) { continue; } - const serverIndex = this._local.findIndex(server => server.local?.name === result.name); + const serverIndex = this._local.findIndex(server => this.areSameMcpServers(server.local, result.local)); let server: McpWorkbenchServer; if (serverIndex !== -1) { this._local[serverIndex].local = result.local; @@ -301,7 +312,7 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ async queryLocal(): Promise { const installed = await this.mcpManagementService.getInstalled(); this._local = installed.map(i => { - const local = this._local.find(server => server.name === i.name) ?? this.instantiationService.createInstance(McpWorkbenchServer, e => this.getInstallState(e), undefined, undefined, undefined); + const local = this.instantiationService.createInstance(McpWorkbenchServer, e => this.getInstallState(e), undefined, undefined, undefined); local.local = i; return local; }); @@ -309,6 +320,40 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ return [...this.local]; } + getEnabledLocalMcpServers(): IWorkbenchLocalMcpServer[] { + const result = new Map(); + const userRemote: IWorkbenchLocalMcpServer[] = []; + const workspace: IWorkbenchLocalMcpServer[] = []; + + for (const server of this.local) { + if (server.local?.scope === LocalMcpServerScope.User) { + result.set(server.name, server.local); + } else if (server.local?.scope === LocalMcpServerScope.RemoteUser) { + userRemote.push(server.local); + } else if (server.local?.scope === LocalMcpServerScope.Workspace) { + workspace.push(server.local); + } + } + + for (const server of userRemote) { + const existing = result.get(server.name); + if (existing) { + this.logService.warn(localize('overwriting', "Overwriting mcp server '{0}' from {1} with {2}.", server.name, server.mcpResource.path, existing.mcpResource.path)); + } + result.set(server.name, server); + } + + for (const server of workspace) { + const existing = result.get(server.name); + if (existing) { + this.logService.warn(localize('overwriting', "Overwriting mcp server '{0}' from {1} with {2}.", server.name, server.mcpResource.path, existing.mcpResource.path)); + } + result.set(server.name, server); + } + + return [...result.values()]; + } + canInstall(mcpServer: IWorkbenchMcpServer): true | IMarkdownString { if (!(mcpServer instanceof McpWorkbenchServer)) { return new MarkdownString().appendText(localize('not an extension', "The provided object is not an mcp server.")); @@ -417,7 +462,7 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ private getUserMcpConfigPath(mcpResource: URI): IMcpConfigPath { return { - id: 'usrlocal', + id: USER_CONFIG_ID, key: 'userLocalValue', target: ConfigurationTarget.USER_LOCAL, label: localize('mcp.configuration.userLocalValue', 'Global in {0}', this.productService.nameShort), @@ -430,7 +475,7 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ private getRemoteMcpConfigPath(mcpResource: URI): IMcpConfigPath { return { - id: 'usrremote', + id: REMOTE_USER_CONFIG_ID, key: 'userRemoteValue', target: ConfigurationTarget.USER_REMOTE, label: this.environmentService.remoteAuthority ? this.labelService.getHostLabel(Schemas.vscodeRemote, this.environmentService.remoteAuthority) : 'Remote', @@ -446,7 +491,7 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ const workspace = this.workspaceService.getWorkspace(); if (workspace.configuration && this.uriIdentityService.extUri.isEqual(workspace.configuration, mcpResource)) { return { - id: 'workspace', + id: WORKSPACE_CONFIG_ID, key: 'workspaceValue', target: ConfigurationTarget.WORKSPACE, label: basename(mcpResource), @@ -463,7 +508,7 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ const workspaceFolder = workspaceFolders[index]; if (this.uriIdentityService.extUri.isEqual(this.uriIdentityService.extUri.joinPath(workspaceFolder.uri, WORKSPACE_STANDALONE_CONFIGURATIONS[MCP_CONFIGURATION_KEY]), mcpResource)) { return { - id: `wf${index}`, + id: `${WORKSPACE_FOLDER_CONFIG_ID_PREFIX}${index}`, key: 'workspaceFolderValue', target: ConfigurationTarget.WORKSPACE_FOLDER, label: `${workspaceFolder.name}/.vscode/mcp.json`, diff --git a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts index 24089082107..66308914560 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts @@ -9,7 +9,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; import { StorageScope } from '../../../../../platform/storage/common/storage.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; -import { McpServerDefinition, McpServerTransportType, IMcpWorkbenchService, IMcpConfigPath, IWorkbenchMcpServer } from '../mcpTypes.js'; +import { McpServerDefinition, McpServerTransportType, IMcpWorkbenchService, IMcpConfigPath } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; import { mcpConfigurationSection } from '../mcpConfiguration.js'; import { posix as pathPosix, win32 as pathWin32, sep as pathSep } from '../../../../../base/common/path.js'; @@ -17,10 +17,10 @@ import { ITextModelService } from '../../../../../editor/common/services/resolve import { getMcpServerMapping } from '../mcpConfigFileUtils.js'; import { Location } from '../../../../../editor/common/languages.js'; import { ResourceMap } from '../../../../../base/common/map.js'; -import { ILocalMcpServer } from '../../../../../platform/mcp/common/mcpManagement.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; import { isWindows, OperatingSystem } from '../../../../../base/common/platform.js'; +import { IWorkbenchLocalMcpServer } from '../../../../services/mcp/common/mcpWorkbenchManagementService.js'; export class InstalledMcpServersDiscovery extends Disposable implements IMcpDiscovery { @@ -58,30 +58,26 @@ export class InstalledMcpServersDiscovery extends Disposable implements IMcpDisc private async sync(): Promise { try { const remoteEnv = await this.remoteAgentService.getEnvironment(); - const collections = new Map(); + const collections = new Map(); const mcpConfigPathInfos = new ResourceMap } | undefined>>(); - for (const server of this.mcpWorkbenchService.local) { - if (!server.local) { - continue; - } - - let mcpConfigPathPromise = mcpConfigPathInfos.get(server.local.mcpResource); + for (const server of this.mcpWorkbenchService.getEnabledLocalMcpServers()) { + let mcpConfigPathPromise = mcpConfigPathInfos.get(server.mcpResource); if (!mcpConfigPathPromise) { - mcpConfigPathPromise = (async (local: ILocalMcpServer) => { + mcpConfigPathPromise = (async (local: IWorkbenchLocalMcpServer) => { const mcpConfigPath = this.mcpWorkbenchService.getMcpConfigPath(local); const locations = mcpConfigPath?.uri ? await this.getServerIdMapping(mcpConfigPath?.uri, mcpConfigPath.section ? [...mcpConfigPath.section, 'servers'] : ['servers']) : new Map(); return mcpConfigPath ? { ...mcpConfigPath, locations } : undefined; - })(server.local); - mcpConfigPathInfos.set(server.local.mcpResource, mcpConfigPathPromise); + })(server); + mcpConfigPathInfos.set(server.mcpResource, mcpConfigPathPromise); } - const config = server.local.config; + const config = server.config; const mcpConfigPath = await mcpConfigPathPromise; const collectionId = `mcp.config.${mcpConfigPath ? mcpConfigPath.id : 'unknown'}`; let definitions = collections.get(collectionId); if (!definitions) { - definitions = [mcpConfigPath, [], server]; + definitions = [mcpConfigPath, []]; collections.set(collectionId, definitions); } @@ -94,8 +90,8 @@ export class InstalledMcpServersDiscovery extends Disposable implements IMcpDisc }; definitions[1].push({ - id: `${collectionId}.${server.local.name}`, - label: server.local.name, + id: `${collectionId}.${server.name}`, + label: server.name, launch: config.type === 'http' ? { type: McpServerTransportType.HTTP, uri: URI.parse(config.url), @@ -127,7 +123,7 @@ export class InstalledMcpServersDiscovery extends Disposable implements IMcpDisc devMode: config.dev, presentation: { order: mcpConfigPath?.order, - origin: mcpConfigPath?.locations.get(server.local.name) + origin: mcpConfigPath?.locations.get(server.name) } }); } diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 383faaa6436..f89be8505d7 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -621,6 +621,7 @@ export interface IMcpWorkbenchService { readonly onChange: Event; readonly onReset: Event; readonly local: readonly IWorkbenchMcpServer[]; + getEnabledLocalMcpServers(): IWorkbenchLocalMcpServer[]; queryLocal(): Promise; queryGallery(options?: IQueryOptions, token?: CancellationToken): Promise; canInstall(mcpServer: IWorkbenchMcpServer): true | IMarkdownString; @@ -647,7 +648,7 @@ export class McpServerContainers extends Disposable { update(server: IWorkbenchMcpServer | undefined): void { for (const container of this.containers) { if (server && container.mcpServer) { - if (server.name === container.mcpServer.name) { + if (server.id === container.mcpServer.id) { container.mcpServer = server; } } else { diff --git a/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts index 58a8ef347ff..7a76f7e5854 100644 --- a/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts @@ -230,6 +230,7 @@ export class SCMRepositoriesViewPane extends ViewPane { this._register(this.tree.onDidChangeSelection(this.onTreeSelectionChange, this)); this._register(this.tree.onDidChangeFocus(this.onTreeDidChangeFocus, this)); + this._register(this.tree.onDidFocus(this.onDidTreeFocus, this)); this._register(this.tree.onContextMenu(this.onTreeContextMenu, this)); this._register(this.tree.onDidChangeContentHeight(this.onTreeContentHeightChange, this)); } @@ -274,6 +275,13 @@ export class SCMRepositoriesViewPane extends ViewPane { } } + private onDidTreeFocus(): void { + const focused = this.tree.getFocus(); + if (focused.length > 0) { + this.scmViewService.focus(focused[0]); + } + } + private onTreeContentHeightChange(height: number): void { this.updateBodySize(height); 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/bufferOutputPolling.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/bufferOutputPolling.ts index 7ba7aee0dac..cec02c6b841 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/bufferOutputPolling.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/bufferOutputPolling.ts @@ -16,7 +16,7 @@ import { IToolInvocationContext } from '../../../chat/common/languageModelToolsS import { ITerminalInstance } from '../../../terminal/browser/terminal.js'; import type { IMarker as IXtermMarker } from '@xterm/xterm'; -const enum PollingConsts { +export const enum PollingConsts { MinNoDataEvents = 2, // Minimum number of no data checks before considering the terminal idle MinPollingDuration = 500, FirstPollingMaxDuration = 20000, // 20 seconds @@ -24,6 +24,56 @@ const enum PollingConsts { MaxPollingIntervalDuration = 2000, // 2 seconds } + +/** + * Waits for either polling to complete (terminal idle or timeout) or for the user to respond to a prompt. + * If polling completes first, the prompt is removed. If the prompt completes first and is accepted, polling continues. + */ +export async function racePollingOrPrompt( + pollFn: () => Promise<{ terminalExecutionIdleBeforeTimeout: boolean; output: string; pollDurationMs?: number; modelOutputEvalResponse?: string }>, + promptFn: () => { promise: Promise; part?: Pick }, + originalResult: { terminalExecutionIdleBeforeTimeout: boolean; output: string; pollDurationMs?: number; modelOutputEvalResponse?: string }, + token: CancellationToken, + languageModelsService: ILanguageModelsService, + execution: { getOutput: () => string; isActive?: () => Promise } +): Promise<{ terminalExecutionIdleBeforeTimeout: boolean; output: string; pollDurationMs?: number; modelOutputEvalResponse?: string }> { + const pollPromise = pollFn(); + const { promise: promptPromise, part } = promptFn(); + let promptResolved = false; + + const pollPromiseWrapped = pollPromise.then(async result => { + if (!promptResolved && part) { + // The terminal polling is finished, no need to show the prompt + part.hide(); + } + return { type: 'poll', result }; + }); + + const promptPromiseWrapped = promptPromise.then(result => { + promptResolved = true; + return { type: 'prompt', result }; + }); + + const raceResult = await Promise.race([ + pollPromiseWrapped, + promptPromiseWrapped + ]); + if (raceResult.type === 'poll') { + return raceResult.result as { terminalExecutionIdleBeforeTimeout: boolean; output: string; pollDurationMs?: number; modelOutputEvalResponse?: string }; + } else if (raceResult.type === 'prompt') { + const promptResult = raceResult.result as boolean; + if (promptResult) { + // User accepted, poll again (extended) + return await pollForOutputAndIdle(execution, true, token, languageModelsService); + } else { + return originalResult; // User rejected, return the original result + } + } + // If prompt was rejected or something else, return the result of the first poll + return await pollFn(); +} + + export function getOutput(instance: ITerminalInstance, startMarker?: IXtermMarker): string { if (!instance.xterm || !instance.xterm.raw) { return ''; @@ -44,7 +94,7 @@ export async function pollForOutputAndIdle( extendedPolling: boolean, token: CancellationToken, languageModelsService: ILanguageModelsService, -): Promise<{ terminalExecutionIdleBeforeTimeout: boolean; output: string; pollDurationMs?: number }> { +): Promise<{ terminalExecutionIdleBeforeTimeout: boolean; output: string; pollDurationMs?: number; modelOutputEvalResponse?: string }> { const maxWaitMs = extendedPolling ? PollingConsts.ExtendedPollingMaxDuration : PollingConsts.FirstPollingMaxDuration; const maxInterval = PollingConsts.MaxPollingIntervalDuration; let currentInterval = PollingConsts.MinPollingDuration; @@ -89,52 +139,58 @@ export async function pollForOutputAndIdle( } if (noNewDataCount >= PollingConsts.MinNoDataEvents) { - terminalExecutionIdleBeforeTimeout = await assessOutputForFinishedState(buffer, execution, token, languageModelsService); - if (terminalExecutionIdleBeforeTimeout) { - return { terminalExecutionIdleBeforeTimeout, output: buffer, pollDurationMs: Date.now() - pollStartTime + (extendedPolling ? PollingConsts.FirstPollingMaxDuration : 0) }; + if (execution.isActive && ((await execution.isActive()) === true)) { + noNewDataCount = 0; + lastBufferLength = currentBufferLength; + continue; } + terminalExecutionIdleBeforeTimeout = true; + const modelOutputEvalResponse = await assessOutputForErrors(buffer, token, languageModelsService); + return { modelOutputEvalResponse, terminalExecutionIdleBeforeTimeout, output: buffer, pollDurationMs: Date.now() - pollStartTime + (extendedPolling ? PollingConsts.FirstPollingMaxDuration : 0) }; } } - return { terminalExecutionIdleBeforeTimeout, output: buffer, pollDurationMs: Date.now() - pollStartTime + (extendedPolling ? PollingConsts.FirstPollingMaxDuration : 0) }; + return { terminalExecutionIdleBeforeTimeout: false, output: buffer, pollDurationMs: Date.now() - pollStartTime + (extendedPolling ? PollingConsts.FirstPollingMaxDuration : 0) }; } -export async function promptForMorePolling(command: string, context: IToolInvocationContext, chatService: IChatService): Promise { +export function promptForMorePolling(command: string, context: IToolInvocationContext, chatService: IChatService): { promise: Promise; part?: ChatElicitationRequestPart } { const chatModel = chatService.getSession(context.sessionId); if (chatModel instanceof ChatModel) { const request = chatModel.getRequests().at(-1); if (request) { - const waitPromise = new Promise(resolve => { - const part = new ChatElicitationRequestPart( + let part: ChatElicitationRequestPart | undefined = undefined; + const promise = new Promise(resolve => { + const thePart = part = new ChatElicitationRequestPart( new MarkdownString(localize('poll.terminal.waiting', "Continue waiting for `{0}` to finish?", command)), new MarkdownString(localize('poll.terminal.polling', "Copilot will continue to poll for output to determine when the terminal becomes idle for up to 2 minutes.")), '', localize('poll.terminal.accept', 'Yes'), localize('poll.terminal.reject', 'No'), async () => { + thePart.state = 'accepted'; + thePart.hide(); resolve(true); }, async () => { + thePart.state = 'rejected'; + thePart.hide(); resolve(false); } ); - chatModel.acceptResponseProgress(request, part); + chatModel.acceptResponseProgress(request, thePart); }); - return waitPromise; + return { promise, part }; } } - return false; // Fallback to not waiting if we can't prompt the user + return { promise: Promise.resolve(false) }; } -export async function assessOutputForFinishedState(buffer: string, execution: { getOutput: () => string; isActive?: () => Promise }, token: CancellationToken, languageModelsService: ILanguageModelsService): Promise { - if (execution.isActive && ((await execution.isActive()) === false)) { - return true; - } +export async function assessOutputForErrors(buffer: string, token: CancellationToken, languageModelsService: ILanguageModelsService): Promise { const models = await languageModelsService.selectLanguageModels({ vendor: 'copilot', family: 'gpt-4o-mini' }); if (!models.length) { - return false; + return 'No models available'; } - const response = await languageModelsService.sendChatRequest(models[0], new ExtensionIdentifier('Github.copilot-chat'), [{ role: ChatMessageRole.Assistant, content: [{ type: 'text', value: `Evaluate this terminal output to determine if the command is finished or still in process: ${buffer}. Return the word true if finished and false if still in process.` }] }], {}, token); + const response = await languageModelsService.sendChatRequest(models[0], new ExtensionIdentifier('Github.copilot-chat'), [{ role: ChatMessageRole.Assistant, content: [{ type: 'text', value: `Evaluate this terminal output to determine if there were errors or if the command ran successfully: ${buffer}.` }] }], {}, token); let responseText = ''; @@ -154,8 +210,8 @@ export async function assessOutputForFinishedState(buffer: string, execution: { try { await Promise.all([response.result, streaming]); - return responseText.includes('true'); + return response.result; } catch (err) { - return false; + return 'Error occurred ' + err; } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts index c42b2a3cd4c..5e5545147e5 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts @@ -11,7 +11,7 @@ import { isNumber } from '../../../../../../base/common/types.js'; import type { ICommandDetectionCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js'; import { ITerminalLogService } from '../../../../../../platform/terminal/common/terminal.js'; import type { ITerminalInstance } from '../../../../terminal/browser/terminal.js'; -import { trackIdleOnPrompt, waitForIdle, type ITerminalExecuteStrategy } from './executeStrategy.js'; +import { trackIdleOnPrompt, waitForIdle, type ITerminalExecuteStrategy, type ITerminalExecuteStrategyResult } from './executeStrategy.js'; /** * This strategy is used when shell integration is enabled, but rich command detection was not @@ -45,7 +45,7 @@ export class BasicExecuteStrategy implements ITerminalExecuteStrategy { ) { } - async execute(commandLine: string, token: CancellationToken): Promise<{ result: string; exitCode?: number; error?: string }> { + async execute(commandLine: string, token: CancellationToken): Promise { const store = new DisposableStore(); try { const idlePromptPromise = trackIdleOnPrompt(this._instance, 1000, store); @@ -107,35 +107,37 @@ export class BasicExecuteStrategy implements ITerminalExecuteStrategy { const endMarker = store.add(xterm.raw.registerMarker()); // Assemble final result - let result: string | undefined; + let output: string | undefined; + const additionalInformationLines: string[] = []; if (finishedCommand) { const commandOutput = finishedCommand?.getOutput(); if (commandOutput !== undefined) { this._logService.debug('RunInTerminalTool#Basic: Fetched output via finished command'); - result = commandOutput; + output = commandOutput; } } - if (result === undefined) { + if (output === undefined) { try { - result = xterm.getContentsAsText(startMarker, endMarker); + output = xterm.getContentsAsText(startMarker, endMarker); this._logService.debug('RunInTerminalTool#Basic: Fetched output via markers'); } catch { this._logService.debug('RunInTerminalTool#Basic: Failed to fetch output via markers'); - result = 'Failed to retrieve command output'; + additionalInformationLines.push('Failed to retrieve command output'); } } - if (result.trim().length === 0) { - result = 'Command produced no output'; + if (output !== undefined && output.trim().length === 0) { + additionalInformationLines.push('Command produced no output'); } const exitCode = finishedCommand?.exitCode; if (isNumber(exitCode) && exitCode > 0) { - result += `\n\nCommand exited with code ${exitCode}`; + additionalInformationLines.push(`Command exited with code ${exitCode}`); } return { - result, + output, + additionalInformation: additionalInformationLines.length > 0 ? additionalInformationLines.join('\n') : undefined, exitCode, }; } finally { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts index f9a21544197..06361937362 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts @@ -15,7 +15,14 @@ export interface ITerminalExecuteStrategy { * Executes a command line and gets a result designed to be passed directly to an LLM. The * result will include information about the exit code. */ - execute(commandLine: string, token: CancellationToken): Promise<{ result: string; exitCode?: number; error?: string }>; + execute(commandLine: string, token: CancellationToken): Promise; +} + +export interface ITerminalExecuteStrategyResult { + output: string | undefined; + additionalInformation?: string; + exitCode?: number; + error?: string; } export async function waitForIdle(onData: Event, idleDurationMs: number): Promise { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts index b8ef5dbf79c..40c9495fe4f 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts @@ -8,7 +8,7 @@ import { CancellationError } from '../../../../../../base/common/errors.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { ITerminalLogService } from '../../../../../../platform/terminal/common/terminal.js'; import type { ITerminalInstance } from '../../../../terminal/browser/terminal.js'; -import { waitForIdle, type ITerminalExecuteStrategy } from './executeStrategy.js'; +import { waitForIdle, type ITerminalExecuteStrategy, type ITerminalExecuteStrategyResult } from './executeStrategy.js'; /** * This strategy is used when no shell integration is available. There are very few extension APIs @@ -25,7 +25,7 @@ export class NoneExecuteStrategy implements ITerminalExecuteStrategy { ) { } - async execute(commandLine: string, token: CancellationToken): Promise<{ result: string; exitCode?: number; error?: string }> { + async execute(commandLine: string, token: CancellationToken): Promise { const store = new DisposableStore(); try { if (token.isCancellationRequested) { @@ -68,16 +68,18 @@ export class NoneExecuteStrategy implements ITerminalExecuteStrategy { const endMarker = store.add(xterm.raw.registerMarker()); // Assemble final result - exit code is not available without shell integration - let result: string; + let output: string | undefined; + const additionalInformationLines: string[] = []; try { - result = xterm.getContentsAsText(startMarker, endMarker); + output = xterm.getContentsAsText(startMarker, endMarker); this._logService.debug('RunInTerminalTool#None: Fetched output via markers'); } catch { this._logService.debug('RunInTerminalTool#None: Failed to fetch output via markers'); - result = 'Failed to retrieve command output'; + additionalInformationLines.push('Failed to retrieve command output'); } return { - result, + output, + additionalInformation: additionalInformationLines.length > 0 ? additionalInformationLines.join('\n') : undefined, exitCode: undefined, }; } finally { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts index 6628e0f95c6..f74bdc8acf1 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts @@ -11,7 +11,7 @@ import { isNumber } from '../../../../../../base/common/types.js'; import type { ICommandDetectionCapability, ITerminalCommand } from '../../../../../../platform/terminal/common/capabilities/capabilities.js'; import { ITerminalLogService } from '../../../../../../platform/terminal/common/terminal.js'; import type { ITerminalInstance } from '../../../../terminal/browser/terminal.js'; -import { trackIdleOnPrompt, type ITerminalExecuteStrategy } from './executeStrategy.js'; +import { trackIdleOnPrompt, type ITerminalExecuteStrategy, type ITerminalExecuteStrategyResult } from './executeStrategy.js'; /** * This strategy is used when the terminal has rich shell integration/command detection is @@ -30,7 +30,7 @@ export class RichExecuteStrategy implements ITerminalExecuteStrategy { ) { } - async execute(commandLine: string, token: CancellationToken): Promise<{ result: string; exitCode?: number; error?: string }> { + async execute(commandLine: string, token: CancellationToken): Promise { const store = new DisposableStore(); try { // Ensure xterm is available @@ -66,6 +66,7 @@ export class RichExecuteStrategy implements ITerminalExecuteStrategy { this._logService.debug(`RunInTerminalTool#Rich: Executing command line \`${commandLine}\``); this._instance.runCommand(commandLine, true); + // Wait for the terminal to idle this._logService.debug(`RunInTerminalTool#Rich: Waiting for done event`); const finishedCommand = await onDone; if (token.isCancellationRequested) { @@ -73,35 +74,38 @@ export class RichExecuteStrategy implements ITerminalExecuteStrategy { } const endMarker = store.add(xterm.raw.registerMarker()); - let result: string | undefined; + // Assemble final result + let output: string | undefined; + const additionalInformationLines: string[] = []; if (finishedCommand) { const commandOutput = finishedCommand?.getOutput(); if (commandOutput !== undefined) { this._logService.debug('RunInTerminalTool#Rich: Fetched output via finished command'); - result = commandOutput; + output = commandOutput; } } - if (result === undefined) { + if (output === undefined) { try { - result = xterm.getContentsAsText(startMarker, endMarker); + output = xterm.getContentsAsText(startMarker, endMarker); this._logService.debug('RunInTerminalTool#Rich: Fetched output via markers'); } catch { this._logService.debug('RunInTerminalTool#Basic: Failed to fetch output via markers'); - result = 'Failed to retrieve command output'; + additionalInformationLines.push('Failed to retrieve command output'); } } - if (result.trim().length === 0) { - result = 'Command produced no output'; + if (output !== undefined && output.trim().length === 0) { + additionalInformationLines.push('Command produced no output'); } const exitCode = finishedCommand?.exitCode; if (isNumber(exitCode) && exitCode > 0) { - result += `\n\nCommand exited with code ${exitCode}`; + additionalInformationLines.push(`Command exited with code ${exitCode}`); } return { - result, + output, + additionalInformation: additionalInformationLines.length > 0 ? additionalInformationLines.join('\n') : undefined, exitCode, }; } finally { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalTool.ts index 4480d1e5ded..d750fa95add 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalTool.ts @@ -21,7 +21,7 @@ import { TerminalCapability } from '../../../../../platform/terminal/common/capa import { ITerminalLogService } from '../../../../../platform/terminal/common/terminal.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; -import { IChatService, type IChatTerminalToolInvocationData, type IChatTerminalToolInvocationData2 } from '../../../chat/common/chatService.js'; +import { IChatService, type IChatTerminalToolInvocationData } from '../../../chat/common/chatService.js'; import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress, type IToolConfirmationMessages } from '../../../chat/common/languageModelToolsService.js'; import { ITerminalService, type ITerminalInstance } from '../../../terminal/browser/terminal.js'; import type { XtermTerminal } from '../../../terminal/browser/xterm/xtermTerminal.js'; @@ -36,7 +36,7 @@ import { isPowerShell } from './runInTerminalHelpers.js'; import { extractInlineSubCommands, splitCommandLineIntoSubCommands } from './subCommands.js'; import { ShellIntegrationQuality, ToolTerminalCreator, type IToolTerminal } from './toolTerminalCreator.js'; import { ILanguageModelsService } from '../../../chat/common/languageModels.js'; -import { getOutput, pollForOutputAndIdle, promptForMorePolling } from './bufferOutputPolling.js'; +import { getOutput, pollForOutputAndIdle, promptForMorePolling, racePollingOrPrompt } from './bufferOutputPolling.js'; const TERMINAL_SESSION_STORAGE_KEY = 'chat.terminalSessions'; @@ -122,7 +122,6 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // HACK: Per-tool call state, saved globally // TODO: These should not be part of the state as different sessions could get confused https://github.com/microsoft/vscode/issues/255889 private _alternativeRecommendation?: IToolResult; - private _rewrittenCommand?: string; private static readonly _backgroundExecutions = new Map(); public static getBackgroundOutput(id: string): string { @@ -222,7 +221,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { confirmationMessages, presentation, toolSpecificData: { - kind: 'terminal2', + kind: 'terminal', commandLine: { original: args.command, toolEdited: toolEditedCommand @@ -241,7 +240,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._logService.debug(`RunInTerminalTool: Invoking with options ${JSON.stringify(args)}`); - const toolSpecificData = invocation.toolSpecificData as IChatTerminalToolInvocationData | IChatTerminalToolInvocationData2 | undefined; + const toolSpecificData = invocation.toolSpecificData as IChatTerminalToolInvocationData | undefined; if (!toolSpecificData) { throw new Error('toolSpecificData must be provided for this tool'); } @@ -251,25 +250,16 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { throw new Error('A chat session ID is required for this tool'); } - let command: string | undefined; - let didUserEditCommand: boolean; - let didToolEditCommand: boolean; - if (toolSpecificData.kind === 'terminal') { - command = toolSpecificData.command ?? this._rewrittenCommand ?? args.command; - didUserEditCommand = typeof toolSpecificData?.command === 'string' && toolSpecificData.command !== args.command; - didToolEditCommand = !didUserEditCommand && this._rewrittenCommand !== undefined; - } else { - command = toolSpecificData.commandLine.userEdited ?? toolSpecificData.commandLine.toolEdited ?? toolSpecificData.commandLine.original; - didUserEditCommand = ( - toolSpecificData.commandLine.userEdited !== undefined && - toolSpecificData.commandLine.userEdited !== toolSpecificData.commandLine.original - ); - didToolEditCommand = ( - !didUserEditCommand && - toolSpecificData.commandLine.toolEdited !== undefined && - toolSpecificData.commandLine.toolEdited !== toolSpecificData.commandLine.original - ); - } + const command = toolSpecificData.commandLine.userEdited ?? toolSpecificData.commandLine.toolEdited ?? toolSpecificData.commandLine.original; + const didUserEditCommand = ( + toolSpecificData.commandLine.userEdited !== undefined && + toolSpecificData.commandLine.userEdited !== toolSpecificData.commandLine.original + ); + const didToolEditCommand = ( + !didUserEditCommand && + toolSpecificData.commandLine.toolEdited !== undefined && + toolSpecificData.commandLine.toolEdited !== toolSpecificData.commandLine.original + ); if (token.isCancellationRequested) { throw new CancellationError(); @@ -281,7 +271,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const termId = generateUuid(); if (args.isBackground) { - let outputAndIdle: { terminalExecutionIdleBeforeTimeout: boolean; output: string; pollDurationMs?: number } | undefined = undefined; + let outputAndIdle: { terminalExecutionIdleBeforeTimeout: boolean; output: string; pollDurationMs?: number; modelOutputEvalResponse?: string } | undefined = undefined; this._logService.debug(`RunInTerminalTool: Creating background terminal with ID=${termId}`); const toolTerminal = await this._instantiationService.createInstance(ToolTerminalCreator).createTerminal(token); @@ -303,14 +293,18 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } const execution = new BackgroundTerminalExecution(toolTerminal.instance, xterm, command); RunInTerminalTool._backgroundExecutions.set(termId, execution); - outputAndIdle = await pollForOutputAndIdle(execution, false, token, this._languageModelsService); if (!outputAndIdle.terminalExecutionIdleBeforeTimeout) { - const extendPolling = await promptForMorePolling(command, invocation.context, this._chatService); - if (extendPolling) { - outputAndIdle = await pollForOutputAndIdle(execution, true, token, this._languageModelsService); - } + outputAndIdle = await racePollingOrPrompt( + () => pollForOutputAndIdle(execution, true, token, this._languageModelsService), + () => promptForMorePolling(command, invocation.context!, this._chatService), + outputAndIdle, + token, + this._languageModelsService, + execution + ); } + let resultText = ( didUserEditCommand ? `Note: The user manually edited the command to \`${command}\`, and that command is now running in terminal with ID=${termId}` @@ -318,7 +312,11 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { ? `Note: The tool simplified the command to \`${command}\`, and that command is now running in terminal with ID=${termId}` : `Command is running in terminal with ID=${termId}` ); - resultText += outputAndIdle.terminalExecutionIdleBeforeTimeout ? `\n\ The command became idle with output:\n${outputAndIdle.output}` : `\n\ The command is still running, with output:\n${outputAndIdle.output}`; + if (outputAndIdle && outputAndIdle.modelOutputEvalResponse) { + resultText += `\n\ The command became idle with output:\n${outputAndIdle.modelOutputEvalResponse}`; + } else if (outputAndIdle) { + resultText += `\n\ The command is still running, with output:\n${outputAndIdle.output}`; + } return { content: [{ kind: 'text', @@ -400,15 +398,20 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } this._logService.debug(`RunInTerminalTool: Using \`${strategy.type}\` execute strategy for command \`${command}\``); const executeResult = await strategy.execute(command, token); - this._logService.debug(`RunInTerminalTool: Finished \`${strategy.type}\` execute strategy with exitCode \`${executeResult.exitCode}\`, result.length \`${executeResult.result.length}\`, error \`${executeResult.error}\``); - outputLineCount = count(executeResult.result, '\n'); + this._logService.debug(`RunInTerminalTool: Finished \`${strategy.type}\` execute strategy with exitCode \`${executeResult.exitCode}\`, result.length \`${executeResult.output?.length}\`, error \`${executeResult.error}\``); + outputLineCount = executeResult.output === undefined ? 0 : count(executeResult.output.trim(), '\n') + 1; exitCode = executeResult.exitCode; error = executeResult.error; - if (typeof executeResult.result === 'string') { - terminalResult = executeResult.result; - } else { - return executeResult.result; + + const resultArr: string[] = []; + if (executeResult.output !== undefined) { + resultArr.push(executeResult.output); } + if (executeResult.additionalInformation) { + resultArr.push(executeResult.additionalInformation); + } + terminalResult = resultArr.join('\n\n'); + } catch (e) { this._logService.debug(`RunInTerminalTool: Threw exception`); toolTerminal.instance.dispose(); 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..0e9e80fdcc7 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,28 +67,28 @@ 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: `Task not found: ${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: `Terminal not found for task ${taskDefinition?.taskLabel}` }], toolResultMessage: new MarkdownString(localize('copilotChat.terminalNotFound', 'Terminal not found for task `{0}`', taskDefinition?.taskLabel)) }; } return { content: [{ kind: 'text', - value: localize('copilotChat.taskOutput', 'Output of task `{0}`:\n{1}', taskDefinition.taskLabel, getOutput(terminal)) + value: `Output of task ${taskDefinition.taskLabel}: ${getOutput(terminal)}` }] }; } 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..0ead70204d5 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/task/runTaskTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/task/runTaskTool.ts @@ -12,10 +12,11 @@ import { ILanguageModelsService } from '../../../../chat/common/languageModels.j import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress } from '../../../../chat/common/languageModelToolsService.js'; import { ITaskService, ITaskSummary, Task } from '../../../../tasks/common/taskService.js'; import { ITerminalService } from '../../../../terminal/browser/terminal.js'; -import { pollForOutputAndIdle, promptForMorePolling } from '../bufferOutputPolling.js'; +import { pollForOutputAndIdle, promptForMorePolling, racePollingOrPrompt } from '../bufferOutputPolling.js'; 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: `Task not found: ${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: `The task ${taskDefinition.taskLabel} is already running.` }], 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,18 +73,20 @@ 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: `Task started but no terminal was found for: ${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)) }); - let outputAndIdle = await pollForOutputAndIdle({ getOutput: () => getOutput(terminal), isActive: () => this._isTaskActive(task) }, false, token, this._languageModelsService); if (!outputAndIdle.terminalExecutionIdleBeforeTimeout) { - const extendPolling = await promptForMorePolling(taskDefinition.taskLabel, invocation.context, this._chatService); - if (extendPolling) { - _progress.report({ message: new MarkdownString(`Checking output for \`${taskDefinition.taskLabel}\``) }); - outputAndIdle = await pollForOutputAndIdle({ getOutput: () => getOutput(terminal), isActive: () => this._isTaskActive(task) }, true, token, this._languageModelsService); - } + outputAndIdle = await racePollingOrPrompt( + () => pollForOutputAndIdle({ getOutput: () => getOutput(terminal), isActive: () => this._isTaskActive(task) }, true, token, this._languageModelsService), + () => promptForMorePolling(taskDefinition.taskLabel, invocation.context!, this._chatService), + outputAndIdle, + token, + this._languageModelsService, + { getOutput: () => getOutput(terminal), isActive: () => this._isTaskActive(task) } + ); } let output = ''; if (result?.exitCode) { @@ -101,7 +106,6 @@ export class RunTaskTool implements IToolImpl { return { content: [{ kind: 'text', value: `The output was ${outputAndIdle.output}` }], toolResultMessage: output }; } - private async _isTaskActive(task: Task): Promise { const activeTasks = await this._tasksService.getActiveTasks(); return Promise.resolve(activeTasks?.includes(task)); @@ -109,9 +113,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 { + ensureNoDisposablesAreLeakedInTestSuite(); + + const defaultOriginalResult = { terminalExecutionIdleBeforeTimeout: false, output: '', pollDurationMs: PollingConsts.FirstPollingMaxDuration }; + const defaultToken = CancellationToken.None; + const defaultLanguageModelsService = {} as any; + const defaultExecution = { getOutput: () => 'output' }; + + /** + * Returns a set of arguments for racePollingOrPrompt, allowing overrides for testing. + */ + function getArgs(overrides?: { + pollFn?: () => Promise extends Promise ? R : any>; + promptFn?: () => { promise: Promise; part?: Pick | undefined }; + originalResult?: Parameters[2]; + token?: CancellationToken; + languageModelsService?: typeof defaultLanguageModelsService; + execution?: typeof defaultExecution; + }) { + return { + pollFn: overrides?.pollFn ?? (async () => ({ terminalExecutionIdleBeforeTimeout: true, output: 'output', pollDurationMs: 0 })), + promptFn: overrides?.promptFn ?? (() => ({ promise: new Promise(() => { }), part: undefined })), + originalResult: overrides?.originalResult ?? defaultOriginalResult, + token: overrides?.token ?? defaultToken, + languageModelsService: overrides?.languageModelsService ?? defaultLanguageModelsService, + execution: overrides?.execution ?? defaultExecution + }; + } + + test('should resolve with poll result if polling finishes first', async () => { + let pollResolved = false; + const args = getArgs({ + pollFn: async () => { + pollResolved = true; + return { terminalExecutionIdleBeforeTimeout: true, output: 'output', pollDurationMs: 0 }; + } + }); + const result = await racePollingOrPrompt(args.pollFn, args.promptFn, args.originalResult, args.token, args.languageModelsService, args.execution); + assert.ok(pollResolved); + assert.deepEqual(result, { terminalExecutionIdleBeforeTimeout: true, output: 'output', pollDurationMs: 0 }); + }); + + test('should resolve with poll result if prompt is rejected', async () => { + const args = getArgs({ + pollFn: async () => ({ terminalExecutionIdleBeforeTimeout: false, output: 'output', pollDurationMs: 0 }), + promptFn: () => ({ promise: Promise.resolve(false), part: undefined }), + originalResult: { terminalExecutionIdleBeforeTimeout: false, output: 'original', pollDurationMs: PollingConsts.FirstPollingMaxDuration } + }); + const result = await racePollingOrPrompt(args.pollFn, args.promptFn, args.originalResult, args.token, args.languageModelsService, args.execution); + assert.deepEqual(result, args.originalResult); + }); + + test('should poll again if prompt is accepted', async () => { + let extraPollCount = 0; + const args = getArgs({ + pollFn: async () => { + extraPollCount++; + return { terminalExecutionIdleBeforeTimeout: false, output: 'output', pollDurationMs: 0 }; + }, + promptFn: () => ({ promise: Promise.resolve(true), part: undefined }), + originalResult: { terminalExecutionIdleBeforeTimeout: false, output: 'original', pollDurationMs: PollingConsts.FirstPollingMaxDuration }, + languageModelsService: { + selectLanguageModels: async () => [], + sendChatRequest: async () => ({ result: '', stream: [] }) + } + }); + const result = await racePollingOrPrompt(args.pollFn, args.promptFn, args.originalResult, args.token, args.languageModelsService, args.execution); + assert.ok(extraPollCount === 1); + assert(result?.pollDurationMs && args.originalResult.pollDurationMs && result.pollDurationMs > args.originalResult.pollDurationMs); + }); + + test('should call part.hide() if polling finishes before prompt resolves', async () => { + let hideCalled = false; + const part: Pick = { hide: () => { hideCalled = true; }, onDidRequestHide: () => new Emitter() }; + const args = getArgs({ + pollFn: async () => ({ terminalExecutionIdleBeforeTimeout: true, output: 'output', pollDurationMs: 0 }), + promptFn: () => ({ + promise: new Promise(() => { }), + part + }) + }); + const result = await racePollingOrPrompt(args.pollFn, args.promptFn, args.originalResult, args.token, args.languageModelsService, args.execution); + assert.strictEqual(hideCalled, true); + assert.deepEqual(result, { terminalExecutionIdleBeforeTimeout: true, output: 'output', pollDurationMs: 0 }); + }); + + test('should return promptly if cancellation is requested', async () => { + let pollCalled = false; + const args = getArgs({ + pollFn: async () => { + pollCalled = true; + return { terminalExecutionIdleBeforeTimeout: false, output: 'output', pollDurationMs: 0 }; + }, + promptFn: () => ({ + promise: new Promise(() => { }), + part: undefined + }), + originalResult: { terminalExecutionIdleBeforeTimeout: false, output: 'original', pollDurationMs: PollingConsts.FirstPollingMaxDuration }, + token: { isCancellationRequested: true } as CancellationToken + }); + const result = await racePollingOrPrompt(args.pollFn, args.promptFn, args.originalResult, args.token, args.languageModelsService, args.execution); + assert.ok(pollCalled); + assert.deepEqual(result, await args.pollFn()); + }); +}); diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/lspCompletionProviderAddon.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/lspCompletionProviderAddon.ts index 67afbf754c9..2844d0aba98 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/lspCompletionProviderAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/lspCompletionProviderAddon.ts @@ -51,28 +51,32 @@ export class LspCompletionProviderAddon extends Disposable implements ITerminalA const lineNum = this._textVirtualModel.object.textEditorModel.getLineCount(); const positionVirtualDocument = new Position(lineNum, column); - - // TODO: Scan back to start of nearest word like other providers? Is this needed for `ILanguageFeaturesService`? const completions: ITerminalCompletion[] = []; if (this._provider && this._provider._debugDisplayName !== 'wordbasedCompletions') { const result = await this._provider.provideCompletionItems(this._textVirtualModel.object.textEditorModel, positionVirtualDocument, { triggerKind: CompletionTriggerKind.TriggerCharacter }, token); - - completions.push(...(result?.suggestions || []).map((e: any) => { + for (const item of (result?.suggestions || [])) { // TODO: Support more terminalCompletionItemKind for [different LSP providers](https://github.com/microsoft/vscode/issues/249479) - const convertedKind = e.kind ? mapLspKindToTerminalKind(e.kind) : TerminalCompletionItemKind.Method; + const convertedKind = item.kind ? mapLspKindToTerminalKind(item.kind) : TerminalCompletionItemKind.Method; const completionItemTemp = createCompletionItemPython(cursorPosition, textBeforeCursor, convertedKind, 'lspCompletionItem', undefined); - - return { - label: e.insertText, + const terminalCompletion: ITerminalCompletion = { + label: item.label, provider: `lsp:${this._provider._debugDisplayName}`, - detail: e.detail, - documentation: e.documentation, + detail: item.detail, + documentation: item.documentation, kind: convertedKind, replacementIndex: completionItemTemp.replacementIndex, replacementLength: completionItemTemp.replacementLength, }; - })); + + // Store unresolved item and provider for lazy resolution if needed + if (this._provider.resolveCompletionItem && (!item.detail || !item.documentation)) { + terminalCompletion._unresolvedItem = item; + terminalCompletion._resolveProvider = this._provider; + } + + completions.push(terminalCompletion); + } } return completions; diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts index f29f8a178f4..dd217449536 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminal.suggest.contribution.ts @@ -190,7 +190,8 @@ class TerminalSuggestContribution extends DisposableStore implements ITerminalCo this.add(textVirtualModel); const virtualProviders = this._languageFeaturesService.completionProvider.all(textVirtualModel.object.textEditorModel); - const provider = virtualProviders.find(p => p._debugDisplayName === PYLANCE_DEBUG_DISPLAY_NAME); + // TODO: Remove hard-coded filter for Python REPL. + const provider = virtualProviders.find(p => p._debugDisplayName === PYLANCE_DEBUG_DISPLAY_NAME || p._debugDisplayName === `ms-python.vscode-pylance(.["')`); if (provider) { const lspCompletionProviderAddon = this._lspAddon.value = this._instantiationService.createInstance(LspCompletionProviderAddon, provider, textVirtualModel, this._lspModelProvider.value); diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionItem.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionItem.ts index 143ed8f6d8b..d2bbe5f5705 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionItem.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionItem.ts @@ -3,9 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { basename } from '../../../../../base/common/path.js'; import { isWindows } from '../../../../../base/common/platform.js'; -import { CompletionItemKind } from '../../../../../editor/common/languages.js'; +import { CompletionItem, CompletionItemKind, CompletionItemProvider } from '../../../../../editor/common/languages.js'; import { ISimpleCompletion, SimpleCompletionItem } from '../../../../services/suggest/browser/simpleCompletionItem.js'; export enum TerminalCompletionItemKind { @@ -71,6 +72,17 @@ export interface ITerminalCompletion extends ISimpleCompletion { * Whether the completion is a keyword. */ isKeyword?: boolean; + + /** + * Unresolved completion item from the language server provider/ + */ + _unresolvedItem?: CompletionItem; + + /** + * Provider that can resolve this item + */ + _resolveProvider?: CompletionItemProvider; + } export class TerminalCompletionItem extends SimpleCompletionItem { @@ -96,6 +108,11 @@ export class TerminalCompletionItem extends SimpleCompletionItem { */ punctuationPenalty: 0 | 1 = 0; + /** + * Completion items details (such as docs) can be lazily resolved when focused. + */ + resolveCache?: Promise; + constructor( override readonly completion: ITerminalCompletion ) { @@ -128,6 +145,43 @@ export class TerminalCompletionItem extends SimpleCompletionItem { this.punctuationPenalty = shouldPenalizeForPunctuation(this.labelLowExcludeFileExt) ? 1 : 0; } + + /** + * Resolves the completion item's details lazily when needed. + */ + async resolve(token: CancellationToken): Promise { + + if (this.resolveCache) { + return this.resolveCache; + } + + const unresolvedItem = this.completion._unresolvedItem; + const provider = this.completion._resolveProvider; + + if (!unresolvedItem || !provider || !provider.resolveCompletionItem) { + return; + } + + this.resolveCache = (async () => { + try { + const resolved = await provider.resolveCompletionItem!(unresolvedItem, token); + if (resolved) { + // Update the completion with resolved details + if (resolved.detail) { + this.completion.detail = resolved.detail; + } + if (resolved.documentation) { + this.completion.documentation = resolved.documentation; + } + } + } catch (error) { + return; + } + })(); + + return this.resolveCache; + } + } function isFile(completion: ITerminalCompletion): boolean { diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts index d2df1f96f67..1d2a13e3804 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts @@ -21,8 +21,9 @@ import { terminalSuggestConfigSection, TerminalSuggestSettingId, type ITerminalS import { LineContext } from '../../../../services/suggest/browser/simpleCompletionModel.js'; import { ISimpleSelectedSuggestion, SimpleSuggestWidget } from '../../../../services/suggest/browser/simpleSuggestWidget.js'; import { ITerminalCompletionService } from './terminalCompletionService.js'; -import { TerminalSettingId, TerminalShellType, PosixShellType, WindowsShellType, GeneralShellType } from '../../../../../platform/terminal/common/terminal.js'; +import { TerminalSettingId, TerminalShellType, PosixShellType, WindowsShellType, GeneralShellType, ITerminalLogService } from '../../../../../platform/terminal/common/terminal.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { createCancelablePromise, CancelablePromise, IntervalTimer, TimeoutTimer } from '../../../../../base/common/async.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; @@ -31,7 +32,6 @@ import { ITerminalConfigurationService } from '../../../terminal/browser/termina import { GOLDEN_LINE_HEIGHT_RATIO, MINIMUM_LINE_HEIGHT } from '../../../../../editor/common/config/fontInfo.js'; import { TerminalCompletionModel } from './terminalCompletionModel.js'; import { TerminalCompletionItem, TerminalCompletionItemKind, type ITerminalCompletion } from './terminalCompletionItem.js'; -import { IntervalTimer, TimeoutTimer } from '../../../../../base/common/async.js'; import { localize } from '../../../../../nls.js'; import { TerminalSuggestTelemetry } from './terminalSuggestTelemetry.js'; import { terminalSymbolAliasIcon, terminalSymbolArgumentIcon, terminalSymbolEnumMember, terminalSymbolFileIcon, terminalSymbolFlagIcon, terminalSymbolInlineSuggestionIcon, terminalSymbolMethodIcon, terminalSymbolOptionIcon, terminalSymbolFolderIcon, terminalSymbolSymbolicLinkFileIcon, terminalSymbolSymbolicLinkFolderIcon } from './terminalSymbolIcons.js'; @@ -89,6 +89,11 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest private _discoverability: TerminalSuggestShownTracker | undefined; + // Terminal suggest resolution tracking (similar to editor's suggest widget) + private _currentSuggestionDetails?: CancelablePromise; + private _focusedItem?: TerminalCompletionItem; + private _ignoreFocusEvents: boolean = false; + isPasting: boolean = false; shellType: TerminalShellType | undefined; private readonly _shellTypeInit: Promise; @@ -161,7 +166,8 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest @IConfigurationService private readonly _configurationService: IConfigurationService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IExtensionService private readonly _extensionService: IExtensionService, - @ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService + @ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService, + @ITerminalLogService private readonly _logService: ITerminalLogService, ) { super(); @@ -753,6 +759,53 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest } } )); + + this._register(this._suggestWidget.onDidFocus(async e => { + if (this._ignoreFocusEvents) { + return; + } + + const focusedItem = e.item; + const focusedIndex = e.index; + + if (focusedItem === this._focusedItem) { + return; + } + + // Cancel any previous resolution + this._currentSuggestionDetails?.cancel(); + this._currentSuggestionDetails = undefined; + this._focusedItem = focusedItem; + + // Check if the item needs resolution and hasn't been resolved yet + if (focusedItem && (!focusedItem.completion.documentation || !focusedItem.completion.detail)) { + + this._currentSuggestionDetails = createCancelablePromise(async token => { + try { + await focusedItem.resolve(token); + } catch (error) { + // Silently fail - the item is still usable without details + this._logService.warn(`Failed to resolve suggestion details for item ${focusedItem} at index ${focusedIndex}`, error); + } + }); + + this._currentSuggestionDetails.then(() => { + // Check if this is still the focused item and it's still in the list + if (focusedItem !== this._focusedItem || !this._suggestWidget?.list || focusedIndex >= this._suggestWidget.list.length) { + return; + } + + // Re-render the specific item to show resolved details (like editor does) + this._ignoreFocusEvents = true; + // Use splice to replace the item and trigger re-render + this._suggestWidget.list.splice(focusedIndex, 1, [focusedItem]); + this._suggestWidget.list.setFocus([focusedIndex]); + this._ignoreFocusEvents = false; + }); + } + + })); + const element = this._terminal?.element?.querySelector('.xterm-helper-textarea'); if (element) { this._register(dom.addDisposableListener(dom.getActiveDocument(), 'click', (event) => { @@ -912,9 +965,13 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest if (cancelAnyRequest) { this._cancellationTokenSource?.cancel(); this._cancellationTokenSource = undefined; + // Also cancel any pending resolution requests + this._currentSuggestionDetails?.cancel(); + this._currentSuggestionDetails = undefined; } this._currentPromptInputState = undefined; this._leadingLineContent = undefined; + this._focusedItem = undefined; this._suggestWidget?.hide(); } } 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/services/mcp/common/mcpWorkbenchManagementService.ts b/src/vs/workbench/services/mcp/common/mcpWorkbenchManagementService.ts index e18ffd7b9c5..90908307cc1 100644 --- a/src/vs/workbench/services/mcp/common/mcpWorkbenchManagementService.ts +++ b/src/vs/workbench/services/mcp/common/mcpWorkbenchManagementService.ts @@ -24,6 +24,11 @@ import { AbstractMcpManagementService, AbstractMcpResourceManagementService, ILo import { IFileService } from '../../../../platform/files/common/files.js'; import { ResourceMap } from '../../../../base/common/map.js'; +export const USER_CONFIG_ID = 'usrlocal'; +export const REMOTE_USER_CONFIG_ID = 'usrremote'; +export const WORKSPACE_CONFIG_ID = 'workspace'; +export const WORKSPACE_FOLDER_CONFIG_ID_PREFIX = 'ws'; + export interface IWorkbencMcpServerInstallOptions extends InstallOptions { target?: ConfigurationTarget | IWorkspaceFolder; } @@ -35,28 +40,41 @@ export const enum LocalMcpServerScope { } export interface IWorkbenchLocalMcpServer extends ILocalMcpServer { - readonly scope?: LocalMcpServerScope; + readonly id: string; + readonly scope: LocalMcpServerScope; +} + +export interface InstallWorkbenchMcpServerEvent extends InstallMcpServerEvent { + readonly scope: LocalMcpServerScope; } export interface IWorkbenchMcpServerInstallResult extends InstallMcpServerResult { readonly local?: IWorkbenchLocalMcpServer; } +export interface UninstallWorkbenchMcpServerEvent extends UninstallMcpServerEvent { + readonly scope: LocalMcpServerScope; +} + +export interface DidUninstallWorkbenchMcpServerEvent extends DidUninstallMcpServerEvent { + readonly scope: LocalMcpServerScope; +} + export const IWorkbenchMcpManagementService = refineServiceDecorator(IMcpManagementService); export interface IWorkbenchMcpManagementService extends IMcpManagementService { readonly _serviceBrand: undefined; - readonly onDidInstallMcpServers: Event; - - readonly onInstallMcpServerInCurrentProfile: Event; + readonly onInstallMcpServerInCurrentProfile: Event; readonly onDidInstallMcpServersInCurrentProfile: Event; readonly onDidUpdateMcpServersInCurrentProfile: Event; - readonly onUninstallMcpServerInCurrentProfile: Event; - readonly onDidUninstallMcpServerInCurrentProfile: Event; + readonly onUninstallMcpServerInCurrentProfile: Event; + readonly onDidUninstallMcpServerInCurrentProfile: Event; readonly onDidChangeProfile: Event; getInstalled(): Promise; install(server: IInstallableMcpServer | URI, options?: IWorkbencMcpServerInstallOptions): Promise; + installFromGallery(server: IGalleryMcpServer, options?: InstallOptions): Promise; + updateMetadata(local: ILocalMcpServer, server: IGalleryMcpServer, profileLocation?: URI): Promise; } export class WorkbenchMcpManagementService extends AbstractMcpManagementService implements IWorkbenchMcpManagementService { @@ -76,7 +94,7 @@ export class WorkbenchMcpManagementService extends AbstractMcpManagementService private _onDidUninstallMcpServer = this._register(new Emitter()); readonly onDidUninstallMcpServer = this._onDidUninstallMcpServer.event; - private readonly _onInstallMcpServerInCurrentProfile = this._register(new Emitter()); + private readonly _onInstallMcpServerInCurrentProfile = this._register(new Emitter()); readonly onInstallMcpServerInCurrentProfile = this._onInstallMcpServerInCurrentProfile.event; private readonly _onDidInstallMcpServersInCurrentProfile = this._register(new Emitter()); @@ -85,10 +103,10 @@ export class WorkbenchMcpManagementService extends AbstractMcpManagementService private readonly _onDidUpdateMcpServersInCurrentProfile = this._register(new Emitter()); readonly onDidUpdateMcpServersInCurrentProfile = this._onDidUpdateMcpServersInCurrentProfile.event; - private readonly _onUninstallMcpServerInCurrentProfile = this._register(new Emitter()); + private readonly _onUninstallMcpServerInCurrentProfile = this._register(new Emitter()); readonly onUninstallMcpServerInCurrentProfile = this._onUninstallMcpServerInCurrentProfile.event; - private readonly _onDidUninstallMcpServerInCurrentProfile = this._register(new Emitter()); + private readonly _onDidUninstallMcpServerInCurrentProfile = this._register(new Emitter()); readonly onDidUninstallMcpServerInCurrentProfile = this._onDidUninstallMcpServerInCurrentProfile.event; private readonly _onDidChangeProfile = this._register(new Emitter()); @@ -119,7 +137,7 @@ export class WorkbenchMcpManagementService extends AbstractMcpManagementService this._register(this.mcpManagementService.onInstallMcpServer(e => { this._onInstallMcpServer.fire(e); if (uriIdentityService.extUri.isEqual(e.mcpResource, this.userDataProfileService.currentProfile.mcpResource)) { - this._onInstallMcpServerInCurrentProfile.fire(e); + this._onInstallMcpServerInCurrentProfile.fire({ ...e, scope: LocalMcpServerScope.User }); } })); @@ -142,20 +160,20 @@ export class WorkbenchMcpManagementService extends AbstractMcpManagementService this._register(this.mcpManagementService.onUninstallMcpServer(e => { this._onUninstallMcpServer.fire(e); if (uriIdentityService.extUri.isEqual(e.mcpResource, this.userDataProfileService.currentProfile.mcpResource)) { - this._onUninstallMcpServerInCurrentProfile.fire(e); + this._onUninstallMcpServerInCurrentProfile.fire({ ...e, scope: LocalMcpServerScope.User }); } })); this._register(this.mcpManagementService.onDidUninstallMcpServer(e => { this._onDidUninstallMcpServer.fire(e); if (uriIdentityService.extUri.isEqual(e.mcpResource, this.userDataProfileService.currentProfile.mcpResource)) { - this._onDidUninstallMcpServerInCurrentProfile.fire(e); + this._onDidUninstallMcpServerInCurrentProfile.fire({ ...e, scope: LocalMcpServerScope.User }); } })); this._register(this.workspaceMcpManagementService.onInstallMcpServer(async e => { this._onInstallMcpServer.fire(e); - this._onInstallMcpServerInCurrentProfile.fire(e); + this._onInstallMcpServerInCurrentProfile.fire({ ...e, scope: LocalMcpServerScope.Workspace }); })); this._register(this.workspaceMcpManagementService.onDidInstallMcpServers(async e => { @@ -166,12 +184,12 @@ export class WorkbenchMcpManagementService extends AbstractMcpManagementService this._register(this.workspaceMcpManagementService.onUninstallMcpServer(async e => { this._onUninstallMcpServer.fire(e); - this._onUninstallMcpServerInCurrentProfile.fire(e); + this._onUninstallMcpServerInCurrentProfile.fire({ ...e, scope: LocalMcpServerScope.Workspace }); })); this._register(this.workspaceMcpManagementService.onDidUninstallMcpServer(async e => { this._onDidUninstallMcpServer.fire(e); - this._onDidUninstallMcpServerInCurrentProfile.fire(e); + this._onDidUninstallMcpServerInCurrentProfile.fire({ ...e, scope: LocalMcpServerScope.Workspace }); })); this._register(this.workspaceMcpManagementService.onDidUpdateMcpServers(e => { @@ -185,7 +203,7 @@ export class WorkbenchMcpManagementService extends AbstractMcpManagementService this._onInstallMcpServer.fire(e); const remoteMcpResource = await this.getRemoteMcpResource(this.userDataProfileService.currentProfile.mcpResource); if (remoteMcpResource ? uriIdentityService.extUri.isEqual(e.mcpResource, remoteMcpResource) : this.userDataProfileService.currentProfile.isDefault) { - this._onInstallMcpServerInCurrentProfile.fire(e); + this._onInstallMcpServerInCurrentProfile.fire({ ...e, scope: LocalMcpServerScope.RemoteUser }); } })); @@ -196,7 +214,7 @@ export class WorkbenchMcpManagementService extends AbstractMcpManagementService this._onUninstallMcpServer.fire(e); const remoteMcpResource = await this.getRemoteMcpResource(this.userDataProfileService.currentProfile.mcpResource); if (remoteMcpResource ? uriIdentityService.extUri.isEqual(e.mcpResource, remoteMcpResource) : this.userDataProfileService.currentProfile.isDefault) { - this._onUninstallMcpServerInCurrentProfile.fire(e); + this._onUninstallMcpServerInCurrentProfile.fire({ ...e, scope: LocalMcpServerScope.RemoteUser }); } })); @@ -204,7 +222,7 @@ export class WorkbenchMcpManagementService extends AbstractMcpManagementService this._onDidUninstallMcpServer.fire(e); const remoteMcpResource = await this.getRemoteMcpResource(this.userDataProfileService.currentProfile.mcpResource); if (remoteMcpResource ? uriIdentityService.extUri.isEqual(e.mcpResource, remoteMcpResource) : this.userDataProfileService.currentProfile.isDefault) { - this._onDidUninstallMcpServerInCurrentProfile.fire(e); + this._onDidUninstallMcpServerInCurrentProfile.fire({ ...e, scope: LocalMcpServerScope.RemoteUser }); } })); } @@ -233,7 +251,7 @@ export class WorkbenchMcpManagementService extends AbstractMcpManagementService return { mcpServerInstallResult, mcpServerInstallResultInCurrentProfile }; } - private async handleRemoteInstallMcpServerResultsFromEvent(e: readonly InstallMcpServerResult[], emitter: Emitter, currentProfileEmitter: Emitter): Promise { + private async handleRemoteInstallMcpServerResultsFromEvent(e: readonly InstallMcpServerResult[], emitter: Emitter, currentProfileEmitter: Emitter): Promise { const mcpServerInstallResult: IWorkbenchMcpServerInstallResult[] = []; const mcpServerInstallResultInCurrentProfile: IWorkbenchMcpServerInstallResult[] = []; const remoteMcpResource = await this.getRemoteMcpResource(this.userDataProfileService.currentProfile.mcpResource); @@ -276,7 +294,33 @@ export class WorkbenchMcpManagementService extends AbstractMcpManagementService } private toWorkspaceMcpServer(server: ILocalMcpServer, scope: LocalMcpServerScope): IWorkbenchLocalMcpServer { - return { ...server, scope }; + return { ...server, id: `mcp.config.${this.getConfigId(server, scope)}.${server.name}`, scope }; + } + + private getConfigId(server: ILocalMcpServer, scope: LocalMcpServerScope): string { + if (scope === LocalMcpServerScope.User) { + return USER_CONFIG_ID; + } + + if (scope === LocalMcpServerScope.RemoteUser) { + return REMOTE_USER_CONFIG_ID; + } + + if (scope === LocalMcpServerScope.Workspace) { + const workspace = this.workspaceContextService.getWorkspace(); + if (workspace.configuration && this.uriIdentityService.extUri.isEqual(workspace.configuration, server.mcpResource)) { + return WORKSPACE_CONFIG_ID; + } + + const workspaceFolders = workspace.folders; + for (let index = 0; index < workspaceFolders.length; index++) { + const workspaceFolder = workspaceFolders[index]; + if (this.uriIdentityService.extUri.isEqual(this.uriIdentityService.extUri.joinPath(workspaceFolder.uri, WORKSPACE_STANDALONE_CONFIGURATIONS[MCP_CONFIGURATION_KEY]), server.mcpResource)) { + return `${WORKSPACE_FOLDER_CONFIG_ID_PREFIX}${index}`; + } + } + } + return 'unknown'; } async install(server: IInstallableMcpServer, options?: IWorkbencMcpServerInstallOptions): Promise { @@ -288,7 +332,8 @@ export class WorkbenchMcpManagementService extends AbstractMcpManagementService throw new Error(`Illegal target: ${options.target}`); } options.mcpResource = mcpResource; - return this.workspaceMcpManagementService.install(server, options); + const result = await this.workspaceMcpManagementService.install(server, options); + return this.toWorkspaceMcpServer(result, LocalMcpServerScope.Workspace); } if (options.target === ConfigurationTarget.USER_REMOTE) { @@ -296,7 +341,8 @@ export class WorkbenchMcpManagementService extends AbstractMcpManagementService throw new Error(`Illegal target: ${options.target}`); } options.mcpResource = await this.getRemoteMcpResource(options.mcpResource); - return this.remoteMcpManagementService.install(server, options); + const result = await this.remoteMcpManagementService.install(server, options); + return this.toWorkspaceMcpServer(result, LocalMcpServerScope.RemoteUser); } if (options.target && options.target !== ConfigurationTarget.USER && options.target !== ConfigurationTarget.USER_LOCAL) { @@ -304,30 +350,35 @@ export class WorkbenchMcpManagementService extends AbstractMcpManagementService } options.mcpResource = this.userDataProfileService.currentProfile.mcpResource; - return this.mcpManagementService.install(server, options); + const result = await this.mcpManagementService.install(server, options); + return this.toWorkspaceMcpServer(result, LocalMcpServerScope.User); } - installFromGallery(server: IGalleryMcpServer, options?: InstallOptions): Promise { + async installFromGallery(server: IGalleryMcpServer, options?: InstallOptions): Promise { options = options ?? {}; if (!options.mcpResource) { options.mcpResource = this.userDataProfileService.currentProfile.mcpResource; } - return this.mcpManagementService.installFromGallery(server, options); + const result = await this.mcpManagementService.installFromGallery(server, options); + return this.toWorkspaceMcpServer(result, LocalMcpServerScope.User); } - updateMetadata(local: IWorkbenchLocalMcpServer, server: IGalleryMcpServer, profileLocation: URI): Promise { + async updateMetadata(local: IWorkbenchLocalMcpServer, server: IGalleryMcpServer, profileLocation: URI): Promise { if (local.scope === LocalMcpServerScope.Workspace) { - return this.workspaceMcpManagementService.updateMetadata(local, server, profileLocation); + const result = await this.workspaceMcpManagementService.updateMetadata(local, server, profileLocation); + return this.toWorkspaceMcpServer(result, LocalMcpServerScope.Workspace); } if (local.scope === LocalMcpServerScope.RemoteUser) { if (!this.remoteMcpManagementService) { throw new Error(`Illegal target: ${local.scope}`); } - return this.remoteMcpManagementService.updateMetadata(local, server, profileLocation); + const result = await this.remoteMcpManagementService.updateMetadata(local, server, profileLocation); + return this.toWorkspaceMcpServer(result, LocalMcpServerScope.RemoteUser); } - return this.mcpManagementService.updateMetadata(local, server, profileLocation); + const result = await this.mcpManagementService.updateMetadata(local, server, profileLocation); + return this.toWorkspaceMcpServer(result, LocalMcpServerScope.User); } async uninstall(server: IWorkbenchLocalMcpServer): Promise { 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; + } +} diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index 224ea642e7f..dc54719e0d8 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -205,24 +205,6 @@ declare module 'vscode' { presentation?: 'hidden' | undefined; } - export interface LanguageModelTool { - prepareInvocation2?(options: LanguageModelToolInvocationPrepareOptions, token: CancellationToken): ProviderResult; - } - - export class PreparedTerminalToolInvocation { - readonly command: string; - readonly language: string; - readonly confirmationMessages?: LanguageModelToolConfirmationMessages; - readonly presentation?: 'hidden' | undefined; - - constructor( - command: string, - language: string, - confirmationMessages?: LanguageModelToolConfirmationMessages, - presentation?: 'hidden' - ); - } - export class ExtendedLanguageModelToolResult extends LanguageModelToolResult { toolResultMessage?: string | MarkdownString; toolResultDetails?: Array;