From 7f27499da41e6226f43c039e0c37a5dd5c4581ac Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 3 Mar 2026 10:56:35 -0800 Subject: [PATCH] chat: add approval management UI to tool picker Adds support for managing tool confirmation preferences directly from the tool picker. This allows users to approve tools and external paths at the workspace level, reducing friction when using tools that require confirmation. - Adds 'Manage Approval' button to tools that support confirmation - Integrates ILanguageModelToolsConfirmationService with tool picker - Adds workspace-level allowlist persistence for external paths - Extends ActionableButton type to support keepOpen behavior - Implements workspace folder selection and allowlist management - Adds ObservableMemento for persistent storage of approved paths Fixes https://github.com/microsoft/vscode-internalbacklog/issues/6805 --- .../quickinput/browser/tree/quickTree.ts | 5 + .../platform/quickinput/common/quickInput.ts | 6 + .../chat/browser/actions/chatToolPicker.ts | 40 ++++- .../languageModelToolsConfirmationService.ts | 41 ++++- .../chatExternalPathConfirmation.ts | 159 ++++++++++++++++-- .../languageModelToolsConfirmationService.ts | 13 +- .../electron-browser/builtInTools/tools.ts | 17 ++ ...ckLanguageModelToolsConfirmationService.ts | 5 +- 8 files changed, 263 insertions(+), 23 deletions(-) diff --git a/src/vs/platform/quickinput/browser/tree/quickTree.ts b/src/vs/platform/quickinput/browser/tree/quickTree.ts index e506021a05892..3c9a614694866 100644 --- a/src/vs/platform/quickinput/browser/tree/quickTree.ts +++ b/src/vs/platform/quickinput/browser/tree/quickTree.ts @@ -104,6 +104,11 @@ export class QuickTree extends QuickInput implements I this.ui.inputBox.setFocus(); } + reveal(element: T): void { + this.ui.tree.tree.reveal(element); + this.ui.tree.tree.setFocus([element]); + } + override show() { if (!this.visible) { const visibilities: Visibilities = { diff --git a/src/vs/platform/quickinput/common/quickInput.ts b/src/vs/platform/quickinput/common/quickInput.ts index 9426be48e2f4a..04d91e66aa488 100644 --- a/src/vs/platform/quickinput/common/quickInput.ts +++ b/src/vs/platform/quickinput/common/quickInput.ts @@ -1172,6 +1172,12 @@ export interface IQuickTree extends IQuickInput { */ focusOnInput(): void; + /** + * Reveals and focuses a specific item in the tree. + * @param element The item to reveal and focus. + */ + reveal(element: T): void; + /** * Focus a particular item in the list. Used internally for keyboard navigation. * @param focus The focus behavior. diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts index 91e60f599dee2..7d8e0512eafb1 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts @@ -22,6 +22,7 @@ import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js'; import { IMcpServer, IMcpService, IMcpWorkbenchService, McpConnectionState, McpServerCacheState, McpServerEditorTab } from '../../../mcp/common/mcpTypes.js'; import { startServerAndWaitForLiveTools } from '../../../mcp/common/mcpTypesUtils.js'; import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; +import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; import { ILanguageModelToolsService, IToolData, IToolSet, ToolDataSource, ToolSet } from '../../common/tools/languageModelToolsService.js'; import { ConfigureToolSets } from '../tools/toolSetsContribution.js'; @@ -31,7 +32,7 @@ const enum BucketOrdinal { User, BuiltIn, Mcp, Extension } type BucketPick = IQuickPickItem & { picked: boolean; ordinal: BucketOrdinal; status?: string; toolset?: ToolSet; children: (ToolPick | ToolSetPick)[] }; type ToolSetPick = IQuickPickItem & { picked: boolean; toolset: ToolSet; parent: BucketPick }; type ToolPick = IQuickPickItem & { picked: boolean; tool: IToolData; parent: BucketPick }; -type ActionableButton = IQuickInputButton & { action: () => void }; +type ActionableButton = IQuickInputButton & { action: () => void; keepOpen?: boolean }; // New QuickTree types for tree-based implementation @@ -77,6 +78,7 @@ interface IToolSetTreeItem extends IToolTreeItem { interface IToolTreeItemData extends IToolTreeItem { readonly itemType: 'tool'; readonly tool: IToolData; + buttons?: ActionableButton[]; checked: boolean; } @@ -205,6 +207,7 @@ export async function showToolsPicker( const editorService = accessor.get(IEditorService); const mcpWorkbenchService = accessor.get(IMcpWorkbenchService); const toolsService = accessor.get(ILanguageModelToolsService); + const confirmationService = accessor.get(ILanguageModelToolsConfirmationService); const telemetryService = accessor.get(ITelemetryService); const mcpServerByTool = new Map(); @@ -451,6 +454,38 @@ export async function showToolsPicker( } } } + // Add approval management buttons to tool items that support confirmation + for (const bucket of sortedBuckets) { + const isMcpBucket = bucket.ordinal === BucketOrdinal.Mcp; + const addConfirmationButton = (toolItem: IToolTreeItemData) => { + if (!confirmationService.toolCanManageConfirmation(toolItem.tool)) { + return; + } + const tool = toolItem.tool; + const manageTools = isMcpBucket ? bucket.children.flatMap(c => isToolTreeItem(c) ? [c.tool] : isToolSetTreeItem(c) && c.children ? c.children.filter(isToolTreeItem).map(gc => gc.tool) : []) : [tool]; + const buttons: ActionableButton[] = toolItem.buttons ? [...toolItem.buttons] : []; + buttons.push({ + iconClass: ThemeIcon.asClassName(Codicon.pass), + tooltip: localize('manageToolApproval', "Manage Approval"), + keepOpen: true, + action: () => confirmationService.manageConfirmationPreferences(manageTools, { focusToolId: tool.id }) + }); + toolItem.buttons = buttons; + }; + + for (const child of bucket.children) { + if (isToolTreeItem(child)) { + addConfirmationButton(child); + } else if (isToolSetTreeItem(child) && child.children) { + for (const grandchild of child.children) { + if (isToolTreeItem(grandchild)) { + addConfirmationButton(grandchild); + } + } + } + } + } + if (treeItems.length === 0) { treePicker.placeholder = localize('noTools', "Add tools to chat"); } else { @@ -474,7 +509,8 @@ export async function showToolsPicker( // Handle button triggers store.add(treePicker.onDidTriggerItemButton(e => { if (e.button && typeof (e.button as ActionableButton).action === 'function') { - (e.button as ActionableButton).action(); + const actionableButton = e.button as ActionableButton; + actionableButton.action(); store.dispose(); } })); diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts index 50a3495c4f845..3661f5a27c379 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts @@ -424,7 +424,15 @@ export class LanguageModelToolsConfirmationService extends Disposable implements }; } - manageConfirmationPreferences(tools: readonly IToolData[], options?: { defaultScope?: 'workspace' | 'profile' | 'session' }): void { + toolCanManageConfirmation(tool: IToolData): boolean { + return !!tool.canRequestPreApproval + || !!tool.canRequestPostApproval + || this._contributions.has(tool.id) + || !!this._preExecutionToolConfirmStore.checkAutoConfirmation(tool.id) + || !!this._postExecutionToolConfirmStore.checkAutoConfirmation(tool.id); + } + + manageConfirmationPreferences(tools: readonly IToolData[], options?: { defaultScope?: 'workspace' | 'profile' | 'session'; focusToolId?: string }): void { interface IToolTreeItem extends IQuickTreeItem { type: 'tool' | 'server' | 'tool-pre' | 'tool-post' | 'server-pre' | 'server-post' | 'manage'; toolId?: string; @@ -690,7 +698,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements description, checked, pickable, - collapsed: true, + collapsed: tools.length > 1, children: toolChildren.length > 0 ? toolChildren : undefined }); } @@ -773,12 +781,12 @@ export class LanguageModelToolsConfirmationService extends Disposable implements } })); - disposables.add(quickTree.onDidAccept(() => { - for (const item of quickTree.activeItems) { - if (item.type === 'manage') { - (item as ILanguageModelToolConfirmationContributionQuickTreeItem).onDidOpen?.(); - quickTree.hide(); - } + disposables.add(quickTree.onDidAccept(async () => { + const manageItem = quickTree.activeItems.find(i => i.type === 'manage'); + if (manageItem) { + quickTree.hide(); + await (manageItem as ILanguageModelToolConfirmationContributionQuickTreeItem).onDidOpen?.(); + this.manageConfirmationPreferences(tools, options); } })); @@ -787,6 +795,23 @@ export class LanguageModelToolsConfirmationService extends Disposable implements })); quickTree.show(); + + // If a focus tool was specified, expand its parent and set it as active. + // Must happen after show() since the tree data is applied via autorun on visibility. + if (options?.focusToolId) { + const focusToolId = options.focusToolId; + for (const serverItem of quickTree.itemTree) { + const serverItemTyped = serverItem as IToolTreeItem; + if (serverItemTyped.children) { + const toolItem = (serverItemTyped.children as IToolTreeItem[]).find(c => c.type === 'tool' && c.toolId === focusToolId); + if (toolItem) { + quickTree.expand(serverItem); + quickTree.reveal(toolItem); + break; + } + } + } + } } public resetToolAutoConfirmation(): void { diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts index 9babb75b5bdb0..ee75a3bc056f5 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts @@ -3,17 +3,32 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../../base/common/map.js'; import { dirname, extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { ObservableMemento, observableMemento } from '../../../../../../platform/observable/common/observableMemento.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { ConfirmedReason, ToolConfirmKind } from '../../chatService/chatService.js'; import { ILanguageModelToolConfirmationActions, ILanguageModelToolConfirmationContribution, + ILanguageModelToolConfirmationContributionQuickTreeItem, ILanguageModelToolConfirmationRef } from '../languageModelToolsConfirmationService.js'; +const workspaceAllowlistMemento = observableMemento({ + key: 'chat.externalPath.workspaceAllowlist', + defaultValue: [], + toStorage: value => JSON.stringify(value), + fromStorage: value => { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + }, +}); + export interface IExternalPathInfo { path: string; isDirectory: boolean; @@ -24,26 +39,59 @@ export interface IExternalPathInfo { * accessing paths outside the workspace, with an option to allow all access * from a containing folder for the current chat session. */ -export class ChatExternalPathConfirmationContribution implements ILanguageModelToolConfirmationContribution { +export class ChatExternalPathConfirmationContribution implements ILanguageModelToolConfirmationContribution, IDisposable { readonly canUseDefaultApprovals = false; private readonly _sessionFolderAllowlist = new ResourceMap(); /** Cache of path URI -> resolved git root URI (or null if not in a repo) */ private readonly _gitRootCache = new ResourceMap(); + private readonly _workspaceAllowlist?: ObservableMemento; constructor( private readonly _getPathInfo: (ref: ILanguageModelToolConfirmationRef) => IExternalPathInfo | undefined, + private readonly _labelService: ILabelService, private readonly _findGitRoot?: (pathUri: URI) => Promise, - ) { } + storageService?: IStorageService, + private readonly _pickFolder?: () => Promise, + ) { + if (storageService) { + this._workspaceAllowlist = workspaceAllowlistMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE, storageService); + } + } - getPreConfirmAction(ref: ILanguageModelToolConfirmationRef): ConfirmedReason | undefined { - const pathInfo = this._getPathInfo(ref); - if (!pathInfo || !ref.chatSessionResource) { - return undefined; + dispose(): void { + this._workspaceAllowlist?.dispose(); + } + + private _getWorkspaceFolders(): ResourceSet { + if (!this._workspaceAllowlist) { + return new ResourceSet(); + } + const set = new ResourceSet(); + for (const s of this._workspaceAllowlist.get()) { + try { + set.add(URI.parse(s)); + } catch { + // ignore malformed URIs + } } + return set; + } + + private _setWorkspaceFolders(folders: ResourceSet): void { + if (!this._workspaceAllowlist) { + return; + } + const uriStrings: string[] = []; + for (const uri of folders) { + uriStrings.push(uri.toString()); + } + this._workspaceAllowlist.set(uriStrings, undefined); + } - const allowedFolders = this._sessionFolderAllowlist.get(ref.chatSessionResource); - if (!allowedFolders || allowedFolders.size === 0) { + getPreConfirmAction(ref: ILanguageModelToolConfirmationRef): ConfirmedReason | undefined { + const pathInfo = this._getPathInfo(ref); + if (!pathInfo) { return undefined; } @@ -55,13 +103,26 @@ export class ChatExternalPathConfirmationContribution implements ILanguageModelT return undefined; } - // Check if path is under any allowed folder - for (const folderUri of allowedFolders) { + // Check workspace-level allowlist + const workspaceFolders = this._getWorkspaceFolders(); + for (const folderUri of workspaceFolders) { if (extUriBiasedIgnorePathCase.isEqualOrParent(pathUri, folderUri)) { return { type: ToolConfirmKind.UserAction }; } } + // Check session-level allowlist + if (ref.chatSessionResource) { + const sessionFolders = this._sessionFolderAllowlist.get(ref.chatSessionResource); + if (sessionFolders) { + for (const folderUri of sessionFolders) { + if (extUriBiasedIgnorePathCase.isEqualOrParent(pathUri, folderUri)) { + return { type: ToolConfirmKind.UserAction }; + } + } + } + } + return undefined; } @@ -149,4 +210,82 @@ export class ChatExternalPathConfirmationContribution implements ILanguageModelT return actions; } + + getManageActions(): ILanguageModelToolConfirmationContributionQuickTreeItem[] { + const items: ILanguageModelToolConfirmationContributionQuickTreeItem[] = []; + + // Workspace-level entries (persisted) + const workspaceFolders = this._getWorkspaceFolders(); + for (const folderUri of workspaceFolders) { + items.push({ + label: this._labelService.getUriLabel(folderUri), + description: localize('workspaceScope', "Workspace"), + checked: true, + onDidChangeChecked: (checked) => { + if (!checked) { + workspaceFolders.delete(folderUri); + this._setWorkspaceFolders(workspaceFolders); + } else { + workspaceFolders.add(folderUri); + this._setWorkspaceFolders(workspaceFolders); + } + }, + }); + } + + // Session-level entries (ephemeral) + const allSessionFolders = new ResourceSet(); + for (const [, folders] of this._sessionFolderAllowlist) { + for (const folder of folders) { + allSessionFolders.add(folder); + } + } + for (const folderUri of allSessionFolders) { + const wasInSessions = [...this._sessionFolderAllowlist].filter(([, folders]) => folders.has(folderUri)); + items.push({ + label: this._labelService.getUriLabel(folderUri), + description: localize('sessionScope', "Session"), + checked: true, + onDidChangeChecked: (checked) => { + if (!checked) { + for (const [, folders] of wasInSessions) { + folders.delete(folderUri); + } + } else { + for (const [, folders] of wasInSessions) { + folders.add(folderUri); + } + } + }, + }); + } + + // "Add Path..." option to add a new workspace-level folder + if (this._pickFolder) { + const pickFolder = this._pickFolder; + items.push({ + pickable: false, + label: localize('addPath', "Add Path..."), + description: localize('addPathDescription', "Allow a folder in this workspace"), + onDidOpen: async () => { + const uri = await pickFolder(); + if (uri) { + const folders = this._getWorkspaceFolders(); + folders.add(uri); + this._setWorkspaceFolders(folders); + } + } + }); + } + + return items; + } + + reset(): void { + this._sessionFolderAllowlist.clear(); + this._gitRootCache.clear(); + if (this._workspaceAllowlist) { + this._workspaceAllowlist.set([], undefined); + } + } } diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts index d77bd07c0c0f5..2e3c66261b821 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts @@ -42,7 +42,7 @@ export interface ILanguageModelToolConfirmationActionProducer { export interface ILanguageModelToolConfirmationContributionQuickTreeItem extends IQuickTreeItem { onDidTriggerItemButton?(button: IQuickInputButton): void; onDidChangeChecked?(checked: boolean): void; - onDidOpen?(): void; + onDidOpen?(): void | Promise; } /** @@ -85,7 +85,7 @@ export interface ILanguageModelToolsConfirmationService extends ILanguageModelTo readonly _serviceBrand: undefined; /** Opens an IQuickTree to let the user manage their preferences. */ - manageConfirmationPreferences(tools: readonly IToolData[], options?: { defaultScope?: 'workspace' | 'profile' | 'session' }): void; + manageConfirmationPreferences(tools: readonly IToolData[], options?: { defaultScope?: 'workspace' | 'profile' | 'session'; focusToolId?: string }): void; /** * Registers a contribution that provides more specific confirmation logic @@ -93,6 +93,15 @@ export interface ILanguageModelToolsConfirmationService extends ILanguageModelTo */ registerConfirmationContribution(toolName: string, contribution: ILanguageModelToolConfirmationContribution): IDisposable; + /** + * Returns true if the tool has confirmation that can be managed, either + * because it has {@link IToolData.canRequestPreApproval} or + * {@link IToolData.canRequestPostApproval} set, because a + * {@link ILanguageModelToolConfirmationContribution} is registered for it, + * or because it has stored auto-confirmation settings. + */ + toolCanManageConfirmation(tool: IToolData): boolean; + /** Resets all tool and server confirmation preferences */ resetToolAutoConfirmation(): void; } diff --git a/src/vs/workbench/contrib/chat/electron-browser/builtInTools/tools.ts b/src/vs/workbench/contrib/chat/electron-browser/builtInTools/tools.ts index 3af7e138aeb54..1d1d822cc305a 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/builtInTools/tools.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/builtInTools/tools.ts @@ -6,8 +6,11 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { dirname, extUriBiasedIgnorePathCase } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { ChatExternalPathConfirmationContribution } from '../../common/tools/builtinTools/chatExternalPathConfirmation.js'; import { ChatUrlFetchingConfirmationContribution } from '../../common/tools/builtinTools/chatUrlFetchingConfirmation.js'; @@ -25,6 +28,9 @@ export class NativeBuiltinToolsContribution extends Disposable implements IWorkb @IInstantiationService instantiationService: IInstantiationService, @ILanguageModelToolsConfirmationService confirmationService: ILanguageModelToolsConfirmationService, @IFileService fileService: IFileService, + @IStorageService storageService: IStorageService, + @IFileDialogService fileDialogService: IFileDialogService, + @ILabelService labelService: ILabelService, ) { super(); @@ -53,6 +59,7 @@ export class NativeBuiltinToolsContribution extends Disposable implements IWorkb } return undefined; }, + labelService, async (pathUri: URI) => { // Walk up from the path looking for a .git folder to find the repository root let dir = dirname(pathUri); @@ -71,8 +78,18 @@ export class NativeBuiltinToolsContribution extends Disposable implements IWorkb dir = parent; } return undefined; + }, + storageService, + async () => { + const result = await fileDialogService.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + }); + return result?.[0]; } ); + this._register(externalPathConfirmation); this._register(confirmationService.registerConfirmationContribution( 'copilot_readFile', diff --git a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsConfirmationService.ts b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsConfirmationService.ts index e8d3da161cbd2..d3ce0affbf835 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsConfirmationService.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsConfirmationService.ts @@ -9,12 +9,15 @@ import { ILanguageModelToolConfirmationActions, ILanguageModelToolConfirmationCo import { IToolData } from '../../../common/tools/languageModelToolsService.js'; export class MockLanguageModelToolsConfirmationService implements ILanguageModelToolsConfirmationService { - manageConfirmationPreferences(tools: readonly IToolData[], options?: { defaultScope?: 'workspace' | 'profile' | 'session' }): void { + manageConfirmationPreferences(tools: readonly IToolData[], options?: { defaultScope?: 'workspace' | 'profile' | 'session'; focusToolId?: string }): void { throw new Error('Method not implemented.'); } registerConfirmationContribution(toolName: string, contribution: ILanguageModelToolConfirmationContribution): IDisposable { throw new Error('Method not implemented.'); } + toolCanManageConfirmation(): boolean { + return false; + } resetToolAutoConfirmation(): void { }