Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import './media/agentFeedbackEditorInput.css';
7
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
8
import { ICodeEditor, IDiffEditor, IOverlayWidget, IOverlayWidgetPosition } from '../../../../editor/browser/editorBrowser.js';
9
import { IEditorContribution } from '../../../../editor/common/editorCommon.js';
10
import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js';
11
import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';
12
import { EditorOption } from '../../../../editor/common/config/editorOptions.js';
13
import { Selection, SelectionDirection } from '../../../../editor/common/core/selection.js';
14
import { URI } from '../../../../base/common/uri.js';
15
import { addStandardDisposableListener, getWindow, ModifierKeyEmitter } from '../../../../base/browser/dom.js';
16
import { KeyCode } from '../../../../base/common/keyCodes.js';
17
import { IAgentFeedbackService } from './agentFeedbackService.js';
18
import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js';
19
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
20
import { createAgentFeedbackContext, getSessionForResource } from './agentFeedbackEditorUtils.js';
21
import { localize } from '../../../../nls.js';
22
import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';
23
import { Action } from '../../../../base/common/actions.js';
24
import { Codicon } from '../../../../base/common/codicons.js';
25
import { ThemeIcon } from '../../../../base/common/themables.js';
26
import { Emitter, Event } from '../../../../base/common/event.js';
27
28
class AgentFeedbackInputWidget implements IOverlayWidget {
29
30
private static readonly _ID = 'agentFeedback.inputWidget';
31
private static readonly _MIN_WIDTH = 150;
32
private static readonly _MAX_WIDTH = 400;
33
34
readonly allowEditorOverflow = false;
35
36
private readonly _domNode: HTMLElement;
37
private readonly _inputElement: HTMLTextAreaElement;
38
private readonly _measureElement: HTMLElement;
39
private readonly _actionBar: ActionBar;
40
private readonly _addAction: Action;
41
private readonly _addAndSubmitAction: Action;
42
private _position: IOverlayWidgetPosition | null = null;
43
private _lineHeight = 0;
44
45
private readonly _onDidTriggerAdd = new Emitter<void>();
46
readonly onDidTriggerAdd: Event<void> = this._onDidTriggerAdd.event;
47
48
private readonly _onDidTriggerAddAndSubmit = new Emitter<void>();
49
readonly onDidTriggerAddAndSubmit: Event<void> = this._onDidTriggerAddAndSubmit.event;
50
51
constructor(
52
private readonly _editor: ICodeEditor,
53
) {
54
this._domNode = document.createElement('div');
55
this._domNode.classList.add('agent-feedback-input-widget');
56
this._domNode.style.display = 'none';
57
58
this._inputElement = document.createElement('textarea');
59
this._inputElement.rows = 1;
60
this._inputElement.placeholder = localize('agentFeedback.addFeedback', "Add Feedback");
61
this._domNode.appendChild(this._inputElement);
62
63
// Hidden element used to measure text width for auto-growing
64
this._measureElement = document.createElement('span');
65
this._measureElement.classList.add('agent-feedback-input-measure');
66
this._domNode.appendChild(this._measureElement);
67
68
// Action bar with add/submit actions
69
const actionsContainer = document.createElement('div');
70
actionsContainer.classList.add('agent-feedback-input-actions');
71
this._domNode.appendChild(actionsContainer);
72
73
this._addAction = new Action(
74
'agentFeedback.add',
75
localize('agentFeedback.add', "Add Feedback (Enter)"),
76
ThemeIcon.asClassName(Codicon.plus),
77
false,
78
() => { this._onDidTriggerAdd.fire(); return Promise.resolve(); }
79
);
80
81
this._addAndSubmitAction = new Action(
82
'agentFeedback.addAndSubmit',
83
localize('agentFeedback.addAndSubmit', "Add Feedback and Submit (Alt+Enter)"),
84
ThemeIcon.asClassName(Codicon.send),
85
false,
86
() => { this._onDidTriggerAddAndSubmit.fire(); return Promise.resolve(); }
87
);
88
89
this._actionBar = new ActionBar(actionsContainer);
90
this._actionBar.push(this._addAction, { icon: true, label: false, keybinding: localize('enter', "Enter") });
91
92
// Toggle to alt action when Alt key is held
93
const modifierKeyEmitter = ModifierKeyEmitter.getInstance();
94
modifierKeyEmitter.event(status => {
95
this._updateActionForAlt(status.altKey);
96
});
97
98
this._lineHeight = 22;
99
this._inputElement.style.lineHeight = `${this._lineHeight}px`;
100
}
101
102
private _isShowingAlt = false;
103
104
private _updateActionForAlt(altKey: boolean): void {
105
if (altKey && !this._isShowingAlt) {
106
this._isShowingAlt = true;
107
this._actionBar.clear();
108
this._actionBar.push(this._addAndSubmitAction, { icon: true, label: false, keybinding: localize('altEnter', "Alt+Enter") });
109
} else if (!altKey && this._isShowingAlt) {
110
this._isShowingAlt = false;
111
this._actionBar.clear();
112
this._actionBar.push(this._addAction, { icon: true, label: false, keybinding: localize('enter', "Enter") });
113
}
114
}
115
116
getId(): string {
117
return AgentFeedbackInputWidget._ID;
118
}
119
120
getDomNode(): HTMLElement {
121
return this._domNode;
122
}
123
124
getPosition(): IOverlayWidgetPosition | null {
125
return this._position;
126
}
127
128
get inputElement(): HTMLTextAreaElement {
129
return this._inputElement;
130
}
131
132
setPosition(position: IOverlayWidgetPosition | null): void {
133
this._position = position;
134
this._editor.layoutOverlayWidget(this);
135
}
136
137
show(): void {
138
this._domNode.style.display = '';
139
}
140
141
hide(): void {
142
this._domNode.style.display = 'none';
143
}
144
145
clearInput(): void {
146
this._inputElement.value = '';
147
this._updateActionEnabled();
148
this._autoSize();
149
}
150
151
autoSize(): void {
152
this._autoSize();
153
}
154
155
updateActionEnabled(): void {
156
this._updateActionEnabled();
157
}
158
159
private _updateActionEnabled(): void {
160
const hasText = this._inputElement.value.trim().length > 0;
161
this._addAction.enabled = hasText;
162
this._addAndSubmitAction.enabled = hasText;
163
}
164
165
private _autoSize(): void {
166
const text = this._inputElement.value || this._inputElement.placeholder;
167
168
// Measure the text width using the hidden span
169
this._measureElement.textContent = text;
170
const textWidth = this._measureElement.scrollWidth;
171
172
// Clamp width between min and max
173
const width = Math.max(AgentFeedbackInputWidget._MIN_WIDTH, Math.min(textWidth + 10, AgentFeedbackInputWidget._MAX_WIDTH));
174
this._inputElement.style.width = `${width}px`;
175
176
// Reset height to auto then expand to fit all content, with a minimum of 1 line
177
this._inputElement.style.height = 'auto';
178
const newHeight = Math.max(this._inputElement.scrollHeight, this._lineHeight);
179
this._inputElement.style.height = `${newHeight}px`;
180
}
181
182
dispose(): void {
183
this._actionBar.dispose();
184
this._addAction.dispose();
185
this._addAndSubmitAction.dispose();
186
this._onDidTriggerAdd.dispose();
187
this._onDidTriggerAddAndSubmit.dispose();
188
}
189
}
190
191
export class AgentFeedbackEditorInputContribution extends Disposable implements IEditorContribution {
192
193
static readonly ID = 'agentFeedback.editorInputContribution';
194
195
private _widget: AgentFeedbackInputWidget | undefined;
196
private _visible = false;
197
private _mouseDown = false;
198
private _suppressSelectionChangeOnce = false;
199
private _sessionResource: URI | undefined;
200
private readonly _widgetListeners = this._store.add(new DisposableStore());
201
202
constructor(
203
private readonly _editor: ICodeEditor,
204
@IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService,
205
@IChatEditingService private readonly _chatEditingService: IChatEditingService,
206
@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,
207
@ICodeEditorService private readonly _codeEditorService: ICodeEditorService,
208
) {
209
super();
210
211
this._store.add(this._editor.onDidChangeCursorSelection(() => this._onSelectionChanged()));
212
this._store.add(this._editor.onDidChangeModel(() => this._onModelChanged()));
213
this._store.add(this._editor.onDidScrollChange(() => {
214
if (this._visible) {
215
this._updatePosition();
216
}
217
}));
218
this._store.add(this._editor.onMouseDown((e) => {
219
if (this._isWidgetTarget(e.event.target)) {
220
return;
221
}
222
this._mouseDown = true;
223
this._hide();
224
}));
225
this._store.add(this._editor.onMouseUp((e) => {
226
this._mouseDown = false;
227
if (this._isWidgetTarget(e.event.target)) {
228
return;
229
}
230
this._onSelectionChanged();
231
}));
232
this._store.add(this._editor.onDidBlurEditorWidget(() => {
233
if (!this._visible) {
234
return;
235
}
236
// Defer so focus has settled to the new target
237
getWindow(this._editor.getDomNode()!).setTimeout(() => {
238
if (!this._visible) {
239
return;
240
}
241
if (this._isWidgetTarget(getWindow(this._editor.getDomNode()!).document.activeElement)) {
242
return;
243
}
244
this._hide();
245
}, 0);
246
}));
247
this._store.add(this._editor.onDidFocusEditorText(() => this._onSelectionChanged()));
248
}
249
250
private _isWidgetTarget(target: EventTarget | Element | null): boolean {
251
return !!this._widget && !!target && this._widget.getDomNode().contains(target as Node);
252
}
253
254
private _ensureWidget(): AgentFeedbackInputWidget {
255
if (!this._widget) {
256
this._widget = new AgentFeedbackInputWidget(this._editor);
257
this._store.add(this._widget.onDidTriggerAdd(() => this._addFeedback()));
258
this._store.add(this._widget.onDidTriggerAddAndSubmit(() => this._addFeedbackAndSubmit()));
259
this._editor.addOverlayWidget(this._widget);
260
}
261
return this._widget;
262
}
263
264
private _onModelChanged(): void {
265
this._hide();
266
this._suppressSelectionChangeOnce = false;
267
this._sessionResource = undefined;
268
}
269
270
private _onSelectionChanged(): void {
271
if (this._suppressSelectionChangeOnce) {
272
this._suppressSelectionChangeOnce = false;
273
return;
274
}
275
276
if (this._mouseDown || !this._editor.hasTextFocus()) {
277
return;
278
}
279
280
const selection = this._editor.getSelection();
281
if (!selection || (selection.isEmpty() && !this._getDiffHunkForSelection(selection))) {
282
this._hide();
283
return;
284
}
285
286
const model = this._editor.getModel();
287
if (!model) {
288
this._hide();
289
return;
290
}
291
292
const sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._sessionsManagementService);
293
if (!sessionResource) {
294
this._hide();
295
return;
296
}
297
298
this._sessionResource = sessionResource;
299
this._show();
300
}
301
302
private _show(): void {
303
const widget = this._ensureWidget();
304
305
if (!this._visible) {
306
this._visible = true;
307
this._registerWidgetListeners(widget);
308
}
309
310
widget.clearInput();
311
widget.show();
312
this._updatePosition();
313
}
314
315
private _hide(): void {
316
if (!this._visible) {
317
return;
318
}
319
320
this._visible = false;
321
this._widgetListeners.clear();
322
323
if (this._widget) {
324
this._widget.hide();
325
this._widget.setPosition(null);
326
this._widget.clearInput();
327
}
328
}
329
330
private _registerWidgetListeners(widget: AgentFeedbackInputWidget): void {
331
this._widgetListeners.clear();
332
333
// Listen for keydown on the editor dom node to detect when the user starts typing
334
const editorDomNode = this._editor.getDomNode();
335
if (editorDomNode) {
336
this._widgetListeners.add(addStandardDisposableListener(editorDomNode, 'keydown', e => {
337
if (!this._visible) {
338
return;
339
}
340
341
// Only steal focus when the editor text area itself is focused,
342
// not when an overlay widget (e.g. find widget) has focus
343
if (!this._editor.hasTextFocus()) {
344
return;
345
}
346
347
// Don't focus if a modifier key is pressed alone
348
if (e.keyCode === KeyCode.Ctrl || e.keyCode === KeyCode.Shift || e.keyCode === KeyCode.Alt || e.keyCode === KeyCode.Meta) {
349
return;
350
}
351
352
// Don't capture Escape at this level - let it fall through to the input handler if focused
353
if (e.keyCode === KeyCode.Escape) {
354
this._hide();
355
this._editor.focus();
356
return;
357
}
358
359
// Ctrl+I / Cmd+I explicitly focuses the feedback input
360
if ((e.ctrlKey || e.metaKey) && e.keyCode === KeyCode.KeyI) {
361
e.preventDefault();
362
e.stopPropagation();
363
widget.inputElement.focus();
364
return;
365
}
366
367
// Don't focus if any modifier is held (keyboard shortcuts)
368
if (e.ctrlKey || e.altKey || e.metaKey) {
369
return;
370
}
371
372
// Keep caret/navigation keys in the editor. Only actual typing should move focus.
373
if (
374
e.keyCode === KeyCode.UpArrow
375
|| e.keyCode === KeyCode.DownArrow
376
|| e.keyCode === KeyCode.LeftArrow
377
|| e.keyCode === KeyCode.RightArrow
378
) {
379
return;
380
}
381
382
// Only auto-focus the input on typing when the document is readonly;
383
// when editable the user must click or use Ctrl+I to focus.
384
if (!this._editor.getOption(EditorOption.readOnly)) {
385
return;
386
}
387
388
// If the input is not focused, focus it and let the keystroke go through
389
if (getWindow(widget.inputElement).document.activeElement !== widget.inputElement) {
390
widget.inputElement.focus();
391
}
392
}));
393
}
394
395
// Listen for keydown on the input element
396
this._widgetListeners.add(addStandardDisposableListener(widget.inputElement, 'keydown', e => {
397
if (e.keyCode === KeyCode.Escape) {
398
e.preventDefault();
399
e.stopPropagation();
400
this._hide();
401
this._editor.focus();
402
return;
403
}
404
405
if (e.keyCode === KeyCode.Enter && e.altKey) {
406
e.preventDefault();
407
e.stopPropagation();
408
this._addFeedbackAndSubmit();
409
return;
410
}
411
412
if (e.keyCode === KeyCode.Enter) {
413
e.preventDefault();
414
e.stopPropagation();
415
this._addFeedback();
416
return;
417
}
418
}));
419
420
// Stop propagation of input events so the editor doesn't handle them
421
this._widgetListeners.add(addStandardDisposableListener(widget.inputElement, 'keypress', e => {
422
e.stopPropagation();
423
}));
424
425
// Auto-size the textarea as the user types
426
this._widgetListeners.add(addStandardDisposableListener(widget.inputElement, 'input', () => {
427
widget.autoSize();
428
widget.updateActionEnabled();
429
this._updatePosition();
430
}));
431
432
// Hide when input loses focus to something outside both editor and widget
433
this._widgetListeners.add(addStandardDisposableListener(widget.inputElement, 'blur', () => {
434
const win = getWindow(widget.inputElement);
435
win.setTimeout(() => {
436
if (!this._visible) {
437
return;
438
}
439
if (this._editor.hasWidgetFocus()) {
440
return;
441
}
442
this._hide();
443
}, 0);
444
}));
445
}
446
447
focusInput(): void {
448
if (this._visible && this._widget) {
449
this._widget.inputElement.focus();
450
}
451
}
452
453
private _hideAndRefocusEditor(): void {
454
this._suppressSelectionChangeOnce = true;
455
this._hide();
456
this._editor.focus();
457
}
458
459
private _addFeedback(): boolean {
460
if (!this._widget) {
461
return false;
462
}
463
464
const text = this._widget.inputElement.value.trim();
465
if (!text) {
466
return false;
467
}
468
469
const selection = this._editor.getSelection();
470
const model = this._editor.getModel();
471
if (!selection || !model || !this._sessionResource) {
472
return false;
473
}
474
475
this._agentFeedbackService.addFeedback(this._sessionResource, model.uri, selection, text, undefined, createAgentFeedbackContext(this._editor, this._codeEditorService, model.uri, selection));
476
this._hideAndRefocusEditor();
477
return true;
478
}
479
480
private _addFeedbackAndSubmit(): void {
481
if (!this._widget) {
482
return;
483
}
484
485
const text = this._widget.inputElement.value.trim();
486
if (!text) {
487
return;
488
}
489
490
const selection = this._editor.getSelection();
491
const model = this._editor.getModel();
492
if (!selection || !model || !this._sessionResource) {
493
return;
494
}
495
496
const sessionResource = this._sessionResource;
497
this._hideAndRefocusEditor();
498
this._agentFeedbackService.addFeedbackAndSubmit(sessionResource, model.uri, selection, text, undefined, createAgentFeedbackContext(this._editor, this._codeEditorService, model.uri, selection));
499
}
500
501
private _getContainingDiffEditor(): IDiffEditor | undefined {
502
return this._codeEditorService.listDiffEditors().find(diffEditor =>
503
diffEditor.getModifiedEditor() === this._editor || diffEditor.getOriginalEditor() === this._editor
504
);
505
}
506
507
private _getDiffHunkForSelection(selection: Selection): { startLineNumber: number; endLineNumberExclusive: number } | undefined {
508
if (!selection.isEmpty()) {
509
return undefined;
510
}
511
512
const diffEditor = this._getContainingDiffEditor();
513
if (!diffEditor) {
514
return undefined;
515
}
516
517
const diffResult = diffEditor.getDiffComputationResult();
518
if (!diffResult) {
519
return undefined;
520
}
521
522
const position = selection.getStartPosition();
523
const lineNumber = position.lineNumber;
524
const isModifiedEditor = diffEditor.getModifiedEditor() === this._editor;
525
for (const change of diffResult.changes2) {
526
const lineRange = isModifiedEditor ? change.modified : change.original;
527
if (!lineRange.isEmpty && lineRange.contains(lineNumber)) {
528
// Don't show when cursor is at the start or end position of the hunk
529
const isAtHunkStart = lineNumber === lineRange.startLineNumber && position.column === 1;
530
const lastHunkLine = lineRange.endLineNumberExclusive - 1;
531
const model = this._editor.getModel();
532
const isAtHunkEnd = model && lineNumber === lastHunkLine && position.column === model.getLineMaxColumn(lastHunkLine);
533
if (isAtHunkStart || isAtHunkEnd) {
534
return undefined;
535
}
536
return {
537
startLineNumber: lineRange.startLineNumber,
538
endLineNumberExclusive: lineRange.endLineNumberExclusive,
539
};
540
}
541
}
542
543
return undefined;
544
}
545
546
private _updatePosition(): void {
547
if (!this._widget || !this._visible) {
548
return;
549
}
550
551
const selection = this._editor.getSelection();
552
if (!selection) {
553
this._hide();
554
return;
555
}
556
557
const lineHeight = this._editor.getOption(EditorOption.lineHeight);
558
const layoutInfo = this._editor.getLayoutInfo();
559
const widgetDom = this._widget.getDomNode();
560
const widgetHeight = widgetDom.offsetHeight || 30;
561
const widgetWidth = widgetDom.offsetWidth || 150;
562
563
if (selection.isEmpty()) {
564
const diffHunk = this._getDiffHunkForSelection(selection);
565
if (!diffHunk) {
566
this._hide();
567
return;
568
}
569
570
const cursorPosition = selection.getStartPosition();
571
const scrolledPosition = this._editor.getScrolledVisiblePosition(cursorPosition);
572
if (!scrolledPosition) {
573
this._widget.setPosition(null);
574
return;
575
}
576
577
const hunkLineCount = diffHunk.endLineNumberExclusive - diffHunk.startLineNumber;
578
const cursorLineOffset = cursorPosition.lineNumber - diffHunk.startLineNumber;
579
const topHalfLineCount = Math.ceil(hunkLineCount / 2);
580
const top = hunkLineCount < 10
581
? cursorLineOffset < topHalfLineCount
582
? scrolledPosition.top - (cursorLineOffset * lineHeight) - widgetHeight
583
: scrolledPosition.top + ((diffHunk.endLineNumberExclusive - cursorPosition.lineNumber) * lineHeight)
584
: scrolledPosition.top - widgetHeight;
585
const left = Math.max(0, Math.min(scrolledPosition.left, layoutInfo.width - widgetWidth));
586
587
this._widget.setPosition({
588
preference: {
589
top: Math.max(0, Math.min(top, layoutInfo.height - widgetHeight)),
590
left,
591
}
592
});
593
return;
594
}
595
596
const cursorPosition = selection.getDirection() === SelectionDirection.LTR
597
? selection.getEndPosition()
598
: selection.getStartPosition();
599
600
const scrolledPosition = this._editor.getScrolledVisiblePosition(cursorPosition);
601
if (!scrolledPosition) {
602
this._widget.setPosition(null);
603
return;
604
}
605
606
// Compute vertical position, flipping if out of bounds
607
let top: number;
608
if (selection.getDirection() === SelectionDirection.LTR) {
609
// Cursor at end (bottom) of selection → prefer below the cursor line
610
top = scrolledPosition.top + lineHeight;
611
if (top + widgetHeight > layoutInfo.height) {
612
// Not enough space below → place above the cursor line
613
top = scrolledPosition.top - widgetHeight;
614
}
615
} else {
616
// Cursor at start (top) of selection → prefer above the cursor line
617
top = scrolledPosition.top - widgetHeight;
618
if (top < 0) {
619
// Not enough space above → place below the cursor line
620
top = scrolledPosition.top + lineHeight;
621
}
622
}
623
624
// Clamp vertical position within editor bounds
625
top = Math.max(0, Math.min(top, layoutInfo.height - widgetHeight));
626
627
// Clamp horizontal position so the widget stays within the editor
628
const left = Math.max(0, Math.min(scrolledPosition.left, layoutInfo.width - widgetWidth));
629
630
this._widget.setPosition({ preference: { top, left } });
631
}
632
633
override dispose(): void {
634
if (this._widget) {
635
this._editor.removeOverlayWidget(this._widget);
636
this._widget.dispose();
637
this._widget = undefined;
638
}
639
super.dispose();
640
}
641
}
642
643
registerEditorContribution(AgentFeedbackEditorInputContribution.ID, AgentFeedbackEditorInputContribution, EditorContributionInstantiation.Eventually);
644
645