diff --git a/src/extension/inlineEdits/node/nextEditProvider.ts b/src/extension/inlineEdits/node/nextEditProvider.ts index 5210ed04ae..eeaeeab1d4 100644 --- a/src/extension/inlineEdits/node/nextEditProvider.ts +++ b/src/extension/inlineEdits/node/nextEditProvider.ts @@ -104,6 +104,12 @@ export interface INextEditProvider(value: T | undefined): T { diff --git a/src/extension/inlineEdits/node/nextEditResult.ts b/src/extension/inlineEdits/node/nextEditResult.ts index 9dac81e4f6..19691cc33c 100644 --- a/src/extension/inlineEdits/node/nextEditResult.ts +++ b/src/extension/inlineEdits/node/nextEditResult.ts @@ -34,7 +34,7 @@ export class NextEditResult implements INextEditResult { edit?: StringReplacement; documentBeforeEdits: StringText; displayLocation?: INextEditDisplayLocation; - targetDocumentId?: DocumentId; + targetDocumentId: DocumentId; action?: Command; isFromCursorJump: boolean; jumpToPosition?: Position; diff --git a/src/extension/inlineEdits/test/vscode-node/inlineEditTriggerer.spec.ts b/src/extension/inlineEdits/test/vscode-node/inlineEditTriggerer.spec.ts index 2193f7f6ee..af03029cd3 100644 --- a/src/extension/inlineEdits/test/vscode-node/inlineEditTriggerer.spec.ts +++ b/src/extension/inlineEdits/test/vscode-node/inlineEditTriggerer.spec.ts @@ -42,6 +42,16 @@ suite('InlineEditTriggerer', () => { public lastRejectionTime: number = Date.now(); public lastTriggerTime: number = Date.now(); public lastOutcome: NesOutcome | undefined = undefined; + public shownNesInfo: { targetDocumentId: DocumentId; expectedCursorLine: number; expectedCursorColumn: number } | undefined; + + public consumeShouldSuppressSelectionChangeTrigger(docId: DocumentId, cursorLine: number, cursorColumn: number): boolean { + const info = this.shownNesInfo; + if (info && info.targetDocumentId === docId && info.expectedCursorLine === cursorLine && info.expectedCursorColumn === cursorColumn) { + this.shownNesInfo = undefined; + return true; + } + return false; + } } class MockVSCodeWorkspace { @@ -1309,4 +1319,144 @@ suite('InlineEditTriggerer', () => { }); // #endregion + + // #region Post-acceptance trigger suppression + + suite('Post-acceptance trigger suppression', () => { + test('No signal when NES target doc and cursor position match', () => { + const { document, textEditor } = createTextDocument(undefined, undefined, 'line1\nline2\nline3'); + nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1; + + triggerTextChange(document); + triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0)); + const countAfterFirst = firedEvents.length; + assert.isAtLeast(countAfterFirst, 1, 'First trigger should fire'); + + // Simulate: NES shown targeting this document, cursor expected at (2, 3) + nextEditProvider.shownNesInfo = { + targetDocumentId: DocumentId.create(document.uri.toString()), + expectedCursorLine: 2, + expectedCursorColumn: 3, + }; + + // Selection changes to the expected position + triggerTextSelectionChange(textEditor, new Selection(2, 3, 2, 3)); + + assert.strictEqual(firedEvents.length, countAfterFirst, + 'Signal should not fire when doc and cursor position match'); + }); + + test('Suppression is consumed and only suppresses once', () => { + const { document, textEditor } = createTextDocument(undefined, undefined, 'line1\nline2\nline3'); + nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1; + + triggerTextChange(document); + triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0)); + const countAfterFirst = firedEvents.length; + + // Simulate NES shown + nextEditProvider.shownNesInfo = { + targetDocumentId: DocumentId.create(document.uri.toString()), + expectedCursorLine: 2, + expectedCursorColumn: 0, + }; + + // First selection change on matching position — suppressed + triggerTextSelectionChange(textEditor, new Selection(2, 0, 2, 0)); + assert.strictEqual(firedEvents.length, countAfterFirst, + 'First selection change after acceptance should be suppressed'); + + // Info should be consumed + assert.strictEqual(nextEditProvider.shownNesInfo, undefined, + 'shownNesInfo should be consumed after match'); + + // Second selection change — NOT suppressed + triggerTextChange(document); + triggerTextSelectionChange(textEditor, new Selection(1, 0, 1, 0)); + assert.isAtLeast(firedEvents.length, countAfterFirst + 1, + 'Second selection change should trigger normally'); + }); + + test('No suppression when cursor column does not match', () => { + const { document, textEditor } = createTextDocument(undefined, undefined, 'line1\nline2\nline3'); + nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1; + + triggerTextChange(document); + triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0)); + const countAfterFirst = firedEvents.length; + + // NES expects cursor at (2, 3) + nextEditProvider.shownNesInfo = { + targetDocumentId: DocumentId.create(document.uri.toString()), + expectedCursorLine: 2, + expectedCursorColumn: 3, + }; + + // Cursor lands on correct line but wrong column + triggerTextSelectionChange(textEditor, new Selection(2, 0, 2, 0)); + + assert.isAtLeast(firedEvents.length, countAfterFirst + 1, + 'Signal should fire when cursor column does not match'); + }); + + test('No suppression when cursor line does not match', () => { + const { document, textEditor } = createTextDocument(undefined, undefined, 'line1\nline2\nline3'); + nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1; + + triggerTextChange(document); + triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0)); + const countAfterFirst = firedEvents.length; + + // NES expects cursor at (2, 0) + nextEditProvider.shownNesInfo = { + targetDocumentId: DocumentId.create(document.uri.toString()), + expectedCursorLine: 2, + expectedCursorColumn: 0, + }; + + // Cursor lands on wrong line + triggerTextSelectionChange(textEditor, new Selection(1, 0, 1, 0)); + + assert.isAtLeast(firedEvents.length, countAfterFirst + 1, + 'Signal should fire when cursor line does not match'); + }); + + test('No suppression when NES target doc does not match', () => { + const doc1 = createTextDocument(undefined, Uri.file('file1.py'), 'line1\nline2'); + const doc2 = createTextDocument(undefined, Uri.file('file2.py'), 'line1\nline2'); + nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1; + + // NES targets doc2 at (1, 0) + nextEditProvider.shownNesInfo = { + targetDocumentId: DocumentId.create(doc2.document.uri.toString()), + expectedCursorLine: 1, + expectedCursorColumn: 0, + }; + + // Selection change in doc1 at same position — should NOT be suppressed + triggerTextChange(doc1.document); + triggerTextSelectionChange(doc1.textEditor, new Selection(1, 0, 1, 0)); + + assert.isAtLeast(firedEvents.length, 1, + 'Signal should fire when NES target doc does not match'); + // Info should NOT be consumed since doc didn't match + assert.isDefined(nextEditProvider.shownNesInfo, + 'shownNesInfo should not be consumed when doc does not match'); + }); + + test('No suppression when no NES info is set', () => { + const { document, textEditor } = createTextDocument(undefined, undefined, 'line1\nline2'); + nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1; + + nextEditProvider.shownNesInfo = undefined; + + triggerTextChange(document); + triggerTextSelectionChange(textEditor, new Selection(1, 0, 1, 0)); + + assert.isAtLeast(firedEvents.length, 1, + 'Signal should fire normally when no NES info is set'); + }); + }); + + // #endregion }); diff --git a/src/extension/inlineEdits/vscode-node/features/diagnosticsInlineEditProvider.ts b/src/extension/inlineEdits/vscode-node/features/diagnosticsInlineEditProvider.ts index 1fd2634014..d8c121754d 100644 --- a/src/extension/inlineEdits/vscode-node/features/diagnosticsInlineEditProvider.ts +++ b/src/extension/inlineEdits/vscode-node/features/diagnosticsInlineEditProvider.ts @@ -160,6 +160,10 @@ export class DiagnosticsNextEditProvider extends Disposable implements INextEdit handleShown(suggestion: DiagnosticsNextEditResult): void { } + consumeShouldSuppressSelectionChangeTrigger(_docId: DocumentId, _cursorLine: number, _cursorColumn: number): boolean { + return false; // diagnostics NES doesn't need trigger suppression + } + handleAcceptance(docId: DocumentId, suggestion: DiagnosticsNextEditResult): void { const completionResult = suggestion.result; if (!completionResult) { diff --git a/src/extension/inlineEdits/vscode-node/inlineEditTriggerer.ts b/src/extension/inlineEdits/vscode-node/inlineEditTriggerer.ts index 9eaf836c1d..a376f5b4a9 100644 --- a/src/extension/inlineEdits/vscode-node/inlineEditTriggerer.ts +++ b/src/extension/inlineEdits/vscode-node/inlineEditTriggerer.ts @@ -189,6 +189,11 @@ export class InlineEditTriggerer extends Disposable { const selectionLine = range.start.line; + if (this.nextEditProvider.consumeShouldSuppressSelectionChangeTrigger(doc.id, selectionLine, range.start.character)) { + logger.trace('Return: suppressed after NES acceptance'); + return; + } + if (this._isSameLineCooldownActive(mostRecentChange, selectionLine, e.textEditor.document)) { logger.trace('Return: same line cooldown'); return;