Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts
13399 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 * as l10n from '@vscode/l10n';
7
import { CancellationToken, Command, EndOfLine, InlineCompletionContext, InlineCompletionDisplayLocation, InlineCompletionDisplayLocationKind, InlineCompletionEndOfLifeReason, InlineCompletionEndOfLifeReasonKind, InlineCompletionItem, InlineCompletionItemProvider, InlineCompletionList, InlineCompletionModelInfo, InlineCompletionProviderOption, InlineCompletionsDisposeReason, InlineCompletionsDisposeReasonKind, NotebookCell, NotebookCellKind, Position, Range, TextDocument, TextDocumentShowOptions, Uri, window, workspace } from 'vscode';
8
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
9
import { IDiffService } from '../../../platform/diff/common/diffService';
10
import { stringEditFromDiff } from '../../../platform/editing/common/edit';
11
import { DocumentEditRecorder } from '../../../platform/editSurvivalTracking/common/editComputer';
12
import { EditSurvivalReporter } from '../../../platform/editSurvivalTracking/common/editSurvivalReporter';
13
import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService';
14
import { DocumentId } from '../../../platform/inlineEdits/common/dataTypes/documentId';
15
import { InlineEditRequestLogContext } from '../../../platform/inlineEdits/common/inlineEditLogContext';
16
import { IInlineEditsModelService } from '../../../platform/inlineEdits/common/inlineEditsModelService';
17
import { shortenOpportunityId } from '../../../platform/inlineEdits/common/utils/utils';
18
import { ILogger, ILogService } from '../../../platform/log/common/logService';
19
import { getNotebookId } from '../../../platform/notebook/common/helpers';
20
import { INotebookService } from '../../../platform/notebook/common/notebookService';
21
import { CapturingToken } from '../../../platform/requestLogger/common/capturingToken';
22
import { IRequestLogger } from '../../../platform/requestLogger/common/requestLogger';
23
import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';
24
import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';
25
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
26
import { findCell, findNotebook, isNotebookCell } from '../../../util/common/notebooks';
27
import { assert } from '../../../util/vs/base/common/assert';
28
import { raceCancellation, timeout } from '../../../util/vs/base/common/async';
29
import { CancellationTokenSource } from '../../../util/vs/base/common/cancellation';
30
import { BugIndicatingError, onUnexpectedError } from '../../../util/vs/base/common/errors';
31
import { Emitter } from '../../../util/vs/base/common/event';
32
import { Disposable } from '../../../util/vs/base/common/lifecycle';
33
import { clamp } from '../../../util/vs/base/common/numbers';
34
import { autorun, IObservable, observableFromEvent } from '../../../util/vs/base/common/observable';
35
import { basename } from '../../../util/vs/base/common/path';
36
import { StringEdit } from '../../../util/vs/editor/common/core/edits/stringEdit';
37
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
38
import { createCorrelationId } from '../common/correlationId';
39
import { NesChangeHint } from '../common/nesTriggerHint';
40
import { NESInlineCompletionContext } from '../node/nextEditProvider';
41
import { NextEditProviderTelemetryBuilder, TelemetrySender } from '../node/nextEditProviderTelemetry';
42
import { INextEditResult, NextEditResult } from '../node/nextEditResult';
43
import { ExpectedEditCaptureController } from './components/expectedEditCaptureController';
44
import { InlineCompletionCommand, InlineEditDebugComponent } from './components/inlineEditDebugComponent';
45
import { LogContextRecorder } from './components/logContextRecorder';
46
import { DiagnosticsNextEditResult } from './features/diagnosticsInlineEditProvider';
47
import { InlineEditModel } from './inlineEditModel';
48
import { learnMoreCommandId, learnMoreLink } from './inlineEditProviderFeature';
49
import { toInlineSuggestion } from './isInlineSuggestion';
50
import { LineCheck } from './naturalLanguageHint';
51
import { InlineEditLogger } from './parts/inlineEditLogger';
52
import { IVSCodeObservableDocument } from './parts/vscodeWorkspace';
53
import { raceAndAll } from './raceAndAll';
54
import { toExternalRange } from './utils/translations';
55
56
const learnMoreAction: Command = {
57
title: l10n.t('Learn More'),
58
command: learnMoreCommandId,
59
tooltip: learnMoreLink
60
};
61
62
export interface NesCompletionItem extends InlineCompletionItem {
63
readonly telemetryBuilder: NextEditProviderTelemetryBuilder;
64
readonly info: NesCompletionInfo;
65
wasShown: boolean;
66
isEditInAnotherDocument?: boolean;
67
/**
68
* Whether the underlying NES suggestion is being served as an inline (ghost
69
* text at cursor) suggestion as opposed to a non-inline NES (e.g. gutter or
70
* side hint). Used by the "mimic ghost text behavior" gating.
71
*/
72
isInlineCompletion?: boolean;
73
}
74
75
export class NesCompletionList extends InlineCompletionList {
76
77
public override enableForwardStability = true;
78
79
constructor(
80
public readonly requestUuid: string,
81
item: NesCompletionItem | undefined,
82
public override readonly commands: InlineCompletionCommand[],
83
public readonly telemetryBuilder: NextEditProviderTelemetryBuilder,
84
) {
85
super(item === undefined ? [] : [item]);
86
}
87
}
88
89
abstract class BaseNesCompletionInfo<T extends INextEditResult> {
90
91
public abstract source: string;
92
93
constructor(
94
public readonly suggestion: T,
95
public readonly documentId: DocumentId,
96
public readonly document: TextDocument,
97
public readonly requestUuid: string
98
) { }
99
}
100
101
class LlmCompletionInfo extends BaseNesCompletionInfo<NextEditResult> {
102
public readonly source = 'provider';
103
}
104
105
class DiagnosticsCompletionInfo extends BaseNesCompletionInfo<DiagnosticsNextEditResult> {
106
public readonly source = 'diagnostics';
107
}
108
109
type NesCompletionInfo = LlmCompletionInfo | DiagnosticsCompletionInfo;
110
111
function isLlmCompletionInfo(item: NesCompletionInfo): item is LlmCompletionInfo {
112
return item.source === 'provider';
113
}
114
115
const GoToNextEdit = l10n.t('Go To Inline Suggestion');
116
117
118
export class InlineCompletionProviderImpl extends Disposable implements InlineCompletionItemProvider {
119
public readonly displayName = 'Inline Suggestion';
120
121
private readonly _logger: ILogger;
122
123
public readonly onDidChange = this.model.onChange;
124
public readonly handleDidPartiallyAcceptCompletionItem = undefined;
125
public readonly handleDidRejectCompletionItem = undefined;
126
127
//#region Model picker
128
private _isModelPickerEnabled: IObservable<boolean> = this._configurationService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.InlineEditsModelPickerEnabled, this._expService);
129
130
public modelInfo: InlineCompletionModelInfo | undefined;
131
132
private readonly _onDidChangeModelInfo = this._register(new Emitter<void>());
133
public onDidChangeModelInfo = this._onDidChangeModelInfo.event;
134
135
public setCurrentModelId: ((modelId: string) => Thenable<void>) | undefined;
136
//#endregion
137
138
//#region Provider options (Eagerness)
139
private static readonly _aggressivenessOptionId = 'eagerness';
140
141
providerOptions: readonly InlineCompletionProviderOption[] | undefined;
142
143
private readonly _onDidChangeProviderOptions = this._register(new Emitter<void>());
144
readonly onDidChangeProviderOptions = this._onDidChangeProviderOptions.event;
145
146
setProviderOptionValue = async (optionId: string, valueId: string): Promise<void> => {
147
if (optionId === InlineCompletionProviderImpl._aggressivenessOptionId) {
148
await this._configurationService.setConfig(ConfigKey.Advanced.InlineEditsAggressiveness, valueId);
149
}
150
};
151
//#endregion
152
153
private readonly _displayNextEditorNES: boolean;
154
private readonly _renameSymbolSuggestions: IObservable<boolean>;
155
private readonly _inlineCompletionsAdvanced: IObservable<boolean>;
156
private readonly _nesMimicGhostTextBehavior: IObservable<boolean>;
157
158
constructor(
159
private readonly model: InlineEditModel,
160
private readonly logger: InlineEditLogger,
161
private readonly logContextRecorder: LogContextRecorder | undefined,
162
private readonly inlineEditDebugComponent: InlineEditDebugComponent | undefined,
163
private readonly telemetrySender: TelemetrySender,
164
private readonly expectedEditCaptureController: ExpectedEditCaptureController,
165
@IInstantiationService private readonly _instantiationService: IInstantiationService,
166
@ITelemetryService private readonly _telemetryService: ITelemetryService,
167
@IDiffService private readonly _diffService: IDiffService,
168
@IConfigurationService private readonly _configurationService: IConfigurationService,
169
@ILogService private readonly _logService: ILogService,
170
@IExperimentationService private readonly _expService: IExperimentationService,
171
@IGitExtensionService private readonly _gitExtensionService: IGitExtensionService,
172
@INotebookService private readonly _notebookService: INotebookService,
173
@IWorkspaceService private readonly _workspaceService: IWorkspaceService,
174
@IRequestLogger private readonly _requestLogger: IRequestLogger,
175
@IInlineEditsModelService private readonly _modelService: IInlineEditsModelService,
176
) {
177
super();
178
this._logger = this._logService.createSubLogger(['NES', 'Provider']);
179
this._displayNextEditorNES = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.UseAlternativeNESNotebookFormat, this._expService);
180
this._renameSymbolSuggestions = this._configurationService.getExperimentBasedConfigObservable(ConfigKey.Advanced.InlineEditsRenameSymbolSuggestions, this._expService);
181
this._inlineCompletionsAdvanced = this._configurationService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.InlineEditsInlineCompletionsAdvanced, this._expService);
182
this._nesMimicGhostTextBehavior = this._configurationService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.InlineEditsNesMimicGhostTextBehavior, this._expService);
183
184
this.setCurrentModelId = (modelId: string) => this._modelService.setCurrentModelId(modelId);
185
186
const modelListUpdatedObs = observableFromEvent(this, this._modelService.onModelListUpdated, () => this._modelService.modelInfo);
187
188
this._register(autorun(reader => {
189
this.modelInfo = this._isModelPickerEnabled.read(reader) ? modelListUpdatedObs.read(reader) : undefined;
190
this._onDidChangeModelInfo.fire();
191
}));
192
193
// Provider options: eagerness
194
const aggressivenessObs = this._configurationService.getExperimentBasedConfigObservable(ConfigKey.Advanced.InlineEditsAggressiveness, this._expService);
195
196
this._register(autorun(reader => {
197
const current = aggressivenessObs.read(reader);
198
this.providerOptions = [{
199
id: InlineCompletionProviderImpl._aggressivenessOptionId,
200
label: l10n.t('Eagerness'),
201
values: [
202
{ id: 'auto', label: l10n.t('Auto') },
203
{ id: 'low', label: l10n.t('Low') },
204
{ id: 'medium', label: l10n.t('Medium') },
205
{ id: 'high', label: l10n.t('High') },
206
],
207
currentValueId: current,
208
}];
209
this._onDidChangeProviderOptions.fire();
210
}));
211
212
}
213
214
// copied from `vscodeWorkspace.ts` `DocumentFilter#_enabledLanguages`
215
private _isCompletionsEnabled(document: TextDocument): boolean {
216
const enabledLanguages = this._configurationService.getConfig(ConfigKey.Enable);
217
const enabledLanguagesMap = new Map(Object.entries(enabledLanguages));
218
if (!enabledLanguagesMap.has('*')) {
219
enabledLanguagesMap.set('*', false);
220
}
221
return enabledLanguagesMap.has(document.languageId) ? enabledLanguagesMap.get(document.languageId)! : enabledLanguagesMap.get('*')!;
222
}
223
224
public async provideInlineCompletionItems(
225
document: TextDocument,
226
position: Position,
227
context: InlineCompletionContext | NESInlineCompletionContext,
228
token: CancellationToken
229
): Promise<NesCompletionList | undefined> {
230
const label = `NES | ${basename(document.uri.fsPath)} (v${document.version})`;
231
232
const capturingToken = new CapturingToken(label, undefined);
233
234
assert(context.changeHint === undefined || NesChangeHint.is(context.changeHint), 'Expected changeHint to be of type TriggerNes or undefined');
235
const changeHint = context.changeHint as NesChangeHint | undefined;
236
const nesContext: NESInlineCompletionContext = { enforceCacheDelay: true, ...context, changeHint };
237
238
return this._requestLogger.captureInvocation(capturingToken, () => this._provideInlineCompletionItems(document, position, nesContext, token));
239
}
240
241
private async _provideInlineCompletionItems(
242
document: TextDocument,
243
position: Position,
244
context: NESInlineCompletionContext,
245
token: CancellationToken
246
): Promise<NesCompletionList | undefined> {
247
const logger = this._logger.createSubLogger(['provideInlineCompletionItems', shortenOpportunityId(context.requestUuid)]);
248
249
// Disable NES while capture mode is active to avoid interference
250
if (this.expectedEditCaptureController.isCaptureActive) {
251
logger.trace('Return: capture mode active');
252
return undefined;
253
}
254
255
const isCompletionsEnabled = this._isCompletionsEnabled(document);
256
257
const unification = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsUnification, this._expService);
258
259
const isInlineEditsEnabled = this._configurationService.getExperimentBasedConfig(ConfigKey.InlineEditsEnabled, this._expService, { languageId: document.languageId });
260
261
const serveAsCompletionsProvider = unification && isCompletionsEnabled && !isInlineEditsEnabled;
262
263
if (!isInlineEditsEnabled && !serveAsCompletionsProvider) {
264
logger.trace('Return: inline edits disabled');
265
return undefined;
266
}
267
268
const ignoreWhenSuggestVisible = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsIgnoreWhenSuggestVisible, this._expService);
269
270
if (ignoreWhenSuggestVisible && context.selectedCompletionInfo && !unification) {
271
logger.trace('Return: suggest widget is showing, not providing NES');
272
return undefined;
273
}
274
275
const doc = this.model.workspace.getDocumentByTextDocument(document);
276
if (!doc) {
277
logger.trace('Return: document not found in workspace');
278
return undefined;
279
}
280
281
const documentVersion = (isNotebookCell(document.uri) ? findNotebook(document.uri, workspace.notebookDocuments)?.version : undefined) || document.version;
282
const logContext = new InlineEditRequestLogContext(doc.id.uri, documentVersion, context);
283
logContext.recordingBookmark = this.model.debugRecorder.createBookmark();
284
this.logger.addLive(logContext);
285
286
const telemetryBuilder = new NextEditProviderTelemetryBuilder(this._gitExtensionService, this._notebookService, this._workspaceService, this.model.nextEditProvider.ID, doc, this.model.debugRecorder, logContext.recordingBookmark);
287
telemetryBuilder.setOpportunityId(context.requestUuid);
288
telemetryBuilder.setConfigIsDiagnosticsNESEnabled(!!this.model.diagnosticsBasedProvider);
289
telemetryBuilder.setIsNaturalLanguageDominated(LineCheck.isNaturalLanguageDominated(document, position));
290
291
const requestCancellationTokenSource = new CancellationTokenSource(token);
292
let suggestionInfo: NesCompletionInfo | undefined;
293
try {
294
logger.trace('invoking next edit provider');
295
296
const { first, all } = raceAndAll([
297
this.model.nextEditProvider.getNextEdit(doc.id, context, logContext, token, telemetryBuilder.nesBuilder).then(r => ({ kind: 'llm' as const, val: r })),
298
(this.model.diagnosticsBasedProvider?.runUntilNextEdit(doc.id, context, logContext, 50, requestCancellationTokenSource.token, telemetryBuilder.diagnosticsBuilder)
299
?? raceCancellation(new Promise<undefined>(() => { }), requestCancellationTokenSource.token)).then(r => ({ kind: 'diagnostics' as const, val: r }))
300
], onUnexpectedError);
301
302
const [llmSuggestion, diagnosticsSuggestion] = await first;
303
304
let suggestion: {
305
kind: 'llm';
306
val: NextEditResult;
307
} | {
308
kind: 'diagnostics';
309
val: DiagnosticsNextEditResult | undefined;
310
};
311
312
if (llmSuggestion !== undefined) {
313
if (llmSuggestion.val.result !== undefined || this.model.diagnosticsBasedProvider === undefined) {
314
suggestion = llmSuggestion;
315
} else {
316
logger.trace('giving some more time to diagnostics provider');
317
const remainingTime = clamp(1250 - (Date.now() - context.requestIssuedDateTime), 0, 1250);
318
timeout(remainingTime).then(() => requestCancellationTokenSource.cancel());
319
[, suggestion] = await all;
320
}
321
} else if (diagnosticsSuggestion !== undefined) {
322
if (diagnosticsSuggestion.val !== undefined && diagnosticsSuggestion.val.result !== undefined) {
323
suggestion = diagnosticsSuggestion;
324
} else {
325
[suggestion] = await all;
326
}
327
} else {
328
throw new BugIndicatingError('At least one of LLM NES or Diagnostics NES must be defined');
329
}
330
331
// Cancel ongoing requests
332
requestCancellationTokenSource.cancel();
333
334
const emptyList = new NesCompletionList(context.requestUuid, undefined, [], telemetryBuilder);
335
const isFromCursorJump = suggestion.kind === 'llm' && (
336
// edit came using cursor jump
337
!!(suggestion.val.result?.isFromCursorJump) ||
338
// no edit but cursor jump suggested jumping to a certain position
339
!!(suggestion.val.result?.jumpToPosition)
340
);
341
const correlationId = createCorrelationId('nes', { isFromCursorJump });
342
343
if (token.isCancellationRequested) {
344
logger.trace('Return: lost race to cancellation');
345
this.telemetrySender.scheduleSendingEnhancedTelemetry({ requestId: logContext.requestId, result: undefined }, telemetryBuilder);
346
return emptyList;
347
}
348
349
// Determine which suggestion to use
350
if (suggestion.kind === 'diagnostics' && suggestion.val && suggestion.val.result) {
351
suggestionInfo = new DiagnosticsCompletionInfo(suggestion.val, doc.id, document, context.requestUuid);
352
} else if (suggestion.kind === 'llm') {
353
suggestionInfo = new LlmCompletionInfo(suggestion.val, doc.id, document, context.requestUuid);
354
} else {
355
this.telemetrySender.scheduleSendingEnhancedTelemetry({ requestId: logContext.requestId, result: undefined }, telemetryBuilder);
356
return emptyList;
357
}
358
359
if (suggestionInfo.source === 'provider' && suggestionInfo.suggestion.result?.jumpToPosition !== undefined) {
360
logger.trace('next edit suggestion only has jumpToPosition');
361
this.telemetrySender.scheduleSendingEnhancedTelemetry(suggestionInfo.suggestion, telemetryBuilder);
362
const positionToJumpOneBased = suggestionInfo.suggestion.result.jumpToPosition;
363
const jumpToPosition = new Position(positionToJumpOneBased.lineNumber - 1, positionToJumpOneBased.column - 1);
364
const targetDocumentId = suggestionInfo.suggestion.result.targetDocumentId;
365
telemetryBuilder.setIsNESForOtherEditor(!!targetDocumentId && targetDocumentId !== doc.id);
366
const jumpToPositionCompletionItem: NesCompletionItem = {
367
insertText: undefined as unknown as string,
368
info: suggestionInfo,
369
wasShown: false,
370
telemetryBuilder,
371
jumpToPosition,
372
...(targetDocumentId ? { uri: Uri.parse(targetDocumentId.uri) } : {}),
373
correlationId
374
};
375
return new NesCompletionList(context.requestUuid, jumpToPositionCompletionItem, [], telemetryBuilder);
376
}
377
378
// Return and send telemetry if there is no result
379
const result = suggestionInfo.suggestion.result;
380
if (!result || !result.edit) {
381
logger.trace('no next edit suggestion');
382
this.telemetrySender.scheduleSendingEnhancedTelemetry(suggestionInfo.suggestion, telemetryBuilder);
383
return emptyList;
384
}
385
386
logger.trace(`using next edit suggestion from ${suggestionInfo.source}`);
387
let isInlineCompletion: boolean = false;
388
let completionItem: Omit<NesCompletionItem, 'telemetryBuilder' | 'info' | 'showInlineEditMenu' | 'action' | 'wasShown' | 'isInlineEdit'> | undefined;
389
390
// When the edit targets a different document, resolve the range against the target document
391
const targetDocumentId = isLlmCompletionInfo(suggestionInfo) ? suggestionInfo.suggestion.result?.targetDocumentId : undefined;
392
let resolveDoc = doc;
393
if (targetDocumentId && targetDocumentId !== doc.id) {
394
const targetTextDoc = this._workspaceService.textDocuments.find(d => d.uri.toString() === targetDocumentId.uri);
395
const targetObsDoc = targetTextDoc ? this.model.workspace.getDocumentByTextDocument(targetTextDoc) : undefined;
396
if (targetObsDoc) {
397
resolveDoc = targetObsDoc;
398
} else {
399
logger.trace('no next edit suggestion: cross-file target document not found in workspace');
400
telemetryBuilder.setIsNESForOtherEditor(true);
401
telemetryBuilder.setStatus('noEdit:crossFileTargetNotFound');
402
this.telemetrySender.scheduleSendingEnhancedTelemetry(suggestionInfo.suggestion, telemetryBuilder);
403
return emptyList;
404
}
405
}
406
const documents = resolveDoc.fromOffsetRange(result.edit.replaceRange);
407
const [targetDocument, range] = documents.length ? documents[0] : [undefined, undefined];
408
409
addNotebookTelemetry(document, position, result.edit.newText, documents, telemetryBuilder);
410
telemetryBuilder.setIsNESForOtherEditor(targetDocument !== undefined && targetDocument !== document);
411
telemetryBuilder.setIsActiveDocument(window.activeTextEditor?.document === targetDocument);
412
413
if (!targetDocument) {
414
logger.trace('no next edit suggestion');
415
} else if (hasNotebookCellMarker(document, result.edit.newText)) {
416
logger.trace('no next edit suggestion, edits contain Notebook Cell Markers');
417
} else if (isNotebookCell(targetDocument.uri) && this._displayNextEditorNES && targetDocument !== document) {
418
// NES is for a different notebook cell
419
completionItem = serveAsCompletionsProvider ?
420
undefined :
421
this.createNextEditorEditCompletionItem(position, {
422
document: targetDocument,
423
insertText: result.edit.newText,
424
range
425
});
426
} else if (targetDocument === document) { // NES is for the active document
427
const allowInlineCompletions = this.model.inlineEditsInlineCompletionsEnabled.get();
428
const inlineSuggestion = allowInlineCompletions ? toInlineSuggestion(position, document, range, result.edit.newText, this._inlineCompletionsAdvanced.get()) : undefined;
429
isInlineCompletion = !!inlineSuggestion;
430
completionItem = serveAsCompletionsProvider && !isInlineCompletion ?
431
undefined :
432
this.createCompletionItem(doc, document, inlineSuggestion?.range ?? range, result, inlineSuggestion?.newText);
433
} else { // NES is not for the active doc but a different one
434
completionItem = serveAsCompletionsProvider ? undefined : {
435
range,
436
insertText: result.edit.newText,
437
command: result.action,
438
uri: targetDocument.uri,
439
};
440
}
441
442
// Gate: when the "mimic ghost text behavior" setting is on, a cached suggestion
443
// that was previously rendered as an inline (ghost text) suggestion must not
444
// re-surface in any other form. Suppress here without evicting the cache entry —
445
// when the cursor returns to an inline-renderable position, we'll serve it again.
446
if (
447
this._nesMimicGhostTextBehavior.get()
448
&& !isInlineCompletion
449
&& isLlmCompletionInfo(suggestionInfo)
450
&& suggestionInfo.suggestion.result?.cacheEntry?.wasRenderedAsInlineSuggestion
451
) {
452
logger.trace('Return: previously shown as inline; current context cannot render as inline');
453
telemetryBuilder.setStatus('noEdit:suppressedNonInlineReshow');
454
this.telemetrySender.scheduleSendingEnhancedTelemetry(suggestionInfo.suggestion, telemetryBuilder);
455
return emptyList;
456
}
457
458
if (!completionItem) {
459
this.telemetrySender.scheduleSendingEnhancedTelemetry(suggestionInfo.suggestion, telemetryBuilder);
460
return emptyList;
461
}
462
463
const menuCommands: InlineCompletionCommand[] = [];
464
if (this.inlineEditDebugComponent) {
465
menuCommands.push(...this.inlineEditDebugComponent.getCommands(logContext));
466
}
467
468
469
// telemetry
470
telemetryBuilder.setPickedNESType(suggestionInfo.source === 'diagnostics' ? 'diagnostics' : 'llm');
471
logContext.setPickedNESType(suggestionInfo.source === 'diagnostics' ? 'diagnostics' : 'llm');
472
telemetryBuilder.setPostProcessingOutcome({ edit: result.edit, displayLocation: result.displayLocation, isInlineCompletion });
473
telemetryBuilder.setHadLlmNES(suggestionInfo.source === 'provider');
474
telemetryBuilder.setHadDiagnosticsNES(suggestionInfo.source === 'diagnostics');
475
all.then(([llmResult, diagnosticsResult]) => {
476
telemetryBuilder.setHadLlmNES(!!llmResult?.val);
477
telemetryBuilder.setHadDiagnosticsNES(!!diagnosticsResult?.val);
478
});
479
480
this.telemetrySender.scheduleSendingEnhancedTelemetry(suggestionInfo.suggestion, telemetryBuilder);
481
482
const supportsRename = (document.languageId === 'typescript' || document.languageId === 'typescriptreact') && this._renameSymbolSuggestions.get();
483
484
const nesCompletionItem: NesCompletionItem = {
485
...completionItem,
486
info: suggestionInfo,
487
telemetryBuilder,
488
action: learnMoreAction,
489
isInlineEdit: !isInlineCompletion,
490
isInlineCompletion,
491
showInlineEditMenu: !(unification && isInlineCompletion),
492
wasShown: false,
493
supportsRename,
494
correlationId,
495
};
496
497
return new NesCompletionList(context.requestUuid, nesCompletionItem, menuCommands, telemetryBuilder);
498
} catch (e) {
499
logger.trace(`error: ${e}`);
500
logContext.setError(e);
501
502
try {
503
this.telemetrySender.sendTelemetry(suggestionInfo?.suggestion, telemetryBuilder);
504
} finally {
505
telemetryBuilder.dispose();
506
}
507
508
throw e;
509
} finally {
510
logContext.markCompleted();
511
requestCancellationTokenSource.dispose();
512
}
513
}
514
515
private createNextEditorEditCompletionItem(requestingPosition: Position,
516
nextEdit: { document: TextDocument; range: Range; insertText: string }
517
): Omit<NesCompletionItem, 'telemetryBuilder' | 'info' | 'showInlineEditMenu' | 'action' | 'wasShown' | 'isInlineEdit'> {
518
// Display the next edit in the current document, but with a command to open the next edit in the other document.
519
// & range of this completion item will be the same as the current documents cursor position.
520
const range = new Range(requestingPosition, requestingPosition);
521
const displayLocation: InlineCompletionDisplayLocation = {
522
range,
523
label: GoToNextEdit,
524
kind: InlineCompletionDisplayLocationKind.Label
525
};
526
527
const commandArgs: TextDocumentShowOptions = {
528
preserveFocus: false,
529
selection: new Range(nextEdit.range.start, nextEdit.range.start)
530
};
531
const command: Command = {
532
command: 'vscode.open',
533
title: GoToNextEdit,
534
arguments: [nextEdit.document.uri, commandArgs]
535
};
536
return {
537
range,
538
insertText: nextEdit.insertText,
539
showRange: range,
540
command,
541
displayLocation,
542
isEditInAnotherDocument: true
543
};
544
}
545
546
private createCompletionItem(
547
doc: IVSCodeObservableDocument,
548
document: TextDocument,
549
range: Range,
550
result: NonNullable<(NextEditResult | DiagnosticsNextEditResult)['result']>,
551
insertTextOverride?: string,
552
): Omit<NesCompletionItem, 'telemetryBuilder' | 'info' | 'showInlineEditMenu' | 'action' | 'wasShown' | 'isInlineEdit'> | undefined {
553
554
if (!result.edit) {
555
return undefined;
556
}
557
558
const displayLocationRange = result.displayLocation && doc.fromRange(document, toExternalRange(result.displayLocation.range));
559
const displayLocation: InlineCompletionDisplayLocation | undefined = result.displayLocation && displayLocationRange ? {
560
range: displayLocationRange,
561
label: result.displayLocation.label,
562
kind: InlineCompletionDisplayLocationKind.Code,
563
} : undefined;
564
565
566
return {
567
range,
568
insertText: insertTextOverride ?? result.edit.newText,
569
displayLocation,
570
command: result.action,
571
};
572
}
573
574
public handleDidShowCompletionItem(completionItem: NesCompletionItem, _updatedInsertText: string): void {
575
completionItem.wasShown = true;
576
completionItem.telemetryBuilder.setAsShown();
577
578
const info = completionItem.info;
579
this.logContextRecorder?.handleShown(info.suggestion);
580
581
if (isLlmCompletionInfo(info)) {
582
// Mark the underlying cache entry as having been rendered as an inline
583
// (ghost text) suggestion. The "mimic ghost text behavior" gate uses this
584
// flag to suppress re-serving the same suggestion in a non-inline form.
585
if (completionItem.isInlineCompletion) {
586
const cacheEntry = info.suggestion.result?.cacheEntry;
587
if (cacheEntry) {
588
cacheEntry.wasRenderedAsInlineSuggestion = true;
589
}
590
}
591
this.model.nextEditProvider.handleShown(info.suggestion);
592
} else {
593
this.model.diagnosticsBasedProvider?.handleShown(info.suggestion);
594
}
595
}
596
597
public handleListEndOfLifetime(list: NesCompletionList, reason: InlineCompletionsDisposeReason): void {
598
const logger = this._logger.createSubLogger(['handleListEndOfLifetime', shortenOpportunityId(list.requestUuid)]);
599
logger.trace(`List ${list.requestUuid} disposed, reason: ${InlineCompletionsDisposeReasonKind[reason.kind]}`);
600
601
const telemetryBuilder = list.telemetryBuilder;
602
603
const disposeReasonStr = InlineCompletionsDisposeReasonKind[reason.kind];
604
605
telemetryBuilder.setDisposalReason(disposeReasonStr);
606
607
this.telemetrySender.sendTelemetryForBuilder(telemetryBuilder);
608
}
609
610
public handleEndOfLifetime(item: NesCompletionItem, reason: InlineCompletionEndOfLifeReason): void {
611
const logger = this._logger.createSubLogger(['handleEndOfLifetime', shortenOpportunityId(item.info.requestUuid)]);
612
logger.trace(`reason: ${InlineCompletionEndOfLifeReasonKind[reason.kind]}`);
613
614
switch (reason.kind) {
615
case InlineCompletionEndOfLifeReasonKind.Accepted: {
616
this._handleAcceptance(item);
617
break;
618
}
619
case InlineCompletionEndOfLifeReasonKind.Rejected: {
620
this._handleDidRejectCompletionItem(item);
621
622
// Trigger expected edit capture if enabled
623
if (this.expectedEditCaptureController.isEnabled && this.expectedEditCaptureController.captureOnReject) {
624
// Get endpoint info from the log context if available (LLM suggestions only)
625
const endpointInfo = isLlmCompletionInfo(item.info) ? item.info.suggestion.source.log.endpointInfo : undefined;
626
const metadata = {
627
requestUuid: item.info.requestUuid,
628
providerInfo: item.info.source,
629
modelName: endpointInfo?.modelName,
630
endpointUrl: endpointInfo?.url,
631
suggestionText: item.insertText?.toString(),
632
suggestionRange: item.range ? [
633
item.range.start.line,
634
item.range.start.character,
635
item.range.end.line,
636
item.range.end.character
637
] as [number, number, number, number] : undefined,
638
documentPath: item.info.documentId.path
639
};
640
void this.expectedEditCaptureController.startCapture('rejection', metadata);
641
}
642
643
break;
644
}
645
case InlineCompletionEndOfLifeReasonKind.Ignored: {
646
const supersededBy = reason.supersededBy ? (reason.supersededBy as NesCompletionItem) : undefined;
647
logger.trace(`Superseded by: ${supersededBy?.info.requestUuid || 'none'}, was shown: ${item.wasShown}`);
648
if (supersededBy) {
649
/* __GDPR__
650
"supersededInlineEdit" : {
651
"owner": "ulugbekna",
652
"comment": "Tracks when an inline edit was superseded by another edit.",
653
"opportunityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The opportunity ID of the original inline edit." },
654
"supersededByOpportunityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The opportunity ID of the inline edit that superseded the original edit." }
655
}
656
*/
657
this._telemetryService.sendMSFTTelemetryEvent('supersededInlineEdit', { opportunityId: item.info.requestUuid, supersededByOpportunityId: supersededBy.info.requestUuid });
658
}
659
this._handleDidIgnoreCompletionItem(item, supersededBy);
660
break;
661
}
662
}
663
}
664
665
private _handleAcceptance(item: NesCompletionItem) {
666
this.logContextRecorder?.handleAcceptance(item.info.suggestion);
667
668
item.telemetryBuilder.setAcceptance('accepted');
669
item.telemetryBuilder.setStatus('accepted');
670
671
const info = item.info;
672
if (isLlmCompletionInfo(info)) {
673
this.model.nextEditProvider.handleAcceptance(info.documentId, info.suggestion);
674
if (!item.isEditInAnotherDocument) {
675
this._trackSurvivalRate(info);
676
}
677
} else {
678
this.model.diagnosticsBasedProvider?.handleAcceptance(info.documentId, info.suggestion);
679
}
680
}
681
682
// TODO: Support tracking Diagnostics NES
683
private async _trackSurvivalRate(item: LlmCompletionInfo) {
684
const result = item.suggestion.result;
685
if (!result || !result.edit) {
686
return;
687
}
688
689
const docBeforeEdits = result.documentBeforeEdits.value;
690
const docAfterEdits = result.edit.toEdit().apply(docBeforeEdits);
691
692
const recorder = this._instantiationService.createInstance(DocumentEditRecorder, item.document);
693
694
// Assumption: The user cannot edit the document while the inline edit is being applied
695
let userEdits = StringEdit.empty;
696
// softAssert(docAfterEdits === userEdits.apply(item.document.getText())); // TODO@hediet
697
698
const diffedNextEdit = await stringEditFromDiff(docBeforeEdits, docAfterEdits, this._diffService);
699
const recordedEdits = recorder.getEdits();
700
701
userEdits = userEdits.compose(recordedEdits);
702
703
this._instantiationService.createInstance(
704
EditSurvivalReporter,
705
item.document,
706
result.documentBeforeEdits.value,
707
diffedNextEdit,
708
userEdits,
709
{ includeArc: true },
710
res => {
711
/* __GDPR__
712
"reportInlineEditSurvivalRate" : {
713
"owner": "hediet",
714
"comment": "Reports the survival rate for an inline edit.",
715
"opportunityId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Unique identifier for an opportunity to show an NES." },
716
717
"survivalRateFourGram": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The rate between 0 and 1 of how much of the AI edit is still present in the document." },
718
"survivalRateNoRevert": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The rate between 0 and 1 of how much of the ranges the AI touched ended up being reverted." },
719
"didBranchChange": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Indicates if the branch changed in the meantime. If the branch changed (value is 1), this event should probably be ignored." },
720
"timeDelayMs": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The time delay between the user accepting the edit and measuring the survival rate." },
721
"arc": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The accepted and restrained character count." }
722
}
723
*/
724
this._telemetryService.sendTelemetryEvent('reportInlineEditSurvivalRate', { microsoft: true, github: { eventNamePrefix: 'copilot-nes/' } },
725
{
726
opportunityId: item.requestUuid,
727
},
728
{
729
survivalRateFourGram: res.fourGram,
730
survivalRateNoRevert: res.noRevert,
731
didBranchChange: res.didBranchChange ? 1 : 0,
732
timeDelayMs: res.timeDelayMs,
733
arc: res.arc!,
734
}
735
);
736
737
}
738
);
739
}
740
741
private _handleDidRejectCompletionItem(completionItem: NesCompletionItem): void {
742
this.logContextRecorder?.handleRejection(completionItem.info.suggestion);
743
744
completionItem.telemetryBuilder.setAcceptance('rejected');
745
completionItem.telemetryBuilder.setStatus('rejected');
746
747
const info = completionItem.info;
748
if (isLlmCompletionInfo(info)) {
749
this.model.nextEditProvider.handleRejection(info.documentId, info.suggestion);
750
} else {
751
this.model.diagnosticsBasedProvider?.handleRejection(info.documentId, info.suggestion);
752
}
753
}
754
755
private _handleDidIgnoreCompletionItem(item: NesCompletionItem, supersededBy?: NesCompletionItem): void {
756
if (supersededBy) {
757
item.telemetryBuilder.setSupersededBy(supersededBy.info.requestUuid);
758
}
759
760
const info = item.info;
761
const supersededBySuggestion = supersededBy ? supersededBy.info.suggestion : undefined;
762
if (isLlmCompletionInfo(info)) {
763
this.model.nextEditProvider.handleIgnored(info.documentId, info.suggestion, supersededBySuggestion);
764
} else {
765
this.model.diagnosticsBasedProvider?.handleIgnored(info.documentId, info.suggestion, supersededBySuggestion);
766
}
767
}
768
}
769
770
function hasNotebookCellMarker(document: TextDocument, newText: string) {
771
return isNotebookCell(document.uri) && newText.includes('%% vscode.cell [id=');
772
}
773
774
function addNotebookTelemetry(document: TextDocument, position: Position, newText: string, documents: [TextDocument, Range][], telemetryBuilder: NextEditProviderTelemetryBuilder) {
775
const notebook = isNotebookCell(document.uri) ? findNotebook(document.uri, workspace.notebookDocuments) : undefined;
776
const cell = notebook ? findCell(document.uri, notebook) : undefined;
777
if (!cell || !notebook || !documents.length) {
778
return;
779
}
780
const cellMarkerCount = newText.match(/%% vscode.cell \[id=/g)?.length || 0;
781
const cellMarkerIndex = newText.indexOf('#%% vscode.cell [id=');
782
const isMultiline = newText.includes('\n');
783
const targetEol = documents[0][0].eol === EndOfLine.CRLF ? '\r\n' : '\n';
784
const sourceEol = newText.includes('\r\n') ? '\r\n' : (newText.includes('\n') ? '\n' : targetEol);
785
const nextEditor = window.visibleTextEditors.find(editor => editor.document === documents[0][0]);
786
const isNextEditorRangeVisible = nextEditor && nextEditor.visibleRanges.some(range => range.contains(documents[0][1]));
787
const notebookId = getNotebookId(notebook);
788
const lineSuffix = `(${position.line}:${position.character})`;
789
const suggestionLineSuffix = `(->${documents[0][1].start.line}:${documents[0][1].start.character})`;
790
const getCellPrefix = (c: NotebookCell) => {
791
if (c === cell) {
792
return `*`;
793
}
794
if (c.document === documents[0][0]) {
795
return `+`;
796
}
797
return '';
798
};
799
const lineCounts = notebook.getCells()
800
.filter(c => c.kind === NotebookCellKind.Code)
801
.map(c => `${getCellPrefix(c)}${c.document.lineCount}${c === cell ? lineSuffix : ''}${c.document === documents[0][0] ? suggestionLineSuffix : ''}`).join(',');
802
telemetryBuilder.
803
setNotebookCellMarkerIndex(cellMarkerIndex)
804
.setNotebookCellMarkerCount(cellMarkerCount)
805
.setIsMultilineEdit(isMultiline)
806
.setIsEolDifferent(targetEol !== sourceEol)
807
.setIsNextEditorVisible(!!nextEditor)
808
.setIsNextEditorRangeVisible(!!isNextEditorRangeVisible)
809
.setNotebookCellLines(lineCounts)
810
.setNotebookId(notebookId);
811
}
812
813