From 7c249057557d6ff89254ae3f37c2124d21cd4e48 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:49:02 -0700 Subject: [PATCH] bypass approvals and toolbar in cli (#300228) * bypass approvals and toolbar in cli * new picker in new chat state for sessions --- .../chat/browser/newChatPermissionPicker.ts | 201 ++++++++++++++++++ .../contrib/chat/browser/newChatViewPane.ts | 11 +- .../browser/sessionsManagementService.ts | 14 +- .../browser/actions/chatExecuteActions.ts | 21 +- .../contrib/chat/browser/widget/chatWidget.ts | 4 + .../browser/widget/input/chatInputPart.ts | 2 +- .../input/permissionPickerActionItem.ts | 3 +- .../chat/common/actions/chatContextKeys.ts | 1 + 8 files changed, 236 insertions(+), 21 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts diff --git a/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts b/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts new file mode 100644 index 00000000000..b5c8ee1730a --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts @@ -0,0 +1,201 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { ChatConfiguration, ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; +import Severity from '../../../../base/common/severity.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; + +// Track whether warnings have been shown this VS Code session +const shownWarnings = new Set(); + +interface IPermissionItem { + readonly level: ChatPermissionLevel; + readonly label: string; + readonly icon: ThemeIcon; + readonly checked: boolean; +} + +/** + * A permission picker for the new-session welcome view. + * Shows Default Approvals and Bypass Approvals options (no Autopilot for CLI sessions). + */ +export class NewChatPermissionPicker extends Disposable { + + private readonly _onDidChangeLevel = this._register(new Emitter()); + readonly onDidChangeLevel: Event = this._onDidChangeLevel.event; + + private _currentLevel: ChatPermissionLevel = ChatPermissionLevel.Default; + private _triggerElement: HTMLElement | undefined; + private _container: HTMLElement | undefined; + private readonly _renderDisposables = this._register(new DisposableStore()); + + get permissionLevel(): ChatPermissionLevel { + return this._currentLevel; + } + + constructor( + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IDialogService private readonly dialogService: IDialogService, + ) { + super(); + } + + render(container: HTMLElement): HTMLElement { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + this._container = slot; + this._renderDisposables.add({ dispose: () => slot.remove() }); + + const trigger = dom.append(slot, dom.$('a.action-label')); + trigger.tabIndex = 0; + trigger.role = 'button'; + this._triggerElement = trigger; + + this._updateTriggerLabel(trigger); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => { + dom.EventHelper.stop(e, true); + this.showPicker(); + })); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this.showPicker(); + } + })); + + return slot; + } + + setVisible(visible: boolean): void { + if (this._container) { + this._container.style.display = visible ? '' : 'none'; + } + } + + showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible) { + return; + } + + const policyRestricted = this.configurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false; + + const items: IActionListItem[] = [ + { + kind: ActionListItemKind.Action, + group: { kind: ActionListItemKind.Header, title: '', icon: Codicon.shield }, + item: { + level: ChatPermissionLevel.Default, + label: localize('permissions.default', "Default Approvals"), + icon: Codicon.shield, + checked: this._currentLevel === ChatPermissionLevel.Default, + }, + label: localize('permissions.default', "Default Approvals"), + description: localize('permissions.default.subtext', "Copilot uses your configured settings"), + disabled: false, + }, + { + kind: ActionListItemKind.Action, + group: { kind: ActionListItemKind.Header, title: '', icon: Codicon.warning }, + item: { + level: ChatPermissionLevel.AutoApprove, + label: localize('permissions.autoApprove', "Bypass Approvals"), + icon: Codicon.warning, + checked: this._currentLevel === ChatPermissionLevel.AutoApprove, + }, + label: localize('permissions.autoApprove', "Bypass Approvals"), + description: localize('permissions.autoApprove.subtext', "All tool calls are auto-approved"), + disabled: policyRestricted, + }, + ]; + + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: async (item) => { + this.actionWidgetService.hide(); + await this._selectLevel(item.level); + }, + onHide: () => { triggerElement.focus(); }, + }; + + this.actionWidgetService.show( + 'permissionPicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], + { + getAriaLabel: (item) => item.label ?? '', + getWidgetAriaLabel: () => localize('permissionPicker.ariaLabel', "Permission Picker"), + }, + ); + } + + private async _selectLevel(level: ChatPermissionLevel): Promise { + if (level === ChatPermissionLevel.AutoApprove && !shownWarnings.has(ChatPermissionLevel.AutoApprove)) { + const result = await this.dialogService.prompt({ + type: Severity.Warning, + message: localize('permissions.autoApprove.warning.title', "Enable Bypass Approvals?"), + buttons: [ + { + label: localize('permissions.autoApprove.warning.confirm', "Enable"), + run: () => true + }, + { + label: localize('permissions.autoApprove.warning.cancel', "Cancel"), + run: () => false + }, + ], + custom: { + icon: Codicon.warning, + markdownDetails: [{ + markdown: new MarkdownString(localize('permissions.autoApprove.warning.detail', "Bypass Approvals will auto-approve all tool calls without asking for confirmation. This includes file edits, terminal commands, and external tool calls.")), + }], + }, + }); + if (result.result !== true) { + return; + } + shownWarnings.add(ChatPermissionLevel.AutoApprove); + } + + this._currentLevel = level; + this._updateTriggerLabel(this._triggerElement); + this._onDidChangeLevel.fire(level); + } + + private _updateTriggerLabel(trigger: HTMLElement | undefined): void { + if (!trigger) { + return; + } + + dom.clearNode(trigger); + const icon = this._currentLevel === ChatPermissionLevel.AutoApprove ? Codicon.warning : Codicon.shield; + const label = this._currentLevel === ChatPermissionLevel.AutoApprove + ? localize('permissions.autoApprove.label', "Bypass Approvals") + : localize('permissions.default.label', "Default Approvals"); + + dom.append(trigger, renderIcon(icon)); + const labelSpan = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = label; + dom.append(trigger, renderIcon(Codicon.chevronDown)); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 274cf54a4f5..f4b76e4ffac 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -70,6 +70,7 @@ import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/co import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; import { ChatHistoryNavigator } from '../../../../workbench/contrib/chat/common/widget/chatWidgetHistoryService.js'; import { IHistoryNavigationWidget } from '../../../../base/browser/history.js'; +import { NewChatPermissionPicker } from './newChatPermissionPicker.js'; import { registerAndCreateHistoryNavigationContext, IHistoryNavigationContext } from '../../../../platform/history/browser/contextScopedHistoryWidget.js'; const STORAGE_KEY_DRAFT_STATE = 'sessions.draftState'; @@ -147,6 +148,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { private _inputSlot: HTMLElement | undefined; private readonly _folderPicker: FolderPicker; private _folderPickerContainer: HTMLElement | undefined; + private readonly _permissionPicker: NewChatPermissionPicker; private readonly _repoPicker: RepoPicker; private _repoPickerContainer: HTMLElement | undefined; private readonly _cloudModelPicker: CloudModelPicker; @@ -194,6 +196,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._history = this._register(this.instantiationService.createInstance(ChatHistoryNavigator, ChatAgentLocation.Chat)); this._contextAttachments = this._register(this.instantiationService.createInstance(NewChatContextAttachments)); this._folderPicker = this._register(this.instantiationService.createInstance(FolderPicker)); + this._permissionPicker = this._register(this.instantiationService.createInstance(NewChatPermissionPicker)); this._repoPicker = this._register(this.instantiationService.createInstance(RepoPicker)); this._cloudModelPicker = this._register(this.instantiationService.createInstance(CloudModelPicker)); this._targetPicker = this._register(new SessionTargetPicker(options.allowedTargets, this._resolveDefaultTarget(options))); @@ -207,6 +210,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._createNewSession(); const isLocal = target === AgentSessionProviders.Background; this._isolationModePicker.setVisible(isLocal); + this._permissionPicker.setVisible(isLocal); this._branchPicker.setVisible(isLocal); this._syncIndicator.setVisible(isLocal); this._updateDraftState(); @@ -305,6 +309,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { const isolationContainer = dom.append(welcomeElement, dom.$('.chat-full-welcome-local-mode')); this._isolationModePicker.render(isolationContainer); dom.append(isolationContainer, dom.$('.sessions-chat-local-mode-spacer')); + this._permissionPicker.render(isolationContainer); const branchContainer = dom.append(isolationContainer, dom.$('.sessions-chat-local-mode-right')); this._branchPicker.render(branchContainer); this._syncIndicator.render(branchContainer); @@ -313,6 +318,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { const isLocal = this._targetPicker.selectedTarget === AgentSessionProviders.Background; const isWorktree = this._isolationModePicker.isolationMode === 'worktree'; this._isolationModePicker.setVisible(isLocal); + this._permissionPicker.setVisible(isLocal); this._branchPicker.setVisible(isLocal && isWorktree); this._syncIndicator.setVisible(isLocal && isWorktree); @@ -1021,7 +1027,10 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { try { await this.sessionsManagementService.sendRequestForNewSession( session.resource, - options?.openNewAfterSend ? { openNewSessionView: true } : undefined + { + ...options?.openNewAfterSend ? { openNewSessionView: true } : {}, + permissionLevel: this._permissionPicker.permissionLevel, + } ); this._newSessionListener.clear(); this._contextAttachments.clear(); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index be6b61aa821..8f365a6eb87 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -16,7 +16,7 @@ import { ISessionOpenOptions, openSession as openSessionDefault } from '../../.. import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IChatService, IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; -import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; +import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; import { IAgentSession, isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; @@ -92,7 +92,7 @@ export interface ISessionsManagementService { * When `openNewSessionView` is true, opens a new session view after sending * instead of navigating to the newly created session. */ - sendRequestForNewSession(sessionResource: URI, options?: { openNewSessionView?: boolean }): Promise; + sendRequestForNewSession(sessionResource: URI, options?: { openNewSessionView?: boolean; permissionLevel?: ChatPermissionLevel }): Promise; /** * Commit files in a worktree and refresh the agent sessions model @@ -306,7 +306,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this.logService.info(`[ActiveSessionService] Active session changed (new): ${sessionResource.toString()}, repository: ${repository?.toString() ?? 'none'}`); } - async sendRequestForNewSession(sessionResource: URI, options?: { openNewSessionView?: boolean }): Promise { + async sendRequestForNewSession(sessionResource: URI, options?: { openNewSessionView?: boolean; permissionLevel?: ChatPermissionLevel }): Promise { const session = this._newSession.value; if (!session) { this.logService.error(`[SessionsManagementService] No new session found for resource: ${sessionResource.toString()}`); @@ -334,6 +334,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa modeInstructions: undefined, modeId: 'agent', applyCodeBlockSuggestionId: undefined, + permissionLevel: options?.permissionLevel ?? ChatPermissionLevel.Default, }, agentIdSilent: contribution?.type, attachedContext: session.attachedContext, @@ -353,6 +354,13 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this.openNewSessionView(); } + // Sync the permission level from the welcome picker to the ChatWidget's input part + const permissionLevel = sendOptions.modeInfo?.permissionLevel; + if (permissionLevel) { + const chatWidget = this.chatWidgetService.getWidgetBySessionResource(session.resource); + chatWidget?.input.setPermissionLevel(permissionLevel); + } + // 2. Apply selected model and options to the session const modelRef = this.chatService.acquireExistingSession(session.resource); if (modelRef) { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 26f26818255..c49e5c69c32 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -475,8 +475,10 @@ export class OpenPermissionPickerAction extends Action2 { ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), ChatContextKeys.chatModeKind.notEqualsTo(ChatModeKind.Ask), ChatContextKeys.inQuickChat.negate(), - ChatContextKeys.lockedToCodingAgent.negate(), - IsSessionsWindowContext.negate(), + ContextKeyExpr.or( + ChatContextKeys.lockedToCodingAgent.negate(), + ChatContextKeys.lockedCodingAgentId.isEqualTo(AgentSessionProviders.Background), + ), ) } }); @@ -599,17 +601,6 @@ export class OpenDelegationPickerAction extends Action2 { f1: false, precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.chatSessionIsEmpty.negate(), ChatContextKeys.currentlyEditingInput.negate(), ChatContextKeys.currentlyEditing.negate()), menu: [ - { - id: MenuId.ChatInput, - order: 0.5, - when: ContextKeyExpr.and( - ChatContextKeys.enabled, - ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), - ChatContextKeys.inQuickChat.negate(), - ChatContextKeys.chatSessionIsEmpty.negate(), - IsSessionsWindowContext), - group: 'navigation', - }, { id: MenuId.ChatInputSecondary, order: 0.5, @@ -617,8 +608,8 @@ export class OpenDelegationPickerAction extends Action2 { ChatContextKeys.enabled, ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), ChatContextKeys.inQuickChat.negate(), - ChatContextKeys.chatSessionIsEmpty.negate(), - IsSessionsWindowContext.negate()), + ChatContextKeys.chatSessionIsEmpty.negate() + ), group: 'navigation', }, ] diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 0cae54e9130..88cf0e53f6f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -286,6 +286,7 @@ export class ChatWidget extends Disposable implements IChatWidget { displayName: string; }; private readonly _lockedToCodingAgentContextKey: IContextKey; + private readonly _lockedCodingAgentIdContextKey: IContextKey; private readonly _agentSupportsAttachmentsContextKey: IContextKey; private readonly _sessionIsEmptyContextKey: IContextKey; private readonly _hasPendingRequestsContextKey: IContextKey; @@ -400,6 +401,7 @@ export class ChatWidget extends Disposable implements IChatWidget { super(); this._lockedToCodingAgentContextKey = ChatContextKeys.lockedToCodingAgent.bindTo(this.contextKeyService); + this._lockedCodingAgentIdContextKey = ChatContextKeys.lockedCodingAgentId.bindTo(this.contextKeyService); this._agentSupportsAttachmentsContextKey = ChatContextKeys.agentSupportsAttachments.bindTo(this.contextKeyService); this._sessionIsEmptyContextKey = ChatContextKeys.chatSessionIsEmpty.bindTo(this.contextKeyService); this._hasPendingRequestsContextKey = ChatContextKeys.hasPendingRequests.bindTo(this.contextKeyService); @@ -2095,6 +2097,7 @@ export class ChatWidget extends Disposable implements IChatWidget { displayName }; this._lockedToCodingAgentContextKey.set(true); + this._lockedCodingAgentIdContextKey.set(agentId); this.renderWelcomeViewContentIfNeeded(); // Update capabilities for the locked agent const agent = this.chatAgentService.getAgent(agentId); @@ -2109,6 +2112,7 @@ export class ChatWidget extends Disposable implements IChatWidget { // Clear all state related to locking this._lockedAgent = undefined; this._lockedToCodingAgentContextKey.set(false); + this._lockedCodingAgentIdContextKey.set(''); this._updateAgentCapabilitiesContextKeys(undefined); // Explicitly update the DOM to reflect unlocked state diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 206e6f5d4b8..cf7862b0d25 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -2026,7 +2026,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.attachedContextContainer = elements.attachedContextContainer; const toolbarsContainer = elements.inputToolbars; this.secondaryToolbarContainer = elements.secondaryToolbar; - if (this.options.isSessionsWindow || this.options.renderStyle === 'compact') { + if (this.options.renderStyle === 'compact') { this.secondaryToolbarContainer.style.display = 'none'; } this.chatEditingSessionWidgetContainer = elements.chatEditingSessionWidgetContainer; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts index 46161115022..b3c674193bc 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts @@ -57,6 +57,7 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { ) { const isAutoApprovePolicyRestricted = () => configurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false; const isAutopilotEnabled = () => configurationService.getValue(ChatConfiguration.AutopilotEnabled) !== false; + const isBackgroundProvider = contextKeyService.getContextKeyValue('lockedCodingAgentId') === 'copilotcli'; const actionProvider: IActionWidgetDropdownActionProvider = { getActions: () => { const currentLevel = delegate.currentPermissionLevel.get(); @@ -130,7 +131,7 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { }, } satisfies IActionWidgetDropdownAction, ]; - if (isAutopilotEnabled()) { + if (isAutopilotEnabled() && !isBackgroundProvider) { actions.push({ ...action, id: 'chat.permissions.autopilot', diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 7b5018b7f44..b249c635261 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -58,6 +58,7 @@ export namespace ChatContextKeys { * True when the chat widget is locked to the coding agent session. */ export const lockedToCodingAgent = new RawContextKey('lockedToCodingAgent', false, { type: 'boolean', description: localize('lockedToCodingAgent', "True when the chat widget is locked to the coding agent session.") }); + export const lockedCodingAgentId = new RawContextKey('lockedCodingAgentId', '', { type: 'string', description: localize('lockedCodingAgentId', "The agent ID when the chat widget is locked to a coding agent session.") }); /** * True when the chat session has a customAgentTarget defined in its contribution, * which means the mode picker should be shown with filtered custom agents.