diff --git a/extensions/copilot/src/extension/inlineEdits/common/shownGhostTextTracker.ts b/extensions/copilot/src/extension/inlineEdits/common/shownGhostTextTracker.ts new file mode 100644 index 0000000000000..5e2d7427699b5 --- /dev/null +++ b/extensions/copilot/src/extension/inlineEdits/common/shownGhostTextTracker.ts @@ -0,0 +1,170 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface GhostTextShowContext { + readonly cursorLine: number; + readonly cursorCharacter: number; + readonly documentVersion: number; +} + +/** + * Maximum number of tracked entries per document before eviction. + * When exceeded, the oldest half of entries are dropped to bound memory. + */ +const MAX_ENTRIES_PER_DOCUMENT = 200; + +/** + * Tracks ghost text (inline suggestion, NOT inline edit) suggestions that have been + * shown to the user, and determines whether a suggestion should be filtered out + * based on prior user interactions. + * + * Rules: + * - If a ghost text suggestion was shown and explicitly rejected → never show it again. + * - If a ghost text suggestion was shown and ignored → do not show it again unless + * it would still be ghost text AND the cursor is at the same position AND the + * document contents are the same (tracked via document version). + */ +export class ShownGhostTextTracker { + /** Edit keys of suggestions that were shown as ghost text and explicitly rejected. */ + private readonly _rejected = new Map>(); + + /** Edit keys of suggestions that were shown as ghost text and ignored, with their show-time context. */ + private readonly _ignored = new Map>(); + + public recordRejected(docUri: string, editKey: string): void { + let docSet = this._rejected.get(docUri); + if (!docSet) { + docSet = new Set(); + this._rejected.set(docUri, docSet); + } + docSet.add(editKey); + this._evictIfNeeded(docUri); + + // Rejection is stronger than ignore — remove from ignored if present + this._ignored.get(docUri)?.delete(editKey); + } + + public recordIgnored(docUri: string, editKey: string, context: GhostTextShowContext): void { + // Do not downgrade a rejection to an ignore + if (this._rejected.get(docUri)?.has(editKey)) { + return; + } + let docMap = this._ignored.get(docUri); + if (!docMap) { + docMap = new Map(); + this._ignored.set(docUri, docMap); + } + docMap.set(editKey, context); + this._evictIfNeeded(docUri); + } + + public clearTracking(docUri: string, editKey: string): void { + this._rejected.get(docUri)?.delete(editKey); + this._ignored.get(docUri)?.delete(editKey); + } + + /** Removes all tracking data for a document (e.g., on document close). */ + public clearDocument(docUri: string): void { + this._rejected.delete(docUri); + this._ignored.delete(docUri); + } + + /** + * Determines whether a suggestion should be filtered out based on prior interactions. + * + * @param docUri - The document URI. + * @param editKey - The edit identity key (range + insertText). + * @param isGhostText - Whether the suggestion would be rendered as ghost text (not inline edit). + * @param cursorLine - Current cursor line. + * @param cursorCharacter - Current cursor character. + * @param documentVersion - Current document version. + * @returns `true` if the suggestion should be filtered out. + */ + public shouldFilter( + docUri: string, + editKey: string, + isGhostText: boolean, + cursorLine: number, + cursorCharacter: number, + documentVersion: number, + ): boolean { + // Rejected ghost text is always filtered — never show again + if (this._rejected.get(docUri)?.has(editKey)) { + return true; + } + + const ignoredCtx = this._ignored.get(docUri)?.get(editKey); + if (!ignoredCtx) { + return false; // not tracked + } + + // Ignored ghost text: allow re-show only if ghost text + same position + same doc version + if ( + isGhostText + && ignoredCtx.cursorLine === cursorLine + && ignoredCtx.cursorCharacter === cursorCharacter + && ignoredCtx.documentVersion === documentVersion + ) { + return false; // same context → allow + } + + return true; // different context or would be inline edit → filter + } + + /** + * Evicts the oldest half of entries for a document when the combined + * rejected + ignored count exceeds {@link MAX_ENTRIES_PER_DOCUMENT}. + */ + private _evictIfNeeded(docUri: string): void { + const rejectedSet = this._rejected.get(docUri); + const ignoredMap = this._ignored.get(docUri); + const total = (rejectedSet?.size ?? 0) + (ignoredMap?.size ?? 0); + if (total <= MAX_ENTRIES_PER_DOCUMENT) { + return; + } + + const halfToKeep = Math.floor(MAX_ENTRIES_PER_DOCUMENT / 2); + + // Evict oldest entries from rejected (Set preserves insertion order) + if (rejectedSet && rejectedSet.size > halfToKeep) { + const toRemove = rejectedSet.size - halfToKeep; + let removed = 0; + for (const key of rejectedSet) { + if (removed >= toRemove) { + break; + } + rejectedSet.delete(key); + removed++; + } + } + + // Evict oldest entries from ignored (Map preserves insertion order) + if (ignoredMap && ignoredMap.size > halfToKeep) { + const toRemove = ignoredMap.size - halfToKeep; + let removed = 0; + for (const key of ignoredMap.keys()) { + if (removed >= toRemove) { + break; + } + ignoredMap.delete(key); + removed++; + } + } + } +} + +/** + * Computes a deterministic edit key from a range and insert text. + * Used to identify "the same suggestion" across provide/show/endOfLife calls. + */ +export function computeGhostTextEditKey( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number, + insertText: string, +): string { + return `${startLine}:${startCharacter}-${endLine}:${endCharacter}|${insertText}`; +} diff --git a/extensions/copilot/src/extension/inlineEdits/test/common/shownGhostTextTracker.spec.ts b/extensions/copilot/src/extension/inlineEdits/test/common/shownGhostTextTracker.spec.ts new file mode 100644 index 0000000000000..ab649739bedaf --- /dev/null +++ b/extensions/copilot/src/extension/inlineEdits/test/common/shownGhostTextTracker.spec.ts @@ -0,0 +1,176 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, test } from 'vitest'; +import { computeGhostTextEditKey, ShownGhostTextTracker } from '../../common/shownGhostTextTracker'; + +describe('ShownGhostTextTracker', () => { + const docUri = 'file:///test.ts'; + + test('untracked suggestion is not filtered', () => { + const tracker = new ShownGhostTextTracker(); + expect(tracker.shouldFilter(docUri, 'edit1', true, 1, 0, 1)).toBe(false); + }); + + test('rejected ghost text is always filtered', () => { + const tracker = new ShownGhostTextTracker(); + tracker.recordRejected(docUri, 'edit1'); + + expect(tracker.shouldFilter(docUri, 'edit1', true, 1, 0, 1)).toBe(true); + // Different position — still filtered + expect(tracker.shouldFilter(docUri, 'edit1', true, 5, 3, 2)).toBe(true); + }); + + test('rejected ghost text is filtered even when it would be an inline edit', () => { + const tracker = new ShownGhostTextTracker(); + tracker.recordRejected(docUri, 'edit1'); + + expect(tracker.shouldFilter(docUri, 'edit1', false, 1, 0, 1)).toBe(true); + }); + + test('ignored ghost text is filtered at different cursor position', () => { + const tracker = new ShownGhostTextTracker(); + tracker.recordIgnored(docUri, 'edit1', { cursorLine: 1, cursorCharacter: 0, documentVersion: 1 }); + + expect(tracker.shouldFilter(docUri, 'edit1', true, 2, 0, 1)).toBe(true); + }); + + test('ignored ghost text is filtered at different document version', () => { + const tracker = new ShownGhostTextTracker(); + tracker.recordIgnored(docUri, 'edit1', { cursorLine: 1, cursorCharacter: 0, documentVersion: 1 }); + + expect(tracker.shouldFilter(docUri, 'edit1', true, 1, 0, 2)).toBe(true); + }); + + test('ignored ghost text is filtered when it would be an inline edit', () => { + const tracker = new ShownGhostTextTracker(); + tracker.recordIgnored(docUri, 'edit1', { cursorLine: 1, cursorCharacter: 0, documentVersion: 1 }); + + // Same position and version, but would be inline edit — filtered + expect(tracker.shouldFilter(docUri, 'edit1', false, 1, 0, 1)).toBe(true); + }); + + test('ignored ghost text is allowed at same position, version, and ghost text mode', () => { + const tracker = new ShownGhostTextTracker(); + tracker.recordIgnored(docUri, 'edit1', { cursorLine: 1, cursorCharacter: 0, documentVersion: 1 }); + + expect(tracker.shouldFilter(docUri, 'edit1', true, 1, 0, 1)).toBe(false); + }); + + test('rejection overrides prior ignore', () => { + const tracker = new ShownGhostTextTracker(); + tracker.recordIgnored(docUri, 'edit1', { cursorLine: 1, cursorCharacter: 0, documentVersion: 1 }); + tracker.recordRejected(docUri, 'edit1'); + + // Same context that would have been allowed for ignore — now rejected + expect(tracker.shouldFilter(docUri, 'edit1', true, 1, 0, 1)).toBe(true); + }); + + test('ignore cannot downgrade a rejection', () => { + const tracker = new ShownGhostTextTracker(); + tracker.recordRejected(docUri, 'edit1'); + tracker.recordIgnored(docUri, 'edit1', { cursorLine: 1, cursorCharacter: 0, documentVersion: 1 }); + + // Still rejected + expect(tracker.shouldFilter(docUri, 'edit1', true, 1, 0, 1)).toBe(true); + }); + + test('acceptance clears both rejection and ignore tracking', () => { + const tracker = new ShownGhostTextTracker(); + tracker.recordRejected(docUri, 'edit1'); + tracker.clearTracking(docUri, 'edit1'); + + expect(tracker.shouldFilter(docUri, 'edit1', true, 1, 0, 1)).toBe(false); + + tracker.recordIgnored(docUri, 'edit2', { cursorLine: 1, cursorCharacter: 0, documentVersion: 1 }); + tracker.clearTracking(docUri, 'edit2'); + + expect(tracker.shouldFilter(docUri, 'edit2', true, 2, 0, 1)).toBe(false); + }); + + test('tracking is scoped per document', () => { + const tracker = new ShownGhostTextTracker(); + const doc1 = 'file:///a.ts'; + const doc2 = 'file:///b.ts'; + + tracker.recordRejected(doc1, 'edit1'); + + expect(tracker.shouldFilter(doc1, 'edit1', true, 1, 0, 1)).toBe(true); + expect(tracker.shouldFilter(doc2, 'edit1', true, 1, 0, 1)).toBe(false); + }); + + test('multiple suggestions tracked independently', () => { + const tracker = new ShownGhostTextTracker(); + + tracker.recordRejected(docUri, 'edit1'); + tracker.recordIgnored(docUri, 'edit2', { cursorLine: 3, cursorCharacter: 5, documentVersion: 4 }); + + expect(tracker.shouldFilter(docUri, 'edit1', true, 1, 0, 1)).toBe(true); + expect(tracker.shouldFilter(docUri, 'edit2', true, 3, 5, 4)).toBe(false); // same context → allowed + expect(tracker.shouldFilter(docUri, 'edit2', true, 1, 0, 4)).toBe(true); // different position → filtered + expect(tracker.shouldFilter(docUri, 'edit3', true, 1, 0, 1)).toBe(false); // untracked + }); + + test('clearDocument removes all tracking for that document', () => { + const tracker = new ShownGhostTextTracker(); + + tracker.recordRejected(docUri, 'edit1'); + tracker.recordIgnored(docUri, 'edit2', { cursorLine: 1, cursorCharacter: 0, documentVersion: 1 }); + + tracker.clearDocument(docUri); + + expect(tracker.shouldFilter(docUri, 'edit1', true, 1, 0, 1)).toBe(false); + expect(tracker.shouldFilter(docUri, 'edit2', true, 5, 0, 2)).toBe(false); + }); + + test('clearDocument does not affect other documents', () => { + const tracker = new ShownGhostTextTracker(); + const doc1 = 'file:///a.ts'; + const doc2 = 'file:///b.ts'; + + tracker.recordRejected(doc1, 'edit1'); + tracker.recordRejected(doc2, 'edit1'); + + tracker.clearDocument(doc1); + + expect(tracker.shouldFilter(doc1, 'edit1', true, 1, 0, 1)).toBe(false); + expect(tracker.shouldFilter(doc2, 'edit1', true, 1, 0, 1)).toBe(true); + }); + + test('evicts oldest entries when per-document cap is exceeded', () => { + const tracker = new ShownGhostTextTracker(); + + // Fill up with 201 rejected entries (exceeds the 200 cap) + for (let i = 0; i < 201; i++) { + tracker.recordRejected(docUri, `edit-${i}`); + } + + // The oldest entries should have been evicted + expect(tracker.shouldFilter(docUri, 'edit-0', true, 1, 0, 1)).toBe(false); + + // The newest entries should still be tracked + expect(tracker.shouldFilter(docUri, 'edit-200', true, 1, 0, 1)).toBe(true); + }); +}); + +describe('computeGhostTextEditKey', () => { + test('produces deterministic keys', () => { + const key1 = computeGhostTextEditKey(1, 0, 1, 10, 'hello'); + const key2 = computeGhostTextEditKey(1, 0, 1, 10, 'hello'); + expect(key1).toBe(key2); + }); + + test('different ranges produce different keys', () => { + const key1 = computeGhostTextEditKey(1, 0, 1, 10, 'hello'); + const key2 = computeGhostTextEditKey(2, 0, 2, 10, 'hello'); + expect(key1).not.toBe(key2); + }); + + test('different text produces different keys', () => { + const key1 = computeGhostTextEditKey(1, 0, 1, 10, 'hello'); + const key2 = computeGhostTextEditKey(1, 0, 1, 10, 'world'); + expect(key1).not.toBe(key2); + }); +}); diff --git a/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts b/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts index 3337e2a7d28ed..8f1bfa6da2836 100644 --- a/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts +++ b/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts @@ -38,6 +38,7 @@ import { IInstantiationService } from '../../../util/vs/platform/instantiation/c import { LineCheck } from '../../inlineChat/vscode-node/naturalLanguageHint'; import { createCorrelationId } from '../common/correlationId'; import { NesChangeHint } from '../common/nesTriggerHint'; +import { computeGhostTextEditKey, ShownGhostTextTracker } from '../common/shownGhostTextTracker'; import { NESInlineCompletionContext } from '../node/nextEditProvider'; import { NextEditProviderTelemetryBuilder, TelemetrySender } from '../node/nextEditProviderTelemetry'; import { INextEditResult, NextEditResult } from '../node/nextEditResult'; @@ -148,6 +149,32 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo private readonly _renameSymbolSuggestions: IObservable; private readonly _inlineCompletionsAdvanced: IObservable; + //#region Ghost text tracking + private readonly _ghostTextTrackingEnabled: boolean; + private readonly _ghostTextTracker = new ShownGhostTextTracker(); + + /** + * Context of the currently pending (not yet finalized) ghost text suggestion. + * Set when a ghost text suggestion is shown; cleared when endOfLifetime is received + * or when a new provide call preemptively records it as ignored (race condition handling). + */ + private _pendingShownGhostText: { + readonly editKey: string; + readonly docUri: string; + readonly cursorLine: number; + readonly cursorCharacter: number; + readonly documentVersion: number; + } | undefined; + + private readonly _ghostTextItemKeys = new WeakMap(); + //#endregion + constructor( private readonly model: InlineEditModel, private readonly logger: InlineEditLogger, @@ -172,6 +199,14 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo this._displayNextEditorNES = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.UseAlternativeNESNotebookFormat, this._expService); this._renameSymbolSuggestions = this._configurationService.getExperimentBasedConfigObservable(ConfigKey.Advanced.InlineEditsRenameSymbolSuggestions, this._expService); this._inlineCompletionsAdvanced = this._configurationService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.InlineEditsInlineCompletionsAdvanced, this._expService); + this._ghostTextTrackingEnabled = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsDoNotChangeGhostTextRendering, this._expService); + + // Clean up ghost text tracking data when documents are closed to prevent memory leaks + if (this._ghostTextTrackingEnabled) { + this._register(workspace.onDidCloseTextDocument(doc => { + this._ghostTextTracker.clearDocument(doc.uri.toString()); + })); + } this.setCurrentModelId = (modelId: string) => this._modelService.setCurrentModelId(modelId); @@ -238,6 +273,18 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo ): Promise { const logger = this._logger.createSubLogger(['provideInlineCompletionItems', shortenOpportunityId(context.requestUuid)]); + // Ghost text tracking: preemptively record the previous ghost text as ignored + // if its endOfLifetime hasn't arrived yet (race condition handling). + if (this._ghostTextTrackingEnabled && this._pendingShownGhostText) { + const pending = this._pendingShownGhostText; + this._ghostTextTracker.recordIgnored(pending.docUri, pending.editKey, { + cursorLine: pending.cursorLine, + cursorCharacter: pending.cursorCharacter, + documentVersion: pending.documentVersion, + }); + this._pendingShownGhostText = undefined; + } + // Disable NES while capture mode is active to avoid interference if (this.expectedEditCaptureController.isCaptureActive) { logger.trace('Return: capture mode active'); @@ -432,6 +479,29 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo return emptyList; } + // Ghost text tracking: compute context once, use for both filtering and recording. + // Use targetDocument for identity/version — completions may target a different document + // than the one requesting completions (e.g., cross-file edits). + let ghostTextCtx: { editKey: string; docUri: string; cursorLine: number; cursorCharacter: number; documentVersion: number } | undefined; + if (this._ghostTextTrackingEnabled && completionItem.range && targetDocument) { + const r = completionItem.range; + const insertTextStr = typeof completionItem.insertText === 'string' ? completionItem.insertText : completionItem.insertText?.value ?? ''; + const ghostTextDocUri = targetDocument.uri.toString(); + ghostTextCtx = { + editKey: computeGhostTextEditKey(r.start.line, r.start.character, r.end.line, r.end.character, insertTextStr), + docUri: ghostTextDocUri, + cursorLine: position.line, + cursorCharacter: position.character, + documentVersion: targetDocument.version, + }; + + if (this._ghostTextTracker.shouldFilter(ghostTextCtx.docUri, ghostTextCtx.editKey, isInlineCompletion, position.line, position.character, targetDocument.version)) { + logger.trace('ghost text tracking: filtered out previously shown suggestion'); + this.telemetrySender.scheduleSendingEnhancedTelemetry(suggestionInfo.suggestion, telemetryBuilder); + return emptyList; + } + } + const menuCommands: InlineCompletionCommand[] = []; if (this.inlineEditDebugComponent) { menuCommands.push(...this.inlineEditDebugComponent.getCommands(logContext)); @@ -465,6 +535,12 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo correlationId, }; + // Ghost text tracking: associate context with ghost text suggestions for + // recording rejection/ignore in handleEndOfLifetime + if (ghostTextCtx && isInlineCompletion) { + this._ghostTextItemKeys.set(nesCompletionItem, ghostTextCtx); + } + return new NesCompletionList(context.requestUuid, nesCompletionItem, menuCommands, telemetryBuilder); } catch (e) { logger.trace(`error: ${e}`); @@ -554,6 +630,14 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo } else { this.model.diagnosticsBasedProvider?.handleShown(info.suggestion); } + + // Ghost text tracking: mark this ghost text suggestion as pending (shown but not yet finalized) + if (this._ghostTextTrackingEnabled) { + const ghostTextCtx = this._ghostTextItemKeys.get(completionItem); + if (ghostTextCtx) { + this._pendingShownGhostText = ghostTextCtx; + } + } } public handleListEndOfLifetime(list: NesCompletionList, reason: InlineCompletionsDisposeReason): void { @@ -573,6 +657,9 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo const logger = this._logger.createSubLogger(['handleEndOfLifetime', shortenOpportunityId(item.info.requestUuid)]); logger.trace(`reason: ${InlineCompletionEndOfLifeReasonKind[reason.kind]}`); + // Ghost text tracking: record outcome for ghost text suggestions that were shown + this._recordGhostTextOutcome(item, reason); + switch (reason.kind) { case InlineCompletionEndOfLifeReasonKind.Accepted: { this._handleAcceptance(item); @@ -727,6 +814,38 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo this.model.diagnosticsBasedProvider?.handleIgnored(info.documentId, info.suggestion, supersededBySuggestion); } } + + private _recordGhostTextOutcome(item: NesCompletionItem, reason: InlineCompletionEndOfLifeReason): void { + if (!this._ghostTextTrackingEnabled || !item.wasShown) { + return; + } + + const ghostTextCtx = this._ghostTextItemKeys.get(item); + if (!ghostTextCtx) { + return; // not a ghost text suggestion + } + + // Clear the pending state if this is the item we were tracking + if (this._pendingShownGhostText === ghostTextCtx) { + this._pendingShownGhostText = undefined; + } + + switch (reason.kind) { + case InlineCompletionEndOfLifeReasonKind.Rejected: + this._ghostTextTracker.recordRejected(ghostTextCtx.docUri, ghostTextCtx.editKey); + break; + case InlineCompletionEndOfLifeReasonKind.Ignored: + this._ghostTextTracker.recordIgnored(ghostTextCtx.docUri, ghostTextCtx.editKey, { + cursorLine: ghostTextCtx.cursorLine, + cursorCharacter: ghostTextCtx.cursorCharacter, + documentVersion: ghostTextCtx.documentVersion, + }); + break; + case InlineCompletionEndOfLifeReasonKind.Accepted: + this._ghostTextTracker.clearTracking(ghostTextCtx.docUri, ghostTextCtx.editKey); + break; + } + } } function hasNotebookCellMarker(document: TextDocument, newText: string) { diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 93360356e49fd..6b433c685bc0f 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -847,6 +847,7 @@ export namespace ConfigKey { export const InlineCompletionsDefaultDiagnosticsOptions = defineTeamInternalSetting('chat.advanced.inlineCompletions.defaultDiagnosticsOptionsString', ConfigType.ExperimentBased, undefined); export const RecordExpectedEditEnabled = defineTeamInternalSetting('chat.advanced.inlineEdits.recordExpectedEdit.enabled', ConfigType.Simple, false); export const RecordExpectedEditOnReject = defineTeamInternalSetting('chat.advanced.inlineEdits.recordExpectedEdit.onReject', ConfigType.Simple, false); + export const InlineEditsDoNotChangeGhostTextRendering = defineTeamInternalSetting('chat.advanced.inlineEdits.doNotChangeGhostTextRendering', ConfigType.ExperimentBased, false, vBoolean()); export const ReadFileCodeFences = defineTeamInternalSetting('chat.advanced.readFileCodeFences', ConfigType.ExperimentBased, false);