Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/widgetHosts/chatQuick.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 * as dom from '../../../../../base/browser/dom.js';
7
import { Orientation, Sash } from '../../../../../base/browser/ui/sash/sash.js';
8
import { disposableTimeout } from '../../../../../base/common/async.js';
9
import { Emitter, Event } from '../../../../../base/common/event.js';
10
import { MarkdownString } from '../../../../../base/common/htmlContent.js';
11
import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js';
12
import { autorun } from '../../../../../base/common/observable.js';
13
import { URI } from '../../../../../base/common/uri.js';
14
import { Selection } from '../../../../../editor/common/core/selection.js';
15
import { localize } from '../../../../../nls.js';
16
import { MenuId } from '../../../../../platform/actions/common/actions.js';
17
import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
18
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
19
import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';
20
import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js';
21
import product from '../../../../../platform/product/common/product.js';
22
import { IQuickInputService, IQuickWidget } from '../../../../../platform/quickinput/common/quickInput.js';
23
import { editorBackground, inputBackground, quickInputBackground, quickInputForeground } from '../../../../../platform/theme/common/colorRegistry.js';
24
import { EDITOR_DRAG_AND_DROP_BACKGROUND } from '../../../../common/theme.js';
25
import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js';
26
import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js';
27
import { isCellTextEditOperationArray } from '../../common/model/chatModel.js';
28
import { ChatMode } from '../../common/chatModes.js';
29
import { IParsedChatRequest } from '../../common/requestParser/chatParserTypes.js';
30
import { IChatModelReference, IChatProgress, IChatService } from '../../common/chatService/chatService.js';
31
import { ChatAgentLocation } from '../../common/constants.js';
32
import { IChatWidgetService, IQuickChatOpenOptions, IQuickChatService } from '../chat.js';
33
import { ChatWidget } from '../widget/chatWidget.js';
34
35
export class QuickChatService extends Disposable implements IQuickChatService {
36
readonly _serviceBrand: undefined;
37
38
private readonly _onDidClose = this._register(new Emitter<void>());
39
get onDidClose() { return this._onDidClose.event; }
40
41
private _input: IQuickWidget | undefined;
42
// TODO@TylerLeonhardt: support multiple chat providers eventually
43
private _currentChat: QuickChat | undefined;
44
private _container: HTMLElement | undefined;
45
46
constructor(
47
@IQuickInputService private readonly quickInputService: IQuickInputService,
48
@IChatService private readonly chatService: IChatService,
49
@IInstantiationService private readonly instantiationService: IInstantiationService,
50
) {
51
super();
52
}
53
54
get enabled(): boolean {
55
return !!this.chatService.isEnabled(ChatAgentLocation.Chat);
56
}
57
58
get focused(): boolean {
59
const widget = this._input?.widget as HTMLElement | undefined;
60
if (!widget) {
61
return false;
62
}
63
return dom.isAncestorOfActiveElement(widget);
64
}
65
66
get sessionResource(): URI | undefined {
67
return this._input && this._currentChat?.sessionResource;
68
}
69
70
toggle(options?: IQuickChatOpenOptions): void {
71
// If the input is already shown, hide it. This provides a toggle behavior of the quick
72
// pick. This should not happen when there is a query.
73
if (this.focused && !options?.query) {
74
this.close();
75
} else {
76
this.open(options);
77
// If this is a partial query, the value should be cleared when closed as otherwise it
78
// would remain for the next time the quick chat is opened in any context.
79
if (options?.isPartialQuery) {
80
const disposable = this._store.add(Event.once(this.onDidClose)(() => {
81
this._currentChat?.clearValue();
82
this._store.delete(disposable);
83
}));
84
}
85
}
86
}
87
88
open(options?: IQuickChatOpenOptions): void {
89
if (this._input) {
90
if (this._currentChat && options?.query) {
91
this._currentChat.focus();
92
this._currentChat.setValue(options.query, options.selection);
93
if (!options.isPartialQuery) {
94
this._currentChat.acceptInput();
95
}
96
return;
97
}
98
return this.focus();
99
}
100
101
const disposableStore = new DisposableStore();
102
103
this._input = this.quickInputService.createQuickWidget();
104
this._input.contextKey = 'chatInputVisible';
105
this._input.ignoreFocusOut = true;
106
disposableStore.add(this._input);
107
108
this._container ??= dom.$('.interactive-session');
109
this._input.widget = this._container;
110
111
this._input.show();
112
if (!this._currentChat) {
113
this._currentChat = this.instantiationService.createInstance(QuickChat);
114
115
// show needs to come after the quickpick is shown
116
this._currentChat.render(this._container);
117
} else {
118
this._currentChat.show();
119
}
120
121
disposableStore.add(this._input.onDidHide(() => {
122
disposableStore.dispose();
123
this._currentChat!.hide();
124
this._input = undefined;
125
this._onDidClose.fire();
126
}));
127
128
this._currentChat.focus();
129
130
if (options?.query) {
131
this._currentChat.setValue(options.query, options.selection);
132
if (!options.isPartialQuery) {
133
this._currentChat.acceptInput();
134
}
135
}
136
}
137
focus(): void {
138
this._currentChat?.focus();
139
}
140
close(): void {
141
this._input?.dispose();
142
this._input = undefined;
143
}
144
async openInChatView(): Promise<void> {
145
await this._currentChat?.openChatView();
146
this.close();
147
}
148
}
149
150
class QuickChat extends Disposable {
151
// TODO@TylerLeonhardt: be responsive to window size
152
static DEFAULT_MIN_HEIGHT = 200;
153
private static readonly DEFAULT_HEIGHT_OFFSET = 100;
154
155
private widget!: ChatWidget;
156
private sash!: Sash;
157
private modelRef: IChatModelReference | undefined;
158
private readonly maintainScrollTimer: MutableDisposable<IDisposable> = this._register(new MutableDisposable<IDisposable>());
159
private _deferUpdatingDynamicLayout: boolean = false;
160
161
public get sessionResource() {
162
return this.modelRef?.object.sessionResource;
163
}
164
165
constructor(
166
@IInstantiationService private readonly instantiationService: IInstantiationService,
167
@IContextKeyService private readonly contextKeyService: IContextKeyService,
168
@IChatService private readonly chatService: IChatService,
169
@IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService,
170
@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,
171
@IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService,
172
@IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService,
173
) {
174
super();
175
}
176
177
private clear() {
178
this.modelRef?.dispose();
179
this.modelRef = undefined;
180
this.updateModel();
181
this.widget.inputEditor.setValue('');
182
return Promise.resolve();
183
}
184
185
focus(selection?: Selection): void {
186
if (this.widget) {
187
this.widget.focusInput();
188
const value = this.widget.inputEditor.getValue();
189
if (value) {
190
this.widget.inputEditor.setSelection(selection ?? {
191
startLineNumber: 1,
192
startColumn: 1,
193
endLineNumber: 1,
194
endColumn: value.length + 1
195
});
196
}
197
}
198
}
199
200
hide(): void {
201
this.widget.setVisible(false);
202
// Maintain scroll position for a short time so that if the user re-shows the chat
203
// the same scroll position will be used.
204
this.maintainScrollTimer.value = disposableTimeout(() => {
205
// At this point, clear this mutable disposable which will be our signal that
206
// the timer has expired and we should stop maintaining scroll position
207
this.maintainScrollTimer.clear();
208
}, 30 * 1000); // 30 seconds
209
}
210
211
show(): void {
212
this.widget.setVisible(true);
213
// If the mutable disposable is set, then we are keeping the existing scroll position
214
// so we should not update the layout.
215
if (this._deferUpdatingDynamicLayout) {
216
this._deferUpdatingDynamicLayout = false;
217
this.widget.updateDynamicChatTreeItemLayout(2, this.maxHeight);
218
}
219
if (!this.maintainScrollTimer.value) {
220
this.widget.layoutDynamicChatTreeItemMode();
221
}
222
}
223
224
render(parent: HTMLElement): void {
225
if (this.widget) {
226
// NOTE: if this changes, we need to make sure disposables in this function are tracked differently.
227
throw new Error('Cannot render quick chat twice');
228
}
229
const scopedInstantiationService = this._register(this.instantiationService.createChild(
230
new ServiceCollection([
231
IContextKeyService,
232
this._register(this.contextKeyService.createScoped(parent))
233
])
234
));
235
this.widget = this._register(
236
scopedInstantiationService.createInstance(
237
ChatWidget,
238
ChatAgentLocation.Chat,
239
{ isQuickChat: true },
240
{
241
autoScroll: true,
242
renderInputOnTop: true,
243
renderStyle: 'compact',
244
menus: { inputSideToolbar: MenuId.ChatInputSide, telemetrySource: 'chatQuick' },
245
enableImplicitContext: true,
246
defaultMode: ChatMode.Ask,
247
clear: () => this.clear(),
248
},
249
{
250
listForeground: quickInputForeground,
251
listBackground: quickInputBackground,
252
overlayBackground: EDITOR_DRAG_AND_DROP_BACKGROUND,
253
inputEditorBackground: inputBackground,
254
resultEditorBackground: editorBackground
255
}));
256
this.widget.render(parent);
257
this.widget.setVisible(true);
258
this.widget.setDynamicChatTreeItemLayout(2, this.maxHeight);
259
this.updateModel();
260
this.sash = this._register(new Sash(parent, { getHorizontalSashTop: () => parent.offsetHeight }, { orientation: Orientation.HORIZONTAL }));
261
this.setupDisclaimer(parent);
262
this.registerListeners(parent);
263
}
264
265
private setupDisclaimer(parent: HTMLElement): void {
266
const disclaimerElement = dom.append(parent, dom.$('.disclaimer.hidden'));
267
const disposables = this._store.add(new DisposableStore());
268
269
this._register(autorun(reader => {
270
disposables.clear();
271
dom.reset(disclaimerElement);
272
273
const sentiment = this.chatEntitlementService.sentimentObs.read(reader);
274
const anonymous = this.chatEntitlementService.anonymousObs.read(reader);
275
const requestInProgress = this.chatService.requestInProgressObs.read(reader);
276
277
const showDisclaimer = !sentiment.installed && anonymous && !requestInProgress;
278
disclaimerElement.classList.toggle('hidden', !showDisclaimer);
279
280
if (showDisclaimer) {
281
const renderedMarkdown = disposables.add(this.markdownRendererService.render(new MarkdownString(localize({ key: 'termsDisclaimer', comment: ['{Locked="]({2})"}', '{Locked="]({3})"}'] }, "By continuing with {0} Copilot, you agree to {1}'s [Terms]({2}) and [Privacy Statement]({3})", product.defaultChatAgent?.provider?.default?.name ?? '', product.defaultChatAgent?.provider?.default?.name ?? '', product.defaultChatAgent?.termsStatementUrl ?? '', product.defaultChatAgent?.privacyStatementUrl ?? ''), { isTrusted: true })));
282
disclaimerElement.appendChild(renderedMarkdown.element);
283
}
284
}));
285
}
286
287
private get maxHeight(): number {
288
return this.layoutService.mainContainerDimension.height - QuickChat.DEFAULT_HEIGHT_OFFSET;
289
}
290
291
private registerListeners(parent: HTMLElement): void {
292
this._register(this.layoutService.onDidLayoutMainContainer(() => {
293
if (this.widget.visible) {
294
this.widget.updateDynamicChatTreeItemLayout(2, this.maxHeight);
295
} else {
296
// If the chat is not visible, then we should defer updating the layout
297
// because it relies on offsetHeight which only works correctly
298
// when the chat is visible.
299
this._deferUpdatingDynamicLayout = true;
300
}
301
}));
302
this._register(this.widget.onDidChangeHeight((e) => this.sash.layout()));
303
const width = parent.offsetWidth;
304
this._register(this.sash.onDidStart(() => {
305
this.widget.isDynamicChatTreeItemLayoutEnabled = false;
306
}));
307
this._register(this.sash.onDidChange((e) => {
308
if (e.currentY < QuickChat.DEFAULT_MIN_HEIGHT || e.currentY > this.maxHeight) {
309
return;
310
}
311
this.widget.layout(e.currentY, width);
312
this.sash.layout();
313
}));
314
this._register(this.sash.onDidReset(() => {
315
this.widget.isDynamicChatTreeItemLayoutEnabled = true;
316
this.widget.layoutDynamicChatTreeItemMode();
317
}));
318
}
319
320
async acceptInput() {
321
return this.widget.acceptInput();
322
}
323
324
async openChatView(): Promise<void> {
325
const widget = await this.chatWidgetService.revealWidget();
326
const model = this.modelRef?.object;
327
if (!widget?.viewModel || !model) {
328
return;
329
}
330
331
for (const request of model.getRequests()) {
332
if (request.response?.response.value || request.response?.result) {
333
334
335
const message: IChatProgress[] = [];
336
for (const item of request.response.response.value) {
337
if (item.kind === 'textEditGroup') {
338
for (const group of item.edits) {
339
message.push({
340
kind: 'textEdit',
341
edits: group,
342
uri: item.uri
343
});
344
}
345
} else if (item.kind === 'notebookEditGroup') {
346
for (const group of item.edits) {
347
if (isCellTextEditOperationArray(group)) {
348
message.push({
349
kind: 'textEdit',
350
edits: group.map(e => e.edit),
351
uri: group[0].uri
352
});
353
} else {
354
message.push({
355
kind: 'notebookEdit',
356
edits: group,
357
uri: item.uri
358
});
359
}
360
}
361
} else {
362
message.push(item);
363
}
364
}
365
366
this.chatService.addCompleteRequest(widget.viewModel.sessionResource,
367
request.message as IParsedChatRequest,
368
request.variableData,
369
request.attempt,
370
{
371
message,
372
result: request.response.result,
373
followups: request.response.followups
374
});
375
} else if (request.message) {
376
377
}
378
}
379
380
const value = this.widget.getViewState();
381
if (value) {
382
widget.viewModel.model.inputModel.setState(value);
383
}
384
widget.focusInput();
385
}
386
387
setValue(value: string, selection?: Selection): void {
388
this.widget.inputEditor.setValue(value);
389
this.focus(selection);
390
}
391
392
clearValue(): void {
393
this.widget.inputEditor.setValue('');
394
}
395
396
private updateModel(): void {
397
this.modelRef ??= this.chatService.startSession(ChatAgentLocation.Chat, { disableBackgroundKeepAlive: true });
398
const model = this.modelRef?.object;
399
if (!model) {
400
throw new Error('Could not start chat session');
401
}
402
403
this.modelRef.object.inputModel.setState({ inputText: '', selections: [] });
404
this.widget.setModel(model);
405
}
406
407
override dispose(): void {
408
this.modelRef?.dispose();
409
this.modelRef = undefined;
410
super.dispose();
411
}
412
}
413
414