Path: blob/main/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import './media/agentFeedbackEditorInput.css';6import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';7import { ICodeEditor, IDiffEditor, IOverlayWidget, IOverlayWidgetPosition } from '../../../../editor/browser/editorBrowser.js';8import { IEditorContribution } from '../../../../editor/common/editorCommon.js';9import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js';10import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';11import { EditorOption } from '../../../../editor/common/config/editorOptions.js';12import { Selection, SelectionDirection } from '../../../../editor/common/core/selection.js';13import { URI } from '../../../../base/common/uri.js';14import { addStandardDisposableListener, getWindow, ModifierKeyEmitter } from '../../../../base/browser/dom.js';15import { KeyCode } from '../../../../base/common/keyCodes.js';16import { IAgentFeedbackService } from './agentFeedbackService.js';17import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js';18import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';19import { createAgentFeedbackContext, getSessionForResource } from './agentFeedbackEditorUtils.js';20import { localize } from '../../../../nls.js';21import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';22import { Action } from '../../../../base/common/actions.js';23import { Codicon } from '../../../../base/common/codicons.js';24import { ThemeIcon } from '../../../../base/common/themables.js';25import { Emitter, Event } from '../../../../base/common/event.js';2627class AgentFeedbackInputWidget implements IOverlayWidget {2829private static readonly _ID = 'agentFeedback.inputWidget';30private static readonly _MIN_WIDTH = 150;31private static readonly _MAX_WIDTH = 400;3233readonly allowEditorOverflow = false;3435private readonly _domNode: HTMLElement;36private readonly _inputElement: HTMLTextAreaElement;37private readonly _measureElement: HTMLElement;38private readonly _actionBar: ActionBar;39private readonly _addAction: Action;40private readonly _addAndSubmitAction: Action;41private _position: IOverlayWidgetPosition | null = null;42private _lineHeight = 0;4344private readonly _onDidTriggerAdd = new Emitter<void>();45readonly onDidTriggerAdd: Event<void> = this._onDidTriggerAdd.event;4647private readonly _onDidTriggerAddAndSubmit = new Emitter<void>();48readonly onDidTriggerAddAndSubmit: Event<void> = this._onDidTriggerAddAndSubmit.event;4950constructor(51private readonly _editor: ICodeEditor,52) {53this._domNode = document.createElement('div');54this._domNode.classList.add('agent-feedback-input-widget');55this._domNode.style.display = 'none';5657this._inputElement = document.createElement('textarea');58this._inputElement.rows = 1;59this._inputElement.placeholder = localize('agentFeedback.addFeedback', "Add Feedback");60this._domNode.appendChild(this._inputElement);6162// Hidden element used to measure text width for auto-growing63this._measureElement = document.createElement('span');64this._measureElement.classList.add('agent-feedback-input-measure');65this._domNode.appendChild(this._measureElement);6667// Action bar with add/submit actions68const actionsContainer = document.createElement('div');69actionsContainer.classList.add('agent-feedback-input-actions');70this._domNode.appendChild(actionsContainer);7172this._addAction = new Action(73'agentFeedback.add',74localize('agentFeedback.add', "Add Feedback (Enter)"),75ThemeIcon.asClassName(Codicon.plus),76false,77() => { this._onDidTriggerAdd.fire(); return Promise.resolve(); }78);7980this._addAndSubmitAction = new Action(81'agentFeedback.addAndSubmit',82localize('agentFeedback.addAndSubmit', "Add Feedback and Submit (Alt+Enter)"),83ThemeIcon.asClassName(Codicon.send),84false,85() => { this._onDidTriggerAddAndSubmit.fire(); return Promise.resolve(); }86);8788this._actionBar = new ActionBar(actionsContainer);89this._actionBar.push(this._addAction, { icon: true, label: false, keybinding: localize('enter', "Enter") });9091// Toggle to alt action when Alt key is held92const modifierKeyEmitter = ModifierKeyEmitter.getInstance();93modifierKeyEmitter.event(status => {94this._updateActionForAlt(status.altKey);95});9697this._lineHeight = 22;98this._inputElement.style.lineHeight = `${this._lineHeight}px`;99}100101private _isShowingAlt = false;102103private _updateActionForAlt(altKey: boolean): void {104if (altKey && !this._isShowingAlt) {105this._isShowingAlt = true;106this._actionBar.clear();107this._actionBar.push(this._addAndSubmitAction, { icon: true, label: false, keybinding: localize('altEnter', "Alt+Enter") });108} else if (!altKey && this._isShowingAlt) {109this._isShowingAlt = false;110this._actionBar.clear();111this._actionBar.push(this._addAction, { icon: true, label: false, keybinding: localize('enter', "Enter") });112}113}114115getId(): string {116return AgentFeedbackInputWidget._ID;117}118119getDomNode(): HTMLElement {120return this._domNode;121}122123getPosition(): IOverlayWidgetPosition | null {124return this._position;125}126127get inputElement(): HTMLTextAreaElement {128return this._inputElement;129}130131setPosition(position: IOverlayWidgetPosition | null): void {132this._position = position;133this._editor.layoutOverlayWidget(this);134}135136show(): void {137this._domNode.style.display = '';138}139140hide(): void {141this._domNode.style.display = 'none';142}143144clearInput(): void {145this._inputElement.value = '';146this._updateActionEnabled();147this._autoSize();148}149150autoSize(): void {151this._autoSize();152}153154updateActionEnabled(): void {155this._updateActionEnabled();156}157158private _updateActionEnabled(): void {159const hasText = this._inputElement.value.trim().length > 0;160this._addAction.enabled = hasText;161this._addAndSubmitAction.enabled = hasText;162}163164private _autoSize(): void {165const text = this._inputElement.value || this._inputElement.placeholder;166167// Measure the text width using the hidden span168this._measureElement.textContent = text;169const textWidth = this._measureElement.scrollWidth;170171// Clamp width between min and max172const width = Math.max(AgentFeedbackInputWidget._MIN_WIDTH, Math.min(textWidth + 10, AgentFeedbackInputWidget._MAX_WIDTH));173this._inputElement.style.width = `${width}px`;174175// Reset height to auto then expand to fit all content, with a minimum of 1 line176this._inputElement.style.height = 'auto';177const newHeight = Math.max(this._inputElement.scrollHeight, this._lineHeight);178this._inputElement.style.height = `${newHeight}px`;179}180181dispose(): void {182this._actionBar.dispose();183this._addAction.dispose();184this._addAndSubmitAction.dispose();185this._onDidTriggerAdd.dispose();186this._onDidTriggerAddAndSubmit.dispose();187}188}189190export class AgentFeedbackEditorInputContribution extends Disposable implements IEditorContribution {191192static readonly ID = 'agentFeedback.editorInputContribution';193194private _widget: AgentFeedbackInputWidget | undefined;195private _visible = false;196private _mouseDown = false;197private _suppressSelectionChangeOnce = false;198private _sessionResource: URI | undefined;199private readonly _widgetListeners = this._store.add(new DisposableStore());200201constructor(202private readonly _editor: ICodeEditor,203@IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService,204@IChatEditingService private readonly _chatEditingService: IChatEditingService,205@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,206@ICodeEditorService private readonly _codeEditorService: ICodeEditorService,207) {208super();209210this._store.add(this._editor.onDidChangeCursorSelection(() => this._onSelectionChanged()));211this._store.add(this._editor.onDidChangeModel(() => this._onModelChanged()));212this._store.add(this._editor.onDidScrollChange(() => {213if (this._visible) {214this._updatePosition();215}216}));217this._store.add(this._editor.onMouseDown((e) => {218if (this._isWidgetTarget(e.event.target)) {219return;220}221this._mouseDown = true;222this._hide();223}));224this._store.add(this._editor.onMouseUp((e) => {225this._mouseDown = false;226if (this._isWidgetTarget(e.event.target)) {227return;228}229this._onSelectionChanged();230}));231this._store.add(this._editor.onDidBlurEditorWidget(() => {232if (!this._visible) {233return;234}235// Defer so focus has settled to the new target236getWindow(this._editor.getDomNode()!).setTimeout(() => {237if (!this._visible) {238return;239}240if (this._isWidgetTarget(getWindow(this._editor.getDomNode()!).document.activeElement)) {241return;242}243this._hide();244}, 0);245}));246this._store.add(this._editor.onDidFocusEditorText(() => this._onSelectionChanged()));247}248249private _isWidgetTarget(target: EventTarget | Element | null): boolean {250return !!this._widget && !!target && this._widget.getDomNode().contains(target as Node);251}252253private _ensureWidget(): AgentFeedbackInputWidget {254if (!this._widget) {255this._widget = new AgentFeedbackInputWidget(this._editor);256this._store.add(this._widget.onDidTriggerAdd(() => this._addFeedback()));257this._store.add(this._widget.onDidTriggerAddAndSubmit(() => this._addFeedbackAndSubmit()));258this._editor.addOverlayWidget(this._widget);259}260return this._widget;261}262263private _onModelChanged(): void {264this._hide();265this._suppressSelectionChangeOnce = false;266this._sessionResource = undefined;267}268269private _onSelectionChanged(): void {270if (this._suppressSelectionChangeOnce) {271this._suppressSelectionChangeOnce = false;272return;273}274275if (this._mouseDown || !this._editor.hasTextFocus()) {276return;277}278279const selection = this._editor.getSelection();280if (!selection || (selection.isEmpty() && !this._getDiffHunkForSelection(selection))) {281this._hide();282return;283}284285const model = this._editor.getModel();286if (!model) {287this._hide();288return;289}290291const sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._sessionsManagementService);292if (!sessionResource) {293this._hide();294return;295}296297this._sessionResource = sessionResource;298this._show();299}300301private _show(): void {302const widget = this._ensureWidget();303304if (!this._visible) {305this._visible = true;306this._registerWidgetListeners(widget);307}308309widget.clearInput();310widget.show();311this._updatePosition();312}313314private _hide(): void {315if (!this._visible) {316return;317}318319this._visible = false;320this._widgetListeners.clear();321322if (this._widget) {323this._widget.hide();324this._widget.setPosition(null);325this._widget.clearInput();326}327}328329private _registerWidgetListeners(widget: AgentFeedbackInputWidget): void {330this._widgetListeners.clear();331332// Listen for keydown on the editor dom node to detect when the user starts typing333const editorDomNode = this._editor.getDomNode();334if (editorDomNode) {335this._widgetListeners.add(addStandardDisposableListener(editorDomNode, 'keydown', e => {336if (!this._visible) {337return;338}339340// Only steal focus when the editor text area itself is focused,341// not when an overlay widget (e.g. find widget) has focus342if (!this._editor.hasTextFocus()) {343return;344}345346// Don't focus if a modifier key is pressed alone347if (e.keyCode === KeyCode.Ctrl || e.keyCode === KeyCode.Shift || e.keyCode === KeyCode.Alt || e.keyCode === KeyCode.Meta) {348return;349}350351// Don't capture Escape at this level - let it fall through to the input handler if focused352if (e.keyCode === KeyCode.Escape) {353this._hide();354this._editor.focus();355return;356}357358// Ctrl+I / Cmd+I explicitly focuses the feedback input359if ((e.ctrlKey || e.metaKey) && e.keyCode === KeyCode.KeyI) {360e.preventDefault();361e.stopPropagation();362widget.inputElement.focus();363return;364}365366// Don't focus if any modifier is held (keyboard shortcuts)367if (e.ctrlKey || e.altKey || e.metaKey) {368return;369}370371// Keep caret/navigation keys in the editor. Only actual typing should move focus.372if (373e.keyCode === KeyCode.UpArrow374|| e.keyCode === KeyCode.DownArrow375|| e.keyCode === KeyCode.LeftArrow376|| e.keyCode === KeyCode.RightArrow377) {378return;379}380381// Only auto-focus the input on typing when the document is readonly;382// when editable the user must click or use Ctrl+I to focus.383if (!this._editor.getOption(EditorOption.readOnly)) {384return;385}386387// If the input is not focused, focus it and let the keystroke go through388if (getWindow(widget.inputElement).document.activeElement !== widget.inputElement) {389widget.inputElement.focus();390}391}));392}393394// Listen for keydown on the input element395this._widgetListeners.add(addStandardDisposableListener(widget.inputElement, 'keydown', e => {396if (e.keyCode === KeyCode.Escape) {397e.preventDefault();398e.stopPropagation();399this._hide();400this._editor.focus();401return;402}403404if (e.keyCode === KeyCode.Enter && e.altKey) {405e.preventDefault();406e.stopPropagation();407this._addFeedbackAndSubmit();408return;409}410411if (e.keyCode === KeyCode.Enter) {412e.preventDefault();413e.stopPropagation();414this._addFeedback();415return;416}417}));418419// Stop propagation of input events so the editor doesn't handle them420this._widgetListeners.add(addStandardDisposableListener(widget.inputElement, 'keypress', e => {421e.stopPropagation();422}));423424// Auto-size the textarea as the user types425this._widgetListeners.add(addStandardDisposableListener(widget.inputElement, 'input', () => {426widget.autoSize();427widget.updateActionEnabled();428this._updatePosition();429}));430431// Hide when input loses focus to something outside both editor and widget432this._widgetListeners.add(addStandardDisposableListener(widget.inputElement, 'blur', () => {433const win = getWindow(widget.inputElement);434win.setTimeout(() => {435if (!this._visible) {436return;437}438if (this._editor.hasWidgetFocus()) {439return;440}441this._hide();442}, 0);443}));444}445446focusInput(): void {447if (this._visible && this._widget) {448this._widget.inputElement.focus();449}450}451452private _hideAndRefocusEditor(): void {453this._suppressSelectionChangeOnce = true;454this._hide();455this._editor.focus();456}457458private _addFeedback(): boolean {459if (!this._widget) {460return false;461}462463const text = this._widget.inputElement.value.trim();464if (!text) {465return false;466}467468const selection = this._editor.getSelection();469const model = this._editor.getModel();470if (!selection || !model || !this._sessionResource) {471return false;472}473474this._agentFeedbackService.addFeedback(this._sessionResource, model.uri, selection, text, undefined, createAgentFeedbackContext(this._editor, this._codeEditorService, model.uri, selection));475this._hideAndRefocusEditor();476return true;477}478479private _addFeedbackAndSubmit(): void {480if (!this._widget) {481return;482}483484const text = this._widget.inputElement.value.trim();485if (!text) {486return;487}488489const selection = this._editor.getSelection();490const model = this._editor.getModel();491if (!selection || !model || !this._sessionResource) {492return;493}494495const sessionResource = this._sessionResource;496this._hideAndRefocusEditor();497this._agentFeedbackService.addFeedbackAndSubmit(sessionResource, model.uri, selection, text, undefined, createAgentFeedbackContext(this._editor, this._codeEditorService, model.uri, selection));498}499500private _getContainingDiffEditor(): IDiffEditor | undefined {501return this._codeEditorService.listDiffEditors().find(diffEditor =>502diffEditor.getModifiedEditor() === this._editor || diffEditor.getOriginalEditor() === this._editor503);504}505506private _getDiffHunkForSelection(selection: Selection): { startLineNumber: number; endLineNumberExclusive: number } | undefined {507if (!selection.isEmpty()) {508return undefined;509}510511const diffEditor = this._getContainingDiffEditor();512if (!diffEditor) {513return undefined;514}515516const diffResult = diffEditor.getDiffComputationResult();517if (!diffResult) {518return undefined;519}520521const position = selection.getStartPosition();522const lineNumber = position.lineNumber;523const isModifiedEditor = diffEditor.getModifiedEditor() === this._editor;524for (const change of diffResult.changes2) {525const lineRange = isModifiedEditor ? change.modified : change.original;526if (!lineRange.isEmpty && lineRange.contains(lineNumber)) {527// Don't show when cursor is at the start or end position of the hunk528const isAtHunkStart = lineNumber === lineRange.startLineNumber && position.column === 1;529const lastHunkLine = lineRange.endLineNumberExclusive - 1;530const model = this._editor.getModel();531const isAtHunkEnd = model && lineNumber === lastHunkLine && position.column === model.getLineMaxColumn(lastHunkLine);532if (isAtHunkStart || isAtHunkEnd) {533return undefined;534}535return {536startLineNumber: lineRange.startLineNumber,537endLineNumberExclusive: lineRange.endLineNumberExclusive,538};539}540}541542return undefined;543}544545private _updatePosition(): void {546if (!this._widget || !this._visible) {547return;548}549550const selection = this._editor.getSelection();551if (!selection) {552this._hide();553return;554}555556const lineHeight = this._editor.getOption(EditorOption.lineHeight);557const layoutInfo = this._editor.getLayoutInfo();558const widgetDom = this._widget.getDomNode();559const widgetHeight = widgetDom.offsetHeight || 30;560const widgetWidth = widgetDom.offsetWidth || 150;561562if (selection.isEmpty()) {563const diffHunk = this._getDiffHunkForSelection(selection);564if (!diffHunk) {565this._hide();566return;567}568569const cursorPosition = selection.getStartPosition();570const scrolledPosition = this._editor.getScrolledVisiblePosition(cursorPosition);571if (!scrolledPosition) {572this._widget.setPosition(null);573return;574}575576const hunkLineCount = diffHunk.endLineNumberExclusive - diffHunk.startLineNumber;577const cursorLineOffset = cursorPosition.lineNumber - diffHunk.startLineNumber;578const topHalfLineCount = Math.ceil(hunkLineCount / 2);579const top = hunkLineCount < 10580? cursorLineOffset < topHalfLineCount581? scrolledPosition.top - (cursorLineOffset * lineHeight) - widgetHeight582: scrolledPosition.top + ((diffHunk.endLineNumberExclusive - cursorPosition.lineNumber) * lineHeight)583: scrolledPosition.top - widgetHeight;584const left = Math.max(0, Math.min(scrolledPosition.left, layoutInfo.width - widgetWidth));585586this._widget.setPosition({587preference: {588top: Math.max(0, Math.min(top, layoutInfo.height - widgetHeight)),589left,590}591});592return;593}594595const cursorPosition = selection.getDirection() === SelectionDirection.LTR596? selection.getEndPosition()597: selection.getStartPosition();598599const scrolledPosition = this._editor.getScrolledVisiblePosition(cursorPosition);600if (!scrolledPosition) {601this._widget.setPosition(null);602return;603}604605// Compute vertical position, flipping if out of bounds606let top: number;607if (selection.getDirection() === SelectionDirection.LTR) {608// Cursor at end (bottom) of selection → prefer below the cursor line609top = scrolledPosition.top + lineHeight;610if (top + widgetHeight > layoutInfo.height) {611// Not enough space below → place above the cursor line612top = scrolledPosition.top - widgetHeight;613}614} else {615// Cursor at start (top) of selection → prefer above the cursor line616top = scrolledPosition.top - widgetHeight;617if (top < 0) {618// Not enough space above → place below the cursor line619top = scrolledPosition.top + lineHeight;620}621}622623// Clamp vertical position within editor bounds624top = Math.max(0, Math.min(top, layoutInfo.height - widgetHeight));625626// Clamp horizontal position so the widget stays within the editor627const left = Math.max(0, Math.min(scrolledPosition.left, layoutInfo.width - widgetWidth));628629this._widget.setPosition({ preference: { top, left } });630}631632override dispose(): void {633if (this._widget) {634this._editor.removeOverlayWidget(this._widget);635this._widget.dispose();636this._widget = undefined;637}638super.dispose();639}640}641642registerEditorContribution(AgentFeedbackEditorInputContribution.ID, AgentFeedbackEditorInputContribution, EditorContributionInstantiation.Eventually);643644645