diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts index 33c572bb0d4..dce09a65403 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts @@ -108,7 +108,6 @@ registerAction2(InlineChatActions.ToggleDiffForChange); registerAction2(InlineChatActions.AcceptChanges); registerAction2(InlineChatActions.ReportIssueAction); -registerAction2(InlineChatActions.CopyRecordings); const workbenchContributionsRegistry = Registry.as(WorkbenchExtensions.Workbench); workbenchContributionsRegistry.registerWorkbenchContribution(InlineChatNotebookContribution, LifecyclePhase.Restored); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index cb1d4633c9d..1dc46e8df41 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -14,15 +14,11 @@ import { InlineChatController, InlineChatRunOptions } from 'vs/workbench/contrib import { ACTION_ACCEPT_CHANGES, CTX_INLINE_CHAT_HAS_AGENT, CTX_INLINE_CHAT_HAS_STASHED_SESSION, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_INNER_CURSOR_FIRST, CTX_INLINE_CHAT_INNER_CURSOR_LAST, CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, CTX_INLINE_CHAT_USER_DID_EDIT, CTX_INLINE_CHAT_DOCUMENT_CHANGED, CTX_INLINE_CHAT_EDIT_MODE, EditMode, MENU_INLINE_CHAT_WIDGET_STATUS, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, CTX_INLINE_CHAT_RESPONSE_TYPE, InlineChatResponseType, ACTION_REGENERATE_RESPONSE, MENU_INLINE_CHAT_CONTENT_STATUS, ACTION_VIEW_IN_CHAT, ACTION_TOGGLE_DIFF, CTX_INLINE_CHAT_CHANGE_HAS_DIFF, CTX_INLINE_CHAT_CHANGE_SHOWS_DIFF, MENU_INLINE_CHAT_ZONE, CTX_INLINE_CHAT_SUPPORT_REPORT_ISSUE, ACTION_REPORT_ISSUE } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; import { localize, localize2 } from 'vs/nls'; import { Action2, IAction2Options } from 'vs/platform/actions/common/actions'; -import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; -import { IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; -import { fromNow } from 'vs/base/common/date'; -import { IInlineChatSessionService, Recording } from './inlineChatSessionService'; import { CONTEXT_ACCESSIBILITY_MODE_ENABLED } from 'vs/platform/accessibility/common/accessibility'; import { CommandsRegistry } from 'vs/platform/commands/common/commands'; import { registerIcon } from 'vs/platform/theme/common/iconRegistry'; @@ -456,42 +452,6 @@ export class MoveToPreviousHunk extends AbstractInlineChatAction { } } -export class CopyRecordings extends AbstractInlineChatAction { - - constructor() { - super({ - id: 'inlineChat.copyRecordings', - f1: true, - title: localize2('copyRecordings', "(Developer) Write Exchange to Clipboard") - }); - } - - override async runInlineChatCommand(accessor: ServicesAccessor): Promise { - - const clipboardService = accessor.get(IClipboardService); - const quickPickService = accessor.get(IQuickInputService); - const ieSessionService = accessor.get(IInlineChatSessionService); - - const recordings = ieSessionService.recordings().filter(r => r.exchanges.length > 0); - if (recordings.length === 0) { - return; - } - - const picks: (IQuickPickItem & { rec: Recording })[] = recordings.map(rec => { - return { - rec, - label: localize('label', "'{0}' and {1} follow ups ({2})", rec.exchanges[0].prompt, rec.exchanges.length - 1, fromNow(rec.when, true)), - tooltip: rec.exchanges.map(ex => ex.prompt).join('\n'), - }; - }); - - const pick = await quickPickService.pick(picks, { canPickMany: false }); - if (pick) { - clipboardService.writeText(JSON.stringify(pick.rec, undefined, 2)); - } - } -} - export class ViewInChatAction extends AbstractInlineChatAction { constructor() { super({ diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index 19f8fe9d68b..46fb9867e99 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -39,7 +39,7 @@ import { ChatAgentLocation } from 'vs/workbench/contrib/chat/common/chatAgents'; import { ChatModel, ChatRequestRemovalReason, IChatRequestModel, IChatTextEditGroup, IChatTextEditGroupState, IResponse } from 'vs/workbench/contrib/chat/common/chatModel'; import { IChatService } from 'vs/workbench/contrib/chat/common/chatService'; import { InlineChatContentWidget } from 'vs/workbench/contrib/inlineChat/browser/inlineChatContentWidget'; -import { EmptyResponse, ErrorResponse, ReplyResponse, Session, StashedSession } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; +import { Session, StashedSession } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSession'; import { InlineChatError } from 'vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl'; import { EditModeStrategy, IEditObserver, LiveStrategy, PreviewStrategy, ProgressingEditsOptions } from 'vs/workbench/contrib/inlineChat/browser/inlineChatStrategies'; import { CTX_INLINE_CHAT_EDITING, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, CTX_INLINE_CHAT_RESPONSE_TYPE, CTX_INLINE_CHAT_SUPPORT_REPORT_ISSUE, CTX_INLINE_CHAT_USER_DID_EDIT, CTX_INLINE_CHAT_VISIBLE, EditMode, INLINE_CHAT_ID, InlineChatConfigKeys, InlineChatResponseType } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; @@ -55,7 +55,6 @@ export const enum State { INIT_UI = 'INIT_UI', WAIT_FOR_INPUT = 'WAIT_FOR_INPUT', SHOW_REQUEST = 'SHOW_REQUEST', - SHOW_RESPONSE = 'SHOW_RESPONSE', PAUSE = 'PAUSE', CANCEL = 'CANCEL', ACCEPT = 'DONE', @@ -382,7 +381,7 @@ export class InlineChatController implements IEditorContribution { return State.INIT_UI; } - private async [State.INIT_UI](options: InlineChatRunOptions): Promise { + private async [State.INIT_UI](options: InlineChatRunOptions): Promise { assertType(this._session); assertType(this._strategy); @@ -462,16 +461,39 @@ export class InlineChatController implements IEditorContribution { } })); + // apply edits from completed requests that haven't been applied yet + const editState = this._createChatTextEditGroupState(); + let didEdit = false; + for (const request of this._session.chatModel.getRequests()) { + if (!request.response) { + // done when seeing the first request that is still pending (no response). + break; + } + for (const part of request.response.response.value) { + if (part.kind !== 'textEditGroup' || !isEqual(part.uri, this._session.textModelN.uri)) { + continue; + } + if (part.state?.applied) { + continue; + } + for (const edit of part.edits) { + this._makeChanges(edit, undefined, !didEdit); + didEdit = true; + } + part.state ??= editState; + } + } + if (didEdit) { + const diff = await this._editorWorkerService.computeDiff(this._session.textModel0.uri, this._session.textModelN.uri, { computeMoves: false, maxComputationTimeMs: Number.MAX_SAFE_INTEGER, ignoreTrimWhitespace: false }, 'advanced'); + this._session.wholeRange.fixup(diff?.changes ?? []); + await this._session.hunkData.recompute(editState, diff); + } + options.position = await this._strategy.renderChanges(); - if (!this._session.chatModel.hasRequests) { - return State.WAIT_FOR_INPUT; - } else if (options.isUnstashed) { - delete options.isUnstashed; - return State.SHOW_RESPONSE; - } else if (this._session.chatModel.requestInProgress) { + if (this._session.chatModel.requestInProgress) { return State.SHOW_REQUEST; } else { - return State.SHOW_RESPONSE; + return State.WAIT_FOR_INPUT; } } @@ -540,8 +562,9 @@ export class InlineChatController implements IEditorContribution { } - private async [State.SHOW_REQUEST](options: InlineChatRunOptions): Promise { + private async [State.SHOW_REQUEST](options: InlineChatRunOptions): Promise { assertType(this._session); + assertType(this._strategy); assertType(this._session.chatModel.requestInProgress); this._ctxRequestInProgress.set(true); @@ -569,7 +592,7 @@ export class InlineChatController implements IEditorContribution { const progressiveEditsClock = StopWatch.create(); const progressiveEditsQueue = new Queue(); - let next: State.SHOW_RESPONSE | State.SHOW_REQUEST | State.CANCEL | State.PAUSE | State.ACCEPT = State.SHOW_RESPONSE; + let next: State.WAIT_FOR_INPUT | State.SHOW_REQUEST | State.CANCEL | State.PAUSE | State.ACCEPT = State.WAIT_FOR_INPUT; store.add(Event.once(this._messages.event)(message => { this._log('state=_makeRequest) message received', message); @@ -646,11 +669,7 @@ export class InlineChatController implements IEditorContribution { let lastLength = 0; let isFirstChange = true; - const sha1 = new DefaultModelSHA1Computer(); - const textModel0Sha1 = sha1.canComputeSHA1(this._session.textModel0) - ? sha1.computeSHA1(this._session.textModel0) - : generateUuid(); - const editState: IChatTextEditGroupState = { sha1: textModel0Sha1, applied: 0 }; + const editState = this._createChatTextEditGroupState(); let localEditGroup: IChatTextEditGroup | undefined; // apply edits @@ -718,7 +737,6 @@ export class InlineChatController implements IEditorContribution { await responsePromise.p; await progressiveEditsQueue.whenIdle(); - if (response.isCanceled) { await this._session.undoChangesUntil(response.requestId); } @@ -731,33 +749,22 @@ export class InlineChatController implements IEditorContribution { this._ctxRequestInProgress.set(false); - return next; - } - - private async[State.SHOW_RESPONSE](options: InlineChatRunOptions): Promise { - assertType(this._session); - assertType(this._strategy); - assertType(this._session.lastExchange, `State ${State.SHOW_RESPONSE} should only be reached if there has been an exchange`); - assertType(this._session.lastExchange.response, `State ${State.SHOW_RESPONSE} should only be reached if last exchange had a response`); - - const response = this._session.lastExchange.response; let newPosition: Position | undefined; - if (response instanceof EmptyResponse) { - // show status message + if (response.response.value.length === 0) { + // empty -> show message const status = localize('empty', "No results, please refine your input and try again"); this._ui.value.zone.widget.updateStatus(status, { classes: ['warn'] }); - return State.WAIT_FOR_INPUT; - } else if (response instanceof ErrorResponse) { - // show error - if (!response.isCancellation) { - this._ui.value.zone.widget.updateStatus(response.message, { classes: ['error'] }); - this._strategy?.cancel(); + } else if (response.result?.errorDetails) { + // error -> show error + if (!response.isCanceled) { + this._ui.value.zone.widget.updateStatus(response.result.errorDetails.message, { classes: ['error'] }); } + this._strategy?.cancel(); - } else if (response instanceof ReplyResponse) { + } else { // real response -> complex... this._ui.value.zone.widget.updateStatus(''); @@ -777,7 +784,7 @@ export class InlineChatController implements IEditorContribution { } this._showWidget(options, false, newPosition); - return State.WAIT_FOR_INPUT; + return next; } private async[State.PAUSE]() { @@ -926,6 +933,20 @@ export class InlineChatController implements IEditorContribution { this._ctxResponseType.set(responseType); } + private _createChatTextEditGroupState(): IChatTextEditGroupState { + assertType(this._session); + + const sha1 = new DefaultModelSHA1Computer(); + const textModel0Sha1 = sha1.canComputeSHA1(this._session.textModel0) + ? sha1.computeSHA1(this._session.textModel0) + : generateUuid(); + + return { + sha1: textModel0Sha1, + applied: 0 + }; + } + private async _makeChanges(edits: TextEdit[], opts: ProgressingEditsOptions | undefined, undoStopBefore: boolean) { assertType(this._session); assertType(this._strategy); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSession.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSession.ts index d120ffeb811..8a6a64c221d 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSession.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSession.ts @@ -5,22 +5,13 @@ import { URI } from 'vs/base/common/uri'; import { Emitter, Event } from 'vs/base/common/event'; -import { TextEdit } from 'vs/editor/common/languages'; import { IIdentifiedSingleEditOperation, IModelDecorationOptions, IModelDeltaDecoration, ITextModel, IValidEditOperation, TrackedRangeStickiness } from 'vs/editor/common/model'; import { EditMode, CTX_INLINE_CHAT_HAS_STASHED_SESSION } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; import { IRange, Range } from 'vs/editor/common/core/range'; import { ModelDecorationOptions } from 'vs/editor/common/model/textModel'; -import { toErrorMessage } from 'vs/base/common/errorMessage'; -import { isCancellationError } from 'vs/base/common/errors'; import { EditOperation, ISingleEditOperation } from 'vs/editor/common/core/editOperation'; import { DetailedLineRangeMapping, LineRangeMapping, RangeMapping } from 'vs/editor/common/diff/rangeMapping'; -import { IUntitledTextEditorModel } from 'vs/workbench/services/untitled/common/untitledTextEditorModel'; -import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; -import { ILanguageService } from 'vs/editor/common/languages/language'; -import { ResourceMap } from 'vs/base/common/map'; -import { Schemas } from 'vs/base/common/network'; -import { isEqual } from 'vs/base/common/resources'; -import { IInlineChatSessionService, Recording } from './inlineChatSessionService'; +import { IInlineChatSessionService } from './inlineChatSessionService'; import { LineRange } from 'vs/editor/common/core/lineRange'; import { IEditorWorkerService } from 'vs/editor/common/services/editorWorker'; import { coalesceInPlace } from 'vs/base/common/arrays'; @@ -30,7 +21,7 @@ import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { ILogService } from 'vs/platform/log/common/log'; -import { ChatModel, IChatRequestModel, IChatResponseModel, IChatTextEditGroupState } from 'vs/workbench/contrib/chat/common/chatModel'; +import { ChatModel, IChatRequestModel, IChatTextEditGroupState } from 'vs/workbench/contrib/chat/common/chatModel'; import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; import { IChatAgent } from 'vs/workbench/contrib/chat/common/chatAgents'; import { IDocumentDiff } from 'vs/editor/common/diff/documentDiffProvider'; @@ -129,11 +120,10 @@ export class SessionWholeRange { export class Session { private _isUnstashed: boolean = false; - private readonly _exchanges: SessionExchange[]; private readonly _startTime = new Date(); private readonly _teldata: TelemetryData; - readonly textModelNAltVersion: number; + private readonly _versionByRequest = new Map(); constructor( readonly editMode: EditMode, @@ -153,9 +143,9 @@ export class Session { readonly wholeRange: SessionWholeRange, readonly hunkData: HunkData, readonly chatModel: ChatModel, - exchanges?: SessionExchange[], + versionsByRequest?: [string, number][], // DEBT? this is needed when a chat model is "reused" for a new chat session ) { - this.textModelNAltVersion = textModelN.getAlternativeVersionId(); + this._teldata = { extension: ExtensionIdentifier.toKey(agent.extensionId), startTime: this._startTime.toISOString(), @@ -170,7 +160,9 @@ export class Session { discardedHunks: 0, responseTypes: '' }; - this._exchanges = exchanges ?? []; + if (versionsByRequest) { + this._versionByRequest = new Map(versionsByRequest); + } } get isUnstashed(): boolean { @@ -182,40 +174,29 @@ export class Session { this._isUnstashed = true; } - addExchange(exchange: SessionExchange): void { - this._isUnstashed = false; - const newLen = this._exchanges.push(exchange); - this._teldata.rounds += `${newLen}|`; - // this._teldata.responseTypes += `${exchange.response instanceof ReplyResponse ? exchange.response.responseType : InlineChatResponseTypes.Empty}|`; + markModelVersion(request: IChatRequestModel) { + this._versionByRequest.set(request.id, this.textModelN.getAlternativeVersionId()); } - get exchanges(): SessionExchange[] { - return this._exchanges; - } - - get lastExchange(): SessionExchange | undefined { - return this._exchanges[this._exchanges.length - 1]; + get versionsByRequest() { + return Array.from(this._versionByRequest); } async undoChangesUntil(requestId: string): Promise { - const idx = this._exchanges.findIndex(candidate => candidate.prompt.request.id === requestId); - if (idx < 0) { + + const targetAltVersion = this._versionByRequest.get(requestId); + if (targetAltVersion === undefined) { return false; } // undo till this point this.hunkData.ignoreTextModelNChanges = true; try { - const targetAltVersion = this._exchanges[idx].prompt.modelAltVersionId; while (targetAltVersion < this.textModelN.getAlternativeVersionId() && this.textModelN.canUndo()) { await this.textModelN.undo(); } } finally { this.hunkData.ignoreTextModelNChanges = false; } - // TODO@jrieken cannot do this yet because some parts still rely on - // exchanges being around... - // // remove this and following exchanges - // this._exchanges.length = idx; return true; } @@ -259,110 +240,9 @@ export class Session { this._teldata.endTime = new Date().toISOString(); return this._teldata; } - - asRecording(): Recording { - const result: Recording = { - session: this.chatModel.sessionId, - when: this._startTime, - exchanges: [] - }; - for (const exchange of this._exchanges) { - const response = exchange.response; - if (response instanceof ReplyResponse) { - result.exchanges.push({ prompt: exchange.prompt.value, res: response.chatResponse }); - } - } - return result; - } } -export class SessionPrompt { - - readonly value: string; - - constructor( - readonly request: IChatRequestModel, - readonly modelAltVersionId: number, - ) { - this.value = request.message.text; - } -} - -export class SessionExchange { - - constructor( - readonly prompt: SessionPrompt, - readonly response: ReplyResponse | EmptyResponse | ErrorResponse - ) { } -} - -export class EmptyResponse { - -} - -export class ErrorResponse { - - readonly message: string; - readonly isCancellation: boolean; - - constructor( - readonly error: any - ) { - this.message = toErrorMessage(error, false); - this.isCancellation = isCancellationError(error); - } -} - -export class ReplyResponse { - - readonly untitledTextModel: IUntitledTextEditorModel | undefined; - - constructor( - localUri: URI, - readonly chatRequest: IChatRequestModel, - readonly chatResponse: IChatResponseModel, - @ITextFileService private readonly _textFileService: ITextFileService, - @ILanguageService private readonly _languageService: ILanguageService, - ) { - - const editsMap = new ResourceMap(); - - for (const item of chatResponse.response.value) { - if (item.kind === 'textEditGroup') { - const array = editsMap.get(item.uri); - for (const group of item.edits) { - if (array) { - array.push(group); - } else { - editsMap.set(item.uri, [group]); - } - } - } - } - - for (const [uri, edits] of editsMap) { - - const flatEdits = edits.flat(); - if (flatEdits.length === 0) { - editsMap.delete(uri); - continue; - } - - const isLocalUri = isEqual(uri, localUri); - if (uri.scheme === Schemas.untitled && !isLocalUri && !this.untitledTextModel) { //TODO@jrieken the first untitled model WINS - const langSelection = this._languageService.createByFilepathOrFirstLine(uri, undefined); - const untitledTextModel = this._textFileService.untitled.create({ - associatedResource: uri, - languageId: langSelection.languageId - }); - this.untitledTextModel = untitledTextModel; - untitledTextModel.resolve(); - } - } - } -} - export class StashedSession { private readonly _listener: IDisposable; @@ -589,6 +469,8 @@ export class HunkData { const hunks = mergedChanges.map(change => new RawHunk(change.original, change.modified, change.innerChanges ?? [])); + editState.applied = hunks.length; + this._textModelN.changeDecorations(accessorN => { this._textModel0.changeDecorations(accessor0 => { @@ -691,6 +573,9 @@ export class HunkData { const edits = this._discardEdits(item); this._textModelN.pushEditOperations(null, edits, () => null); data.state = HunkState.Rejected; + if (data.editState.applied > 0) { + data.editState.applied -= 1; + } } }, acceptChanges: () => { @@ -707,7 +592,6 @@ export class HunkData { } this._textModel0.pushEditOperations(null, edits, () => null); data.state = HunkState.Accepted; - data.editState.applied += 1; } } }; diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts index 9b2246ad8f1..9e38c4e920d 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionService.ts @@ -10,17 +10,9 @@ import { IActiveCodeEditor, ICodeEditor } from 'vs/editor/browser/editorBrowser' import { IRange } from 'vs/editor/common/core/range'; import { IValidEditOperation } from 'vs/editor/common/model'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; -import { IChatResponseModel } from 'vs/workbench/contrib/chat/common/chatModel'; import { EditMode } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; import { Session, StashedSession } from './inlineChatSession'; - -export type Recording = { - when: Date; - session: string; - exchanges: { prompt: string; res: IChatResponseModel }[]; -}; - export interface ISessionKeyComputer { getComparisonKey(editor: ICodeEditor, uri: URI): string; } @@ -58,8 +50,5 @@ export interface IInlineChatSessionService { registerSessionKeyComputer(scheme: string, value: ISessionKeyComputer): IDisposable; - // - recordings(): readonly Recording[]; - dispose(): void; } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts index a6a4c9b4c46..3daf5e11568 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from 'vs/base/common/cancellation'; -import { CancellationError } from 'vs/base/common/errors'; import { Emitter, Event } from 'vs/base/common/event'; import { DisposableStore, IDisposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { Schemas } from 'vs/base/common/network'; @@ -26,8 +25,11 @@ import { IChatService } from 'vs/workbench/contrib/chat/common/chatService'; import { CTX_INLINE_CHAT_HAS_AGENT, EditMode } from 'vs/workbench/contrib/inlineChat/common/inlineChat'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { UntitledTextEditorInput } from 'vs/workbench/services/untitled/common/untitledTextEditorInput'; -import { EmptyResponse, ErrorResponse, HunkData, ReplyResponse, Session, SessionExchange, SessionPrompt, SessionWholeRange, StashedSession, TelemetryData, TelemetryDataClassification } from './inlineChatSession'; -import { IInlineChatSessionEndEvent, IInlineChatSessionEvent, IInlineChatSessionService, ISessionKeyComputer, Recording } from './inlineChatSessionService'; +import { HunkData, Session, SessionWholeRange, StashedSession, TelemetryData, TelemetryDataClassification } from './inlineChatSession'; +import { IInlineChatSessionEndEvent, IInlineChatSessionEvent, IInlineChatSessionService, ISessionKeyComputer } from './inlineChatSessionService'; +import { isEqual } from 'vs/base/common/resources'; +import { ILanguageService } from 'vs/editor/common/languages/language'; +import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; type SessionData = { @@ -65,8 +67,6 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { private readonly _sessions = new Map(); private readonly _keyComputers = new Map(); - private _recordings: Recording[] = []; - constructor( @ITelemetryService private readonly _telemetryService: ITelemetryService, @@ -76,6 +76,8 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { @ILogService private readonly _logService: ILogService, @IInstantiationService private readonly _instaService: IInstantiationService, @IEditorService private readonly _editorService: IEditorService, + @ITextFileService private readonly _textFileService: ITextFileService, + @ILanguageService private readonly _languageService: ILanguageService, @IChatService private readonly _chatService: IChatService, @IChatAgentService private readonly _chatAgentService: IChatAgentService ) { } @@ -126,8 +128,7 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { const { response } = e.request; - const prompt = new SessionPrompt(e.request, session.textModelN.getAlternativeVersionId()); - + session.markModelVersion(e.request); lastResponseListener.value = response.onDidChange(() => { if (!response.isComplete) { @@ -136,34 +137,22 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { lastResponseListener.clear(); // ONCE - let inlineResponse: ErrorResponse | EmptyResponse | ReplyResponse; - - // make an response from the ChatResponseModel - if (response.isCanceled) { - // error: cancelled - inlineResponse = new ErrorResponse(new CancellationError()); - } else if (response.result?.errorDetails) { - // error: "real" error - inlineResponse = new ErrorResponse(new Error(response.result.errorDetails.message)); - } else if (response.response.value.length === 0) { - // epmty response - inlineResponse = new EmptyResponse(); - } else { - inlineResponse = this._instaService.createInstance( - ReplyResponse, - session.textModelN.uri, - e.request, - response - ); - } - - session.addExchange(new SessionExchange(prompt, inlineResponse)); - - if (inlineResponse instanceof ReplyResponse && inlineResponse.untitledTextModel) { - this._textModelService.createModelReference(inlineResponse.untitledTextModel.resource).then(ref => { + // special handling for untitled files + for (const part of response.response.value) { + if (part.kind !== 'textEditGroup' || part.uri.scheme !== Schemas.untitled || isEqual(part.uri, session.textModelN.uri)) { + continue; + } + const langSelection = this._languageService.createByFilepathOrFirstLine(part.uri, undefined); + const untitledTextModel = this._textFileService.untitled.create({ + associatedResource: part.uri, + languageId: langSelection.languageId + }); + untitledTextModel.resolve(); + this._textModelService.createModelReference(part.uri).then(ref => { store.add(ref); }); } + }); })); @@ -216,7 +205,7 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { store.add(new SessionWholeRange(textModelN, wholeRange)), store.add(new HunkData(this._editorWorkerService, textModel0, textModelN)), chatModel, - options.session?.exchanges, // @ulugbekna: very hacky: we pass exchanges by reference because an exchange is added only on `addRequest` event from chat model which the migrated inline chat misses + options.session?.versionsByRequest, ); // store: key -> session @@ -279,7 +268,6 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { return; } - this._keepRecording(session); this._telemetryService.publicLog2('interactiveEditor/session', session.asTelemetryData()); const [key, value] = tuple; @@ -291,7 +279,6 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { } stashSession(session: Session, editor: ICodeEditor, undoCancelEdits: IValidEditOperation[]): StashedSession { - this._keepRecording(session); const result = this._instaService.createInstance(StashedSession, editor, session, undoCancelEdits); this._onDidStashSession.fire({ editor, session }); this._logService.trace(`[IE] did STASH session for ${editor.getId()}, ${session.agent.extensionId}`); @@ -324,19 +311,6 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { this._keyComputers.set(scheme, value); return toDisposable(() => this._keyComputers.delete(scheme)); } - - // --- debug - - private _keepRecording(session: Session) { - const newLen = this._recordings.unshift(session.asRecording()); - if (newLen > 5) { - this._recordings.pop(); - } - } - - recordings(): readonly Recording[] { - return this._recordings; - } } export class InlineChatEnabler { diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts index a85c09f032f..49eb8f52dad 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatWidget.ts @@ -145,11 +145,20 @@ export class InlineChatWidget { supportsFileReferences: _configurationService.getValue(`chat.experimental.variables.${location.location}`) === true, filter: item => { if (isWelcomeVM(item)) { + // filter welcome messages return false; } - if (isResponseVM(item) && item.isComplete && item.response.value.length > 0 && item.response.value.every(item => item.kind === 'textEditGroup' && options.chatWidgetViewOptions?.rendererOptions?.renderTextEditsAsSummary?.(item.uri))) { - // filter responses that are just text edits (prevents the "Made Edits") - return false; + if (isResponseVM(item) && item.isComplete) { + // filter responses that + // - are just text edits(prevents the "Made Edits") + // - are all empty + if (item.response.value.length > 0 && item.response.value.every(item => item.kind === 'textEditGroup' && options.chatWidgetViewOptions?.rendererOptions?.renderTextEditsAsSummary?.(item.uri))) { + return false; + } + if (item.response.value.length === 0) { + return false; + } + return true; } return true; }, 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 d0900c72836..0e414188a2e 100644 --- a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { equals } from 'vs/base/common/arrays'; import { DeferredPromise, raceCancellation, timeout } from 'vs/base/common/async'; -import { Event } from 'vs/base/common/event'; +import { Emitter, Event } from 'vs/base/common/event'; import { DisposableStore } from 'vs/base/common/lifecycle'; import { mock } from 'vs/base/test/common/mock'; import { runWithFakedTimers } from 'vs/base/test/common/timeTravelScheduler'; @@ -85,7 +85,7 @@ suite('InteractiveChatController', function () { class TestController extends InlineChatController { static INIT_SEQUENCE: readonly State[] = [State.CREATE_SESSION, State.INIT_UI, State.WAIT_FOR_INPUT]; - static INIT_SEQUENCE_AUTO_SEND: readonly State[] = [...this.INIT_SEQUENCE, State.SHOW_REQUEST, State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]; + static INIT_SEQUENCE_AUTO_SEND: readonly State[] = [...this.INIT_SEQUENCE, State.SHOW_REQUEST, State.WAIT_FOR_INPUT]; readonly onDidChangeState: Event = this._onDidEnterState.event; @@ -320,7 +320,7 @@ suite('InteractiveChatController', function () { ctrl.chatWidget.setInput('GENGEN'); ctrl.acceptInput(); - assert.strictEqual(await ctrl.awaitStates([State.SHOW_REQUEST, State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]), undefined); + assert.strictEqual(await ctrl.awaitStates([State.SHOW_REQUEST, State.WAIT_FOR_INPUT]), undefined); assert.deepStrictEqual(session.wholeRange.value, new Range(1, 1, 4, 3)); @@ -370,7 +370,7 @@ suite('InteractiveChatController', function () { const valueThen = editor.getModel().getValue(); ctrl = instaService.createInstance(TestController, editor); - const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST, State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]); + const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST, State.WAIT_FOR_INPUT]); const r = ctrl.run({ message: 'Hello', autoSend: true }); assert.strictEqual(await p, undefined); ctrl.acceptSession(); @@ -413,7 +413,7 @@ suite('InteractiveChatController', function () { // store.add(editor.getModel().onDidChangeContent(() => { modelChangeCounter++; })); ctrl = instaService.createInstance(TestController, editor); - const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST, State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]); + const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST, State.WAIT_FOR_INPUT]); const r = ctrl.run({ message: 'Hello', autoSend: true }); assert.strictEqual(await p, undefined); @@ -436,7 +436,7 @@ suite('InteractiveChatController', function () { // NO manual edits -> cancel ctrl = instaService.createInstance(TestController, editor); - const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST, State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]); + const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST, State.WAIT_FOR_INPUT]); const r = ctrl.run({ message: 'GENERATED', autoSend: true }); assert.strictEqual(await p, undefined); @@ -452,7 +452,7 @@ suite('InteractiveChatController', function () { // manual edits -> finish ctrl = instaService.createInstance(TestController, editor); - const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST, State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]); + const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST, State.WAIT_FOR_INPUT]); const r = ctrl.run({ message: 'GENERATED', autoSend: true }); assert.strictEqual(await p, undefined); @@ -487,14 +487,14 @@ suite('InteractiveChatController', function () { model.setValue(''); - const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST, State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]); + const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST, State.WAIT_FOR_INPUT]); const r = ctrl.run({ message: 'PROMPT_', autoSend: true }); assert.strictEqual(await p, undefined); assert.strictEqual(model.getValue(), 'PROMPT_1'); - const p2 = ctrl.awaitStates([State.SHOW_REQUEST, State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]); + const p2 = ctrl.awaitStates([State.SHOW_REQUEST, State.WAIT_FOR_INPUT]); await instaService.invokeFunction(rerun.runInlineChatCommand, ctrl, editor); assert.strictEqual(await p2, undefined); @@ -528,14 +528,14 @@ suite('InteractiveChatController', function () { model.setValue(''); // REQUEST 1 - const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST, State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]); + const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST, State.WAIT_FOR_INPUT]); const r = ctrl.run({ message: '1', autoSend: true }); assert.strictEqual(await p, undefined); assert.strictEqual(model.getValue(), 'eins-'); // REQUEST 2 - const p2 = ctrl.awaitStates([State.SHOW_REQUEST, State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]); + const p2 = ctrl.awaitStates([State.SHOW_REQUEST, State.WAIT_FOR_INPUT]); ctrl.chatWidget.setInput('1'); await ctrl.acceptInput(); assert.strictEqual(await p2, undefined); @@ -543,7 +543,7 @@ suite('InteractiveChatController', function () { assert.strictEqual(model.getValue(), 'zwei-eins-'); // REQUEST 2 - RERUN - const p3 = ctrl.awaitStates([State.SHOW_REQUEST, State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]); + const p3 = ctrl.awaitStates([State.SHOW_REQUEST, State.WAIT_FOR_INPUT]); await instaService.invokeFunction(rerun.runInlineChatCommand, ctrl, editor); assert.strictEqual(await p3, undefined); @@ -572,7 +572,7 @@ suite('InteractiveChatController', function () { ctrl = instaService.createInstance(TestController, editor); // REQUEST 1 - const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST, State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]); + const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST, State.WAIT_FOR_INPUT]); ctrl.run({ message: '1', autoSend: true }); assert.strictEqual(await p, undefined); @@ -612,14 +612,14 @@ suite('InteractiveChatController', function () { ctrl = instaService.createInstance(TestController, editor); // REQUEST 1 - const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST, State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]); + const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST, State.WAIT_FOR_INPUT]); ctrl.run({ message: '1', autoSend: true }); assert.strictEqual(await p, undefined); assert.strictEqual(model.getValue(), 'eins\nHello\nWorld\nHello Again\nHello World\n'); // REQUEST 2 - const p2 = ctrl.awaitStates([State.SHOW_REQUEST, State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]); + const p2 = ctrl.awaitStates([State.SHOW_REQUEST, State.WAIT_FOR_INPUT]); ctrl.chatWidget.setInput('1'); await ctrl.acceptInput(); assert.strictEqual(await p2, undefined); @@ -651,11 +651,14 @@ suite('InteractiveChatController', function () { let count = 0; const commandDetection: (boolean | undefined)[] = []; + const onDidInvoke = new Emitter(); + store.add(chatAgentService.registerDynamicAgent({ id: 'testEditorAgent2', ...agentData }, { async invoke(request, progress, history, token) { + queueMicrotask(() => onDidInvoke.fire()); commandDetection.push(request.enableCommandDetection); progress({ kind: 'textEdit', uri: model.uri, edits: [{ range: new Range(1, 1, 1, 1), text: request.message + (count++) }] }); @@ -672,17 +675,23 @@ suite('InteractiveChatController', function () { ctrl = instaService.createInstance(TestController, editor); // REQUEST 1 - const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST]); + // const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST]); + const p = Event.toPromise(onDidInvoke.event); ctrl.run({ message: 'Hello-', autoSend: true }); - assert.strictEqual(await p, undefined); + + await p; + + // assert.strictEqual(await p, undefined); // resend pending request without command detection const request = ctrl.chatWidget.viewModel?.model.getRequests().at(-1); assertType(request); - const p2 = ctrl.awaitStates([State.SHOW_REQUEST, State.SHOW_RESPONSE]); + const p2 = Event.toPromise(onDidInvoke.event); + const p3 = ctrl.awaitStates([State.SHOW_REQUEST, State.WAIT_FOR_INPUT]); chatService.resendRequest(request, { noCommandDetection: true, attempt: request.attempt + 1, location: ChatAgentLocation.Editor }); - assert.strictEqual(await p2, undefined); + await p2; + assert.strictEqual(await p3, undefined); assert.deepStrictEqual(commandDetection, [true, false]); assert.strictEqual(model.getValue(), 'Hello-1'); @@ -708,14 +717,14 @@ suite('InteractiveChatController', function () { ctrl = instaService.createInstance(TestController, editor); // REQUEST 1 - const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST, State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]); + const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE, State.SHOW_REQUEST, State.WAIT_FOR_INPUT]); ctrl.run({ message: 'Hello-', autoSend: true }); assert.strictEqual(await p, undefined); // resend pending request without command detection const request = ctrl.chatWidget.viewModel?.model.getRequests().at(-1); assertType(request); - const p2 = ctrl.awaitStates([State.SHOW_REQUEST, State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]); + const p2 = ctrl.awaitStates([State.SHOW_REQUEST, State.WAIT_FOR_INPUT]); chatService.resendRequest(request, { noCommandDetection: true, attempt: request.attempt + 1, location: ChatAgentLocation.Editor }); assert.strictEqual(await p2, undefined); @@ -759,7 +768,7 @@ suite('InteractiveChatController', function () { assert.deepStrictEqual(attempts, [0]); // RERUN (cancel, undo, redo) - const p2 = ctrl.awaitStates([State.SHOW_REQUEST, State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]); + const p2 = ctrl.awaitStates([State.SHOW_REQUEST, State.WAIT_FOR_INPUT]); const rerun = new RerunAction(); await instaService.invokeFunction(rerun.runInlineChatCommand, ctrl, editor); assert.strictEqual(await p2, undefined); @@ -806,11 +815,48 @@ suite('InteractiveChatController', function () { await modelChange; assert.strictEqual(model.getValue(), 'HelloWorld'); // first word has been streamed - const p2 = ctrl.awaitStates([State.SHOW_RESPONSE, State.WAIT_FOR_INPUT]); + const p2 = ctrl.awaitStates([State.WAIT_FOR_INPUT]); chatService.cancelCurrentRequestForSession(ctrl.chatWidget.viewModel!.model.sessionId); assert.strictEqual(await p2, undefined); assert.strictEqual(model.getValue(), 'World'); }); + + test('Apply Edits from existing session w/ edits', async function () { + + model.setValue(''); + + const newSession = await inlineChatSessionService.createSession(editor, { editMode: EditMode.Live }, CancellationToken.None); + assertType(newSession); + + await chatService.sendRequest(newSession.chatModel.sessionId, 'Existing', { location: ChatAgentLocation.Editor }); + + + assert.strictEqual(newSession.chatModel.requestInProgress, true); + + const response = newSession.chatModel.lastRequest?.response; + assertType(response); + + await new Promise(resolve => { + if (response.isComplete) { + resolve(undefined); + } + const d = response.onDidChange(() => { + if (response.isComplete) { + d.dispose(); + resolve(undefined); + } + }); + }); + + ctrl = instaService.createInstance(TestController, editor); + const p = ctrl.awaitStates([...TestController.INIT_SEQUENCE]); + ctrl.run({ existingSession: newSession }); + + assert.strictEqual(await p, undefined); + + assert.strictEqual(model.getValue(), 'Existing'); + + }); });