Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/vs/platform/quickinput/browser/tree/quickTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ export class QuickTree<T extends IQuickTreeItem> 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 = {
Expand Down
6 changes: 6 additions & 0 deletions src/vs/platform/quickinput/common/quickInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1172,6 +1172,12 @@ export interface IQuickTree<T extends IQuickTreeItem> 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.
Expand Down
40 changes: 38 additions & 2 deletions src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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

Expand Down Expand Up @@ -77,6 +78,7 @@ interface IToolSetTreeItem extends IToolTreeItem {
interface IToolTreeItemData extends IToolTreeItem {
readonly itemType: 'tool';
readonly tool: IToolData;
buttons?: ActionableButton[];
checked: boolean;
}

Expand Down Expand Up @@ -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<string, IMcpServer>();
Expand Down Expand Up @@ -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 {
Expand All @@ -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();
}
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
});
}
Expand Down Expand Up @@ -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);
}
}));

Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<readonly string[]>({
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;
Expand All @@ -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<ResourceSet>();
/** Cache of path URI -> resolved git root URI (or null if not in a repo) */
private readonly _gitRootCache = new ResourceMap<URI | null>();
private readonly _workspaceAllowlist?: ObservableMemento<readonly string[]>;

constructor(
private readonly _getPathInfo: (ref: ILanguageModelToolConfirmationRef) => IExternalPathInfo | undefined,
private readonly _labelService: ILabelService,
private readonly _findGitRoot?: (pathUri: URI) => Promise<URI | undefined>,
) { }
storageService?: IStorageService,
private readonly _pickFolder?: () => Promise<URI | undefined>,
) {
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;
}

Expand All @@ -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;
}

Expand Down Expand Up @@ -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);
}
}
}
Loading
Loading