From dede7bb4b7e9c9ec69155a243bb84037a40588fe Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 17 Apr 2025 14:17:04 -0700 Subject: [PATCH 1/5] mcp: refine extension API (#246856) - Replace SSE with HTTP in the API (although this still supports the same SSE fallback we otherwise have, this is not documented) - Add a new `resolveMcpServerDefinition` step that is only run before the MCP server is started -- both from a cache or from a running extension. This lets extensions do user interaction is a more correct way. - Add appropriate docs and such. --- src/vs/workbench/api/browser/mainThreadMcp.ts | 12 ++- .../workbench/api/common/extHost.api.impl.ts | 2 +- .../workbench/api/common/extHost.protocol.ts | 3 +- src/vs/workbench/api/common/extHostMcp.ts | 53 ++++++----- .../api/common/extHostTypeConverters.ts | 26 +++++ src/vs/workbench/api/common/extHostTypes.ts | 6 +- .../contrib/mcp/common/mcpRegistry.ts | 16 +++- .../workbench/contrib/mcp/common/mcpTypes.ts | 5 + ...ode.proposed.mcpConfigurationProvider.d.ts | 95 ++++++++++++++++--- 9 files changed, 175 insertions(+), 43 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadMcp.ts b/src/vs/workbench/api/browser/mainThreadMcp.ts index 030b9e4547f..c93065c1b53 100644 --- a/src/vs/workbench/api/browser/mainThreadMcp.ts +++ b/src/vs/workbench/api/browser/mainThreadMcp.ts @@ -9,11 +9,12 @@ import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { ISettableObservable, observableValue } from '../../../base/common/observable.js'; import { LogLevel } from '../../../platform/log/common/log.js'; import { IMcpMessageTransport, IMcpRegistry } from '../../contrib/mcp/common/mcpRegistryTypes.js'; -import { McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js'; +import { McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js'; import { MCP } from '../../contrib/mcp/common/modelContextProtocol.js'; import { ExtensionHostKind, extensionHostKindToString } from '../../services/extensions/common/extensionHostKind.js'; import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; -import { ExtHostContext, MainContext, MainThreadMcpShape } from '../common/extHost.protocol.js'; +import { Proxied } from '../../services/extensions/common/proxyIdentifier.js'; +import { ExtHostContext, ExtHostMcpShape, MainContext, MainThreadMcpShape } from '../common/extHost.protocol.js'; @extHostNamedCustomer(MainContext.MainThreadMcp) export class MainThreadMcp extends Disposable implements MainThreadMcpShape { @@ -21,6 +22,7 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { private _serverIdCounter = 0; private readonly _servers = new Map(); + private readonly _proxy: Proxied; private readonly _collectionDefinitions = this._register(new DisposableMap; @@ -32,7 +34,7 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, ) { super(); - const proxy = _extHostContext.getProxy(ExtHostContext.ExtHostMcp); + const proxy = this._proxy = _extHostContext.getProxy(ExtHostContext.ExtHostMcp); this._register(this._mcpRegistry.registerDelegate({ // Prefer Node.js extension hosts when they're available. No CORS issues etc. priority: _extHostContext.extensionHostKind === ExtensionHostKind.LocalWebWorker ? 0 : 1, @@ -73,6 +75,10 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { const serverDefinitions = observableValue('mcpServers', servers); const handle = this._mcpRegistry.registerCollection({ ...collection, + resolveServerLanch: collection.canResolveLaunch ? (async def => { + const r = await this._proxy.$resolveMcpLaunch(collection.id, def.label); + return r ? McpServerLaunch.fromSerialized(r) : undefined; + }) : undefined, remoteAuthority: this._extHostContext.remoteAuthority, serverDefinitions, }); diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 0db23694cd0..3165c3c4a79 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1830,7 +1830,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I AISearchKeyword: AISearchKeyword, TextSearchCompleteMessageTypeNew: TextSearchCompleteMessageType, ChatErrorLevel: extHostTypes.ChatErrorLevel, - McpSSEServerDefinition: extHostTypes.McpSSEServerDefinition, + McpHttpServerDefinition: extHostTypes.McpHttpServerDefinition, McpStdioServerDefinition: extHostTypes.McpStdioServerDefinition, }; }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 23b2026b543..28953da827b 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -2977,6 +2977,7 @@ export interface ExtHostTestingShape { } export interface ExtHostMcpShape { + $resolveMcpLaunch(collectionId: string, label: string): Promise; $startMcp(id: number, launch: McpServerLaunch.Serialized): void; $stopMcp(id: number): void; $sendMessage(id: number, message: string): void; @@ -2987,7 +2988,7 @@ export interface MainThreadMcpShape { $onDidChangeState(id: number, state: McpConnectionState): void; $onDidPublishLog(id: number, level: LogLevel, log: string): void; $onDidReceiveMessage(id: number, message: string): void; - $upsertMcpCollection(collection: McpCollectionDefinition.FromExtHost, servers: Dto[]): void; + $upsertMcpCollection(collection: McpCollectionDefinition.FromExtHost, servers: McpServerDefinition.Serialized[]): void; $deleteMcpCollection(collectionId: string): void; } diff --git a/src/vs/workbench/api/common/extHostMcp.ts b/src/vs/workbench/api/common/extHostMcp.ts index 9d04a4b0362..e8ccfc48f4d 100644 --- a/src/vs/workbench/api/common/extHostMcp.ts +++ b/src/vs/workbench/api/common/extHostMcp.ts @@ -15,6 +15,7 @@ import { StorageScope } from '../../../platform/storage/common/storage.js'; import { extensionPrefixedIdentifier, McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch, McpServerTransportHTTP, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js'; import { ExtHostMcpShape, MainContext, MainThreadMcpShape } from './extHost.protocol.js'; import { IExtHostRpcService } from './extHostRpcService.js'; +import * as Convert from './extHostTypeConverters.js'; export const IExtHostMpcService = createDecorator('IExtHostMpcService'); @@ -26,6 +27,11 @@ export class ExtHostMcpService extends Disposable implements IExtHostMpcService protected _proxy: MainThreadMcpShape; private readonly _initialProviderPromises = new Set>(); private readonly _sseEventSources = this._register(new DisposableMap()); + private readonly _unresolvedMcpServers = new Map(); + constructor( @IExtHostRpcService extHostRpc: IExtHostRpcService, ) { @@ -61,6 +67,24 @@ export class ExtHostMcpService extends Disposable implements IExtHostMpcService await Promise.all(this._initialProviderPromises); } + async $resolveMcpLaunch(collectionId: string, label: string): Promise { + const rec = this._unresolvedMcpServers.get(collectionId); + if (!rec) { + return; + } + + const server = rec.servers.find(s => s.label === label); + if (!server) { + return; + } + if (!rec.provider.resolveMcpServerDefinition) { + return Convert.McpServerDefinition.from(server); + } + + const resolved = await rec.provider.resolveMcpServerDefinition(server, CancellationToken.None); + return resolved ? Convert.McpServerDefinition.from(resolved) : undefined; + } + /** {@link vscode.lm.registerMcpConfigurationProvider} */ public registerMcpConfigurationProvider(extension: IExtensionDescription, id: string, provider: vscode.McpConfigurationProvider): IDisposable { const store = new DisposableStore(); @@ -74,37 +98,21 @@ export class ExtHostMcpService extends Disposable implements IExtHostMpcService id: extensionPrefixedIdentifier(extension.identifier, id), isTrustedByDefault: true, label: metadata?.label ?? extension.displayName ?? extension.name, - scope: StorageScope.WORKSPACE + scope: StorageScope.WORKSPACE, + canResolveLaunch: typeof provider.resolveMcpServerDefinition === 'function', + extensionId: extension.identifier.value, }; const update = async () => { - const list = await provider.provideMcpServerDefinitions(CancellationToken.None); + this._unresolvedMcpServers.set(mcp.id, { servers: list ?? [], provider }); - function isSSEConfig(candidate: vscode.McpServerDefinition): candidate is vscode.McpSSEServerDefinition { - return !!(candidate as vscode.McpSSEServerDefinition).uri; - } - - const servers: McpServerDefinition[] = []; - + const servers: McpServerDefinition.Serialized[] = []; for (const item of list ?? []) { servers.push({ id: ExtensionIdentifier.toKey(extension.identifier), label: item.label, - launch: isSSEConfig(item) - ? { - type: McpServerTransportType.HTTP, - uri: item.uri, - headers: item.headers, - } - : { - type: McpServerTransportType.Stdio, - cwd: item.cwd, - args: item.args, - command: item.command, - env: item.env, - envFile: undefined, - } + launch: Convert.McpServerDefinition.from(item) }); } @@ -112,6 +120,7 @@ export class ExtHostMcpService extends Disposable implements IExtHostMpcService }; store.add(toDisposable(() => { + this._unresolvedMcpServers.delete(mcp.id); this._proxy.$deleteMcpCollection(mcp.id); })); diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 75c7f54d600..a42e18e171b 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -63,6 +63,7 @@ import { getPrivateApiFor } from './extHostTestingPrivateApi.js'; import * as types from './extHostTypes.js'; import { LanguageModelPromptTsxPart, LanguageModelTextPart } from './extHostTypes.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; +import { McpServerLaunch, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js'; export namespace Command { @@ -3256,3 +3257,28 @@ export namespace IconPath { return iconPath; } } + +export namespace McpServerDefinition { + function isHttpConfig(candidate: vscode.McpServerDefinition): candidate is vscode.McpHttpServerDefinition { + return !!(candidate as vscode.McpHttpServerDefinition).uri; + } + + export function from(item: vscode.McpServerDefinition): McpServerLaunch.Serialized { + return McpServerLaunch.toSerialized( + isHttpConfig(item) + ? { + type: McpServerTransportType.HTTP, + uri: item.uri, + headers: Object.entries(item.headers), + } + : { + type: McpServerTransportType.Stdio, + cwd: item.cwd, + args: item.args, + command: item.command, + env: item.env, + envFile: undefined, + } + ); + } +} diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 9546d32946a..3d3d1d224a3 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -5158,11 +5158,11 @@ export class McpStdioServerDefinition implements vscode.McpStdioServerDefinition ) { } } -export class McpSSEServerDefinition implements vscode.McpSSEServerDefinition { - headers: [string, string][] = []; +export class McpHttpServerDefinition implements vscode.McpHttpServerDefinition { constructor( public label: string, - public uri: URI + public uri: URI, + public headers: Record = {}, ) { } } //#endregion diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts index 42990525bef..65ba4094e3a 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts @@ -326,7 +326,12 @@ export class McpRegistry extends Disposable implements IMcpRegistry { } public async resolveConnection({ collectionRef, definitionRef, forceTrust, logger }: IMcpResolveConnectionOptions): Promise { - const collection = this._collections.get().find(c => c.id === collectionRef.id); + let collection = this._collections.get().find(c => c.id === collectionRef.id); + if (collection?.lazy) { + await collection.lazy.load(); + collection = this._collections.get().find(c => c.id === collectionRef.id); + } + const definition = collection?.serverDefinitions.get().find(s => s.id === definitionRef.id); if (!collection || !definition) { throw new Error(`Collection or definition not found for ${collectionRef.id} and ${definitionRef.id}`); @@ -356,7 +361,14 @@ export class McpRegistry extends Disposable implements IMcpRegistry { } } - let launch: McpServerLaunch | undefined; + let launch: McpServerLaunch | undefined = definition.launch; + if (collection.resolveServerLanch) { + launch = await collection.resolveServerLanch(definition); + if (!launch) { + return undefined; // interaction cancelled by user + } + } + try { launch = await this._replaceVariablesInLaunch(definition, definition.launch); } catch (e) { diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 7d06c30764e..a590925c9a8 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -44,6 +44,9 @@ export interface McpCollectionDefinition { /** Scope where associated collection info should be stored. */ readonly scope: StorageScope; + /** Resolves a server definition. If present, always called before a server starts. */ + resolveServerLanch?(definition: McpServerDefinition): Promise; + /** For lazy-loaded collections only: */ readonly lazy?: { /** True if `serverDefinitions` were loaded from the cache */ @@ -78,6 +81,8 @@ export namespace McpCollectionDefinition { readonly label: string; readonly isTrustedByDefault: boolean; readonly scope: StorageScope; + readonly canResolveLaunch: boolean; + readonly extensionId: string; } export function equals(a: McpCollectionDefinition, b: McpCollectionDefinition): boolean { diff --git a/src/vscode-dts/vscode.proposed.mcpConfigurationProvider.d.ts b/src/vscode-dts/vscode.proposed.mcpConfigurationProvider.d.ts index 28a9c7b2f36..93db89adced 100644 --- a/src/vscode-dts/vscode.proposed.mcpConfigurationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.mcpConfigurationProvider.d.ts @@ -6,37 +6,110 @@ declare module 'vscode' { // https://github.com/microsoft/vscode/issues/243522 + /** + * McpStdioServerDefinition represents an MCP server available by running + * a local process and listening to its stdin and stdout streams. + */ export class McpStdioServerDefinition { - + /** + * The human-readable name of the server. + */ label: string; + /** + * The working directory used to start the server. + */ cwd?: Uri; + + /** + * The command used to start the server. Node.js-based servers may use + * `process.execPath` to use the editor's version of Node.js to run the script. + */ command: string; - args: readonly string[]; + /** + * Additional command-line arguments passed to the server. + */ + args: string[]; + + /** + * Optional additional environment information for the server. Variables + * in this environment will overwrite or remove (if null) the default + * environment variables. + */ env: Record; - constructor(label: string, command: string, args: string[], env: { [key: string]: string }); + constructor(label: string, command: string, args?: string[], env?: Record); } - export class McpSSEServerDefinition { - + /** + * McpHttpServerDefinition represents an MCP server available using the + * Streamable HTTP transport. + */ + export class McpHttpServerDefinition { + /** + * The human-readable name of the server. + */ label: string; + /** + * The URI of the server. The editor will make a POST request to this URI + * to begin each session. + */ uri: Uri; - headers: [string, string][]; + /** + * Optional additional heads included with each request to the server. + */ + headers: Record; - constructor(label: string, uri: Uri); + /** + * @param label The human-readable name of the server. + * @param uri The URI of the server. + * @param headers Optional additional heads included with each request to the server. + */ + constructor(label: string, uri: Uri, headers?: Record); } - export type McpServerDefinition = McpStdioServerDefinition | McpSSEServerDefinition; - - export interface McpConfigurationProvider { + export type McpServerDefinition = McpStdioServerDefinition | McpHttpServerDefinition; + /** + * A type that can provide server configurations. This may only be used in + * conjunction with `contributes.modelContextServerCollections` in the + * extension's package.json. + * + * To allow the editor to cache available servers, extensions should register + * this before `activate()` resolves. + */ + export interface McpConfigurationProvider { + /** + * Optional event fired to signal that the set of available servers has changed. + */ onDidChange?: Event; - provideMcpServerDefinitions(token: CancellationToken): ProviderResult; + /** + * Provides available MCP servers. The editor will call this method eagerly + * to ensure the availability of servers for the language model, and so + * extensions should not take actions which would require user + * interaction, such as authentication. + * + * @param token A cancellation token. + * @returns An array of MCP available MCP servers + */ + provideMcpServerDefinitions(token: CancellationToken): ProviderResult; + /** + * This function will be called when the editor needs to start MCP server. + * At this point, the extension may take any actions which may require user + * interaction, such as authentication. + * + * The extension may return undefined on error to indicate that the server + * should not be started. + * + * @param server The MCP server to resolve + * @param token A cancellation token. + * @returns The given, resolved server or thenable that resolves to such. + */ + resolveMcpServerDefinition?(server: T, token: CancellationToken): ProviderResult; } namespace lm { From 720ee54497d0e1c598024a6ea45ca44a66283d85 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Thu, 17 Apr 2025 14:23:03 -0700 Subject: [PATCH 2/5] Fix null access crash for `$traceid` --- .../typescript-language-features/src/tsServer/server.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/typescript-language-features/src/tsServer/server.ts b/extensions/typescript-language-features/src/tsServer/server.ts index 5cc83788c81..dbb867f8bb3 100644 --- a/extensions/typescript-language-features/src/tsServer/server.ts +++ b/extensions/typescript-language-features/src/tsServer/server.ts @@ -224,7 +224,7 @@ export class SingleTsServer extends Disposable implements ITypeScriptServer { } } - public executeImpl(command: keyof TypeScriptRequests, args: any, executeInfo: { isAsync: boolean; token?: vscode.CancellationToken; expectsResult: boolean; lowPriority?: boolean; executionTarget?: ExecutionTarget }): Array> | undefined> { + public executeImpl(command: keyof TypeScriptRequests, args: unknown, executeInfo: { isAsync: boolean; token?: vscode.CancellationToken; expectsResult: boolean; lowPriority?: boolean; executionTarget?: ExecutionTarget }): Array> | undefined> { const request = this._requestQueue.createRequest(command, args); const requestInfo: RequestItem = { request, @@ -269,9 +269,9 @@ export class SingleTsServer extends Disposable implements ITypeScriptServer { } this._requestQueue.enqueue(requestInfo); - if (typeof args.$traceId === 'string') { + if (args && typeof (args as any).$traceId === 'string') { const queueLength = this._requestQueue.length - 1; - this._telemetryReporter.logTraceEvent('TSServer.enqueueRequest', args.$traceId, JSON.stringify({ command, queueLength })); + this._telemetryReporter.logTraceEvent('TSServer.enqueueRequest', (args as any).$traceId, JSON.stringify({ command, queueLength })); } this.sendNextRequests(); From 1a7677821b48803b416733af9a81e6e504b47d63 Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Thu, 17 Apr 2025 15:14:36 -0700 Subject: [PATCH 3/5] Switch model picker to use action widget (#246852) --- .../contrib/chat/browser/chatInputPart.ts | 94 +---------- .../modelPicker/modelPickerActionItem.ts | 115 ++++++++++++++ .../browser/modelPicker/modelPickerWidget.ts | 146 ++++++++++++++++++ 3 files changed, 265 insertions(+), 90 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/modelPicker/modelPickerActionItem.ts create mode 100644 src/vs/workbench/contrib/chat/browser/modelPicker/modelPickerWidget.ts diff --git a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts index 398214a5b16..c83ef68780b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts @@ -14,7 +14,7 @@ import { Button } from '../../../../base/browser/ui/button/button.js'; import { IActionProvider } from '../../../../base/browser/ui/dropdown/dropdown.js'; import { createInstantHoverDelegate, getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { IAction, Separator, toAction, WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../base/common/actions.js'; +import { IAction } from '../../../../base/common/actions.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; @@ -49,7 +49,6 @@ import { DropdownWithPrimaryActionViewItem, IDropdownWithPrimaryActionViewItemOp import { getFlatActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { IMenuService, MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; @@ -63,7 +62,6 @@ import { WorkbenchList } from '../../../../platform/list/browser/listService.js' import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { ISharedWebContentExtractorService } from '../../../../platform/webContentExtractor/common/webContentExtractor.js'; import { ResourceLabels } from '../../../browser/labels.js'; @@ -75,7 +73,6 @@ import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions, setupSimpleEd import { IChatAgentService } from '../common/chatAgents.js'; import { ChatContextKeys } from '../common/chatContextKeys.js'; import { IChatEditingSession } from '../common/chatEditingService.js'; -import { ChatEntitlement, IChatEntitlementService } from '../common/chatEntitlementService.js'; import { IChatRequestVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry } from '../common/chatModel.js'; import { IChatFollowup, IChatService } from '../common/chatService.js'; import { IChatVariablesService } from '../common/chatVariables.js'; @@ -102,6 +99,7 @@ import { ChatFileReference } from './contrib/chatDynamicVariables/chatFileRefere import { ChatImplicitContext } from './contrib/chatImplicitContext.js'; import { ChatRelatedFiles } from './contrib/chatInputRelatedFilesContrib.js'; import { resizeImage } from './imageUtils.js'; +import { IModelPickerDelegate, ModelPickerActionItem } from './modelPicker/modelPickerActionItem.js'; const $ = dom.$; @@ -997,7 +995,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } if (this._currentLanguageModel) { - const itemDelegate: ModelPickerDelegate = { + const itemDelegate: IModelPickerDelegate = { onDidChangeModel: this._onDidChangeCurrentLanguageModel.event, setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { // The user changed the language model, so we don't wait for the persisted option to be registered @@ -1007,7 +1005,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }, getModels: () => this.getModels() }; - return this.instantiationService.createInstance(ModelPickerActionViewItem, action, this._currentLanguageModel, itemDelegate); + return this.instantiationService.createInstance(ModelPickerActionItem, action, this._currentLanguageModel, itemDelegate); } } else if (action.id === ToggleAgentModeActionId && action instanceof MenuItemAction) { const delegate: IModePickerDelegate = { @@ -1495,90 +1493,6 @@ class ChatSubmitDropdownActionItem extends DropdownWithPrimaryActionViewItem { } } -interface ModelPickerDelegate { - onDidChangeModel: Event; - setModel(selectedModelId: ILanguageModelChatMetadataAndIdentifier): void; - getModels(): ILanguageModelChatMetadataAndIdentifier[]; -} - -class ModelPickerActionViewItem extends DropdownMenuActionViewItemWithKeybinding { - constructor( - action: MenuItemAction, - private currentLanguageModel: ILanguageModelChatMetadataAndIdentifier, - private readonly delegate: ModelPickerDelegate, - @IContextMenuService contextMenuService: IContextMenuService, - @IKeybindingService keybindingService: IKeybindingService, - @IContextKeyService contextKeyService: IContextKeyService, - @IChatEntitlementService chatEntitlementService: IChatEntitlementService, - @ICommandService commandService: ICommandService, - @IMenuService menuService: IMenuService, - @ITelemetryService telemetryService: ITelemetryService, - ) { - const modelActionsProvider: IActionProvider = { - getActions: () => { - const setLanguageModelAction = (entry: ILanguageModelChatMetadataAndIdentifier): IAction => { - return { - id: entry.identifier, - label: entry.metadata.name, - tooltip: '', - class: undefined, - enabled: true, - checked: entry.identifier === this.currentLanguageModel.identifier, - run: () => { - this.currentLanguageModel = entry; - this.renderLabel(this.element!); - this.delegate.setModel(entry); - } - }; - }; - - const models: ILanguageModelChatMetadataAndIdentifier[] = this.delegate.getModels(); - const actions = models.map(entry => setLanguageModelAction(entry)); - - // Add menu contributions from extensions - const menuActions = menuService.getMenuActions(MenuId.ChatModelPicker, contextKeyService); - const menuContributions = getFlatActionBarActions(menuActions); - if (menuContributions.length > 0 || chatEntitlementService.entitlement === ChatEntitlement.Limited) { - actions.push(new Separator()); - } - actions.push(...menuContributions); - if (chatEntitlementService.entitlement === ChatEntitlement.Limited) { - actions.push(toAction({ - id: 'moreModels', label: localize('chat.moreModels', "Add Premium Models"), run: () => { - const commandId = 'workbench.action.chat.upgradePlan'; - telemetryService.publicLog2('workbenchActionExecuted', { id: commandId, from: 'chat-models' }); - commandService.executeCommand(commandId); - } - })); - } - return actions; - } - }; - - const actionWithLabel: IAction = { - ...action, - tooltip: localize('chat.modelPicker.label', "Pick Model"), - run: () => { } - }; - super(actionWithLabel, modelActionsProvider, contextMenuService, undefined, keybindingService, contextKeyService); - this._register(delegate.onDidChangeModel(modelId => { - this.currentLanguageModel = modelId; - this.renderLabel(this.element!); - })); - } - - protected override renderLabel(element: HTMLElement): IDisposable | null { - this.setAriaLabelAttributes(element); - dom.reset(element, dom.$('span.chat-model-label', undefined, this.currentLanguageModel.metadata.name), ...renderLabelWithIcons(`$(chevron-down)`)); - return null; - } - - override render(container: HTMLElement): void { - super.render(container); - container.classList.add('chat-modelPicker-item'); - } -} - const chatInputEditorContainerSelector = '.interactive-input-editor'; setupSimpleEditorSelectionStyling(chatInputEditorContainerSelector); diff --git a/src/vs/workbench/contrib/chat/browser/modelPicker/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/modelPicker/modelPickerActionItem.ts new file mode 100644 index 00000000000..90b6fdeb957 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/modelPicker/modelPickerActionItem.ts @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IAction } from '../../../../../base/common/actions.js'; +import { Event } from '../../../../../base/common/event.js'; +import { ILanguageModelChatMetadataAndIdentifier } from '../../common/languageModels.js'; +import { localize } from '../../../../../nls.js'; +import { ModelPickerWidget } from './modelPickerWidget.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import * as dom from '../../../../../base/browser/dom.js'; +import { renderLabelWithIcons } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ActionViewItem } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; + +export interface IModelPickerDelegate { + readonly onDidChangeModel: Event; + setModel(model: ILanguageModelChatMetadataAndIdentifier): void; + getModels(): ILanguageModelChatMetadataAndIdentifier[]; +} + +/** + * Action view item for selecting a language model in the chat interface. + */ +export class ModelPickerActionItem extends ActionViewItem { + private widget: ModelPickerWidget | undefined; + + constructor( + action: IAction, + private readonly currentModel: ILanguageModelChatMetadataAndIdentifier, + private readonly delegate: IModelPickerDelegate, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + // Modify the original action with a different label and make it show the current model + const actionWithLabel: IAction = { + ...action, + label: currentModel.metadata.name, + tooltip: localize('chat.modelPicker.label', "Pick Model"), + run: () => { /* Will be overridden by our click handler */ } + }; + + super(undefined, actionWithLabel, { label: true }); + + // Listen for model changes from the delegate + this._register(delegate.onDidChangeModel(model => { + this.action.label = model.metadata.name; + this.updateLabel(); + })); + } + + /** + * Override rendering of the label to include the dropdown indicator + */ + protected override updateLabel(): void { + if (this.label) { + // Reset the label element with the current model name and a dropdown indicator + dom.reset(this.label, + dom.$('span.chat-model-label', undefined, this.action.label), + ...renderLabelWithIcons(`$(${Codicon.chevronDown.id})`) + ); + } + } + + /** + * Override rendering to add CSS classes and initialize the widget + */ + override render(container: HTMLElement): void { + super.render(container); + + // Add classes for styling this element + container.classList.add('chat-modelPicker-item'); + + // Create the model picker widget that will be shown when clicked + this.widget = this.instantiationService.createInstance( + ModelPickerWidget, + this.currentModel, + () => this.delegate.getModels(), + (model) => this.delegate.setModel(model) + ); + + // Register event handlers + this._register(this.widget.onDidChangeModel(model => { + this.action.label = model.metadata.name; + this.updateLabel(); + })); + } + + /** + * Override the onClick to show our picker widget + */ + override onClick(event: MouseEvent): void { + if (!this.widget) { + return; + } + + // Show the model picker at the current position + this.widget.showAt({ + x: event.clientX, + y: event.clientY + }); + + event.stopPropagation(); + event.preventDefault(); + } + + /** + * Set aria label attributes on the element + */ + protected setAriaLabelAttributes(element: HTMLElement): void { + element.setAttribute('aria-label', localize('chatModelPicker', "Chat Model: {0}", this.action.label)); + element.setAttribute('aria-haspopup', 'true'); + element.setAttribute('role', 'button'); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/modelPicker/modelPickerWidget.ts b/src/vs/workbench/contrib/chat/browser/modelPicker/modelPickerWidget.ts new file mode 100644 index 00000000000..882ae001fa3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/modelPicker/modelPickerWidget.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IAction } from '../../../../../base/common/actions.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IAnchor } from '../../../../../base/browser/ui/contextview/contextview.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { ILanguageModelChatMetadataAndIdentifier } from '../../common/languageModels.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { localize } from '../../../../../nls.js'; +import { MenuId, IMenuService } from '../../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IChatEntitlementService, ChatEntitlement } from '../../common/chatEntitlementService.js'; +import { getFlatActionBarActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { ActionListItemKind, IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; + +interface IModelPickerActionItem { + model: ILanguageModelChatMetadataAndIdentifier; + isCurrent: boolean; +} + +/** + * Widget for picking a language model for chat. + */ +export class ModelPickerWidget extends Disposable { + private readonly _onDidChangeModel = this._register(new Emitter()); + readonly onDidChangeModel = this._onDidChangeModel.event; + + constructor( + private currentModel: ILanguageModelChatMetadataAndIdentifier, + private readonly getModels: () => ILanguageModelChatMetadataAndIdentifier[], + private readonly setModel: (model: ILanguageModelChatMetadataAndIdentifier) => void, + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + @IMenuService private readonly menuService: IMenuService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, + @ICommandService private readonly commandService: ICommandService, + ) { + super(); + } + + /** + * Get the label to display in the button that shows the current model + */ + get buttonLabel(): string { + return this.currentModel.metadata.name; + } + + /** + * Convert available models to action items for display + */ + private getActionItems(): IModelPickerActionItem[] { + const items: IModelPickerActionItem[] = this.getModels().map(model => ({ + model, + isCurrent: model.identifier === this.currentModel.identifier + })); + + return items; + } + + /** + * Get any additional actions to add to the picker menu + */ + private getAdditionalActions(): IAction[] { + const menuActions = this.menuService.createMenu(MenuId.ChatModelPicker, this.contextKeyService); + const menuContributions = getFlatActionBarActions(menuActions.getActions()); + menuActions.dispose(); + + const additionalActions: IAction[] = []; + + // Add menu contributions from extensions + if (menuContributions.length > 0) { + additionalActions.push(...menuContributions); + } + + // Add upgrade option if entitlement is limited + if (this.chatEntitlementService.entitlement === ChatEntitlement.Limited) { + additionalActions.push({ + id: 'moreModels', + label: localize('chat.moreModels', "Add Premium Models"), + enabled: true, + tooltip: localize('chat.moreModels.tooltip', "Add premium models"), + class: undefined, + run: () => { + const commandId = 'workbench.action.chat.upgradePlan'; + this.commandService.executeCommand(commandId); + } + }); + } + + return additionalActions; + } + + /** + * Shows the picker at the specified anchor + */ + showAt(anchor: IAnchor, container?: HTMLElement): void { + const items: IActionListItem[] = this.getActionItems().map(item => ({ + item: item.model, + kind: ActionListItemKind.Action, + canPreview: false, + group: { title: '', icon: ThemeIcon.fromId(item.isCurrent ? Codicon.check.id : Codicon.blank.id) }, + disabled: false, + hideIcon: false, + label: item.model.metadata.name, + } satisfies IActionListItem)); + + const delegate = { + onSelect: (item: ILanguageModelChatMetadataAndIdentifier) => { + if (item.identifier !== this.currentModel.identifier) { + this.setModel(item); + this.currentModel = item; + this._onDidChangeModel.fire(item); + } + this.actionWidgetService.hide(false); + return true; + }, + onHide: () => { }, + getWidgetAriaLabel: () => localize('modelPicker', "Model Picker") + }; + + // Get additional actions to show in the picker + const additionalActions = this.getAdditionalActions(); + let buttonBar: IAction[] = []; + + // If we have additional actions, add them to the button bar + if (additionalActions.length > 0) { + buttonBar = additionalActions; + } + + this.actionWidgetService.show( + 'modelPicker', + false, + items, + delegate, + anchor, + container, + buttonBar + ); + } +} From 1b000ef82b3c3082833f6b04e050bc0e4e12ae3b Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Thu, 17 Apr 2025 17:48:05 -0700 Subject: [PATCH 4/5] feat: add menu entry for opening walkthroughs in help menu (#246868) --- .../browser/gettingStarted.contribution.ts | 5 +++++ .../common/gettingStartedContent.ts | 11 ----------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts index bb611f3a904..a1c7dac9944 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts @@ -236,6 +236,11 @@ registerAction2(class extends Action2 { title: localize2('welcome.showAllWalkthroughs', 'Open Walkthrough...'), category, f1: true, + menu: { + id: MenuId.MenubarHelpMenu, + group: '1_welcome', + order: 3, + }, }); } diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts b/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts index 5126beeb958..c370ab068a9 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/common/gettingStartedContent.ts @@ -174,17 +174,6 @@ export const startEntries: GettingStartedStartEntryContent = [ command: 'command:remoteHub.openRepository', } }, - { - id: 'topLevelShowWalkthroughs', - title: localize('gettingStarted.topLevelShowWalkthroughs.title', "Open a Walkthrough..."), - description: localize('gettingStarted.topLevelShowWalkthroughs.description', "View a walkthrough on the editor or an extension"), - icon: Codicon.checklist, - when: 'allWalkthroughsHidden', - content: { - type: 'startEntry', - command: 'command:welcome.showAllWalkthroughs', - } - }, { id: 'topLevelRemoteOpen', title: localize('gettingStarted.topLevelRemoteOpen.title', "Connect to..."), From 73a247197ffd1c32bba21be518aa532c2e45c7a0 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Fri, 18 Apr 2025 15:10:27 +0200 Subject: [PATCH 5/5] Status: NES shows checked but disabled (fix microsoft/vscode-copilot#16139) (#246890) --- .../workbench/contrib/chat/browser/chatStatus.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus.ts b/src/vs/workbench/contrib/chat/browser/chatStatus.ts index 813426add25..3e323a12291 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus.ts @@ -543,13 +543,13 @@ class ChatStatusDashboard extends Disposable { // --- Next Edit Suggestions { const setting = append(settings, $('div.setting')); - this.createNextEditSuggestionsSetting(setting, localize('settings.nextEditSuggestions', "Next Edit Suggestions"), modeId, this.getCompletionsSettingAccessor(modeId), disposables); + this.createNextEditSuggestionsSetting(setting, localize('settings.nextEditSuggestions', "Next Edit Suggestions"), this.getCompletionsSettingAccessor(modeId), disposables); } return settings; } - private createSetting(container: HTMLElement, settingId: string, label: string, accessor: ISettingsAccessor, disposables: DisposableStore): Checkbox { + private createSetting(container: HTMLElement, settingIdsToReEvaluate: string[], label: string, accessor: ISettingsAccessor, disposables: DisposableStore): Checkbox { const checkbox = disposables.add(new Checkbox(label, Boolean(accessor.readSetting()), defaultCheckboxStyles)); container.appendChild(checkbox.domNode); @@ -572,7 +572,7 @@ class ChatStatusDashboard extends Disposable { })); disposables.add(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(settingId)) { + if (settingIdsToReEvaluate.some(id => e.affectsConfiguration(id))) { checkbox.checked = Boolean(accessor.readSetting()); } })); @@ -580,13 +580,14 @@ class ChatStatusDashboard extends Disposable { if (!canUseCopilot(this.chatEntitlementService)) { container.classList.add('disabled'); checkbox.disable(); + checkbox.checked = false; } return checkbox; } private createCodeCompletionsSetting(container: HTMLElement, label: string, modeId: string | undefined, disposables: DisposableStore): void { - this.createSetting(container, defaultChat.completionsEnablementSetting, label, this.getCompletionsSettingAccessor(modeId), disposables); + this.createSetting(container, [defaultChat.completionsEnablementSetting], label, this.getCompletionsSettingAccessor(modeId), disposables); } private getCompletionsSettingAccessor(modeId = '*'): ISettingsAccessor { @@ -605,13 +606,13 @@ class ChatStatusDashboard extends Disposable { }; } - private createNextEditSuggestionsSetting(container: HTMLElement, label: string, modeId: string | undefined, completionsSettingAccessor: ISettingsAccessor, disposables: DisposableStore): void { + private createNextEditSuggestionsSetting(container: HTMLElement, label: string, completionsSettingAccessor: ISettingsAccessor, disposables: DisposableStore): void { const nesSettingId = defaultChat.nextEditSuggestionsSetting; const completionsSettingId = defaultChat.completionsEnablementSetting; const resource = EditorResourceAccessor.getOriginalUri(this.editorService.activeEditor, { supportSideBySide: SideBySideEditor.PRIMARY }); - const checkbox = this.createSetting(container, nesSettingId, label, { - readSetting: () => this.textResourceConfigurationService.getValue(resource, nesSettingId), + const checkbox = this.createSetting(container, [nesSettingId, completionsSettingId], label, { + readSetting: () => completionsSettingAccessor.readSetting() && this.textResourceConfigurationService.getValue(resource, nesSettingId), writeSetting: (value: boolean) => this.textResourceConfigurationService.updateValue(resource, nesSettingId, value) }, disposables);