Skip to content

Commit a918b44

Browse files
committed
tools: UI side of virtual tools at threshold
When the extension-defined tool threshold limit is hit, we show a warning in chat the selection may be degraded. This warning is temporary and will be removed as virtual tools get better. Closes microsoft#248021 Corresponds to microsoft/vscode-copilot-chat#385
1 parent 366d00b commit a918b44

File tree

10 files changed

+174
-16
lines changed

10 files changed

+174
-16
lines changed

src/vs/platform/actions/browser/actionViewItemService.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,39 +3,46 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { IActionViewItemProvider } from '../../../base/browser/ui/actionbar/actionbar.js';
6+
import { IActionViewItem } from '../../../base/browser/ui/actionbar/actionbar.js';
7+
import { IActionViewItemOptions } from '../../../base/browser/ui/actionbar/actionViewItems.js';
8+
import { IAction } from '../../../base/common/actions.js';
79
import { Emitter, Event } from '../../../base/common/event.js';
810
import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
911
import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js';
10-
import { createDecorator } from '../../instantiation/common/instantiation.js';
12+
import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js';
1113
import { MenuId } from '../common/actions.js';
1214

1315

1416
export const IActionViewItemService = createDecorator<IActionViewItemService>('IActionViewItemService');
1517

18+
19+
export interface IActionViewItemFactory {
20+
(action: IAction, options: IActionViewItemOptions, instaService: IInstantiationService): IActionViewItem | undefined;
21+
}
22+
1623
export interface IActionViewItemService {
1724

1825
_serviceBrand: undefined;
1926

2027
onDidChange: Event<MenuId>;
2128

22-
register(menu: MenuId, submenu: MenuId, provider: IActionViewItemProvider, event?: Event<unknown>): IDisposable;
23-
register(menu: MenuId, commandId: string, provider: IActionViewItemProvider, event?: Event<unknown>): IDisposable;
29+
register(menu: MenuId, submenu: MenuId, provider: IActionViewItemFactory, event?: Event<unknown>): IDisposable;
30+
register(menu: MenuId, commandId: string, provider: IActionViewItemFactory, event?: Event<unknown>): IDisposable;
2431

25-
lookUp(menu: MenuId, submenu: MenuId): IActionViewItemProvider | undefined;
26-
lookUp(menu: MenuId, commandId: string): IActionViewItemProvider | undefined;
32+
lookUp(menu: MenuId, submenu: MenuId): IActionViewItemFactory | undefined;
33+
lookUp(menu: MenuId, commandId: string): IActionViewItemFactory | undefined;
2734
}
2835

