diff --git a/src/vs/base/common/observableInternal/utils.ts b/src/vs/base/common/observableInternal/utils.ts index c42f12f7b8ef7..56d3e692b4959 100644 --- a/src/vs/base/common/observableInternal/utils.ts +++ b/src/vs/base/common/observableInternal/utils.ts @@ -202,20 +202,24 @@ export namespace observableFromEvent { } export function observableSignalFromEvent( - debugName: string, + owner: DebugOwner | string, event: Event ): IObservable { - return new FromEventObservableSignal(debugName, event); + return new FromEventObservableSignal(typeof owner === 'string' ? owner : new DebugNameData(owner, undefined, undefined), event); } class FromEventObservableSignal extends BaseObservable { private subscription: IDisposable | undefined; + public readonly debugName: string; constructor( - public readonly debugName: string, + debugNameDataOrName: DebugNameData | string, private readonly event: Event, ) { super(); + this.debugName = typeof debugNameDataOrName === 'string' + ? debugNameDataOrName + : debugNameDataOrName.getDebugName(this) ?? 'Observable Signal From Event'; } protected override onFirstObserverAdded(): void { diff --git a/src/vs/base/common/strings.ts b/src/vs/base/common/strings.ts index be89ea4929944..c4913e9bfa98b 100644 --- a/src/vs/base/common/strings.ts +++ b/src/vs/base/common/strings.ts @@ -749,7 +749,7 @@ export function isEmojiImprecise(x: number): boolean { * happens at favorable positions - such as whitespace or punctuation characters. * The return value can be longer than the given value of `n`. Leading whitespace is always trimmed. */ -export function lcut(text: string, n: number, prefix = '') { +export function lcut(text: string, n: number, prefix = ''): string { const trimmed = text.trimStart(); if (trimmed.length < n) { @@ -774,6 +774,35 @@ export function lcut(text: string, n: number, prefix = '') { return prefix + trimmed.substring(i).trimStart(); } +/** + * Given a string and a max length returns a shorted version. Shorting + * happens at favorable positions - such as whitespace or punctuation characters. + * The return value can be longer than the given value of `n`. Trailing whitespace is always trimmed. + */ +export function rcut(text: string, n: number, suffix = ''): string { + const trimmed = text.trimEnd(); + + if (trimmed.length < n) { + return trimmed; + } + + const re = /\b/g; + let lastWordBreak = trimmed.length; + + while (re.test(trimmed)) { + if (trimmed.length - re.lastIndex > n) { + lastWordBreak = re.lastIndex; + } + re.lastIndex += 1; + } + + if (lastWordBreak === trimmed.length) { + return trimmed; + } + + return (trimmed.substring(0, lastWordBreak) + suffix).trimEnd(); +} + // Escape codes, compiled from https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Functions-using-CSI-_-ordered-by-the-final-character_s_ // Plus additional markers for custom `\x1b]...\x07` instructions. const CSI_SEQUENCE = /(?:(?:\x1b\[|\x9B)[=?>!]?[\d;:]*["$#'* ]?[a-zA-Z@^`{}|~])|(:?\x1b\].*?\x07)/g; diff --git a/src/vs/editor/common/core/position.ts b/src/vs/editor/common/core/position.ts index c6bc5da82ba3e..e3033e953f9f9 100644 --- a/src/vs/editor/common/core/position.ts +++ b/src/vs/editor/common/core/position.ts @@ -56,7 +56,7 @@ export class Position { * @param deltaColumn column delta */ delta(deltaLineNumber: number = 0, deltaColumn: number = 0): Position { - return this.with(this.lineNumber + deltaLineNumber, this.column + deltaColumn); + return this.with(Math.max(1, this.lineNumber + deltaLineNumber), Math.max(1, this.column + deltaColumn)); } /** diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts index 8ab3798c9fb54..40ac18994392d 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatClearActions.ts @@ -19,6 +19,7 @@ import { ChatAgentLocation } from '../../common/chatAgents.js'; import { ChatContextKeys } from '../../common/chatContextKeys.js'; import { hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, WorkingSetEntryState } from '../../common/chatEditingService.js'; import { ChatViewId, EditsViewId, IChatWidgetService } from '../chat.js'; +import { ctxIsGlobalEditingSession } from '../chatEditorController.js'; import { ChatEditorInput } from '../chatEditorInput.js'; import { ChatViewPane } from '../chatViewPane.js'; import { CHAT_CATEGORY } from './chatActions.js'; @@ -332,6 +333,7 @@ export function registerNewChatActions() { order: 2 }, { id: MenuId.ChatEditingEditorContent, + when: ctxIsGlobalEditingSession, group: 'navigate', order: 4, }], diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 965a2fe353da7..3dc094237e4ed 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -81,6 +81,7 @@ import { ChatRelatedFilesContribution } from './contrib/chatInputRelatedFilesCon import { ChatQuotasService, ChatQuotasStatusBarEntry, IChatQuotasService } from './chatQuotasService.js'; import { BuiltinToolsContribution } from './tools/tools.js'; import { ChatSetupContribution } from './chatSetup.js'; +import { ChatEditorOverlayController } from './chatEditorOverlay.js'; import '../common/promptSyntax/languageFeatures/promptLinkProvider.js'; // Register configuration @@ -335,6 +336,7 @@ registerChatDeveloperActions(); registerChatEditorActions(); registerEditorFeature(ChatPasteProvidersFeature); +registerEditorContribution(ChatEditorOverlayController.ID, ChatEditorOverlayController, EditorContributionInstantiation.Lazy); registerEditorContribution(ChatEditorController.ID, ChatEditorController, EditorContributionInstantiation.Eventually); registerSingleton(IChatService, ChatService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index ef6da957b264c..bc18d5eeee3bf 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -187,6 +187,7 @@ export interface IChatWidgetViewOptions { defaultElementHeight?: number; editorOverflowWidgetsDomNode?: HTMLElement; enableImplicitContext?: boolean; + enableWorkingSet?: 'explicit' | 'implicit'; } export interface IChatViewViewContext { diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts index 91821810a13b3..e030552e8366f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { RunOnceScheduler } from '../../../../../base/common/async.js'; -import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Emitter } from '../../../../../base/common/event.js'; import { Disposable, IReference, toDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; @@ -305,10 +304,9 @@ export class ChatEditingModifiedFileEntry extends Disposable implements IModifie this._clearCurrentEditLineDecoration(); // AUTO accept mode - if (!this.reviewMode.get()) { + if (!this.reviewMode.get() && !this._autoAcceptCtrl.get()) { const future = Date.now() + (this._autoAcceptTimeout.get() * 1000); - const cts = new CancellationTokenSource(); const update = () => { const reviewMode = this.reviewMode.get(); @@ -318,17 +316,15 @@ export class ChatEditingModifiedFileEntry extends Disposable implements IModifie return; } - if (cts.token.isCancellationRequested) { - this._autoAcceptCtrl.set(undefined, undefined); - return; - } - const remain = Math.round((future - Date.now()) / 1000); if (remain <= 0) { this.accept(undefined); } else { - this._autoAcceptCtrl.set(new AutoAcceptControl(remain, () => cts.cancel()), undefined); - setTimeout(update, 100); + const handle = setTimeout(update, 100); + this._autoAcceptCtrl.set(new AutoAcceptControl(remain, () => { + clearTimeout(handle); + this._autoAcceptCtrl.set(undefined, undefined); + }), undefined); } }; update(); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingService.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingService.ts index bddeb051851fa..fa18d8628e0d8 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingService.ts @@ -242,7 +242,7 @@ export class ChatEditingService extends Disposable implements IChatEditingServic this._currentSessionDisposables.clear(); - const session = this._instantiationService.createInstance(ChatEditingSession, chatSessionId, this._editingSessionFileLimitPromise, this._lookupEntry.bind(this)); + const session = this._instantiationService.createInstance(ChatEditingSession, chatSessionId, true, this._editingSessionFileLimitPromise, this._lookupEntry.bind(this)); await session.init(); // listen for completed responses, run the code mapper and apply the edits to this edit session @@ -258,7 +258,7 @@ export class ChatEditingService extends Disposable implements IChatEditingServic } async createAdhocEditingSession(chatSessionId: string): Promise { - const session = this._instantiationService.createInstance(ChatEditingSession, chatSessionId, this._editingSessionFileLimitPromise, this._lookupEntry.bind(this)); + const session = this._instantiationService.createInstance(ChatEditingSession, chatSessionId, false, this._editingSessionFileLimitPromise, this._lookupEntry.bind(this)); await session.init(); const list = this._adhocSessionsObs.get(); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts index 27a7bebfb4f1b..f6a2e9daa54f2 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts @@ -155,18 +155,14 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio return this._onDidDispose.event; } - get isVisible(): boolean { - this._assertNotDisposed(); - return Boolean(this._editorPane && this._editorPane.isVisible()); - } - private _isToolsAgentSession = false; get isToolsAgentSession(): boolean { return this._isToolsAgentSession; } constructor( - public readonly chatSessionId: string, + readonly chatSessionId: string, + readonly isGlobalEditingSession: boolean, private editingSessionFileLimitPromise: Promise, private _lookupExternalEntry: (uri: URI) => ChatEditingModifiedFileEntry | undefined, @IInstantiationService private readonly _instantiationService: IInstantiationService, diff --git a/src/vs/workbench/contrib/chat/browser/chatEditorController.ts b/src/vs/workbench/contrib/chat/browser/chatEditorController.ts index 66127bd38a7c7..c9137403d01cb 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditorController.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditorController.ts @@ -35,8 +35,9 @@ import { isDiffEditorForEntry } from './chatEditing/chatEditing.js'; import { basename, isEqual } from '../../../../base/common/resources.js'; import { ChatAgentLocation, IChatAgentService } from '../common/chatAgents.js'; import { EditorsOrder, IEditorIdentifier, isDiffEditorInput } from '../../../common/editor.js'; -import { ChatEditorOverlayWidget } from './chatEditorOverlay.js'; +import { ChatEditorOverlayController } from './chatEditorOverlay.js'; +export const ctxIsGlobalEditingSession = new RawContextKey('chat.isGlobalEditingSession', undefined, localize('chat.ctxEditSessionIsGlobal', "The current editor is part of the global edit session")); export const ctxHasEditorModification = new RawContextKey('chat.hasEditorModifications', undefined, localize('chat.hasEditorModifications', "The current editor contains chat modifications")); export const ctxHasRequestInProgress = new RawContextKey('chat.ctxHasRequestInProgress', false, localize('chat.ctxHasRequestInProgress', "The current editor shows a file from an edit session which is still in progress")); export const ctxReviewModeEnabled = new RawContextKey('chat.ctxReviewModeEnabled', true, localize('chat.ctxReviewModeEnabled', "Review mode for chat changes is enabled")); @@ -54,8 +55,9 @@ export class ChatEditorController extends Disposable implements IEditorContribut private _viewZones: string[] = []; - private readonly _overlayWidget: ChatEditorOverlayWidget; + private readonly _overlayCtrl: ChatEditorOverlayController; + private readonly _ctxIsGlobalEditsSession: IContextKey; private readonly _ctxHasEditorModification: IContextKey; private readonly _ctxRequestInProgress: IContextKey; private readonly _ctxReviewModelEnabled: IContextKey; @@ -83,7 +85,8 @@ export class ChatEditorController extends Disposable implements IEditorContribut ) { super(); - this._overlayWidget = _instantiationService.createInstance(ChatEditorOverlayWidget, _editor); + this._overlayCtrl = ChatEditorOverlayController.get(_editor)!; + this._ctxIsGlobalEditsSession = ctxIsGlobalEditingSession.bindTo(contextKeyService); this._ctxHasEditorModification = ctxHasEditorModification.bindTo(contextKeyService); this._ctxRequestInProgress = ctxHasRequestInProgress.bindTo(contextKeyService); this._ctxReviewModelEnabled = ctxReviewModeEnabled.bindTo(contextKeyService); @@ -129,6 +132,7 @@ export class ChatEditorController extends Disposable implements IEditorContribut const currentEditorEntry = entryForEditor.read(r); if (!currentEditorEntry) { + this._ctxIsGlobalEditsSession.reset(); this._clear(); didReval = false; return; @@ -141,6 +145,7 @@ export class ChatEditorController extends Disposable implements IEditorContribut const { session, entries, idx, entry } = currentEditorEntry; + this._ctxIsGlobalEditsSession.set(session.isGlobalEditingSession); this._ctxReviewModelEnabled.set(entry.reviewMode.read(r)); // context @@ -148,9 +153,9 @@ export class ChatEditorController extends Disposable implements IEditorContribut // overlay widget if (entry.state.read(r) !== WorkingSetEntryState.Modified) { - this._overlayWidget.hide(); + this._overlayCtrl.hide(); } else { - this._overlayWidget.show(session, entry, entries[(idx + 1) % entries.length]); + this._overlayCtrl.showEntry(session, entry, entries[(idx + 1) % entries.length]); } // scrolling logic @@ -242,7 +247,7 @@ export class ChatEditorController extends Disposable implements IEditorContribut private _clear() { this._clearDiffRendering(); - this._overlayWidget.hide(); + this._overlayCtrl.hide(); this._diffLineDecorations.clear(); this._currentChangeIndex.set(undefined, undefined); this._currentEntryIndex.set(undefined, undefined); diff --git a/src/vs/workbench/contrib/chat/browser/chatEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/chatEditorOverlay.ts index c37d9a4d4109e..347b2697d90e6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditorOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditorOverlay.ts @@ -24,8 +24,12 @@ import { AcceptAction, navigationBearingFakeActionId, RejectAction } from './cha import { ChatEditorController } from './chatEditorController.js'; import './media/chatEditorOverlay.css'; import { findDiffEditorContainingCodeEditor } from '../../../../editor/browser/widget/diffEditor/commands.js'; +import { IChatService } from '../common/chatService.js'; +import { IEditorContribution } from '../../../../editor/common/editorCommon.js'; +import { rcut } from '../../../../base/common/strings.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; -export class ChatEditorOverlayWidget implements IOverlayWidget { +class ChatEditorOverlayWidget implements IOverlayWidget { readonly allowEditorOverflow = true; @@ -43,6 +47,8 @@ export class ChatEditorOverlayWidget implements IOverlayWidget { constructor( private readonly _editor: ICodeEditor, @IEditorService editorService: IEditorService, + @IHoverService private readonly _hoverService: IHoverService, + @IChatService private readonly _chatService: IChatService, @IInstantiationService private readonly _instaService: IInstantiationService, ) { this._domNode = document.createElement('div'); @@ -212,7 +218,32 @@ export class ChatEditorOverlayWidget implements IOverlayWidget { return { preference: OverlayWidgetPositionPreference.BOTTOM_RIGHT_CORNER }; } - show(session: IChatEditingSession, activeEntry: IModifiedFileEntry, next: IModifiedFileEntry) { + showRequest(session: IChatEditingSession) { + + this._showStore.clear(); + + const chatModel = this._chatService.getSession(session.chatSessionId); + const chatRequest = chatModel?.getRequests().at(-1); + + if (!chatRequest || !chatRequest.response) { + this.hide(); + return; + } + + this._domNode.classList.toggle('busy', true); + + const message = rcut(chatRequest.message.text, 47); + reset(this._progressNode, message); + + this._showStore.add(this._hoverService.setupDelayedHover(this._progressNode, { + content: chatRequest.message.text, + appearance: { showPointer: true } + })); + + this._show(); + } + + showEntry(session: IChatEditingSession, activeEntry: IModifiedFileEntry, next: IModifiedFileEntry) { this._showStore.clear(); @@ -226,8 +257,8 @@ export class ChatEditorOverlayWidget implements IOverlayWidget { this._showStore.add(autorun(r => { const value = activeEntry.rewriteRatio.read(r); reset(this._progressNode, (value === 0 - ? localize('generating', "Generating edits...") - : localize('applyingPercentage', "{0}% Applying edits...", Math.round(value * 100)))); + ? localize('generating', "Generating edits") + : localize('applyingPercentage', "{0}% Applying edits", Math.round(value * 100)))); })); this._showStore.add(autorun(r => { @@ -256,8 +287,12 @@ export class ChatEditorOverlayWidget implements IOverlayWidget { this._navigationBearings.set({ changeCount: changes, activeIdx, entriesCount: entries.length }, undefined); })); + this._show(); + } - const editorWithObs = observableFromEvent(this._editor.onDidLayoutChange, () => { + private _show(): void { + + const editorWidthObs = observableFromEvent(this._editor.onDidLayoutChange, () => { const diffEditor = this._instaService.invokeFunction(findDiffEditorContainingCodeEditor, this._editor); return diffEditor ? diffEditor.getOriginalEditor().getLayoutInfo().contentWidth + diffEditor.getModifiedEditor().getLayoutInfo().contentWidth @@ -265,7 +300,7 @@ export class ChatEditorOverlayWidget implements IOverlayWidget { }); this._showStore.add(autorun(r => { - const width = editorWithObs.read(r); + const width = editorWidthObs.read(r); this._domNode.style.maxWidth = `${width - 20}px`; })); @@ -289,3 +324,40 @@ export class ChatEditorOverlayWidget implements IOverlayWidget { } } } + + +export class ChatEditorOverlayController implements IEditorContribution { + + static readonly ID = 'editor.contrib.chatEditorOverlayController'; + + static get(editor: ICodeEditor): ChatEditorOverlayController | undefined { + return editor.getContribution(ChatEditorOverlayController.ID) ?? undefined; + } + + private readonly _overlayWidget: ChatEditorOverlayWidget; + + constructor( + private readonly _editor: ICodeEditor, + @IInstantiationService private readonly _instaService: IInstantiationService, + ) { + this._overlayWidget = this._instaService.createInstance(ChatEditorOverlayWidget, this._editor); + + } + + dispose(): void { + this.hide(); + this._overlayWidget.dispose(); + } + + showRequest(session: IChatEditingSession) { + this._overlayWidget.showRequest(session); + } + + showEntry(session: IChatEditingSession, activeEntry: IModifiedFileEntry, next: IModifiedFileEntry) { + this._overlayWidget.showEntry(session, activeEntry, next); + } + + hide() { + this._overlayWidget.hide(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts index e37cde758f858..f135e364ac1f1 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts @@ -124,6 +124,7 @@ interface IChatInputPartOptions { }; editorOverflowWidgetsDomNode?: HTMLElement; enableImplicitContext?: boolean; + renderWorkingSet?: boolean; } export interface IWorkingSetEntry { @@ -1141,7 +1142,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge async renderChatEditingSessionState(chatEditingSession: IChatEditingSession | null, chatWidget?: IChatWidget) { dom.setVisibility(Boolean(chatEditingSession), this.chatEditingSessionWidgetContainer); - if (!chatEditingSession) { + if (!chatEditingSession || !this.options.renderWorkingSet) { dom.clearNode(this.chatEditingSessionWidgetContainer); this._chatEditsDisposables.clear(); this._chatEditList = undefined; diff --git a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts index 742ae8cbf6b6a..8455f189e6e36 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts @@ -185,6 +185,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { }, enableImplicitContext: this.chatOptions.location === ChatAgentLocation.Panel, editorOverflowWidgetsDomNode: editorOverflowNode, + enableWorkingSet: this.chatOptions.location === ChatAgentLocation.EditingSession ? 'explicit' : undefined }, { listForeground: SIDE_BAR_FOREGROUND, diff --git a/src/vs/workbench/contrib/chat/browser/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/chatWidget.ts index b3f85bfca8217..ed820a0e07977 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWidget.ts @@ -15,7 +15,7 @@ import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, combinedDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../base/common/map.js'; import { Schemas } from '../../../../base/common/network.js'; -import { autorun } from '../../../../base/common/observable.js'; +import { autorunWithStore, observableFromEvent } from '../../../../base/common/observable.js'; import { extUri, isEqual } from '../../../../base/common/resources.js'; import { isDefined } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; @@ -187,6 +187,8 @@ export class ChatWidget extends Disposable implements IChatWidget { return this._viewModel; } + private _editingSession: IChatEditingSession | undefined; + private parsedChatRequest: IParsedChatRequest | undefined; get parsedInput() { if (this.parsedChatRequest === undefined) { @@ -249,38 +251,42 @@ export class ChatWidget extends Disposable implements IChatWidget { this._codeBlockModelCollection = this._register(instantiationService.createInstance(CodeBlockModelCollection)); - const chatEditingSessionDisposables = this._register(new DisposableStore()); - this._register(autorun(r => { - const session = this.chatEditingService.currentEditingSessionObs.read(r); + const viewModelObs = observableFromEvent(this, this.onDidChangeViewModel, () => this.viewModel); + + this._register(autorunWithStore((r, store) => { + + const viewModel = viewModelObs.read(r); + const sessions = chatEditingService.editingSessionsObs.read(r); + + const session = sessions.find(candidate => candidate.chatSessionId === viewModel?.sessionId); + this._editingSession = undefined; + this.renderChatEditingSessionState(); // this is necessary to make sure we dispose previous buttons, etc. + if (!session) { + // none or for a different chat widget return; } - if (session.chatSessionId !== this.viewModel?.sessionId) { - // this chat editing session is for a different chat widget - return; - } - // make sure to clean up anything related to the prev session (if any) - chatEditingSessionDisposables.clear(); - this.renderChatEditingSessionState(null); // this is necessary to make sure we dispose previous buttons, etc. - chatEditingSessionDisposables.add(session.onDidChange(() => { - this.renderChatEditingSessionState(session); + this._editingSession = session; + + store.add(session.onDidChange(() => { + this.renderChatEditingSessionState(); })); - chatEditingSessionDisposables.add(session.onDidDispose(() => { - chatEditingSessionDisposables.clear(); - this.renderChatEditingSessionState(null); + store.add(session.onDidDispose(() => { + this._editingSession = undefined; + this.renderChatEditingSessionState(); })); - chatEditingSessionDisposables.add(this.onDidChangeParsedInput(() => { - this.renderChatEditingSessionState(session); + store.add(this.onDidChangeParsedInput(() => { + this.renderChatEditingSessionState(); })); - chatEditingSessionDisposables.add(this.inputEditor.onDidChangeModelContent(() => { + store.add(this.inputEditor.onDidChangeModelContent(() => { if (this.getInput() === '') { this.refreshParsedInput(); - this.renderChatEditingSessionState(session); + this.renderChatEditingSessionState(); } })); - this.renderChatEditingSessionState(session); + this.renderChatEditingSessionState(); })); if (this._location.location === ChatAgentLocation.EditingSession) { @@ -289,7 +295,7 @@ export class ChatWidget extends Disposable implements IChatWidget { const sessionId = this._viewModel?.sessionId; if (sessionId) { if (sessionId !== currentEditSession?.chatSessionId) { - currentEditSession = await this.chatEditingService.startOrContinueEditingSession(sessionId); + currentEditSession = await chatEditingService.startOrContinueEditingSession(sessionId); } } else { if (currentEditSession) { @@ -581,8 +587,11 @@ export class ChatWidget extends Disposable implements IChatWidget { } } - private async renderChatEditingSessionState(session: IChatEditingSession | null) { - this.inputPart.renderChatEditingSessionState(session, this); + private async renderChatEditingSessionState() { + if (!this.inputPart) { + return; + } + this.inputPart.renderChatEditingSessionState(this._editingSession ?? null, this); if (this.bodyDimension) { this.layout(this.bodyDimension.height, this.bodyDimension.width); @@ -763,7 +772,8 @@ export class ChatWidget extends Disposable implements IChatWidget { renderStyle: options?.renderStyle === 'minimal' ? 'compact' : options?.renderStyle, menus: { executeToolbar: MenuId.ChatExecute, ...this.viewOptions.menus }, editorOverflowWidgetsDomNode: this.viewOptions.editorOverflowWidgetsDomNode, - enableImplicitContext: this.viewOptions.enableImplicitContext + enableImplicitContext: this.viewOptions.enableImplicitContext, + renderWorkingSet: this.viewOptions.enableWorkingSet === 'explicit' }, this.styles, () => this.collectInputState() @@ -829,9 +839,9 @@ export class ChatWidget extends Disposable implements IChatWidget { this._onDidChangeContentHeight.fire(); })); this._register(this.inputPart.attachmentModel.onDidChangeContext(() => { - if (this.chatEditingService.currentEditingSession && this.chatEditingService.currentEditingSession?.chatSessionId === this.viewModel?.sessionId) { + if (this._editingSession) { // TODO still needed? Do this inside input part and fire onDidChangeHeight? - this.renderChatEditingSessionState(this.chatEditingService.currentEditingSession); + this.renderChatEditingSessionState(); } })); this._register(this.inputEditor.onDidChangeModelContent(() => { @@ -872,8 +882,8 @@ export class ChatWidget extends Disposable implements IChatWidget { this.scrollToEnd(); } - if (this.chatEditingService.currentEditingSession && this.chatEditingService.currentEditingSession?.chatSessionId === this.viewModel?.sessionId) { - this.renderChatEditingSessionState(this.chatEditingService.currentEditingSession); + if (this._editingSession) { + this.renderChatEditingSessionState(); } })); this.viewModelDisposables.add(this.viewModel.onDidDisposeModel(() => { @@ -1018,8 +1028,8 @@ export class ChatWidget extends Disposable implements IChatWidget { let attachedContext = this.inputPart.getAttachedAndImplicitContext(this.viewModel.sessionId); let workingSet: URI[] | undefined; - if (this.location === ChatAgentLocation.EditingSession) { - const currentEditingSession = this.chatEditingService.currentEditingSessionObs.get(); + if (this.viewOptions.enableWorkingSet !== undefined) { + const currentEditingSession = this._editingSession; const unconfirmedSuggestions = new ResourceSet(); const uniqueWorkingSetEntries = new ResourceSet(); // NOTE: this is used for bookkeeping so the UI can avoid rendering references in the UI that are already shown in the working set diff --git a/src/vs/workbench/contrib/chat/browser/media/chatEditorOverlay.css b/src/vs/workbench/contrib/chat/browser/media/chatEditorOverlay.css index eb1603f43d1b4..90d4859bc6cca 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chatEditorOverlay.css +++ b/src/vs/workbench/contrib/chat/browser/media/chatEditorOverlay.css @@ -38,6 +38,8 @@ padding: 0px 5px; font-size: 12px; font-variant-numeric: tabular-nums; + overflow: hidden; + white-space: nowrap; } .chat-editor-overlay-widget.busy .chat-editor-overlay-progress { @@ -49,6 +51,33 @@ /* font-style: italic; */ } +@keyframes ellipsis { + 0% { + content: ""; + } + 25% { + content: "."; + } + 50% { + content: ".."; + } + 75% { + content: "..."; + } + 100% { + content: ""; + } +} + +.chat-editor-overlay-widget.busy .chat-editor-overlay-progress .busy-label::after { + content: ""; + display: inline-flex; + white-space: nowrap; + overflow: hidden; + width: 3ch; + animation: ellipsis steps(4, end) 1s infinite; +} + .chat-editor-overlay-widget.busy .chat-editor-overlay-toolbar { display: none; } diff --git a/src/vs/workbench/contrib/chat/common/chatEditingService.ts b/src/vs/workbench/contrib/chat/common/chatEditingService.ts index 71f168eb0973b..ee3a0a6ef0396 100644 --- a/src/vs/workbench/contrib/chat/common/chatEditingService.ts +++ b/src/vs/workbench/contrib/chat/common/chatEditingService.ts @@ -76,13 +76,13 @@ export interface WorkingSetDisplayMetadata { } export interface IChatEditingSession { + readonly isGlobalEditingSession: boolean; readonly chatSessionId: string; readonly onDidChange: Event; readonly onDidDispose: Event; readonly state: IObservable; readonly entries: IObservable; readonly workingSet: ResourceMap; - readonly isVisible: boolean; readonly isToolsAgentSession: boolean; addFileToWorkingSet(uri: URI, description?: string, kind?: WorkingSetEntryState.Transient | WorkingSetEntryState.Suggested): void; show(): Promise; diff --git a/src/vs/workbench/contrib/chat/common/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService.ts index 20089cb5315ae..e6974becb6382 100644 --- a/src/vs/workbench/contrib/chat/common/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService.ts @@ -446,7 +446,7 @@ export interface IChatService { isEnabled(location: ChatAgentLocation): boolean; hasSessions(): boolean; - startSession(location: ChatAgentLocation, token: CancellationToken): ChatModel | undefined; + startSession(location: ChatAgentLocation, token: CancellationToken): ChatModel; getSession(sessionId: string): IChatModel | undefined; getOrRestoreSession(sessionId: string): IChatModel | undefined; loadSessionFromContent(data: IExportableChatData | ISerializableChatData): IChatModel | undefined; diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatService.ts index 31fbb654b6357..841b65ccc085b 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatService.ts @@ -26,7 +26,7 @@ export class MockChatService implements IChatService { getProviderInfos(): IChatProviderInfo[] { throw new Error('Method not implemented.'); } - startSession(location: ChatAgentLocation, token: CancellationToken): ChatModel | undefined { + startSession(location: ChatAgentLocation, token: CancellationToken): ChatModel { throw new Error('Method not implemented.'); } addSession(session: IChatModel): void { diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts index ee316323294a6..b5da56628a489 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts @@ -23,7 +23,11 @@ import { ChatContextKeys } from '../../chat/common/chatContextKeys.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { InlineChatAccessibilityHelp } from './inlineChatAccessibilityHelp.js'; import { InlineChatExpandLineAction, InlineChatHintsController, HideInlineChatHintAction, ShowInlineChatHintAction } from './inlineChatCurrentLine.js'; +import { InlineChatController2, StartSessionAction2, StopSessionAction2 } from './inlineChatController2.js'; +registerEditorContribution(InlineChatController2.ID, InlineChatController2, EditorContributionInstantiation.Eager); // EAGER because of notebook dispose/create of editors +registerAction2(StartSessionAction2); +registerAction2(StopSessionAction2); // --- browser diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index 6d8f16a122ca7..9c6b69b3e4bbb 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -186,7 +186,7 @@ export class InlineChatController implements IEditorContribution { } } - const zone = _instaService.createInstance(InlineChatZoneWidget, location, this._editor); + const zone = _instaService.createInstance(InlineChatZoneWidget, location, undefined, this._editor); this._store.add(zone); this._store.add(zone.widget.chatWidget.onDidClear(async () => { const r = this.joinCurrentRun(); @@ -1139,10 +1139,6 @@ export class InlineChatController implements IEditorContribution { const uri = this._editor.getModel().uri; const chatModel = this._chatService.startSession(ChatAgentLocation.Editor, token); - if (!chatModel) { - return false; - } - const editSession = await this._chatEditingService.createAdhocEditingSession(chatModel.sessionId); // diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController2.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController2.ts new file mode 100644 index 0000000000000..b444ddebd81be --- /dev/null +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController2.ts @@ -0,0 +1,357 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun, autorunWithStore, constObservable, derived, observableFromEvent, observableSignalFromEvent, observableValue, transaction } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { assertType } from '../../../../base/common/types.js'; +import { ICodeEditor, isCodeEditor, isDiffEditor } from '../../../../editor/browser/editorBrowser.js'; +import { EditorAction2, ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; +import { observableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js'; +import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; +import { EmbeddedCodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/embeddedCodeEditorWidget.js'; +import { EmbeddedDiffEditorWidget } from '../../../../editor/browser/widget/diffEditor/embeddedDiffEditorWidget.js'; +import { IEditorContribution } from '../../../../editor/common/editorCommon.js'; +import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { IAction2Options, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { ctxIsGlobalEditingSession } from '../../chat/browser/chatEditorController.js'; +import { ChatEditorOverlayController } from '../../chat/browser/chatEditorOverlay.js'; +import { IChatWidgetLocationOptions } from '../../chat/browser/chatWidget.js'; +import { ChatAgentLocation } from '../../chat/common/chatAgents.js'; +import { WorkingSetEntryState } from '../../chat/common/chatEditingService.js'; +import { INotebookEditorService } from '../../notebook/browser/services/notebookEditorService.js'; +import { CTX_INLINE_CHAT_HAS_AGENT2, CTX_INLINE_CHAT_POSSIBLE, CTX_INLINE_CHAT_VISIBLE } from '../common/inlineChat.js'; +import { IInlineChatSession2, IInlineChatSessionService } from './inlineChatSessionService.js'; +import { InlineChatZoneWidget } from './inlineChatZoneWidget.js'; + + +export const CTX_HAS_SESSION = new RawContextKey('inlineChatHasSession', undefined, localize('chat.hasInlineChatSession', "The current editor has an active inline chat session")); + + +export class InlineChatController2 implements IEditorContribution { + + static readonly ID = 'editor.contrib.inlineChatController2'; + + static get(editor: ICodeEditor): InlineChatController2 | undefined { + return editor.getContribution(InlineChatController2.ID) ?? undefined; + } + + private readonly _store = new DisposableStore(); + + + private readonly _showWidgetOverrideObs = observableValue(this, false); + + constructor( + private readonly _editor: ICodeEditor, + @IInstantiationService private readonly _instaService: IInstantiationService, + @INotebookEditorService private readonly _notebookEditorService: INotebookEditorService, + @IInlineChatSessionService inlineChatSessions: IInlineChatSessionService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + + const ctxHasSession = CTX_HAS_SESSION.bindTo(contextKeyService); + const ctxInlineChatVisible = CTX_INLINE_CHAT_VISIBLE.bindTo(contextKeyService); + + const location: IChatWidgetLocationOptions = { + location: ChatAgentLocation.Editor, + resolveData: () => { + assertType(this._editor.hasModel()); + + return { + type: ChatAgentLocation.Editor, + selection: this._editor.getSelection(), + document: this._editor.getModel().uri, + wholeRange: this._editor.getSelection(), + }; + } + }; + + // inline chat in notebooks + // check if this editor is part of a notebook editor + // and iff so, use the notebook location but keep the resolveData + // talk about editor data + for (const notebookEditor of this._notebookEditorService.listNotebookEditors()) { + for (const [, codeEditor] of notebookEditor.codeEditors) { + if (codeEditor === this._editor) { + location.location = ChatAgentLocation.Notebook; + break; + } + } + } + + const zone = this._instaService.createInstance(InlineChatZoneWidget, + location, + { + enableWorkingSet: 'implicit', + // filter: item => isRequestVM(item), + rendererOptions: { + renderCodeBlockPills: true, + renderTextEditsAsSummary: uri => isEqual(uri, _editor.getModel()?.uri) + } + }, + this._editor + ); + + const overlay = ChatEditorOverlayController.get(_editor)!; + + const editorObs = observableCodeEditor(_editor); + + const sessionsSignal = observableSignalFromEvent(this, inlineChatSessions.onDidChangeSessions); + + const sessionObs = derived(r => { + sessionsSignal.read(r); + const model = editorObs.model.read(r); + const value = model && inlineChatSessions.getSession2(_editor, model.uri); + return value ?? undefined; + }); + + + this._store.add(autorun(r => { + const session = sessionObs.read(r); + ctxHasSession.set(Boolean(session)); + })); + + const visibleSessionObs = observableValue(this, undefined); + + this._store.add(autorunWithStore((r, store) => { + + const session = sessionObs.read(r); + + if (!session) { + visibleSessionObs.set(undefined, undefined); + return; + } + + const { chatModel } = session; + const showShowUntil = this._showWidgetOverrideObs.read(r); + const hasNoRequests = chatModel.getRequests().length === 0; + + store.add(chatModel.onDidChange(e => { + if (e.kind === 'addRequest') { + transaction(tx => { + this._showWidgetOverrideObs.set(false, tx); + visibleSessionObs.set(undefined, tx); + }); + } + })); + + if (showShowUntil || hasNoRequests) { + visibleSessionObs.set(session, undefined); + } else { + visibleSessionObs.set(undefined, undefined); + } + })); + + this._store.add(autorun(r => { + + const session = visibleSessionObs.read(r); + + if (!session) { + zone.hide(); + _editor.focus(); + ctxInlineChatVisible.reset(); + } else { + ctxInlineChatVisible.set(true); + zone.widget.setChatModel(session.chatModel); + if (!zone.position) { + zone.show(session.initialPosition); + } else { + zone.reveal(zone.position); + } + zone.widget.focus(); + session.editingSession.getEntry(session.uri)?.autoAcceptController.get()?.cancel(); + } + })); + + this._store.add(autorun(r => { + + const session = sessionObs.read(r); + const model = editorObs.model.read(r); + if (!session || !model) { + overlay.hide(); + return; + } + + const lastResponse = observableFromEvent(this, session.chatModel.onDidChange, () => session.chatModel.getRequests().at(-1)?.response); + const response = lastResponse.read(r); + + const isInProgress = response + ? observableFromEvent(this, response.onDidChange, () => !response.isComplete) + : constObservable(false); + + if (isInProgress.read(r)) { + overlay.showRequest(session.editingSession); + } else if (session.editingSession.getEntry(session.uri)?.state.get() !== WorkingSetEntryState.Modified) { + overlay.hide(); + } + })); + } + + dispose(): void { + this._store.dispose(); + } + + toggleWidgetUntilNextRequest() { + const value = this._showWidgetOverrideObs.get(); + this._showWidgetOverrideObs.set(!value, undefined); + } +} + +export class StartSessionAction2 extends EditorAction2 { + + constructor() { + super({ + id: 'inlineChat2.start', + title: localize2('start', "Inline Chat"), + precondition: ContextKeyExpr.and( + CTX_INLINE_CHAT_HAS_AGENT2, + CTX_INLINE_CHAT_POSSIBLE, + CTX_HAS_SESSION.negate(), + EditorContextKeys.writable, + EditorContextKeys.editorSimpleInput.negate() + ), + f1: true, + category: AbstractInlineChatAction.category, + keybinding: { + when: EditorContextKeys.focus, + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyCode.KeyI + }, + menu: { + id: MenuId.ChatCommandCenter, + group: 'd_inlineChat', + order: 10, + } + }); + } + + override async runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, ...args: any[]) { + const inlineChatSessions = accessor.get(IInlineChatSessionService); + if (!editor.hasModel()) { + return; + } + const textModel = editor.getModel(); + await inlineChatSessions.createSession2(editor, textModel.uri, CancellationToken.None); + } +} + +abstract class AbstractInlineChatAction extends EditorAction2 { + + static readonly category = localize2('cat', "Inline Chat"); + + constructor(desc: IAction2Options) { + super({ + ...desc, + category: AbstractInlineChatAction.category, + precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_HAS_AGENT2, desc.precondition) + }); + } + + override runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, ..._args: any[]) { + const editorService = accessor.get(IEditorService); + const logService = accessor.get(ILogService); + + let ctrl = InlineChatController2.get(editor); + if (!ctrl) { + const { activeTextEditorControl } = editorService; + if (isCodeEditor(activeTextEditorControl)) { + editor = activeTextEditorControl; + } else if (isDiffEditor(activeTextEditorControl)) { + editor = activeTextEditorControl.getModifiedEditor(); + } + ctrl = InlineChatController2.get(editor); + } + + if (!ctrl) { + logService.warn('[IE] NO controller found for action', this.desc.id, editor.getModel()?.uri); + return; + } + + if (editor instanceof EmbeddedCodeEditorWidget) { + editor = editor.getParentEditor(); + } + if (!ctrl) { + for (const diffEditor of accessor.get(ICodeEditorService).listDiffEditors()) { + if (diffEditor.getOriginalEditor() === editor || diffEditor.getModifiedEditor() === editor) { + if (diffEditor instanceof EmbeddedDiffEditorWidget) { + this.runEditorCommand(accessor, diffEditor.getParentEditor(), ..._args); + } + } + } + return; + } + this.runInlineChatCommand(accessor, ctrl, editor, ..._args); + } + + abstract runInlineChatCommand(accessor: ServicesAccessor, ctrl: InlineChatController2, editor: ICodeEditor, ...args: any[]): void; +} + +export class StopSessionAction2 extends AbstractInlineChatAction { + constructor() { + super({ + id: 'inlineChat2.stop', + title: localize2('stop', "Stop"), + f1: true, + precondition: ContextKeyExpr.and( + CTX_HAS_SESSION, CTX_INLINE_CHAT_VISIBLE + ), + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyCode.Escape + }, + }); + } + + runInlineChatCommand(accessor: ServicesAccessor, _ctrl: InlineChatController2, editor: ICodeEditor, ...args: any[]): void { + const inlineChatSessions = accessor.get(IInlineChatSessionService); + if (!editor.hasModel()) { + return; + } + const textModel = editor.getModel(); + inlineChatSessions.getSession2(editor, textModel.uri)?.dispose(); + } +} + +class RevealWidget extends AbstractInlineChatAction { + constructor() { + super({ + id: 'inlineChat2.reveal', + title: localize2('reveal', "Toggle Inline Chat"), + f1: true, + icon: Codicon.copilot, + precondition: ContextKeyExpr.and( + CTX_HAS_SESSION, + ), + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyCode.KeyI + }, + menu: { + id: MenuId.ChatEditingEditorContent, + when: ContextKeyExpr.and( + CTX_HAS_SESSION, + ctxIsGlobalEditingSession.negate(), + ), + group: 'navigate', + order: 4, + } + }); + } + + runInlineChatCommand(accessor: ServicesAccessor, ctrl: InlineChatController2, editor: ICodeEditor, ...args: any[]): void { + ctrl.toggleWidgetUntilNextRequest(); + } +} + +registerAction2(RevealWidget); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts index 6c4541c633ed9..93be522ac5be6 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts @@ -7,9 +7,12 @@ import { Event } from '../../../../base/common/event.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { IActiveCodeEditor, ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; +import { Position } from '../../../../editor/common/core/position.js'; import { IRange } from '../../../../editor/common/core/range.js'; import { IValidEditOperation } from '../../../../editor/common/model.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IChatEditingSession } from '../../chat/common/chatEditingService.js'; +import { IChatModel } from '../../chat/common/chatModel.js'; import { Session, StashedSession } from './inlineChatSession.js'; export interface ISessionKeyComputer { @@ -27,6 +30,14 @@ export interface IInlineChatSessionEndEvent extends IInlineChatSessionEvent { readonly endedByExternalCause: boolean; } +export interface IInlineChatSession2 { + readonly initialPosition: Position; + readonly uri: URI; + readonly chatModel: IChatModel; + readonly editingSession: IChatEditingSession; + dispose(): void; +} + export interface IInlineChatSessionService { _serviceBrand: undefined; @@ -50,4 +61,9 @@ export interface IInlineChatSessionService { registerSessionKeyComputer(scheme: string, value: ISessionKeyComputer): IDisposable; dispose(): void; + + + createSession2(editor: ICodeEditor, uri: URI, token: CancellationToken): Promise; + getSession2(editor: ICodeEditor, uri: URI): IInlineChatSession2 | undefined; + onDidChangeSessions: Event; } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts index 284212f668f2e..767d074b27e58 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts @@ -22,14 +22,17 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { DEFAULT_EDITOR_ASSOCIATION } from '../../../common/editor.js'; import { ChatAgentLocation, IChatAgentService } from '../../chat/common/chatAgents.js'; import { IChatService } from '../../chat/common/chatService.js'; -import { CTX_INLINE_CHAT_HAS_AGENT, CTX_INLINE_CHAT_POSSIBLE } from '../common/inlineChat.js'; +import { CTX_INLINE_CHAT_HAS_AGENT, CTX_INLINE_CHAT_HAS_AGENT2, CTX_INLINE_CHAT_POSSIBLE } from '../common/inlineChat.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { UntitledTextEditorInput } from '../../../services/untitled/common/untitledTextEditorInput.js'; import { HunkData, Session, SessionWholeRange, StashedSession, TelemetryData, TelemetryDataClassification } from './inlineChatSession.js'; -import { IInlineChatSessionEndEvent, IInlineChatSessionEvent, IInlineChatSessionService, ISessionKeyComputer } from './inlineChatSessionService.js'; +import { IInlineChatSession2, IInlineChatSessionEndEvent, IInlineChatSessionEvent, IInlineChatSessionService, ISessionKeyComputer } from './inlineChatSessionService.js'; import { isEqual } from '../../../../base/common/resources.js'; import { ILanguageService } from '../../../../editor/common/languages/language.js'; import { ITextFileService } from '../../../services/textfile/common/textfiles.js'; +import { IChatEditingService, WorkingSetEntryState } from '../../chat/common/chatEditingService.js'; +import { assertType } from '../../../../base/common/types.js'; +import { autorun } from '../../../../base/common/observable.js'; type SessionData = { @@ -79,7 +82,8 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { @ITextFileService private readonly _textFileService: ITextFileService, @ILanguageService private readonly _languageService: ILanguageService, @IChatService private readonly _chatService: IChatService, - @IChatAgentService private readonly _chatAgentService: IChatAgentService + @IChatAgentService private readonly _chatAgentService: IChatAgentService, + @IChatEditingService private readonly _chatEditingService: IChatEditingService, ) { } dispose() { @@ -311,6 +315,65 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { this._keyComputers.set(scheme, value); return toDisposable(() => this._keyComputers.delete(scheme)); } + + // ---- NEW + + private readonly _sessions2 = new Map(); + + private readonly _onDidChangeSessions = this._store.add(new Emitter()); + readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; + + + async createSession2(editor: ICodeEditor, uri: URI, token: CancellationToken): Promise { + + assertType(editor.hasModel()); + + const key = this._key(editor, uri); + if (this._sessions2.has(key)) { + throw new Error('Session already exists'); + } + + this._onWillStartSession.fire(editor as IActiveCodeEditor); + + const chatModel = this._chatService.startSession(ChatAgentLocation.EditingSession, token); + + const editingSession = await this._chatEditingService.createAdhocEditingSession(chatModel.sessionId); + editingSession.addFileToWorkingSet(uri); + + const store = new DisposableStore(); + store.add(toDisposable(() => { + editingSession.reject(); + this._sessions2.delete(key); + this._onDidChangeSessions.fire(this); + })); + store.add(editingSession); + store.add(chatModel); + + store.add(autorun(r => { + const entry = editingSession.readEntry(uri, r); + const state = entry?.state.read(r); + if (state === WorkingSetEntryState.Accepted || state === WorkingSetEntryState.Rejected) { + // self terminate + store.dispose(); + } + })); + + const result: IInlineChatSession2 = { + uri, + initialPosition: editor.getPosition().delta(-1), + chatModel, + editingSession, + dispose: store.dispose.bind(store) + }; + this._sessions2.set(key, result); + this._onDidChangeSessions.fire(this); + return result; + } + + getSession2(editor: ICodeEditor, uri: URI): IInlineChatSession2 | undefined { + const key = this._key(editor, uri); + return this._sessions2.get(key); + } } export class InlineChatEnabler { @@ -318,6 +381,7 @@ export class InlineChatEnabler { static Id = 'inlineChat.enabler'; private readonly _ctxHasProvider: IContextKey; + private readonly _ctxHasProvider2: IContextKey; private readonly _ctxPossible: IContextKey; private readonly _store = new DisposableStore(); @@ -328,11 +392,21 @@ export class InlineChatEnabler { @IEditorService editorService: IEditorService, ) { this._ctxHasProvider = CTX_INLINE_CHAT_HAS_AGENT.bindTo(contextKeyService); + this._ctxHasProvider2 = CTX_INLINE_CHAT_HAS_AGENT2.bindTo(contextKeyService); this._ctxPossible = CTX_INLINE_CHAT_POSSIBLE.bindTo(contextKeyService); const updateAgent = () => { - const hasEditorAgent = Boolean(chatAgentService.getDefaultAgent(ChatAgentLocation.Editor)); - this._ctxHasProvider.set(hasEditorAgent); + const agent = chatAgentService.getDefaultAgent(ChatAgentLocation.Editor); + if (agent?.locations.length === 1) { + this._ctxHasProvider.set(true); + this._ctxHasProvider2.reset(); + } else if (agent?.locations.includes(ChatAgentLocation.EditingSession)) { + this._ctxHasProvider.reset(); + this._ctxHasProvider2.set(true); + } else { + this._ctxHasProvider.reset(); + this._ctxHasProvider2.reset(); + } }; this._store.add(chatAgentService.onDidChangeAgents(updateAgent)); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts index 9d61a980d52a1..138b51910c448 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts @@ -19,6 +19,7 @@ import { localize } from '../../../../nls.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IChatWidgetViewOptions } from '../../chat/browser/chat.js'; import { IChatWidgetLocationOptions } from '../../chat/browser/chatWidget.js'; import { isResponseVM } from '../../chat/common/chatViewModel.js'; import { ACTION_REGENERATE_RESPONSE, ACTION_REPORT_ISSUE, ACTION_TOGGLE_DIFF, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, MENU_INLINE_CHAT_WIDGET_SECONDARY, MENU_INLINE_CHAT_WIDGET_STATUS } from '../common/inlineChat.js'; @@ -47,6 +48,7 @@ export class InlineChatZoneWidget extends ZoneWidget { constructor( location: IChatWidgetLocationOptions, + options: IChatWidgetViewOptions | undefined, editor: ICodeEditor, @IInstantiationService private readonly _instaService: IInstantiationService, @ILogService private _logService: ILogService, @@ -80,13 +82,15 @@ export class InlineChatZoneWidget extends ZoneWidget { menus: { telemetrySource: 'interactiveEditorWidget-toolbar', }, + ...options, rendererOptions: { renderTextEditsAsSummary: (uri) => { // render when dealing with the current file in the editor return isEqual(uri, editor.getModel()?.uri); }, renderDetectedCommandsWithRequest: true, - } + ...options?.rendererOptions + }, } }); this._disposables.add(this.widget); diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index 2270a8f9ef241..21dcf39b8d668 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -74,6 +74,7 @@ export const enum InlineChatResponseType { export const CTX_INLINE_CHAT_POSSIBLE = new RawContextKey('inlineChatPossible', false, localize('inlineChatHasPossible', "Whether a provider for inline chat exists and whether an editor for inline chat is open")); export const CTX_INLINE_CHAT_HAS_AGENT = new RawContextKey('inlineChatHasProvider', false, localize('inlineChatHasProvider', "Whether a provider for interactive editors exists")); +export const CTX_INLINE_CHAT_HAS_AGENT2 = new RawContextKey('inlineChatHasEditsAgent', false, localize('inlineChatHasEditsAgent', "Whether an agent for inliine for interactive editors exists")); export const CTX_INLINE_CHAT_VISIBLE = new RawContextKey('inlineChatVisible', false, localize('inlineChatVisible', "Whether the interactive editor input is visible")); export const CTX_INLINE_CHAT_FOCUSED = new RawContextKey('inlineChatFocused', false, localize('inlineChatFocused', "Whether the interactive editor input is focused")); export const CTX_INLINE_CHAT_EDITING = new RawContextKey('inlineChatEditing', true, localize('inlineChatEditing', "Whether the user is currently editing or generating code in the inline chat")); diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts index 06791cc943237..d8d8ce7467cec 100644 --- a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts @@ -68,7 +68,7 @@ import { IChatEditingService, IChatEditingSession } from '../../../chat/common/c import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; import { TextModelResolverService } from '../../../../services/textmodelResolver/common/textModelResolverService.js'; import { ChatInputBoxContentProvider } from '../../../chat/browser/chatEdinputInputContentProvider.js'; -import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { ILanguageModelToolsService } from '../../../chat/common/languageModelToolsService.js'; import { MockLanguageModelToolsService } from '../../../chat/test/common/mockLanguageModelToolsService.js'; @@ -164,6 +164,7 @@ suite('InlineChatController', function () { [ICommandService, new SyncDescriptor(TestCommandService)], [IChatEditingService, new class extends mock() { override currentEditingSessionObs: IObservable = observableValue(this, null); + override editingSessionsObs: IObservable = constObservable([]); }], [IEditorProgressService, new class extends mock() { override show(total: unknown, delay?: unknown): IProgressRunner { diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSession.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSession.test.ts index 80b877eb95a8c..cfa95c164672c 100644 --- a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSession.test.ts +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatSession.test.ts @@ -60,6 +60,8 @@ import { ILanguageModelToolsService } from '../../../chat/common/languageModelTo import { MockLanguageModelToolsService } from '../../../chat/test/common/mockLanguageModelToolsService.js'; import { IChatRequestModel } from '../../../chat/common/chatModel.js'; import { assertSnapshot } from '../../../../../base/test/common/snapshot.js'; +import { IObservable, observableValue, constObservable } from '../../../../../base/common/observable.js'; +import { IChatEditingService, IChatEditingSession } from '../../../chat/common/chatEditingService.js'; suite('InlineChatSession', function () { @@ -103,6 +105,10 @@ suite('InlineChatSession', function () { }; } }], + [IChatEditingService, new class extends mock() { + override currentEditingSessionObs: IObservable = observableValue(this, null); + override editingSessionsObs: IObservable = constObservable([]); + }], [IChatAccessibilityService, new class extends mock() { override acceptResponse(response: IChatResponseViewModel | undefined, requestId: number): void { } override acceptRequest(): number { return -1; } diff --git a/test/unit/electron/renderer.js b/test/unit/electron/renderer.js index b93d91a78e943..033667df0aa2c 100644 --- a/test/unit/electron/renderer.js +++ b/test/unit/electron/renderer.js @@ -294,10 +294,12 @@ async function loadTests(opts) { // should not have unexpected errors const errors = _unexpectedErrors.concat(_loaderErrors); if (errors.length) { + const msg = []; for (const error of errors) { console.error(`Error: Test run should not have unexpected errors:\n${error}`); + msg.push(String(error)) } - assert.ok(false, 'Error: Test run should not have unexpected errors.'); + assert.ok(false, `Error: Test run should not have unexpected errors:\n${msg.join('\n')}`); } });