diff --git a/src/vs/editor/browser/observableCodeEditor.ts b/src/vs/editor/browser/observableCodeEditor.ts index 4ada6de5c8a..a5f2f2246d5 100644 --- a/src/vs/editor/browser/observableCodeEditor.ts +++ b/src/vs/editor/browser/observableCodeEditor.ts @@ -167,6 +167,17 @@ export class ObservableCodeEditor extends Disposable { }; }, () => this.editor.hasWidgetFocus()); + public readonly isTextFocused = observableFromEvent(this, e => { + const d1 = this.editor.onDidFocusEditorText(e); + const d2 = this.editor.onDidBlurEditorText(e); + return { + dispose() { + d1.dispose(); + d2.dispose(); + } + }; + }, () => this.editor.hasTextFocus()); + private _inComposition = false; public readonly inComposition = observableFromEvent(this, e => { const d1 = this.editor.onDidCompositionStart(() => { diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts index 64d4f2a11d9..3572a0801b3 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts @@ -37,7 +37,7 @@ registerEditorContribution(INLINE_CHAT_ID, InlineChatController, EditorContribut registerAction2(InlineChatExpandLineAction); registerAction2(ShowInlineChatHintAction); registerAction2(HideInlineChatHintAction); -registerEditorContribution(InlineChatHintsController.ID, InlineChatHintsController, EditorContributionInstantiation.Lazy); +registerEditorContribution(InlineChatHintsController.ID, InlineChatHintsController, EditorContributionInstantiation.Eventually); // --- MENU special --- diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index 11e8513cee7..30dd09c6e48 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../base/common/codicons.js'; -import { KeyChord, KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { ICodeEditor, isCodeEditor, isDiffEditor } from '../../../../editor/browser/editorBrowser.js'; import { EditorAction2 } from '../../../../editor/browser/editorExtensions.js'; import { EmbeddedDiffEditorWidget } from '../../../../editor/browser/widget/diffEditor/embeddedDiffEditorWidget.js'; @@ -62,8 +62,7 @@ export class StartSessionAction extends Action2 { keybinding: { when: EditorContextKeys.focus, weight: KeybindingWeight.WorkbenchContrib, - primary: KeyMod.CtrlCmd | KeyCode.KeyI, - secondary: [KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyCode.KeyI)], + primary: KeyMod.CtrlCmd | KeyCode.KeyI }, icon: START_INLINE_CHAT, menu: { diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatCurrentLine.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatCurrentLine.ts index 8aa9b86969a..dfd271fa79b 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatCurrentLine.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatCurrentLine.ts @@ -9,7 +9,7 @@ import { IEditorContribution } from '../../../../editor/common/editorCommon.js'; import { localize, localize2 } from '../../../../nls.js'; import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { InlineChatController, State } from './inlineChatController.js'; -import { ACTION_START, CTX_INLINE_CHAT_HAS_AGENT, CTX_INLINE_CHAT_VISIBLE } from '../common/inlineChat.js'; +import { ACTION_START, CTX_INLINE_CHAT_HAS_AGENT, CTX_INLINE_CHAT_VISIBLE, InlineChatConfigKeys } from '../common/inlineChat.js'; import { EditorAction2, ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; import { EditOperation } from '../../../../editor/common/core/editOperation.js'; import { Range } from '../../../../editor/common/core/range.js'; @@ -20,8 +20,8 @@ import { InjectedTextCursorStops, IValidEditOperation, TrackedRangeStickiness } import { URI } from '../../../../base/common/uri.js'; import { isEqual } from '../../../../base/common/resources.js'; import { StandardTokenType } from '../../../../editor/common/encodedTokenAttributes.js'; -import { autorun, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; -import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { autorun, derived, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { KeyChord, KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import './media/inlineChat.css'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; @@ -30,6 +30,12 @@ import { InlineCompletionsController } from '../../../../editor/contrib/inlineCo import { ChatAgentLocation, IChatAgentService } from '../../chat/common/chatAgents.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { IMarkerDecorationsService } from '../../../../editor/common/services/markerDecorations.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { toAction } from '../../../../base/common/actions.js'; +import { IMouseEvent } from '../../../../base/browser/mouseEvent.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { Event } from '../../../../base/common/event.js'; +import { observableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js'; export const CTX_INLINE_CHAT_SHOWING_HINT = new RawContextKey('inlineChatShowingHint', false, localize('inlineChatShowingHint', "Whether inline chat shows a contextual hint")); @@ -44,11 +50,14 @@ export class InlineChatExpandLineAction extends EditorAction2 { title: localize2('startWithCurrentLine', "Start in Editor with Current Line"), f1: true, precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_VISIBLE.negate(), CTX_INLINE_CHAT_HAS_AGENT, EditorContextKeys.writable), - keybinding: { + keybinding: [{ when: CTX_INLINE_CHAT_SHOWING_HINT, weight: KeybindingWeight.WorkbenchContrib + 1, primary: KeyMod.CtrlCmd | KeyCode.KeyI - } + }, { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyCode.KeyI), + }] }); } @@ -140,6 +149,12 @@ export class ShowInlineChatHintAction extends EditorAction2 { } } +class HintData { + constructor( + readonly setting: string + ) { } +} + export class InlineChatHintsController extends Disposable implements IEditorContribution { public static readonly ID = 'editor.contrib.inlineChatHints'; @@ -151,6 +166,7 @@ export class InlineChatHintsController extends Disposable implements IEditorCont private readonly _editor: ICodeEditor; private readonly _ctxShowingHint: IContextKey; private readonly _visibilityObs = observableValue(this, false); + private readonly _ctxMenuVisibleObs = observableValue(this, false); constructor( editor: ICodeEditor, @@ -158,70 +174,101 @@ export class InlineChatHintsController extends Disposable implements IEditorCont @ICommandService commandService: ICommandService, @IKeybindingService keybindingService: IKeybindingService, @IChatAgentService chatAgentService: IChatAgentService, - @IMarkerDecorationsService markerDecorationService: IMarkerDecorationsService + @IMarkerDecorationsService markerDecorationService: IMarkerDecorationsService, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, + @IConfigurationService private readonly _configurationService: IConfigurationService ) { super(); this._editor = editor; this._ctxShowingHint = CTX_INLINE_CHAT_SHOWING_HINT.bindTo(contextKeyService); - const ghostCtrl = InlineCompletionsController.get(editor); this._store.add(this._editor.onMouseDown(e => { if (e.target.type !== MouseTargetType.CONTENT_TEXT) { return; } - if (e.target.detail.injectedText?.options.attachedData !== this) { + const attachedData = e.target.detail.injectedText?.options.attachedData; + if (!(attachedData instanceof HintData)) { return; } - commandService.executeCommand(_inlineChatActionId); - this.hide(); - })); - - this._store.add(commandService.onWillExecuteCommand(e => { - if (e.commandId === _inlineChatActionId) { + if (e.event.leftButton) { + commandService.executeCommand(_inlineChatActionId); this.hide(); + } else if (e.event.rightButton) { + e.event.preventDefault(); + this._showContextMenu(e.event, attachedData.setting); } })); const markerSuppression = this._store.add(new MutableDisposable()); const decos = this._editor.createDecorationsCollection(); - const modelObs = observableFromEvent(editor.onDidChangeModel, () => editor.getModel()); - const posObs = observableFromEvent(editor.onDidChangeCursorPosition, () => editor.getPosition()); + const editorObs = observableCodeEditor(editor); + const keyObs = observableFromEvent(keybindingService.onDidUpdateKeybindings, _ => keybindingService.lookupKeybinding(ACTION_START)?.getLabel()); + const configSignal = observableFromEvent(Event.filter(_configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(InlineChatConfigKeys.LineEmptyHint) || e.affectsConfiguration(InlineChatConfigKeys.LineSuffixHint)), () => undefined); + + const showDataObs = derived(r => { + const ghostState = ghostCtrl?.model.read(r)?.state.read(r); + + const textFocus = editorObs.isTextFocused.read(r); + const position = editorObs.cursorPosition.read(r); + const model = editorObs.model.read(r); + + const kb = keyObs.read(r); + + configSignal.read(r); + + if (ghostState !== undefined || !kb || !position || !model || !textFocus) { + return undefined; + } + + const visible = this._visibilityObs.read(r);// || this._ctxMenuVisibleObs.read(r); + const isEol = model.getLineMaxColumn(position.lineNumber) === position.column; + const isWhitespace = model.getLineLastNonWhitespaceColumn(position.lineNumber) === 0 && model.getValueLength() > 0; + + const shouldShow = (isWhitespace && _configurationService.getValue(InlineChatConfigKeys.LineEmptyHint)) + || (visible && isEol && _configurationService.getValue(InlineChatConfigKeys.LineSuffixHint)); + + if (!shouldShow) { + return undefined; + } + + return { isEol, isWhitespace, kb, position, model }; + }); + this._store.add(autorun(r => { - const ghostState = ghostCtrl?.model.read(r)?.state.read(r); - const visible = this._visibilityObs.read(r); - const kb = keyObs.read(r); - const position = posObs.read(r); - const model = modelObs.read(r); - - // update context key - this._ctxShowingHint.set(visible); - - if (!visible || !kb || !position || ghostState !== undefined || !model) { + const showData = showDataObs.read(r); + if (!showData) { decos.clear(); markerSuppression.clear(); + this._ctxShowingHint.reset(); return; } const agentName = chatAgentService.getDefaultAgent(ChatAgentLocation.Editor)?.fullName ?? localize('defaultTitle', "Chat"); - const isEol = model.getLineMaxColumn(position.lineNumber) === position.column; + const { position, isEol, isWhitespace, kb, model } = showData; + const inlineClassName: string[] = ['inline-chat-hint']; let content: string; - let inlineClassName: string; - - if (isEol) { - content = '\u00A0' + localize('title', "{0} to continue with {1}...", kb, agentName); - inlineClassName = `inline-chat-hint${decos.length === 0 ? ' first' : ''}`; + if (isWhitespace) { + content = '\u00a0' + localize('title2', "{0} to edit with {1}...", kb, agentName); + } else if (isEol) { + content = '\u00a0' + localize('title1', "{0} to continue with {1}...", kb, agentName); } else { content = '\u200a' + kb + '\u200a'; - inlineClassName = 'inline-chat-hint embedded'; + inlineClassName.push('embedded'); } + if (decos.length === 0) { + inlineClassName.push('first'); + } + + this._ctxShowingHint.set(true); + decos.set([{ range: Range.fromPositions(position), options: { @@ -231,10 +278,10 @@ export class InlineChatHintsController extends Disposable implements IEditorCont hoverMessage: new MarkdownString(localize('toolttip', "Continue this with {0}...", agentName)), after: { content, - inlineClassName, + inlineClassName: inlineClassName.join(' '), inlineClassNameAffectsLetterSpacing: true, - cursorStops: InjectedTextCursorStops.Both, - attachedData: this + cursorStops: InjectedTextCursorStops.None, + attachedData: new HintData(isWhitespace ? InlineChatConfigKeys.LineEmptyHint : InlineChatConfigKeys.LineSuffixHint) } } }]); @@ -243,6 +290,23 @@ export class InlineChatHintsController extends Disposable implements IEditorCont })); } + private _showContextMenu(event: IMouseEvent, setting: string): void { + this._ctxMenuVisibleObs.set(true, undefined); + this._contextMenuService.showContextMenu({ + getAnchor: () => ({ x: event.posx, y: event.posy }), + onHide: () => this._ctxMenuVisibleObs.set(false, undefined), + getActions: () => [ + toAction({ + id: 'inlineChat.disableHint', + label: localize('disableHint', "Disable Inline Chat Hint"), + run: async () => { + await this._configurationService.updateValue(setting, false); + } + }) + ] + }); + } + show(): void { this._visibilityObs.set(true, undefined); } diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index e2912601816..15d2b83cf5b 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -18,7 +18,9 @@ export const enum InlineChatConfigKeys { AcceptedOrDiscardBeforeSave = 'inlineChat.acceptedOrDiscardBeforeSave', StartWithOverlayWidget = 'inlineChat.startWithOverlayWidget', HoldToSpeech = 'inlineChat.holdToSpeech', - AccessibleDiffView = 'inlineChat.accessibleDiffView' + AccessibleDiffView = 'inlineChat.accessibleDiffView', + LineEmptyHint = 'inlineChat.lineEmptyHint', + LineSuffixHint = 'inlineChat.lineSuffixHint' } export const enum EditMode { @@ -60,6 +62,16 @@ Registry.as(Extensions.Configuration).registerConfigurat localize('accessibleDiffView.off', "The accessible diff viewer is never enabled."), ], }, + [InlineChatConfigKeys.LineEmptyHint]: { + description: localize('emptyLineHint', "Whether empty lines show a hint to generate code with inline chat."), + default: false, + type: 'boolean' + }, + [InlineChatConfigKeys.LineSuffixHint]: { + description: localize('lineSuffixHint', "Whether a hint to complete a line with inline chat is shown."), + default: true, + type: 'boolean' + }, } });