Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/electron-browser/actions/voiceChatActions.ts
4780 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 { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js';
7
import { RunOnceScheduler, disposableTimeout, raceCancellation } from '../../../../../base/common/async.js';
8
import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js';
9
import { Codicon } from '../../../../../base/common/codicons.js';
10
import { Color } from '../../../../../base/common/color.js';
11
import { Event } from '../../../../../base/common/event.js';
12
import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js';
13
import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';
14
import { isNumber } from '../../../../../base/common/types.js';
15
import { getCodeEditor } from '../../../../../editor/browser/editorBrowser.js';
16
import { EditorContextKeys } from '../../../../../editor/common/editorContextKeys.js';
17
import { localize, localize2 } from '../../../../../nls.js';
18
import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js';
19
import { Action2, IAction2Options, MenuId } from '../../../../../platform/actions/common/actions.js';
20
import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js';
21
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
22
import { Extensions, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js';
23
import { ContextKeyExpr, ContextKeyExpression, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js';
24
import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';
25
import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js';
26
import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js';
27
import { Registry } from '../../../../../platform/registry/common/platform.js';
28
import { contrastBorder, focusBorder } from '../../../../../platform/theme/common/colorRegistry.js';
29
import { spinningLoading, syncing } from '../../../../../platform/theme/common/iconRegistry.js';
30
import { isHighContrast } from '../../../../../platform/theme/common/theme.js';
31
import { registerThemingParticipant } from '../../../../../platform/theme/common/themeService.js';
32
import { ActiveEditorContext } from '../../../../common/contextkeys.js';
33
import { IWorkbenchContribution } from '../../../../common/contributions.js';
34
import { ACTIVITY_BAR_FOREGROUND } from '../../../../common/theme.js';
35
import { IEditorService } from '../../../../services/editor/common/editorService.js';
36
import { IHostService } from '../../../../services/host/browser/host.js';
37
import { IWorkbenchLayoutService, Parts } from '../../../../services/layout/browser/layoutService.js';
38
import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, StatusbarAlignment } from '../../../../services/statusbar/browser/statusbar.js';
39
import { AccessibilityVoiceSettingId, SpeechTimeoutDefault, accessibilityConfigurationNodeBase } from '../../../accessibility/browser/accessibilityConfiguration.js';
40
import { InlineChatController } from '../../../inlineChat/browser/inlineChatController.js';
41
import { CTX_INLINE_CHAT_FOCUSED, MENU_INLINE_CHAT_WIDGET_SECONDARY } from '../../../inlineChat/common/inlineChat.js';
42
import { NOTEBOOK_EDITOR_FOCUSED } from '../../../notebook/common/notebookContextKeys.js';
43
import { CONTEXT_SETTINGS_EDITOR } from '../../../preferences/common/preferences.js';
44
import { SearchContext } from '../../../search/common/constants.js';
45
import { TextToSpeechInProgress as GlobalTextToSpeechInProgress, HasSpeechProvider, ISpeechService, KeywordRecognitionStatus, SpeechToTextInProgress, SpeechToTextStatus, TextToSpeechStatus } from '../../../speech/common/speechService.js';
46
import { CHAT_CATEGORY } from '../../browser/actions/chatActions.js';
47
import { IChatExecuteActionContext } from '../../browser/actions/chatExecuteActions.js';
48
import { IChatWidget, IChatWidgetService, IQuickChatService } from '../../browser/chat.js';
49
import { IChatAgentService } from '../../common/participants/chatAgents.js';
50
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
51
import { IChatResponseModel } from '../../common/model/chatModel.js';
52
import { KEYWORD_ACTIVIATION_SETTING_ID } from '../../common/chatService/chatService.js';
53
import { ChatResponseViewModel, IChatResponseViewModel, isResponseVM } from '../../common/model/chatViewModel.js';
54
import { ChatAgentLocation } from '../../common/constants.js';
55
import { VoiceChatInProgress as GlobalVoiceChatInProgress, IVoiceChatService } from '../../common/voiceChatService.js';
56
import './media/voiceChatActions.css';
57
58
//#region Speech to Text
59
60
type VoiceChatSessionContext = 'view' | 'inline' | 'quick' | 'editor';
61
const VoiceChatSessionContexts: VoiceChatSessionContext[] = ['view', 'inline', 'quick', 'editor'];
62
63
// Global Context Keys (set on global context key service)
64
const CanVoiceChat = ContextKeyExpr.and(ChatContextKeys.enabled, HasSpeechProvider);
65
const FocusInChatInput = ContextKeyExpr.or(CTX_INLINE_CHAT_FOCUSED, ChatContextKeys.inChatInput);
66
67
// Scoped Context Keys (set on per-chat-context scoped context key service)
68
const ScopedVoiceChatGettingReady = new RawContextKey<boolean>('scopedVoiceChatGettingReady', false, { type: 'boolean', description: localize('scopedVoiceChatGettingReady', "True when getting ready for receiving voice input from the microphone for voice chat. This key is only defined scoped, per chat context.") });
69
const ScopedVoiceChatInProgress = new RawContextKey<VoiceChatSessionContext | undefined>('scopedVoiceChatInProgress', undefined, { type: 'string', description: localize('scopedVoiceChatInProgress', "Defined as a location where voice recording from microphone is in progress for voice chat. This key is only defined scoped, per chat context.") });
70
const AnyScopedVoiceChatInProgress = ContextKeyExpr.or(...VoiceChatSessionContexts.map(context => ScopedVoiceChatInProgress.isEqualTo(context)));
71
72
enum VoiceChatSessionState {
73
Stopped = 1,
74
GettingReady,
75
Started
76
}
77
78
interface IVoiceChatSessionController {
79
80
readonly onDidAcceptInput: Event<unknown>;
81
readonly onDidHideInput: Event<unknown>;
82
83
readonly context: VoiceChatSessionContext;
84
readonly scopedContextKeyService: IContextKeyService;
85
86
updateState(state: VoiceChatSessionState): void;
87
88
focusInput(): void;
89
acceptInput(): Promise<IChatResponseModel | undefined>;
90
updateInput(text: string): void;
91
getInput(): string;
92
93
setInputPlaceholder(text: string): void;
94
clearInputPlaceholder(): void;
95
}
96
97
class VoiceChatSessionControllerFactory {
98
99
static async create(accessor: ServicesAccessor, context: 'view' | 'inline' | 'quick' | 'focused'): Promise<IVoiceChatSessionController | undefined> {
100
const chatWidgetService = accessor.get(IChatWidgetService);
101
const quickChatService = accessor.get(IQuickChatService);
102
const layoutService = accessor.get(IWorkbenchLayoutService);
103
const editorService = accessor.get(IEditorService);
104
105
switch (context) {
106
case 'focused': {
107
const controller = VoiceChatSessionControllerFactory.doCreateForFocusedChat(chatWidgetService, layoutService);
108
return controller ?? VoiceChatSessionControllerFactory.create(accessor, 'view'); // fallback to 'view'
109
}
110
case 'view': {
111
const chatWidget = await chatWidgetService.revealWidget();
112
if (chatWidget) {
113
return VoiceChatSessionControllerFactory.doCreateForChatWidget('view', chatWidget);
114
}
115
break;
116
}
117
case 'inline': {
118
const activeCodeEditor = getCodeEditor(editorService.activeTextEditorControl);
119
if (activeCodeEditor) {
120
const inlineChat = InlineChatController.get(activeCodeEditor);
121
if (inlineChat) {
122
if (!inlineChat.isActive) {
123
inlineChat.run();
124
}
125
return VoiceChatSessionControllerFactory.doCreateForChatWidget('inline', inlineChat.widget.chatWidget);
126
}
127
}
128
break;
129
}
130
case 'quick': {
131
quickChatService.open(); // this will populate focused chat widget in the chat widget service
132
return VoiceChatSessionControllerFactory.create(accessor, 'focused');
133
}
134
}
135
136
return undefined;
137
}
138
139
private static doCreateForFocusedChat(chatWidgetService: IChatWidgetService, layoutService: IWorkbenchLayoutService): IVoiceChatSessionController | undefined {
140
const chatWidget = chatWidgetService.lastFocusedWidget;
141
if (chatWidget?.hasInputFocus()) {
142
143
// Figure out the context of the chat widget by asking
144
// layout service for the part that has focus. Unfortunately
145
// there is no better way because the widget does not know
146
// its location.
147
148
let context: VoiceChatSessionContext;
149
if (layoutService.hasFocus(Parts.EDITOR_PART)) {
150
context = chatWidget.location === ChatAgentLocation.Chat ? 'editor' : 'inline';
151
} else if (
152
[Parts.SIDEBAR_PART, Parts.PANEL_PART, Parts.AUXILIARYBAR_PART, Parts.TITLEBAR_PART, Parts.STATUSBAR_PART, Parts.BANNER_PART, Parts.ACTIVITYBAR_PART].some(part => layoutService.hasFocus(part))
153
) {
154
context = 'view';
155
} else {
156
context = 'quick';
157
}
158
159
return VoiceChatSessionControllerFactory.doCreateForChatWidget(context, chatWidget);
160
}
161
162
return undefined;
163
}
164
165
private static createChatContextKeyController(contextKeyService: IContextKeyService, context: VoiceChatSessionContext): (state: VoiceChatSessionState) => void {
166
const contextVoiceChatGettingReady = ScopedVoiceChatGettingReady.bindTo(contextKeyService);
167
const contextVoiceChatInProgress = ScopedVoiceChatInProgress.bindTo(contextKeyService);
168
169
return (state: VoiceChatSessionState) => {
170
switch (state) {
171
case VoiceChatSessionState.GettingReady:
172
contextVoiceChatGettingReady.set(true);
173
contextVoiceChatInProgress.reset();
174
break;
175
case VoiceChatSessionState.Started:
176
contextVoiceChatGettingReady.reset();
177
contextVoiceChatInProgress.set(context);
178
break;
179
case VoiceChatSessionState.Stopped:
180
contextVoiceChatGettingReady.reset();
181
contextVoiceChatInProgress.reset();
182
break;
183
}
184
};
185
}
186
187
private static doCreateForChatWidget(context: VoiceChatSessionContext, chatWidget: IChatWidget): IVoiceChatSessionController {
188
return {
189
context,
190
scopedContextKeyService: chatWidget.scopedContextKeyService,
191
onDidAcceptInput: chatWidget.onDidAcceptInput,
192
onDidHideInput: chatWidget.onDidHide,
193
focusInput: () => chatWidget.focusInput(),
194
acceptInput: () => chatWidget.acceptInput(undefined, { isVoiceInput: true }),
195
updateInput: text => chatWidget.setInput(text),
196
getInput: () => chatWidget.getInput(),
197
setInputPlaceholder: text => chatWidget.setInputPlaceholder(text),
198
clearInputPlaceholder: () => chatWidget.resetInputPlaceholder(),
199
updateState: VoiceChatSessionControllerFactory.createChatContextKeyController(chatWidget.scopedContextKeyService, context)
200
};
201
}
202
}
203
204
interface IVoiceChatSession {
205
setTimeoutDisabled(disabled: boolean): void;
206
207
accept(): void;
208
stop(): void;
209
}
210
211
interface IActiveVoiceChatSession extends IVoiceChatSession {
212
readonly id: number;
213
readonly controller: IVoiceChatSessionController;
214
readonly disposables: DisposableStore;
215
216
hasRecognizedInput: boolean;
217
}
218
219
class VoiceChatSessions {
220
221
private static instance: VoiceChatSessions | undefined = undefined;
222
static getInstance(instantiationService: IInstantiationService): VoiceChatSessions {
223
if (!VoiceChatSessions.instance) {
224
VoiceChatSessions.instance = instantiationService.createInstance(VoiceChatSessions);
225
}
226
227
return VoiceChatSessions.instance;
228
}
229
230
private currentVoiceChatSession: IActiveVoiceChatSession | undefined = undefined;
231
private voiceChatSessionIds = 0;
232
233
constructor(
234
@IVoiceChatService private readonly voiceChatService: IVoiceChatService,
235
@IConfigurationService private readonly configurationService: IConfigurationService,
236
@IInstantiationService private readonly instantiationService: IInstantiationService,
237
@IAccessibilityService private readonly accessibilityService: IAccessibilityService
238
) { }
239
240
async start(controller: IVoiceChatSessionController, context?: IChatExecuteActionContext): Promise<IVoiceChatSession> {
241
242
// Stop running text-to-speech or speech-to-text sessions in chats
243
this.stop();
244
ChatSynthesizerSessions.getInstance(this.instantiationService).stop();
245
246
let disableTimeout = false;
247
248
const sessionId = ++this.voiceChatSessionIds;
249
const session: IActiveVoiceChatSession = this.currentVoiceChatSession = {
250
id: sessionId,
251
controller,
252
hasRecognizedInput: false,
253
disposables: new DisposableStore(),
254
setTimeoutDisabled: (disabled: boolean) => { disableTimeout = disabled; },
255
accept: () => this.accept(sessionId),
256
stop: () => this.stop(sessionId, controller.context)
257
};
258
259
const cts = new CancellationTokenSource();
260
session.disposables.add(toDisposable(() => cts.dispose(true)));
261
262
session.disposables.add(controller.onDidAcceptInput(() => this.stop(sessionId, controller.context)));
263
session.disposables.add(controller.onDidHideInput(() => this.stop(sessionId, controller.context)));
264
265
controller.focusInput();
266
267
controller.updateState(VoiceChatSessionState.GettingReady);
268
269
const voiceChatSession = await this.voiceChatService.createVoiceChatSession(cts.token, { usesAgents: controller.context !== 'inline', model: context?.widget?.viewModel?.model });
270
271
let inputValue = controller.getInput();
272
273
let voiceChatTimeout = this.configurationService.getValue<number>(AccessibilityVoiceSettingId.SpeechTimeout);
274
if (!isNumber(voiceChatTimeout) || voiceChatTimeout < 0) {
275
voiceChatTimeout = SpeechTimeoutDefault;
276
}
277
278
const acceptTranscriptionScheduler = session.disposables.add(new RunOnceScheduler(() => this.accept(sessionId), voiceChatTimeout));
279
session.disposables.add(voiceChatSession.onDidChange(({ status, text, waitingForInput }) => {
280
if (cts.token.isCancellationRequested) {
281
return;
282
}
283
284
switch (status) {
285
case SpeechToTextStatus.Started:
286
this.onDidSpeechToTextSessionStart(controller, session.disposables);
287
break;
288
case SpeechToTextStatus.Recognizing:
289
if (text) {
290
session.hasRecognizedInput = true;
291
session.controller.updateInput(inputValue ? [inputValue, text].join(' ') : text);
292
if (voiceChatTimeout > 0 && context?.voice?.disableTimeout !== true && !disableTimeout) {
293
acceptTranscriptionScheduler.cancel();
294
}
295
}
296
break;
297
case SpeechToTextStatus.Recognized:
298
if (text) {
299
session.hasRecognizedInput = true;
300
inputValue = inputValue ? [inputValue, text].join(' ') : text;
301
session.controller.updateInput(inputValue);
302
if (voiceChatTimeout > 0 && context?.voice?.disableTimeout !== true && !waitingForInput && !disableTimeout) {
303
acceptTranscriptionScheduler.schedule();
304
}
305
}
306
break;
307
case SpeechToTextStatus.Stopped:
308
this.stop(session.id, controller.context);
309
break;
310
}
311
}));
312
313
return session;
314
}
315
316
private onDidSpeechToTextSessionStart(controller: IVoiceChatSessionController, disposables: DisposableStore): void {
317
controller.updateState(VoiceChatSessionState.Started);
318
319
let dotCount = 0;
320
321
const updatePlaceholder = () => {
322
dotCount = (dotCount + 1) % 4;
323
controller.setInputPlaceholder(`${localize('listening', "I'm listening")}${'.'.repeat(dotCount)}`);
324
placeholderScheduler.schedule();
325
};
326
327
const placeholderScheduler = disposables.add(new RunOnceScheduler(updatePlaceholder, 500));
328
updatePlaceholder();
329
}
330
331
stop(voiceChatSessionId = this.voiceChatSessionIds, context?: VoiceChatSessionContext): void {
332
if (
333
!this.currentVoiceChatSession ||
334
this.voiceChatSessionIds !== voiceChatSessionId ||
335
(context && this.currentVoiceChatSession.controller.context !== context)
336
) {
337
return;
338
}
339
340
this.currentVoiceChatSession.controller.clearInputPlaceholder();
341
342
this.currentVoiceChatSession.controller.updateState(VoiceChatSessionState.Stopped);
343
344
this.currentVoiceChatSession.disposables.dispose();
345
this.currentVoiceChatSession = undefined;
346
}
347
348
async accept(voiceChatSessionId = this.voiceChatSessionIds): Promise<void> {
349
if (
350
!this.currentVoiceChatSession ||
351
this.voiceChatSessionIds !== voiceChatSessionId
352
) {
353
return;
354
}
355
356
if (!this.currentVoiceChatSession.hasRecognizedInput) {
357
// If we have an active session but without recognized
358
// input, we do not want to just accept the input that
359
// was maybe typed before. But we still want to stop the
360
// voice session because `acceptInput` would do that.
361
this.stop(voiceChatSessionId, this.currentVoiceChatSession.controller.context);
362
return;
363
}
364
365
const controller = this.currentVoiceChatSession.controller;
366
const response = await controller.acceptInput();
367
if (!response) {
368
return;
369
}
370
const autoSynthesize = this.configurationService.getValue<'on' | 'off'>(AccessibilityVoiceSettingId.AutoSynthesize);
371
if (autoSynthesize === 'on' || (autoSynthesize !== 'off' && !this.accessibilityService.isScreenReaderOptimized())) {
372
let context: IVoiceChatSessionController | 'focused';
373
if (controller.context === 'inline') {
374
// This is ugly, but the lightweight inline chat turns into
375
// a different widget as soon as a response comes in, so we fallback to
376
// picking up from the focused chat widget
377
context = 'focused';
378
} else {
379
context = controller;
380
}
381
ChatSynthesizerSessions.getInstance(this.instantiationService).start(this.instantiationService.invokeFunction(accessor => ChatSynthesizerSessionController.create(accessor, context, response)));
382
}
383
}
384
}
385
386
export const VOICE_KEY_HOLD_THRESHOLD = 500;
387
388
async function startVoiceChatWithHoldMode(id: string, accessor: ServicesAccessor, target: 'view' | 'inline' | 'quick' | 'focused', context?: IChatExecuteActionContext): Promise<void> {
389
const instantiationService = accessor.get(IInstantiationService);
390
const keybindingService = accessor.get(IKeybindingService);
391
392
const holdMode = keybindingService.enableKeybindingHoldMode(id);
393
394
const controller = await VoiceChatSessionControllerFactory.create(accessor, target);
395
if (!controller) {
396
return;
397
}
398
399
const session = await VoiceChatSessions.getInstance(instantiationService).start(controller, context);
400
401
let acceptVoice = false;
402
const handle = disposableTimeout(() => {
403
acceptVoice = true;
404
session?.setTimeoutDisabled(true); // disable accept on timeout when hold mode runs for VOICE_KEY_HOLD_THRESHOLD
405
}, VOICE_KEY_HOLD_THRESHOLD);
406
await holdMode;
407
handle.dispose();
408
409
if (acceptVoice) {
410
session.accept();
411
}
412
}
413
414
class VoiceChatWithHoldModeAction extends Action2 {
415
416
constructor(desc: Readonly<IAction2Options>, private readonly target: 'view' | 'inline' | 'quick') {
417
super(desc);
418
}
419
420
run(accessor: ServicesAccessor, context?: IChatExecuteActionContext): Promise<void> {
421
return startVoiceChatWithHoldMode(this.desc.id, accessor, this.target, context);
422
}
423
}
424
425
export class VoiceChatInChatViewAction extends VoiceChatWithHoldModeAction {
426
427
static readonly ID = 'workbench.action.chat.voiceChatInChatView';
428
429
constructor() {
430
super({
431
id: VoiceChatInChatViewAction.ID,
432
title: localize2('workbench.action.chat.voiceChatInView.label', "Voice Chat in Chat View"),
433
category: CHAT_CATEGORY,
434
precondition: CanVoiceChat,
435
f1: true
436
}, 'view');
437
}
438
}
439
440
export class HoldToVoiceChatInChatViewAction extends Action2 {
441
442
static readonly ID = 'workbench.action.chat.holdToVoiceChatInChatView';
443
444
constructor() {
445
super({
446
id: HoldToVoiceChatInChatViewAction.ID,
447
title: localize2('workbench.action.chat.holdToVoiceChatInChatView.label', "Hold to Voice Chat in Chat View"),
448
keybinding: {
449
weight: KeybindingWeight.WorkbenchContrib,
450
when: ContextKeyExpr.and(
451
CanVoiceChat,
452
ChatContextKeys.requestInProgress.negate(), // disable when a chat request is in progress
453
FocusInChatInput?.negate(), // when already in chat input, disable this action and prefer to start voice chat directly
454
EditorContextKeys.focus.negate(), // do not steal the inline-chat keybinding
455
NOTEBOOK_EDITOR_FOCUSED.negate(), // do not steal the notebook keybinding
456
SearchContext.SearchViewFocusedKey.negate(), // do not steal the search keybinding
457
CONTEXT_SETTINGS_EDITOR.negate(), // do not steal the settings editor keybinding
458
),
459
primary: KeyMod.CtrlCmd | KeyCode.KeyI
460
}
461
});
462
}
463
464
override async run(accessor: ServicesAccessor, context?: IChatExecuteActionContext): Promise<void> {
465
466
// The intent of this action is to provide 2 modes to align with what `Ctrlcmd+I` in inline chat:
467
// - if the user press and holds, we start voice chat in the chat view
468
// - if the user press and releases quickly enough, we just open the chat view without voice chat
469
470
const instantiationService = accessor.get(IInstantiationService);
471
const keybindingService = accessor.get(IKeybindingService);
472
const widgetService = accessor.get(IChatWidgetService);
473
474
const holdMode = keybindingService.enableKeybindingHoldMode(HoldToVoiceChatInChatViewAction.ID);
475
476
let session: IVoiceChatSession | undefined;
477
const handle = disposableTimeout(async () => {
478
const controller = await VoiceChatSessionControllerFactory.create(accessor, 'view');
479
if (controller) {
480
session = await VoiceChatSessions.getInstance(instantiationService).start(controller, context);
481
session.setTimeoutDisabled(true);
482
}
483
}, VOICE_KEY_HOLD_THRESHOLD);
484
485
(await widgetService.revealWidget())?.focusInput();
486
487
await holdMode;
488
handle.dispose();
489
490
if (session) {
491
session.accept();
492
}
493
}
494
}
495
496
export class InlineVoiceChatAction extends VoiceChatWithHoldModeAction {
497
498
static readonly ID = 'workbench.action.chat.inlineVoiceChat';
499
500
constructor() {
501
super({
502
id: InlineVoiceChatAction.ID,
503
title: localize2('workbench.action.chat.inlineVoiceChat', "Inline Voice Chat"),
504
category: CHAT_CATEGORY,
505
precondition: ContextKeyExpr.and(
506
CanVoiceChat,
507
ActiveEditorContext,
508
),
509
f1: true
510
}, 'inline');
511
}
512
}
513
514
export class QuickVoiceChatAction extends VoiceChatWithHoldModeAction {
515
516
static readonly ID = 'workbench.action.chat.quickVoiceChat';
517
518
constructor() {
519
super({
520
id: QuickVoiceChatAction.ID,
521
title: localize2('workbench.action.chat.quickVoiceChat.label', "Quick Voice Chat"),
522
category: CHAT_CATEGORY,
523
precondition: CanVoiceChat,
524
f1: true
525
}, 'quick');
526
}
527
}
528
529
const primaryVoiceActionMenu = (when: ContextKeyExpression | undefined) => {
530
return [
531
{
532
id: MenuId.ChatExecute,
533
when: ContextKeyExpr.and(ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), when),
534
group: 'navigation',
535
order: 3
536
},
537
{
538
id: MenuId.ChatExecute,
539
when: ContextKeyExpr.and(ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat).negate(), when),
540
group: 'navigation',
541
order: 2
542
}
543
];
544
};
545
546
export class StartVoiceChatAction extends Action2 {
547
548
static readonly ID = 'workbench.action.chat.startVoiceChat';
549
550
constructor() {
551
super({
552
id: StartVoiceChatAction.ID,
553
title: localize2('workbench.action.chat.startVoiceChat.label', "Start Voice Chat"),
554
category: CHAT_CATEGORY,
555
f1: true,
556
keybinding: {
557
weight: KeybindingWeight.WorkbenchContrib,
558
when: ContextKeyExpr.and(
559
FocusInChatInput, // scope this action to chat input fields only
560
EditorContextKeys.focus.negate(), // do not steal the editor inline-chat keybinding
561
NOTEBOOK_EDITOR_FOCUSED.negate() // do not steal the notebook inline-chat keybinding
562
),
563
primary: KeyMod.CtrlCmd | KeyCode.KeyI
564
},
565
icon: Codicon.mic,
566
precondition: ContextKeyExpr.and(
567
CanVoiceChat,
568
ScopedVoiceChatGettingReady.negate(), // disable when voice chat is getting ready
569
SpeechToTextInProgress.negate() // disable when speech to text is in progress
570
),
571
menu: primaryVoiceActionMenu(ContextKeyExpr.and(
572
HasSpeechProvider,
573
ScopedChatSynthesisInProgress.negate(), // hide when text to speech is in progress
574
AnyScopedVoiceChatInProgress?.negate(), // hide when voice chat is in progress
575
))
576
});
577
}
578
579
async run(accessor: ServicesAccessor, context?: IChatExecuteActionContext): Promise<void> {
580
const widget = context?.widget;
581
if (widget) {
582
// if we already get a context when the action is executed
583
// from a toolbar within the chat widget, then make sure
584
// to move focus into the input field so that the controller
585
// is properly retrieved
586
widget.focusInput();
587
}
588
589
return startVoiceChatWithHoldMode(this.desc.id, accessor, 'focused', context);
590
}
591
}
592
593
export class StopListeningAction extends Action2 {
594
595
static readonly ID = 'workbench.action.chat.stopListening';
596
597
constructor() {
598
super({
599
id: StopListeningAction.ID,
600
title: localize2('workbench.action.chat.stopListening.label', "Stop Listening"),
601
category: CHAT_CATEGORY,
602
f1: true,
603
keybinding: {
604
weight: KeybindingWeight.WorkbenchContrib + 100,
605
primary: KeyCode.Escape,
606
when: AnyScopedVoiceChatInProgress
607
},
608
icon: spinningLoading,
609
precondition: GlobalVoiceChatInProgress, // need global context here because of `f1: true`
610
menu: primaryVoiceActionMenu(AnyScopedVoiceChatInProgress)
611
});
612
}
613
614
async run(accessor: ServicesAccessor): Promise<void> {
615
VoiceChatSessions.getInstance(accessor.get(IInstantiationService)).stop();
616
}
617
}
618
619
export class StopListeningAndSubmitAction extends Action2 {
620
621
static readonly ID = 'workbench.action.chat.stopListeningAndSubmit';
622
623
constructor() {
624
super({
625
id: StopListeningAndSubmitAction.ID,
626
title: localize2('workbench.action.chat.stopListeningAndSubmit.label', "Stop Listening and Submit"),
627
category: CHAT_CATEGORY,
628
f1: true,
629
keybinding: {
630
weight: KeybindingWeight.WorkbenchContrib,
631
when: ContextKeyExpr.and(
632
FocusInChatInput,
633
AnyScopedVoiceChatInProgress
634
),
635
primary: KeyMod.CtrlCmd | KeyCode.KeyI
636
},
637
precondition: GlobalVoiceChatInProgress // need global context here because of `f1: true`
638
});
639
}
640
641
run(accessor: ServicesAccessor): void {
642
VoiceChatSessions.getInstance(accessor.get(IInstantiationService)).accept();
643
}
644
}
645
646
//#endregion
647
648
//#region Text to Speech
649
650
const ScopedChatSynthesisInProgress = new RawContextKey<boolean>('scopedChatSynthesisInProgress', false, { type: 'boolean', description: localize('scopedChatSynthesisInProgress', "Defined as a location where voice recording from microphone is in progress for voice chat. This key is only defined scoped, per chat context.") });
651
652
interface IChatSynthesizerSessionController {
653
654
readonly onDidHideChat: Event<unknown>;
655
656
readonly contextKeyService: IContextKeyService;
657
readonly response: IChatResponseModel;
658
}
659
660
class ChatSynthesizerSessionController {
661
662
static create(accessor: ServicesAccessor, context: IVoiceChatSessionController | 'focused', response: IChatResponseModel): IChatSynthesizerSessionController {
663
if (context === 'focused') {
664
return ChatSynthesizerSessionController.doCreateForFocusedChat(accessor, response);
665
} else {
666
return {
667
onDidHideChat: context.onDidHideInput,
668
contextKeyService: context.scopedContextKeyService,
669
response
670
};
671
}
672
}
673
674
private static doCreateForFocusedChat(accessor: ServicesAccessor, response: IChatResponseModel): IChatSynthesizerSessionController {
675
const chatWidgetService = accessor.get(IChatWidgetService);
676
const contextKeyService = accessor.get(IContextKeyService);
677
let chatWidget = chatWidgetService.getWidgetBySessionResource(response.session.sessionResource);
678
if (chatWidget?.location === ChatAgentLocation.EditorInline) {
679
chatWidget = chatWidgetService.lastFocusedWidget; // workaround for https://github.com/microsoft/vscode/issues/212785
680
}
681
682
return {
683
onDidHideChat: chatWidget?.onDidHide ?? Event.None,
684
contextKeyService: chatWidget?.scopedContextKeyService ?? contextKeyService,
685
response
686
};
687
}
688
}
689
690
interface IChatSynthesizerContext {
691
readonly ignoreCodeBlocks: boolean;
692
insideCodeBlock: boolean;
693
}
694
695
class ChatSynthesizerSessions {
696
697
private static instance: ChatSynthesizerSessions | undefined = undefined;
698
static getInstance(instantiationService: IInstantiationService): ChatSynthesizerSessions {
699
if (!ChatSynthesizerSessions.instance) {
700
ChatSynthesizerSessions.instance = instantiationService.createInstance(ChatSynthesizerSessions);
701
}
702
703
return ChatSynthesizerSessions.instance;
704
}
705
706
private activeSession: CancellationTokenSource | undefined = undefined;
707
708
constructor(
709
@ISpeechService private readonly speechService: ISpeechService,
710
@IConfigurationService private readonly configurationService: IConfigurationService,
711
@IInstantiationService private readonly instantiationService: IInstantiationService
712
) { }
713
714
async start(controller: IChatSynthesizerSessionController): Promise<void> {
715
716
// Stop running text-to-speech or speech-to-text sessions in chats
717
this.stop();
718
VoiceChatSessions.getInstance(this.instantiationService).stop();
719
720
const activeSession = this.activeSession = new CancellationTokenSource();
721
722
const disposables = new DisposableStore();
723
disposables.add(activeSession.token.onCancellationRequested(() => disposables.dispose()));
724
725
const session = await this.speechService.createTextToSpeechSession(activeSession.token, 'chat');
726
727
if (activeSession.token.isCancellationRequested) {
728
return;
729
}
730
731
disposables.add(controller.onDidHideChat(() => this.stop()));
732
733
const scopedChatToSpeechInProgress = ScopedChatSynthesisInProgress.bindTo(controller.contextKeyService);
734
disposables.add(toDisposable(() => scopedChatToSpeechInProgress.reset()));
735
736
disposables.add(session.onDidChange(e => {
737
switch (e.status) {
738
case TextToSpeechStatus.Started:
739
scopedChatToSpeechInProgress.set(true);
740
break;
741
case TextToSpeechStatus.Stopped:
742
scopedChatToSpeechInProgress.reset();
743
break;
744
}
745
}));
746
747
for await (const chunk of this.nextChatResponseChunk(controller.response, activeSession.token)) {
748
if (activeSession.token.isCancellationRequested) {
749
return;
750
}
751
752
await raceCancellation(session.synthesize(chunk), activeSession.token);
753
}
754
}
755
756
private async *nextChatResponseChunk(response: IChatResponseModel, token: CancellationToken): AsyncIterable<string> {
757
const context: IChatSynthesizerContext = {
758
ignoreCodeBlocks: this.configurationService.getValue<boolean>(AccessibilityVoiceSettingId.IgnoreCodeBlocks),
759
insideCodeBlock: false
760
};
761
762
let totalOffset = 0;
763
let complete = false;
764
do {
765
const responseLength = response.response.toString().length;
766
const { chunk, offset } = this.parseNextChatResponseChunk(response, totalOffset, context);
767
totalOffset = offset;
768
complete = response.isComplete;
769
770
if (chunk) {
771
yield chunk;
772
}
773
774
if (token.isCancellationRequested) {
775
return;
776
}
777
778
if (!complete && responseLength === response.response.toString().length) {
779
await raceCancellation(Event.toPromise(response.onDidChange), token); // wait for the response to change
780
}
781
} while (!token.isCancellationRequested && !complete);
782
}
783
784
private parseNextChatResponseChunk(response: IChatResponseModel, offset: number, context: IChatSynthesizerContext): { readonly chunk: string | undefined; readonly offset: number } {
785
let chunk: string | undefined = undefined;
786
787
const text = response.response.toString();
788
789
if (response.isComplete) {
790
chunk = text.substring(offset);
791
offset = text.length + 1;
792
} else {
793
const res = parseNextChatResponseChunk(text, offset);
794
chunk = res.chunk;
795
offset = res.offset;
796
}
797
798
if (chunk && context.ignoreCodeBlocks) {
799
chunk = this.filterCodeBlocks(chunk, context);
800
}
801
802
return {
803
chunk: chunk ? renderAsPlaintext({ value: chunk }) : chunk, // convert markdown to plain text
804
offset
805
};
806
}
807
808
private filterCodeBlocks(chunk: string, context: IChatSynthesizerContext): string {
809
return chunk.split('\n')
810
.filter(line => {
811
if (line.trimStart().startsWith('```')) {
812
context.insideCodeBlock = !context.insideCodeBlock;
813
return false;
814
}
815
return !context.insideCodeBlock;
816
})
817
.join('\n');
818
}
819
820
stop(): void {
821
this.activeSession?.dispose(true);
822
this.activeSession = undefined;
823
}
824
}
825
826
const sentenceDelimiter = ['.', '!', '?', ':'];
827
const lineDelimiter = '\n';
828
const wordDelimiter = ' ';
829
830
export function parseNextChatResponseChunk(text: string, offset: number): { readonly chunk: string | undefined; readonly offset: number } {
831
let chunk: string | undefined = undefined;
832
833
for (let i = text.length - 1; i >= offset; i--) { // going from end to start to produce largest chunks
834
const cur = text[i];
835
const next = text[i + 1];
836
if (
837
sentenceDelimiter.includes(cur) && next === wordDelimiter || // end of sentence
838
lineDelimiter === cur // end of line
839
) {
840
chunk = text.substring(offset, i + 1).trim();
841
offset = i + 1;
842
break;
843
}
844
}
845
846
return { chunk, offset };
847
}
848
849
export class ReadChatResponseAloud extends Action2 {
850
constructor() {
851
super({
852
id: 'workbench.action.chat.readChatResponseAloud',
853
title: localize2('workbench.action.chat.readChatResponseAloud', "Read Aloud"),
854
icon: Codicon.unmute,
855
precondition: CanVoiceChat,
856
menu: [{
857
id: MenuId.ChatMessageFooter,
858
when: ContextKeyExpr.and(
859
CanVoiceChat,
860
ChatContextKeys.isResponse, // only for responses
861
ScopedChatSynthesisInProgress.negate(), // but not when already in progress
862
ChatContextKeys.responseIsFiltered.negate(), // and not when response is filtered
863
),
864
group: 'navigation',
865
order: -10 // first
866
}, {
867
id: MENU_INLINE_CHAT_WIDGET_SECONDARY,
868
when: ContextKeyExpr.and(
869
CanVoiceChat,
870
ChatContextKeys.isResponse, // only for responses
871
ScopedChatSynthesisInProgress.negate(), // but not when already in progress
872
ChatContextKeys.responseIsFiltered.negate() // and not when response is filtered
873
),
874
group: 'navigation',
875
order: -10 // first
876
}]
877
});
878
}
879
880
run(accessor: ServicesAccessor, ...args: unknown[]) {
881
const instantiationService = accessor.get(IInstantiationService);
882
const chatWidgetService = accessor.get(IChatWidgetService);
883
884
let response: IChatResponseViewModel | undefined = undefined;
885
if (args.length > 0) {
886
const responseArg = args[0];
887
if (isResponseVM(responseArg)) {
888
response = responseArg;
889
}
890
} else {
891
const chatWidget = chatWidgetService.lastFocusedWidget;
892
if (chatWidget) {
893
894
// pick focused response
895
const focus = chatWidget.getFocus();
896
if (focus instanceof ChatResponseViewModel) {
897
response = focus;
898
}
899
900
// pick the last response
901
else {
902
const chatViewModel = chatWidget.viewModel;
903
if (chatViewModel) {
904
const items = chatViewModel.getItems();
905
for (let i = items.length - 1; i >= 0; i--) {
906
const item = items[i];
907
if (isResponseVM(item)) {
908
response = item;
909
break;
910
}
911
}
912
}
913
}
914
}
915
}
916
917
if (!response) {
918
return;
919
}
920
921
const controller = ChatSynthesizerSessionController.create(accessor, 'focused', response.model);
922
ChatSynthesizerSessions.getInstance(instantiationService).start(controller);
923
}
924
}
925
926
export class StopReadAloud extends Action2 {
927
928
static readonly ID = 'workbench.action.speech.stopReadAloud';
929
930
constructor() {
931
super({
932
id: StopReadAloud.ID,
933
icon: syncing,
934
title: localize2('workbench.action.speech.stopReadAloud', "Stop Reading Aloud"),
935
f1: true,
936
category: CHAT_CATEGORY,
937
precondition: GlobalTextToSpeechInProgress, // need global context here because of `f1: true`
938
keybinding: {
939
weight: KeybindingWeight.WorkbenchContrib + 100,
940
primary: KeyCode.Escape,
941
when: ScopedChatSynthesisInProgress
942
},
943
menu: primaryVoiceActionMenu(ScopedChatSynthesisInProgress)
944
});
945
}
946
947
async run(accessor: ServicesAccessor) {
948
ChatSynthesizerSessions.getInstance(accessor.get(IInstantiationService)).stop();
949
}
950
}
951
952
export class StopReadChatItemAloud extends Action2 {
953
954
static readonly ID = 'workbench.action.chat.stopReadChatItemAloud';
955
956
constructor() {
957
super({
958
id: StopReadChatItemAloud.ID,
959
icon: Codicon.mute,
960
title: localize2('workbench.action.chat.stopReadChatItemAloud', "Stop Reading Aloud"),
961
precondition: ScopedChatSynthesisInProgress,
962
keybinding: {
963
weight: KeybindingWeight.WorkbenchContrib + 100,
964
primary: KeyCode.Escape,
965
},
966
menu: [
967
{
968
id: MenuId.ChatMessageFooter,
969
when: ContextKeyExpr.and(
970
ScopedChatSynthesisInProgress, // only when in progress
971
ChatContextKeys.isResponse, // only for responses
972
ChatContextKeys.responseIsFiltered.negate() // but not when response is filtered
973
),
974
group: 'navigation',
975
order: -10 // first
976
},
977
{
978
id: MENU_INLINE_CHAT_WIDGET_SECONDARY,
979
when: ContextKeyExpr.and(
980
ScopedChatSynthesisInProgress, // only when in progress
981
ChatContextKeys.isResponse, // only for responses
982
ChatContextKeys.responseIsFiltered.negate() // but not when response is filtered
983
),
984
group: 'navigation',
985
order: -10 // first
986
}
987
]
988
});
989
}
990
991
async run(accessor: ServicesAccessor, ...args: unknown[]) {
992
ChatSynthesizerSessions.getInstance(accessor.get(IInstantiationService)).stop();
993
}
994
}
995
996
//#endregion
997
998
//#region Keyword Recognition
999
1000
function supportsKeywordActivation(configurationService: IConfigurationService, speechService: ISpeechService, chatAgentService: IChatAgentService): boolean {
1001
if (!speechService.hasSpeechProvider || !chatAgentService.getDefaultAgent(ChatAgentLocation.Chat)) {
1002
return false;
1003
}
1004
1005
const value = configurationService.getValue(KEYWORD_ACTIVIATION_SETTING_ID);
1006
1007
return typeof value === 'string' && value !== KeywordActivationContribution.SETTINGS_VALUE.OFF;
1008
}
1009
1010
export class KeywordActivationContribution extends Disposable implements IWorkbenchContribution {
1011
1012
static readonly ID = 'workbench.contrib.keywordActivation';
1013
1014
static SETTINGS_VALUE = {
1015
OFF: 'off',
1016
INLINE_CHAT: 'inlineChat',
1017
QUICK_CHAT: 'quickChat',
1018
VIEW_CHAT: 'chatInView',
1019
CHAT_IN_CONTEXT: 'chatInContext'
1020
};
1021
1022
private activeSession: CancellationTokenSource | undefined = undefined;
1023
1024
constructor(
1025
@ISpeechService private readonly speechService: ISpeechService,
1026
@IConfigurationService private readonly configurationService: IConfigurationService,
1027
@ICommandService private readonly commandService: ICommandService,
1028
@IInstantiationService instantiationService: IInstantiationService,
1029
@IEditorService private readonly editorService: IEditorService,
1030
@IHostService private readonly hostService: IHostService,
1031
@IChatAgentService private readonly chatAgentService: IChatAgentService,
1032
) {
1033
super();
1034
1035
this._register(instantiationService.createInstance(KeywordActivationStatusEntry));
1036
1037
this.registerListeners();
1038
}
1039
1040
private registerListeners(): void {
1041
this._register(Event.runAndSubscribe(this.speechService.onDidChangeHasSpeechProvider, () => {
1042
this.updateConfiguration();
1043
this.handleKeywordActivation();
1044
}));
1045
1046
const onDidAddDefaultAgent = this._register(this.chatAgentService.onDidChangeAgents(() => {
1047
if (this.chatAgentService.getDefaultAgent(ChatAgentLocation.Chat)) {
1048
this.updateConfiguration();
1049
this.handleKeywordActivation();
1050
1051
onDidAddDefaultAgent.dispose();
1052
}
1053
}));
1054
1055
this._register(this.speechService.onDidStartSpeechToTextSession(() => this.handleKeywordActivation()));
1056
this._register(this.speechService.onDidEndSpeechToTextSession(() => this.handleKeywordActivation()));
1057
1058
this._register(this.configurationService.onDidChangeConfiguration(e => {
1059
if (e.affectsConfiguration(KEYWORD_ACTIVIATION_SETTING_ID)) {
1060
this.handleKeywordActivation();
1061
}
1062
}));
1063
}
1064
1065
private updateConfiguration(): void {
1066
if (!this.speechService.hasSpeechProvider || !this.chatAgentService.getDefaultAgent(ChatAgentLocation.Chat)) {
1067
return; // these settings require a speech and chat provider
1068
}
1069
1070
const registry = Registry.as<IConfigurationRegistry>(Extensions.Configuration);
1071
registry.registerConfiguration({
1072
...accessibilityConfigurationNodeBase,
1073
properties: {
1074
[KEYWORD_ACTIVIATION_SETTING_ID]: {
1075
'type': 'string',
1076
'enum': [
1077
KeywordActivationContribution.SETTINGS_VALUE.OFF,
1078
KeywordActivationContribution.SETTINGS_VALUE.VIEW_CHAT,
1079
KeywordActivationContribution.SETTINGS_VALUE.QUICK_CHAT,
1080
KeywordActivationContribution.SETTINGS_VALUE.INLINE_CHAT,
1081
KeywordActivationContribution.SETTINGS_VALUE.CHAT_IN_CONTEXT
1082
],
1083
'enumDescriptions': [
1084
localize('voice.keywordActivation.off', "Keyword activation is disabled."),
1085
localize('voice.keywordActivation.chatInView', "Keyword activation is enabled and listening for 'Hey Code' to start a voice chat session in the chat view."),
1086
localize('voice.keywordActivation.quickChat', "Keyword activation is enabled and listening for 'Hey Code' to start a voice chat session in the quick chat."),
1087
localize('voice.keywordActivation.inlineChat', "Keyword activation is enabled and listening for 'Hey Code' to start a voice chat session in the active editor if possible."),
1088
localize('voice.keywordActivation.chatInContext', "Keyword activation is enabled and listening for 'Hey Code' to start a voice chat session in the active editor or view depending on keyboard focus.")
1089
],
1090
'description': localize('voice.keywordActivation', "Controls whether the keyword phrase 'Hey Code' is recognized to start a voice chat session. Enabling this will start recording from the microphone but the audio is processed locally and never sent to a server."),
1091
'default': 'off',
1092
'tags': ['accessibility']
1093
}
1094
}
1095
});
1096
}
1097
1098
private handleKeywordActivation(): void {
1099
const enabled =
1100
supportsKeywordActivation(this.configurationService, this.speechService, this.chatAgentService) &&
1101
!this.speechService.hasActiveSpeechToTextSession;
1102
if (
1103
(enabled && this.activeSession) ||
1104
(!enabled && !this.activeSession)
1105
) {
1106
return; // already running or stopped
1107
}
1108
1109
// Start keyword activation
1110
if (enabled) {
1111
this.enableKeywordActivation();
1112
}
1113
1114
// Stop keyword activation
1115
else {
1116
this.disableKeywordActivation();
1117
}
1118
}
1119
1120
private async enableKeywordActivation(): Promise<void> {
1121
const session = this.activeSession = new CancellationTokenSource();
1122
const result = await this.speechService.recognizeKeyword(session.token);
1123
if (session.token.isCancellationRequested || session !== this.activeSession) {
1124
return; // cancelled
1125
}
1126
1127
this.activeSession = undefined;
1128
1129
if (result === KeywordRecognitionStatus.Recognized) {
1130
if (this.hostService.hasFocus) {
1131
this.commandService.executeCommand(this.getKeywordCommand());
1132
}
1133
1134
// Immediately start another keyboard activation session
1135
// because we cannot assume that the command we execute
1136
// will trigger a speech recognition session.
1137
1138
this.handleKeywordActivation();
1139
}
1140
}
1141
1142
private getKeywordCommand(): string {
1143
const setting = this.configurationService.getValue(KEYWORD_ACTIVIATION_SETTING_ID);
1144
switch (setting) {
1145
case KeywordActivationContribution.SETTINGS_VALUE.INLINE_CHAT:
1146
return InlineVoiceChatAction.ID;
1147
case KeywordActivationContribution.SETTINGS_VALUE.QUICK_CHAT:
1148
return QuickVoiceChatAction.ID;
1149
case KeywordActivationContribution.SETTINGS_VALUE.CHAT_IN_CONTEXT: {
1150
const activeCodeEditor = getCodeEditor(this.editorService.activeTextEditorControl);
1151
if (activeCodeEditor?.hasWidgetFocus()) {
1152
return InlineVoiceChatAction.ID;
1153
}
1154
}
1155
default:
1156
return VoiceChatInChatViewAction.ID;
1157
}
1158
}
1159
1160
private disableKeywordActivation(): void {
1161
this.activeSession?.dispose(true);
1162
this.activeSession = undefined;
1163
}
1164
1165
override dispose(): void {
1166
this.activeSession?.dispose();
1167
1168
super.dispose();
1169
}
1170
}
1171
1172
class KeywordActivationStatusEntry extends Disposable {
1173
1174
private readonly entry = this._register(new MutableDisposable<IStatusbarEntryAccessor>());
1175
1176
private static STATUS_NAME = localize('keywordActivation.status.name', "Voice Keyword Activation");
1177
private static STATUS_COMMAND = 'keywordActivation.status.command';
1178
private static STATUS_ACTIVE = localize('keywordActivation.status.active', "Listening to 'Hey Code'...");
1179
private static STATUS_INACTIVE = localize('keywordActivation.status.inactive', "Waiting for voice chat to end...");
1180
1181
constructor(
1182
@ISpeechService private readonly speechService: ISpeechService,
1183
@IStatusbarService private readonly statusbarService: IStatusbarService,
1184
@ICommandService private readonly commandService: ICommandService,
1185
@IConfigurationService private readonly configurationService: IConfigurationService,
1186
@IChatAgentService private readonly chatAgentService: IChatAgentService
1187
) {
1188
super();
1189
1190
this._register(CommandsRegistry.registerCommand(KeywordActivationStatusEntry.STATUS_COMMAND, () => this.commandService.executeCommand('workbench.action.openSettings', KEYWORD_ACTIVIATION_SETTING_ID)));
1191
1192
this.registerListeners();
1193
this.updateStatusEntry();
1194
}
1195
1196
private registerListeners(): void {
1197
this._register(this.speechService.onDidStartKeywordRecognition(() => this.updateStatusEntry()));
1198
this._register(this.speechService.onDidEndKeywordRecognition(() => this.updateStatusEntry()));
1199
this._register(this.configurationService.onDidChangeConfiguration(e => {
1200
if (e.affectsConfiguration(KEYWORD_ACTIVIATION_SETTING_ID)) {
1201
this.updateStatusEntry();
1202
}
1203
}));
1204
}
1205
1206
private updateStatusEntry(): void {
1207
const visible = supportsKeywordActivation(this.configurationService, this.speechService, this.chatAgentService);
1208
if (visible) {
1209
if (!this.entry.value) {
1210
this.createStatusEntry();
1211
}
1212
1213
this.updateStatusLabel();
1214
} else {
1215
this.entry.clear();
1216
}
1217
}
1218
1219
private createStatusEntry() {
1220
this.entry.value = this.statusbarService.addEntry(this.getStatusEntryProperties(), 'status.voiceKeywordActivation', StatusbarAlignment.RIGHT, 103);
1221
}
1222
1223
private getStatusEntryProperties(): IStatusbarEntry {
1224
return {
1225
name: KeywordActivationStatusEntry.STATUS_NAME,
1226
text: this.speechService.hasActiveKeywordRecognition ? '$(mic-filled)' : '$(mic)',
1227
tooltip: this.speechService.hasActiveKeywordRecognition ? KeywordActivationStatusEntry.STATUS_ACTIVE : KeywordActivationStatusEntry.STATUS_INACTIVE,
1228
ariaLabel: this.speechService.hasActiveKeywordRecognition ? KeywordActivationStatusEntry.STATUS_ACTIVE : KeywordActivationStatusEntry.STATUS_INACTIVE,
1229
command: KeywordActivationStatusEntry.STATUS_COMMAND,
1230
kind: 'prominent',
1231
showInAllWindows: true
1232
};
1233
}
1234
1235
private updateStatusLabel(): void {
1236
this.entry.value?.update(this.getStatusEntryProperties());
1237
}
1238
}
1239
1240
//#endregion
1241
1242
registerThemingParticipant((theme, collector) => {
1243
let activeRecordingColor: Color | undefined;
1244
let activeRecordingDimmedColor: Color | undefined;
1245
if (!isHighContrast(theme.type)) {
1246
activeRecordingColor = theme.getColor(ACTIVITY_BAR_FOREGROUND) ?? theme.getColor(focusBorder);
1247
activeRecordingDimmedColor = activeRecordingColor?.transparent(0.38);
1248
} else {
1249
activeRecordingColor = theme.getColor(contrastBorder);
1250
activeRecordingDimmedColor = theme.getColor(contrastBorder);
1251
}
1252
1253
// Show a "microphone" or "pulse" icon when speech-to-text or text-to-speech is in progress that glows via outline.
1254
collector.addRule(`
1255
.monaco-workbench.monaco-enable-motion .interactive-input-part .monaco-action-bar .action-label.codicon-sync.codicon-modifier-spin:not(.disabled),
1256
.monaco-workbench.monaco-enable-motion .interactive-input-part .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled) {
1257
color: ${activeRecordingColor};
1258
outline: 1px solid ${activeRecordingColor};
1259
outline-offset: -1px;
1260
animation: pulseAnimation 1s infinite;
1261
border-radius: 50%;
1262
}
1263
1264
.monaco-workbench.monaco-enable-motion .interactive-input-part .monaco-action-bar .action-label.codicon-sync.codicon-modifier-spin:not(.disabled)::before,
1265
.monaco-workbench.monaco-enable-motion .interactive-input-part .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled)::before {
1266
position: absolute;
1267
outline: 1px solid ${activeRecordingColor};
1268
outline-offset: 2px;
1269
border-radius: 50%;
1270
width: 16px;
1271
height: 16px;
1272
}
1273
1274
.monaco-workbench.monaco-enable-motion .interactive-input-part .monaco-action-bar .action-label.codicon-sync.codicon-modifier-spin:not(.disabled)::after,
1275
.monaco-workbench.monaco-enable-motion .interactive-input-part .monaco-action-bar .action-label.codicon-loading.codicon-modifier-spin:not(.disabled)::after {
1276
outline: 2px solid ${activeRecordingColor};
1277
outline-offset: -1px;
1278
animation: pulseAnimation 1500ms cubic-bezier(0.75, 0, 0.25, 1) infinite;
1279
}
1280
1281
@keyframes pulseAnimation {
1282
0% {
1283
outline-width: 2px;
1284
}
1285
62% {
1286
outline-width: 5px;
1287
outline-color: ${activeRecordingDimmedColor};
1288
}
1289
100% {
1290
outline-width: 2px;
1291
}
1292
}
1293
`);
1294
});
1295
1296