Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedFileEntry.ts
5239 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { RunOnceScheduler } from '../../../../../base/common/async.js';
7
import { Emitter } from '../../../../../base/common/event.js';
8
import { Disposable, DisposableMap, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';
9
import { Schemas } from '../../../../../base/common/network.js';
10
import { clamp } from '../../../../../base/common/numbers.js';
11
import { autorun, derived, IObservable, ITransaction, observableValue, observableValueOpts, transaction } from '../../../../../base/common/observable.js';
12
import { URI } from '../../../../../base/common/uri.js';
13
import { TextEdit } from '../../../../../editor/common/languages.js';
14
import { EditDeltaInfo } from '../../../../../editor/common/textModelEditSource.js';
15
import { localize } from '../../../../../nls.js';
16
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
17
import { IFileService } from '../../../../../platform/files/common/files.js';
18
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
19
import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js';
20
import { editorBackground, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js';
21
import { IUndoRedoElement, IUndoRedoService } from '../../../../../platform/undoRedo/common/undoRedo.js';
22
import { IEditorPane } from '../../../../common/editor.js';
23
import { IFilesConfigurationService } from '../../../../services/filesConfiguration/common/filesConfigurationService.js';
24
import { IAiEditTelemetryService } from '../../../editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js';
25
import { ICellEditOperation } from '../../../notebook/common/notebookCommon.js';
26
import { ChatUserAction, IChatService } from '../../common/chatService/chatService.js';
27
import { ChatEditKind, IModifiedEntryTelemetryInfo, IModifiedFileEntry, IModifiedFileEntryEditorIntegration, ISnapshotEntry, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js';
28
import { IChatResponseModel } from '../../common/model/chatModel.js';
29
30
class AutoAcceptControl {
31
constructor(
32
readonly total: number,
33
readonly remaining: number,
34
readonly cancel: () => void
35
) { }
36
}
37
38
export const pendingRewriteMinimap = registerColor('minimap.chatEditHighlight',
39
transparent(editorBackground, 0.6),
40
localize('editorSelectionBackground', "Color of pending edit regions in the minimap"));
41
42
43
export abstract class AbstractChatEditingModifiedFileEntry extends Disposable implements IModifiedFileEntry {
44
45
static readonly scheme = 'modified-file-entry';
46
47
private static lastEntryId = 0;
48
49
readonly entryId = `${AbstractChatEditingModifiedFileEntry.scheme}::${++AbstractChatEditingModifiedFileEntry.lastEntryId}`;
50
51
protected readonly _onDidDelete = this._register(new Emitter<void>());
52
readonly onDidDelete = this._onDidDelete.event;
53
54
protected readonly _stateObs = observableValue<ModifiedFileEntryState>(this, ModifiedFileEntryState.Modified);
55
readonly state: IObservable<ModifiedFileEntryState> = this._stateObs;
56
57
protected readonly _waitsForLastEdits = observableValue<boolean>(this, false);
58
readonly waitsForLastEdits: IObservable<boolean> = this._waitsForLastEdits;
59
60
protected readonly _isCurrentlyBeingModifiedByObs = observableValue<{ responseModel: IChatResponseModel; undoStopId: string | undefined } | undefined>(this, undefined);
61
readonly isCurrentlyBeingModifiedBy: IObservable<{ responseModel: IChatResponseModel; undoStopId: string | undefined } | undefined> = this._isCurrentlyBeingModifiedByObs;
62
63
/**
64
* Flag to track if we're currently in an external edit operation.
65
* When true, file system changes should be treated as agent edits, not user edits.
66
*/
67
protected _isExternalEditInProgress = false;
68
69
protected readonly _lastModifyingResponseObs = observableValueOpts<IChatResponseModel | undefined>({ equalsFn: (a, b) => a?.requestId === b?.requestId }, undefined);
70
readonly lastModifyingResponse: IObservable<IChatResponseModel | undefined> = this._lastModifyingResponseObs;
71
72
protected readonly _lastModifyingResponseInProgressObs = this._lastModifyingResponseObs.map((value, r) => {
73
return value?.isInProgress.read(r) ?? false;
74
});
75
76
protected readonly _rewriteRatioObs = observableValue<number>(this, 0);
77
readonly rewriteRatio: IObservable<number> = this._rewriteRatioObs;
78
79
private readonly _reviewModeTempObs = observableValue<true | undefined>(this, undefined);
80
readonly reviewMode: IObservable<boolean>;
81
82
private readonly _autoAcceptCtrl = observableValue<AutoAcceptControl | undefined>(this, undefined);
83
readonly autoAcceptController: IObservable<AutoAcceptControl | undefined> = this._autoAcceptCtrl;
84
85
protected readonly _autoAcceptTimeout: IObservable<number>;
86
87
get telemetryInfo(): IModifiedEntryTelemetryInfo {
88
return this._telemetryInfo;
89
}
90
91
readonly createdInRequestId: string | undefined;
92
93
get lastModifyingRequestId() {
94
return this._telemetryInfo.requestId;
95
}
96
97
private _refCounter: number = 1;
98
99
readonly abstract originalURI: URI;
100
101
protected readonly _userEditScheduler = this._register(new RunOnceScheduler(() => this._notifySessionAction('userModified'), 1000));
102
103
constructor(
104
readonly modifiedURI: URI,
105
protected _telemetryInfo: IModifiedEntryTelemetryInfo,
106
kind: ChatEditKind,
107
@IConfigurationService configService: IConfigurationService,
108
@IFilesConfigurationService protected _fileConfigService: IFilesConfigurationService,
109
@IChatService protected readonly _chatService: IChatService,
110
@IFileService protected readonly _fileService: IFileService,
111
@IUndoRedoService private readonly _undoRedoService: IUndoRedoService,
112
@IInstantiationService protected readonly _instantiationService: IInstantiationService,
113
@IAiEditTelemetryService private readonly _aiEditTelemetryService: IAiEditTelemetryService,
114
) {
115
super();
116
117
if (kind === ChatEditKind.Created) {
118
this.createdInRequestId = this._telemetryInfo.requestId;
119
}
120
121
if (this.modifiedURI.scheme !== Schemas.untitled && this.modifiedURI.scheme !== Schemas.vscodeNotebookCell) {
122
this._register(this._fileService.watch(this.modifiedURI));
123
this._register(this._fileService.onDidFilesChange(e => {
124
if (e.affects(this.modifiedURI) && kind === ChatEditKind.Created && e.gotDeleted()) {
125
this._onDidDelete.fire();
126
}
127
}));
128
}
129
130
// review mode depends on setting and temporary override
131
const autoAcceptRaw = observableConfigValue('chat.editing.autoAcceptDelay', 0, configService);
132
this._autoAcceptTimeout = derived(r => {
133
const value = autoAcceptRaw.read(r);
134
return clamp(value, 0, 100);
135
});
136
this.reviewMode = derived(r => {
137
const configuredValue = this._autoAcceptTimeout.read(r);
138
const tempValue = this._reviewModeTempObs.read(r);
139
return tempValue ?? configuredValue === 0;
140
});
141
142
this._store.add(toDisposable(() => this._lastModifyingResponseObs.set(undefined, undefined)));
143
144
const autoSaveOff = this._store.add(new MutableDisposable());
145
this._store.add(autorun(r => {
146
if (this._waitsForLastEdits.read(r)) {
147
autoSaveOff.value = _fileConfigService.disableAutoSave(this.modifiedURI);
148
} else {
149
autoSaveOff.clear();
150
}
151
}));
152
153
this._store.add(autorun(r => {
154
const inProgress = this._lastModifyingResponseInProgressObs.read(r);
155
if (inProgress === false && !this.reviewMode.read(r)) {
156
// AUTO accept mode (when request is done)
157
158
const acceptTimeout = this._autoAcceptTimeout.read(undefined) * 1000;
159
const future = Date.now() + acceptTimeout;
160
const update = () => {
161
162
const reviewMode = this.reviewMode.read(undefined);
163
if (reviewMode) {
164
// switched back to review mode
165
this._autoAcceptCtrl.set(undefined, undefined);
166
return;
167
}
168
169
const remain = Math.round(future - Date.now());
170
if (remain <= 0) {
171
this.accept();
172
} else {
173
const handle = setTimeout(update, 100);
174
this._autoAcceptCtrl.set(new AutoAcceptControl(acceptTimeout, remain, () => {
175
clearTimeout(handle);
176
this._autoAcceptCtrl.set(undefined, undefined);
177
}), undefined);
178
}
179
};
180
update();
181
}
182
}));
183
}
184
185
override dispose(): void {
186
if (--this._refCounter === 0) {
187
super.dispose();
188
}
189
}
190
191
acquire() {
192
this._refCounter++;
193
return this;
194
}
195
196
enableReviewModeUntilSettled(): void {
197
198
if (this.state.get() !== ModifiedFileEntryState.Modified) {
199
// nothing to do
200
return;
201
}
202
203
this._reviewModeTempObs.set(true, undefined);
204
205
const cleanup = autorun(r => {
206
// reset config when settled
207
const resetConfig = this.state.read(r) !== ModifiedFileEntryState.Modified;
208
if (resetConfig) {
209
this._store.delete(cleanup);
210
this._reviewModeTempObs.set(undefined, undefined);
211
}
212
});
213
214
this._store.add(cleanup);
215
}
216
217
updateTelemetryInfo(telemetryInfo: IModifiedEntryTelemetryInfo) {
218
this._telemetryInfo = telemetryInfo;
219
}
220
221
async accept(): Promise<void> {
222
const callback = await this.acceptDeferred();
223
if (callback) {
224
transaction(callback);
225
}
226
}
227
228
/** Accepts and returns a function used to transition the state. This MUST be called by the consumer. */
229
async acceptDeferred(): Promise<((tx: ITransaction) => void) | undefined> {
230
if (this._stateObs.get() !== ModifiedFileEntryState.Modified) {
231
// already accepted or rejected
232
return;
233
}
234
235
await this._doAccept();
236
237
return (tx: ITransaction) => {
238
this._stateObs.set(ModifiedFileEntryState.Accepted, tx);
239
this._autoAcceptCtrl.set(undefined, tx);
240
this._notifySessionAction('accepted');
241
};
242
}
243
244
protected abstract _doAccept(): Promise<void>;
245
246
async reject(): Promise<void> {
247
const callback = await this.rejectDeferred();
248
if (callback) {
249
transaction(callback);
250
}
251
}
252
253
/** Rejects and returns a function used to transition the state. This MUST be called by the consumer. */
254
async rejectDeferred(): Promise<((tx: ITransaction) => void) | undefined> {
255
if (this._stateObs.get() !== ModifiedFileEntryState.Modified) {
256
// already accepted or rejected
257
return undefined;
258
}
259
260
this._notifySessionAction('rejected');
261
await this._doReject();
262
263
return (tx: ITransaction) => {
264
this._stateObs.set(ModifiedFileEntryState.Rejected, tx);
265
this._autoAcceptCtrl.set(undefined, tx);
266
};
267
}
268
269
protected abstract _doReject(): Promise<void>;
270
271
protected _notifySessionAction(outcome: 'accepted' | 'rejected' | 'userModified') {
272
this._notifyAction({ kind: 'chatEditingSessionAction', uri: this.modifiedURI, hasRemainingEdits: false, outcome });
273
}
274
275
protected _notifyAction(action: ChatUserAction) {
276
if (action.kind === 'chatEditingHunkAction') {
277
this._aiEditTelemetryService.handleCodeAccepted({
278
suggestionId: undefined, // TODO@hediet try to figure this out
279
acceptanceMethod: 'accept',
280
presentation: 'highlightedEdit',
281
modelId: this._telemetryInfo.modelId,
282
modeId: this._telemetryInfo.modeId,
283
applyCodeBlockSuggestionId: this._telemetryInfo.applyCodeBlockSuggestionId,
284
editDeltaInfo: new EditDeltaInfo(
285
action.linesAdded,
286
action.linesRemoved,
287
-1,
288
-1,
289
),
290
feature: this._telemetryInfo.feature,
291
languageId: action.languageId,
292
source: undefined,
293
});
294
}
295
296
this._chatService.notifyUserAction({
297
action,
298
agentId: this._telemetryInfo.agentId,
299
modelId: this._telemetryInfo.modelId,
300
modeId: this._telemetryInfo.modeId,
301
command: this._telemetryInfo.command,
302
sessionResource: this._telemetryInfo.sessionResource,
303
requestId: this._telemetryInfo.requestId,
304
result: this._telemetryInfo.result
305
});
306
}
307
308
private readonly _editorIntegrations = this._register(new DisposableMap<IEditorPane, IModifiedFileEntryEditorIntegration>());
309
310
getEditorIntegration(pane: IEditorPane): IModifiedFileEntryEditorIntegration {
311
let value = this._editorIntegrations.get(pane);
312
if (!value) {
313
value = this._createEditorIntegration(pane);
314
this._editorIntegrations.set(pane, value);
315
}
316
return value;
317
}
318
319
/**
320
* Create the editor integration for this entry and the given editor pane. This will only be called
321
* once (and cached) per pane. The integration is meant to be scoped to this entry only and when the
322
* passed pane/editor changes input, then the editor integration must handle that, e.g use default/null
323
* values
324
*/
325
protected abstract _createEditorIntegration(editor: IEditorPane): IModifiedFileEntryEditorIntegration;
326
327
abstract readonly changesCount: IObservable<number>;
328
329
acceptStreamingEditsStart(responseModel: IChatResponseModel, undoStopId: string | undefined, tx: ITransaction | undefined) {
330
this._resetEditsState(tx);
331
this._isCurrentlyBeingModifiedByObs.set({ responseModel, undoStopId }, tx);
332
this._lastModifyingResponseObs.set(responseModel, tx);
333
this._autoAcceptCtrl.get()?.cancel();
334
335
const undoRedoElement = this._createUndoRedoElement(responseModel);
336
if (undoRedoElement) {
337
this._undoRedoService.pushElement(undoRedoElement);
338
}
339
}
340
341
protected abstract _createUndoRedoElement(response: IChatResponseModel): IUndoRedoElement | undefined;
342
343
abstract acceptAgentEdits(uri: URI, edits: (TextEdit | ICellEditOperation)[], isLastEdits: boolean, responseModel: IChatResponseModel | undefined): Promise<void>;
344
345
async acceptStreamingEditsEnd() {
346
this._resetEditsState(undefined);
347
348
if (await this._areOriginalAndModifiedIdentical()) {
349
// ACCEPT if identical
350
await this.accept();
351
}
352
}
353
354
protected abstract _areOriginalAndModifiedIdentical(): Promise<boolean>;
355
356
protected _resetEditsState(tx: ITransaction | undefined): void {
357
this._isCurrentlyBeingModifiedByObs.set(undefined, tx);
358
this._rewriteRatioObs.set(0, tx);
359
this._waitsForLastEdits.set(false, tx);
360
}
361
362
// --- snapshot
363
364
abstract createSnapshot(chatSessionResource: URI, requestId: string | undefined, undoStop: string | undefined): ISnapshotEntry;
365
366
abstract equalsSnapshot(snapshot: ISnapshotEntry | undefined): boolean;
367
368
abstract restoreFromSnapshot(snapshot: ISnapshotEntry, restoreToDisk?: boolean): Promise<void>;
369
370
// --- inital content
371
372
abstract resetToInitialContent(): Promise<void>;
373
374
abstract initialContent: string;
375
376
/**
377
* Computes the edits between two snapshots of the file content.
378
* @param beforeSnapshot The content before the changes
379
* @param afterSnapshot The content after the changes
380
* @returns Array of text edits or cell edit operations
381
*/
382
abstract computeEditsFromSnapshots(beforeSnapshot: string, afterSnapshot: string): Promise<(TextEdit | ICellEditOperation)[]>;
383
384
/**
385
* Marks the start of an external edit operation.
386
* File system changes will be treated as agent edits until stopExternalEdit is called.
387
*/
388
startExternalEdit(): void {
389
this._isExternalEditInProgress = true;
390
}
391
392
/**
393
* Marks the end of an external edit operation.
394
*/
395
stopExternalEdit(): void {
396
this._isExternalEditInProgress = false;
397
}
398
399
/**
400
* Saves the current model state to disk.
401
*/
402
abstract save(): Promise<void>;
403
404
/**
405
* Reloads the model from disk to ensure it's in sync with file system changes.
406
*/
407
abstract revertToDisk(): Promise<void>;
408
}
409
410