Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatStatus.ts
3296 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 './media/chatStatus.css';
7
import { safeIntl } from '../../../../base/common/date.js';
8
import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js';
9
import { language } from '../../../../base/common/platform.js';
10
import { localize } from '../../../../nls.js';
11
import { IWorkbenchContribution } from '../../../common/contributions.js';
12
import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, ShowTooltipCommand, StatusbarAlignment, StatusbarEntryKind } from '../../../services/statusbar/browser/statusbar.js';
13
import { $, addDisposableListener, append, clearNode, disposableWindowInterval, EventHelper, EventType, getWindow } from '../../../../base/browser/dom.js';
14
import { ChatEntitlement, ChatEntitlementService, IChatEntitlementService, IQuotaSnapshot, isProUser } from '../common/chatEntitlementService.js';
15
import { CancellationToken } from '../../../../base/common/cancellation.js';
16
import { defaultButtonStyles, defaultCheckboxStyles } from '../../../../platform/theme/browser/defaultStyles.js';
17
import { Checkbox } from '../../../../base/browser/ui/toggle/toggle.js';
18
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
19
import { ICommandService } from '../../../../platform/commands/common/commands.js';
20
import { Lazy } from '../../../../base/common/lazy.js';
21
import { contrastBorder, inputValidationErrorBorder, inputValidationInfoBorder, inputValidationWarningBorder, registerColor, transparent } from '../../../../platform/theme/common/colorRegistry.js';
22
import { IHoverService, nativeHoverDelegate } from '../../../../platform/hover/browser/hover.js';
23
import { Color } from '../../../../base/common/color.js';
24
import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js';
25
import { IEditorService } from '../../../services/editor/common/editorService.js';
26
import product from '../../../../platform/product/common/product.js';
27
import { isObject } from '../../../../base/common/types.js';
28
import { ILanguageService } from '../../../../editor/common/languages/language.js';
29
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
30
import { Button } from '../../../../base/browser/ui/button/button.js';
31
import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
32
import { WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification, IAction, toAction } from '../../../../base/common/actions.js';
33
import { parseLinkedText } from '../../../../base/common/linkedText.js';
34
import { Link } from '../../../../platform/opener/browser/link.js';
35
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
36
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
37
import { IChatStatusItemService, ChatStatusEntry } from './chatStatusItemService.js';
38
import { ITextResourceConfigurationService } from '../../../../editor/common/services/textResourceConfiguration.js';
39
import { EditorResourceAccessor, SideBySideEditor } from '../../../common/editor.js';
40
import { getCodeEditor } from '../../../../editor/browser/editorBrowser.js';
41
import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';
42
import { ThemeIcon } from '../../../../base/common/themables.js';
43
import { Codicon } from '../../../../base/common/codicons.js';
44
import { URI } from '../../../../base/common/uri.js';
45
import { IInlineCompletionsService } from '../../../../editor/browser/services/inlineCompletionsService.js';
46
import { IChatSessionsService } from '../common/chatSessionsService.js';
47
48
const gaugeForeground = registerColor('gauge.foreground', {
49
dark: inputValidationInfoBorder,
50
light: inputValidationInfoBorder,
51
hcDark: contrastBorder,
52
hcLight: contrastBorder
53
}, localize('gaugeForeground', "Gauge foreground color."));
54
55
registerColor('gauge.background', {
56
dark: transparent(gaugeForeground, 0.3),
57
light: transparent(gaugeForeground, 0.3),
58
hcDark: Color.white,
59
hcLight: Color.white
60
}, localize('gaugeBackground', "Gauge background color."));
61
62
registerColor('gauge.border', {
63
dark: null,
64
light: null,
65
hcDark: contrastBorder,
66
hcLight: contrastBorder
67
}, localize('gaugeBorder', "Gauge border color."));
68
69
const gaugeWarningForeground = registerColor('gauge.warningForeground', {
70
dark: inputValidationWarningBorder,
71
light: inputValidationWarningBorder,
72
hcDark: contrastBorder,
73
hcLight: contrastBorder
74
}, localize('gaugeWarningForeground', "Gauge warning foreground color."));
75
76
registerColor('gauge.warningBackground', {
77
dark: transparent(gaugeWarningForeground, 0.3),
78
light: transparent(gaugeWarningForeground, 0.3),
79
hcDark: Color.white,
80
hcLight: Color.white
81
}, localize('gaugeWarningBackground', "Gauge warning background color."));
82
83
const gaugeErrorForeground = registerColor('gauge.errorForeground', {
84
dark: inputValidationErrorBorder,
85
light: inputValidationErrorBorder,
86
hcDark: contrastBorder,
87
hcLight: contrastBorder
88
}, localize('gaugeErrorForeground', "Gauge error foreground color."));
89
90
registerColor('gauge.errorBackground', {
91
dark: transparent(gaugeErrorForeground, 0.3),
92
light: transparent(gaugeErrorForeground, 0.3),
93
hcDark: Color.white,
94
hcLight: Color.white
95
}, localize('gaugeErrorBackground', "Gauge error background color."));
96
97
//#endregion
98
99
const defaultChat = {
100
extensionId: product.defaultChatAgent?.extensionId ?? '',
101
completionsEnablementSetting: product.defaultChatAgent?.completionsEnablementSetting ?? '',
102
nextEditSuggestionsSetting: product.defaultChatAgent?.nextEditSuggestionsSetting ?? '',
103
manageSettingsUrl: product.defaultChatAgent?.manageSettingsUrl ?? '',
104
manageOverageUrl: product.defaultChatAgent?.manageOverageUrl ?? '',
105
};
106
107
export class ChatStatusBarEntry extends Disposable implements IWorkbenchContribution {
108
109
static readonly ID = 'workbench.contrib.chatStatusBarEntry';
110
111
private entry: IStatusbarEntryAccessor | undefined = undefined;
112
113
private dashboard = new Lazy<ChatStatusDashboard>(() => this.instantiationService.createInstance(ChatStatusDashboard));
114
115
private readonly activeCodeEditorListener = this._register(new MutableDisposable());
116
117
constructor(
118
@IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService,
119
@IInstantiationService private readonly instantiationService: IInstantiationService,
120
@IStatusbarService private readonly statusbarService: IStatusbarService,
121
@IEditorService private readonly editorService: IEditorService,
122
@IConfigurationService private readonly configurationService: IConfigurationService,
123
@IInlineCompletionsService private readonly completionsService: IInlineCompletionsService,
124
@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,
125
) {
126
super();
127
128
this.update();
129
this.registerListeners();
130
}
131
132
private update(): void {
133
const sentiment = this.chatEntitlementService.sentiment;
134
if (!sentiment.hidden) {
135
const props = this.getEntryProps();
136
if (this.entry) {
137
this.entry.update(props);
138
} else {
139
this.entry = this.statusbarService.addEntry(props, 'chat.statusBarEntry', StatusbarAlignment.RIGHT, { location: { id: 'status.editor.mode', priority: 100.1 }, alignment: StatusbarAlignment.RIGHT });
140
}
141
} else {
142
this.entry?.dispose();
143
this.entry = undefined;
144
}
145
}
146
147
private registerListeners(): void {
148
this._register(this.chatEntitlementService.onDidChangeQuotaExceeded(() => this.update()));
149
this._register(this.chatEntitlementService.onDidChangeSentiment(() => this.update()));
150
this._register(this.chatEntitlementService.onDidChangeEntitlement(() => this.update()));
151
this._register(this.completionsService.onDidChangeIsSnoozing(() => this.update()));
152
this._register(this.chatSessionsService.onDidChangeInProgress(() => this.update()));
153
154
this._register(this.editorService.onDidActiveEditorChange(() => this.onDidActiveEditorChange()));
155
156
this._register(this.configurationService.onDidChangeConfiguration(e => {
157
if (e.affectsConfiguration(defaultChat.completionsEnablementSetting)) {
158
this.update();
159
}
160
}));
161
}
162
163
private onDidActiveEditorChange(): void {
164
this.update();
165
166
this.activeCodeEditorListener.clear();
167
168
// Listen to language changes in the active code editor
169
const activeCodeEditor = getCodeEditor(this.editorService.activeTextEditorControl);
170
if (activeCodeEditor) {
171
this.activeCodeEditorListener.value = activeCodeEditor.onDidChangeModelLanguage(() => {
172
this.update();
173
});
174
}
175
}
176
177
private getEntryProps(): IStatusbarEntry {
178
let text = '$(copilot)';
179
let ariaLabel = localize('chatStatus', "Copilot Status");
180
let kind: StatusbarEntryKind | undefined;
181
182
// Check if there are any chat sessions in progress
183
const inProgress = this.chatSessionsService.getInProgress();
184
const hasInProgressSessions = inProgress.some(item => item.count > 0);
185
186
if (isNewUser(this.chatEntitlementService)) {
187
const entitlement = this.chatEntitlementService.entitlement;
188
189
// Finish Setup
190
if (
191
this.chatEntitlementService.sentiment.later || // user skipped setup
192
entitlement === ChatEntitlement.Available || // user is entitled
193
isProUser(entitlement) || // user is already pro
194
entitlement === ChatEntitlement.Free // user is already free
195
) {
196
const finishSetup = localize('copilotLaterStatus', "Finish Setup");
197
198
text = `$(copilot) ${finishSetup}`;
199
ariaLabel = finishSetup;
200
kind = 'prominent';
201
}
202
} else {
203
const chatQuotaExceeded = this.chatEntitlementService.quotas.chat?.percentRemaining === 0;
204
const completionsQuotaExceeded = this.chatEntitlementService.quotas.completions?.percentRemaining === 0;
205
206
// Disabled
207
if (this.chatEntitlementService.sentiment.disabled || this.chatEntitlementService.sentiment.untrusted) {
208
text = `$(copilot-unavailable)`;
209
ariaLabel = localize('copilotDisabledStatus', "Copilot Disabled");
210
}
211
212
// Signed out
213
else if (this.chatEntitlementService.entitlement === ChatEntitlement.Unknown) {
214
const signedOutWarning = localize('notSignedIntoCopilot', "Signed out");
215
216
text = `$(copilot-not-connected) ${signedOutWarning}`;
217
ariaLabel = signedOutWarning;
218
kind = 'prominent';
219
}
220
221
// Free Quota Exceeded
222
else if (this.chatEntitlementService.entitlement === ChatEntitlement.Free && (chatQuotaExceeded || completionsQuotaExceeded)) {
223
let quotaWarning: string;
224
if (chatQuotaExceeded && !completionsQuotaExceeded) {
225
quotaWarning = localize('chatQuotaExceededStatus', "Chat quota reached");
226
} else if (completionsQuotaExceeded && !chatQuotaExceeded) {
227
quotaWarning = localize('completionsQuotaExceededStatus', "Completions quota reached");
228
} else {
229
quotaWarning = localize('chatAndCompletionsQuotaExceededStatus', "Quota reached");
230
}
231
232
text = `$(copilot-warning) ${quotaWarning}`;
233
ariaLabel = quotaWarning;
234
kind = 'prominent';
235
}
236
237
// Completions Disabled
238
else if (this.editorService.activeTextEditorLanguageId && !isCompletionsEnabled(this.configurationService, this.editorService.activeTextEditorLanguageId)) {
239
text = `$(copilot-unavailable)`;
240
ariaLabel = localize('completionsDisabledStatus', "Code completions disabled");
241
}
242
243
// Completions Snoozed
244
else if (this.completionsService.isSnoozing()) {
245
text = `$(copilot-snooze)`;
246
ariaLabel = localize('completionsSnoozedStatus', "Code completions snoozed");
247
}
248
}
249
250
// Show progress indicator when chat sessions are in progress
251
if (hasInProgressSessions) {
252
text = `$(loading~spin)\u00A0${text}`;
253
// Update aria label to include progress information
254
const sessionCount = inProgress.reduce((total, item) => total + item.count, 0);
255
ariaLabel = `${ariaLabel}, ${sessionCount} chat session${sessionCount === 1 ? '' : 's'} in progress`;
256
}
257
258
const baseResult = {
259
name: localize('chatStatus', "Copilot Status"),
260
text,
261
ariaLabel,
262
command: ShowTooltipCommand,
263
showInAllWindows: true,
264
kind,
265
tooltip: { element: (token: CancellationToken) => this.dashboard.value.show(token) }
266
};
267
268
return baseResult;
269
}
270
271
override dispose(): void {
272
super.dispose();
273
274
this.entry?.dispose();
275
this.entry = undefined;
276
}
277
}
278
279
function isNewUser(chatEntitlementService: IChatEntitlementService): boolean {
280
return !chatEntitlementService.sentiment.installed || // copilot not installed
281
chatEntitlementService.entitlement === ChatEntitlement.Available; // not yet signed up to copilot
282
}
283
284
function canUseCopilot(chatEntitlementService: IChatEntitlementService): boolean {
285
const newUser = isNewUser(chatEntitlementService);
286
const disabled = chatEntitlementService.sentiment.disabled || chatEntitlementService.sentiment.untrusted;
287
const signedOut = chatEntitlementService.entitlement === ChatEntitlement.Unknown;
288
const free = chatEntitlementService.entitlement === ChatEntitlement.Free;
289
const allFreeQuotaReached = free && chatEntitlementService.quotas.chat?.percentRemaining === 0 && chatEntitlementService.quotas.completions?.percentRemaining === 0;
290
291
return !newUser && !signedOut && !allFreeQuotaReached && !disabled;
292
}
293
294
function isCompletionsEnabled(configurationService: IConfigurationService, modeId: string = '*'): boolean {
295
const result = configurationService.getValue<Record<string, boolean>>(defaultChat.completionsEnablementSetting);
296
if (!isObject(result)) {
297
return false;
298
}
299
300
if (typeof result[modeId] !== 'undefined') {
301
return Boolean(result[modeId]); // go with setting if explicitly defined
302
}
303
304
return Boolean(result['*']); // fallback to global setting otherwise
305
}
306
307
interface ISettingsAccessor {
308
readSetting: () => boolean;
309
writeSetting: (value: boolean) => Promise<void>;
310
}
311
312
type ChatSettingChangedClassification = {
313
owner: 'bpasero';
314
comment: 'Provides insight into chat settings changed from the chat status entry.';
315
settingIdentifier: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifier of the setting that changed.' };
316
settingMode?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The optional editor language for which the setting changed.' };
317
settingEnablement: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the setting got enabled or disabled.' };
318
};
319
type ChatSettingChangedEvent = {
320
settingIdentifier: string;
321
settingMode?: string;
322
settingEnablement: 'enabled' | 'disabled';
323
};
324
325
class ChatStatusDashboard extends Disposable {
326
327
private readonly element = $('div.chat-status-bar-entry-tooltip');
328
329
private readonly dateFormatter = safeIntl.DateTimeFormat(language, { year: 'numeric', month: 'long', day: 'numeric' });
330
private readonly dateTimeFormatter = safeIntl.DateTimeFormat(language, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' });
331
private readonly quotaPercentageFormatter = safeIntl.NumberFormat(undefined, { maximumFractionDigits: 1, minimumFractionDigits: 0 });
332
private readonly quotaOverageFormatter = safeIntl.NumberFormat(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 0 });
333
334
private readonly entryDisposables = this._register(new MutableDisposable());
335
336
constructor(
337
@IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService,
338
@IChatStatusItemService private readonly chatStatusItemService: IChatStatusItemService,
339
@ICommandService private readonly commandService: ICommandService,
340
@IConfigurationService private readonly configurationService: IConfigurationService,
341
@IEditorService private readonly editorService: IEditorService,
342
@IHoverService private readonly hoverService: IHoverService,
343
@ILanguageService private readonly languageService: ILanguageService,
344
@IOpenerService private readonly openerService: IOpenerService,
345
@ITelemetryService private readonly telemetryService: ITelemetryService,
346
@ITextResourceConfigurationService private readonly textResourceConfigurationService: ITextResourceConfigurationService,
347
@IInlineCompletionsService private readonly inlineCompletionsService: IInlineCompletionsService,
348
@IChatSessionsService private readonly chatSessionsService: IChatSessionsService
349
) {
350
super();
351
}
352
353
show(token: CancellationToken): HTMLElement {
354
clearNode(this.element);
355
356
const disposables = this.entryDisposables.value = new DisposableStore();
357
disposables.add(token.onCancellationRequested(() => disposables.dispose()));
358
359
let needsSeparator = false;
360
const addSeparator = (label?: string, action?: IAction) => {
361
if (needsSeparator) {
362
this.element.appendChild($('hr'));
363
}
364
365
if (label || action) {
366
this.renderHeader(this.element, disposables, label ?? '', action);
367
}
368
369
needsSeparator = true;
370
};
371
372
// Quota Indicator
373
const { chat: chatQuota, completions: completionsQuota, premiumChat: premiumChatQuota, resetDate, resetDateHasTime } = this.chatEntitlementService.quotas;
374
if (chatQuota || completionsQuota || premiumChatQuota) {
375
376
addSeparator(localize('usageTitle', "Copilot Usage"), toAction({
377
id: 'workbench.action.manageCopilot',
378
label: localize('quotaLabel', "Manage Chat"),
379
tooltip: localize('quotaTooltip', "Manage Chat"),
380
class: ThemeIcon.asClassName(Codicon.settings),
381
run: () => this.runCommandAndClose(() => this.openerService.open(URI.parse(defaultChat.manageSettingsUrl))),
382
}));
383
384
const completionsQuotaIndicator = completionsQuota && (completionsQuota.total > 0 || completionsQuota.unlimited) ? this.createQuotaIndicator(this.element, disposables, completionsQuota, localize('completionsLabel', "Code completions"), false) : undefined;
385
const chatQuotaIndicator = chatQuota && (chatQuota.total > 0 || chatQuota.unlimited) ? this.createQuotaIndicator(this.element, disposables, chatQuota, localize('chatsLabel', "Chat messages"), false) : undefined;
386
const premiumChatQuotaIndicator = premiumChatQuota && (premiumChatQuota.total > 0 || premiumChatQuota.unlimited) ? this.createQuotaIndicator(this.element, disposables, premiumChatQuota, localize('premiumChatsLabel', "Premium requests"), true) : undefined;
387
388
if (resetDate) {
389
this.element.appendChild($('div.description', undefined, localize('limitQuota', "Allowance resets {0}.", resetDateHasTime ? this.dateTimeFormatter.value.format(new Date(resetDate)) : this.dateFormatter.value.format(new Date(resetDate)))));
390
}
391
392
if (this.chatEntitlementService.entitlement === ChatEntitlement.Free && (Number(chatQuota?.percentRemaining) <= 25 || Number(completionsQuota?.percentRemaining) <= 25)) {
393
const upgradeProButton = disposables.add(new Button(this.element, { ...defaultButtonStyles, hoverDelegate: nativeHoverDelegate, secondary: canUseCopilot(this.chatEntitlementService) /* use secondary color when copilot can still be used */ }));
394
upgradeProButton.label = localize('upgradeToCopilotPro', "Upgrade to GitHub Copilot Pro");
395
disposables.add(upgradeProButton.onDidClick(() => this.runCommandAndClose('workbench.action.chat.upgradePlan')));
396
}
397
398
(async () => {
399
await this.chatEntitlementService.update(token);
400
if (token.isCancellationRequested) {
401
return;
402
}
403
404
const { chat: chatQuota, completions: completionsQuota, premiumChat: premiumChatQuota } = this.chatEntitlementService.quotas;
405
if (completionsQuota) {
406
completionsQuotaIndicator?.(completionsQuota);
407
}
408
if (chatQuota) {
409
chatQuotaIndicator?.(chatQuota);
410
}
411
if (premiumChatQuota) {
412
premiumChatQuotaIndicator?.(premiumChatQuota);
413
}
414
})();
415
}
416
417
// Chat sessions
418
{
419
let chatSessionsElement: HTMLElement | undefined;
420
const updateStatus = () => {
421
const inProgress = this.chatSessionsService.getInProgress();
422
if (inProgress.some(item => item.count > 0)) {
423
addSeparator(localize('chatSessionsTitle', "Chat Sessions"), toAction({
424
id: 'workbench.view.chat.status.sessions',
425
label: localize('viewChatSessionsLabel', "View Chat Sessions"),
426
tooltip: localize('viewChatSessionsTooltip', "View Chat Sessions"),
427
class: ThemeIcon.asClassName(Codicon.eye),
428
run: () => this.runCommandAndClose('workbench.view.chat.sessions'),
429
}));
430
431
for (const { displayName, count } of inProgress) {
432
if (count > 0) {
433
let lowerCaseName = displayName.toLocaleLowerCase();
434
// Very specific case for providers that end in session/sessions to ensure we pluralize correctly
435
if (lowerCaseName.endsWith('session') || lowerCaseName.endsWith('sessions')) {
436
lowerCaseName = lowerCaseName.replace(/session$|sessions$/g, count > 1 ? 'sessions' : 'session');
437
}
438
const text = localize('inProgressChatSession', "$(loading~spin) {0} {1} in progress", count, lowerCaseName);
439
chatSessionsElement = this.element.appendChild($('div.description'));
440
const parts = renderLabelWithIcons(text);
441
chatSessionsElement.append(...parts);
442
}
443
}
444
}
445
else {
446
chatSessionsElement?.remove();
447
}
448
};
449
updateStatus();
450
disposables.add(this.chatSessionsService.onDidChangeInProgress(updateStatus));
451
}
452
453
// Contributions
454
{
455
for (const item of this.chatStatusItemService.getEntries()) {
456
addSeparator();
457
458
const itemDisposables = disposables.add(new MutableDisposable());
459
460
let rendered = this.renderContributedChatStatusItem(item);
461
itemDisposables.value = rendered.disposables;
462
this.element.appendChild(rendered.element);
463
464
disposables.add(this.chatStatusItemService.onDidChange(e => {
465
if (e.entry.id === item.id) {
466
const previousElement = rendered.element;
467
468
rendered = this.renderContributedChatStatusItem(e.entry);
469
itemDisposables.value = rendered.disposables;
470
471
previousElement.replaceWith(rendered.element);
472
}
473
}));
474
}
475
}
476
477
// Settings
478
{
479
const chatSentiment = this.chatEntitlementService.sentiment;
480
addSeparator(localize('codeCompletions', "Code Completions"), chatSentiment.installed && !chatSentiment.disabled && !chatSentiment.untrusted ? toAction({
481
id: 'workbench.action.openChatSettings',
482
label: localize('settingsLabel', "Settings"),
483
tooltip: localize('settingsTooltip', "Open Settings"),
484
class: ThemeIcon.asClassName(Codicon.settingsGear),
485
run: () => this.runCommandAndClose(() => this.commandService.executeCommand('workbench.action.openSettings', { query: `@id:${defaultChat.completionsEnablementSetting} @id:${defaultChat.nextEditSuggestionsSetting}` })),
486
}) : undefined);
487
488
this.createSettings(this.element, disposables);
489
}
490
491
// Completions Snooze
492
if (canUseCopilot(this.chatEntitlementService)) {
493
const snooze = append(this.element, $('div.snooze-completions'));
494
this.createCompletionsSnooze(snooze, localize('settings.snooze', "Snooze"), disposables);
495
}
496
497
// New to Copilot / Signed out
498
{
499
const newUser = isNewUser(this.chatEntitlementService);
500
const disabled = this.chatEntitlementService.sentiment.disabled || this.chatEntitlementService.sentiment.untrusted;
501
const signedOut = this.chatEntitlementService.entitlement === ChatEntitlement.Unknown;
502
if (newUser || signedOut || disabled) {
503
addSeparator();
504
505
let descriptionText: string;
506
if (newUser) {
507
descriptionText = localize('activateDescription', "Set up Copilot to use AI features.");
508
} else if (disabled) {
509
descriptionText = localize('enableDescription', "Enable Copilot to use AI features.");
510
} else {
511
descriptionText = localize('signInDescription', "Sign in to use Copilot AI features.");
512
}
513
514
let buttonLabel: string;
515
if (newUser) {
516
buttonLabel = localize('activateCopilotButton', "Set up Copilot");
517
} else if (disabled) {
518
buttonLabel = localize('enableCopilotButton', "Enable Copilot");
519
} else {
520
buttonLabel = localize('signInToUseCopilotButton', "Sign in to use Copilot");
521
}
522
523
this.element.appendChild($('div.description', undefined, descriptionText));
524
525
const button = disposables.add(new Button(this.element, { ...defaultButtonStyles, hoverDelegate: nativeHoverDelegate }));
526
button.label = buttonLabel;
527
disposables.add(button.onDidClick(() => this.runCommandAndClose('workbench.action.chat.triggerSetup')));
528
}
529
}
530
531
return this.element;
532
}
533
534
private renderHeader(container: HTMLElement, disposables: DisposableStore, label: string, action?: IAction): void {
535
const header = container.appendChild($('div.header', undefined, label ?? ''));
536
537
if (action) {
538
const toolbar = disposables.add(new ActionBar(header, { hoverDelegate: nativeHoverDelegate }));
539
toolbar.push([action], { icon: true, label: false });
540
}
541
}
542
543
private renderContributedChatStatusItem(item: ChatStatusEntry): { element: HTMLElement; disposables: DisposableStore } {
544
const disposables = new DisposableStore();
545
546
const itemElement = $('div.contribution');
547
548
const headerLabel = typeof item.label === 'string' ? item.label : item.label.label;
549
const headerLink = typeof item.label === 'string' ? undefined : item.label.link;
550
this.renderHeader(itemElement, disposables, headerLabel, headerLink ? toAction({
551
id: 'workbench.action.openChatStatusItemLink',
552
label: localize('learnMore', "Learn More"),
553
tooltip: localize('learnMore', "Learn More"),
554
class: ThemeIcon.asClassName(Codicon.linkExternal),
555
run: () => this.runCommandAndClose(() => this.openerService.open(URI.parse(headerLink))),
556
}) : undefined);
557
558
const itemBody = itemElement.appendChild($('div.body'));
559
560
const description = itemBody.appendChild($('span.description'));
561
this.renderTextPlus(description, item.description, disposables);
562
563
if (item.detail) {
564
const detail = itemBody.appendChild($('div.detail-item'));
565
this.renderTextPlus(detail, item.detail, disposables);
566
}
567
568
return { element: itemElement, disposables };
569
}
570
571
private renderTextPlus(target: HTMLElement, text: string, store: DisposableStore): void {
572
for (const node of parseLinkedText(text).nodes) {
573
if (typeof node === 'string') {
574
const parts = renderLabelWithIcons(node);
575
target.append(...parts);
576
} else {
577
store.add(new Link(target, node, undefined, this.hoverService, this.openerService));
578
}
579
}
580
}
581
582
private runCommandAndClose(commandOrFn: string | Function): void {
583
if (typeof commandOrFn === 'function') {
584
commandOrFn();
585
} else {
586
this.telemetryService.publicLog2<WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification>('workbenchActionExecuted', { id: commandOrFn, from: 'chat-status' });
587
this.commandService.executeCommand(commandOrFn);
588
}
589
590
this.hoverService.hideHover(true);
591
}
592
593
private createQuotaIndicator(container: HTMLElement, disposables: DisposableStore, quota: IQuotaSnapshot, label: string, supportsOverage: boolean): (quota: IQuotaSnapshot) => void {
594
const quotaValue = $('span.quota-value');
595
const quotaBit = $('div.quota-bit');
596
const overageLabel = $('span.overage-label');
597
598
const quotaIndicator = container.appendChild($('div.quota-indicator', undefined,
599
$('div.quota-label', undefined,
600
$('span', undefined, label),
601
quotaValue
602
),
603
$('div.quota-bar', undefined,
604
quotaBit
605
),
606
$('div.description', undefined,
607
overageLabel
608
)
609
));
610
611
if (supportsOverage && (this.chatEntitlementService.entitlement === ChatEntitlement.Pro || this.chatEntitlementService.entitlement === ChatEntitlement.ProPlus)) {
612
const manageOverageButton = disposables.add(new Button(quotaIndicator, { ...defaultButtonStyles, secondary: true, hoverDelegate: nativeHoverDelegate }));
613
manageOverageButton.label = localize('enableAdditionalUsage', "Manage paid premium requests");
614
disposables.add(manageOverageButton.onDidClick(() => this.runCommandAndClose(() => this.openerService.open(URI.parse(defaultChat.manageOverageUrl)))));
615
}
616
617
const update = (quota: IQuotaSnapshot) => {
618
quotaIndicator.classList.remove('error');
619
quotaIndicator.classList.remove('warning');
620
621
let usedPercentage: number;
622
if (quota.unlimited) {
623
usedPercentage = 0;
624
} else {
625
usedPercentage = Math.max(0, 100 - quota.percentRemaining);
626
}
627
628
if (quota.unlimited) {
629
quotaValue.textContent = localize('quotaUnlimited', "Included");
630
} else if (quota.overageCount) {
631
quotaValue.textContent = localize('quotaDisplayWithOverage', "+{0} requests", this.quotaOverageFormatter.value.format(quota.overageCount));
632
} else {
633
quotaValue.textContent = localize('quotaDisplay', "{0}%", this.quotaPercentageFormatter.value.format(usedPercentage));
634
}
635
636
quotaBit.style.width = `${usedPercentage}%`;
637
638
if (usedPercentage >= 90) {
639
quotaIndicator.classList.add('error');
640
} else if (usedPercentage >= 75) {
641
quotaIndicator.classList.add('warning');
642
}
643
644
if (supportsOverage) {
645
if (quota.overageEnabled) {
646
overageLabel.textContent = localize('additionalUsageEnabled', "Additional paid premium requests enabled.");
647
} else {
648
overageLabel.textContent = localize('additionalUsageDisabled', "Additional paid premium requests disabled.");
649
}
650
} else {
651
overageLabel.textContent = '';
652
}
653
};
654
655
update(quota);
656
657
return update;
658
}
659
660
private createSettings(container: HTMLElement, disposables: DisposableStore): HTMLElement {
661
const modeId = this.editorService.activeTextEditorLanguageId;
662
const settings = container.appendChild($('div.settings'));
663
664
// --- Code completions
665
{
666
const globalSetting = append(settings, $('div.setting'));
667
this.createCodeCompletionsSetting(globalSetting, localize('settings.codeCompletions.allFiles', "All files"), '*', disposables);
668
669
if (modeId) {
670
const languageSetting = append(settings, $('div.setting'));
671
this.createCodeCompletionsSetting(languageSetting, localize('settings.codeCompletions.language', "{0}", this.languageService.getLanguageName(modeId) ?? modeId), modeId, disposables);
672
}
673
}
674
675
// --- Next edit suggestions
676
{
677
const setting = append(settings, $('div.setting'));
678
this.createNextEditSuggestionsSetting(setting, localize('settings.nextEditSuggestions', "Next edit suggestions"), this.getCompletionsSettingAccessor(modeId), disposables);
679
}
680
681
return settings;
682
}
683
684
private createSetting(container: HTMLElement, settingIdsToReEvaluate: string[], label: string, accessor: ISettingsAccessor, disposables: DisposableStore): Checkbox {
685
const checkbox = disposables.add(new Checkbox(label, Boolean(accessor.readSetting()), { ...defaultCheckboxStyles, hoverDelegate: nativeHoverDelegate }));
686
container.appendChild(checkbox.domNode);
687
688
const settingLabel = append(container, $('span.setting-label', undefined, label));
689
disposables.add(Gesture.addTarget(settingLabel));
690
[EventType.CLICK, TouchEventType.Tap].forEach(eventType => {
691
disposables.add(addDisposableListener(settingLabel, eventType, e => {
692
if (checkbox?.enabled) {
693
EventHelper.stop(e, true);
694
695
checkbox.checked = !checkbox.checked;
696
accessor.writeSetting(checkbox.checked);
697
checkbox.focus();
698
}
699
}));
700
});
701
702
disposables.add(checkbox.onChange(() => {
703
accessor.writeSetting(checkbox.checked);
704
}));
705
706
disposables.add(this.configurationService.onDidChangeConfiguration(e => {
707
if (settingIdsToReEvaluate.some(id => e.affectsConfiguration(id))) {
708
checkbox.checked = Boolean(accessor.readSetting());
709
}
710
}));
711
712
if (!canUseCopilot(this.chatEntitlementService)) {
713
container.classList.add('disabled');
714
checkbox.disable();
715
checkbox.checked = false;
716
}
717
718
return checkbox;
719
}
720
721
private createCodeCompletionsSetting(container: HTMLElement, label: string, modeId: string | undefined, disposables: DisposableStore): void {
722
this.createSetting(container, [defaultChat.completionsEnablementSetting], label, this.getCompletionsSettingAccessor(modeId), disposables);
723
}
724
725
private getCompletionsSettingAccessor(modeId = '*'): ISettingsAccessor {
726
const settingId = defaultChat.completionsEnablementSetting;
727
728
return {
729
readSetting: () => isCompletionsEnabled(this.configurationService, modeId),
730
writeSetting: (value: boolean) => {
731
this.telemetryService.publicLog2<ChatSettingChangedEvent, ChatSettingChangedClassification>('chatStatus.settingChanged', {
732
settingIdentifier: settingId,
733
settingMode: modeId,
734
settingEnablement: value ? 'enabled' : 'disabled'
735
});
736
737
let result = this.configurationService.getValue<Record<string, boolean>>(settingId);
738
if (!isObject(result)) {
739
result = Object.create(null);
740
}
741
742
return this.configurationService.updateValue(settingId, { ...result, [modeId]: value });
743
}
744
};
745
}
746
747
private createNextEditSuggestionsSetting(container: HTMLElement, label: string, completionsSettingAccessor: ISettingsAccessor, disposables: DisposableStore): void {
748
const nesSettingId = defaultChat.nextEditSuggestionsSetting;
749
const completionsSettingId = defaultChat.completionsEnablementSetting;
750
const resource = EditorResourceAccessor.getOriginalUri(this.editorService.activeEditor, { supportSideBySide: SideBySideEditor.PRIMARY });
751
752
const checkbox = this.createSetting(container, [nesSettingId, completionsSettingId], label, {
753
readSetting: () => completionsSettingAccessor.readSetting() && this.textResourceConfigurationService.getValue<boolean>(resource, nesSettingId),
754
writeSetting: (value: boolean) => {
755
this.telemetryService.publicLog2<ChatSettingChangedEvent, ChatSettingChangedClassification>('chatStatus.settingChanged', {
756
settingIdentifier: nesSettingId,
757
settingEnablement: value ? 'enabled' : 'disabled'
758
});
759
760
return this.textResourceConfigurationService.updateValue(resource, nesSettingId, value);
761
}
762
}, disposables);
763
764
// enablement of NES depends on completions setting
765
// so we have to update our checkbox state accordingly
766
767
if (!completionsSettingAccessor.readSetting()) {
768
container.classList.add('disabled');
769
checkbox.disable();
770
}
771
772
disposables.add(this.configurationService.onDidChangeConfiguration(e => {
773
if (e.affectsConfiguration(completionsSettingId)) {
774
if (completionsSettingAccessor.readSetting() && canUseCopilot(this.chatEntitlementService)) {
775
checkbox.enable();
776
container.classList.remove('disabled');
777
} else {
778
checkbox.disable();
779
container.classList.add('disabled');
780
}
781
}
782
}));
783
}
784
785
private createCompletionsSnooze(container: HTMLElement, label: string, disposables: DisposableStore): void {
786
const isEnabled = () => {
787
const completionsEnabled = isCompletionsEnabled(this.configurationService);
788
const completionsEnabledActiveLanguage = isCompletionsEnabled(this.configurationService, this.editorService.activeTextEditorLanguageId);
789
return completionsEnabled || completionsEnabledActiveLanguage;
790
};
791
792
const button = disposables.add(new Button(container, { disabled: !isEnabled(), ...defaultButtonStyles, hoverDelegate: nativeHoverDelegate, secondary: true }));
793
794
const timerDisplay = container.appendChild($('span.snooze-label'));
795
796
const actionBar = container.appendChild($('div.snooze-action-bar'));
797
const toolbar = disposables.add(new ActionBar(actionBar, { hoverDelegate: nativeHoverDelegate }));
798
const cancelAction = toAction({
799
id: 'workbench.action.cancelSnoozeStatusBarLink',
800
label: localize('cancelSnooze', "Cancel Snooze"),
801
run: () => this.inlineCompletionsService.cancelSnooze(),
802
class: ThemeIcon.asClassName(Codicon.stopCircle)
803
});
804
805
const update = (isEnabled: boolean) => {
806
container.classList.toggle('disabled', !isEnabled);
807
toolbar.clear();
808
809
const timeLeftMs = this.inlineCompletionsService.snoozeTimeLeft;
810
if (!isEnabled || timeLeftMs <= 0) {
811
timerDisplay.textContent = localize('completions.snooze5minutesTitle', "Hide completions for 5 min");
812
timerDisplay.title = '';
813
button.label = label;
814
button.setTitle(localize('completions.snooze5minutes', "Hide completions and NES for 5 min"));
815
return true;
816
}
817
818
const timeLeftSeconds = Math.ceil(timeLeftMs / 1000);
819
const minutes = Math.floor(timeLeftSeconds / 60);
820
const seconds = timeLeftSeconds % 60;
821
822
timerDisplay.textContent = `${minutes}:${seconds < 10 ? '0' : ''}${seconds} ${localize('completions.remainingTime', "remaining")}`;
823
timerDisplay.title = localize('completions.snoozeTimeDescription', "Completions are hidden for the remaining duration");
824
button.label = localize('completions.plus5min', "+5 min");
825
button.setTitle(localize('completions.snoozeAdditional5minutes', "Snooze additional 5 min"));
826
toolbar.push([cancelAction], { icon: true, label: false });
827
828
return false;
829
};
830
831
// Update every second if there's time remaining
832
const timerDisposables = disposables.add(new DisposableStore());
833
function updateIntervalTimer() {
834
timerDisposables.clear();
835
const enabled = isEnabled();
836
837
if (update(enabled)) {
838
return;
839
}
840
841
timerDisposables.add(disposableWindowInterval(
842
getWindow(container),
843
() => update(enabled),
844
1_000,
845
));
846
}
847
updateIntervalTimer();
848
849
disposables.add(button.onDidClick(() => {
850
this.inlineCompletionsService.snooze();
851
update(isEnabled());
852
}));
853
854
disposables.add(this.configurationService.onDidChangeConfiguration(e => {
855
if (e.affectsConfiguration(defaultChat.completionsEnablementSetting)) {
856
button.enabled = isEnabled();
857
}
858
updateIntervalTimer();
859
}));
860
861
disposables.add(this.inlineCompletionsService.onDidChangeIsSnoozing(e => {
862
updateIntervalTimer();
863
}));
864
}
865
}
866
867