Path: blob/main/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts
5221 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*--------------------------------------------------------------------------------------------*/4import { Emitter, Event } from '../../../../base/common/event.js';5import { Disposable, dispose, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';6import { ResourceMap } from '../../../../base/common/map.js';7import { autorun, observableFromEvent } from '../../../../base/common/observable.js';8import { isEqual } from '../../../../base/common/resources.js';9import { URI } from '../../../../base/common/uri.js';10import { IActiveCodeEditor, isCodeEditor, isCompositeEditor, isDiffEditor } from '../../../../editor/browser/editorBrowser.js';11import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';12import { localize, localize2 } from '../../../../nls.js';13import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';14import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';15import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';16import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';17import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';18import { ILogService } from '../../../../platform/log/common/log.js';19import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js';20import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';21import { IEditorService } from '../../../services/editor/common/editorService.js';22import { IChatAgentService } from '../../chat/common/participants/chatAgents.js';23import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js';24import { ModifiedFileEntryState } from '../../chat/common/editing/chatEditingService.js';25import { IChatService } from '../../chat/common/chatService/chatService.js';26import { ChatAgentLocation } from '../../chat/common/constants.js';27import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../chat/common/tools/languageModelToolsService.js';28import { CTX_INLINE_CHAT_HAS_AGENT2, CTX_INLINE_CHAT_HAS_NOTEBOOK_AGENT, CTX_INLINE_CHAT_POSSIBLE, InlineChatConfigKeys } from '../common/inlineChat.js';29import { askInPanelChat, IInlineChatSession2, IInlineChatSessionService } from './inlineChatSessionService.js';3031export class InlineChatError extends Error {32static readonly code = 'InlineChatError';33constructor(message: string) {34super(message);35this.name = InlineChatError.code;36}37}3839export class InlineChatSessionServiceImpl implements IInlineChatSessionService {4041declare _serviceBrand: undefined;4243private readonly _store = new DisposableStore();44private readonly _sessions = new ResourceMap<IInlineChatSession2>();4546private readonly _onWillStartSession = this._store.add(new Emitter<IActiveCodeEditor>());47readonly onWillStartSession: Event<IActiveCodeEditor> = this._onWillStartSession.event;4849private readonly _onDidChangeSessions = this._store.add(new Emitter<this>());50readonly onDidChangeSessions: Event<this> = this._onDidChangeSessions.event;5152constructor(53@IChatService private readonly _chatService: IChatService,54@IChatAgentService chatAgentService: IChatAgentService,55) {56// Listen for agent changes and dispose all sessions when there is no agent57const agentObs = observableFromEvent(this, chatAgentService.onDidChangeAgents, () => chatAgentService.getDefaultAgent(ChatAgentLocation.EditorInline));58this._store.add(autorun(r => {59const agent = agentObs.read(r);60if (!agent) {61// No agent available, dispose all sessions62dispose(this._sessions.values());63this._sessions.clear();64}65}));66}6768dispose() {69this._store.dispose();70}717273createSession(editor: IActiveCodeEditor): IInlineChatSession2 {74const uri = editor.getModel().uri;7576if (this._sessions.has(uri)) {77throw new Error('Session already exists');78}7980this._onWillStartSession.fire(editor);8182const chatModelRef = this._chatService.startSession(ChatAgentLocation.EditorInline, { canUseTools: false /* SEE https://github.com/microsoft/vscode/issues/279946 */ });83const chatModel = chatModelRef.object;84chatModel.startEditingSession(false);8586const store = new DisposableStore();87store.add(toDisposable(() => {88this._chatService.cancelCurrentRequestForSession(chatModel.sessionResource);89chatModel.editingSession?.reject();90this._sessions.delete(uri);91this._onDidChangeSessions.fire(this);92}));93store.add(chatModelRef);9495store.add(autorun(r => {9697const entries = chatModel.editingSession?.entries.read(r);98if (!entries?.length) {99return;100}101102const state = entries.find(entry => isEqual(entry.modifiedURI, uri))?.state.read(r);103if (state === ModifiedFileEntryState.Accepted || state === ModifiedFileEntryState.Rejected) {104const response = chatModel.getRequests().at(-1)?.response;105if (response) {106this._chatService.notifyUserAction({107sessionResource: response.session.sessionResource,108requestId: response.requestId,109agentId: response.agent?.id,110command: response.slashCommand?.name,111result: response.result,112action: {113kind: 'inlineChat',114action: state === ModifiedFileEntryState.Accepted ? 'accepted' : 'discarded'115}116});117}118}119120const allSettled = entries.every(entry => {121const state = entry.state.read(r);122return (state === ModifiedFileEntryState.Accepted || state === ModifiedFileEntryState.Rejected)123&& !entry.isCurrentlyBeingModifiedBy.read(r);124});125126if (allSettled && !chatModel.requestInProgress.read(undefined)) {127// self terminate128store.dispose();129}130}));131132const result: IInlineChatSession2 = {133uri,134initialPosition: editor.getSelection().getStartPosition().delta(-1), /* one line above selection start */135initialSelection: editor.getSelection(),136chatModel,137editingSession: chatModel.editingSession!,138dispose: store.dispose.bind(store)139};140this._sessions.set(uri, result);141this._onDidChangeSessions.fire(this);142return result;143}144145getSessionByTextModel(uri: URI): IInlineChatSession2 | undefined {146let result = this._sessions.get(uri);147if (!result) {148// no direct session, try to find an editing session which has a file entry for the uri149for (const [_, candidate] of this._sessions) {150const entry = candidate.editingSession.getEntry(uri);151if (entry) {152result = candidate;153break;154}155}156}157return result;158}159160getSessionBySessionUri(sessionResource: URI): IInlineChatSession2 | undefined {161for (const session of this._sessions.values()) {162if (isEqual(session.chatModel.sessionResource, sessionResource)) {163return session;164}165}166return undefined;167}168}169170export class InlineChatEnabler {171172static Id = 'inlineChat.enabler';173174private readonly _ctxHasProvider2: IContextKey<boolean>;175private readonly _ctxHasNotebookProvider: IContextKey<boolean>;176private readonly _ctxPossible: IContextKey<boolean>;177178private readonly _store = new DisposableStore();179180constructor(181@IContextKeyService contextKeyService: IContextKeyService,182@IChatAgentService chatAgentService: IChatAgentService,183@IEditorService editorService: IEditorService,184@IConfigurationService configService: IConfigurationService,185) {186this._ctxHasProvider2 = CTX_INLINE_CHAT_HAS_AGENT2.bindTo(contextKeyService);187this._ctxHasNotebookProvider = CTX_INLINE_CHAT_HAS_NOTEBOOK_AGENT.bindTo(contextKeyService);188this._ctxPossible = CTX_INLINE_CHAT_POSSIBLE.bindTo(contextKeyService);189190const agentObs = observableFromEvent(this, chatAgentService.onDidChangeAgents, () => chatAgentService.getDefaultAgent(ChatAgentLocation.EditorInline));191const notebookAgentObs = observableFromEvent(this, chatAgentService.onDidChangeAgents, () => chatAgentService.getDefaultAgent(ChatAgentLocation.Notebook));192const notebookAgentConfigObs = observableConfigValue(InlineChatConfigKeys.notebookAgent, false, configService);193194this._store.add(autorun(r => {195const agent = agentObs.read(r);196if (!agent) {197this._ctxHasProvider2.reset();198} else {199this._ctxHasProvider2.set(true);200}201}));202203this._store.add(autorun(r => {204this._ctxHasNotebookProvider.set(notebookAgentConfigObs.read(r) && !!notebookAgentObs.read(r));205}));206207const updateEditor = () => {208const ctrl = editorService.activeEditorPane?.getControl();209const isCodeEditorLike = isCodeEditor(ctrl) || isDiffEditor(ctrl) || isCompositeEditor(ctrl);210this._ctxPossible.set(isCodeEditorLike);211};212213this._store.add(editorService.onDidActiveEditorChange(updateEditor));214updateEditor();215}216217dispose() {218this._ctxPossible.reset();219this._ctxHasProvider2.reset();220this._store.dispose();221}222}223224225export class InlineChatEscapeToolContribution extends Disposable {226227static readonly Id = 'inlineChat.escapeTool';228229static readonly DONT_ASK_AGAIN_KEY = 'inlineChat.dontAskMoveToPanelChat';230231private static readonly _data: IToolData = {232id: 'inline_chat_exit',233source: ToolDataSource.Internal,234canBeReferencedInPrompt: false,235alwaysDisplayInputOutput: false,236displayName: localize('name', "Inline Chat to Panel Chat"),237modelDescription: 'Moves the inline chat session to the richer panel chat which supports edits across files, creating and deleting files, multi-turn conversations between the user and the assistant, and access to more IDE tools, like retrieve problems, interact with source control, run terminal commands etc.',238};239240constructor(241@ILanguageModelToolsService lmTools: ILanguageModelToolsService,242@IInlineChatSessionService inlineChatSessionService: IInlineChatSessionService,243@IDialogService dialogService: IDialogService,244@ICodeEditorService codeEditorService: ICodeEditorService,245@IChatService chatService: IChatService,246@ILogService logService: ILogService,247@IStorageService storageService: IStorageService,248@IInstantiationService instaService: IInstantiationService,249) {250251super();252253this._store.add(lmTools.registerTool(InlineChatEscapeToolContribution._data, {254invoke: async (invocation, _tokenCountFn, _progress, _token) => {255256const sessionResource = invocation.context?.sessionResource;257258if (!sessionResource) {259logService.warn('InlineChatEscapeToolContribution: no sessionId in tool invocation context');260return { content: [{ kind: 'text', value: 'Cancel' }] };261}262263const session = inlineChatSessionService.getSessionBySessionUri(sessionResource);264265if (!session) {266logService.warn(`InlineChatEscapeToolContribution: no session found for id ${sessionResource}`);267return { content: [{ kind: 'text', value: 'Cancel' }] };268}269270const dontAskAgain = storageService.getBoolean(InlineChatEscapeToolContribution.DONT_ASK_AGAIN_KEY, StorageScope.PROFILE);271272let result: { confirmed: boolean; checkboxChecked?: boolean };273if (dontAskAgain !== undefined) {274// Use previously stored user preference: true = 'Continue in Chat view', false = 'Rephrase' (Cancel)275result = { confirmed: dontAskAgain, checkboxChecked: false };276} else {277result = await dialogService.confirm({278type: 'question',279title: localize('confirm.title', "Do you want to continue in Chat view?"),280message: localize('confirm', "Do you want to continue in Chat view?"),281detail: localize('confirm.detail', "Inline chat is designed for making single-file code changes. Continue your request in the Chat view or rephrase it for inline chat."),282primaryButton: localize('confirm.yes', "Continue in Chat view"),283cancelButton: localize('confirm.cancel', "Cancel"),284checkbox: { label: localize('chat.remove.confirmation.checkbox', "Don't ask again"), checked: false },285});286}287288const editor = codeEditorService.getFocusedCodeEditor();289290if (!editor || result.confirmed) {291logService.trace('InlineChatEscapeToolContribution: moving session to panel chat');292await instaService.invokeFunction(askInPanelChat, session.chatModel.getRequests().at(-1)!, session.chatModel.inputModel.state.get());293session.dispose();294295} else {296logService.trace('InlineChatEscapeToolContribution: rephrase prompt');297const lastRequest = session.chatModel.getRequests().at(-1)!;298chatService.removeRequest(session.chatModel.sessionResource, lastRequest.id);299session.chatModel.inputModel.setState({ inputText: lastRequest.message.text });300}301302if (result.checkboxChecked) {303storageService.store(InlineChatEscapeToolContribution.DONT_ASK_AGAIN_KEY, result.confirmed, StorageScope.PROFILE, StorageTarget.USER);304logService.trace('InlineChatEscapeToolContribution: stored don\'t ask again preference');305}306307return { content: [{ kind: 'text', value: 'Success' }] };308}309}));310}311}312313registerAction2(class ResetMoveToPanelChatChoice extends Action2 {314constructor() {315super({316id: 'inlineChat.resetMoveToPanelChatChoice',317precondition: ChatContextKeys.Setup.hidden.negate(),318title: localize2('resetChoice.label', "Reset Choice for 'Move Inline Chat to Panel Chat'"),319f1: true320});321}322run(accessor: ServicesAccessor) {323accessor.get(IStorageService).remove(InlineChatEscapeToolContribution.DONT_ASK_AGAIN_KEY, StorageScope.PROFILE);324}325});326327328