Path: blob/main/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts
5241 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/inlineChatOverlayWidget.css';6import * as dom from '../../../../base/browser/dom.js';7import { renderAsPlaintext } from '../../../../base/browser/markdownRenderer.js';8import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';9import { IAction, Separator } from '../../../../base/common/actions.js';10import { ActionBar, ActionsOrientation } from '../../../../base/browser/ui/actionbar/actionbar.js';11import { Codicon } from '../../../../base/common/codicons.js';12import { KeyCode } from '../../../../base/common/keyCodes.js';13import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';14import { autorun, constObservable, derived, IObservable, observableFromEvent, observableFromEventOpts, observableValue } from '../../../../base/common/observable.js';15import { ThemeIcon } from '../../../../base/common/themables.js';16import { URI } from '../../../../base/common/uri.js';17import { IActiveCodeEditor, IOverlayWidgetPosition } from '../../../../editor/browser/editorBrowser.js';18import { ObservableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js';19import { EditorOption } from '../../../../editor/common/config/editorOptions.js';20import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js';21import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js';22import { IModelService } from '../../../../editor/common/services/model.js';23import { localize } from '../../../../nls.js';24import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';25import { IMenuService, MenuId } from '../../../../platform/actions/common/actions.js';26import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';27import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';28import { ChatEditingAcceptRejectActionViewItem } from '../../chat/browser/chatEditing/chatEditingEditorOverlay.js';29import { ACTION_START } from '../common/inlineChat.js';30import { StickyScrollController } from '../../../../editor/contrib/stickyScroll/browser/stickyScrollController.js';31import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';32import { getFlatActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js';33import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';34import { getSimpleEditorOptions } from '../../codeEditor/browser/simpleEditorOptions.js';35import { PlaceholderTextContribution } from '../../../../editor/contrib/placeholderText/browser/placeholderTextContribution.js';36import { InlineChatRunOptions } from './inlineChatController.js';37import { IInlineChatSession2 } from './inlineChatSessionService.js';38import { Position } from '../../../../editor/common/core/position.js';39import { CancelChatActionId } from '../../chat/browser/actions/chatExecuteActions.js';40import { assertType } from '../../../../base/common/types.js';4142/**43* Overlay widget that displays a vertical action bar menu.44*/45export class InlineChatInputWidget extends Disposable {4647private readonly _domNode: HTMLElement;48private readonly _inputContainer: HTMLElement;49private readonly _actionBar: ActionBar;50private readonly _input: IActiveCodeEditor;51private readonly _position = observableValue<IOverlayWidgetPosition | null>(this, null);52readonly position: IObservable<IOverlayWidgetPosition | null> = this._position;535455private readonly _showStore = this._store.add(new DisposableStore());56private readonly _stickyScrollHeight: IObservable<number>;57private _inlineStartAction: IAction | undefined;58private _anchorLineNumber: number = 0;59private _anchorLeft: number = 0;60private _anchorAbove: boolean = false;616263constructor(64private readonly _editorObs: ObservableCodeEditor,65@IKeybindingService private readonly _keybindingService: IKeybindingService,66@IMenuService private readonly _menuService: IMenuService,67@IContextKeyService private readonly _contextKeyService: IContextKeyService,68@IInstantiationService instantiationService: IInstantiationService,69@IModelService modelService: IModelService,70@IConfigurationService configurationService: IConfigurationService,71) {72super();7374// Create container75this._domNode = dom.$('.inline-chat-gutter-menu');7677// Create input editor container78this._inputContainer = dom.append(this._domNode, dom.$('.input'));79this._inputContainer.style.width = '200px';80this._inputContainer.style.height = '26px';81this._inputContainer.style.display = 'flex';82this._inputContainer.style.alignItems = 'center';83this._inputContainer.style.justifyContent = 'center';8485// Create editor options86const options = getSimpleEditorOptions(configurationService);87options.wordWrap = 'on';88options.lineNumbers = 'off';89options.glyphMargin = false;90options.lineDecorationsWidth = 0;91options.lineNumbersMinChars = 0;92options.folding = false;93options.minimap = { enabled: false };94options.scrollbar = { vertical: 'auto', horizontal: 'hidden', alwaysConsumeMouseWheel: true, verticalSliderSize: 6 };95options.renderLineHighlight = 'none';9697const codeEditorWidgetOptions: ICodeEditorWidgetOptions = {98isSimpleWidget: true,99contributions: EditorExtensionsRegistry.getSomeEditorContributions([100PlaceholderTextContribution.ID,101])102};103104this._input = this._store.add(instantiationService.createInstance(CodeEditorWidget, this._inputContainer, options, codeEditorWidgetOptions)) as IActiveCodeEditor;105106const model = this._store.add(modelService.createModel('', null, URI.parse(`gutter-input:${Date.now()}`), true));107this._input.setModel(model);108109// Initialize sticky scroll height observable110const stickyScrollController = StickyScrollController.get(this._editorObs.editor);111this._stickyScrollHeight = stickyScrollController ? observableFromEvent(stickyScrollController.onDidChangeStickyScrollHeight, () => stickyScrollController.stickyScrollWidgetHeight) : constObservable(0);112113// Update placeholder based on selection state114this._store.add(autorun(r => {115const selection = this._editorObs.cursorSelection.read(r);116const hasSelection = selection && !selection.isEmpty();117const placeholderText = hasSelection118? localize('placeholderWithSelection', "Modify selected code")119: localize('placeholderNoSelection', "Generate code");120121this._input.updateOptions({ placeholder: this._keybindingService.appendKeybinding(placeholderText, ACTION_START) });122}));123124// Listen to content size changes and resize the input editor (max 3 lines)125this._store.add(this._input.onDidContentSizeChange(e => {126if (e.contentHeightChanged) {127this._updateInputHeight(e.contentHeight);128}129}));130131// Handle Enter key to submit and ArrowDown to focus action bar132this._store.add(this._input.onKeyDown(e => {133if (e.keyCode === KeyCode.Enter && !e.shiftKey) {134const value = this._input.getModel().getValue() ?? '';135// TODO@jrieken this isn't nice136if (this._inlineStartAction && value) {137e.preventDefault();138e.stopPropagation();139this._actionBar.actionRunner.run(140this._inlineStartAction,141{ message: value, autoSend: true } satisfies InlineChatRunOptions142);143}144} else if (e.keyCode === KeyCode.Escape) {145// Hide overlay if input is empty146const value = this._input.getModel().getValue() ?? '';147if (!value) {148e.preventDefault();149e.stopPropagation();150this._hide();151}152} else if (e.keyCode === KeyCode.DownArrow) {153// Focus first action bar item when at the end of the input154const inputModel = this._input.getModel();155const position = this._input.getPosition();156const lastLineNumber = inputModel.getLineCount();157const lastLineMaxColumn = inputModel.getLineMaxColumn(lastLineNumber);158if (Position.equals(position, new Position(lastLineNumber, lastLineMaxColumn))) {159e.preventDefault();160e.stopPropagation();161this._actionBar.focus();162}163}164}));165166// Create vertical action bar167this._actionBar = this._store.add(new ActionBar(this._domNode, {168orientation: ActionsOrientation.VERTICAL,169preventLoopNavigation: true,170}));171172// Handle ArrowUp on first action bar item to focus input editor173this._store.add(dom.addDisposableListener(this._actionBar.domNode, 'keydown', e => {174const event = new StandardKeyboardEvent(e);175if (event.equals(KeyCode.UpArrow) && this._actionBar.isFocused(this._actionBar.viewItems.findIndex(item => item.action.id !== Separator.ID))) {176event.preventDefault();177event.stopPropagation();178this._input.focus();179}180}, true));181182// Track focus - hide when focus leaves183const focusTracker = this._store.add(dom.trackFocus(this._domNode));184this._store.add(focusTracker.onDidBlur(() => this._hide()));185186// Handle action bar cancel (Escape key)187this._store.add(this._actionBar.onDidCancel(() => this._hide()));188this._store.add(this._actionBar.onWillRun(() => this._hide()));189}190191/**192* Show the widget at the specified line.193* @param lineNumber The line number to anchor the widget to194* @param left Left offset relative to editor195* @param anchorAbove Whether to anchor above the position (widget grows upward)196*/197show(lineNumber: number, left: number, anchorAbove: boolean): void {198this._showStore.clear();199200// Clear input state201this._input.getModel().setValue('');202this._updateInputHeight(this._input.getContentHeight());203204// Refresh actions from menu205this._refreshActions();206207// Store anchor info for scroll updates208this._anchorLineNumber = lineNumber;209this._anchorLeft = left;210this._anchorAbove = anchorAbove;211212// Set initial position213this._updatePosition();214215// Create overlay widget via observable pattern216this._showStore.add(this._editorObs.createOverlayWidget({217domNode: this._domNode,218position: this._position,219minContentWidthInPx: constObservable(0),220allowEditorOverflow: true,221}));222223// If anchoring above, adjust position after render to account for widget height224if (anchorAbove) {225this._updatePosition();226}227228// Update position on scroll, hide if anchor line is out of view (only when input is empty)229this._showStore.add(this._editorObs.editor.onDidScrollChange(() => {230const visibleRanges = this._editorObs.editor.getVisibleRanges();231const isLineVisible = visibleRanges.some(range =>232this._anchorLineNumber >= range.startLineNumber && this._anchorLineNumber <= range.endLineNumber233);234const hasContent = !!this._input.getModel().getValue();235if (!isLineVisible && !hasContent) {236this._hide();237} else {238this._updatePosition();239}240}));241242// Focus the input editor243setTimeout(() => this._input.focus(), 0);244}245246private _updatePosition(): void {247const editor = this._editorObs.editor;248const lineHeight = editor.getOption(EditorOption.lineHeight);249const top = editor.getTopForLineNumber(this._anchorLineNumber) - editor.getScrollTop();250let adjustedTop = top;251252if (this._anchorAbove) {253const widgetHeight = this._domNode.offsetHeight;254adjustedTop = top - widgetHeight;255} else {256adjustedTop = top + lineHeight;257}258259// Clamp to viewport bounds when anchor line is out of view260const stickyScrollHeight = this._stickyScrollHeight.get();261const layoutInfo = editor.getLayoutInfo();262const widgetHeight = this._domNode.offsetHeight;263const minTop = stickyScrollHeight;264const maxTop = layoutInfo.height - widgetHeight;265266const clampedTop = Math.max(minTop, Math.min(adjustedTop, maxTop));267const isClamped = clampedTop !== adjustedTop;268this._domNode.classList.toggle('clamped', isClamped);269270this._position.set({271preference: { top: clampedTop, left: this._anchorLeft },272stackOrdinal: 10000,273}, undefined);274}275276/**277* Hide the widget (removes from editor but does not dispose).278*/279private _hide(): void {280// Focus editor if focus is still within the editor's DOM281const editorDomNode = this._editorObs.editor.getDomNode();282if (editorDomNode && dom.isAncestorOfActiveElement(editorDomNode)) {283this._editorObs.editor.focus();284}285this._position.set(null, undefined);286this._showStore.clear();287}288289private _refreshActions(): void {290// Clear existing actions291this._actionBar.clear();292this._inlineStartAction = undefined;293294// Get fresh actions from menu295const actions = getFlatActionBarActions(this._menuService.getMenuActions(MenuId.ChatEditorInlineGutter, this._contextKeyService, { shouldForwardArgs: true }));296297// Set actions with keybindings (skip ACTION_START since we have the input editor)298for (const action of actions) {299if (action.id === ACTION_START) {300this._inlineStartAction = action;301continue;302}303const keybinding = this._keybindingService.lookupKeybinding(action.id)?.getLabel();304this._actionBar.push(action, { icon: false, label: true, keybinding });305}306}307308private _updateInputHeight(contentHeight: number): void {309const lineHeight = this._input.getOption(EditorOption.lineHeight);310const maxHeight = 3 * lineHeight;311const clampedHeight = Math.min(contentHeight, maxHeight);312const containerPadding = 8;313314this._inputContainer.style.height = `${clampedHeight + containerPadding}px`;315this._input.layout({ width: 200, height: clampedHeight });316}317}318319/**320* Overlay widget that displays progress messages during inline chat requests.321*/322export class InlineChatSessionOverlayWidget extends Disposable {323324private readonly _domNode: HTMLElement = document.createElement('div');325private readonly _container: HTMLElement;326private readonly _statusNode: HTMLElement;327private readonly _icon: HTMLElement;328private readonly _message: HTMLElement;329private readonly _toolbarNode: HTMLElement;330331private readonly _showStore = this._store.add(new DisposableStore());332private readonly _position = observableValue<IOverlayWidgetPosition | null>(this, null);333private readonly _minContentWidthInPx = constObservable(0);334335private readonly _stickyScrollHeight: IObservable<number>;336337constructor(338private readonly _editorObs: ObservableCodeEditor,339@IInstantiationService private readonly _instaService: IInstantiationService,340@IKeybindingService private readonly _keybindingService: IKeybindingService,341) {342super();343344this._domNode.classList.add('inline-chat-session-overlay-widget');345346this._container = document.createElement('div');347this._domNode.appendChild(this._container);348this._container.classList.add('inline-chat-session-overlay-container');349350// Create status node with icon and message351this._statusNode = document.createElement('div');352this._statusNode.classList.add('status');353this._icon = dom.append(this._statusNode, dom.$('span'));354this._message = dom.append(this._statusNode, dom.$('span.message'));355this._container.appendChild(this._statusNode);356357// Create toolbar node358this._toolbarNode = document.createElement('div');359this._toolbarNode.classList.add('toolbar');360361// Initialize sticky scroll height observable362const stickyScrollController = StickyScrollController.get(this._editorObs.editor);363this._stickyScrollHeight = stickyScrollController ? observableFromEvent(stickyScrollController.onDidChangeStickyScrollHeight, () => stickyScrollController.stickyScrollWidgetHeight) : constObservable(0);364}365366show(session: IInlineChatSession2): void {367assertType(this._editorObs.editor.hasModel());368this._showStore.clear();369370// Derived entry observable for this session371const entry = derived(r => session.editingSession.readEntry(session.uri, r));372373// Set up status message and icon observable374const requestMessage = derived(r => {375const chatModel = session?.chatModel;376if (!session || !chatModel) {377return undefined;378}379380const response = chatModel.lastRequestObs.read(r)?.response;381if (!response) {382return { message: localize('working', "Working..."), icon: ThemeIcon.modify(Codicon.loading, 'spin') };383}384385if (response.isComplete) {386// Check for errors first387const result = response.result;388if (result?.errorDetails) {389return {390message: localize('error', "Sorry, your request failed"),391icon: Codicon.error392};393}394395const changes = entry.read(r)?.changesCount.read(r) ?? 0;396return {397message: changes === 0398? localize('done', "Done")399: changes === 1400? localize('done1', "Done, 1 change")401: localize('doneN', "Done, {0} changes", changes),402icon: Codicon.check403};404}405406const lastPart = observableFromEventOpts({ equalsFn: () => false }, response.onDidChange, () => response.response.value)407.read(r)408.filter(part => part.kind === 'progressMessage' || part.kind === 'toolInvocation')409.at(-1);410411if (lastPart?.kind === 'toolInvocation') {412return { message: lastPart.invocationMessage, icon: ThemeIcon.modify(Codicon.loading, 'spin') };413} else if (lastPart?.kind === 'progressMessage') {414return { message: lastPart.content, icon: ThemeIcon.modify(Codicon.loading, 'spin') };415} else {416return { message: localize('working', "Working..."), icon: ThemeIcon.modify(Codicon.loading, 'spin') };417}418});419420this._showStore.add(autorun(r => {421const value = requestMessage.read(r);422if (value) {423this._message.innerText = renderAsPlaintext(value.message);424this._icon.className = '';425this._icon.classList.add(...ThemeIcon.asClassNameArray(value.icon));426} else {427this._message.innerText = '';428this._icon.className = '';429}430}));431432// Add toolbar433this._container.appendChild(this._toolbarNode);434this._showStore.add(toDisposable(() => this._toolbarNode.remove()));435436const that = this;437438this._showStore.add(this._instaService.createInstance(MenuWorkbenchToolBar, this._toolbarNode, MenuId.ChatEditorInlineExecute, {439telemetrySource: 'inlineChatProgress.overlayToolbar',440hiddenItemStrategy: HiddenItemStrategy.Ignore,441toolbarOptions: {442primaryGroup: () => true,443useSeparatorsInPrimaryActions: true444},445menuOptions: { renderShortTitle: true },446actionViewItemProvider: (action, options) => {447const primaryActions = [CancelChatActionId, 'inlineChat2.keep'];448const labeledActions = primaryActions.concat(['inlineChat2.undo']);449450if (!labeledActions.includes(action.id)) {451return undefined; // use default action view item with label452}453454return new ChatEditingAcceptRejectActionViewItem(action, options, entry, undefined, that._keybindingService, primaryActions);455}456}));457458// Position in top right of editor, below sticky scroll459const lineHeight = this._editorObs.getOption(EditorOption.lineHeight);460461// Track widget width changes462const widgetWidth = observableValue<number>(this, 0);463const resizeObserver = new dom.DisposableResizeObserver(() => {464widgetWidth.set(this._domNode.offsetWidth, undefined);465});466this._showStore.add(resizeObserver);467this._showStore.add(resizeObserver.observe(this._domNode));468469this._showStore.add(autorun(r => {470const layoutInfo = this._editorObs.layoutInfo.read(r);471const stickyScrollHeight = this._stickyScrollHeight.read(r);472const width = widgetWidth.read(r);473const padding = Math.round(lineHeight.read(r) * 2 / 3);474475// Cap max-width to the editor viewport (content area)476const maxWidth = layoutInfo.contentWidth - 2 * padding;477this._domNode.style.maxWidth = `${maxWidth}px`;478479// Position: top right, below sticky scroll with padding, left of minimap and scrollbar480const top = stickyScrollHeight + padding;481const left = layoutInfo.width - width - layoutInfo.verticalScrollbarWidth - layoutInfo.minimap.minimapWidth - padding;482483this._position.set({484preference: { top, left },485stackOrdinal: 10000,486}, undefined);487}));488489// Create overlay widget490this._showStore.add(this._editorObs.createOverlayWidget({491domNode: this._domNode,492position: this._position,493minContentWidthInPx: this._minContentWidthInPx,494allowEditorOverflow: false,495}));496}497498hide(): void {499this._position.set(null, undefined);500this._showStore.clear();501}502}503504505