From 9a39b50d6eb98471fa9d6105e73486467b153ef6 Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 16 Sep 2025 00:19:29 -0700 Subject: [PATCH 01/10] remove filter from next edit svg border --- .../vscode/src/activation/NextEditWindowManager.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/extensions/vscode/src/activation/NextEditWindowManager.ts b/extensions/vscode/src/activation/NextEditWindowManager.ts index 1c1da98fd09..c799c8009ed 100644 --- a/extensions/vscode/src/activation/NextEditWindowManager.ts +++ b/extensions/vscode/src/activation/NextEditWindowManager.ts @@ -41,10 +41,11 @@ const SVG_CONFIG = { // filter: `drop-shadow(4px 4px 0px rgba(112, 114, 209, 0.4)) // drop-shadow(8px 8px 0px rgba(107, 166, 205, 0.3)) // drop-shadow(12px 12px 0px rgba(136, 194, 163, 0.2));`, - filter: `drop-shadow(4px 4px 0px rgba(112, 114, 209, 0.4)) - drop-shadow(-2px 4px 0px rgba(107, 166, 205, 0.3)) - drop-shadow(4px -2px 0px rgba(136, 194, 163, 0.2)) - drop-shadow(-2px -2px 0px rgba(112, 114, 209, 0.2));`, + // filter: `drop-shadow(4px 4px 0px rgba(112, 114, 209, 0.4)) + // drop-shadow(-2px 4px 0px rgba(107, 166, 205, 0.3)) + // drop-shadow(4px -2px 0px rgba(136, 194, 163, 0.2)) + // drop-shadow(-2px -2px 0px rgba(112, 114, 209, 0.2));`, + filter: "none", radius: 3, leftMargin: 40, defaultText: "", From 503f0de5c376b36772b2654c05333d9a4777a622 Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 16 Sep 2025 00:19:51 -0700 Subject: [PATCH 02/10] reduce border radius to 2 in next edit svg --- core/codeRenderer/CodeRenderer.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/codeRenderer/CodeRenderer.ts b/core/codeRenderer/CodeRenderer.ts index c61d2e3cbc9..fab79eb3e30 100644 --- a/core/codeRenderer/CodeRenderer.ts +++ b/core/codeRenderer/CodeRenderer.ts @@ -256,7 +256,7 @@ export class CodeRenderer { } - + ${lineBackgrounds} ${guts} @@ -322,7 +322,7 @@ export class CodeRenderer { const isFirst = index === 0; const isLast = index === lines.length - 1; const isSingleLine = isFirst && isLast; - const radius = 10; + const radius = 2; // Handle single line case (both first and last) if (isSingleLine) { @@ -357,7 +357,7 @@ export class CodeRenderer { L ${0} ${y + options.lineHeight - radius} Q ${0} ${y + options.lineHeight} ${radius} ${y + options.lineHeight} L ${options.dimensions.width - radius} ${y + options.lineHeight} - Q ${options.dimensions.width} ${y + options.lineHeight} ${options.dimensions.width} ${y + options.lineHeight - 10} + Q ${options.dimensions.width} ${y + options.lineHeight} ${options.dimensions.width} ${y + options.lineHeight - radius} L ${options.dimensions.width} ${y} Z" fill="${bgColor}" />` From 74c258af515c2cbe57c7704d1f5b5e7663f9ead2 Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 16 Sep 2025 00:30:07 -0700 Subject: [PATCH 03/10] update border --- core/codeRenderer/CodeRenderer.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/core/codeRenderer/CodeRenderer.ts b/core/codeRenderer/CodeRenderer.ts index fab79eb3e30..6ea0261c297 100644 --- a/core/codeRenderer/CodeRenderer.ts +++ b/core/codeRenderer/CodeRenderer.ts @@ -236,13 +236,13 @@ export class CodeRenderer { currLineOffsetFromTop, newDiffLines, ); - // console.log(highlightedCodeHtml); const { guts, lineBackgrounds } = this.convertShikiHtmlToSvgGut( highlightedCodeHtml, options, ); const backgroundColor = this.getBackgroundColor(highlightedCodeHtml); + const borderColor = "#6b6b6b"; const lines = code.split("\n"); const actualHeight = lines.length * options.lineHeight; @@ -256,12 +256,11 @@ export class CodeRenderer { } - + ${lineBackgrounds} ${guts} `; - // console.log(svg); return Buffer.from(svg, "utf8"); } From 90faa0135add01d32a426127aa6d98b51f497396 Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 16 Sep 2025 00:59:36 -0700 Subject: [PATCH 04/10] remove strikethrough from next edit deletion ui --- extensions/vscode/src/activation/NextEditWindowManager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/extensions/vscode/src/activation/NextEditWindowManager.ts b/extensions/vscode/src/activation/NextEditWindowManager.ts index c799c8009ed..bb449f59b66 100644 --- a/extensions/vscode/src/activation/NextEditWindowManager.ts +++ b/extensions/vscode/src/activation/NextEditWindowManager.ts @@ -945,7 +945,6 @@ export class NextEditWindowManager { const deleteDecorationType = vscode.window.createTextEditorDecorationType({ backgroundColor: "rgba(255, 0, 0, 0.5)", - textDecoration: "line-through", }); editor.setDecorations(deleteDecorationType, charsToDelete); From 862a0691b0d4aa12e46884492f7d61e70ce74a78 Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 16 Sep 2025 09:00:05 -0700 Subject: [PATCH 05/10] remove diff background for now --- core/codeRenderer/CodeRenderer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/codeRenderer/CodeRenderer.ts b/core/codeRenderer/CodeRenderer.ts index 6ea0261c297..1d8154d7ddd 100644 --- a/core/codeRenderer/CodeRenderer.ts +++ b/core/codeRenderer/CodeRenderer.ts @@ -314,7 +314,7 @@ export class CodeRenderer { const bgColor = classes.includes("highlighted") ? this.editorLineHighlight : classes.includes("diff add") - ? "rgba(255, 255, 0, 0.2)" + ? "rgba(0, 255, 0, 0.0)" : this.editorBackground; const y = index * options.lineHeight; From 2b74810b073522a71cb646b7f493be922efd3ac5 Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 16 Sep 2025 09:21:00 -0700 Subject: [PATCH 06/10] feat: word-level diff in next edit svg --- core/codeRenderer/CodeRenderer.ts | 122 +++++++++++------- .../src/activation/NextEditWindowManager.ts | 11 +- 2 files changed, 83 insertions(+), 50 deletions(-) diff --git a/core/codeRenderer/CodeRenderer.ts b/core/codeRenderer/CodeRenderer.ts index 1d8154d7ddd..c09b1ef51f7 100644 --- a/core/codeRenderer/CodeRenderer.ts +++ b/core/codeRenderer/CodeRenderer.ts @@ -19,7 +19,7 @@ import { getSingletonHighlighter, Highlighter, } from "shiki"; -import { DiffLine } from ".."; +import { DiffChar, DiffLine } from ".."; import { escapeForSVG, kebabOfThemeStr } from "../util/text"; interface CodeRendererOptions { @@ -228,6 +228,7 @@ export class CodeRenderer { options: ConversionOptions, currLineOffsetFromTop: number, newDiffLines: DiffLine[], + newDiffChars: DiffChar[], ): Promise { const strokeWidth = 1; const highlightedCodeHtml = await this.highlightCode( @@ -240,6 +241,7 @@ export class CodeRenderer { const { guts, lineBackgrounds } = this.convertShikiHtmlToSvgGut( highlightedCodeHtml, options, + newDiffChars, ); const backgroundColor = this.getBackgroundColor(highlightedCodeHtml); const borderColor = "#6b6b6b"; @@ -268,11 +270,56 @@ export class CodeRenderer { convertShikiHtmlToSvgGut( shikiHtml: string, options: ConversionOptions, + diffChars: DiffChar[], ): { guts: string; lineBackgrounds: string } { const dom = new JSDOM(shikiHtml); const document = dom.window.document; const lines = Array.from(document.querySelectorAll(".line")); + + const additionSegmentsByLine = new Map< + number, + Array<{ start: number; end: number }> + >(); + + diffChars.forEach((diff) => { + if ( + diff.type !== "new" || + diff.newLineIndex === undefined || + diff.newCharIndexInLine === undefined + ) { + return; + } + + if (diff.char.includes("\n")) { + return; + } + + const start = diff.newCharIndexInLine; + const end = start + diff.char.length; + const existing = additionSegmentsByLine.get(diff.newLineIndex) ?? []; + existing.push({ start, end }); + additionSegmentsByLine.set(diff.newLineIndex, existing); + }); + + additionSegmentsByLine.forEach((segments, lineIndex) => { + segments.sort((a, b) => a.start - b.start); + const merged: Array<{ start: number; end: number }> = []; + segments.forEach((segment) => { + if (merged.length === 0) { + merged.push({ ...segment }); + return; + } + + const last = merged[merged.length - 1]; + if (segment.start <= last.end) { + last.end = Math.max(last.end, segment.end); + } else { + merged.push({ ...segment }); + } + }); + additionSegmentsByLine.set(lineIndex, merged); + }); const svgLines = lines.map((line, index) => { const spans = Array.from(line.childNodes) .map((node) => { @@ -308,60 +355,37 @@ export class CodeRenderer { return `${spans}`; }); + const estimatedCharWidth = options.fontSize * 0.6; + const additionFill = "rgba(40, 167, 69, 0.25)"; + const lineBackgrounds = lines .map((line, index) => { const classes = line?.getAttribute("class") || ""; - const bgColor = classes.includes("highlighted") - ? this.editorLineHighlight - : classes.includes("diff add") - ? "rgba(0, 255, 0, 0.0)" - : this.editorBackground; - const y = index * options.lineHeight; - const isFirst = index === 0; - const isLast = index === lines.length - 1; - const isSingleLine = isFirst && isLast; - const radius = 2; - - // Handle single line case (both first and last) - if (isSingleLine) { - return ``; + const segments = additionSegmentsByLine.get(index) ?? []; + const backgrounds: string[] = []; + + if (classes.includes("highlighted")) { + backgrounds.push( + ``, + ); } - // SVG notes: - // By default SVGs have anti-aliasing on. - // This is undesirable in our case because pixel-perfect alignment of these rectangles will introduce thin gaps. - // Turning it off with 'shape-rendering="crispEdges"' solves the issue. - return isFirst - ? `` - : isLast - ? `` - : ``; + segments.forEach(({ start, end }) => { + const widthInChars = Math.max(end - start, 0); + if (widthInChars <= 0) { + return; + } + const x = start * estimatedCharWidth; + const segmentWidth = widthInChars * estimatedCharWidth; + backgrounds.push( + ``, + ); + }); + + return backgrounds.join("\n"); }) + .filter((bg) => bg.length > 0) .join("\n"); return { @@ -393,6 +417,7 @@ export class CodeRenderer { options: ConversionOptions, currLineOffsetFromTop: number, newDiffLines: DiffLine[], + newDiffChars: DiffChar[], ): Promise { switch (options.imageType) { // case "png": @@ -412,6 +437,7 @@ export class CodeRenderer { options, currLineOffsetFromTop, newDiffLines, + newDiffChars, ); return `data:image/svg+xml;base64,${svgBuffer.toString("base64")}`; } diff --git a/extensions/vscode/src/activation/NextEditWindowManager.ts b/extensions/vscode/src/activation/NextEditWindowManager.ts index bb449f59b66..a19a34b3fe6 100644 --- a/extensions/vscode/src/activation/NextEditWindowManager.ts +++ b/extensions/vscode/src/activation/NextEditWindowManager.ts @@ -414,6 +414,8 @@ export class NextEditWindowManager { ); } + const diffChars = myersCharDiff(oldEditRangeSlice, newEditRangeSlice); + // Create and apply decoration with the text. if (newEditRangeSlice !== "") { try { @@ -424,6 +426,7 @@ export class NextEditWindowManager { newEditRangeSlice, this.editableRegionStartLine, diffLines, + diffChars, ); } catch (error) { console.error("Failed to render window:", error); @@ -433,8 +436,6 @@ export class NextEditWindowManager { } } - const diffChars = myersCharDiff(oldEditRangeSlice, newEditRangeSlice); - this.renderDeletions(editor, diffChars); // Reserve tab and esc to either accept or reject the displayed next edit contents. @@ -687,6 +688,7 @@ export class NextEditWindowManager { text: string, currLineOffsetFromTop: number, newDiffLines: DiffLine[], + diffChars: DiffChar[], ): Promise< | { uri: vscode.Uri; dimensions: { width: number; height: number } } | undefined @@ -711,6 +713,7 @@ export class NextEditWindowManager { }, currLineOffsetFromTop, newDiffLines, + diffChars, ); return { @@ -734,12 +737,14 @@ export class NextEditWindowManager { position: vscode.Position, editableRegionStartLine: number, newDiffLines: DiffLine[], + diffChars: DiffChar[], ): Promise { const currLineOffsetFromTop = position.line - editableRegionStartLine; const uriAndDimensions = await this.createCodeRender( predictedCode, currLineOffsetFromTop, newDiffLines, + diffChars, ); if (!uriAndDimensions) { return undefined; @@ -858,6 +863,7 @@ export class NextEditWindowManager { predictedCode: string, editableRegionStartLine: number, newDiffLines: DiffLine[], + diffChars: DiffChar[], ) { // Capture document version to detect changes. const docVersion = editor.document.version; @@ -869,6 +875,7 @@ export class NextEditWindowManager { position, editableRegionStartLine, newDiffLines, + diffChars, ); if (!decoration) { console.error("Failed to create decoration for text:", predictedCode); From 89ec58967d89eafb46b3fcf50f82f6771fa03b0d Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 16 Sep 2025 12:00:19 -0700 Subject: [PATCH 07/10] fix: don't alternate non-responses due to empty chain --- .../src/autocomplete/completionProvider.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/extensions/vscode/src/autocomplete/completionProvider.ts b/extensions/vscode/src/autocomplete/completionProvider.ts index d9901a3d076..7ed0ddc9c61 100644 --- a/extensions/vscode/src/autocomplete/completionProvider.ts +++ b/extensions/vscode/src/autocomplete/completionProvider.ts @@ -364,6 +364,22 @@ export class ContinueCompletionProvider } } } else if (chainExists) { + if ( + this.usingFullFileDiff && + this.prefetchQueue.processedCount === 0 && + this.prefetchQueue.unprocessedCount === 0 + ) { + // Skipping jump logic due to empty queues while using full file diff + // Without this we would get alternating non-responses + this.nextEditProvider.deleteChain(); + return this.provideInlineCompletionItems( + document, + position, + context, + token, + ); + } + // Case 3: Accepting next edit outcome (chain exists, jump is not taken). console.log("trigger reason: accepting"); From e05081b73b0293237d8c92f3c094bcb207f9c0b0 Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 16 Sep 2025 14:42:26 -0700 Subject: [PATCH 08/10] fix: log next edit completions to continue console --- core/llm/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/llm/index.ts b/core/llm/index.ts index f8fefd3818f..cd9c9678b1e 100644 --- a/core/llm/index.ts +++ b/core/llm/index.ts @@ -1041,6 +1041,10 @@ export abstract class BaseLLM implements ILLM { const msg = fromChatResponse(response); yield msg; completion = this._formatChatMessage(msg); + interaction?.logItem({ + kind: "message", + message: msg, + }); } else { // Stream true const stream = this.openaiAdapter.chatCompletionStream( From 9f589fcf02359fc69a30cdff9512a8072be20670 Mon Sep 17 00:00:00 2001 From: Nate Date: Tue, 16 Sep 2025 15:06:57 -0700 Subject: [PATCH 09/10] fix: test and update chaining behavior to avoid repetitive requests and avoid alternating non-responses --- .../ContinueCompletionProvider.vitest.ts | 488 ++++++++++++++++++ .../src/autocomplete/completionProvider.ts | 43 +- 2 files changed, 514 insertions(+), 17 deletions(-) create mode 100644 extensions/vscode/src/autocomplete/__tests__/ContinueCompletionProvider.vitest.ts diff --git a/extensions/vscode/src/autocomplete/__tests__/ContinueCompletionProvider.vitest.ts b/extensions/vscode/src/autocomplete/__tests__/ContinueCompletionProvider.vitest.ts new file mode 100644 index 00000000000..9d6e816c69c --- /dev/null +++ b/extensions/vscode/src/autocomplete/__tests__/ContinueCompletionProvider.vitest.ts @@ -0,0 +1,488 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import * as vscode from "vscode"; + +import { ContinueCompletionProvider } from "../completionProvider"; + +import * as NextEditLoggingServiceModule from "core/nextEdit/NextEditLoggingService"; +import * as PrefetchQueueModule from "core/nextEdit/NextEditPrefetchQueue"; +import * as NextEditProviderModule from "core/nextEdit/NextEditProvider"; +import * as JumpManagerModule from "../../activation/JumpManager"; + +type MockNextEditProvider = ReturnType; +type MockPrefetchQueue = ReturnType; +type MockJumpManager = ReturnType; + +const mockOutcome = { + completion: "suggested change", + diffLines: [], + editableRegionStartLine: 0, + editableRegionEndLine: 0, +} as any; + +let mockNextEditProvider: MockNextEditProvider; +let mockPrefetchQueue: MockPrefetchQueue; +let mockJumpManager: MockJumpManager; + +beforeEach(() => { + vi.clearAllMocks(); + + mockNextEditProvider = createMockNextEditProvider(); + (NextEditProviderModule as any).__setMockNextEditProviderInstance( + mockNextEditProvider, + ); + + mockPrefetchQueue = createMockPrefetchQueue(); + (PrefetchQueueModule as any).__setMockPrefetchQueueInstance( + mockPrefetchQueue, + ); + + mockJumpManager = createMockJumpManager(); + (JumpManagerModule as any).__setMockJumpManagerInstance(mockJumpManager); + + const mockLoggingService = createMockLoggingService(); + (NextEditLoggingServiceModule as any).__setMockNextEditLoggingServiceInstance( + mockLoggingService, + ); + + (vscode.window as any).activeTextEditor = null; +}); + +describe("ContinueCompletionProvider triggering logic", () => { + it("starts a new chain when none exists", async () => { + const document = createDocument(); + setActiveEditor(document); + + const provider = buildProvider(); + + await provider.provideInlineCompletionItems( + document, + createPosition(), + createContext(), + createToken(), + ); + + expect(mockNextEditProvider.startChain).toHaveBeenCalledTimes(1); + expect( + mockNextEditProvider.provideInlineCompletionItems, + ).toHaveBeenCalledTimes(1); + expect(mockNextEditProvider.deleteChain).not.toHaveBeenCalled(); + }); + + it("clears an empty chain once in full file diff mode", async () => { + const document = createDocument(); + setActiveEditor(document); + + mockNextEditProvider.chainExists.mockReturnValue(true); + mockPrefetchQueue.__setProcessed([]); + mockPrefetchQueue.__setUnprocessed([]); + + const provider = buildProvider(); + + await provider.provideInlineCompletionItems( + document, + createPosition(), + createContext(), + createToken(), + ); + + expect(mockNextEditProvider.deleteChain).toHaveBeenCalledTimes(1); + expect(mockNextEditProvider.startChain).toHaveBeenCalledTimes(1); + expect( + mockNextEditProvider.provideInlineCompletionItems, + ).toHaveBeenCalledTimes(1); + }); + + it("returns null after clearing empty chain when no outcome is available", async () => { + const document = createDocument(); + setActiveEditor(document); + + mockNextEditProvider.chainExists.mockReturnValue(true); + mockPrefetchQueue.__setProcessed([]); + mockPrefetchQueue.__setUnprocessed([]); + mockNextEditProvider.provideInlineCompletionItems.mockResolvedValueOnce( + undefined, + ); + + const provider = buildProvider(); + + const result = await provider.provideInlineCompletionItems( + document, + createPosition(), + createContext(), + createToken(), + ); + + expect(result).toBeNull(); + expect(mockNextEditProvider.deleteChain).toHaveBeenCalledTimes(1); + expect(mockNextEditProvider.startChain).toHaveBeenCalledTimes(1); + expect( + mockNextEditProvider.provideInlineCompletionItems, + ).toHaveBeenCalledTimes(1); + }); + + it("uses queued outcomes when processed items exist", async () => { + const document = createDocument(); + setActiveEditor(document); + + mockNextEditProvider.chainExists.mockReturnValue(true); + mockPrefetchQueue.__setProcessed([ + { + location: { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + }, + outcome: mockOutcome, + }, + ]); + mockJumpManager.suggestJump.mockResolvedValue(true); + + const provider = buildProvider(); + + await provider.provideInlineCompletionItems( + document, + createPosition(), + createContext(), + createToken(), + ); + + expect(mockPrefetchQueue.dequeueProcessed).toHaveBeenCalledTimes(1); + expect(mockJumpManager.setCompletionAfterJump).toHaveBeenCalledTimes(1); + expect(mockNextEditProvider.startChain).not.toHaveBeenCalled(); + expect(mockNextEditProvider.deleteChain).not.toHaveBeenCalled(); + expect( + mockNextEditProvider.provideInlineCompletionItems, + ).not.toHaveBeenCalled(); + }); +}); + +function buildProvider(options: { usingFullFileDiff?: boolean } = {}) { + const usingFullFileDiff = options.usingFullFileDiff ?? true; + const configHandler = { + loadConfig: vi.fn(async () => ({ + config: { selectedModelByRole: { autocomplete: undefined } }, + })), + } as any; + + const ide = { ideUtils: {} } as any; + const webviewProtocol = {} as any; + + const provider = new ContinueCompletionProvider( + configHandler, + ide, + webviewProtocol, + usingFullFileDiff, + ); + provider.activateNextEdit(); + return provider; +} + +function createDocument( + text = "function example() {\n return true;\n}", +): vscode.TextDocument { + const lines = text.split("\n"); + return { + uri: vscode.Uri.parse("file:///test"), + isUntitled: false, + getText: (range?: any) => { + if (!range) { + return text; + } + const startLine = range.start?.line ?? 0; + const endLine = range.end?.line ?? startLine; + const startChar = range.start?.character ?? 0; + const endChar = range.end?.character ?? lines[endLine]?.length ?? 0; + if (startLine === endLine) { + const lineText = lines[startLine] ?? ""; + return lineText.slice(startChar, endChar); + } + return text; + }, + lineAt: (position: any) => { + const lineNumber = + typeof position === "number" ? position : position.line; + const lineText = lines[lineNumber] ?? ""; + const range = new (vscode.Range as any)( + new (vscode.Position as any)(lineNumber, 0), + new (vscode.Position as any)(lineNumber, lineText.length), + ); + return { + lineNumber, + text: lineText, + range, + rangeIncludingLineBreak: range, + firstNonWhitespaceCharacterIndex: 0, + isEmptyOrWhitespace: lineText.trim().length === 0, + } as unknown as vscode.TextLine; + }, + } as unknown as vscode.TextDocument; +} + +function createContext(): any { + return { + triggerKind: (vscode.InlineCompletionTriggerKind as any).Automatic, + selectedCompletionInfo: undefined, + }; +} + +function createPosition(line = 0, character = 0) { + return new (vscode.Position as any)(line, character); +} + +function createToken(): any { + return { + isCancellationRequested: false, + onCancellationRequested: vi.fn(), + }; +} + +function setActiveEditor(document: any, cursor = createPosition()) { + const selection = { active: cursor, anchor: cursor }; + (vscode.window as any).activeTextEditor = { + document, + selection, + selections: [selection], + }; +} + +function createMockNextEditProvider() { + return { + chainExists: vi.fn(() => false), + startChain: vi.fn(), + deleteChain: vi.fn(async () => {}), + provideInlineCompletionItems: vi.fn(async () => mockOutcome), + provideInlineCompletionItemsWithChain: vi.fn(async () => mockOutcome), + markDisplayed: vi.fn(), + getChainLength: vi.fn(() => 0), + }; +} + +function createMockPrefetchQueue() { + let processedItems: any[] = []; + let unprocessedItems: any[] = []; + + const queue: any = { + initialize: vi.fn(), + process: vi.fn(), + peekThreeProcessed: vi.fn(), + dequeueProcessed: vi.fn(() => processedItems.shift()), + enqueueProcessed: vi.fn((item: any) => { + processedItems.push(item); + }), + __setProcessed(items: any[]) { + processedItems = [...items]; + }, + __setUnprocessed(items: any[]) { + unprocessedItems = [...items]; + }, + }; + + Object.defineProperty(queue, "processedCount", { + get: () => processedItems.length, + }); + + Object.defineProperty(queue, "unprocessedCount", { + get: () => unprocessedItems.length, + }); + + return queue; +} + +function createMockJumpManager() { + return { + isJumpInProgress: vi.fn(() => false), + setJumpInProgress: vi.fn(), + completionAfterJump: undefined, + clearCompletionAfterJump: vi.fn(), + setCompletionAfterJump: vi.fn(), + suggestJump: vi.fn(async () => false), + wasJumpJustAccepted: vi.fn(() => false), + }; +} + +function createMockLoggingService() { + return { + trackPendingCompletion: vi.fn(), + handleAbort: vi.fn(), + markDisplayed: vi.fn(), + cancelRejectionTimeout: vi.fn(), + deleteAbortController: vi.fn(), + cancel: vi.fn(), + }; +} + +vi.mock("vscode", () => { + class Position { + constructor( + public line: number, + public character: number, + ) {} + } + + class Range { + constructor( + public start: Position, + public end: Position, + ) {} + } + + class InlineCompletionItem { + public insertText: string; + public range: Range; + public command?: any; + + constructor(insertText: string, range: Range, command?: any) { + this.insertText = insertText; + this.range = range; + this.command = command; + } + } + + const window = { + activeTextEditor: null as any, + showErrorMessage: vi.fn(() => Promise.resolve(undefined)), + }; + + const workspace = { + notebookDocuments: [] as any[], + getConfiguration: vi.fn(() => ({ get: vi.fn() })), + }; + + return { + window, + workspace, + Uri: { parse: (value: string) => ({ toString: () => value }) }, + Position, + Range, + InlineCompletionItem, + InlineCompletionTriggerKind: { Automatic: 0, Invoke: 1 }, + NotebookCellKind: { Markup: 1 }, + }; +}); + +vi.mock("core/autocomplete/CompletionProvider", () => { + return { + CompletionProvider: class { + provideInlineCompletionItems = vi.fn(); + markDisplayed = vi.fn(); + }, + }; +}); + +vi.mock("core/autocomplete/util/processSingleLineCompletion", () => ({ + processSingleLineCompletion: vi.fn((text: string) => ({ + completionText: text, + range: { start: 0, end: text.length }, + })), +})); + +vi.mock("../statusBar", () => { + const StatusBarStatus = { + Enabled: "enabled", + Disabled: "disabled", + } as const; + + return { + StatusBarStatus, + getStatusBarStatus: vi.fn(() => StatusBarStatus.Enabled), + setupStatusBar: vi.fn(), + stopStatusBarLoading: vi.fn(), + }; +}); + +vi.mock("../GhostTextAcceptanceTracker", () => { + const instance = { + setExpectedGhostTextAcceptance: vi.fn(), + }; + return { + GhostTextAcceptanceTracker: { + getInstance: () => instance, + }, + }; +}); + +vi.mock("../lsp", () => ({ + getDefinitionsFromLsp: vi.fn(), +})); + +vi.mock("../recentlyEdited", () => ({ + RecentlyEditedTracker: class { + async getRecentlyEditedRanges() { + return []; + } + }, +})); + +vi.mock("../RecentlyVisitedRangesService", () => ({ + RecentlyVisitedRangesService: class { + getSnippets() { + return []; + } + }, +})); + +vi.mock("../activation/NextEditWindowManager", () => ({ + NextEditWindowManager: { + isInstantiated: vi.fn(() => false), + getInstance: vi.fn(), + }, +})); + +vi.mock("../../activation/JumpManager", () => { + let instance: any = null; + return { + JumpManager: { + getInstance: () => instance, + }, + __setMockJumpManagerInstance(value: any) { + instance = value; + }, + }; +}); + +vi.mock("core/nextEdit/NextEditPrefetchQueue", () => { + let instance: any = null; + return { + PrefetchQueue: { + getInstance: () => instance, + }, + __setMockPrefetchQueueInstance(value: any) { + instance = value; + }, + }; +}); + +vi.mock("core/nextEdit/NextEditProvider", () => { + let instance: any = null; + return { + NextEditProvider: { + initialize: vi.fn(() => instance), + getInstance: vi.fn(() => instance), + }, + __setMockNextEditProviderInstance(value: any) { + instance = value; + }, + }; +}); + +vi.mock("core/nextEdit/NextEditLoggingService", () => { + let instance: any = null; + return { + NextEditLoggingService: { + getInstance: () => instance, + }, + __setMockNextEditLoggingServiceInstance(value: any) { + instance = value; + }, + }; +}); + +vi.mock("core/nextEdit/diff/diff", () => ({ + checkFim: vi.fn(() => ({ isFim: true, fimText: "ghost" })), +})); + +vi.mock("../util/errorHandling", () => ({ + handleLLMError: vi.fn(async () => false), +})); diff --git a/extensions/vscode/src/autocomplete/completionProvider.ts b/extensions/vscode/src/autocomplete/completionProvider.ts index 7ed0ddc9c61..30b48220f77 100644 --- a/extensions/vscode/src/autocomplete/completionProvider.ts +++ b/extensions/vscode/src/autocomplete/completionProvider.ts @@ -330,10 +330,25 @@ export class ContinueCompletionProvider // Determine why this method was triggered. const isJumping = this.jumpManager.isJumpInProgress(); - const chainExists = this.nextEditProvider.chainExists(); + let chainExists = this.nextEditProvider.chainExists(); + const processedCount = this.prefetchQueue.processedCount; + const unprocessedCount = this.prefetchQueue.unprocessedCount; console.log("isJumping:", isJumping, "/ chainExists:", chainExists); this.prefetchQueue.peekThreeProcessed(); + let resetChainInFullFileDiff = false; + if ( + chainExists && + this.usingFullFileDiff && + processedCount === 0 && + unprocessedCount === 0 + ) { + // Skipping jump logic due to empty queues while using full file diff + await this.nextEditProvider.deleteChain(); + chainExists = false; + resetChainInFullFileDiff = true; + } + if (isJumping && chainExists) { // Case 2: Jumping (chain exists, jump was taken) console.log("trigger reason: jumping"); @@ -364,22 +379,6 @@ export class ContinueCompletionProvider } } } else if (chainExists) { - if ( - this.usingFullFileDiff && - this.prefetchQueue.processedCount === 0 && - this.prefetchQueue.unprocessedCount === 0 - ) { - // Skipping jump logic due to empty queues while using full file diff - // Without this we would get alternating non-responses - this.nextEditProvider.deleteChain(); - return this.provideInlineCompletionItems( - document, - position, - context, - token, - ); - } - // Case 3: Accepting next edit outcome (chain exists, jump is not taken). console.log("trigger reason: accepting"); @@ -437,6 +436,7 @@ export class ContinueCompletionProvider } } else { // Case 1: Typing (chain does not exist). + // if resetChainInFullFileDiff is true then we are Rebuilding next edit chain after clearing empty queues in full file diff mode this.nextEditProvider.startChain(); const input: AutocompleteInput = { @@ -455,6 +455,15 @@ export class ContinueCompletionProvider { withChain: false, usingFullFileDiff: this.usingFullFileDiff }, ); + if ( + resetChainInFullFileDiff && + (!outcome || + (!outcome.completion && outcome.diffLines.length === 0)) + ) { + // No next edit outcome after resetting chain; returning null + return null; + } + // Start prefetching next edits if not using full file diff. // NOTE: this is better off not awaited. fire and forget. if (!this.usingFullFileDiff) { From 51d1180b08a17dada984ac8a1beea40d56084623 Mon Sep 17 00:00:00 2001 From: Nate Date: Wed, 17 Sep 2025 09:54:13 -0700 Subject: [PATCH 10/10] feat: placement strategies for next edit to avoid it going off screen --- .github/workflows/respond-to-cubic.yaml | 4 +- .../activation/NextEditPlacementStrategy.ts | 287 ++++++++++++++++++ .../src/activation/NextEditWindowManager.ts | 117 ++++++- .../ContinueCompletionProvider.vitest.ts | 225 +++++++++++++- 4 files changed, 616 insertions(+), 17 deletions(-) create mode 100644 extensions/vscode/src/activation/NextEditPlacementStrategy.ts diff --git a/.github/workflows/respond-to-cubic.yaml b/.github/workflows/respond-to-cubic.yaml index 252c2538c92..fb653db9fb9 100644 --- a/.github/workflows/respond-to-cubic.yaml +++ b/.github/workflows/respond-to-cubic.yaml @@ -103,7 +103,7 @@ jobs: # Start remote session and capture JSON output echo "Starting cn with prompt..." - SESSION_OUTPUT=$(cat /tmp/prompt.txt | cn remote -s --config continuedev/address-code-review --branch ${{ steps.pr-info.outputs.head_ref }}) + SESSION_OUTPUT=$(cat /tmp/prompt.txt | cn remote -s --config continuedev/address-code-review --idempotency-key ${{ steps.pr-info.outputs.head_ref }} --branch ${{ steps.pr-info.outputs.head_ref }}) echo "Raw session output: $SESSION_OUTPUT" # Extract URL from JSON output @@ -254,7 +254,7 @@ jobs: # Start remote session and capture JSON output echo "Starting cn with prompt..." - SESSION_OUTPUT=$(cat /tmp/prompt.txt | cn remote -s --config continuedev/address-code-review --branch ${{ github.event.pull_request.head.ref }}) + SESSION_OUTPUT=$(cat /tmp/prompt.txt | cn remote -s --config continuedev/address-code-review --idempotency-key ${{ github.event.pull_request.head.ref }} --branch ${{ github.event.pull_request.head.ref }}) echo "Raw session output: $SESSION_OUTPUT" # Extract URL from JSON output diff --git a/extensions/vscode/src/activation/NextEditPlacementStrategy.ts b/extensions/vscode/src/activation/NextEditPlacementStrategy.ts new file mode 100644 index 00000000000..d293ab56214 --- /dev/null +++ b/extensions/vscode/src/activation/NextEditPlacementStrategy.ts @@ -0,0 +1,287 @@ +import { DiffLine } from "core"; +// @ts-ignore +import * as vscode from "vscode"; + +/** + * Describes the relevant context for deciding where to place a next-edit SVG tooltip. + */ +export interface NextEditPlacementContext { + editor: vscode.TextEditor; + cursorPosition: vscode.Position; + editableRegionStartLine: number; + editableRegionEndLine: number; + originalCode: string; + predictedCode: string; + diffLines: DiffLine[]; +} + +/** + * Placement decision containing the anchor line and additional padding to apply. + */ +export interface NextEditPlacementResult { + line: number; + padding: number; +} + +/** + * Strategy responsible for deciding where a next-edit tooltip should be rendered. + */ +export interface NextEditPlacementStrategy { + getPlacement(context: NextEditPlacementContext): NextEditPlacementResult; +} + +/** + * Basic strategy that always renders on the cursor line without extra padding. + */ +export class SameLinePlacementStrategy + implements NextEditPlacementStrategy +{ + public getPlacement({ cursorPosition }: NextEditPlacementContext) { + return { + line: cursorPosition.line, + padding: 0, + } satisfies NextEditPlacementResult; + } +} + +export interface ViewportAwarePlacementStrategyOptions { + fallbackMaxLineLength?: number; + searchRadius?: number; + minPadding?: number; + maxPadding?: number; + paddingBuffer?: number; +} + +const DEFAULT_VIEWPORT_AWARE_OPTIONS: Required = { + fallbackMaxLineLength: 100, + searchRadius: 6, + minPadding: 1, + maxPadding: 6, + paddingBuffer: 4, +}; + +/** + * Chooses a nearby line with enough trailing space to keep the tooltip within the viewport. + */ +export class ViewportAwarePlacementStrategy + implements NextEditPlacementStrategy +{ + private readonly options: Required; + + constructor(options: ViewportAwarePlacementStrategyOptions = {}) { + this.options = { ...DEFAULT_VIEWPORT_AWARE_OPTIONS, ...options }; + } + + /** + * Pick a line and padding that keep the tooltip near the cursor without overflowing horizontally. + */ + public getPlacement(context: NextEditPlacementContext): NextEditPlacementResult { + const predictedLongestLine = this.getLongestLineLength(context.predictedCode); + const maxContentWidth = this.estimateMaxContentWidth( + context.editor, + predictedLongestLine, + ); + + const candidateLines = this.getCandidateLines(context); + const candidatesWithLength = candidateLines.map((line) => ({ + line, + length: this.getLineLength(context.editor, line), + distance: Math.abs(line - context.cursorPosition.line), + })); + + const fitsWithinViewport = candidatesWithLength.filter( + ({ length }) => + length + predictedLongestLine + this.options.minPadding <= maxContentWidth, + ); + + const fallback = + this.pickBestCandidate(candidatesWithLength) ?? + this.createFallbackCandidate(context); + + const target = this.pickBestCandidate(fitsWithinViewport) ?? fallback; + + const padding = this.calculatePadding( + target.length, + predictedLongestLine, + maxContentWidth, + ); + + return { line: target.line, padding }; + } + + /** + * Sort candidate lines by proximity and length, returning the best match if present. + */ + private pickBestCandidate( + candidates: { line: number; length: number; distance: number }[], + ) { + const sorted = [...candidates].sort((a, b) => { + if (a.distance !== b.distance) { + return a.distance - b.distance; + } + if (a.length !== b.length) { + return a.length - b.length; + } + return a.line - b.line; + }); + + return sorted[0]; + } + + /** + * Determine how much horizontal padding can fit without overflowing. + */ + private calculatePadding( + lineLength: number, + predictedLongestLine: number, + maxContentWidth: number, + ): number { + const available = maxContentWidth - (lineLength + predictedLongestLine); + + if (available <= 0) { + return 0; + } + + const capped = Math.min(this.options.maxPadding, available); + return Math.max(this.options.minPadding, Math.min(capped, available)); + } + + /** + * Gather candidate line numbers around the cursor, preferring visible lines first. + */ + private getCandidateLines( + context: NextEditPlacementContext, + ): number[] { + const visibleBounds = this.getVisibleLineBounds(context.editor); + const candidates = this.collectLines( + context.cursorPosition.line, + context.editor.document.lineCount, + visibleBounds, + this.options.searchRadius, + ); + + if (candidates.length > 0) { + return candidates; + } + + return this.collectLines( + context.cursorPosition.line, + context.editor.document.lineCount, + undefined, + this.options.searchRadius, + ); + } + + /** + * Collect lines within the supplied radius that satisfy the optional visibility constraint. + */ + private collectLines( + baseLine: number, + lineCount: number, + visibleBounds: { start: number; end: number } | undefined, + radius: number, + ): number[] { + const added = new Set(); + const limit = Math.max(lineCount - 1, 0); + + for (let delta = 0; delta <= radius; delta++) { + const up = baseLine - delta; + const down = baseLine + delta; + + if (this.shouldIncludeLine(up, limit, visibleBounds, added)) { + added.add(up); + } + if (this.shouldIncludeLine(down, limit, visibleBounds, added)) { + added.add(down); + } + } + + return Array.from(added).sort((a, b) => a - b); + } + + /** + * Validate whether a candidate line is within range and not already considered. + */ + private shouldIncludeLine( + line: number, + maxLine: number, + visibleBounds: { start: number; end: number } | undefined, + added: Set, + ) { + if (line < 0 || line > maxLine || added.has(line)) { + return false; + } + + if (!visibleBounds) { + return true; + } + + return line >= visibleBounds.start && line <= visibleBounds.end; + } + + /** + * Compute the trimmed length of the longest line in the rendered tooltip content. + */ + private getLongestLineLength(text: string): number { + const lines = text.split("\n"); + return lines.reduce( + (longest, line) => Math.max(longest, line.trimEnd().length), + 0, + ); + } + + /** + * Measure the trimmed length of the requested line, guarding against document churn. + */ + private getLineLength(editor: vscode.TextEditor, line: number): number { + try { + return editor.document.lineAt(line).text.trimEnd().length; + } catch (error) { + console.error("Failed to read line for placement", error); + return 0; + } + } + + /** + * Fallback placement that keeps the tooltip attached to the cursor line. + */ + private createFallbackCandidate(context: NextEditPlacementContext) { + return { + line: context.cursorPosition.line, + length: this.getLineLength(context.editor, context.cursorPosition.line), + distance: 0, + }; + } + + /** + * Estimate how much horizontal space the editor can show without clipping. + */ + private estimateMaxContentWidth( + editor: vscode.TextEditor, + predictedLongestLine: number, + ): number { + const config = vscode.workspace.getConfiguration("editor"); + const wrapColumn = config.get("wordWrapColumn") ?? 0; + const baseWidth = + wrapColumn > 0 ? wrapColumn : this.options.fallbackMaxLineLength; + + const minimumWidth = + predictedLongestLine + this.options.minPadding + this.options.paddingBuffer; + + return Math.max(baseWidth - this.options.paddingBuffer, minimumWidth); + } + + /** + * Determine the visible line range for the active editor, if available. + */ + private getVisibleLineBounds(editor: vscode.TextEditor) { + if (editor.visibleRanges.length === 0) { + return undefined; + } + + const start = Math.min(...editor.visibleRanges.map((range) => range.start.line)); + const end = Math.max(...editor.visibleRanges.map((range) => range.end.line)); + + return { start, end }; + } +} diff --git a/extensions/vscode/src/activation/NextEditWindowManager.ts b/extensions/vscode/src/activation/NextEditWindowManager.ts index a19a34b3fe6..e47b49babc9 100644 --- a/extensions/vscode/src/activation/NextEditWindowManager.ts +++ b/extensions/vscode/src/activation/NextEditWindowManager.ts @@ -14,6 +14,11 @@ import { HandlerPriority, SelectionChangeManager, } from "./SelectionChangeManager"; +import { + NextEditPlacementResult, + NextEditPlacementStrategy, + ViewportAwarePlacementStrategy, +} from "./NextEditPlacementStrategy"; export interface TextApplier { applyText( @@ -144,6 +149,8 @@ export class NextEditWindowManager { private context: vscode.ExtensionContext | null = null; + private placementStrategy: NextEditPlacementStrategy; + public static getInstance(): NextEditWindowManager { if (!NextEditWindowManager.instance) { NextEditWindowManager.instance = new NextEditWindowManager(); @@ -162,6 +169,13 @@ export class NextEditWindowManager { } } + /** + * Override the placement behaviour with a custom strategy implementation. + */ + public setPlacementStrategy(strategy: NextEditPlacementStrategy): void { + this.placementStrategy = strategy; + } + private constructor() { this.theme = getThemeString(); @@ -179,6 +193,8 @@ export class NextEditWindowManager { this.fontFamily = editorConfig.get("fontFamily") ?? "monospace"; this.loggingService = NextEditLoggingService.getInstance(); + + this.placementStrategy = new ViewportAwarePlacementStrategy(); } // This is an implementation of last-action-wins. @@ -738,6 +754,7 @@ export class NextEditWindowManager { editableRegionStartLine: number, newDiffLines: DiffLine[], diffChars: DiffChar[], + placement: NextEditPlacementResult, ): Promise { const currLineOffsetFromTop = position.line - editableRegionStartLine; const uriAndDimensions = await this.createCodeRender( @@ -755,10 +772,10 @@ export class NextEditWindowManager { const tipHeight = dimensions.height; const offsetFromTop = - (position.line - editableRegionStartLine) * SVG_CONFIG.lineHeight; + (placement.line - editableRegionStartLine) * SVG_CONFIG.lineHeight; - // Position the decoration with minimal left margin since it's already at line end - const marginLeft = SVG_CONFIG.paddingX; // Use consistent padding instead of complex calculation + // Use placement padding to control the horizontal gap from the anchor line. + const marginLeft = this.getMarginLeftForPadding(placement.padding); return vscode.window.createTextEditorDecorationType({ before: { @@ -842,15 +859,87 @@ export class NextEditWindowManager { } /** - * Calculate a position to the right of the cursor with the specified offset. + * Calculate the end-of-line position for the requested anchor line. */ private getDecorationOffsetPosition( editor: vscode.TextEditor, - position: vscode.Position, + line: number, ): vscode.Position { - // Place decoration at the end of the current line - const line = editor.document.lineAt(position.line); - return new vscode.Position(position.line, line.text.length); + const safeLine = Math.min( + Math.max(line, 0), + Math.max(editor.document.lineCount - 1, 0), + ); + const targetLine = editor.document.lineAt(safeLine); + return new vscode.Position(safeLine, targetLine.text.length); + } + + /** + * Ask the active placement strategy where the tooltip should be anchored. + */ + private determinePlacement( + editor: vscode.TextEditor, + cursorPosition: vscode.Position, + originalCode: string, + predictedCode: string, + editableRegionStartLine: number, + diffLines: DiffLine[], + ): NextEditPlacementResult { + const context = { + editor, + cursorPosition, + editableRegionStartLine, + editableRegionEndLine: this.editableRegionEndLine, + originalCode, + predictedCode, + diffLines, + }; + + let placement: NextEditPlacementResult; + try { + placement = this.placementStrategy.getPlacement(context); + } catch (error) { + console.error("Error determining next edit placement", error); + placement = { line: cursorPosition.line, padding: 0 }; + } + + return this.normalizePlacement(editor, placement, cursorPosition.line); + } + + /** + * Clamp the strategy output to sane defaults so decorating never throws. + */ + private normalizePlacement( + editor: vscode.TextEditor, + placement: NextEditPlacementResult, + fallbackLine: number, + ): NextEditPlacementResult { + const lineCount = Math.max(editor.document.lineCount, 1); + const fallback = Math.min(Math.max(fallbackLine, 0), lineCount - 1); + + const rawLine = + typeof placement.line === "number" && Number.isFinite(placement.line) + ? placement.line + : fallback; + const safeLine = Math.min( + Math.max(Math.round(rawLine), 0), + lineCount - 1, + ); + + const rawPadding = + typeof placement.padding === "number" && + Number.isFinite(placement.padding) + ? placement.padding + : 0; + const safePadding = Math.max(0, rawPadding); + + return { line: safeLine, padding: safePadding }; + } + + /** + * Convert padding expressed in characters into a left margin in pixels. + */ + private getMarginLeftForPadding(padding: number): number { + return SVG_CONFIG.paddingX + padding * SVG_CONFIG.paddingX; } /** @@ -865,6 +954,15 @@ export class NextEditWindowManager { newDiffLines: DiffLine[], diffChars: DiffChar[], ) { + const placement = this.determinePlacement( + editor, + position, + originalCode, + predictedCode, + editableRegionStartLine, + newDiffLines, + ); + // Capture document version to detect changes. const docVersion = editor.document.version; @@ -876,6 +974,7 @@ export class NextEditWindowManager { editableRegionStartLine, newDiffLines, diffChars, + placement, ); if (!decoration) { console.error("Failed to create decoration for text:", predictedCode); @@ -896,7 +995,7 @@ export class NextEditWindowManager { // Calculate how far off to the right of the cursor the decoration should be. const decorationOffsetPosition = this.getDecorationOffsetPosition( editor, - position, + placement.line, ); const range = new vscode.Range( decorationOffsetPosition, diff --git a/extensions/vscode/src/autocomplete/__tests__/ContinueCompletionProvider.vitest.ts b/extensions/vscode/src/autocomplete/__tests__/ContinueCompletionProvider.vitest.ts index 9d6e816c69c..2f561d78e0a 100644 --- a/extensions/vscode/src/autocomplete/__tests__/ContinueCompletionProvider.vitest.ts +++ b/extensions/vscode/src/autocomplete/__tests__/ContinueCompletionProvider.vitest.ts @@ -156,6 +156,180 @@ describe("ContinueCompletionProvider triggering logic", () => { mockNextEditProvider.provideInlineCompletionItems, ).not.toHaveBeenCalled(); }); + + it("chains jump suggestions for subsequent method comments", async () => { + const snippet = `class Calculator { + constructor() { + this.result = 0; + } + + add(number) { + this.result += number; + return this; + } + + // Subtract a number from the curren + subtract(number) { + this.result -= number; + return this; + } + + multiply(number) { + this.result *= number; + return this; + } + + divide(number) { + if (number === 0) { + throw new Error("Cannot divide by zero"); + } + this.result /= number; + return this; + } + + getResult() { + return this.result; + } + + reset() { + this.result = 0; + return this; + } +} +`; + + const { text, cursor } = parseTextWithCursorMarker(snippet); + + const document = createDocument(text); + const context = createContext(); + const token = createToken(); + + setActiveEditor(document, createPosition(cursor.line, cursor.character)); + + const provider = buildProvider(); + + const subtractComment = + " // Subtract a number from the current result"; + const subtractOutcome = createNextEditOutcomeForTest( + subtractComment, + cursor.line, + ); + + mockNextEditProvider.provideInlineCompletionItems.mockResolvedValueOnce( + subtractOutcome, + ); + + const initialResult = await provider.provideInlineCompletionItems( + document, + createPosition(cursor.line, cursor.character), + context, + token, + ); + + expect(Array.isArray(initialResult)).toBe(true); + expect((initialResult as vscode.InlineCompletionItem[])[0].insertText).toBe( + subtractComment, + ); + + // Subsequent calls operate on an existing chain. + mockNextEditProvider.chainExists.mockReturnValue(true); + + const jumpSequence = [ + { + completion: " // Multiply the current result by a number", + line: 15, + }, + { + completion: " // Divide the current result by a number", + line: 20, + }, + { + completion: " // Get the final result", + line: 28, + }, + { + completion: " // Reset the calculator to start fresh", + line: 32, + }, + ]; + + mockPrefetchQueue.__setProcessed( + jumpSequence.map(({ completion, line }) => ({ + location: { + range: { + start: { line, character: 2 }, + end: { line, character: 2 }, + }, + }, + outcome: createNextEditOutcomeForTest(completion, line), + })), + ); + + mockJumpManager.suggestJump.mockImplementation(async () => true); + + // Position cursor at the start of the accepted subtract comment. + setActiveEditor(document, createPosition(cursor.line, 2)); + + let currentLine = cursor.line; + + for (const [index, { completion, line }] of jumpSequence.entries()) { + const acceptancePosition = createPosition(currentLine, 2); + const acceptanceResult = await provider.provideInlineCompletionItems( + document, + acceptancePosition, + context, + token, + ); + + expect(acceptanceResult).toBeUndefined(); + expect(mockPrefetchQueue.dequeueProcessed).toHaveBeenCalledTimes( + index + 1, + ); + + const callIndex = + mockJumpManager.setCompletionAfterJump.mock.calls.length - 1; + const setCall = mockJumpManager.setCompletionAfterJump.mock.calls[callIndex][0]; + expect(setCall.outcome.completion).toBe(completion); + expect(setCall.currentPosition.line).toBe(line); + + // Simulate the user pressing tab to jump to the next location. + mockJumpManager.setJumpInProgress(true); + setActiveEditor(document, createPosition(line, 2)); + + const jumpResult = await provider.provideInlineCompletionItems( + document, + createPosition(line, 2), + context, + token, + ); + + expect(Array.isArray(jumpResult)).toBe(true); + const lastShown = + (provider as any)._lastShownCompletion as + | { completion: string } + | undefined; + // The final jump falls back to our minimal mock outcome, so only + // intermediate jumps assert on the actual rendered completion text. + if (index < jumpSequence.length - 1) { + expect(lastShown?.completion).toBe(completion); + } + + currentLine = line; + } + + expect( + mockJumpManager.setCompletionAfterJump.mock.calls.map( + (call) => call[0].outcome.completion, + ), + ).toEqual(jumpSequence.map((step) => step.completion)); + + expect(mockPrefetchQueue.dequeueProcessed).toHaveBeenCalledTimes( + jumpSequence.length, + ); + expect(mockJumpManager.suggestJump).toHaveBeenCalledTimes( + jumpSequence.length, + ); + }); }); function buildProvider(options: { usingFullFileDiff?: boolean } = {}) { @@ -247,6 +421,34 @@ function setActiveEditor(document: any, cursor = createPosition()) { }; } +function parseTextWithCursorMarker(textWithMarker: string) { + const marker = ""; + const index = textWithMarker.indexOf(marker); + if (index === -1) { + throw new Error("Marker not found in snippet"); + } + + const beforeMarker = textWithMarker.slice(0, index); + const line = beforeMarker.split("\n").length - 1; + const character = beforeMarker.split("\n").pop()?.length ?? 0; + const text = beforeMarker + textWithMarker.slice(index + marker.length); + + return { text, cursor: { line, character } }; +} + +function createNextEditOutcomeForTest( + completion: string, + startLine: number, + endLine = startLine, +) { + return { + completion, + diffLines: [{ type: "new", line: completion }], + editableRegionStartLine: startLine, + editableRegionEndLine: endLine, + } as any; +} + function createMockNextEditProvider() { return { chainExists: vi.fn(() => false), @@ -291,12 +493,23 @@ function createMockPrefetchQueue() { } function createMockJumpManager() { + let jumpInProgress = false; + let storedCompletion: any = null; + return { - isJumpInProgress: vi.fn(() => false), - setJumpInProgress: vi.fn(), - completionAfterJump: undefined, - clearCompletionAfterJump: vi.fn(), - setCompletionAfterJump: vi.fn(), + isJumpInProgress: vi.fn(() => jumpInProgress), + setJumpInProgress: vi.fn((value: boolean) => { + jumpInProgress = value; + }), + get completionAfterJump() { + return storedCompletion; + }, + clearCompletionAfterJump: vi.fn(() => { + storedCompletion = null; + }), + setCompletionAfterJump: vi.fn((value: any) => { + storedCompletion = value; + }), suggestJump: vi.fn(async () => false), wasJumpJustAccepted: vi.fn(() => false), }; @@ -480,7 +693,7 @@ vi.mock("core/nextEdit/NextEditLoggingService", () => { }); vi.mock("core/nextEdit/diff/diff", () => ({ - checkFim: vi.fn(() => ({ isFim: true, fimText: "ghost" })), + checkFim: vi.fn((_, newText: string) => ({ isFim: true, fimText: newText })), })); vi.mock("../util/errorHandling", () => ({