Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts
5241 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/inlineChatOverlayWidget.css';
7
import * as dom from '../../../../base/browser/dom.js';
8
import { renderAsPlaintext } from '../../../../base/browser/markdownRenderer.js';
9
import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
10
import { IAction, Separator } from '../../../../base/common/actions.js';
11
import { ActionBar, ActionsOrientation } from '../../../../base/browser/ui/actionbar/actionbar.js';
12
import { Codicon } from '../../../../base/common/codicons.js';
13
import { KeyCode } from '../../../../base/common/keyCodes.js';
14
import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
15
import { autorun, constObservable, derived, IObservable, observableFromEvent, observableFromEventOpts, observableValue } from '../../../../base/common/observable.js';
16
import { ThemeIcon } from '../../../../base/common/themables.js';
17
import { URI } from '../../../../base/common/uri.js';
18
import { IActiveCodeEditor, IOverlayWidgetPosition } from '../../../../editor/browser/editorBrowser.js';
19
import { ObservableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js';
20
import { EditorOption } from '../../../../editor/common/config/editorOptions.js';
21
import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js';
22
import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js';
23
import { IModelService } from '../../../../editor/common/services/model.js';
24
import { localize } from '../../../../nls.js';
25
import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';
26
import { IMenuService, MenuId } from '../../../../platform/actions/common/actions.js';
27
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
28
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
29
import { ChatEditingAcceptRejectActionViewItem } from '../../chat/browser/chatEditing/chatEditingEditorOverlay.js';
30
import { ACTION_START } from '../common/inlineChat.js';
31
import { StickyScrollController } from '../../../../editor/contrib/stickyScroll/browser/stickyScrollController.js';
32
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
33
import { getFlatActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js';
34
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
35
import { getSimpleEditorOptions } from '../../codeEditor/browser/simpleEditorOptions.js';
36
import { PlaceholderTextContribution } from '../../../../editor/contrib/placeholderText/browser/placeholderTextContribution.js';
37
import { InlineChatRunOptions } from './inlineChatController.js';
38
import { IInlineChatSession2 } from './inlineChatSessionService.js';
39
import { Position } from '../../../../editor/common/core/position.js';
40
import { CancelChatActionId } from '../../chat/browser/actions/chatExecuteActions.js';
41
import { assertType } from '../../../../base/common/types.js';
42
43
/**
44
* Overlay widget that displays a vertical action bar menu.
45
*/
46
export class InlineChatInputWidget extends Disposable {
47
48
private readonly _domNode: HTMLElement;
49
private readonly _inputContainer: HTMLElement;
50
private readonly _actionBar: ActionBar;
51
private readonly _input: IActiveCodeEditor;
52
private readonly _position = observableValue<IOverlayWidgetPosition | null>(this, null);
53
readonly position: IObservable<IOverlayWidgetPosition | null> = this._position;
54
55
56
private readonly _showStore = this._store.add(new DisposableStore());
57
private readonly _stickyScrollHeight: IObservable<number>;
58
private _inlineStartAction: IAction | undefined;
59
private _anchorLineNumber: number = 0;
60
private _anchorLeft: number = 0;
61
private _anchorAbove: boolean = false;
62
63
64
constructor(
65
private readonly _editorObs: ObservableCodeEditor,
66
@IKeybindingService private readonly _keybindingService: IKeybindingService,
67
@IMenuService private readonly _menuService: IMenuService,
68
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
69
@IInstantiationService instantiationService: IInstantiationService,
70
@IModelService modelService: IModelService,
71
@IConfigurationService configurationService: IConfigurationService,
72
) {
73
super();
74
75
// Create container
76
this._domNode = dom.$('.inline-chat-gutter-menu');
77
78
// Create input editor container
79
this._inputContainer = dom.append(this._domNode, dom.$('.input'));
80
this._inputContainer.style.width = '200px';
81
this._inputContainer.style.height = '26px';
82
this._inputContainer.style.display = 'flex';
83
this._inputContainer.style.alignItems = 'center';
84
this._inputContainer.style.justifyContent = 'center';
85
86
// Create editor options
87
const options = getSimpleEditorOptions(configurationService);
88
options.wordWrap = 'on';
89
options.lineNumbers = 'off';
90
options.glyphMargin = false;
91
options.lineDecorationsWidth = 0;
92
options.lineNumbersMinChars = 0;
93
options.folding = false;
94
options.minimap = { enabled: false };
95
options.scrollbar = { vertical: 'auto', horizontal: 'hidden', alwaysConsumeMouseWheel: true, verticalSliderSize: 6 };
96
options.renderLineHighlight = 'none';
97
98
const codeEditorWidgetOptions: ICodeEditorWidgetOptions = {
99
isSimpleWidget: true,
100
contributions: EditorExtensionsRegistry.getSomeEditorContributions([
101
PlaceholderTextContribution.ID,
102
])
103
};
104
105
this._input = this._store.add(instantiationService.createInstance(CodeEditorWidget, this._inputContainer, options, codeEditorWidgetOptions)) as IActiveCodeEditor;
106
107
const model = this._store.add(modelService.createModel('', null, URI.parse(`gutter-input:${Date.now()}`), true));
108
this._input.setModel(model);
109
110
// Initialize sticky scroll height observable
111
const stickyScrollController = StickyScrollController.get(this._editorObs.editor);
112
this._stickyScrollHeight = stickyScrollController ? observableFromEvent(stickyScrollController.onDidChangeStickyScrollHeight, () => stickyScrollController.stickyScrollWidgetHeight) : constObservable(0);
113
114
// Update placeholder based on selection state
115
this._store.add(autorun(r => {
116
const selection = this._editorObs.cursorSelection.read(r);
117
const hasSelection = selection && !selection.isEmpty();
118
const placeholderText = hasSelection
119
? localize('placeholderWithSelection', "Modify selected code")
120
: localize('placeholderNoSelection', "Generate code");
121
122
this._input.updateOptions({ placeholder: this._keybindingService.appendKeybinding(placeholderText, ACTION_START) });
123
}));
124
125
// Listen to content size changes and resize the input editor (max 3 lines)
126
this._store.add(this._input.onDidContentSizeChange(e => {
127
if (e.contentHeightChanged) {
128
this._updateInputHeight(e.contentHeight);
129
}
130
}));
131
132
// Handle Enter key to submit and ArrowDown to focus action bar
133
this._store.add(this._input.onKeyDown(e => {
134
if (e.keyCode === KeyCode.Enter && !e.shiftKey) {
135
const value = this._input.getModel().getValue() ?? '';
136
// TODO@jrieken this isn't nice
137
if (this._inlineStartAction && value) {
138
e.preventDefault();
139
e.stopPropagation();
140
this._actionBar.actionRunner.run(
141
this._inlineStartAction,
142
{ message: value, autoSend: true } satisfies InlineChatRunOptions
143
);
144
}
145
} else if (e.keyCode === KeyCode.Escape) {
146
// Hide overlay if input is empty
147
const value = this._input.getModel().getValue() ?? '';
148
if (!value) {
149
e.preventDefault();
150
e.stopPropagation();
151
this._hide();
152
}
153
} else if (e.keyCode === KeyCode.DownArrow) {
154
// Focus first action bar item when at the end of the input
155
const inputModel = this._input.getModel();
156
const position = this._input.getPosition();
157
const lastLineNumber = inputModel.getLineCount();
158
const lastLineMaxColumn = inputModel.getLineMaxColumn(lastLineNumber);
159
if (Position.equals(position, new Position(lastLineNumber, lastLineMaxColumn))) {
160
e.preventDefault();
161
e.stopPropagation();
162
this._actionBar.focus();
163
}
164
}
165
}));
166
167
// Create vertical action bar
168
this._actionBar = this._store.add(new ActionBar(this._domNode, {
169
orientation: ActionsOrientation.VERTICAL,
170
preventLoopNavigation: true,
171
}));
172
173
// Handle ArrowUp on first action bar item to focus input editor
174
this._store.add(dom.addDisposableListener(this._actionBar.domNode, 'keydown', e => {
175
const event = new StandardKeyboardEvent(e);
176
if (event.equals(KeyCode.UpArrow) && this._actionBar.isFocused(this._actionBar.viewItems.findIndex(item => item.action.id !== Separator.ID))) {
177
event.preventDefault();
178
event.stopPropagation();
179
this._input.focus();
180
}
181
}, true));
182
183
// Track focus - hide when focus leaves
184
const focusTracker = this._store.add(dom.trackFocus(this._domNode));
185
this._store.add(focusTracker.onDidBlur(() => this._hide()));
186
187
// Handle action bar cancel (Escape key)
188
this._store.add(this._actionBar.onDidCancel(() => this._hide()));
189
this._store.add(this._actionBar.onWillRun(() => this._hide()));
190
}
191
192
/**
193
* Show the widget at the specified line.
194
* @param lineNumber The line number to anchor the widget to
195
* @param left Left offset relative to editor
196
* @param anchorAbove Whether to anchor above the position (widget grows upward)
197
*/
198
show(lineNumber: number, left: number, anchorAbove: boolean): void {
199
this._showStore.clear();
200
201
// Clear input state
202
this._input.getModel().setValue('');
203
this._updateInputHeight(this._input.getContentHeight());
204
205
// Refresh actions from menu
206
this._refreshActions();
207
208
// Store anchor info for scroll updates
209
this._anchorLineNumber = lineNumber;
210
this._anchorLeft = left;
211
this._anchorAbove = anchorAbove;
212
213
// Set initial position
214
this._updatePosition();
215
216
// Create overlay widget via observable pattern
217
this._showStore.add(this._editorObs.createOverlayWidget({
218
domNode: this._domNode,
219
position: this._position,
220
minContentWidthInPx: constObservable(0),
221
allowEditorOverflow: true,
222
}));
223
224
// If anchoring above, adjust position after render to account for widget height
225
if (anchorAbove) {
226
this._updatePosition();
227
}
228
229
// Update position on scroll, hide if anchor line is out of view (only when input is empty)
230
this._showStore.add(this._editorObs.editor.onDidScrollChange(() => {
231
const visibleRanges = this._editorObs.editor.getVisibleRanges();
232
const isLineVisible = visibleRanges.some(range =>
233
this._anchorLineNumber >= range.startLineNumber && this._anchorLineNumber <= range.endLineNumber
234
);
235
const hasContent = !!this._input.getModel().getValue();
236
if (!isLineVisible && !hasContent) {
237
this._hide();
238
} else {
239
this._updatePosition();
240
}
241
}));
242
243
// Focus the input editor
244
setTimeout(() => this._input.focus(), 0);
245
}
246
247
private _updatePosition(): void {
248
const editor = this._editorObs.editor;
249
const lineHeight = editor.getOption(EditorOption.lineHeight);
250
const top = editor.getTopForLineNumber(this._anchorLineNumber) - editor.getScrollTop();
251
let adjustedTop = top;
252
253
if (this._anchorAbove) {
254
const widgetHeight = this._domNode.offsetHeight;
255
adjustedTop = top - widgetHeight;
256
} else {
257
adjustedTop = top + lineHeight;
258
}
259
260
// Clamp to viewport bounds when anchor line is out of view
261
const stickyScrollHeight = this._stickyScrollHeight.get();
262
const layoutInfo = editor.getLayoutInfo();
263
const widgetHeight = this._domNode.offsetHeight;
264
const minTop = stickyScrollHeight;
265
const maxTop = layoutInfo.height - widgetHeight;
266
267
const clampedTop = Math.max(minTop, Math.min(adjustedTop, maxTop));
268
const isClamped = clampedTop !== adjustedTop;
269
this._domNode.classList.toggle('clamped', isClamped);
270
271
this._position.set({
272
preference: { top: clampedTop, left: this._anchorLeft },
273
stackOrdinal: 10000,
274
}, undefined);
275
}
276
277
/**
278
* Hide the widget (removes from editor but does not dispose).
279
*/
280
private _hide(): void {
281
// Focus editor if focus is still within the editor's DOM
282
const editorDomNode = this._editorObs.editor.getDomNode();
283
if (editorDomNode && dom.isAncestorOfActiveElement(editorDomNode)) {
284
this._editorObs.editor.focus();
285
}
286
this._position.set(null, undefined);
287
this._showStore.clear();
288
}
289
290
private _refreshActions(): void {
291
// Clear existing actions
292
this._actionBar.clear();
293
this._inlineStartAction = undefined;
294
295
// Get fresh actions from menu
296
const actions = getFlatActionBarActions(this._menuService.getMenuActions(MenuId.ChatEditorInlineGutter, this._contextKeyService, { shouldForwardArgs: true }));
297
298
// Set actions with keybindings (skip ACTION_START since we have the input editor)
299
for (const action of actions) {
300
if (action.id === ACTION_START) {
301
this._inlineStartAction = action;
302
continue;
303
}
304
const keybinding = this._keybindingService.lookupKeybinding(action.id)?.getLabel();
305
this._actionBar.push(action, { icon: false, label: true, keybinding });
306
}
307
}
308
309
private _updateInputHeight(contentHeight: number): void {
310
const lineHeight = this._input.getOption(EditorOption.lineHeight);
311
const maxHeight = 3 * lineHeight;
312
const clampedHeight = Math.min(contentHeight, maxHeight);
313
const containerPadding = 8;
314
315
this._inputContainer.style.height = `${clampedHeight + containerPadding}px`;
316
this._input.layout({ width: 200, height: clampedHeight });
317
}
318
}
319
320
/**
321
* Overlay widget that displays progress messages during inline chat requests.
322
*/
323
export class InlineChatSessionOverlayWidget extends Disposable {
324
325
private readonly _domNode: HTMLElement = document.createElement('div');
326
private readonly _container: HTMLElement;
327
private readonly _statusNode: HTMLElement;
328
private readonly _icon: HTMLElement;
329
private readonly _message: HTMLElement;
330
private readonly _toolbarNode: HTMLElement;
331
332
private readonly _showStore = this._store.add(new DisposableStore());
333
private readonly _position = observableValue<IOverlayWidgetPosition | null>(this, null);
334
private readonly _minContentWidthInPx = constObservable(0);
335
336
private readonly _stickyScrollHeight: IObservable<number>;
337
338
constructor(
339
private readonly _editorObs: ObservableCodeEditor,
340
@IInstantiationService private readonly _instaService: IInstantiationService,
341
@IKeybindingService private readonly _keybindingService: IKeybindingService,
342
) {
343
super();
344
345
this._domNode.classList.add('inline-chat-session-overlay-widget');
346
347
this._container = document.createElement('div');
348
this._domNode.appendChild(this._container);
349
this._container.classList.add('inline-chat-session-overlay-container');
350
351
// Create status node with icon and message
352
this._statusNode = document.createElement('div');
353
this._statusNode.classList.add('status');
354
this._icon = dom.append(this._statusNode, dom.$('span'));
355
this._message = dom.append(this._statusNode, dom.$('span.message'));
356
this._container.appendChild(this._statusNode);
357
358
// Create toolbar node
359
this._toolbarNode = document.createElement('div');
360
this._toolbarNode.classList.add('toolbar');
361
362
// Initialize sticky scroll height observable
363
const stickyScrollController = StickyScrollController.get(this._editorObs.editor);
364
this._stickyScrollHeight = stickyScrollController ? observableFromEvent(stickyScrollController.onDidChangeStickyScrollHeight, () => stickyScrollController.stickyScrollWidgetHeight) : constObservable(0);
365
}
366
367
show(session: IInlineChatSession2): void {
368
assertType(this._editorObs.editor.hasModel());
369
this._showStore.clear();
370
371
// Derived entry observable for this session
372
const entry = derived(r => session.editingSession.readEntry(session.uri, r));
373
374
// Set up status message and icon observable
375
const requestMessage = derived(r => {
376
const chatModel = session?.chatModel;
377
if (!session || !chatModel) {
378
return undefined;
379
}
380
381
const response = chatModel.lastRequestObs.read(r)?.response;
382
if (!response) {
383
return { message: localize('working', "Working..."), icon: ThemeIcon.modify(Codicon.loading, 'spin') };
384
}
385
386
if (response.isComplete) {
387
// Check for errors first
388
const result = response.result;
389
if (result?.errorDetails) {
390
return {
391
message: localize('error', "Sorry, your request failed"),
392
icon: Codicon.error
393
};
394
}
395
396
const changes = entry.read(r)?.changesCount.read(r) ?? 0;
397
return {
398
message: changes === 0
399
? localize('done', "Done")
400
: changes === 1
401
? localize('done1', "Done, 1 change")
402
: localize('doneN', "Done, {0} changes", changes),
403
icon: Codicon.check
404
};
405
}
406
407
const lastPart = observableFromEventOpts({ equalsFn: () => false }, response.onDidChange, () => response.response.value)
408
.read(r)
409
.filter(part => part.kind === 'progressMessage' || part.kind === 'toolInvocation')
410
.at(-1);
411
412
if (lastPart?.kind === 'toolInvocation') {
413
return { message: lastPart.invocationMessage, icon: ThemeIcon.modify(Codicon.loading, 'spin') };
414
} else if (lastPart?.kind === 'progressMessage') {
415
return { message: lastPart.content, icon: ThemeIcon.modify(Codicon.loading, 'spin') };
416
} else {
417
return { message: localize('working', "Working..."), icon: ThemeIcon.modify(Codicon.loading, 'spin') };
418
}
419
});
420
421
this._showStore.add(autorun(r => {
422
const value = requestMessage.read(r);
423
if (value) {
424
this._message.innerText = renderAsPlaintext(value.message);
425
this._icon.className = '';
426
this._icon.classList.add(...ThemeIcon.asClassNameArray(value.icon));
427
} else {
428
this._message.innerText = '';
429
this._icon.className = '';
430
}
431
}));
432
433
// Add toolbar
434
this._container.appendChild(this._toolbarNode);
435
this._showStore.add(toDisposable(() => this._toolbarNode.remove()));
436
437
const that = this;
438
439
this._showStore.add(this._instaService.createInstance(MenuWorkbenchToolBar, this._toolbarNode, MenuId.ChatEditorInlineExecute, {
440
telemetrySource: 'inlineChatProgress.overlayToolbar',
441
hiddenItemStrategy: HiddenItemStrategy.Ignore,
442
toolbarOptions: {
443
primaryGroup: () => true,
444
useSeparatorsInPrimaryActions: true
445
},
446
menuOptions: { renderShortTitle: true },
447
actionViewItemProvider: (action, options) => {
448
const primaryActions = [CancelChatActionId, 'inlineChat2.keep'];
449
const labeledActions = primaryActions.concat(['inlineChat2.undo']);
450
451
if (!labeledActions.includes(action.id)) {
452
return undefined; // use default action view item with label
453
}
454
455
return new ChatEditingAcceptRejectActionViewItem(action, options, entry, undefined, that._keybindingService, primaryActions);
456
}
457
}));
458
459
// Position in top right of editor, below sticky scroll
460
const lineHeight = this._editorObs.getOption(EditorOption.lineHeight);
461
462
// Track widget width changes
463
const widgetWidth = observableValue<number>(this, 0);
464
const resizeObserver = new dom.DisposableResizeObserver(() => {
465
widgetWidth.set(this._domNode.offsetWidth, undefined);
466
});
467
this._showStore.add(resizeObserver);
468
this._showStore.add(resizeObserver.observe(this._domNode));
469
470
this._showStore.add(autorun(r => {
471
const layoutInfo = this._editorObs.layoutInfo.read(r);
472
const stickyScrollHeight = this._stickyScrollHeight.read(r);
473
const width = widgetWidth.read(r);
474
const padding = Math.round(lineHeight.read(r) * 2 / 3);
475
476
// Cap max-width to the editor viewport (content area)
477
const maxWidth = layoutInfo.contentWidth - 2 * padding;
478
this._domNode.style.maxWidth = `${maxWidth}px`;
479
480
// Position: top right, below sticky scroll with padding, left of minimap and scrollbar
481
const top = stickyScrollHeight + padding;
482
const left = layoutInfo.width - width - layoutInfo.verticalScrollbarWidth - layoutInfo.minimap.minimapWidth - padding;
483
484
this._position.set({
485
preference: { top, left },
486
stackOrdinal: 10000,
487
}, undefined);
488
}));
489
490
// Create overlay widget
491
this._showStore.add(this._editorObs.createOverlayWidget({
492
domNode: this._domNode,
493
position: this._position,
494
minContentWidthInPx: this._minContentWidthInPx,
495
allowEditorOverflow: false,
496
}));
497
}
498
499
hide(): void {
500
this._position.set(null, undefined);
501
this._showStore.clear();
502
}
503
}
504
505