Skip to content

Commit 3135e98

Browse files
authored
feat(amazonq): auto-reject edit suggestions when cursor moves >25 lines away (#7868)
## Problem Amazon Q edit suggestions remain active even when users navigate far away from the suggestion location, leading to confusion and potential unintended edits when users accept suggestions they can no longer see. ## Solution Added automatic rejection of edit suggestions when the cursor moves more than 25 lines away from the suggestion location. Implemented cursor change listener with proper cleanup to prevent memory leaks and ensure suggestions are only accepted when contextually relevant. Fixed 2 failing recommendationService.test.ts tests --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license.
1 parent 3ef1aba commit 3135e98

File tree

3 files changed

+175
-18
lines changed

3 files changed

+175
-18
lines changed

packages/amazonq/src/app/inline/EditRendering/displayImage.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import { EditSuggestionState } from '../editSuggestionState'
1616
import type { AmazonQInlineCompletionItemProvider } from '../completion'
1717
import { vsCodeState } from 'aws-core-vscode/codewhisperer'
1818

19+
const autoRejectEditCursorDistance = 25
20+
1921
export class EditDecorationManager {
2022
private imageDecorationType: vscode.TextEditorDecorationType
2123
private removedCodeDecorationType: vscode.TextEditorDecorationType
@@ -346,6 +348,19 @@ export async function displaySvgDecoration(
346348
void vscode.commands.executeCommand('aws.amazonq.inline.rejectEdit')
347349
}
348350
})
351+
const cursorChangeListener = vscode.window.onDidChangeTextEditorSelection((e) => {
352+
if (!EditSuggestionState.isEditSuggestionActive()) {
353+
return
354+
}
355+
if (e.textEditor !== editor) {
356+
return
357+
}
358+
const currentPosition = e.selections[0].active
359+
const distance = Math.abs(currentPosition.line - startLine)
360+
if (distance > autoRejectEditCursorDistance) {
361+
void vscode.commands.executeCommand('aws.amazonq.inline.rejectEdit')
362+
}
363+
})
349364
await decorationManager.displayEditSuggestion(
350365
editor,
351366
svgImage,
@@ -371,6 +386,7 @@ export async function displaySvgDecoration(
371386

372387
await decorationManager.clearDecorations(editor)
373388
documentChangeListener.dispose()
389+
cursorChangeListener.dispose()
374390
const params: LogInlineCompletionSessionResultsParams = {
375391
sessionId: session.sessionId,
376392
completionSessionResult: {
@@ -405,6 +421,7 @@ export async function displaySvgDecoration(
405421
getLogger().info('Edit suggestion rejected')
406422
await decorationManager.clearDecorations(editor)
407423
documentChangeListener.dispose()
424+
cursorChangeListener.dispose()
408425
const params: LogInlineCompletionSessionResultsParams = {
409426
sessionId: session.sessionId,
410427
completionSessionResult: {

packages/amazonq/test/unit/amazonq/apps/inline/recommendationService.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ describe('RecommendationService', () => {
129129

130130
describe('getAllRecommendations', () => {
131131
it('should handle single request with no partial result token', async () => {
132+
// Mock EditSuggestionState to return false (no edit suggestion active)
133+
sandbox.stub(EditSuggestionState, 'isEditSuggestionActive').returns(false)
134+
132135
const mockFirstResult = {
133136
sessionId: 'test-session',
134137
items: [mockInlineCompletionItemOne],
@@ -172,6 +175,9 @@ describe('RecommendationService', () => {
172175
})
173176

174177
it('should handle multiple request with partial result token', async () => {
178+
// Mock EditSuggestionState to return false (no edit suggestion active)
179+
sandbox.stub(EditSuggestionState, 'isEditSuggestionActive').returns(false)
180+
175181
const mockFirstResult = {
176182
sessionId: 'test-session',
177183
items: [mockInlineCompletionItemOne],

packages/amazonq/test/unit/app/inline/EditRendering/displayImage.test.ts

Lines changed: 152 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,30 @@
66
import * as vscode from 'vscode'
77
import * as sinon from 'sinon'
88
import assert from 'assert'
9-
import { EditDecorationManager } from '../../../../../src/app/inline/EditRendering/displayImage'
9+
import { EditDecorationManager, displaySvgDecoration } from '../../../../../src/app/inline/EditRendering/displayImage'
10+
import { EditSuggestionState } from '../../../../../src/app/inline/editSuggestionState'
11+
12+
// Shared helper function to create common stubs
13+
function createCommonStubs(sandbox: sinon.SinonSandbox) {
14+
const documentStub = {
15+
getText: sandbox.stub().returns('Original code content'),
16+
uri: vscode.Uri.file('/test/file.ts'),
17+
lineAt: sandbox.stub().returns({
18+
text: 'Line text content',
19+
range: new vscode.Range(0, 0, 0, 18),
20+
rangeIncludingLineBreak: new vscode.Range(0, 0, 0, 19),
21+
firstNonWhitespaceCharacterIndex: 0,
22+
isEmptyOrWhitespace: false,
23+
}),
24+
} as unknown as sinon.SinonStubbedInstance<vscode.TextDocument>
25+
26+
const editorStub = {
27+
document: documentStub,
28+
setDecorations: sandbox.stub(),
29+
} as unknown as sinon.SinonStubbedInstance<vscode.TextEditor>
30+
31+
return { documentStub, editorStub }
32+
}
1033

1134
describe('EditDecorationManager', function () {
1235
let sandbox: sinon.SinonSandbox
@@ -25,23 +48,13 @@ describe('EditDecorationManager', function () {
2548
dispose: sandbox.stub(),
2649
} as unknown as sinon.SinonStubbedInstance<vscode.TextEditorDecorationType>
2750

28-
documentStub = {
29-
getText: sandbox.stub().returns('Original code content'),
30-
lineCount: 5,
31-
lineAt: sandbox.stub().returns({
32-
text: 'Line text content',
33-
range: new vscode.Range(0, 0, 0, 18),
34-
rangeIncludingLineBreak: new vscode.Range(0, 0, 0, 19),
35-
firstNonWhitespaceCharacterIndex: 0,
36-
isEmptyOrWhitespace: false,
37-
}),
38-
} as unknown as sinon.SinonStubbedInstance<vscode.TextDocument>
39-
40-
editorStub = {
41-
document: documentStub,
42-
setDecorations: sandbox.stub(),
43-
edit: sandbox.stub().resolves(true),
44-
} as unknown as sinon.SinonStubbedInstance<vscode.TextEditor>
51+
const commonStubs = createCommonStubs(sandbox)
52+
documentStub = commonStubs.documentStub
53+
editorStub = commonStubs.editorStub
54+
55+
// Add additional properties needed for this test suite - extend the stub objects
56+
Object.assign(documentStub, { lineCount: 5 })
57+
Object.assign(editorStub, { edit: sandbox.stub().resolves(true) })
4558

4659
windowStub = sandbox.stub(vscode.window)
4760
windowStub.createTextEditorDecorationType.returns(decorationTypeStub as any)
@@ -174,3 +187,124 @@ describe('EditDecorationManager', function () {
174187
sinon.assert.calledWith(editorStub.setDecorations.secondCall, manager['removedCodeDecorationType'], [])
175188
})
176189
})
190+
191+
describe('displaySvgDecoration cursor distance auto-reject', function () {
192+
let sandbox: sinon.SinonSandbox
193+
let editorStub: sinon.SinonStubbedInstance<vscode.TextEditor>
194+
let windowStub: sinon.SinonStub
195+
let commandsStub: sinon.SinonStub
196+
let editSuggestionStateStub: sinon.SinonStub
197+
let onDidChangeTextEditorSelectionStub: sinon.SinonStub
198+
let selectionChangeListener: (e: vscode.TextEditorSelectionChangeEvent) => void
199+
200+
// Helper function to setup displaySvgDecoration
201+
async function setupDisplaySvgDecoration(startLine: number) {
202+
return await displaySvgDecoration(
203+
editorStub as unknown as vscode.TextEditor,
204+
vscode.Uri.parse('data:image/svg+xml;base64,test'),
205+
startLine,
206+
'new code',
207+
[],
208+
{} as any,
209+
{} as any,
210+
{ itemId: 'test', insertText: 'patch' } as any
211+
)
212+
}
213+
214+
// Helper function to create selection change event
215+
function createSelectionChangeEvent(line: number): vscode.TextEditorSelectionChangeEvent {
216+
const position = new vscode.Position(line, 0)
217+
const selection = new vscode.Selection(position, position)
218+
return {
219+
textEditor: editorStub,
220+
selections: [selection],
221+
kind: vscode.TextEditorSelectionChangeKind.Mouse,
222+
} as vscode.TextEditorSelectionChangeEvent
223+
}
224+
225+
beforeEach(function () {
226+
sandbox = sinon.createSandbox()
227+
228+
const commonStubs = createCommonStubs(sandbox)
229+
editorStub = commonStubs.editorStub
230+
231+
// Mock vscode.window.onDidChangeTextEditorSelection
232+
onDidChangeTextEditorSelectionStub = sandbox.stub()
233+
onDidChangeTextEditorSelectionStub.returns({ dispose: sandbox.stub() })
234+
windowStub = sandbox.stub(vscode.window, 'onDidChangeTextEditorSelection')
235+
windowStub.callsFake((callback) => {
236+
selectionChangeListener = callback
237+
return { dispose: sandbox.stub() }
238+
})
239+
240+
// Mock vscode.commands.executeCommand
241+
commandsStub = sandbox.stub(vscode.commands, 'executeCommand')
242+
243+
// Mock EditSuggestionState
244+
editSuggestionStateStub = sandbox.stub(EditSuggestionState, 'isEditSuggestionActive')
245+
editSuggestionStateStub.returns(true)
246+
247+
// Mock other required dependencies
248+
sandbox.stub(vscode.workspace, 'onDidChangeTextDocument').returns({ dispose: sandbox.stub() })
249+
})
250+
251+
afterEach(function () {
252+
sandbox.restore()
253+
})
254+
255+
it('should not reject when cursor moves less than 25 lines away', async function () {
256+
const startLine = 50
257+
await setupDisplaySvgDecoration(startLine)
258+
259+
selectionChangeListener(createSelectionChangeEvent(startLine + 24))
260+
261+
sinon.assert.notCalled(commandsStub)
262+
})
263+
264+
it('should not reject when cursor moves exactly 25 lines away', async function () {
265+
const startLine = 50
266+
await setupDisplaySvgDecoration(startLine)
267+
268+
selectionChangeListener(createSelectionChangeEvent(startLine + 25))
269+
270+
sinon.assert.notCalled(commandsStub)
271+
})
272+
273+
it('should reject when cursor moves more than 25 lines away', async function () {
274+
const startLine = 50
275+
await setupDisplaySvgDecoration(startLine)
276+
277+
selectionChangeListener(createSelectionChangeEvent(startLine + 26))
278+
279+
sinon.assert.calledOnceWithExactly(commandsStub, 'aws.amazonq.inline.rejectEdit')
280+
})
281+
282+
it('should reject when cursor moves more than 25 lines before the edit', async function () {
283+
const startLine = 50
284+
await setupDisplaySvgDecoration(startLine)
285+
286+
selectionChangeListener(createSelectionChangeEvent(startLine - 26))
287+
288+
sinon.assert.calledOnceWithExactly(commandsStub, 'aws.amazonq.inline.rejectEdit')
289+
})
290+
291+
it('should not reject when edit is near beginning of file and cursor cannot move far enough', async function () {
292+
const startLine = 10
293+
await setupDisplaySvgDecoration(startLine)
294+
295+
selectionChangeListener(createSelectionChangeEvent(0))
296+
297+
sinon.assert.notCalled(commandsStub)
298+
})
299+
300+
it('should not reject when edit suggestion is not active', async function () {
301+
editSuggestionStateStub.returns(false)
302+
303+
const startLine = 50
304+
await setupDisplaySvgDecoration(startLine)
305+
306+
selectionChangeListener(createSelectionChangeEvent(startLine + 100))
307+
308+
sinon.assert.notCalled(commandsStub)
309+
})
310+
})

0 commit comments

Comments
 (0)