Path: blob/main/src/vs/workbench/contrib/inlineChat/browser/inlineChatSession.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { URI } from '../../../../base/common/uri.js';6import { Emitter, Event } from '../../../../base/common/event.js';7import { IIdentifiedSingleEditOperation, IModelDecorationOptions, IModelDeltaDecoration, ITextModel, IValidEditOperation, TrackedRangeStickiness } from '../../../../editor/common/model.js';8import { CTX_INLINE_CHAT_HAS_STASHED_SESSION } from '../common/inlineChat.js';9import { IRange, Range } from '../../../../editor/common/core/range.js';10import { ModelDecorationOptions } from '../../../../editor/common/model/textModel.js';11import { EditOperation, ISingleEditOperation } from '../../../../editor/common/core/editOperation.js';12import { DetailedLineRangeMapping, LineRangeMapping, RangeMapping } from '../../../../editor/common/diff/rangeMapping.js';13import { IInlineChatSessionService } from './inlineChatSessionService.js';14import { LineRange } from '../../../../editor/common/core/ranges/lineRange.js';15import { IEditorWorkerService } from '../../../../editor/common/services/editorWorker.js';16import { coalesceInPlace } from '../../../../base/common/arrays.js';17import { Iterable } from '../../../../base/common/iterator.js';18import { IModelContentChangedEvent } from '../../../../editor/common/textModelEvents.js';19import { DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';20import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js';21import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';22import { ILogService } from '../../../../platform/log/common/log.js';23import { ChatModel, IChatRequestModel, IChatTextEditGroupState } from '../../chat/common/chatModel.js';24import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js';25import { IChatAgent } from '../../chat/common/chatAgents.js';26import { IDocumentDiff } from '../../../../editor/common/diff/documentDiffProvider.js';272829export type TelemetryData = {30extension: string;31rounds: string;32undos: string;33unstashed: number;34edits: number;35finishedByEdit: boolean;36startTime: string;37endTime: string;38acceptedHunks: number;39discardedHunks: number;40responseTypes: string;41};4243export type TelemetryDataClassification = {44owner: 'jrieken';45comment: 'Data about an interaction editor session';46extension: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension providing the data' };47rounds: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of request that were made' };48undos: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Requests that have been undone' };49edits: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Did edits happen while the session was active' };50unstashed: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How often did this session become stashed and resumed' };51finishedByEdit: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Did edits cause the session to terminate' };52startTime: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'When the session started' };53endTime: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'When the session ended' };54acceptedHunks: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of accepted hunks' };55discardedHunks: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of discarded hunks' };56responseTypes: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Comma separated list of response types like edits, message, mixed' };57};585960export class SessionWholeRange {6162private static readonly _options: IModelDecorationOptions = ModelDecorationOptions.register({ description: 'inlineChat/session/wholeRange' });6364private readonly _onDidChange = new Emitter<this>();65readonly onDidChange: Event<this> = this._onDidChange.event;6667private _decorationIds: string[] = [];6869constructor(private readonly _textModel: ITextModel, wholeRange: IRange) {70this._decorationIds = _textModel.deltaDecorations([], [{ range: wholeRange, options: SessionWholeRange._options }]);71}7273dispose() {74this._onDidChange.dispose();75if (!this._textModel.isDisposed()) {76this._textModel.deltaDecorations(this._decorationIds, []);77}78}7980fixup(changes: readonly DetailedLineRangeMapping[]): void {81const newDeco: IModelDeltaDecoration[] = [];82for (const { modified } of changes) {83const modifiedRange = this._textModel.validateRange(modified.isEmpty84? new Range(modified.startLineNumber, 1, modified.startLineNumber, Number.MAX_SAFE_INTEGER)85: new Range(modified.startLineNumber, 1, modified.endLineNumberExclusive - 1, Number.MAX_SAFE_INTEGER));8687newDeco.push({ range: modifiedRange, options: SessionWholeRange._options });88}89const [first, ...rest] = this._decorationIds; // first is the original whole range90const newIds = this._textModel.deltaDecorations(rest, newDeco);91this._decorationIds = [first].concat(newIds);92this._onDidChange.fire(this);93}9495get trackedInitialRange(): Range {96const [first] = this._decorationIds;97return this._textModel.getDecorationRange(first) ?? new Range(1, 1, 1, 1);98}99100get value(): Range {101let result: Range | undefined;102for (const id of this._decorationIds) {103const range = this._textModel.getDecorationRange(id);104if (range) {105if (!result) {106result = range;107} else {108result = Range.plusRange(result, range);109}110}111}112return result!;113}114}115116export class Session {117118private _isUnstashed: boolean = false;119private readonly _startTime = new Date();120private readonly _teldata: TelemetryData;121122private readonly _versionByRequest = new Map<string, number>();123124constructor(125readonly headless: boolean,126/**127* The URI of the document which is being EditorEdit128*/129readonly targetUri: URI,130/**131* A copy of the document at the time the session was started132*/133readonly textModel0: ITextModel,134/**135* The model of the editor136*/137readonly textModelN: ITextModel,138readonly agent: IChatAgent,139readonly wholeRange: SessionWholeRange,140readonly hunkData: HunkData,141readonly chatModel: ChatModel,142versionsByRequest?: [string, number][], // DEBT? this is needed when a chat model is "reused" for a new chat session143) {144145this._teldata = {146extension: ExtensionIdentifier.toKey(agent.extensionId),147startTime: this._startTime.toISOString(),148endTime: this._startTime.toISOString(),149edits: 0,150finishedByEdit: false,151rounds: '',152undos: '',153unstashed: 0,154acceptedHunks: 0,155discardedHunks: 0,156responseTypes: ''157};158if (versionsByRequest) {159this._versionByRequest = new Map(versionsByRequest);160}161}162163get isUnstashed(): boolean {164return this._isUnstashed;165}166167markUnstashed() {168this._teldata.unstashed! += 1;169this._isUnstashed = true;170}171172markModelVersion(request: IChatRequestModel) {173this._versionByRequest.set(request.id, this.textModelN.getAlternativeVersionId());174}175176get versionsByRequest() {177return Array.from(this._versionByRequest);178}179180async undoChangesUntil(requestId: string): Promise<boolean> {181182const targetAltVersion = this._versionByRequest.get(requestId);183if (targetAltVersion === undefined) {184return false;185}186// undo till this point187this.hunkData.ignoreTextModelNChanges = true;188try {189while (targetAltVersion < this.textModelN.getAlternativeVersionId() && this.textModelN.canUndo()) {190await this.textModelN.undo();191}192} finally {193this.hunkData.ignoreTextModelNChanges = false;194}195return true;196}197198get hasChangedText(): boolean {199return !this.textModel0.equalsTextBuffer(this.textModelN.getTextBuffer());200}201202asChangedText(changes: readonly LineRangeMapping[]): string | undefined {203if (changes.length === 0) {204return undefined;205}206207let startLine = Number.MAX_VALUE;208let endLine = Number.MIN_VALUE;209for (const change of changes) {210startLine = Math.min(startLine, change.modified.startLineNumber);211endLine = Math.max(endLine, change.modified.endLineNumberExclusive);212}213214return this.textModelN.getValueInRange(new Range(startLine, 1, endLine, Number.MAX_VALUE));215}216217recordExternalEditOccurred(didFinish: boolean) {218this._teldata.edits += 1;219this._teldata.finishedByEdit = didFinish;220}221222asTelemetryData(): TelemetryData {223224for (const item of this.hunkData.getInfo()) {225switch (item.getState()) {226case HunkState.Accepted:227this._teldata.acceptedHunks += 1;228break;229case HunkState.Rejected:230this._teldata.discardedHunks += 1;231break;232}233}234235this._teldata.endTime = new Date().toISOString();236return this._teldata;237}238}239240241export class StashedSession {242243private readonly _listener: IDisposable;244private readonly _ctxHasStashedSession: IContextKey<boolean>;245private _session: Session | undefined;246247constructor(248editor: ICodeEditor,249session: Session,250private readonly _undoCancelEdits: IValidEditOperation[],251@IContextKeyService contextKeyService: IContextKeyService,252@IInlineChatSessionService private readonly _sessionService: IInlineChatSessionService,253@ILogService private readonly _logService: ILogService254) {255this._ctxHasStashedSession = CTX_INLINE_CHAT_HAS_STASHED_SESSION.bindTo(contextKeyService);256257// keep session for a little bit, only release when user continues to work (type, move cursor, etc.)258this._session = session;259this._ctxHasStashedSession.set(true);260this._listener = Event.once(Event.any(editor.onDidChangeCursorSelection, editor.onDidChangeModelContent, editor.onDidChangeModel, editor.onDidBlurEditorWidget))(() => {261this._session = undefined;262this._sessionService.releaseSession(session);263this._ctxHasStashedSession.reset();264});265}266267dispose() {268this._listener.dispose();269this._ctxHasStashedSession.reset();270if (this._session) {271this._sessionService.releaseSession(this._session);272}273}274275unstash(): Session | undefined {276if (!this._session) {277return undefined;278}279this._listener.dispose();280const result = this._session;281result.markUnstashed();282result.hunkData.ignoreTextModelNChanges = true;283result.textModelN.pushEditOperations(null, this._undoCancelEdits, () => null);284result.hunkData.ignoreTextModelNChanges = false;285this._session = undefined;286this._logService.debug('[IE] Unstashed session');287return result;288}289}290291// ---292293function lineRangeAsRange(lineRange: LineRange, model: ITextModel): Range {294return lineRange.isEmpty295? new Range(lineRange.startLineNumber, 1, lineRange.startLineNumber, Number.MAX_SAFE_INTEGER)296: new Range(lineRange.startLineNumber, 1, lineRange.endLineNumberExclusive - 1, Number.MAX_SAFE_INTEGER);297}298299export class HunkData {300301private static readonly _HUNK_TRACKED_RANGE = ModelDecorationOptions.register({302description: 'inline-chat-hunk-tracked-range',303stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges304});305306private static readonly _HUNK_THRESHOLD = 8;307308private readonly _store = new DisposableStore();309private readonly _data = new Map<RawHunk, RawHunkData>();310private _ignoreChanges: boolean = false;311312constructor(313@IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService,314private readonly _textModel0: ITextModel,315private readonly _textModelN: ITextModel,316) {317318this._store.add(_textModelN.onDidChangeContent(e => {319if (!this._ignoreChanges) {320this._mirrorChanges(e);321}322}));323}324325dispose(): void {326if (!this._textModelN.isDisposed()) {327this._textModelN.changeDecorations(accessor => {328for (const { textModelNDecorations } of this._data.values()) {329textModelNDecorations.forEach(accessor.removeDecoration, accessor);330}331});332}333if (!this._textModel0.isDisposed()) {334this._textModel0.changeDecorations(accessor => {335for (const { textModel0Decorations } of this._data.values()) {336textModel0Decorations.forEach(accessor.removeDecoration, accessor);337}338});339}340this._data.clear();341this._store.dispose();342}343344set ignoreTextModelNChanges(value: boolean) {345this._ignoreChanges = value;346}347348get ignoreTextModelNChanges(): boolean {349return this._ignoreChanges;350}351352private _mirrorChanges(event: IModelContentChangedEvent) {353354// mirror textModelN changes to textModel0 execept for those that355// overlap with a hunk356357type HunkRangePair = { rangeN: Range; range0: Range; markAccepted: () => void };358const hunkRanges: HunkRangePair[] = [];359360const ranges0: Range[] = [];361362for (const entry of this._data.values()) {363364if (entry.state === HunkState.Pending) {365// pending means the hunk's changes aren't "sync'd" yet366for (let i = 1; i < entry.textModelNDecorations.length; i++) {367const rangeN = this._textModelN.getDecorationRange(entry.textModelNDecorations[i]);368const range0 = this._textModel0.getDecorationRange(entry.textModel0Decorations[i]);369if (rangeN && range0) {370hunkRanges.push({371rangeN, range0,372markAccepted: () => entry.state = HunkState.Accepted373});374}375}376377} else if (entry.state === HunkState.Accepted) {378// accepted means the hunk's changes are also in textModel0379for (let i = 1; i < entry.textModel0Decorations.length; i++) {380const range = this._textModel0.getDecorationRange(entry.textModel0Decorations[i]);381if (range) {382ranges0.push(range);383}384}385}386}387388hunkRanges.sort((a, b) => Range.compareRangesUsingStarts(a.rangeN, b.rangeN));389ranges0.sort(Range.compareRangesUsingStarts);390391const edits: IIdentifiedSingleEditOperation[] = [];392393for (const change of event.changes) {394395let isOverlapping = false;396397let pendingChangesLen = 0;398399for (const entry of hunkRanges) {400if (entry.rangeN.getEndPosition().isBefore(Range.getStartPosition(change.range))) {401// pending hunk _before_ this change. When projecting into textModel0 we need to402// subtract that. Because diffing is relaxed it might include changes that are not403// actual insertions/deletions. Therefore we need to take the length of the original404// range into account.405pendingChangesLen += this._textModelN.getValueLengthInRange(entry.rangeN);406pendingChangesLen -= this._textModel0.getValueLengthInRange(entry.range0);407408} else if (Range.areIntersectingOrTouching(entry.rangeN, change.range)) {409// an edit overlaps with a (pending) hunk. We take this as a signal410// to mark the hunk as accepted and to ignore the edit. The range of the hunk411// will be up-to-date because of decorations created for them412entry.markAccepted();413isOverlapping = true;414break;415416} else {417// hunks past this change aren't relevant418break;419}420}421422if (isOverlapping) {423// hunk overlaps, it grew424continue;425}426427const offset0 = change.rangeOffset - pendingChangesLen;428const start0 = this._textModel0.getPositionAt(offset0);429430let acceptedChangesLen = 0;431for (const range of ranges0) {432if (range.getEndPosition().isBefore(start0)) {433// accepted hunk _before_ this projected change. When projecting into textModel0434// we need to add that435acceptedChangesLen += this._textModel0.getValueLengthInRange(range);436}437}438439const start = this._textModel0.getPositionAt(offset0 + acceptedChangesLen);440const end = this._textModel0.getPositionAt(offset0 + acceptedChangesLen + change.rangeLength);441edits.push(EditOperation.replace(Range.fromPositions(start, end), change.text));442}443444this._textModel0.pushEditOperations(null, edits, () => null);445}446447async recompute(editState: IChatTextEditGroupState, diff?: IDocumentDiff | null) {448449diff ??= await this._editorWorkerService.computeDiff(this._textModel0.uri, this._textModelN.uri, { ignoreTrimWhitespace: false, maxComputationTimeMs: Number.MAX_SAFE_INTEGER, computeMoves: false }, 'advanced');450451let mergedChanges: DetailedLineRangeMapping[] = [];452453if (diff && diff.changes.length > 0) {454// merge changes neighboring changes455mergedChanges = [diff.changes[0]];456for (let i = 1; i < diff.changes.length; i++) {457const lastChange = mergedChanges[mergedChanges.length - 1];458const thisChange = diff.changes[i];459if (thisChange.modified.startLineNumber - lastChange.modified.endLineNumberExclusive <= HunkData._HUNK_THRESHOLD) {460mergedChanges[mergedChanges.length - 1] = new DetailedLineRangeMapping(461lastChange.original.join(thisChange.original),462lastChange.modified.join(thisChange.modified),463(lastChange.innerChanges ?? []).concat(thisChange.innerChanges ?? [])464);465} else {466mergedChanges.push(thisChange);467}468}469}470471const hunks = mergedChanges.map(change => new RawHunk(change.original, change.modified, change.innerChanges ?? []));472473editState.applied = hunks.length;474475this._textModelN.changeDecorations(accessorN => {476477this._textModel0.changeDecorations(accessor0 => {478479// clean up old decorations480for (const { textModelNDecorations, textModel0Decorations } of this._data.values()) {481textModelNDecorations.forEach(accessorN.removeDecoration, accessorN);482textModel0Decorations.forEach(accessor0.removeDecoration, accessor0);483}484485this._data.clear();486487// add new decorations488for (const hunk of hunks) {489490const textModelNDecorations: string[] = [];491const textModel0Decorations: string[] = [];492493textModelNDecorations.push(accessorN.addDecoration(lineRangeAsRange(hunk.modified, this._textModelN), HunkData._HUNK_TRACKED_RANGE));494textModel0Decorations.push(accessor0.addDecoration(lineRangeAsRange(hunk.original, this._textModel0), HunkData._HUNK_TRACKED_RANGE));495496for (const change of hunk.changes) {497textModelNDecorations.push(accessorN.addDecoration(change.modifiedRange, HunkData._HUNK_TRACKED_RANGE));498textModel0Decorations.push(accessor0.addDecoration(change.originalRange, HunkData._HUNK_TRACKED_RANGE));499}500501this._data.set(hunk, {502editState,503textModelNDecorations,504textModel0Decorations,505state: HunkState.Pending506});507}508});509});510}511512get size(): number {513return this._data.size;514}515516get pending(): number {517return Iterable.reduce(this._data.values(), (r, { state }) => r + (state === HunkState.Pending ? 1 : 0), 0);518}519520private _discardEdits(item: HunkInformation): ISingleEditOperation[] {521const edits: ISingleEditOperation[] = [];522const rangesN = item.getRangesN();523const ranges0 = item.getRanges0();524for (let i = 1; i < rangesN.length; i++) {525const modifiedRange = rangesN[i];526527const originalValue = this._textModel0.getValueInRange(ranges0[i]);528edits.push(EditOperation.replace(modifiedRange, originalValue));529}530return edits;531}532533discardAll() {534const edits: ISingleEditOperation[][] = [];535for (const item of this.getInfo()) {536if (item.getState() === HunkState.Pending) {537edits.push(this._discardEdits(item));538}539}540const undoEdits: IValidEditOperation[][] = [];541this._textModelN.pushEditOperations(null, edits.flat(), (_undoEdits) => {542undoEdits.push(_undoEdits);543return null;544});545return undoEdits.flat();546}547548getInfo(): HunkInformation[] {549550const result: HunkInformation[] = [];551552for (const [hunk, data] of this._data.entries()) {553const item: HunkInformation = {554getState: () => {555return data.state;556},557isInsertion: () => {558return hunk.original.isEmpty;559},560getRangesN: () => {561const ranges = data.textModelNDecorations.map(id => this._textModelN.getDecorationRange(id));562coalesceInPlace(ranges);563return ranges;564},565getRanges0: () => {566const ranges = data.textModel0Decorations.map(id => this._textModel0.getDecorationRange(id));567coalesceInPlace(ranges);568return ranges;569},570discardChanges: () => {571// DISCARD: replace modified range with original value. The modified range is retrieved from a decoration572// which was created above so that typing in the editor keeps discard working.573if (data.state === HunkState.Pending) {574const edits = this._discardEdits(item);575this._textModelN.pushEditOperations(null, edits, () => null);576data.state = HunkState.Rejected;577if (data.editState.applied > 0) {578data.editState.applied -= 1;579}580}581},582acceptChanges: () => {583// ACCEPT: replace original range with modified value. The modified value is retrieved from the model via584// its decoration and the original range is retrieved from the hunk.585if (data.state === HunkState.Pending) {586const edits: ISingleEditOperation[] = [];587const rangesN = item.getRangesN();588const ranges0 = item.getRanges0();589for (let i = 1; i < ranges0.length; i++) {590const originalRange = ranges0[i];591const modifiedValue = this._textModelN.getValueInRange(rangesN[i]);592edits.push(EditOperation.replace(originalRange, modifiedValue));593}594this._textModel0.pushEditOperations(null, edits, () => null);595data.state = HunkState.Accepted;596}597}598};599result.push(item);600}601602return result;603}604}605606class RawHunk {607constructor(608readonly original: LineRange,609readonly modified: LineRange,610readonly changes: RangeMapping[]611) { }612}613614type RawHunkData = {615textModelNDecorations: string[];616textModel0Decorations: string[];617state: HunkState;618editState: IChatTextEditGroupState;619};620621export const enum HunkState {622Pending = 0,623Accepted = 1,624Rejected = 2625}626627export interface HunkInformation {628/**629* The first element [0] is the whole modified range and subsequent elements are word-level changes630*/631getRangesN(): Range[];632633getRanges0(): Range[];634635isInsertion(): boolean;636637discardChanges(): void;638639/**640* Accept the hunk. Applies the corresponding edits into textModel0641*/642acceptChanges(): void;643644getState(): HunkState;645}646647648