Path: blob/main/extensions/copilot/src/extension/inlineEdits/vscode-node/components/expectedEditCaptureController.ts
13405 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 { commands, MarkdownString, StatusBarAlignment, StatusBarItem, ThemeColor, Uri, window, workspace } from 'vscode';6import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';7import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';8import { DocumentId } from '../../../../platform/inlineEdits/common/dataTypes/documentId';9import { DebugRecorderBookmark } from '../../../../platform/inlineEdits/common/debugRecorderBookmark';10import { ILogger, ILogService } from '../../../../platform/log/common/logService';11import { IFetcherService } from '../../../../platform/networking/common/fetcherService';12import { deserializeEdit, ISerializedEdit, LogEntry, serializeEdit } from '../../../../platform/workspaceRecorder/common/workspaceLog';13import { Disposable } from '../../../../util/vs/base/common/lifecycle';14import { DebugRecorder } from '../../node/debugRecorder';15import { filterLogForSensitiveFiles } from './inlineEditDebugComponent';16import { NesFeedbackSubmitter } from './nesFeedbackSubmitter';1718export const copilotNesCaptureMode = 'copilotNesCaptureMode';1920interface CaptureState {21active: boolean;22startBookmark: DebugRecorderBookmark;23endBookmark?: DebugRecorderBookmark;24startDocumentId: DocumentId;25startTime: number;26trigger: 'rejection' | 'manual';27originalNesMetadata?: {28requestUuid: string;29providerInfo?: string;30modelName?: string;31endpointUrl?: string;32suggestionText?: string;33suggestionRange?: [number, number, number, number];34documentPath?: string;35};36}3738/**39* Controller for capturing expected edit suggestions from users when NES suggestions40* are rejected or don't appear. Leverages DebugRecorder's automatic edit tracking.41*/42export class ExpectedEditCaptureController extends Disposable {4344private static readonly CAPTURE_FOLDER = '.copilot/nes-feedback';4546private _state: CaptureState | undefined;47private _statusBarItem: StatusBarItem | undefined;48private _statusBarAnimationInterval: ReturnType<typeof setInterval> | undefined;49private readonly _feedbackSubmitter: NesFeedbackSubmitter;50private readonly _logger: ILogger;5152constructor(53private readonly _debugRecorder: DebugRecorder,54@IConfigurationService private readonly _configurationService: IConfigurationService,55@ILogService private readonly _logService: ILogService,56@IAuthenticationService private readonly _authenticationService: IAuthenticationService,57@IFetcherService private readonly _fetcherService: IFetcherService,58) {59super();60this._logger = this._logService.createSubLogger(['NES', 'Capture']);61this._feedbackSubmitter = new NesFeedbackSubmitter(62this._logService,63this._authenticationService,64this._fetcherService65);66}6768/**69* Check if the feature is enabled in settings.70*/71public get isEnabled(): boolean {72return this._configurationService.getConfig(ConfigKey.TeamInternal.RecordExpectedEditEnabled) ?? false;73}7475/**76* Check if automatic capture on rejection is enabled.77*/78public get captureOnReject(): boolean {79return this._configurationService.getConfig(ConfigKey.TeamInternal.RecordExpectedEditOnReject) ?? true;80}8182/**83* Check if a capture session is currently active.84*/85public get isCaptureActive(): boolean {86return this._state?.active ?? false;87}8889/**90* Start a capture session.91* @param trigger How the capture was initiated92* @param nesMetadata Optional metadata about the rejected NES suggestion93*/94public async startCapture(95trigger: 'rejection' | 'manual',96nesMetadata?: CaptureState['originalNesMetadata']97): Promise<void> {98if (!this.isEnabled) {99this._logger.trace('Feature disabled, ignoring start request');100return;101}102103if (this._state?.active) {104this._logger.trace('Capture already active, ignoring start request');105return;106}107108const editor = window.activeTextEditor;109if (!editor) {110this._logger.trace('No active editor, cannot start capture');111return;112}113114// Create bookmark to mark the start point115const startBookmark = this._debugRecorder.createBookmark();116const documentId = DocumentId.create(editor.document.uri.toString());117118this._state = {119active: true,120startBookmark,121startDocumentId: documentId,122startTime: Date.now(),123trigger,124originalNesMetadata: nesMetadata125};126127// Set context key to enable keybindings128await commands.executeCommand('setContext', copilotNesCaptureMode, true);129130// Show status bar message131this._createStatusBarItem();132133this._logger.info(`Started capture session: trigger=${trigger}, documentUri=${editor.document.uri.toString()}, hasMetadata=${!!nesMetadata}`);134}135136/**137* Confirm and save the capture.138*/139public async confirmCapture(): Promise<void> {140if (!this._state?.active) {141this._logger.trace('No active capture to confirm');142return;143}144145try {146// Create end bookmark147const endBookmark = this._debugRecorder.createBookmark();148this._state.endBookmark = endBookmark;149150// Get log slices151const logUpToStart = this._debugRecorder.getRecentLog(this._state.startBookmark);152const logUpToEnd = this._debugRecorder.getRecentLog(endBookmark);153154if (!logUpToStart || !logUpToEnd) {155this._logger.warn('Failed to retrieve logs from debug recorder');156await this.abortCapture();157return;158}159160// Extract edits between bookmarks161const nextUserEdit = this._extractEditsBetweenBookmarks(162logUpToStart,163logUpToEnd,164this._state.startDocumentId165);166167// Build recording168// Filter out both non-interacted documents and sensitive files (settings.json, .env)169const filteredLog = filterLogForSensitiveFiles(this._filterLogForNonInteractedDocuments(logUpToStart));170const recording = {171log: filteredLog,172nextUserEdit: nextUserEdit173};174175// Save to disk176const noEditExpected = nextUserEdit?.edit && typeof nextUserEdit.edit === 'object' && '__marker__' in nextUserEdit.edit && nextUserEdit.edit.__marker__ === 'NO_EDIT_EXPECTED';177await this._saveRecording(recording, this._state, noEditExpected);178179const durationMs = Date.now() - this._state.startTime;180this._logger.info(`Capture confirmed and saved: durationMs=${durationMs}, hasEdit=${!noEditExpected}, noEditExpected=${noEditExpected}, trigger=${this._state.trigger}`);181182if (noEditExpected) {183window.showInformationMessage('Captured: No edit expected (this is valid feedback!).');184} else {185window.showInformationMessage('Expected edit captured successfully!');186}187} catch (error) {188this._logger.error(error instanceof Error ? error : String(error), 'Error confirming capture');189window.showErrorMessage('Failed to save expected edit capture');190} finally {191await this.cleanup();192}193}194195/**196* Abort the current capture session without saving.197*/198public async abortCapture(): Promise<void> {199if (!this._state?.active) {200return;201}202203this._logger.info('Capture aborted');204await this.cleanup();205}206207/**208* Clean up capture state and UI.209*/210private async cleanup(): Promise<void> {211this._state = undefined;212await commands.executeCommand('setContext', copilotNesCaptureMode, false);213this._disposeStatusBarItem();214}215216/**217* Create and show the status bar item during capture with animated attention-grabbing effects.218*/219private _createStatusBarItem(): void {220if (this._statusBarItem) {221this._statusBarItem.dispose();222}223if (this._statusBarAnimationInterval) {224clearInterval(this._statusBarAnimationInterval);225}226227this._statusBarItem = window.createStatusBarItem(StatusBarAlignment.Left, 10000); // High priority for visibility228this._statusBarItem.backgroundColor = new ThemeColor('statusBarItem.errorBackground');229230// Rich markdown tooltip231const ctrlOrCmd = process.platform === 'darwin' ? 'Cmd' : 'Ctrl';232const tooltip = new MarkdownString();233tooltip.appendMarkdown('### 🔴 NES CAPTURE MODE ACTIVE\n\n');234tooltip.appendMarkdown('Type your expected edit, then:\n\n');235tooltip.appendMarkdown(`- **${ctrlOrCmd}+Enter** — Save your edits\n`);236tooltip.appendMarkdown(`- **${ctrlOrCmd}+Enter (empty)** — No edit expected\n`);237tooltip.appendMarkdown('- **Esc** — Cancel capture\n');238tooltip.isTrusted = true;239this._statusBarItem.tooltip = tooltip;240241// Animated icons and text for attention242const icons = ['$(record)', '$(alert)', '$(warning)', '$(zap)'];243let iconIndex = 0;244let isExpanded = false;245246const updateText = () => {247if (!this._statusBarItem) {248return;249}250const icon = icons[iconIndex];251if (isExpanded) {252this._statusBarItem.text = `${icon} NES CAPTURE MODE: ${ctrlOrCmd}+Enter=Save, Esc=Cancel ${icon}`;253} else {254this._statusBarItem.text = `${icon} NES CAPTURE MODE ACTIVE ${icon}`;255}256iconIndex = (iconIndex + 1) % icons.length;257isExpanded = !isExpanded;258};259260updateText(); // Initial text261this._statusBarAnimationInterval = setInterval(updateText, 1000);262263this._statusBarItem.show();264}265266/**267* Dispose the status bar item and stop animation.268*/269private _disposeStatusBarItem(): void {270if (this._statusBarAnimationInterval) {271clearInterval(this._statusBarAnimationInterval);272this._statusBarAnimationInterval = undefined;273}274if (this._statusBarItem) {275this._statusBarItem.dispose();276this._statusBarItem = undefined;277}278}279280/**281* Extract edits that occurred between two bookmarks for a specific document.282* Returns a special marker object with __marker__ field if no edits were made.283*/284private _extractEditsBetweenBookmarks(285logBefore: LogEntry[],286logAfter: LogEntry[],287targetDocId: DocumentId288): { relativePath: string; edit: ISerializedEdit | { __marker__: 'NO_EDIT_EXPECTED' } } | undefined {289// Find the numeric ID for our target document290let docNumericId: number | undefined;291let relativePath: string | undefined;292293for (const entry of logBefore) {294if (entry.kind === 'documentEncountered') {295const entryPath = entry.relativePath;296// Check if this is our document by comparing paths297if (entryPath && this._pathMatchesDocument(entryPath, targetDocId)) {298docNumericId = entry.id;299relativePath = entry.relativePath;300break;301}302}303}304305if (docNumericId === undefined || !relativePath) {306this._logger.trace('Could not find document in log');307return undefined;308}309310// Get only the new entries (diff between logs)311const newEntries = logAfter.slice(logBefore.length);312313// Filter for 'changed' entries on target document314const editEntries = newEntries.filter(e =>315e.kind === 'changed' && e.id === docNumericId316);317318if (editEntries.length === 0) {319this._logger.trace('No edits found between bookmarks - marking as NO_EDIT_EXPECTED');320return {321relativePath,322edit: { __marker__: 'NO_EDIT_EXPECTED' as const }323};324}325326// Compose all edits into one327let composedEdit: ISerializedEdit = [];328for (const entry of editEntries) {329if (entry.kind === 'changed') {330composedEdit = this._composeSerializedEdits(composedEdit, entry.edit);331}332}333334return {335relativePath,336edit: composedEdit337};338}339340/**341* Check if a relative path from the log matches a DocumentId.342*/343private _pathMatchesDocument(logPath: string, documentId: DocumentId): boolean {344// Simple comparison - both should be relative paths345// For notebook cells, the log path includes the fragment (e.g., "file.ipynb#cell0")346const docPath = documentId.path;347return logPath.endsWith(docPath) || docPath.endsWith(logPath);348}349350/**351* Compose two serialized edits using StringEdit.compose.352*/353private _composeSerializedEdits(354first: ISerializedEdit,355second: ISerializedEdit356): ISerializedEdit {357const firstEdit = deserializeEdit(first);358const secondEdit = deserializeEdit(second);359const composed = firstEdit.compose(secondEdit);360return serializeEdit(composed);361}362363/**364* Filter out documents that had no user interaction (background/virtual documents).365* Real documents will have user selection, visibility, or edit events.366* This removes startup noise like package.json files from node_modules that VS Code367* opens in the background, while preserving real workspace files that existed before capture.368*/369private _filterLogForNonInteractedDocuments(log: LogEntry[]): LogEntry[] {370// Collect document IDs that had actual user interaction371const interactedDocIds = new Set<number>();372373for (const entry of log) {374// Documents with these events are "real" documents that the user interacted with375if (entry.kind === 'selectionChanged' ||376entry.kind === 'changed') {377if ('id' in entry && typeof entry.id === 'number') {378interactedDocIds.add(entry.id);379}380}381}382383// Collect document IDs that should be excluded (no interaction)384const excludedDocIds = new Set<number>();385for (const entry of log) {386if (entry.kind === 'documentEncountered') {387if (!interactedDocIds.has(entry.id)) {388excludedDocIds.add(entry.id);389this._logger.trace(`Filtering out background document: ${entry.relativePath}`);390}391}392}393394// Filter the log to exclude non-interactive documents395return log.filter(entry => {396if (entry.kind === 'header') {397return true;398}399if ('id' in entry && typeof entry.id === 'number') {400return !excludedDocIds.has(entry.id);401}402return true;403});404}405406/**407* Save the recording to disk in .recording.w.json format.408*/409private async _saveRecording(410recording: { log: LogEntry[]; nextUserEdit?: { relativePath: string; edit: ISerializedEdit | { __marker__: 'NO_EDIT_EXPECTED' } } },411state: CaptureState,412noEditExpected: boolean = false413): Promise<void> {414const workspaceFolder = workspace.workspaceFolders?.[0];415if (!workspaceFolder) {416throw new Error('No workspace folder found');417}418419// Create folder if it doesn't exist420const folderUri = Uri.joinPath(workspaceFolder.uri, ExpectedEditCaptureController.CAPTURE_FOLDER);421try {422await workspace.fs.createDirectory(folderUri);423} catch (error) {424// Ignore if already exists425}426427// Generate filename with timestamp428const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);429const filename = `capture-${timestamp}.recording.w.json`;430const fileUri = Uri.joinPath(folderUri, filename);431432// Write file433const content = JSON.stringify(recording, null, 2);434await workspace.fs.writeFile(fileUri, Buffer.from(content, 'utf8'));435436// Optionally save metadata437await this._saveMetadata(folderUri, filename, state, noEditExpected);438439this._logger.info(`Saved recording: path=${fileUri.fsPath}, noEditExpected=${noEditExpected}`);440}441442/**443* Save additional metadata alongside the recording.444*/445private async _saveMetadata(446folderUri: Uri,447recordingFilename: string,448state: CaptureState,449noEditExpected: boolean = false450): Promise<void> {451const metadataFilename = recordingFilename.replace('.recording.w.json', '.metadata.json');452const metadataUri = Uri.joinPath(folderUri, metadataFilename);453454const metadata = {455captureTimestamp: new Date(state.startTime).toISOString(),456trigger: state.trigger,457durationMs: Date.now() - state.startTime,458noEditExpected,459originalNesContext: state.originalNesMetadata460};461462const content = JSON.stringify(metadata, null, 2);463await workspace.fs.writeFile(metadataUri, Buffer.from(content, 'utf8'));464}465466/**467* Submit all captured NES feedback files to a private GitHub repository.468* Delegates to NesFeedbackSubmitter for file collection, filtering, and upload.469*/470public async submitCaptures(): Promise<void> {471const workspaceFolder = workspace.workspaceFolders?.[0];472if (!workspaceFolder) {473window.showErrorMessage('No workspace folder found');474return;475}476477const feedbackFolderUri = Uri.joinPath(workspaceFolder.uri, ExpectedEditCaptureController.CAPTURE_FOLDER);478await this._feedbackSubmitter.submitFromFolder(feedbackFolderUri);479}480481override dispose(): void {482// Ensure complete cleanup if disposed during active capture483if (this._state?.active) {484this._state = undefined;485// Note: Can't await in dispose, but this is best-effort cleanup486void commands.executeCommand('setContext', copilotNesCaptureMode, false);487}488this._disposeStatusBarItem();489super.dispose();490}491}492493494