Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/inlineEdits/vscode-node/components/expectedEditCaptureController.ts
13405 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 { commands, MarkdownString, StatusBarAlignment, StatusBarItem, ThemeColor, Uri, window, workspace } from 'vscode';
7
import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';
8
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
9
import { DocumentId } from '../../../../platform/inlineEdits/common/dataTypes/documentId';
10
import { DebugRecorderBookmark } from '../../../../platform/inlineEdits/common/debugRecorderBookmark';
11
import { ILogger, ILogService } from '../../../../platform/log/common/logService';
12
import { IFetcherService } from '../../../../platform/networking/common/fetcherService';
13
import { deserializeEdit, ISerializedEdit, LogEntry, serializeEdit } from '../../../../platform/workspaceRecorder/common/workspaceLog';
14
import { Disposable } from '../../../../util/vs/base/common/lifecycle';
15
import { DebugRecorder } from '../../node/debugRecorder';
16
import { filterLogForSensitiveFiles } from './inlineEditDebugComponent';
17
import { NesFeedbackSubmitter } from './nesFeedbackSubmitter';
18
19
export const copilotNesCaptureMode = 'copilotNesCaptureMode';
20
21
interface CaptureState {
22
active: boolean;
23
startBookmark: DebugRecorderBookmark;
24
endBookmark?: DebugRecorderBookmark;
25
startDocumentId: DocumentId;
26
startTime: number;
27
trigger: 'rejection' | 'manual';
28
originalNesMetadata?: {
29
requestUuid: string;
30
providerInfo?: string;
31
modelName?: string;
32
endpointUrl?: string;
33
suggestionText?: string;
34
suggestionRange?: [number, number, number, number];
35
documentPath?: string;
36
};
37
}
38
39
/**
40
* Controller for capturing expected edit suggestions from users when NES suggestions
41
* are rejected or don't appear. Leverages DebugRecorder's automatic edit tracking.
42
*/
43
export class ExpectedEditCaptureController extends Disposable {
44
45
private static readonly CAPTURE_FOLDER = '.copilot/nes-feedback';
46
47
private _state: CaptureState | undefined;
48
private _statusBarItem: StatusBarItem | undefined;
49
private _statusBarAnimationInterval: ReturnType<typeof setInterval> | undefined;
50
private readonly _feedbackSubmitter: NesFeedbackSubmitter;
51
private readonly _logger: ILogger;
52
53
constructor(
54
private readonly _debugRecorder: DebugRecorder,
55
@IConfigurationService private readonly _configurationService: IConfigurationService,
56
@ILogService private readonly _logService: ILogService,
57
@IAuthenticationService private readonly _authenticationService: IAuthenticationService,
58
@IFetcherService private readonly _fetcherService: IFetcherService,
59
) {
60
super();
61
this._logger = this._logService.createSubLogger(['NES', 'Capture']);
62
this._feedbackSubmitter = new NesFeedbackSubmitter(
63
this._logService,
64
this._authenticationService,
65
this._fetcherService
66
);
67
}
68
69
/**
70
* Check if the feature is enabled in settings.
71
*/
72
public get isEnabled(): boolean {
73
return this._configurationService.getConfig(ConfigKey.TeamInternal.RecordExpectedEditEnabled) ?? false;
74
}
75
76
/**
77
* Check if automatic capture on rejection is enabled.
78
*/
79
public get captureOnReject(): boolean {
80
return this._configurationService.getConfig(ConfigKey.TeamInternal.RecordExpectedEditOnReject) ?? true;
81
}
82
83
/**
84
* Check if a capture session is currently active.
85
*/
86
public get isCaptureActive(): boolean {
87
return this._state?.active ?? false;
88
}
89
90
/**
91
* Start a capture session.
92
* @param trigger How the capture was initiated
93
* @param nesMetadata Optional metadata about the rejected NES suggestion
94
*/
95
public async startCapture(
96
trigger: 'rejection' | 'manual',
97
nesMetadata?: CaptureState['originalNesMetadata']
98
): Promise<void> {
99
if (!this.isEnabled) {
100
this._logger.trace('Feature disabled, ignoring start request');
101
return;
102
}
103
104
if (this._state?.active) {
105
this._logger.trace('Capture already active, ignoring start request');
106
return;
107
}
108
109
const editor = window.activeTextEditor;
110
if (!editor) {
111
this._logger.trace('No active editor, cannot start capture');
112
return;
113
}
114
115
// Create bookmark to mark the start point
116
const startBookmark = this._debugRecorder.createBookmark();
117
const documentId = DocumentId.create(editor.document.uri.toString());
118
119
this._state = {
120
active: true,
121
startBookmark,
122
startDocumentId: documentId,
123
startTime: Date.now(),
124
trigger,
125
originalNesMetadata: nesMetadata
126
};
127
128
// Set context key to enable keybindings
129
await commands.executeCommand('setContext', copilotNesCaptureMode, true);
130
131
// Show status bar message
132
this._createStatusBarItem();
133
134
this._logger.info(`Started capture session: trigger=${trigger}, documentUri=${editor.document.uri.toString()}, hasMetadata=${!!nesMetadata}`);
135
}
136
137
/**
138
* Confirm and save the capture.
139
*/
140
public async confirmCapture(): Promise<void> {
141
if (!this._state?.active) {
142
this._logger.trace('No active capture to confirm');
143
return;
144
}
145
146
try {
147
// Create end bookmark
148
const endBookmark = this._debugRecorder.createBookmark();
149
this._state.endBookmark = endBookmark;
150
151
// Get log slices
152
const logUpToStart = this._debugRecorder.getRecentLog(this._state.startBookmark);
153
const logUpToEnd = this._debugRecorder.getRecentLog(endBookmark);
154
155
if (!logUpToStart || !logUpToEnd) {
156
this._logger.warn('Failed to retrieve logs from debug recorder');
157
await this.abortCapture();
158
return;
159
}
160
161
// Extract edits between bookmarks
162
const nextUserEdit = this._extractEditsBetweenBookmarks(
163
logUpToStart,
164
logUpToEnd,
165
this._state.startDocumentId
166
);
167
168
// Build recording
169
// Filter out both non-interacted documents and sensitive files (settings.json, .env)
170
const filteredLog = filterLogForSensitiveFiles(this._filterLogForNonInteractedDocuments(logUpToStart));
171
const recording = {
172
log: filteredLog,
173
nextUserEdit: nextUserEdit
174
};
175
176
// Save to disk
177
const noEditExpected = nextUserEdit?.edit && typeof nextUserEdit.edit === 'object' && '__marker__' in nextUserEdit.edit && nextUserEdit.edit.__marker__ === 'NO_EDIT_EXPECTED';
178
await this._saveRecording(recording, this._state, noEditExpected);
179
180
const durationMs = Date.now() - this._state.startTime;
181
this._logger.info(`Capture confirmed and saved: durationMs=${durationMs}, hasEdit=${!noEditExpected}, noEditExpected=${noEditExpected}, trigger=${this._state.trigger}`);
182
183
if (noEditExpected) {
184
window.showInformationMessage('Captured: No edit expected (this is valid feedback!).');
185
} else {
186
window.showInformationMessage('Expected edit captured successfully!');
187
}
188
} catch (error) {
189
this._logger.error(error instanceof Error ? error : String(error), 'Error confirming capture');
190
window.showErrorMessage('Failed to save expected edit capture');
191
} finally {
192
await this.cleanup();
193
}
194
}
195
196
/**
197
* Abort the current capture session without saving.
198
*/
199
public async abortCapture(): Promise<void> {
200
if (!this._state?.active) {
201
return;
202
}
203
204
this._logger.info('Capture aborted');
205
await this.cleanup();
206
}
207
208
/**
209
* Clean up capture state and UI.
210
*/
211
private async cleanup(): Promise<void> {
212
this._state = undefined;
213
await commands.executeCommand('setContext', copilotNesCaptureMode, false);
214
this._disposeStatusBarItem();
215
}
216
217
/**
218
* Create and show the status bar item during capture with animated attention-grabbing effects.
219
*/
220
private _createStatusBarItem(): void {
221
if (this._statusBarItem) {
222
this._statusBarItem.dispose();
223
}
224
if (this._statusBarAnimationInterval) {
225
clearInterval(this._statusBarAnimationInterval);
226
}
227
228
this._statusBarItem = window.createStatusBarItem(StatusBarAlignment.Left, 10000); // High priority for visibility
229
this._statusBarItem.backgroundColor = new ThemeColor('statusBarItem.errorBackground');
230
231
// Rich markdown tooltip
232
const ctrlOrCmd = process.platform === 'darwin' ? 'Cmd' : 'Ctrl';
233
const tooltip = new MarkdownString();
234
tooltip.appendMarkdown('### 🔴 NES CAPTURE MODE ACTIVE\n\n');
235
tooltip.appendMarkdown('Type your expected edit, then:\n\n');
236
tooltip.appendMarkdown(`- **${ctrlOrCmd}+Enter** — Save your edits\n`);
237
tooltip.appendMarkdown(`- **${ctrlOrCmd}+Enter (empty)** — No edit expected\n`);
238
tooltip.appendMarkdown('- **Esc** — Cancel capture\n');
239
tooltip.isTrusted = true;
240
this._statusBarItem.tooltip = tooltip;
241
242
// Animated icons and text for attention
243
const icons = ['$(record)', '$(alert)', '$(warning)', '$(zap)'];
244
let iconIndex = 0;
245
let isExpanded = false;
246
247
const updateText = () => {
248
if (!this._statusBarItem) {
249
return;
250
}
251
const icon = icons[iconIndex];
252
if (isExpanded) {
253
this._statusBarItem.text = `${icon} NES CAPTURE MODE: ${ctrlOrCmd}+Enter=Save, Esc=Cancel ${icon}`;
254
} else {
255
this._statusBarItem.text = `${icon} NES CAPTURE MODE ACTIVE ${icon}`;
256
}
257
iconIndex = (iconIndex + 1) % icons.length;
258
isExpanded = !isExpanded;
259
};
260
261
updateText(); // Initial text
262
this._statusBarAnimationInterval = setInterval(updateText, 1000);
263
264
this._statusBarItem.show();
265
}
266
267
/**
268
* Dispose the status bar item and stop animation.
269
*/
270
private _disposeStatusBarItem(): void {
271
if (this._statusBarAnimationInterval) {
272
clearInterval(this._statusBarAnimationInterval);
273
this._statusBarAnimationInterval = undefined;
274
}
275
if (this._statusBarItem) {
276
this._statusBarItem.dispose();
277
this._statusBarItem = undefined;
278
}
279
}
280
281
/**
282
* Extract edits that occurred between two bookmarks for a specific document.
283
* Returns a special marker object with __marker__ field if no edits were made.
284
*/
285
private _extractEditsBetweenBookmarks(
286
logBefore: LogEntry[],
287
logAfter: LogEntry[],
288
targetDocId: DocumentId
289
): { relativePath: string; edit: ISerializedEdit | { __marker__: 'NO_EDIT_EXPECTED' } } | undefined {
290
// Find the numeric ID for our target document
291
let docNumericId: number | undefined;
292
let relativePath: string | undefined;
293
294
for (const entry of logBefore) {
295
if (entry.kind === 'documentEncountered') {
296
const entryPath = entry.relativePath;
297
// Check if this is our document by comparing paths
298
if (entryPath && this._pathMatchesDocument(entryPath, targetDocId)) {
299
docNumericId = entry.id;
300
relativePath = entry.relativePath;
301
break;
302
}
303
}
304
}
305
306
if (docNumericId === undefined || !relativePath) {
307
this._logger.trace('Could not find document in log');
308
return undefined;
309
}
310
311
// Get only the new entries (diff between logs)
312
const newEntries = logAfter.slice(logBefore.length);
313
314
// Filter for 'changed' entries on target document
315
const editEntries = newEntries.filter(e =>
316
e.kind === 'changed' && e.id === docNumericId
317
);
318
319
if (editEntries.length === 0) {
320
this._logger.trace('No edits found between bookmarks - marking as NO_EDIT_EXPECTED');
321
return {
322
relativePath,
323
edit: { __marker__: 'NO_EDIT_EXPECTED' as const }
324
};
325
}
326
327
// Compose all edits into one
328
let composedEdit: ISerializedEdit = [];
329
for (const entry of editEntries) {
330
if (entry.kind === 'changed') {
331
composedEdit = this._composeSerializedEdits(composedEdit, entry.edit);
332
}
333
}
334
335
return {
336
relativePath,
337
edit: composedEdit
338
};
339
}
340
341
/**
342
* Check if a relative path from the log matches a DocumentId.
343
*/
344
private _pathMatchesDocument(logPath: string, documentId: DocumentId): boolean {
345
// Simple comparison - both should be relative paths
346
// For notebook cells, the log path includes the fragment (e.g., "file.ipynb#cell0")
347
const docPath = documentId.path;
348
return logPath.endsWith(docPath) || docPath.endsWith(logPath);
349
}
350
351
/**
352
* Compose two serialized edits using StringEdit.compose.
353
*/
354
private _composeSerializedEdits(
355
first: ISerializedEdit,
356
second: ISerializedEdit
357
): ISerializedEdit {
358
const firstEdit = deserializeEdit(first);
359
const secondEdit = deserializeEdit(second);
360
const composed = firstEdit.compose(secondEdit);
361
return serializeEdit(composed);
362
}
363
364
/**
365
* Filter out documents that had no user interaction (background/virtual documents).
366
* Real documents will have user selection, visibility, or edit events.
367
* This removes startup noise like package.json files from node_modules that VS Code
368
* opens in the background, while preserving real workspace files that existed before capture.
369
*/
370
private _filterLogForNonInteractedDocuments(log: LogEntry[]): LogEntry[] {
371
// Collect document IDs that had actual user interaction
372
const interactedDocIds = new Set<number>();
373
374
for (const entry of log) {
375
// Documents with these events are "real" documents that the user interacted with
376
if (entry.kind === 'selectionChanged' ||
377
entry.kind === 'changed') {
378
if ('id' in entry && typeof entry.id === 'number') {
379
interactedDocIds.add(entry.id);
380
}
381
}
382
}
383
384
// Collect document IDs that should be excluded (no interaction)
385
const excludedDocIds = new Set<number>();
386
for (const entry of log) {
387
if (entry.kind === 'documentEncountered') {
388
if (!interactedDocIds.has(entry.id)) {
389
excludedDocIds.add(entry.id);
390
this._logger.trace(`Filtering out background document: ${entry.relativePath}`);
391
}
392
}
393
}
394
395
// Filter the log to exclude non-interactive documents
396
return log.filter(entry => {
397
if (entry.kind === 'header') {
398
return true;
399
}
400
if ('id' in entry && typeof entry.id === 'number') {
401
return !excludedDocIds.has(entry.id);
402
}
403
return true;
404
});
405
}
406
407
/**
408
* Save the recording to disk in .recording.w.json format.
409
*/
410
private async _saveRecording(
411
recording: { log: LogEntry[]; nextUserEdit?: { relativePath: string; edit: ISerializedEdit | { __marker__: 'NO_EDIT_EXPECTED' } } },
412
state: CaptureState,
413
noEditExpected: boolean = false
414
): Promise<void> {
415
const workspaceFolder = workspace.workspaceFolders?.[0];
416
if (!workspaceFolder) {
417
throw new Error('No workspace folder found');
418
}
419
420
// Create folder if it doesn't exist
421
const folderUri = Uri.joinPath(workspaceFolder.uri, ExpectedEditCaptureController.CAPTURE_FOLDER);
422
try {
423
await workspace.fs.createDirectory(folderUri);
424
} catch (error) {
425
// Ignore if already exists
426
}
427
428
// Generate filename with timestamp
429
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
430
const filename = `capture-${timestamp}.recording.w.json`;
431
const fileUri = Uri.joinPath(folderUri, filename);
432
433
// Write file
434
const content = JSON.stringify(recording, null, 2);
435
await workspace.fs.writeFile(fileUri, Buffer.from(content, 'utf8'));
436
437
// Optionally save metadata
438
await this._saveMetadata(folderUri, filename, state, noEditExpected);
439
440
this._logger.info(`Saved recording: path=${fileUri.fsPath}, noEditExpected=${noEditExpected}`);
441
}
442
443
/**
444
* Save additional metadata alongside the recording.
445
*/
446
private async _saveMetadata(
447
folderUri: Uri,
448
recordingFilename: string,
449
state: CaptureState,
450
noEditExpected: boolean = false
451
): Promise<void> {
452
const metadataFilename = recordingFilename.replace('.recording.w.json', '.metadata.json');
453
const metadataUri = Uri.joinPath(folderUri, metadataFilename);
454
455
const metadata = {
456
captureTimestamp: new Date(state.startTime).toISOString(),
457
trigger: state.trigger,
458
durationMs: Date.now() - state.startTime,
459
noEditExpected,
460
originalNesContext: state.originalNesMetadata
461
};
462
463
const content = JSON.stringify(metadata, null, 2);
464
await workspace.fs.writeFile(metadataUri, Buffer.from(content, 'utf8'));
465
}
466
467
/**
468
* Submit all captured NES feedback files to a private GitHub repository.
469
* Delegates to NesFeedbackSubmitter for file collection, filtering, and upload.
470
*/
471
public async submitCaptures(): Promise<void> {
472
const workspaceFolder = workspace.workspaceFolders?.[0];
473
if (!workspaceFolder) {
474
window.showErrorMessage('No workspace folder found');
475
return;
476
}
477
478
const feedbackFolderUri = Uri.joinPath(workspaceFolder.uri, ExpectedEditCaptureController.CAPTURE_FOLDER);
479
await this._feedbackSubmitter.submitFromFolder(feedbackFolderUri);
480
}
481
482
override dispose(): void {
483
// Ensure complete cleanup if disposed during active capture
484
if (this._state?.active) {
485
this._state = undefined;
486
// Note: Can't await in dispose, but this is best-effort cleanup
487
void commands.executeCommand('setContext', copilotNesCaptureMode, false);
488
}
489
this._disposeStatusBarItem();
490
super.dispose();
491
}
492
}
493
494