Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as dom from '../../../../base/browser/dom.js';
7
import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js';
8
import { Codicon } from '../../../../base/common/codicons.js';
9
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
10
import { autorun, IObservable } from '../../../../base/common/observable.js';
11
import { localize } from '../../../../nls.js';
12
import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js';
13
import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListOptions } from '../../../../platform/actionWidget/browser/actionList.js';
14
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
15
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
16
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
17
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
18
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
19
import { ThemeIcon } from '../../../../base/common/themables.js';
20
import { ChatConfiguration, ChatPermissionLevel, isChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js';
21
import Severity from '../../../../base/common/severity.js';
22
import { MarkdownString } from '../../../../base/common/htmlContent.js';
23
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
24
import { URI } from '../../../../base/common/uri.js';
25
import { CopilotChatSessionsProvider } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js';
26
import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
27
28
const PERMISSION_LEVEL_OPTION_ID = 'permissionLevel';
29
30
/**
31
* Strategy for the per-provider parts of {@link PermissionPicker}: how to read
32
* back the current level (if at all), whether the picker should be visible
33
* given the active session, and where to write the user's selection.
34
*
35
* Implementations live with the provider they back (e.g.
36
* {@link CopilotPermissionPickerDelegate} below for the default Copilot
37
* provider, or `AgentHostPermissionPickerDelegate` in the agent-host folder).
38
*/
39
export interface IPermissionPickerDelegate {
40
/**
41
* If provided, the picker's trigger label reactively tracks this. If
42
* omitted, the picker manages its own internal state and starts at
43
* {@link ChatPermissionLevel.Default}.
44
*/
45
readonly currentPermissionLevel?: IObservable<ChatPermissionLevel>;
46
47
/**
48
* If provided, the picker hides itself when this is `false`. Used by
49
* delegates whose applicability depends on the active session.
50
*/
51
readonly isApplicable?: IObservable<boolean>;
52
53
/**
54
* Called after the user selects a level (and any required confirmation
55
* dialog has been accepted).
56
*/
57
setPermissionLevel(level: ChatPermissionLevel): void;
58
}
59
60
interface IPermissionItem {
61
readonly level?: ChatPermissionLevel;
62
readonly label: string;
63
readonly icon: ThemeIcon;
64
readonly checked: boolean;
65
}
66
67
// Track whether warnings have been shown this VS Code session
68
const shownWarnings = new Set<ChatPermissionLevel>();
69
70
export class PermissionPicker extends Disposable {
71
72
private _currentLevel: ChatPermissionLevel = ChatPermissionLevel.Default;
73
private _triggerElement: HTMLElement | undefined;
74
private readonly _renderDisposables = this._register(new DisposableStore());
75
76
constructor(
77
private readonly _delegate: IPermissionPickerDelegate,
78
@IActionWidgetService private readonly actionWidgetService: IActionWidgetService,
79
@IConfigurationService private readonly configurationService: IConfigurationService,
80
@IDialogService private readonly dialogService: IDialogService,
81
@IOpenerService private readonly openerService: IOpenerService,
82
) {
83
super();
84
}
85
86
render(container: HTMLElement): HTMLElement {
87
this._renderDisposables.clear();
88
89
// Initialize the picker to reflect the configured default permission level
90
// (`chat.permissions.default`) whenever it is (re-)rendered. If enterprise
91
// policy disables global auto-approval, clamp to Default regardless of the
92
// configured default so we never show an elevated level the user can't pick.
93
const policyRestricted = this.configurationService.inspect<boolean>(ChatConfiguration.GlobalAutoApprove).policyValue === false;
94
const configuredDefault = this.configurationService.getValue<string>(ChatConfiguration.DefaultPermissionLevel);
95
const initialLevel = isChatPermissionLevel(configuredDefault) ? configuredDefault : ChatPermissionLevel.Default;
96
this._currentLevel = policyRestricted ? ChatPermissionLevel.Default : initialLevel;
97
98
const slot = dom.append(container, dom.$('.sessions-chat-picker-slot'));
99
this._renderDisposables.add({ dispose: () => slot.remove() });
100
101
const trigger = dom.append(slot, dom.$('a.action-label'));
102
trigger.tabIndex = 0;
103
trigger.role = 'button';
104
this._triggerElement = trigger;
105
106
this._updateTriggerLabel(trigger);
107
108
this._renderDisposables.add(Gesture.addTarget(trigger));
109
for (const eventType of [dom.EventType.CLICK, TouchEventType.Tap]) {
110
this._renderDisposables.add(dom.addDisposableListener(trigger, eventType, (e) => {
111
dom.EventHelper.stop(e, true);
112
this.showPicker();
113
}));
114
}
115
116
this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => {
117
if (e.key === 'Enter' || e.key === ' ') {
118
dom.EventHelper.stop(e, true);
119
this.showPicker();
120
}
121
}));
122
123
const currentPermissionLevel = this._delegate.currentPermissionLevel;
124
if (currentPermissionLevel) {
125
this._renderDisposables.add(autorun(reader => {
126
this._currentLevel = currentPermissionLevel.read(reader);
127
this._updateTriggerLabel(trigger);
128
}));
129
}
130
131
const isApplicable = this._delegate.isApplicable;
132
if (isApplicable) {
133
this._renderDisposables.add(autorun(reader => {
134
slot.style.display = isApplicable.read(reader) ? '' : 'none';
135
}));
136
}
137
138
return slot;
139
}
140
141
showPicker(): void {
142
if (!this._triggerElement || this.actionWidgetService.isVisible) {
143
return;
144
}
145
146
const policyRestricted = this.configurationService.inspect<boolean>(ChatConfiguration.GlobalAutoApprove).policyValue === false;
147
const isAutopilotEnabled = this.configurationService.getValue<boolean>(ChatConfiguration.AutopilotEnabled) !== false;
148
149
const items: IActionListItem<IPermissionItem>[] = [
150
{
151
kind: ActionListItemKind.Action,
152
group: { kind: ActionListItemKind.Header, title: '', icon: Codicon.shield },
153
item: {
154
level: ChatPermissionLevel.Default,
155
label: localize('permissions.default', "Default Approvals"),
156
icon: Codicon.shield,
157
checked: this._currentLevel === ChatPermissionLevel.Default,
158
},
159
label: localize('permissions.default', "Default Approvals"),
160
detail: localize('permissions.default.subtext', "Copilot uses your configured settings"),
161
disabled: false,
162
},
163
{
164
kind: ActionListItemKind.Action,
165
group: { kind: ActionListItemKind.Header, title: '', icon: Codicon.warning },
166
item: {
167
level: ChatPermissionLevel.AutoApprove,
168
label: localize('permissions.autoApprove', "Bypass Approvals"),
169
icon: Codicon.warning,
170
checked: this._currentLevel === ChatPermissionLevel.AutoApprove,
171
},
172
label: localize('permissions.autoApprove', "Bypass Approvals"),
173
detail: localize('permissions.autoApprove.subtext', "All tool calls are auto-approved"),
174
disabled: policyRestricted,
175
},
176
];
177
178
if (isAutopilotEnabled) {
179
items.push({
180
kind: ActionListItemKind.Action,
181
group: { kind: ActionListItemKind.Header, title: '', icon: Codicon.rocket },
182
item: {
183
level: ChatPermissionLevel.Autopilot,
184
label: localize('permissions.autopilot', "Autopilot (Preview)"),
185
icon: Codicon.rocket,
186
checked: this._currentLevel === ChatPermissionLevel.Autopilot,
187
},
188
label: localize('permissions.autopilot', "Autopilot (Preview)"),
189
detail: localize('permissions.autopilot.subtext', "Autonomously iterates from start to finish"),
190
disabled: policyRestricted,
191
});
192
}
193
194
items.push({
195
kind: ActionListItemKind.Separator,
196
label: '',
197
disabled: false,
198
});
199
items.push({
200
kind: ActionListItemKind.Action,
201
group: { kind: ActionListItemKind.Header, title: '', icon: Codicon.blank },
202
item: {
203
label: localize('permissions.learnMore', "Learn more about permissions"),
204
icon: Codicon.blank,
205
checked: false,
206
},
207
label: localize('permissions.learnMore', "Learn more about permissions"),
208
hideIcon: false,
209
disabled: false,
210
});
211
212
const triggerElement = this._triggerElement;
213
const delegate: IActionListDelegate<IPermissionItem> = {
214
onSelect: async (item) => {
215
this.actionWidgetService.hide();
216
if (item.level) {
217
await this._selectLevel(item.level);
218
} else {
219
await this.openerService.open(URI.parse('https://code.visualstudio.com/docs/copilot/agents/agent-tools#_permission-levels'));
220
}
221
},
222
onHide: () => { triggerElement.focus(); },
223
};
224
225
const listOptions: IActionListOptions = { minWidth: 255 };
226
this.actionWidgetService.show<IPermissionItem>(
227
'permissionPicker',
228
false,
229
items,
230
delegate,
231
this._triggerElement,
232
undefined,
233
[],
234
{
235
getWidgetAriaLabel: () => localize('permissionPicker.ariaLabel', "Permission Picker"),
236
},
237
listOptions,
238
);
239
}
240
241
private async _selectLevel(level: ChatPermissionLevel): Promise<void> {
242
if (level === ChatPermissionLevel.AutoApprove && !shownWarnings.has(ChatPermissionLevel.AutoApprove)) {
243
const result = await this.dialogService.prompt({
244
type: Severity.Warning,
245
message: localize('permissions.autoApprove.warning.title', "Enable Bypass Approvals?"),
246
buttons: [
247
{
248
label: localize('permissions.autoApprove.warning.confirm', "Enable"),
249
run: () => true
250
},
251
{
252
label: localize('permissions.autoApprove.warning.cancel', "Cancel"),
253
run: () => false
254
},
255
],
256
custom: {
257
icon: Codicon.warning,
258
markdownDetails: [{
259
markdown: new MarkdownString(
260
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.\n\nTo make this the starting permission level for new chat sessions, change the [{0}](command:workbench.action.openSettings?%5B%22{0}%22%5D) setting.", ChatConfiguration.DefaultPermissionLevel),
261
{ isTrusted: { enabledCommands: ['workbench.action.openSettings'] } },
262
),
263
}],
264
},
265
});
266
if (result.result !== true) {
267
return;
268
}
269
shownWarnings.add(ChatPermissionLevel.AutoApprove);
270
}
271
272
if (level === ChatPermissionLevel.Autopilot && !shownWarnings.has(ChatPermissionLevel.Autopilot)) {
273
const result = await this.dialogService.prompt({
274
type: Severity.Warning,
275
message: localize('permissions.autopilot.warning.title', "Enable Autopilot?"),
276
buttons: [
277
{
278
label: localize('permissions.autopilot.warning.confirm', "Enable"),
279
run: () => true
280
},
281
{
282
label: localize('permissions.autopilot.warning.cancel', "Cancel"),
283
run: () => false
284
},
285
],
286
custom: {
287
icon: Codicon.rocket,
288
markdownDetails: [{
289
markdown: new MarkdownString(
290
localize('permissions.autopilot.warning.detail', "Autopilot will auto-approve all tool calls and continue working autonomously until the task is complete. The agent will make decisions on your behalf without asking for confirmation.\n\nYou can stop the agent at any time by clicking the stop button. This applies to the current session only.\n\nTo make this the starting permission level for new chat sessions, change the [{0}](command:workbench.action.openSettings?%5B%22{0}%22%5D) setting.", ChatConfiguration.DefaultPermissionLevel),
291
{ isTrusted: { enabledCommands: ['workbench.action.openSettings'] } },
292
),
293
}],
294
},
295
});
296
if (result.result !== true) {
297
return;
298
}
299
shownWarnings.add(ChatPermissionLevel.Autopilot);
300
}
301
302
this._currentLevel = level;
303
this._updateTriggerLabel(this._triggerElement);
304
this._delegate.setPermissionLevel(level);
305
}
306
307
private _updateTriggerLabel(trigger: HTMLElement | undefined): void {
308
if (!trigger) {
309
return;
310
}
311
312
dom.clearNode(trigger);
313
let icon: ThemeIcon;
314
let label: string;
315
switch (this._currentLevel) {
316
case ChatPermissionLevel.Autopilot:
317
icon = Codicon.rocket;
318
label = localize('permissions.autopilot.label', "Autopilot (Preview)");
319
break;
320
case ChatPermissionLevel.AutoApprove:
321
icon = Codicon.warning;
322
label = localize('permissions.autoApprove.label', "Bypass Approvals");
323
break;
324
default:
325
icon = Codicon.shield;
326
label = localize('permissions.default.label', "Default Approvals");
327
break;
328
}
329
330
dom.append(trigger, renderIcon(icon));
331
const labelSpan = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label'));
332
labelSpan.textContent = label;
333
dom.append(trigger, renderIcon(Codicon.chevronDown));
334
335
trigger.ariaLabel = localize('permissionPicker.triggerAriaLabel', "Pick Permission Level, {0}", label);
336
337
trigger.classList.toggle('warning', this._currentLevel === ChatPermissionLevel.Autopilot);
338
trigger.classList.toggle('info', this._currentLevel === ChatPermissionLevel.AutoApprove);
339
}
340
}
341
342
/**
343
* Default-Copilot {@link IPermissionPickerDelegate}: writes the user's chosen
344
* level back to the active {@link CopilotChatSessionsProvider} session.
345
*
346
* Does not provide `currentPermissionLevel` or `isApplicable`, so the picker
347
* manages its own state and is always visible (visibility is gated at the menu
348
* contribution level via `when` clauses).
349
*/
350
export class CopilotPermissionPickerDelegate extends Disposable implements IPermissionPickerDelegate {
351
constructor(
352
@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,
353
@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,
354
@IChatSessionsService private readonly _chatSessionsService: IChatSessionsService,
355
) {
356
super();
357
}
358
359
setPermissionLevel(level: ChatPermissionLevel): void {
360
const session = this._sessionsManagementService.activeSession.get();
361
if (!session) {
362
return;
363
}
364
const provider = this._sessionsProvidersService.getProvider(session.providerId);
365
if (provider instanceof CopilotChatSessionsProvider) {
366
const chatSession = provider.getSession(session.sessionId);
367
if (!chatSession) {
368
return;
369
}
370
if (chatSession.setOption) {
371
chatSession.setPermissionLevel(level);
372
chatSession.setOption(PERMISSION_LEVEL_OPTION_ID, level);
373
} else {
374
this._chatSessionsService.setSessionOption(chatSession.resource, PERMISSION_LEVEL_OPTION_ID, level);
375
}
376
}
377
}
378
}
379
380