Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>>();

Comment thread
ulugbekna marked this conversation as resolved.
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}`;
}
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);
});
});
Loading
Loading