Path: blob/main/src/vs/workbench/contrib/inlineChat/browser/inlineChatCurrentLine.ts
3296 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 { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js';6import { ICodeEditor, MouseTargetType } from '../../../../editor/browser/editorBrowser.js';7import { IEditorContribution } from '../../../../editor/common/editorCommon.js';8import { localize, localize2 } from '../../../../nls.js';9import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';10import { InlineChatController } from './inlineChatController.js';11import { ACTION_START, CTX_INLINE_CHAT_HAS_AGENT, CTX_INLINE_CHAT_VISIBLE, InlineChatConfigKeys } from '../common/inlineChat.js';12import { EditorAction2, ServicesAccessor } from '../../../../editor/browser/editorExtensions.js';13import { EditOperation } from '../../../../editor/common/core/editOperation.js';14import { Range } from '../../../../editor/common/core/range.js';15import { IPosition, Position } from '../../../../editor/common/core/position.js';16import { AbstractInline1ChatAction } from './inlineChatActions.js';17import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js';18import { IValidEditOperation, TrackedRangeStickiness } from '../../../../editor/common/model.js';19import { URI } from '../../../../base/common/uri.js';20import { isEqual } from '../../../../base/common/resources.js';21import { StandardTokenType } from '../../../../editor/common/encodedTokenAttributes.js';22import { autorun, derived, observableFromEvent, observableValue } from '../../../../base/common/observable.js';23import { KeyChord, KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';24import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';25import './media/inlineChat.css';26import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';27import { ICommandService } from '../../../../platform/commands/common/commands.js';28import { InlineCompletionsController } from '../../../../editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.js';29import { IChatAgentService } from '../../chat/common/chatAgents.js';30import { IMarkerDecorationsService } from '../../../../editor/common/services/markerDecorations.js';31import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';32import { toAction } from '../../../../base/common/actions.js';33import { IMouseEvent } from '../../../../base/browser/mouseEvent.js';34import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';35import { observableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js';36import { PLAINTEXT_LANGUAGE_ID } from '../../../../editor/common/languages/modesRegistry.js';37import { createStyleSheet2 } from '../../../../base/browser/domStylesheets.js';38import { stringValue } from '../../../../base/browser/cssValue.js';39import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js';40import { ChatAgentLocation } from '../../chat/common/constants.js';41import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID } from '../../chat/common/promptSyntax/promptTypes.js';42import { MODE_FILE_EXTENSION } from '../../chat/common/promptSyntax/config/promptFileLocations.js';4344/**45* Set of language IDs where inline chat hints should not be shown.46*/47const IGNORED_LANGUAGE_IDS = new Set([48PLAINTEXT_LANGUAGE_ID,49'markdown',50'search-result',51INSTRUCTIONS_LANGUAGE_ID,52PROMPT_LANGUAGE_ID,53MODE_FILE_EXTENSION54]);5556export const CTX_INLINE_CHAT_SHOWING_HINT = new RawContextKey<boolean>('inlineChatShowingHint', false, localize('inlineChatShowingHint', "Whether inline chat shows a contextual hint"));5758const _inlineChatActionId = 'inlineChat.startWithCurrentLine';5960export class InlineChatExpandLineAction extends EditorAction2 {6162constructor() {63super({64id: _inlineChatActionId,65category: AbstractInline1ChatAction.category,66title: localize2('startWithCurrentLine', "Start in Editor with Current Line"),67f1: true,68precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_VISIBLE.negate(), CTX_INLINE_CHAT_HAS_AGENT, EditorContextKeys.writable),69keybinding: [{70when: CTX_INLINE_CHAT_SHOWING_HINT,71weight: KeybindingWeight.WorkbenchContrib + 1,72primary: KeyMod.CtrlCmd | KeyCode.KeyI73}, {74weight: KeybindingWeight.WorkbenchContrib,75primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyCode.KeyI),76}]77});78}7980override async runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor) {81const ctrl = InlineChatController.get(editor);82if (!ctrl || !editor.hasModel()) {83return;84}8586const model = editor.getModel();87const lineNumber = editor.getSelection().positionLineNumber;88const lineContent = model.getLineContent(lineNumber);8990const startColumn = model.getLineFirstNonWhitespaceColumn(lineNumber);91const endColumn = model.getLineMaxColumn(lineNumber);9293// clear the line94let undoEdits: IValidEditOperation[] = [];95model.pushEditOperations(null, [EditOperation.replace(new Range(lineNumber, startColumn, lineNumber, endColumn), '')], (edits) => {96undoEdits = edits;97return null;98});99100// trigger chat101const accepted = await ctrl.run({102autoSend: true,103message: lineContent.trim(),104position: new Position(lineNumber, startColumn)105});106107if (!accepted) {108model.pushEditOperations(null, undoEdits, () => null);109}110}111}112113export class ShowInlineChatHintAction extends EditorAction2 {114115constructor() {116super({117id: 'inlineChat.showHint',118category: AbstractInline1ChatAction.category,119title: localize2('showHint', "Show Inline Chat Hint"),120f1: false,121precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_VISIBLE.negate(), CTX_INLINE_CHAT_HAS_AGENT, EditorContextKeys.writable),122});123}124125override async runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor, ...args: [uri: URI, position: IPosition, ...rest: any[]]) {126if (!editor.hasModel()) {127return;128}129130const ctrl = InlineChatHintsController.get(editor);131if (!ctrl) {132return;133}134135const [uri, position] = args;136if (!URI.isUri(uri) || !Position.isIPosition(position)) {137ctrl.hide();138return;139}140141const model = editor.getModel();142if (!isEqual(model.uri, uri)) {143ctrl.hide();144return;145}146147model.tokenization.forceTokenization(position.lineNumber);148const tokens = model.tokenization.getLineTokens(position.lineNumber);149150let totalLength = 0;151let specialLength = 0;152let lastTokenType: StandardTokenType | undefined;153154tokens.forEach(idx => {155const tokenType = tokens.getStandardTokenType(idx);156const startOffset = tokens.getStartOffset(idx);157const endOffset = tokens.getEndOffset(idx);158totalLength += endOffset - startOffset;159160if (tokenType !== StandardTokenType.Other) {161specialLength += endOffset - startOffset;162}163lastTokenType = tokenType;164});165166if (specialLength / totalLength > 0.25) {167ctrl.hide();168return;169}170if (lastTokenType === StandardTokenType.Comment) {171ctrl.hide();172return;173}174ctrl.show();175}176}177178export class InlineChatHintsController extends Disposable implements IEditorContribution {179180public static readonly ID = 'editor.contrib.inlineChatHints';181182static get(editor: ICodeEditor): InlineChatHintsController | null {183return editor.getContribution<InlineChatHintsController>(InlineChatHintsController.ID);184}185186private readonly _editor: ICodeEditor;187private readonly _ctxShowingHint: IContextKey<boolean>;188private readonly _visibilityObs = observableValue<boolean>(this, false);189190constructor(191editor: ICodeEditor,192@IContextKeyService contextKeyService: IContextKeyService,193@ICommandService commandService: ICommandService,194@IKeybindingService keybindingService: IKeybindingService,195@IChatAgentService chatAgentService: IChatAgentService,196@IMarkerDecorationsService markerDecorationService: IMarkerDecorationsService,197@IContextMenuService private readonly _contextMenuService: IContextMenuService,198@IConfigurationService private readonly _configurationService: IConfigurationService199) {200super();201this._editor = editor;202this._ctxShowingHint = CTX_INLINE_CHAT_SHOWING_HINT.bindTo(contextKeyService);203204const ghostCtrl = InlineCompletionsController.get(editor);205206this._store.add(commandService.onWillExecuteCommand(e => {207if (e.commandId === _inlineChatActionId || e.commandId === ACTION_START) {208this.hide();209}210}));211212this._store.add(this._editor.onMouseDown(e => {213if (e.target.type !== MouseTargetType.CONTENT_TEXT) {214return;215}216if (!e.target.element?.classList.contains('inline-chat-hint-text')) {217return;218}219if (e.event.leftButton) {220commandService.executeCommand(_inlineChatActionId);221this.hide();222} else if (e.event.rightButton) {223e.event.preventDefault();224this._showContextMenu(e.event, e.target.element?.classList.contains('whitespace')225? InlineChatConfigKeys.LineEmptyHint226: InlineChatConfigKeys.LineNLHint227);228}229}));230231const markerSuppression = this._store.add(new MutableDisposable());232const decos = this._editor.createDecorationsCollection();233234const editorObs = observableCodeEditor(editor);235const keyObs = observableFromEvent(keybindingService.onDidUpdateKeybindings, _ => keybindingService.lookupKeybinding(ACTION_START)?.getLabel());236const configHintEmpty = observableConfigValue(InlineChatConfigKeys.LineEmptyHint, false, this._configurationService);237const configHintNL = observableConfigValue(InlineChatConfigKeys.LineNLHint, false, this._configurationService);238239const showDataObs = derived((r) => {240const ghostState = ghostCtrl?.model.read(r)?.state.read(r);241242const textFocus = editorObs.isTextFocused.read(r);243const position = editorObs.cursorPosition.read(r);244const model = editorObs.model.read(r);245246const kb = keyObs.read(r);247248if (ghostState !== undefined || !kb || !position || !model || !textFocus) {249return undefined;250}251252if (IGNORED_LANGUAGE_IDS.has(model.getLanguageId())) {253return undefined;254}255256editorObs.versionId.read(r);257258const visible = this._visibilityObs.read(r);259const isEol = model.getLineMaxColumn(position.lineNumber) === position.column;260const isWhitespace = model.getLineLastNonWhitespaceColumn(position.lineNumber) === 0 && model.getValueLength() > 0 && position.column > 1;261262if (isWhitespace) {263return configHintEmpty.read(r)264? { isEol, isWhitespace, kb, position, model }265: undefined;266}267268if (visible && isEol && configHintNL.read(r)) {269return { isEol, isWhitespace, kb, position, model };270}271272return undefined;273});274275const style = createStyleSheet2();276this._store.add(style);277278this._store.add(autorun(r => {279280const showData = showDataObs.read(r);281if (!showData) {282decos.clear();283markerSuppression.clear();284this._ctxShowingHint.reset();285return;286}287288const agentName = chatAgentService.getDefaultAgent(ChatAgentLocation.Editor)?.name ?? localize('defaultTitle', "Chat");289const { position, isEol, isWhitespace, kb, model } = showData;290291const inlineClassName: string[] = ['a' /*HACK but sorts as we want*/, 'inline-chat-hint', 'inline-chat-hint-text'];292let content: string;293if (isWhitespace) {294content = '\u00a0' + localize('title2', "{0} to edit with {1}", kb, agentName);295} else if (isEol) {296content = '\u00a0' + localize('title1', "{0} to continue with {1}", kb, agentName);297} else {298content = '\u200a' + kb + '\u200a';299inlineClassName.push('embedded');300}301302style.setStyle(`.inline-chat-hint-text::after { content: ${stringValue(content)} }`);303if (isWhitespace) {304inlineClassName.push('whitespace');305}306307this._ctxShowingHint.set(true);308309decos.set([{310range: Range.fromPositions(position),311options: {312description: 'inline-chat-hint-line',313showIfCollapsed: true,314stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,315afterContentClassName: inlineClassName.join(' '),316}317}]);318319markerSuppression.value = markerDecorationService.addMarkerSuppression(model.uri, model.validateRange(new Range(position.lineNumber, 1, position.lineNumber, Number.MAX_SAFE_INTEGER)));320}));321}322323private _showContextMenu(event: IMouseEvent, setting: string): void {324this._contextMenuService.showContextMenu({325getAnchor: () => ({ x: event.posx, y: event.posy }),326getActions: () => [327toAction({328id: 'inlineChat.disableHint',329label: localize('disableHint', "Disable Inline Chat Hint"),330run: async () => {331await this._configurationService.updateValue(setting, false);332}333})334]335});336}337338show(): void {339this._visibilityObs.set(true, undefined);340}341342hide(): void {343this._visibilityObs.set(false, undefined);344}345}346347export class HideInlineChatHintAction extends EditorAction2 {348349constructor() {350super({351id: 'inlineChat.hideHint',352title: localize2('hideHint', "Hide Inline Chat Hint"),353precondition: CTX_INLINE_CHAT_SHOWING_HINT,354keybinding: {355weight: KeybindingWeight.EditorContrib - 10,356primary: KeyCode.Escape357}358});359}360361override async runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor): Promise<void> {362InlineChatHintsController.get(editor)?.hide();363}364}365366367