Path: blob/main/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/4import { assertNever } from '../../../../../base/common/assert.js';5import { Codicon } from '../../../../../base/common/codicons.js';6import { Event } from '../../../../../base/common/event.js';7import { DisposableStore } from '../../../../../base/common/lifecycle.js';8import { ThemeIcon } from '../../../../../base/common/themables.js';9import { localize } from '../../../../../nls.js';10import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js';11import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js';12import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';13import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickTreeItem } from '../../../../../platform/quickinput/common/quickInput.js';14import { URI } from '../../../../../base/common/uri.js';15import { IEditorService } from '../../../../services/editor/common/editorService.js';16import { ExtensionEditorTab, IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js';17import { McpCommandIds } from '../../../mcp/common/mcpCommandIds.js';18import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js';19import { IMcpServer, IMcpService, IMcpWorkbenchService, McpConnectionState, McpServerEditorTab } from '../../../mcp/common/mcpTypes.js';20import { ILanguageModelToolsService, IToolData, ToolDataSource, ToolSet } from '../../common/languageModelToolsService.js';21import { ConfigureToolSets } from '../tools/toolSetsContribution.js';22import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';23import { ChatContextKeys } from '../../common/chatContextKeys.js';24import Severity from '../../../../../base/common/severity.js';25import { markdownCommandLink } from '../../../../../base/common/htmlContent.js';2627const enum BucketOrdinal { User, BuiltIn, Mcp, Extension }2829// Legacy QuickPick types (existing implementation)30type BucketPick = IQuickPickItem & { picked: boolean; ordinal: BucketOrdinal; status?: string; toolset?: ToolSet; children: (ToolPick | ToolSetPick)[] };31type ToolSetPick = IQuickPickItem & { picked: boolean; toolset: ToolSet; parent: BucketPick };32type ToolPick = IQuickPickItem & { picked: boolean; tool: IToolData; parent: BucketPick };33type ActionableButton = IQuickInputButton & { action: () => void };3435// New QuickTree types for tree-based implementation3637/**38* Base interface for all tree items in the QuickTree implementation.39* Extends IQuickTreeItem with common properties for tool picker items.40*/41interface IToolTreeItem extends IQuickTreeItem {42readonly itemType: 'bucket' | 'toolset' | 'tool' | 'callback';43readonly ordinal?: BucketOrdinal;44readonly buttons?: readonly ActionableButton[];45}4647/**48* Bucket tree item - represents a category of tools (User, BuiltIn, MCP Server, Extension).49* For MCP servers, the bucket directly represents the server and stores the toolset.50*/51interface IBucketTreeItem extends IToolTreeItem {52readonly itemType: 'bucket';53readonly ordinal: BucketOrdinal;54toolset?: ToolSet; // For MCP servers where the bucket represents the ToolSet - mutable55readonly status?: string;56readonly children: AnyTreeItem[];57checked: boolean | 'partial' | undefined;58}5960/**61* ToolSet tree item - represents a collection of tools that can be managed together.62* Used for regular (non-MCP) toolsets that appear as intermediate nodes in the tree.63*/64interface IToolSetTreeItem extends IToolTreeItem {65readonly itemType: 'toolset';66readonly toolset: ToolSet;67children: AnyTreeItem[] | undefined;68checked: boolean | 'partial';69}7071/**72* Tool tree item - represents an individual tool that can be selected/deselected.73* This is a leaf node in the tree structure.74*/75interface IToolTreeItemData extends IToolTreeItem {76readonly itemType: 'tool';77readonly tool: IToolData;78checked: boolean;79}8081/**82* Callback tree item - represents action items like "Add MCP Server" or "Configure Tool Sets".83* These are non-selectable items that execute actions when clicked.84*/85interface ICallbackTreeItem extends IToolTreeItem {86readonly itemType: 'callback';87readonly run: () => void;88readonly pickable: false;89}9091type AnyTreeItem = IBucketTreeItem | IToolSetTreeItem | IToolTreeItemData | ICallbackTreeItem;9293// Type guards for new QuickTree types94function isBucketTreeItem(item: AnyTreeItem): item is IBucketTreeItem {95return item.itemType === 'bucket';96}97function isToolSetTreeItem(item: AnyTreeItem): item is IToolSetTreeItem {98return item.itemType === 'toolset';99}100function isToolTreeItem(item: AnyTreeItem): item is IToolTreeItemData {101return item.itemType === 'tool';102}103function isCallbackTreeItem(item: AnyTreeItem): item is ICallbackTreeItem {104return item.itemType === 'callback';105}106107/**108* Maps different icon types (ThemeIcon or URI-based) to QuickTreeItem icon properties.109* Handles the conversion between ToolSet/IToolData icon formats and tree item requirements.110* Provides a default tool icon when no icon is specified.111*112* @param icon - Icon to map (ThemeIcon, URI object, or undefined)113* @param useDefaultToolIcon - Whether to use a default tool icon when none is provided114* @returns Object with iconClass (for ThemeIcon) or iconPath (for URIs) properties115*/116function mapIconToTreeItem(icon: ThemeIcon | { dark: URI; light?: URI } | undefined, useDefaultToolIcon: boolean = false): Pick<IQuickTreeItem, 'iconClass' | 'iconPath'> {117if (!icon) {118if (useDefaultToolIcon) {119return { iconClass: ThemeIcon.asClassName(Codicon.tools) };120}121return {};122}123124if (ThemeIcon.isThemeIcon(icon)) {125return { iconClass: ThemeIcon.asClassName(icon) };126} else {127return { iconPath: icon };128}129}130131function createToolTreeItemFromData(tool: IToolData, checked: boolean): IToolTreeItemData {132const iconProps = mapIconToTreeItem(tool.icon, true); // Use default tool icon if none provided133134return {135itemType: 'tool',136tool,137id: tool.id,138label: tool.toolReferenceName ?? tool.displayName,139description: tool.userDescription ?? tool.modelDescription,140checked,141...iconProps142};143}144145function createToolSetTreeItem(toolset: ToolSet, checked: boolean, editorService: IEditorService): IToolSetTreeItem {146const iconProps = mapIconToTreeItem(toolset.icon);147const buttons = [];148if (toolset.source.type === 'user') {149const resource = toolset.source.file;150buttons.push({151iconClass: ThemeIcon.asClassName(Codicon.edit),152tooltip: localize('editUserBucket', "Edit Tool Set"),153action: () => editorService.openEditor({ resource })154});155}156return {157itemType: 'toolset',158toolset,159buttons,160id: toolset.id,161label: toolset.referenceName,162description: toolset.description,163checked,164children: undefined,165collapsed: true,166...iconProps167};168}169170/**171* New QuickTree implementation of the tools picker.172* Uses IQuickTree to provide a true hierarchical tree structure with:173* - Collapsible nodes for buckets and toolsets174* - Checkbox state management with parent-child relationships175* - Special handling for MCP servers (server as bucket, tools as direct children)176* - Built-in filtering and search capabilities177*178* @param accessor - Service accessor for dependency injection179* @param placeHolder - Placeholder text shown in the picker180* @param description - Optional description text shown in the picker181* @param toolsEntries - Optional initial selection state for tools and toolsets182* @param onUpdate - Optional callback fired when the selection changes183* @returns Promise resolving to the final selection map, or undefined if cancelled184*/185export async function showToolsPicker(186accessor: ServicesAccessor,187placeHolder: string,188description?: string,189toolsEntries?: ReadonlyMap<ToolSet | IToolData, boolean>190): Promise<ReadonlyMap<ToolSet | IToolData, boolean> | undefined> {191192const quickPickService = accessor.get(IQuickInputService);193const mcpService = accessor.get(IMcpService);194const mcpRegistry = accessor.get(IMcpRegistry);195const commandService = accessor.get(ICommandService);196const extensionsWorkbenchService = accessor.get(IExtensionsWorkbenchService);197const editorService = accessor.get(IEditorService);198const mcpWorkbenchService = accessor.get(IMcpWorkbenchService);199const toolsService = accessor.get(ILanguageModelToolsService);200const toolLimit = accessor.get(IContextKeyService).getContextKeyValue<number>(ChatContextKeys.chatToolGroupingThreshold.key);201202const mcpServerByTool = new Map<string, IMcpServer>();203for (const server of mcpService.servers.get()) {204for (const tool of server.tools.get()) {205mcpServerByTool.set(tool.id, server);206}207}208209// Create default entries if none provided210if (!toolsEntries) {211const defaultEntries = new Map();212for (const tool of toolsService.getTools()) {213if (tool.canBeReferencedInPrompt) {214defaultEntries.set(tool, false);215}216}217for (const toolSet of toolsService.toolSets.get()) {218defaultEntries.set(toolSet, false);219}220toolsEntries = defaultEntries;221}222223// Build tree structure224const treeItems: AnyTreeItem[] = [];225const bucketMap = new Map<string, IBucketTreeItem>();226227const getKey = (source: ToolDataSource): string => {228switch (source.type) {229case 'mcp':230case 'extension':231return ToolDataSource.toKey(source);232case 'internal':233return BucketOrdinal.BuiltIn.toString();234case 'user':235return BucketOrdinal.User.toString();236case 'external':237throw new Error('should not be reachable');238default:239assertNever(source);240}241};242243const createBucket = (source: ToolDataSource, key: string): IBucketTreeItem | undefined => {244if (source.type === 'mcp') {245const { definitionId } = source;246const mcpServer = mcpService.servers.get().find(candidate => candidate.definition.id === definitionId);247if (!mcpServer) {248return undefined;249}250251const buttons: ActionableButton[] = [];252const collection = mcpRegistry.collections.get().find(c => c.id === mcpServer.collection.id);253if (collection?.source) {254buttons.push({255iconClass: ThemeIcon.asClassName(Codicon.settingsGear),256tooltip: localize('configMcpCol', "Configure {0}", collection.label),257action: () => collection.source ? collection.source instanceof ExtensionIdentifier ? extensionsWorkbenchService.open(collection.source.value, { tab: ExtensionEditorTab.Features, feature: 'mcp' }) : mcpWorkbenchService.open(collection.source, { tab: McpServerEditorTab.Configuration }) : undefined258});259} else if (collection?.presentation?.origin) {260buttons.push({261iconClass: ThemeIcon.asClassName(Codicon.settingsGear),262tooltip: localize('configMcpCol', "Configure {0}", collection.label),263action: () => editorService.openEditor({264resource: collection!.presentation!.origin,265})266});267}268if (mcpServer.connectionState.get().state === McpConnectionState.Kind.Error) {269buttons.push({270iconClass: ThemeIcon.asClassName(Codicon.warning),271tooltip: localize('mcpShowOutput', "Show Output"),272action: () => mcpServer.showOutput(),273});274}275return {276itemType: 'bucket',277ordinal: BucketOrdinal.Mcp,278id: key,279label: localize('mcplabel', "MCP Server: {0}", source.label),280checked: undefined,281collapsed: true,282children: [],283buttons,284iconClass: ThemeIcon.asClassName(Codicon.mcp)285};286} else if (source.type === 'extension') {287return {288itemType: 'bucket',289ordinal: BucketOrdinal.Extension,290id: key,291label: localize('ext', 'Extension: {0}', source.label),292checked: undefined,293children: [],294buttons: [],295collapsed: true,296iconClass: ThemeIcon.asClassName(Codicon.extensions)297};298} else if (source.type === 'internal') {299return {300itemType: 'bucket',301ordinal: BucketOrdinal.BuiltIn,302id: key,303label: localize('defaultBucketLabel', "Built-In"),304checked: undefined,305children: [],306buttons: [],307collapsed: false308};309} else {310return {311itemType: 'bucket',312ordinal: BucketOrdinal.User,313id: key,314label: localize('userBucket', "User Defined Tool Sets"),315checked: undefined,316children: [],317buttons: [],318collapsed: true319};320}321};322323const getBucket = (source: ToolDataSource): IBucketTreeItem | undefined => {324const key = getKey(source);325let bucket = bucketMap.get(key);326if (!bucket) {327bucket = createBucket(source, key);328if (bucket) {329bucketMap.set(key, bucket);330}331}332return bucket;333};334335for (const toolSet of toolsService.toolSets.get()) {336if (!toolsEntries.has(toolSet)) {337continue;338}339const bucket = getBucket(toolSet.source);340if (!bucket) {341continue;342}343const toolSetChecked = toolsEntries.get(toolSet) === true;344if (toolSet.source.type === 'mcp') {345// bucket represents the toolset346bucket.toolset = toolSet;347if (toolSetChecked) {348bucket.checked = toolSetChecked;349}350// all mcp tools are part of toolsService.getTools()351} else {352const treeItem = createToolSetTreeItem(toolSet, toolSetChecked, editorService);353bucket.children.push(treeItem);354const children = [];355for (const tool of toolSet.getTools()) {356const toolChecked = toolSetChecked || toolsEntries.get(tool) === true;357const toolTreeItem = createToolTreeItemFromData(tool, toolChecked);358children.push(toolTreeItem);359}360if (children.length > 0) {361treeItem.children = children;362}363}364}365for (const tool of toolsService.getTools()) {366if (!tool.canBeReferencedInPrompt || !toolsEntries.has(tool)) {367continue;368}369const bucket = getBucket(tool.source);370if (!bucket) {371continue;372}373const toolChecked = bucket.checked === true || toolsEntries.get(tool) === true;374const toolTreeItem = createToolTreeItemFromData(tool, toolChecked);375bucket.children.push(toolTreeItem);376}377378// Convert bucket map to sorted tree items379const sortedBuckets = Array.from(bucketMap.values()).sort((a, b) => a.ordinal - b.ordinal);380treeItems.push(...sortedBuckets);381382// Create and configure the tree picker383const store = new DisposableStore();384const treePicker = store.add(quickPickService.createQuickTree<AnyTreeItem>());385386treePicker.placeholder = placeHolder;387treePicker.ignoreFocusOut = true;388treePicker.description = description;389treePicker.matchOnDescription = true;390treePicker.matchOnLabel = true;391392if (treeItems.length === 0) {393treePicker.placeholder = localize('noTools', "Add tools to chat");394}395396treePicker.setItemTree(treeItems);397398// Handle button triggers399store.add(treePicker.onDidTriggerItemButton(e => {400if (e.button && typeof (e.button as ActionableButton).action === 'function') {401(e.button as ActionableButton).action();402store.dispose();403}404}));405406const updateToolLimitMessage = () => {407if (toolLimit) {408let count = 0;409const traverse = (items: readonly AnyTreeItem[]) => {410for (const item of items) {411if (isBucketTreeItem(item) || isToolSetTreeItem(item)) {412if (item.children) {413traverse(item.children);414}415} else if (isToolTreeItem(item) && item.checked) {416count++;417}418}419};420traverse(treeItems);421if (count > toolLimit) {422treePicker.severity = Severity.Warning;423treePicker.validationMessage = localize('toolLimitExceeded', "{0} tools are enabled. You may experience degraded tool calling above {1} tools.", count, markdownCommandLink({ title: String(toolLimit), id: '_chat.toolPicker.closeAndOpenVirtualThreshold' }));424} else {425treePicker.severity = Severity.Ignore;426treePicker.validationMessage = undefined;427}428}429};430updateToolLimitMessage();431432const collectResults = () => {433434const result = new Map<IToolData | ToolSet, boolean>();435const traverse = (items: readonly AnyTreeItem[]) => {436for (const item of items) {437if (isBucketTreeItem(item)) {438if (item.toolset) { // MCP server439// MCP toolset is enabled only if all tools are enabled440const allChecked = item.checked === true;441result.set(item.toolset, allChecked);442}443traverse(item.children);444} else if (isToolSetTreeItem(item)) {445result.set(item.toolset, item.checked === true);446if (item.children) {447traverse(item.children);448}449} else if (isToolTreeItem(item)) {450result.set(item.tool, item.checked);451}452}453};454455traverse(treeItems);456return result;457};458459// Temporary command to close the picker and open settings, for use in the validation message460store.add(CommandsRegistry.registerCommand({461id: '_chat.toolPicker.closeAndOpenVirtualThreshold',462handler: () => {463treePicker.hide();464commandService.executeCommand('workbench.action.openSettings', 'github.copilot.chat.virtualTools.threshold');465}466}));467468// Handle checkbox state changes469store.add(treePicker.onDidChangeCheckedLeafItems(() => updateToolLimitMessage()));470471// Handle acceptance472let didAccept = false;473store.add(treePicker.onDidAccept(() => {474// Check if a callback item was activated475const activeItems = treePicker.activeItems;476const callbackItem = activeItems.find(isCallbackTreeItem);477if (callbackItem) {478callbackItem.run();479} else {480didAccept = true;481}482}));483484const addMcpServerButton = {485iconClass: ThemeIcon.asClassName(Codicon.mcp),486tooltip: localize('addMcpServer', 'Add MCP Server...')487};488const installExtension = {489iconClass: ThemeIcon.asClassName(Codicon.extensions),490tooltip: localize('addExtensionButton', 'Install Extension...')491};492const configureToolSets = {493iconClass: ThemeIcon.asClassName(Codicon.gear),494tooltip: localize('configToolSets', 'Configure Tool Sets...')495};496treePicker.title = localize('configureTools', "Configure Tools");497treePicker.buttons = [addMcpServerButton, installExtension, configureToolSets];498store.add(treePicker.onDidTriggerButton(button => {499if (button === addMcpServerButton) {500commandService.executeCommand(McpCommandIds.AddConfiguration);501} else if (button === installExtension) {502extensionsWorkbenchService.openSearch('@tag:language-model-tools');503} else if (button === configureToolSets) {504commandService.executeCommand(ConfigureToolSets.ID);505}506treePicker.hide();507}));508509treePicker.show();510511await Promise.race([Event.toPromise(Event.any(treePicker.onDidAccept, treePicker.onDidHide), store)]);512513store.dispose();514515return didAccept ? collectResults() : undefined;516}517518519