Skip to content

Commit 372d323

Browse files
author
Kilo Code
committed
✨ feat(autocomplete): improve text insertion handling
- integrate processTextInsertion to handle overlapping text - enhance createInlineCompletionItem to use insertRange ✅ test(autocomplete): add tests for CompletionTextProcessor - add unit tests for processTextInsertion function - include MockTextEditor and MockTextDocument for testing - test various completion and overlap scenarios
1 parent 45e4858 commit 372d323

File tree

6 files changed

+464
-17
lines changed

6 files changed

+464
-17
lines changed

src/services/autocomplete/AutocompleteProvider.ts

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { AutocompleteDecorationAnimation } from "./AutocompleteDecorationAnimati
1010
import { isHumanEdit } from "./utils/EditDetectionUtils"
1111
import { ExperimentId } from "@roo-code/types"
1212
import { ClineProvider } from "../../core/webview/ClineProvider"
13+
import { processTextInsertion, InsertionContext } from "./utils/CompletionTextProcessor"
1314

1415
export const UI_UPDATE_DEBOUNCE_MS = 250
1516
export const BAIL_OUT_TOO_MANY_LINES_LIMIT = 100
@@ -213,30 +214,31 @@ function setupAutocomplete(context: vscode.ExtensionContext): vscode.Disposable
213214
const linePrefix = document
214215
.getText(new vscode.Range(new vscode.Position(position.line, 0), position))
215216
.trimStart()
216-
console.log(`🚀🛑 Autocomplete for line with prefix: "${linePrefix}"!`)
217-
218217
const codeContext = await contextGatherer.gatherContext(document, position, true, true)
218+
console.log(`🚀🛑 Autocomplete for line: '${codeContext.currentLine}'!`)
219219