2936
export class NullActionViewItemService implements IActionViewItemService {
3037
_serviceBrand: undefined;
3138

3239
onDidChange: Event<MenuId> = Event.None;
3340

34-
register(menu: MenuId, commandId: string | MenuId, provider: IActionViewItemProvider, event?: Event<unknown>): IDisposable {
41+
register(menu: MenuId, commandId: string | MenuId, provider: IActionViewItemFactory, event?: Event<unknown>): IDisposable {
3542
return Disposable.None;
3643
}
3744

38-
lookUp(menu: MenuId, commandId: string | MenuId): IActionViewItemProvider | undefined {
45+
lookUp(menu: MenuId, commandId: string | MenuId): IActionViewItemFactory | undefined {
3946
return undefined;
4047
}
4148
}
@@ -44,7 +51,7 @@ class ActionViewItemService implements IActionViewItemService {
4451

4552
declare _serviceBrand: undefined;
4653

47-
private readonly _providers = new Map<string, IActionViewItemProvider>();
54+
private readonly _providers = new Map<string, IActionViewItemFactory>();
4855

4956
private readonly _onDidChange = new Emitter<MenuId>();
5057
readonly onDidChange: Event<MenuId> = this._onDidChange.event;
@@ -53,7 +60,7 @@ class ActionViewItemService implements IActionViewItemService {
5360
this._onDidChange.dispose();
5461
}
5562

56-
register(menu: MenuId, commandOrSubmenuId: string | MenuId, provider: IActionViewItemProvider, event?: Event<unknown>): IDisposable {
63+
register(menu: MenuId, commandOrSubmenuId: string | MenuId, provider: IActionViewItemFactory, event?: Event<unknown>): IDisposable {
5764
const id = this._makeKey(menu, commandOrSubmenuId);
5865
if (this._providers.has(id)) {
5966
throw new Error(`A provider for the command ${commandOrSubmenuId} and menu ${menu} is already registered.`);
@@ -70,7 +77,7 @@ class ActionViewItemService implements IActionViewItemService {
7077
});
7178
}
7279

73-
lookUp(menu: MenuId, commandOrMenuId: string | MenuId): IActionViewItemProvider | undefined {
80+
lookUp(menu: MenuId, commandOrMenuId: string | MenuId): IActionViewItemFactory | undefined {
7481
return this._providers.get(this._makeKey(menu, commandOrMenuId));
7582
}
7683

src/vs/platform/actions/browser/toolbar.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ export class MenuWorkbenchToolBar extends WorkbenchToolBar {
358358
if (!provider) {
359359
provider = options?.actionViewItemProvider;
360360
}
361-
const viewItem = provider?.(action, opts);
361+
const viewItem = provider?.(action, opts, instaService);
362362
if (viewItem) {
363363
return viewItem;
364364
}

src/vs/platform/observable/common/platformObservableUtils.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';
7-
import { derivedOpts, IObservable, IReader, observableFromEventOpts } from '../../../base/common/observable.js';
7+
import { derivedOpts, IObservable, IReader, observableFromEvent, observableFromEventOpts } from '../../../base/common/observable.js';
88
import { IConfigurationService } from '../../configuration/common/configuration.js';
99
import { ContextKeyValue, IContextKeyService, RawContextKey } from '../../contextkey/common/contextkey.js';
1010

@@ -40,3 +40,7 @@ export function bindContextKey<T extends ContextKeyValue>(key: RawContextKey<T>,
4040
return compute_$show2FramesUp();
4141
}
4242

43+
44+
export function observableContextKey<T>(key: string, contextKeyService: IContextKeyService): IObservable<T | undefined> {
45+
return observableFromEvent(contextKeyService.onDidChangeContext, () => contextKeyService.getContextKeyValue<T>(key));
46+
}

src/vs/platform/quickinput/common/quickInput.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,16 @@ export interface IQuickTree<T extends IQuickTreeItem> extends IQuickInput {
10161016
*/
10171017
activeItems: ReadonlyArray<T>;
10181018

1019+
/**
1020+
* The validation message for the quick pick. This is rendered below the input.
1021+
*/
1022+
validationMessage: string | undefined;
1023+
1024+
/**
1025+
* The severity of the validation message.
1026+
*/
1027+
severity: Severity;
1028+
10191029
/**
10201030
* The items currently displayed in the quick tree.
10211031
* @note modifications to this array directly will not cause updates.

src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,21 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import { $ } from '../../../../../base/browser/dom.js';
67
import { Codicon } from '../../../../../base/common/codicons.js';
78
import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js';
9+
import { markAsSingleton } from '../../../../../base/common/lifecycle.js';
10+
import { ThemeIcon } from '../../../../../base/common/themables.js';
811
import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js';
912
import { localize, localize2 } from '../../../../../nls.js';
10-
import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js';
13+
import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js';
14+
import { MenuEntryActionViewItem } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js';
15+
import { Action2, MenuId, MenuItemAction, registerAction2 } from '../../../../../platform/actions/common/actions.js';
1116
import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';
1217
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
1318
import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js';
1419
import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';
20+
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js';
1521
import { ChatContextKeys } from '../../common/chatContextKeys.js';
1622
import { IChatToolInvocation } from '../../common/chatService.js';
1723
import { isResponseVM } from '../../common/chatViewModel.js';
@@ -71,10 +77,11 @@ class AcceptToolConfirmation extends Action2 {
7177
}
7278

7379
class ConfigureToolsAction extends Action2 {
80+
public static ID = 'workbench.action.chat.configureTools';
7481

7582
constructor() {
7683
super({
77-
id: 'workbench.action.chat.configureTools',
84+
id: ConfigureToolsAction.ID,
7885
title: localize('label', "Configure Tools..."),
7986
icon: Codicon.tools,
8087
f1: false,
@@ -153,7 +160,79 @@ class ConfigureToolsAction extends Action2 {
153160
}
154161
}
155162

163+
class ConfigureToolsActionRendering implements IWorkbenchContribution {
164+
165+
static readonly ID = 'chat.configureToolsActionRendering';
166+
167+
constructor(
168+
@IActionViewItemService actionViewItemService: IActionViewItemService,
169+
) {
170+
const disposable = actionViewItemService.register(MenuId.ChatExecute, ConfigureToolsAction.ID, (action, _opts, instantiationService) => {
171+
if (!(action instanceof MenuItemAction)) {
172+
return undefined;
173+
}
174+
return instantiationService.createInstance(class extends MenuEntryActionViewItem {
175+
private warningElement!: HTMLElement;
176+
177+
override render(container: HTMLElement): void {
178+
super.render(container);
179+
180+
// Add warning indicator element
181+
this.warningElement = $(`.tool-warning-indicator${ThemeIcon.asCSSSelector(Codicon.warning)}`);
182+
this.warningElement.style.display = 'none';
183+
container.appendChild(this.warningElement);
184+
container.style.position = 'relative';
185+
186+
// Set up context key listeners
187+
this.updateWarningState();
188+
this._register(this._contextKeyService.onDidChangeContext(() => {
189+
this.updateWarningState();
190+
}));
191+
}
192+
193+
private updateWarningState(): void {
194+
const wasShown = this.warningElement.style.display === 'block';
195+
const shouldBeShown = this.isAboveToolLimit();
196+
197+
if (!wasShown && shouldBeShown) {
198+
this.warningElement.style.display = 'block';
199+
this.updateTooltip();
200+
} else if (wasShown && !shouldBeShown) {
201+
this.warningElement.style.display = 'none';
202+
this.updateTooltip();
203+
}
204+
}
205+
206+
protected override getTooltip(): string {
207+
if (this.isAboveToolLimit()) {
208+
const warningMessage = localize('chatTools.tooManyEnabled', 'More than {0} tools are enabled, you may experience degraded tool calling.', this._contextKeyService.getContextKeyValue(ChatContextKeys.chatToolGroupingThreshold.key));
209+
return `${warningMessage}`;
210+
}
211+
212+
return super.getTooltip();
213+
}
214+
215+
private isAboveToolLimit() {
216+
const rawToolLimit = this._contextKeyService.getContextKeyValue(ChatContextKeys.chatToolGroupingThreshold.key);
217+
const rawToolCount = this._contextKeyService.getContextKeyValue(ChatContextKeys.chatToolCount.key);
218+
if (rawToolLimit === undefined || rawToolCount === undefined) {
219+
return false;
220+
}
221+
222+
const toolLimit = Number(rawToolLimit || 0);
223+
const toolCount = Number(rawToolCount || 0);
224+
return toolCount > toolLimit;
225+
}
226+
}, action, undefined);
227+
});
228+
229+
// Reduces flicker a bit on reload/restart
230+
markAsSingleton(disposable);
231+
}
232+
}
233+
156234
export function registerChatToolActions() {
157235
registerAction2(AcceptToolConfirmation);
158236
registerAction2(ConfigureToolsAction);
237+
registerWorkbenchContribution2(ConfigureToolsActionRendering.ID, ConfigureToolsActionRendering, WorkbenchPhase.BlockRestore);
159238
}

src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js';
2424
import { IMcpServer, IMcpService, IMcpWorkbenchService, McpConnectionState, McpServerEditorTab } from '../../../mcp/common/mcpTypes.js';
2525
import { ILanguageModelToolsService, IToolData, ToolDataSource, ToolSet } from '../../common/languageModelToolsService.js';
2626
import { ConfigureToolSets } from '../tools/toolSetsContribution.js';
27+
import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
28+
import { ChatContextKeys } from '../../common/chatContextKeys.js';
29+
import { Iterable } from '../../../../../base/common/iterator.js';
30+
import Severity from '../../../../../base/common/severity.js';
2731

2832
/**
2933
* Chat Tools Picker - Dual Implementation
@@ -207,6 +211,7 @@ async function showToolsPickerTree(
207211
const editorService = accessor.get(IEditorService);
208212
const mcpWorkbenchService = accessor.get(IMcpWorkbenchService);
209213
const toolsService = accessor.get(ILanguageModelToolsService);
214+
const toolLimit = accessor.get(IContextKeyService).getContextKeyValue<number>(ChatContextKeys.chatToolGroupingThreshold.key);
210215

211216
const mcpServerByTool = new Map<string, IMcpServer>();
212217
for (const server of mcpService.servers.get()) {
@@ -423,6 +428,7 @@ async function showToolsPickerTree(
423428
const collectResults = () => {
424429
result.clear();
425430

431+
let count = 0;
426432
const traverse = (items: readonly AnyTreeItem[]) => {
427433
for (const item of items) {
428434
if (isBucketTreeItem(item)) {
@@ -442,13 +448,24 @@ async function showToolsPickerTree(
442448
}
443449
} else if (isToolTreeItem(item)) {
444450
const checked = typeof item.checked === 'boolean' ? item.checked : false;
451+
if (checked) { count++; }
445452
result.set(item.tool, checked);
446453
}
447454
}
448455
};
449456

450457
traverse(treeItems);
451458

459+
if (toolLimit) {
460+
if (count > toolLimit) {
461+
treePicker.severity = Severity.Warning;
462+
treePicker.validationMessage = localize('toolLimitExceeded', "{0} tools are enabled. You may experience degraded tool calling above {1} tools.", count, toolLimit);
463+
} else {
464+
treePicker.severity = Severity.Ignore;
465+
treePicker.validationMessage = undefined;
466+
}
467+
}
468+
452469
// Special MCP handling: MCP toolset is enabled only if all tools are enabled
453470
for (const item of toolsService.toolSets.get()) {
454471
if (item.source.type === 'mcp') {
@@ -457,6 +474,7 @@ async function showToolsPickerTree(
457474
}
458475
}
459476
};
477+
collectResults();
460478

461479
// Handle checkbox state changes
462480
store.add(treePicker.onDidChangeCheckedLeafItems(() => {
@@ -603,6 +621,7 @@ async function showToolsPickerLegacy(
603621
picked: false,
604622
};
605623

624+
const toolLimit = accessor.get(IContextKeyService).getContextKeyValue<number>(ChatContextKeys.chatToolGroupingThreshold.key);
606625
const addMcpPick: CallbackPick = { type: 'item', label: localize('addServer', "Add MCP Server..."), iconClass: ThemeIcon.asClassName(Codicon.add), pickable: false, run: () => commandService.executeCommand(McpCommandIds.AddConfiguration) };
607626
const configureToolSetsPick: CallbackPick = { type: 'item', label: localize('configToolSet', "Configure Tool Sets..."), iconClass: ThemeIcon.asClassName(Codicon.gear), pickable: false, run: () => commandService.executeCommand(ConfigureToolSets.ID) };
608627
const addExpPick: CallbackPick = { type: 'item', label: localize('addExtension', "Install Extension..."), iconClass: ThemeIcon.asClassName(Codicon.add), pickable: false, run: () => extensionsWorkbenchService.openSearch('@tag:language-model-tools') };
@@ -812,6 +831,7 @@ async function showToolsPickerLegacy(
812831
const items = picks.filter((p): p is AnyPick => p.type === 'item' && Boolean(p.picked));
813832
lastSelectedItems = new Set(items);
814833
picker.selectedItems = items;
834+
let count = 0;
815835

816836
result.clear();
817837
for (const item of picks) {
@@ -820,22 +840,36 @@ async function showToolsPickerLegacy(
820840
}
821841
if (isToolSetPick(item)) {
822842
result.set(item.toolset, item.picked);
843+
count += Iterable.length(item.toolset.getTools());
823844
} else if (isToolPick(item)) {
824845
result.set(item.tool, item.picked);
846+
count++;
825847
} else if (isBucketPick(item)) {
826848
if (item.toolset) {
827849
result.set(item.toolset, item.picked);
828850
}
829851
for (const child of item.children) {
830852
if (isToolSetPick(child)) {
831853
result.set(child.toolset, item.picked);
854+
count += Iterable.length(child.toolset.getTools());
832855
} else if (isToolPick(child)) {
833856
result.set(child.tool, item.picked);
857+
count++;
834858
}
835859
}
836860
}
837861
}
838862

863+
if (toolLimit) {
864+
if (count > toolLimit) {
865+
picker.severity = Severity.Warning;
866+
picker.validationMessage = localize('toolLimitExceeded', "{0} tools are enabled. You may experience degraded tool calling above {1} tools.", count, toolLimit);
867+
} else {
868+
picker.severity = Severity.Ignore;
869+
picker.validationMessage = undefined;
870+
}
871+
}
872+
839873
if (onUpdate) {
840874
let didChange = toolsEntries.size !== result.size;
841875
for (const [key, value] of toolsEntries) {

src/vs/workbench/contrib/chat/browser/chatInputPart.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,16 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
423423
this.inputEditorHasFocus = ChatContextKeys.inputHasFocus.bindTo(contextKeyService);
424424
this.promptFileAttached = ChatContextKeys.hasPromptFile.bindTo(contextKeyService);
425425
this.chatModeKindKey = ChatContextKeys.chatModeKind.bindTo(contextKeyService);
426+
const chatToolCount = ChatContextKeys.chatToolCount.bindTo(contextKeyService);
427+
428+
this._register(autorun(reader => {
429+
let count = 0;
430+
for (const enabled of this.selectedToolsModel.enablementMap.read(reader).values()) {
431+
if (enabled) { count++; }
432+
}
433+
434+
chatToolCount.set(count);
435+
}));
426436

427437
this.history = this.loadHistory();
428438
this._register(this.historyService.onDidClearHistory(() => this.history = new HistoryNavigator2<IChatHistoryEntry>([{ text: '', state: this.getInputState() }], ChatInputHistoryMaxEntries, historyKeyFn)));

src/vs/workbench/contrib/chat/browser/chatSelectedTools.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ export class ChatSelectedTools extends Disposable {
6868
key: 'chat/selectedTools',
6969
});
7070

71-
7271
this._selectedTools = this._store.add(storedTools(StorageScope.WORKSPACE, StorageTarget.MACHINE, _storageService));
7372
this._allTools = observableFromEvent(_toolsService.onDidChangeTools, () => Array.from(_toolsService.getTools()));
73+
7474
}
7575

7676
/**

0 commit comments

Comments
 (0)