inline hint for empty lines, right-click to hide, differentiate between empty line and suffix hint (#234773)

This commit is contained in:
Johannes Rieken
2024-11-27 18:08:40 +01:00
committed by GitHub
parent dbc5c12e06
commit 5555f6604d
5 changed files with 127 additions and 41 deletions

View File

@@ -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<boolean>('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<boolean>;
private readonly _visibilityObs = observableValue<boolean>(this, false);
private readonly _ctxMenuVisibleObs = observableValue<boolean>(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);
}