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 { }