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