diff --git a/src/services/ghost/classic-auto-complete/GhostInlineCompletionProvider.ts b/src/services/ghost/classic-auto-complete/GhostInlineCompletionProvider.ts index 6f04ebc330a..386a803c9f8 100644 --- a/src/services/ghost/classic-auto-complete/GhostInlineCompletionProvider.ts +++ b/src/services/ghost/classic-auto-complete/GhostInlineCompletionProvider.ts @@ -19,37 +19,74 @@ export type CostTrackingCallback = ( cacheReadTokens: number, ) => void +export interface MatchingSuggestionResult { + text: string + originalSuggestion: FillInAtCursorSuggestion +} + +function isAutoClosingChar(char: string): boolean { + return [")", "]", "}", ">", '"', "'", "`"].includes(char) +} + +function removePotentialAutoBracket(suffix: string): string { + return suffix.length > 0 && isAutoClosingChar(suffix[0]) ? suffix.substring(1) : suffix +} + +function checkPrefixSuffixMatch( + prefix: string, + suffix: string, + expectedPrefix: string, + expectedSuffix: string, +): { matches: boolean; cleanedSuffix: string } { + const cleanedSuffix = removePotentialAutoBracket(suffix) + + if (prefix === expectedPrefix && suffix === expectedSuffix) { + return { matches: true, cleanedSuffix: suffix } + } + + if (prefix === expectedPrefix && cleanedSuffix === expectedSuffix) { + return { matches: true, cleanedSuffix } + } + + return { matches: false, cleanedSuffix } +} + /** * Find a matching suggestion from the history based on current prefix and suffix * @param prefix - The text before the cursor position * @param suffix - The text after the cursor position * @param suggestionsHistory - Array of previous suggestions (most recent last) - * @returns The matching suggestion text, or null if no match found + * @returns The matching suggestion result with text and original suggestion, or null if no match found */ export function findMatchingSuggestion( prefix: string, suffix: string, suggestionsHistory: FillInAtCursorSuggestion[], -): string | null { +): MatchingSuggestionResult | null { // Search from most recent to least recent for (let i = suggestionsHistory.length - 1; i >= 0; i--) { const fillInAtCursor = suggestionsHistory[i] - // First, try exact prefix/suffix match - if (prefix === fillInAtCursor.prefix && suffix === fillInAtCursor.suffix) { - return fillInAtCursor.text + const exactMatch = checkPrefixSuffixMatch(prefix, suffix, fillInAtCursor.prefix, fillInAtCursor.suffix) + if (exactMatch.matches) { + return { + text: fillInAtCursor.text, + originalSuggestion: fillInAtCursor, + } } - // If no exact match, check for partial typing - // The user may have started typing the suggested text - if (prefix.startsWith(fillInAtCursor.prefix) && suffix === fillInAtCursor.suffix) { - // Extract what the user has typed between the original prefix and current position - const typedContent = prefix.substring(fillInAtCursor.prefix.length) + if (prefix.startsWith(fillInAtCursor.prefix)) { + const partialMatch = checkPrefixSuffixMatch(prefix, suffix, fillInAtCursor.prefix, fillInAtCursor.suffix) - // Check if the typed content matches the beginning of the suggestion - if (fillInAtCursor.text.startsWith(typedContent)) { - // Return the remaining part of the suggestion (with already-typed portion removed) - return fillInAtCursor.text.substring(typedContent.length) + if (partialMatch.cleanedSuffix === fillInAtCursor.suffix) { + const typedContent = prefix.substring(fillInAtCursor.prefix.length) + + if (fillInAtCursor.text.startsWith(typedContent)) { + return { + text: fillInAtCursor.text.substring(typedContent.length), + originalSuggestion: fillInAtCursor, + } + } } } } @@ -237,12 +274,26 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte ): Promise { const { prefix, suffix } = extractPrefixSuffix(document, position) - const matchingText = findMatchingSuggestion(prefix, suffix, this.suggestionsHistory) + const matchingResult = findMatchingSuggestion(prefix, suffix, this.suggestionsHistory) + + if (matchingResult !== null) { + // Check if suffix has a new auto-inserted bracket at the start + // This happens when VS Code's bracket completion runs before our suggestion + const suffixFirstChar = suffix.length > 0 ? suffix[0] : "" + const originalSuffixFirstChar = + matchingResult.originalSuggestion.suffix.length > 0 ? matchingResult.originalSuggestion.suffix[0] : "" + + // Detect if a bracket was auto-inserted: + // 1. Current suffix starts with an auto-closing character + // 2. Original suffix didn't start with that character (or was different) + const hasAutoInsertedBracket = + isAutoClosingChar(suffixFirstChar) && suffixFirstChar !== originalSuffixFirstChar - if (matchingText !== null) { const item: vscode.InlineCompletionItem = { - insertText: matchingText, - range: new vscode.Range(position, position), + insertText: matchingResult.text, + range: hasAutoInsertedBracket + ? new vscode.Range(position, new vscode.Position(position.line, position.character + 1)) // Replace the auto-bracket + : new vscode.Range(position, position), // Just insert } return [item] } diff --git a/src/services/ghost/classic-auto-complete/__tests__/GhostInlineCompletionProvider.test.ts b/src/services/ghost/classic-auto-complete/__tests__/GhostInlineCompletionProvider.test.ts index 3d878e426c6..37ef2a74413 100644 --- a/src/services/ghost/classic-auto-complete/__tests__/GhostInlineCompletionProvider.test.ts +++ b/src/services/ghost/classic-auto-complete/__tests__/GhostInlineCompletionProvider.test.ts @@ -3,6 +3,7 @@ import { GhostInlineCompletionProvider, findMatchingSuggestion, CostTrackingCallback, + MatchingSuggestionResult, } from "../GhostInlineCompletionProvider" import { GhostSuggestionsState, FillInAtCursorSuggestion } from "../GhostSuggestions" import { MockTextDocument } from "../../../mocking/MockTextDocument" @@ -34,7 +35,9 @@ describe("findMatchingSuggestion", () => { ] const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - expect(result).toBe("console.log('Hello, World!');") + expect(result).not.toBeNull() + expect(result?.text).toBe("console.log('Hello, World!');") + expect(result?.originalSuggestion).toEqual(suggestions[0]) }) it("should return null when prefix does not match", () => { @@ -81,7 +84,9 @@ describe("findMatchingSuggestion", () => { // User typed "cons" after the prefix const result = findMatchingSuggestion("const x = 1cons", "\nconst y = 2", suggestions) - expect(result).toBe("ole.log('Hello, World!');") + expect(result).not.toBeNull() + expect(result?.text).toBe("ole.log('Hello, World!');") + expect(result?.originalSuggestion).toEqual(suggestions[0]) }) it("should return full suggestion when no partial typing", () => { @@ -94,7 +99,8 @@ describe("findMatchingSuggestion", () => { ] const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - expect(result).toBe("console.log('test');") + expect(result).not.toBeNull() + expect(result?.text).toBe("console.log('test');") }) it("should return null when partially typed content does not match suggestion", () => { @@ -121,7 +127,8 @@ describe("findMatchingSuggestion", () => { ] const result = findMatchingSuggestion("const x = 1console.log('test');", "\nconst y = 2", suggestions) - expect(result).toBe("") + expect(result).not.toBeNull() + expect(result?.text).toBe("") }) it("should return null when suffix has changed during partial typing", () => { @@ -149,7 +156,8 @@ describe("findMatchingSuggestion", () => { // User typed "function te" const result = findMatchingSuggestion("const x = 1function te", "\nconst y = 2", suggestions) - expect(result).toBe("st() { return 42; }") + expect(result).not.toBeNull() + expect(result?.text).toBe("st() { return 42; }") }) it("should be case-sensitive in partial matching", () => { @@ -183,7 +191,8 @@ describe("findMatchingSuggestion", () => { ] const result = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - expect(result).toBe("second suggestion") + expect(result).not.toBeNull() + expect(result?.text).toBe("second suggestion") }) it("should match different suggestions based on context", () => { @@ -201,10 +210,12 @@ describe("findMatchingSuggestion", () => { ] const result1 = findMatchingSuggestion("const x = 1", "\nconst y = 2", suggestions) - expect(result1).toBe("first suggestion") + expect(result1).not.toBeNull() + expect(result1?.text).toBe("first suggestion") const result2 = findMatchingSuggestion("const a = 1", "\nconst b = 2", suggestions) - expect(result2).toBe("second suggestion") + expect(result2).not.toBeNull() + expect(result2?.text).toBe("second suggestion") }) it("should prefer exact match over partial match", () => { @@ -223,7 +234,8 @@ describe("findMatchingSuggestion", () => { // User is at position that matches exact prefix of second suggestion const result = findMatchingSuggestion("const x = 1cons", "\nconst y = 2", suggestions) - expect(result).toBe("exact match") + expect(result).not.toBeNull() + expect(result?.text).toBe("exact match") }) }) }) @@ -808,6 +820,106 @@ describe("GhostInlineCompletionProvider", () => { expect(result).toEqual([]) }) }) + + describe("auto-bracket detection", () => { + it("should replace auto-inserted closing bracket when detected", async () => { + // Set up a suggestion with no bracket in suffix + const suggestions = new GhostSuggestionsState() + suggestions.setFillInAtCursor({ + text: "useState(true);", + prefix: "const x = ", + suffix: "\nconst y = 2", + }) + provider.updateSuggestions(suggestions) + + // Simulate VS Code auto-inserting a closing bracket after typing "[" + // Document now has: "const x = ]\nconst y = 2" + // Position is right after "= " and before the auto-inserted "]" + const documentWithBracket = new MockTextDocument( + vscode.Uri.file("/test.ts"), + "const x = ]\nconst y = 2", + ) + const positionBeforeBracket = new vscode.Position(0, 10) // After "= ", before "]" + + const result = (await provider.provideInlineCompletionItems( + documentWithBracket, + positionBeforeBracket, + mockContext, + mockToken, + )) as vscode.InlineCompletionItem[] + + expect(result).toHaveLength(1) + expect(result[0].insertText).toBe("useState(true);") + // Should replace the auto-inserted bracket + expect(result[0].range).toEqual(new vscode.Range(positionBeforeBracket, new vscode.Position(0, 11))) + }) + + it("should not replace bracket if it was in original suffix", async () => { + // Set up a suggestion where bracket was already in suffix + const suggestions = new GhostSuggestionsState() + suggestions.setFillInAtCursor({ + text: "useState(true);", + prefix: "const x = ", + suffix: "]\nconst y = 2", + }) + provider.updateSuggestions(suggestions) + + // Same document state - bracket was already there when suggestion was cached + const documentWithBracket = new MockTextDocument( + vscode.Uri.file("/test.ts"), + "const x = ]\nconst y = 2", + ) + const positionBeforeBracket = new vscode.Position(0, 10) + + const result = (await provider.provideInlineCompletionItems( + documentWithBracket, + positionBeforeBracket, + mockContext, + mockToken, + )) as vscode.InlineCompletionItem[] + + expect(result).toHaveLength(1) + expect(result[0].insertText).toBe("useState(true);") + // Should NOT replace the bracket - just insert + expect(result[0].range).toEqual(new vscode.Range(positionBeforeBracket, positionBeforeBracket)) + }) + + it("should handle other auto-closing characters", async () => { + const testCases = [ + { char: ")", desc: "parenthesis" }, + { char: "}", desc: "curly brace" }, + { char: ">", desc: "angle bracket" }, + { char: '"', desc: "double quote" }, + { char: "'", desc: "single quote" }, + ] + + for (const { char, desc } of testCases) { + const suggestions = new GhostSuggestionsState() + suggestions.setFillInAtCursor({ + text: "test", + prefix: "const x = ", + suffix: "\nconst y = 2", + }) + provider.updateSuggestions(suggestions) + + const documentWithChar = new MockTextDocument( + vscode.Uri.file("/test.ts"), + `const x = ${char}\nconst y = 2`, + ) + const position = new vscode.Position(0, 10) + + const result = (await provider.provideInlineCompletionItems( + documentWithChar, + position, + mockContext, + mockToken, + )) as vscode.InlineCompletionItem[] + + expect(result).toHaveLength(1) + expect(result[0].range).toEqual(new vscode.Range(position, new vscode.Position(0, 11))) + } + }) + }) }) describe("cachedSuggestionAvailable", () => {