bypass approvals and toolbar in cli (#300228)

* bypass approvals and toolbar in cli

* new picker in new chat state for sessions
This commit is contained in:
Justin Chen
2026-03-09 17:49:02 -07:00
committed by GitHub
parent 975cdcf8fe
commit 7c24905755
8 changed files with 236 additions and 21 deletions

View File

@@ -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<ChatPermissionLevel>();
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<ChatPermissionLevel>());
readonly onDidChangeLevel: Event<ChatPermissionLevel> = 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<boolean>(ChatConfiguration.GlobalAutoApprove).policyValue === false;
const items: IActionListItem<IPermissionItem>[] = [
{
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<IPermissionItem> = {
onSelect: async (item) => {
this.actionWidgetService.hide();
await this._selectLevel(item.level);
},
onHide: () => { triggerElement.focus(); },
};
this.actionWidgetService.show<IPermissionItem>(
'permissionPicker',
false,
items,
delegate,
this._triggerElement,
undefined,
[],
{
getAriaLabel: (item) => item.label ?? '',
getWidgetAriaLabel: () => localize('permissionPicker.ariaLabel', "Permission Picker"),
},
);
}
private async _selectLevel(level: ChatPermissionLevel): Promise<void> {
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));
}
}

View File

@@ -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();

View File

@@ -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<void>;
sendRequestForNewSession(sessionResource: URI, options?: { openNewSessionView?: boolean; permissionLevel?: ChatPermissionLevel }): Promise<void>;
/**
* 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<void> {
async sendRequestForNewSession(sessionResource: URI, options?: { openNewSessionView?: boolean; permissionLevel?: ChatPermissionLevel }): Promise<void> {
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) {

View File

@@ -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',
},
]

View File

@@ -286,6 +286,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
displayName: string;
};
private readonly _lockedToCodingAgentContextKey: IContextKey<boolean>;
private readonly _lockedCodingAgentIdContextKey: IContextKey<string>;
private readonly _agentSupportsAttachmentsContextKey: IContextKey<boolean>;
private readonly _sessionIsEmptyContextKey: IContextKey<boolean>;
private readonly _hasPendingRequestsContextKey: IContextKey<boolean>;
@@ -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

View File

@@ -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;

View File

@@ -57,6 +57,7 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem {
) {
const isAutoApprovePolicyRestricted = () => configurationService.inspect<boolean>(ChatConfiguration.GlobalAutoApprove).policyValue === false;
const isAutopilotEnabled = () => configurationService.getValue<boolean>(ChatConfiguration.AutopilotEnabled) !== false;
const isBackgroundProvider = contextKeyService.getContextKeyValue<string>('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',

View File

@@ -58,6 +58,7 @@ export namespace ChatContextKeys {
* True when the chat widget is locked to the coding agent session.
*/
export const lockedToCodingAgent = new RawContextKey<boolean>('lockedToCodingAgent', false, { type: 'boolean', description: localize('lockedToCodingAgent', "True when the chat widget is locked to the coding agent session.") });
export const lockedCodingAgentId = new RawContextKey<string>('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.