-
Notifications
You must be signed in to change notification settings - Fork 39.2k
Copilot: track shown ghost text suggestions to filter redundant re-display #309497
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
ulugbekna
wants to merge
3
commits into
main
Choose a base branch
from
ulugbekna/agents/alleged-canid
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
48099c1
Copilot: track shown ghost text suggestions to filter redundant re-di…
ulugbekna 1287972
Address review: cross-file doc identity, memory eviction, document cl…
ulugbekna 908220a
Rename config to doNotChangeGhostTextRendering and make experiment-based
ulugbekna File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
170 changes: 170 additions & 0 deletions
170
extensions/copilot/src/extension/inlineEdits/common/shownGhostTextTracker.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, Set<string>>(); | ||
|
|
||
| /** Edit keys of suggestions that were shown as ghost text and ignored, with their show-time context. */ | ||
| private readonly _ignored = new Map<string, Map<string, GhostTextShowContext>>(); | ||
|
|
||
| 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}`; | ||
| } | ||
176 changes: 176 additions & 0 deletions
176
extensions/copilot/src/extension/inlineEdits/test/common/shownGhostTextTracker.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.