220220
// Check if we have a cached completion for this context
221221
const cacheKey = generateCacheKey(codeContext)
222222
const cachedCompletions = completionsCache.get(cacheKey) ?? []
223223
for (const completion of cachedCompletions) {
224224
if (completion.startsWith(linePrefix)) {
225-
// Only show the remaining part of the completion
226-
const remainingSuffix = completion.substring(linePrefix.length)
227-
if (remainingSuffix.length > 0) {
228-
console.log(`🚀🎯 Using cached completions (${cachedCompletions.length} options)`)
225+
// Process the completion text to avoid duplicating existing text in the document
226+
const processedResult = processTextInsertion({ document, position, textToInsert: completion })
227+
if (processedResult) {
228+
console.log(
229+
`🚀🎯 Using cached completion '${processedResult.processedText}' (${cachedCompletions.length} options)`,
230+
)
229231
animationManager.stopAnimation()
230-
return [createInlineCompletionItem(remainingSuffix, position)]
232+
return [createInlineCompletionItem(processedResult.processedText, processedResult.insertRange)]
231233
}
232234
}
233235
}
234236

235-
const result = await debouncedGenerateCompletion({ document, codeContext, position })
236-
if (!result || token.isCancellationRequested) {
237+
const generationResult = await debouncedGenerateCompletion({ document, codeContext, position })
238+
if (!generationResult || token.isCancellationRequested) {
237239
return null
238240
}
239-
const { processedCompletion, cost } = result
241+
const { processedCompletion, cost } = generationResult
240242
console.log(`🚀🛑🚀🛑🚀🛑🚀🛑🚀🛑 \n`, {
241243
processedCompletion,
242244
cost: humanFormatCost(cost || 0),
@@ -260,7 +262,11 @@ function setupAutocomplete(context: vscode.ExtensionContext): vscode.Disposable
260262
completionsCache.set(cacheKey, completions)
261263
}
262264

263-
return [createInlineCompletionItem(processedCompletion, position)]
265+
const processedResult = processTextInsertion({ document, position, textToInsert: processedCompletion })
266+
if (processedResult) {
267+
return [createInlineCompletionItem(processedResult.processedText, processedResult.insertRange)]
268+
}
269+
return null
264270
},
265271
}
266272

@@ -377,18 +383,14 @@ Model: ${DEFAULT_MODEL}\
377383
/**
378384
* Creates an inline completion item with tracking command
379385
* @param completionText The text to be inserted as completion
380-
* @param insertRange The range where the completion should be inserted
381-
* @param position The position in the document
382386
* @returns A configured vscode.InlineCompletionItem
383387
*/
384-
function createInlineCompletionItem(completionText: string, position: vscode.Position): vscode.InlineCompletionItem {
385-
const insertRange = new vscode.Range(position, position)
386-
388+
function createInlineCompletionItem(completionText: string, insertRange: vscode.Range): vscode.InlineCompletionItem {
387389
return Object.assign(new vscode.InlineCompletionItem(completionText, insertRange), {
388390
command: {
389391
command: "kilo-code.trackAcceptedSuggestion",
390392
title: "Track Accepted Suggestion",
391-
arguments: [completionText, position],
393+
arguments: [completionText],
392394
},
393395
})
394396
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import "./autocompleteTestSetup"
2+
import * as vscode from "vscode"
3+
import { processTextInsertion, InsertionContext } from "../utils/CompletionTextProcessor"
4+
import { MockTextEditor } from "./MockTextEditor"
5+
6+
describe("CompletionTextProcessor", () => {
7+
test("should trim beginning when first line of completion starts with existing text", () => {
8+
const completionText = "const x = 1 + 2;\nconsole.log(x);"
9+
const { document, cursorPosition } = MockTextEditor.create("const x = ␣")
10+
11+
const result = processTextInsertion({ document, position: cursorPosition, textToInsert: completionText })
12+
expect(result?.processedText).toBe("1 + 2;\nconsole.log(x);")
13+
})
14+
15+
test("should trim end when last line of completion matches existing text", () => {
16+
const completionText = "function add(a, b) {\n return a + b;\n}"
17+
const { document, cursorPosition } = MockTextEditor.create("function add(a, b) {␣\n}")
18+
19+
const result = processTextInsertion({ document, position: cursorPosition, textToInsert: completionText })
20+
expect(result?.processedText).toBe("\n return a + b;") // only the non-overlapping part
21+
})
22+
23+
test("should trim both beginning and end when both match", () => {
24+
const completionText = "if (condition) {\n doSomething();\n}"
25+
const { document, cursorPosition } = MockTextEditor.create("if (condition) {␣\n}")
26+
27+
const result = processTextInsertion({ document, position: cursorPosition, textToInsert: completionText })
28+
expect(result?.processedText).toBe("\n doSomething();") // only the non-overlapping part
29+
})
30+
31+
test("should return null when completion text is completely contained in existing text", () => {
32+
const completionText = "const x = 1;"
33+
const { document, cursorPosition } = MockTextEditor.create("const x = 1;␣")
34+
35+
const result = processTextInsertion({ document, position: cursorPosition, textToInsert: completionText })
36+
expect(result).toBeNull()
37+
})
38+
39+
test("should return original completion when there's no overlap", () => {
40+
const completionText = "const y = 2;"
41+
const { document, cursorPosition } = MockTextEditor.create("const x = 1;␣")
42+
43+
const result = processTextInsertion({ document, position: cursorPosition, textToInsert: completionText })
44+
expect(result?.processedText).toBe("const y = 2;")
45+
})
46+
47+
test("should handle the example case from the code", () => {
48+
const completionText = "a + b\n}"
49+
const { document, cursorPosition } = MockTextEditor.create("a + ␣")
50+
51+
const result = processTextInsertion({ document, position: cursorPosition, textToInsert: completionText })
52+
expect(result?.processedText).toBe("b\n}")
53+
})
54+
55+
test("should handle auto-closing parenthesis", () => {
56+
const completionText = "a, b) {\n return a + b\n}"
57+
const { document, cursorPosition } = MockTextEditor.create(`function sum(␣)`)
58+
59+
const result = processTextInsertion({ document, position: cursorPosition, textToInsert: completionText })
60+
expect(result?.processedText).toBe("a, b) {\n return a + b\n}")
61+
expect(result?.insertRange).toEqual(new vscode.Range(cursorPosition, cursorPosition.translate(0, 1)))
62+
})
63+
64+
test("should handle the case where closing brace already exists", () => {
65+
const completionText = "a + b\n}"
66+
const { document, cursorPosition } = MockTextEditor.create("a + ␣\n}")
67+
68+
const result = processTextInsertion({ document, position: cursorPosition, textToInsert: completionText })
69+
expect(result?.processedText).toBe("b")
70+
})
71+
72+
test("should handle common prefix with different suffixes", () => {
73+
const completionText = "export function calculateSum(a: number, b: number)\n return a + b;"
74+
const { document, cursorPosition } = MockTextEditor.create("export function calculate␣): number {")
75+
76+
const result = processTextInsertion({ document, position: cursorPosition, textToInsert: completionText })
77+
// Should find the common prefix "export function calculate" and only return the remaining part
78+
expect(result?.processedText).toBe("Sum(a: number, b: number)\n return a + b;")
79+
})
80+
81+
test("should provide modification information", () => {
82+
const completionText = "const x = 1 + 2;\nconsole.log(x);"
83+
const { document, cursorPosition } = MockTextEditor.create("const x = ␣")
84+
85+
const result = processTextInsertion({ document, position: cursorPosition, textToInsert: completionText })
86+
expect(result?.modifications).toEqual({
87+
prefixTrimmed: 10, // "const x = " plus trailing space
88+
suffixTrimmed: 0,
89+
originalLength: completionText.length,
90+
})
91+
})
92+
93+
test("should handle whitespace before completion text", () => {
94+
const completionText = "return a + b"
95+
const { document, cursorPosition } = MockTextEditor.create(
96+
`\
97+
function sum(a:number, b:number): number {
98+
return ␣
99+
}`,
100+
)
101+
102+
const result = processTextInsertion({ document, position: cursorPosition, textToInsert: completionText })
103+
expect(result?.processedText).toBe("a + b")
104+
})
105+
})
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import * as vscode from "vscode"
2+
3+
/**
4+
* Create a mock TextDocument for testing
5+
* This implementation does not contain cursor position information
6+
*/
7+
export class MockTextDocument {
8+
private contentLines: string[]
9+
10+
constructor(content: string) {
11+
this.contentLines = content.split("\n")
12+
}
13+
14+
getText(range?: vscode.Range): string {
15+
if (!range) {
16+
return this.contentLines.join("\n")
17+
}
18+
19+
const startLine = range.start.line
20+
const endLine = range.end.line
21+
22+
if (startLine === endLine) {
23+
return this.contentLines[startLine].substring(range.start.character, range.end.character)
24+
}
25+
26+
const lines: string[] = []
27+
for (let i = startLine; i <= endLine && i < this.contentLines.length; i++) {
28+
if (i === startLine) {
29+
lines.push(this.contentLines[i].substring(range.start.character))
30+
} else if (i === endLine) {
31+
lines.push(this.contentLines[i].substring(0, range.end.character))
32+
} else {
33+
lines.push(this.contentLines[i])
34+
}
35+
}
36+
37+
return lines.join("\n")
38+
}
39+
40+
get lineCount(): number {
41+
return this.contentLines.length
42+
}
43+
44+
/**
45+
* Returns information about a specific line in the document
46+
* @param lineNumber The zero-based line number
47+
* @returns A simplified TextLine object containing the text and position information
48+
*/
49+
lineAt(lineNumber: number): vscode.TextLine {
50+
if (lineNumber < 0 || lineNumber >= this.contentLines.length) {
51+
throw new Error(`Invalid line number: ${lineNumber}`)
52+
}
53+
54+
const text = this.contentLines[lineNumber]
55+
const range = new vscode.Range(new vscode.Position(lineNumber, 0), new vscode.Position(lineNumber, text.length))
56+
57+
return {
58+
text,
59+
range,
60+
lineNumber,
61+
rangeIncludingLineBreak: range,
62+
firstNonWhitespaceCharacterIndex: text.search(/\S|$/),
63+
isEmptyOrWhitespace: !/\S/.test(text),
64+
} as vscode.TextLine
65+
}
66+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import * as vscode from "vscode"
2+
import { MockTextDocument } from "./MockTextDocument"
3+
4+
/**
5+
* Special character used to mark cursor position in test documents.
6+
* Using "␣" (U+2423, OPEN BOX) as it's visually distinct and unlikely to be in normal code.
7+
*/
8+
export const CURSOR_MARKER = "␣"
9+
10+
/**
11+
* MockTextEditor encapsulates both a TextDocument and cursor position
12+
* for simpler testing of editor-related functionality
13+
*/
14+
export class MockTextEditor {
15+
private _document: vscode.TextDocument
16+
private _cursorPosition: vscode.Position
17+
18+
/**
19+
* Creates a new MockTextEditor
20+
* @param content Text content with required cursor marker (CURSOR_MARKER)
21+
*/
22+
constructor(content: string) {
23+
// Find cursor position and remove the marker
24+
const cursorOffset = content.indexOf(CURSOR_MARKER)
25+
if (cursorOffset === -1) {
26+
throw new Error(`Cursor marker ${CURSOR_MARKER} not found in test content`)
27+
}
28+
29+
// Remove the cursor marker
30+
const cleanContent = content.substring(0, cursorOffset) + content.substring(cursorOffset + CURSOR_MARKER.length)
31+
32+
// Calculate line and character for cursor position
33+
const beforeCursor = content.substring(0, cursorOffset)
34+
const lines = beforeCursor.split("\n")
35+
const line = lines.length - 1
36+
const character = lines[line].length
37+
38+
this._cursorPosition = new vscode.Position(line, character)
39+
this._document = new MockTextDocument(cleanContent) as unknown as vscode.TextDocument
40+
}
41+
42+
get document(): vscode.TextDocument {
43+
return this._document
44+
}
45+
46+
get cursorPosition(): vscode.Position {
47+
return this._cursorPosition
48+
}
49+
50+
static create(content: string): MockTextEditor {
51+
return new MockTextEditor(content)
52+
}
53+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// This file should be imported at the top of test files
2+
// It sets up common mocks used in tests
3+
4+
// Mock the vscode module
5+
jest.mock("vscode", () => ({
6+
Position: class {
7+
constructor(
8+
public line: number,
9+
public character: number,
10+
) {
11+
this.line = line
12+
this.character = character
13+
}
14+
15+
translate(lineDelta: number, characterDelta: number): any {
16+
return new (jest.requireMock("vscode").Position)(this.line + lineDelta, this.character + characterDelta)
17+
}
18+
},
19+
Range: class {
20+
constructor(
21+
public start: any,
22+
public end: any,
23+
) {
24+
this.start = start
25+
this.end = end
26+
}
27+
},
28+
InlineCompletionItem: class {
29+
constructor(
30+
public text: string,
31+
public range: any,
32+
) {
33+
this.text = text
34+
this.range = range
35+
}
36+
},
37+
EndOfLine: {
38+
LF: 1,
39+
CRLF: 2,
40+
},
41+
}))

0 commit comments

Comments
 (0)