diff --git a/src/services/ghost/GhostDecorations.ts b/src/services/ghost/GhostDecorations.ts index feb39bffca7..bb11084bd10 100644 --- a/src/services/ghost/GhostDecorations.ts +++ b/src/services/ghost/GhostDecorations.ts @@ -52,23 +52,28 @@ export class GhostDecorations { /** * Display deletion operations using simple border styling + * Returns the range for accumulation */ - private displayDeleteOperationGroup(editor: vscode.TextEditor, group: GhostSuggestionEditOperation[]): void { + private createDeleteOperationRange(editor: vscode.TextEditor, group: GhostSuggestionEditOperation[]): vscode.Range { const lines = group.map((x) => x.oldLine) const from = Math.min(...lines) const to = Math.max(...lines) const start = editor.document.lineAt(from).range.start const end = editor.document.lineAt(to).range.end - const range = new vscode.Range(start, end) - - editor.setDecorations(this.deletionDecorationType, [{ range }]) + return new vscode.Range(start, end) } /** * Display suggestions using hybrid approach: SVG for edits/additions, simple styling for deletions + * Shows all groups that should use decorations + * @param suggestions - The suggestions state + * @param skipGroupIndices - Array of group indices to skip (they're shown as inline completion) */ - public async displaySuggestions(suggestions: GhostSuggestionsState): Promise { + public async displaySuggestions( + suggestions: GhostSuggestionsState, + skipGroupIndices: number[] = [], + ): Promise { const editor = vscode.window.activeTextEditor if (!editor) { return @@ -97,19 +102,39 @@ export class GhostDecorations { this.clearAll() return } - const selectedGroup = groups[selectedGroupIndex] - const groupType = suggestionsFile.getGroupType(selectedGroup) // Clear previous decorations this.clearAll() - // Route to appropriate display method - if (groupType === "/") { - await this.displayEditOperationGroup(editor, selectedGroup) - } else if (groupType === "-") { - this.displayDeleteOperationGroup(editor, selectedGroup) - } else if (groupType === "+") { - await this.displayAdditionsOperationGroup(editor, selectedGroup) + // Accumulate deletion ranges to apply all at once + const deletionRanges: vscode.Range[] = [] + + // Display each group based on whether it should use decorations + for (let i = 0; i < groups.length; i++) { + const group = groups[i] + const groupType = suggestionsFile.getGroupType(group) + + // Skip groups that are using inline completion + if (skipGroupIndices.includes(i)) { + continue + } + + // Show decoration for this group + if (groupType === "/") { + await this.displayEditOperationGroup(editor, group) + } else if (groupType === "-") { + deletionRanges.push(this.createDeleteOperationRange(editor, group)) + } else if (groupType === "+") { + await this.displayAdditionsOperationGroup(editor, group) + } + } + + // Apply all deletion decorations at once + if (deletionRanges.length > 0) { + editor.setDecorations( + this.deletionDecorationType, + deletionRanges.map((range) => ({ range })), + ) } } diff --git a/src/services/ghost/GhostInlineCompletionProvider.ts b/src/services/ghost/GhostInlineCompletionProvider.ts new file mode 100644 index 00000000000..1a7503dbe99 --- /dev/null +++ b/src/services/ghost/GhostInlineCompletionProvider.ts @@ -0,0 +1,870 @@ +import * as vscode from "vscode" +import { GhostSuggestionsState, GhostSuggestionFile } from "./GhostSuggestions" +import { GhostSuggestionEditOperation } from "./types" + +// Constants +const PLACEHOLDER_TEXT = "<<>>" +const MAX_CURSOR_DISTANCE = 5 +const COMMON_PREFIX_THRESHOLD = 0.8 + +/** + * Inline Completion Provider for Ghost Code Suggestions + * + * Provides ghost text completions at the cursor position based on + * the currently selected suggestion group using VS Code's native + * inline completion API. + */ +export class GhostInlineCompletionProvider implements vscode.InlineCompletionItemProvider { + private suggestions: GhostSuggestionsState + private onIntelliSenseDetected?: () => void + + constructor(suggestions: GhostSuggestionsState, onIntelliSenseDetected?: () => void) { + this.suggestions = suggestions + this.onIntelliSenseDetected = onIntelliSenseDetected + } + + /** + * Update the suggestions reference + */ + public updateSuggestions(suggestions: GhostSuggestionsState): void { + this.suggestions = suggestions + } + + /** + * Extract and join content from operations + */ + private extractContent(operations: GhostSuggestionEditOperation[], type?: "+" | "-"): string { + const filtered = type ? operations.filter((op) => op.type === type) : operations + return filtered + .sort((a, b) => a.line - b.line) + .map((op) => op.content) + .join("\n") + } + + /** + * Check if content is placeholder-only + */ + private isPlaceholderContent(content: string): boolean { + return content.trim() === PLACEHOLDER_TEXT + } + + /** + * Check if a deletion group is placeholder-only + */ + private isPlaceholderOnlyDeletion(group: GhostSuggestionEditOperation[]): boolean { + const deletedContent = this.extractContent(group, "-") + return this.isPlaceholderContent(deletedContent) + } + + /** + * Find common prefix between two strings + */ + private findCommonPrefix(str1: string, str2: string): string { + let i = 0 + while (i < str1.length && i < str2.length && str1[i] === str2[i]) { + i++ + } + return str1.substring(0, i) + } + + /** + * Check if added content has common prefix with deleted content + */ + private isPrefix(deletedContent: string, addedContent: string): boolean { + return addedContent.startsWith(deletedContent) + } + + /** + * Check if deletion+addition should be treated as pure addition + */ + private shouldTreatAsAddition(deletedContent: string, addedContent: string): boolean { + // If added content starts with deleted content, let common prefix logic handle this + if (this.isPrefix(deletedContent, addedContent)) { + return false + } + + // Added content starts with newline - indicates LLM wants to add content after current line + return addedContent.startsWith("\n") || addedContent.startsWith("\r\n") + } + + private shouldHandleGroupInline(groupType: "+" | "/" | "-", group?: GhostSuggestionEditOperation[]): boolean { + // Always show pure additions + if (groupType === "+") { + return true + } + + // For modifications, allow completions with common prefix + if (groupType === "/" && group) { + const deletedContent = this.extractContent(group, "-") + const addedContent = this.extractContent(group, "+") + + if (deletedContent && addedContent && this.isPrefix(deletedContent, addedContent)) { + return true + } + } + + // Don't show deletions or non-prefix modifications + return false + } + + /** + * Calculate target line for a group + */ + private getTargetLine( + group: GhostSuggestionEditOperation[], + groupType: "+" | "/" | "-", + offset: { added: number; removed: number }, + ): number { + // For modifications, use the deletion line without offsets + if (groupType === "/") { + const deleteOp = group.find((op) => op.type === "-") + return deleteOp ? deleteOp.line : group[0].line + } + + // For additions, apply the offset to account for previously removed lines + if (groupType === "+") { + return group[0].line + offset.removed + } + + // For deletions + return group[0].line + offset.added + } + + /** + * Check if group is within cursor distance + */ + private isWithinCursorDistance(cursorLine: number, targetLine: number): boolean { + return Math.abs(cursorLine - targetLine) <= MAX_CURSOR_DISTANCE + } + + /** + * Check if modification has valid common prefix for inline completion + */ + private hasValidCommonPrefixForInline(group: GhostSuggestionEditOperation[]): boolean { + const deletedContent = this.extractContent(group, "-") + const addedContent = this.extractContent(group, "+") + + if (!deletedContent || !addedContent) { + return false + } + + // If deleted content is empty or placeholder, treat as pure addition + const trimmedDeleted = deletedContent.trim() + if (trimmedDeleted.length === 0 || this.isPlaceholderContent(trimmedDeleted)) { + return true + } + + // Check if should be treated as addition + if (this.shouldTreatAsAddition(deletedContent, addedContent)) { + return true + } + + // Check for common prefix + const commonPrefix = this.findCommonPrefix(deletedContent, addedContent) + return commonPrefix.length > 0 + } + + /** + * Determine if a group should use inline completion instead of SVG decoration + */ + public shouldUseInlineCompletion( + selectedGroup: GhostSuggestionEditOperation[], + groupType: "+" | "/" | "-", + cursorLine: number, + file: any, + ): boolean { + // First check if this group type should be shown at all + if (!this.shouldHandleGroupInline(groupType, selectedGroup)) { + return false + } + + // Deletions never use inline + if (groupType === "-") { + return false + } + + // Check distance from cursor + const offset = file.getPlaceholderOffsetSelectedGroupOperations() + const targetLine = this.getTargetLine(selectedGroup, groupType, offset) + + if (!this.isWithinCursorDistance(cursorLine, targetLine)) { + return false + } + + // Pure additions always use inline + if (groupType === "+") { + return true + } + + // For modifications, check if there's a valid common prefix + return this.hasValidCommonPrefixForInline(selectedGroup) + } + + /** + * Determine if inline suggestions should be triggered for current state + */ + public shouldTriggerInline(editor: vscode.TextEditor): boolean { + if (!this.suggestions.hasSuggestions()) { + return false + } + + const file = this.suggestions.getFile(editor.document.uri) + if (!file) { + return false + } + + const groups = file.getGroupsOperations() + const selectedGroupIndex = file.getSelectedGroup() + + if (selectedGroupIndex === null || selectedGroupIndex >= groups.length) { + return false + } + + const selectedGroup = groups[selectedGroupIndex] + const selectedGroupType = file.getGroupType(selectedGroup) + + return this.shouldUseInlineCompletion(selectedGroup, selectedGroupType, editor.selection.active.line, file) + } + + /** + * Check if deletion and addition groups should be combined + * + * MULTI-GROUP COMBINATIONS: + * This is a core part of handling LLM suggestions that come as separate deletion and addition + * groups but should be treated as a single modification. When the LLM generates diffs, + * it sometimes produces: + * Group 1 (deletion): "const x =" + * Group 2 (addition): "const x = 10" + * + * These should be combined into a synthetic modification group so the inline completion + * shows only the suffix (e.g., " 10") rather than the entire addition. This provides a + * better user experience by showing only what's actually being added/changed. + */ + private shouldCombineGroups(deletedContent: string, addedContent: string): boolean { + return ( + this.isPrefix(deletedContent, addedContent) || + this.isPlaceholderContent(deletedContent) || + addedContent.startsWith("\n") || + addedContent.startsWith("\r\n") + ) + } + + /** + * Get indices of groups that should be skipped for SVG decorations + */ + public getSkipGroupIndices(file: any, editor: vscode.TextEditor): number[] { + const groups = file.getGroupsOperations() + const selectedGroupIndex = file.getSelectedGroup() + + if (selectedGroupIndex === null) { + return [] + } + + const selectedGroup = groups[selectedGroupIndex] + const selectedGroupType = file.getGroupType(selectedGroup) + const skipGroupIndices: number[] = [] + + // Filter out groups based on onlyAdditions setting + for (let i = 0; i < groups.length; i++) { + const group = groups[i] + const groupType = file.getGroupType(group) + + if (!this.shouldHandleGroupInline(groupType, group)) { + skipGroupIndices.push(i) + } + } + + // Check if selected group uses inline completion + const selectedGroupUsesInline = this.shouldUseInlineCompletion( + selectedGroup, + selectedGroupType, + editor.selection.active.line, + file, + ) + + if (selectedGroupUsesInline) { + // Always skip the selected group if it uses inline completion + if (!skipGroupIndices.includes(selectedGroupIndex)) { + skipGroupIndices.push(selectedGroupIndex) + } + + // Skip associated addition group if this is a synthetic modification + if (selectedGroupType === "-" && selectedGroupIndex + 1 < groups.length) { + const nextGroup = groups[selectedGroupIndex + 1] + const nextGroupType = file.getGroupType(nextGroup) + + if (nextGroupType === "+") { + const deletedContent = this.extractContent(selectedGroup, "-") + const addedContent = this.extractContent(nextGroup, "+") + + if (this.shouldCombineGroups(deletedContent, addedContent)) { + if (!skipGroupIndices.includes(selectedGroupIndex + 1)) { + skipGroupIndices.push(selectedGroupIndex + 1) + } + } + } + } + + // Hide ALL other groups to prevent multiple suggestions simultaneously + for (let i = 0; i < groups.length; i++) { + if (i !== selectedGroupIndex && !skipGroupIndices.includes(i)) { + skipGroupIndices.push(i) + } + } + } + + return skipGroupIndices + } + + /** + * Check if group is valid to show + */ + private isValidGroup(group: GhostSuggestionEditOperation[], groupType: "+" | "/" | "-"): boolean { + const isPlaceholder = groupType === "-" && this.isPlaceholderOnlyDeletion(group) + const shouldHandleGroupInline = this.shouldHandleGroupInline(groupType, group) + return !isPlaceholder && shouldHandleGroupInline + } + + /** + * Find next valid group index + */ + public findNextValidGroup(file: any, startIndex: number): number | null { + const groups = file.getGroupsOperations() + const maxAttempts = groups.length + let attempts = 0 + let currentIndex = startIndex + + while (attempts < maxAttempts) { + file.selectNextGroup() + attempts++ + currentIndex = file.getSelectedGroup() + + if (currentIndex !== null && currentIndex < groups.length) { + const currentGroup = groups[currentIndex] + const currentGroupType = file.getGroupType(currentGroup) + + if (this.isValidGroup(currentGroup, currentGroupType)) { + return currentIndex + } + } + + if (currentIndex === startIndex) { + break + } + } + + return null + } + + /** + * Find previous valid group index + */ + public findPreviousValidGroup(file: any, startIndex: number): number | null { + const groups = file.getGroupsOperations() + const maxAttempts = groups.length + let attempts = 0 + let currentIndex = startIndex + + while (attempts < maxAttempts) { + file.selectPreviousGroup() + attempts++ + currentIndex = file.getSelectedGroup() + + if (currentIndex !== null && currentIndex < groups.length) { + const currentGroup = groups[currentIndex] + const currentGroupType = file.getGroupType(currentGroup) + + if (this.isValidGroup(currentGroup, currentGroupType)) { + return currentIndex + } + } + + if (currentIndex === startIndex) { + break + } + } + + return null + } + + /** + * Select closest valid group after initial selection + */ + public selectClosestValidGroup(file: any, editor: vscode.TextEditor): void { + const selectedGroupIndex = file.getSelectedGroup() + if (selectedGroupIndex === null) { + return + } + + const groups = file.getGroupsOperations() + const selectedGroup = groups[selectedGroupIndex] + const selectedGroupType = file.getGroupType(selectedGroup) + + if (!this.isValidGroup(selectedGroup, selectedGroupType)) { + this.findNextValidGroup(file, selectedGroupIndex) + } + } + + /** + * Get the next addition group if it exists + */ + private getNextAdditionGroup( + file: any, + groups: GhostSuggestionEditOperation[][], + currentIndex: number, + ): { group: GhostSuggestionEditOperation[]; type: "+" } | null { + if (currentIndex + 1 < groups.length) { + const nextGroup = groups[currentIndex + 1] + const nextGroupType = file.getGroupType(nextGroup) + + if (nextGroupType === "+") { + return { group: nextGroup, type: "+" } + } + } + return null + } + + /** + * Check if groups should be combined into synthetic modification + */ + private shouldCreateSyntheticModification( + previousGroup: GhostSuggestionEditOperation[], + currentGroup: GhostSuggestionEditOperation[], + ): boolean { + const deletedContent = this.extractContent(previousGroup, "-") + const addedContent = this.extractContent(currentGroup, "+") + + if (!deletedContent || !addedContent) { + return false + } + + const trimmedDeleted = deletedContent.trim() + const commonPrefix = this.findCommonPrefix(trimmedDeleted, addedContent) + + return commonPrefix.length > 0 && commonPrefix.length >= trimmedDeleted.length * COMMON_PREFIX_THRESHOLD + } + + /** + * Handle modification group with empty deletion + * + * SUBSEQUENT ADDITION GROUP MERGING: + * When a modification has an empty or placeholder deletion (e.g., "<<>>"), + * it's actually a pure addition. However, the LLM may split this addition across multiple + * groups: + * Group 1 (modification): - "<<>>" + "function foo() {" + * Group 2 (addition): " return 42;" + * Group 3 (addition): "}" + * + * This function merges all subsequent addition groups (lines 488-500) into a single combined + * group. This ensures the inline completion shows the complete multi-line addition as one + * cohesive suggestion, rather than requiring the user to navigate through multiple separate + * groups. Without this merging, the user would see: + * - First: "function foo() {" + * - Tab to next: " return 42;" + * - Tab to next: "}" + * + * With merging, they see the complete function in one go, which is more natural and efficient. + */ + private handleEmptyDeletionModification( + group: GhostSuggestionEditOperation[], + groups: GhostSuggestionEditOperation[][], + selectedGroupIndex: number, + file: any, + ): { group: GhostSuggestionEditOperation[]; type: "+" } | null { + const deletedContent = this.extractContent(group, "-").trim() + + if (deletedContent.length === 0 || this.isPlaceholderContent(deletedContent)) { + const addOps = group.filter((op) => op.type === "+") + const combinedOps = [...addOps] + + // Add subsequent addition groups + let nextIndex = selectedGroupIndex + 1 + while (nextIndex < groups.length) { + const nextGroup = groups[nextIndex] + const nextGroupType = file.getGroupType(nextGroup) + + if (nextGroupType === "+") { + combinedOps.push(...nextGroup) + nextIndex++ + } else { + break + } + } + + return { group: combinedOps, type: "+" } + } + + return null + } + + /** + * Handle deletion group with associated addition + */ + private handleDeletionWithAddition( + selectedGroup: GhostSuggestionEditOperation[], + groups: GhostSuggestionEditOperation[][], + selectedGroupIndex: number, + file: any, + ): { group: GhostSuggestionEditOperation[]; type: "/" | "+" } | null { + const deletedContent = this.extractContent(selectedGroup, "-").trim() + + // Case 1: Placeholder-only deletion + if (this.isPlaceholderContent(deletedContent)) { + return this.getNextAdditionGroup(file, groups, selectedGroupIndex) + } + + // Case 2: Deletion followed by addition + if (selectedGroupIndex + 1 < groups.length) { + const nextGroup = groups[selectedGroupIndex + 1] + const nextGroupType = file.getGroupType(nextGroup) + + if (nextGroupType === "+") { + const addedContent = this.extractContent(nextGroup, "+") + + // Common prefix scenario - create synthetic modification + if (this.isPrefix(deletedContent, addedContent)) { + console.log("[InlineCompletion] Common prefix detected, creating synthetic modification group") + console.log("[InlineCompletion] Deleted:", deletedContent.substring(0, 50)) + console.log("[InlineCompletion] Added:", addedContent.substring(0, 50)) + return { group: [...selectedGroup, ...nextGroup], type: "/" } + } + + // Should be treated as addition after existing content + if (this.shouldTreatAsAddition(deletedContent, addedContent)) { + return { group: nextGroup, type: "+" } + } + } + } + + return null + } + + /** + * Handle addition group that may need combination with previous deletion + */ + private handleAdditionWithPreviousDeletion( + selectedGroup: GhostSuggestionEditOperation[], + groups: GhostSuggestionEditOperation[][], + selectedGroupIndex: number, + file: GhostSuggestionFile, + ): { group: GhostSuggestionEditOperation[]; type: "/" } | null { + if (selectedGroupIndex <= 0) { + return null + } + + const previousGroup = groups[selectedGroupIndex - 1] + const previousGroupType = file.getGroupType(previousGroup) + + if (previousGroupType === "-" && this.shouldCreateSyntheticModification(previousGroup, selectedGroup)) { + return { group: [...previousGroup, ...selectedGroup], type: "/" } + } + + return null + } + + /** + * Get effective group for inline completion (handles separated deletion+addition groups) + */ + private getEffectiveGroup( + file: any, + groups: GhostSuggestionEditOperation[][], + selectedGroupIndex: number, + ): { group: GhostSuggestionEditOperation[]; type: "+" | "/" | "-" } | null { + if (selectedGroupIndex >= groups.length) { + return null + } + + const selectedGroup = groups[selectedGroupIndex] + const selectedGroupType = file.getGroupType(selectedGroup) + + // Handle modification with empty deletion + if (selectedGroupType === "/") { + const result = this.handleEmptyDeletionModification(selectedGroup, groups, selectedGroupIndex, file) + if (result) return result + } + + // Handle deletion with associated addition + if (selectedGroupType === "-") { + const result = this.handleDeletionWithAddition(selectedGroup, groups, selectedGroupIndex, file) + if (result) return result + return null // Regular deletions use SVG decorations + } + + // Handle addition that may combine with previous deletion + if (selectedGroupType === "+") { + const result = this.handleAdditionWithPreviousDeletion(selectedGroup, groups, selectedGroupIndex, file) + if (result) return result + } + + return { group: selectedGroup, type: selectedGroupType } + } + + /** + * Get completion text for addition that may be part of modification + * + * FIRST-LINE PREFIX WITH MULTI-LINE ADDITIONS: + * This handles a complex scenario where the LLM generates suggestions with a common prefix + * on the first line, followed by additional lines. For example: + * Previous deletion: "const x =" + * Addition group: "const x = 10\n + 20\n + 30" + * + * There are two cases to handle: + * + * 1. Entire addition starts with deletion (lines 634-636): + * If the whole addition text begins with the deleted content, we strip the common prefix + * from the entire text. This is straightforward prefix removal. + * + * 2. Only first line starts with deletion (lines 638-642): + * More complex - only the first operation's content contains the prefix, but subsequent + * lines don't. We need to: + * a) Extract the suffix from the first line after removing the prefix + * b) Keep all subsequent lines intact + * c) Join them with newlines + * + * Example result: " 10\n + 20\n + 30" (only " 10" is shown inline on first line, + * then the remaining lines appear below) + * + * This approach allows for natural inline completion of multi-line suggestions where + * the first line modifies existing code and subsequent lines add new content below it. + * Without this logic, the inline completion would show redundant text that's already + * on the line. + */ + private getAdditionCompletionText( + group: GhostSuggestionEditOperation[], + groups: GhostSuggestionEditOperation[][], + selectedGroupIndex: number, + file: any, + ): { text: string; isAddition: boolean } { + const text = this.extractContent(group) + const sortedOps = group.sort((a, b) => a.line - b.line) + + // Check if there's a previous deletion group + if (selectedGroupIndex > 0) { + const previousGroup = groups[selectedGroupIndex - 1] + const previousGroupType = file.getGroupType(previousGroup) + + if (previousGroupType === "-") { + const deletedContent = this.extractContent(previousGroup, "-") + + // If entire addition starts with deletion, strip common prefix + if (this.isPrefix(deletedContent, text)) { + return { text: text.substring(deletedContent.length), isAddition: false } + } + + // Check if just first line starts with deletion + if (sortedOps.length > 0 && sortedOps[0].content.startsWith(deletedContent)) { + const firstLineSuffix = sortedOps[0].content.substring(deletedContent.length) + const remainingLines = sortedOps.slice(1).map((op) => op.content) + return { text: [firstLineSuffix, ...remainingLines].join("\n"), isAddition: false } + } + } + } + + return { text, isAddition: true } + } + + /** + * Get completion text for modification + */ + private getModificationCompletionText( + group: GhostSuggestionEditOperation[], + groups: GhostSuggestionEditOperation[][], + selectedGroupIndex: number, + file: any, + ): { text: string; isAddition: boolean } { + const deletedContent = this.extractContent(group, "-") + const addedContent = this.extractContent(group, "+") + + if (!deletedContent || !addedContent) { + return { text: "", isAddition: false } + } + + const trimmedDeleted = deletedContent.trim() + + // Empty or placeholder deletion - show all added content + if (trimmedDeleted.length === 0 || this.isPlaceholderContent(trimmedDeleted)) { + return { text: addedContent, isAddition: true } + } + + // Should be treated as addition + if (this.shouldTreatAsAddition(deletedContent, addedContent)) { + if (this.isPrefix(deletedContent, addedContent)) { + return { text: addedContent.substring(deletedContent.length), isAddition: false } + } else if (addedContent.startsWith("\n") || addedContent.startsWith("\r\n")) { + return { text: addedContent.replace(/^\r?\n/, ""), isAddition: true } + } + } + + // Regular modification - show suffix after common prefix + const commonPrefix = this.findCommonPrefix(deletedContent, addedContent) + if (commonPrefix.length === 0) { + return { text: "", isAddition: false } + } + + const suffix = addedContent.substring(commonPrefix.length) + + // Check if there are subsequent addition groups to combine + if (selectedGroupIndex + 1 < groups.length) { + const nextGroup = groups[selectedGroupIndex + 1] + const nextGroupType = file.getGroupType(nextGroup) + + if (nextGroupType === "+") { + const nextAdditions = this.extractContent(nextGroup) + return { text: suffix + "\n" + nextAdditions, isAddition: false } + } + } + + return { text: suffix, isAddition: false } + } + + /** + * Calculate completion text for different scenarios + */ + private getCompletionText( + groupType: "+" | "/" | "-", + group: GhostSuggestionEditOperation[], + file: any, + groups: GhostSuggestionEditOperation[][], + selectedGroupIndex: number, + ): { text: string; isAddition: boolean } { + if (groupType === "+") { + return this.getAdditionCompletionText(group, groups, selectedGroupIndex, file) + } + + return this.getModificationCompletionText(group, groups, selectedGroupIndex, file) + } + + /** + * Calculate insertion position and range + */ + private getInsertionRange( + document: vscode.TextDocument, + position: vscode.Position, + targetLine: number, + isAddition: boolean, + completionText: string, + ): vscode.Range { + // For pure additions, position at end of current line + if (isAddition) { + const currentLineText = document.lineAt(position.line).text + const insertPosition = new vscode.Position(position.line, currentLineText.length) + return new vscode.Range(insertPosition, insertPosition) + } + + // For modifications on same line with multi-line content + if (targetLine === position.line) { + if (completionText.includes("\n")) { + const nextLine = Math.min(position.line + 1, document.lineCount) + const insertPosition = new vscode.Position(nextLine, 0) + return new vscode.Range(insertPosition, insertPosition) + } else { + return new vscode.Range(position, position) + } + } + + // For different lines + if (targetLine >= document.lineCount) { + const lastLineIndex = Math.max(0, document.lineCount - 1) + const lastLineText = document.lineAt(lastLineIndex).text + const insertPosition = new vscode.Position(lastLineIndex, lastLineText.length) + return new vscode.Range(insertPosition, insertPosition) + } + + const insertPosition = new vscode.Position(targetLine, 0) + return new vscode.Range(insertPosition, insertPosition) + } + + /** + * Provide inline completion items at the given position + */ + public async provideInlineCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + context: vscode.InlineCompletionContext, + token: vscode.CancellationToken, + ): Promise { + if (token.isCancellationRequested) { + return undefined + } + + // Suppress inline completion when IntelliSense is showing + if (context.selectedCompletionInfo) { + if (this.onIntelliSenseDetected) { + this.onIntelliSenseDetected() + } + return undefined + } + + // Get file suggestions + const file = this.suggestions.getFile(document.uri) + if (!file) { + return undefined + } + + // Get effective group + const groups = file.getGroupsOperations() + const selectedGroupIndex = file.getSelectedGroup() + + if (selectedGroupIndex === null) { + return undefined + } + + const effectiveGroup = this.getEffectiveGroup(file, groups, selectedGroupIndex) + if (!effectiveGroup) { + return undefined + } + + // Check distance from cursor + const offset = file.getPlaceholderOffsetSelectedGroupOperations() + const targetLine = this.getTargetLine(effectiveGroup.group, effectiveGroup.type, offset) + + if (!this.isWithinCursorDistance(position.line, targetLine)) { + return undefined + } + + // Get completion text + const { text: completionText, isAddition } = this.getCompletionText( + effectiveGroup.type, + effectiveGroup.group, + file, + groups, + selectedGroupIndex, + ) + + if (!completionText.trim()) { + return undefined + } + + // Calculate insertion range + const range = this.getInsertionRange(document, position, targetLine, isAddition, completionText) + let finalCompletionText = completionText + + // Add newline prefix if needed for multi-line content + if (isAddition && completionText.includes("\n") && !completionText.startsWith("\n")) { + finalCompletionText = "\n" + completionText + } else if ( + !isAddition && + completionText.includes("\n") && + range.start.line === position.line && + !completionText.startsWith("\n") + ) { + finalCompletionText = "\n" + completionText + } + + // Create completion item + const item: vscode.InlineCompletionItem = { + insertText: finalCompletionText, + range, + command: { + command: "kilo-code.ghost.acceptInlineCompletion", + title: "Accept inline completion", + }, + } + + return [item] + } + + public dispose(): void { + // Cleanup if needed + } +} diff --git a/src/services/ghost/GhostProvider.ts b/src/services/ghost/GhostProvider.ts index ca9fff1b08b..cda1a2a5e64 100644 --- a/src/services/ghost/GhostProvider.ts +++ b/src/services/ghost/GhostProvider.ts @@ -7,6 +7,7 @@ import { AutoTriggerStrategy } from "./strategies/AutoTriggerStrategy" import { GhostModel } from "./GhostModel" import { GhostWorkspaceEdit } from "./GhostWorkspaceEdit" import { GhostDecorations } from "./GhostDecorations" +import { GhostInlineCompletionProvider } from "./GhostInlineCompletionProvider" import { GhostSuggestionContext, contextToAutocompleteInput, extractPrefixSuffix } from "./types" import { GhostStatusBar } from "./GhostStatusBar" import { GhostSuggestionsState } from "./GhostSuggestions" @@ -25,6 +26,8 @@ import { normalizeAutoTriggerDelayToMs } from "./utils/autocompleteDelayUtils" export class GhostProvider { private static instance: GhostProvider | null = null private decorations: GhostDecorations + private inlineCompletionProvider: GhostInlineCompletionProvider + private inlineCompletionDisposable: vscode.Disposable | null = null private documentStore: GhostDocumentStore private model: GhostModel private streamingParser: GhostStreamingParser @@ -62,6 +65,9 @@ export class GhostProvider { // Register Internal Components this.decorations = new GhostDecorations() + this.inlineCompletionProvider = new GhostInlineCompletionProvider(this.suggestions, () => + this.onIntelliSenseDetected(), + ) this.documentStore = new GhostDocumentStore() this.streamingParser = new GhostStreamingParser() this.autoTriggerStrategy = new AutoTriggerStrategy() @@ -75,6 +81,9 @@ export class GhostProvider { // Register the providers this.codeActionProvider = new GhostCodeActionProvider() + // Register inline completion provider + this.registerInlineCompletionProvider() + // Register document event handlers vscode.workspace.onDidChangeTextDocument(this.onDidChangeTextDocument, this, context.subscriptions) vscode.workspace.onDidOpenTextDocument(this.onDidOpenTextDocument, this, context.subscriptions) @@ -408,7 +417,24 @@ export class GhostProvider { private async render() { await this.updateGlobalContext() - await this.displaySuggestions() + + this.inlineCompletionProvider.updateSuggestions(this.suggestions) + + const editor = vscode.window.activeTextEditor + const shouldTriggerInline = editor ? this.inlineCompletionProvider.shouldTriggerInline(editor) : false + + // Trigger or hide inline suggestions as appropriate + if (shouldTriggerInline) { + await vscode.commands.executeCommand("editor.action.inlineSuggest.trigger") + } else { + // If we're not showing inline completion, explicitly hide any existing ones + // This prevents conflicts with IntelliSense + await vscode.commands.executeCommand("editor.action.inlineSuggest.hide") + } + + // TODO: Turned off for now and only show Inline ghost completions. Reintroduce later for next edits. + // Display SVG decorations for appropriate groups + // await this.displaySuggestions() } private selectClosestSuggestion() { @@ -421,6 +447,9 @@ export class GhostProvider { return } file.selectClosestGroup(editor.selection) + + // Use inline completion provider to validate and select closest valid group + this.inlineCompletionProvider.selectClosestValidGroup(file, editor) } public async displaySuggestions() { @@ -431,7 +460,30 @@ export class GhostProvider { if (!editor) { return } - await this.decorations.displaySuggestions(this.suggestions) + + const file = this.suggestions.getFile(editor.document.uri) + if (!file) { + this.decorations.clearAll() + return + } + + const groups = file.getGroupsOperations() + if (groups.length === 0) { + this.decorations.clearAll() + return + } + + const selectedGroupIndex = file.getSelectedGroup() + if (selectedGroupIndex === null) { + this.decorations.clearAll() + return + } + + // Use inline completion provider to determine which groups to skip + const skipGroupIndices = this.inlineCompletionProvider.getSkipGroupIndices(file, editor) + + // Display decorations, skipping groups as determined by inline provider + await this.decorations.displaySuggestions(this.suggestions, skipGroupIndices) } private async updateGlobalContext() { @@ -467,11 +519,32 @@ export class GhostProvider { this.decorations.clearAll() this.suggestions.clear() + // Update inline completion provider + this.inlineCompletionProvider.updateSuggestions(this.suggestions) + + await vscode.commands.executeCommand("editor.action.inlineSuggest.hide") + this.clearAutoTriggerTimer() await this.render() } + /** + * Apply suggestion via workspace edit (for SVG decoration acceptance). + * Used when accepting through custom keybindings or clicking decorations. + */ public async applySelectedSuggestions() { + await this.acceptSuggestion(true) + } + + /** + * Accept suggestion without applying edits (for inline completion acceptance). + * VSCode's inline completion API already inserted the text - only clean up state. + */ + public async acceptInlineCompletion() { + await this.acceptSuggestion(false) + } + + private async acceptSuggestion(applyEdits: boolean) { if (!this.enabled) { return } @@ -488,17 +561,25 @@ export class GhostProvider { await this.cancelSuggestions() return } - if (suggestionsFile.getSelectedGroup() === null) { + const selectedGroupIndex = suggestionsFile.getSelectedGroup() + if (selectedGroupIndex === null) { await this.cancelSuggestions() return } + TelemetryService.instance.captureEvent(TelemetryEventName.INLINE_ASSIST_ACCEPT_SUGGESTION, { taskId: this.taskId, }) this.decorations.clearAll() - await this.workspaceEdit.applySelectedSuggestions(this.suggestions) - this.cursor.moveToAppliedGroup(this.suggestions) + + // Apply workspace edits only if requested (for custom keybindings/decorations) + // For inline completions, VSCode already inserted the text + if (applyEdits) { + await this.workspaceEdit.applySelectedSuggestions(this.suggestions) + this.cursor.moveToAppliedGroup(this.suggestions) + } suggestionsFile.deleteSelectedGroup() + suggestionsFile.selectClosestGroup(editor.selection) this.suggestions.validateFiles() this.clearAutoTriggerTimer() @@ -540,7 +621,13 @@ export class GhostProvider { await this.cancelSuggestions() return } - suggestionsFile.selectNextGroup() + + // Use inline completion provider to find next valid group + const originalSelection = suggestionsFile.getSelectedGroup() + if (originalSelection !== null) { + this.inlineCompletionProvider.findNextValidGroup(suggestionsFile, originalSelection) + } + await this.render() } @@ -561,7 +648,13 @@ export class GhostProvider { await this.cancelSuggestions() return } - suggestionsFile.selectPreviousGroup() + + // Use inline completion provider to find previous valid group + const originalSelection = suggestionsFile.getSelectedGroup() + if (originalSelection !== null) { + this.inlineCompletionProvider.findPreviousValidGroup(suggestionsFile, originalSelection) + } + await this.render() } @@ -658,6 +751,17 @@ export class GhostProvider { this.streamingParser.reset() } + /** + * Called when IntelliSense is detected to be active + * Immediately cancels our suggestions to prevent conflicts + */ + private onIntelliSenseDetected(): void { + if (this.hasPendingSuggestions()) { + console.log("[Ghost] IntelliSense detected, canceling ghost suggestions to prevent conflict") + void this.cancelSuggestions() + } + } + /** * Handle typing events for auto-trigger functionality */ @@ -722,6 +826,23 @@ export class GhostProvider { await this.codeSuggestion() } + /** + * Register or re-register the inline completion provider + */ + private registerInlineCompletionProvider(): void { + // Dispose existing registration + if (this.inlineCompletionDisposable) { + this.inlineCompletionDisposable.dispose() + this.inlineCompletionDisposable = null + } + + // Register inline completion provider for all languages + this.inlineCompletionDisposable = vscode.languages.registerInlineCompletionItemProvider( + { pattern: "**" }, + this.inlineCompletionProvider, + ) + } + /** * Dispose of all resources used by the GhostProvider */ @@ -732,6 +853,13 @@ export class GhostProvider { this.suggestions.clear() this.decorations.clearAll() + // Dispose inline completion provider + if (this.inlineCompletionDisposable) { + this.inlineCompletionDisposable.dispose() + this.inlineCompletionDisposable = null + } + this.inlineCompletionProvider.dispose() + this.statusBar?.dispose() this.cursorAnimation.dispose() diff --git a/src/services/ghost/GhostSuggestions.ts b/src/services/ghost/GhostSuggestions.ts index bbf302cde58..c62addb1328 100644 --- a/src/services/ghost/GhostSuggestions.ts +++ b/src/services/ghost/GhostSuggestions.ts @@ -1,7 +1,7 @@ import * as vscode from "vscode" import { GhostSuggestionEditOperation, GhostSuggestionEditOperationsOffset } from "./types" -class GhostSuggestionFile { +export class GhostSuggestionFile { public fileUri: vscode.Uri private selectedGroup: number | null = null private groups: Array = [] @@ -104,12 +104,21 @@ class GhostSuggestionFile { return this.selectedGroup } - public getGroupType = (group: GhostSuggestionEditOperation[]) => { - const types = group.flatMap((x) => x.type) - if (types.length == 2) { + public getGroupType = (group: GhostSuggestionEditOperation[]): "+" | "-" | "/" => { + if (group.length === 0) { + return "+" // Default to addition for empty groups + } + + const hasAdd = group.some((op) => op.type === "+") + const hasDel = group.some((op) => op.type === "-") + + // Modification: has both additions and deletions + if (hasAdd && hasDel) { return "/" } - return types[0] + + // Pure addition or deletion + return group[0].type } public getSelectedGroupPreviousOperations(): GhostSuggestionEditOperation[] { @@ -161,9 +170,47 @@ class GhostSuggestionFile { .forEach((group) => { group.sort((a, b) => a.line - b.line) }) + + // Filter out empty operations after sorting + this.removeOperationsWithEmptyContent() + this.selectedGroup = this.groups.length > 0 ? 0 : null } + /** + * Remove operations with empty content and clean up empty groups + * + * This filters out operations that have no actual content (empty strings), + * which can occur when the LLM generates malformed diffs or placeholder operations. + * After filtering operations, any groups that become empty are also removed. + * + * Example: + * Before: [ + * [{ type: '+', content: 'function foo() {', line: 1 }], + * [{ type: '-', content: '', line: 2 }], // Empty deletion - will be removed + * [{ type: '+', content: 'return 42;', line: 3 }] + * ] + * After: [ + * [{ type: '+', content: 'function foo() {', line: 1 }], + * [{ type: '+', content: 'return 42;', line: 3 }] + * ] + */ + private removeOperationsWithEmptyContent() { + // Filter each group to remove operations (additions and deletions) with empty content + this.groups = this.groups + .map((group) => { + return group.filter((op) => { + if (op.type === "-" || op.type === "+") { + // Only keep deletions and additions that have non-empty content + return op.content !== "" + } else { + return true + } + }) + }) + .filter((group) => group.length > 0) // Remove empty groups + } + private computeOperationsOffset(group: GhostSuggestionEditOperation[]): GhostSuggestionEditOperationsOffset { const { added, removed } = group.reduce( (acc, op) => { diff --git a/src/services/ghost/GhostWorkspaceEdit.ts b/src/services/ghost/GhostWorkspaceEdit.ts index 48e68451a34..e25adf741f2 100644 --- a/src/services/ghost/GhostWorkspaceEdit.ts +++ b/src/services/ghost/GhostWorkspaceEdit.ts @@ -72,7 +72,10 @@ export class GhostWorkspaceEdit { // --- 2. Translate and Prepare Current Operations --- const currentDeletes = operations.filter((op) => op.type === "-").sort((a, b) => a.line - b.line) - const currentInserts = operations.filter((op) => op.type === "+").sort((a, b) => a.line - b.line) + // For insertions, sort by oldLine to apply them in the correct order relative to the original document + const currentInserts = operations + .filter((op) => op.type === "+") + .sort((a, b) => a.oldLine - b.oldLine || a.newLine - b.newLine) const translatedInsertOps: { originalLine: number; content: string }[] = [] let currDelPtr = 0 let currInsPtr = 0 @@ -80,17 +83,17 @@ export class GhostWorkspaceEdit { // Run the simulation for the new operations, starting from the state calculated above. while (currDelPtr < currentDeletes.length || currInsPtr < currentInserts.length) { const nextDelLine = currentDeletes[currDelPtr]?.line ?? Infinity - const nextInsLine = currentInserts[currInsPtr]?.line ?? Infinity + // Use oldLine for insertions to determine their position in the original document + const nextInsLine = currentInserts[currInsPtr]?.oldLine ?? Infinity if (nextDelLine <= originalLineCursor && nextDelLine !== Infinity) { originalLineCursor++ currDelPtr++ - } else if (nextInsLine <= finalLineCursor && nextInsLine !== Infinity) { + } else if (nextInsLine <= originalLineCursor && nextInsLine !== Infinity) { translatedInsertOps.push({ originalLine: originalLineCursor, content: currentInserts[currInsPtr].content || "", }) - finalLineCursor++ currInsPtr++ } else if (nextDelLine === Infinity && nextInsLine === Infinity) { break diff --git a/src/services/ghost/__tests__/GhostInlineCompletionProvider.spec.ts b/src/services/ghost/__tests__/GhostInlineCompletionProvider.spec.ts new file mode 100644 index 00000000000..5a356494fe5 --- /dev/null +++ b/src/services/ghost/__tests__/GhostInlineCompletionProvider.spec.ts @@ -0,0 +1,1074 @@ +import { describe, it, expect, beforeEach, vi } from "vitest" +import * as vscode from "vscode" +import { GhostInlineCompletionProvider } from "../GhostInlineCompletionProvider" +import { GhostSuggestionsState } from "../GhostSuggestions" +import { GhostSuggestionEditOperation } from "../types" + +describe("GhostInlineCompletionProvider", () => { + let provider: GhostInlineCompletionProvider + let suggestions: GhostSuggestionsState + let mockDocument: vscode.TextDocument + let mockPosition: vscode.Position + let mockContext: vscode.InlineCompletionContext + let mockToken: vscode.CancellationToken + + beforeEach(() => { + suggestions = new GhostSuggestionsState() + provider = new GhostInlineCompletionProvider(suggestions) + + // Mock document + mockDocument = { + uri: vscode.Uri.file("/test/file.ts"), + lineCount: 10, + lineAt: (line: number) => ({ + text: `line ${line}`, + range: new vscode.Range(line, 0, line, 10), + }), + } as any + + mockPosition = new vscode.Position(5, 0) + mockContext = { + triggerKind: 0, + selectedCompletionInfo: undefined, + } as any + mockToken = { isCancellationRequested: false } as any + }) + + describe("provideInlineCompletionItems", () => { + it("should return undefined when no suggestions exist", async () => { + const result = await provider.provideInlineCompletionItems( + mockDocument, + mockPosition, + mockContext, + mockToken, + ) + + expect(result).toBeUndefined() + }) + + it("should return undefined when token is cancelled", async () => { + mockToken.isCancellationRequested = true + + const result = await provider.provideInlineCompletionItems( + mockDocument, + mockPosition, + mockContext, + mockToken, + ) + + expect(result).toBeUndefined() + }) + + it("should return undefined for deletion-only groups", async () => { + // Add a deletion group + const file = suggestions.addFile(mockDocument.uri) + const deleteOp: GhostSuggestionEditOperation = { + type: "-", + line: 5, + oldLine: 5, + newLine: 5, + content: "deleted line", + } + file.addOperation(deleteOp) + file.sortGroups() + + const result = await provider.provideInlineCompletionItems( + mockDocument, + mockPosition, + mockContext, + mockToken, + ) + + expect(result).toBeUndefined() + }) + + it("should return inline completion for addition at cursor position", async () => { + // Add an addition group at the cursor line + const file = suggestions.addFile(mockDocument.uri) + const addOp: GhostSuggestionEditOperation = { + type: "+", + line: 5, + oldLine: 5, + newLine: 5, + content: "new line of code", + } + file.addOperation(addOp) + file.sortGroups() + + const result = await provider.provideInlineCompletionItems( + mockDocument, + mockPosition, + mockContext, + mockToken, + ) + + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + expect((result as any[]).length).toBe(1) + + const item = (result as any[])[0] + expect(item.insertText).toContain("new line of code") + }) + + it("should return undefined when suggestion is far from cursor", async () => { + // Add a suggestion far from the cursor (>5 lines away) + // The inline provider returns undefined, decorations will handle it instead + const file = suggestions.addFile(mockDocument.uri) + const addOp: GhostSuggestionEditOperation = { + type: "+", + line: 20, // Far from cursor at line 5 (15 lines away) + oldLine: 20, + newLine: 20, + content: "distant code", + } + file.addOperation(addOp) + file.sortGroups() + + const result = await provider.provideInlineCompletionItems( + mockDocument, + mockPosition, + mockContext, + mockToken, + ) + + // Provider returns undefined for far suggestions - decorations handle them + expect(result).toBeUndefined() + }) + + it("should return undefined for modification groups (delete + add) - decorations handle them", async () => { + // Add a modification group at cursor line + const file = suggestions.addFile(mockDocument.uri) + + const deleteOp: GhostSuggestionEditOperation = { + type: "-", + line: 5, + oldLine: 5, + newLine: 5, + content: "old code", + } + const addOp: GhostSuggestionEditOperation = { + type: "+", + line: 5, + oldLine: 5, + newLine: 5, + content: "new code", + } + + file.addOperation(deleteOp) + file.addOperation(addOp) + file.sortGroups() + + const result = await provider.provideInlineCompletionItems( + mockDocument, + mockPosition, + mockContext, + mockToken, + ) + + // Modifications should return undefined - SVG decorations handle them + expect(result).toBeUndefined() + }) + + it("should handle multi-line additions when grouped", async () => { + const file = suggestions.addFile(mockDocument.uri) + + // Create consecutive addition operations that will be grouped together + const addOp1: GhostSuggestionEditOperation = { + type: "+", + line: 5, + oldLine: 5, + newLine: 5, + content: "line 1", + } + const addOp2: GhostSuggestionEditOperation = { + type: "+", + line: 6, + oldLine: 6, + newLine: 6, + content: "line 2", + } + + file.addOperation(addOp1) + file.addOperation(addOp2) + file.sortGroups() + + // Verify they were grouped together + const groups = file.getGroupsOperations() + expect(groups.length).toBe(1) + expect(groups[0].length).toBe(2) + + // The provider may or may not show inline completions for multi-line additions + // depending on cursor position, but it should not throw errors + const result = await provider.provideInlineCompletionItems( + mockDocument, + mockPosition, + mockContext, + mockToken, + ) + + // Just verify it doesn't error and returns expected type + expect(result === undefined || Array.isArray(result)).toBe(true) + }) + + it("should handle comment-driven completions as inline ghost text", async () => { + // Mock document with comment line + mockDocument = { + uri: vscode.Uri.file("/test/file.ts"), + lineCount: 10, + lineAt: (line: number) => { + if (line === 5) { + return { + text: "// implement function to add two numbers", + range: new vscode.Range(line, 0, line, 40), + } + } + return { + text: `line ${line}`, + range: new vscode.Range(line, 0, line, 10), + } + }, + } as any + + // Simulate the scenario: LLM response creates deletion of comment + addition of function + const file = suggestions.addFile(mockDocument.uri) + + // Group 1: Deletion of comment line (this happens when context has placeholder appended) + const deleteOp: GhostSuggestionEditOperation = { + type: "-", + line: 5, + oldLine: 5, + newLine: 5, + content: "// implement function to add two numbers", + } + + // Group 2: Addition of function (starts with newline) + const addOp: GhostSuggestionEditOperation = { + type: "+", + line: 6, + oldLine: 6, + newLine: 6, + content: "\nfunction addNumbers(a: number, b: number): number {\n return a + b;\n}", + } + + file.addOperation(deleteOp) + file.addOperation(addOp) + file.sortGroups() + + // Position cursor at the comment line + mockPosition = new vscode.Position(5, 40) // End of comment line + + const result = await provider.provideInlineCompletionItems( + mockDocument, + mockPosition, + mockContext, + mockToken, + ) + + // Should return inline completion (not undefined) + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + const items = result as vscode.InlineCompletionItem[] + expect(items.length).toBe(1) + + // Should show the function without the comment part + const completionText = items[0].insertText as string + expect(completionText).toContain("function addNumbers") + expect(completionText).not.toContain("// implement function") + }) + + it("should handle modifications with common prefix", async () => { + // Mock document with existing code + mockDocument = { + uri: vscode.Uri.file("/test/file.ts"), + lineCount: 10, + lineAt: (line: number) => { + if (line === 5) { + return { + text: "const y = ", + range: new vscode.Range(line, 0, line, 10), + } + } + return { + text: `line ${line}`, + range: new vscode.Range(line, 0, line, 10), + } + }, + } as any + + // Create modification group with common prefix + const file = suggestions.addFile(mockDocument.uri) + + const deleteOp: GhostSuggestionEditOperation = { + type: "-", + line: 5, + oldLine: 5, + newLine: 5, + content: "const y = ", + } + + const addOp: GhostSuggestionEditOperation = { + type: "+", + line: 5, + oldLine: 5, + newLine: 5, + content: "const y = divideNumbers(4, 2);", + } + + file.addOperation(deleteOp) + file.addOperation(addOp) + file.sortGroups() + + // Position cursor after "const y = " + mockPosition = new vscode.Position(5, 10) + + const result = await provider.provideInlineCompletionItems( + mockDocument, + mockPosition, + mockContext, + mockToken, + ) + + // Should return inline completion showing only the suffix + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + const items = result as vscode.InlineCompletionItem[] + expect(items.length).toBe(1) + + const completionText = items[0].insertText as string + expect(completionText).toBe("divideNumbers(4, 2);") + }) + + it("should return undefined for modifications without common prefix", async () => { + // Create modification group without common prefix + const file = suggestions.addFile(mockDocument.uri) + + const deleteOp: GhostSuggestionEditOperation = { + type: "-", + line: 5, + oldLine: 5, + newLine: 5, + content: "var x = 10", + } + + const addOp: GhostSuggestionEditOperation = { + type: "+", + line: 5, + oldLine: 5, + newLine: 5, + content: "const x = 10", + } + + file.addOperation(deleteOp) + file.addOperation(addOp) + file.sortGroups() + + const result = await provider.provideInlineCompletionItems( + mockDocument, + mockPosition, + mockContext, + mockToken, + ) + + // Should return undefined - SVG decorations handle this + expect(result).toBeUndefined() + }) + + it("should handle comment with placeholder as inline ghost completion (mutual exclusivity test)", async () => { + // This test covers the exact scenario: "// implme<<>>" + // where both inline and SVG were showing (should only show inline) + + mockDocument = { + uri: vscode.Uri.file("/test/file.ts"), + lineCount: 10, + lineAt: (line: number) => { + if (line === 5) { + return { + text: "// implme", + range: new vscode.Range(line, 0, line, 8), + } + } + return { + text: `line ${line}`, + range: new vscode.Range(line, 0, line, 10), + } + }, + } as any + + const file = suggestions.addFile(mockDocument.uri) + + // Simulate LLM response: search "// implme<<>>" replace "// implme\nfunction..." + // This creates: Group 1 (delete comment), Group 2 (add comment + function) + const deleteOp: GhostSuggestionEditOperation = { + type: "-", + line: 5, + oldLine: 5, + newLine: 5, + content: "// implme", + } + + const addOp: GhostSuggestionEditOperation = { + type: "+", + line: 6, + oldLine: 6, + newLine: 6, + content: "\nfunction implementFeature() {\n console.log('Feature implemented');\n}", + } + + file.addOperation(deleteOp) + file.addOperation(addOp) + file.sortGroups() + + // Position cursor at end of comment line + mockPosition = new vscode.Position(5, 8) + + const result = await provider.provideInlineCompletionItems( + mockDocument, + mockPosition, + mockContext, + mockToken, + ) + + // Should return inline completion (ensuring only inline shows, no SVG) + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + const items = result as vscode.InlineCompletionItem[] + expect(items.length).toBe(1) + + // Should show function as ghost text + const completionText = items[0].insertText as string + expect(completionText).toContain("function implementFeature") + }) + + it("should handle partial comment completion with common prefix (avoid duplication)", async () => { + // Test case: "// now imple" should complete with "ment a function..." not duplicate "// now implement..." + mockDocument = { + uri: vscode.Uri.file("/test/file.ts"), + lineCount: 10, + lineAt: (line: number) => { + if (line === 5) { + return { + text: "// now imple", + range: new vscode.Range(line, 0, line, 12), + } + } + return { + text: `line ${line}`, + range: new vscode.Range(line, 0, line, 10), + } + }, + } as any + + const file = suggestions.addFile(mockDocument.uri) + + // Simulate: search "// now imple<<>>" replace "// now implement a function..." + const deleteOp: GhostSuggestionEditOperation = { + type: "-", + line: 5, + oldLine: 5, + newLine: 5, + content: "// now imple", + } + + const addOp: GhostSuggestionEditOperation = { + type: "+", + line: 5, + oldLine: 5, + newLine: 5, + content: + "// now implement a function that subtracts two numbers\nfunction subtractNumbers(a: number, b: number): number {\n return a - b;\n}", + } + + file.addOperation(deleteOp) + file.addOperation(addOp) + file.sortGroups() + + // Position cursor after "// now imple" + mockPosition = new vscode.Position(5, 12) + + const result = await provider.provideInlineCompletionItems( + mockDocument, + mockPosition, + mockContext, + mockToken, + ) + + // Should return inline completion with only the suffix (no duplication) + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + const items = result as vscode.InlineCompletionItem[] + expect(items.length).toBe(1) + + // Should show only the completion part, not the existing comment + const completionText = items[0].insertText as string + expect(completionText).toBe( + "ment a function that subtracts two numbers\nfunction subtractNumbers(a: number, b: number): number {\n return a - b;\n}", + ) + expect(completionText).not.toContain("// now imple") // Should not duplicate existing text + }) + + it("should handle single-line completion without duplication (add → addNumbers)", async () => { + // Test case: typing "add" should complete to "addNumbers" on same line + mockDocument = { + uri: vscode.Uri.file("/test/file.ts"), + lineCount: 10, + lineAt: (line: number) => { + if (line === 5) { + return { + text: "const num = add", + range: new vscode.Range(line, 0, line, 15), + } + } + return { + text: `line ${line}`, + range: new vscode.Range(line, 0, line, 10), + } + }, + } as any + + const file = suggestions.addFile(mockDocument.uri) + + // LLM creates: delete "const num = add", add "const num = addNumbers" + const deleteOp: GhostSuggestionEditOperation = { + type: "-", + line: 5, + oldLine: 5, + newLine: 5, + content: "const num = add", + } + + const addOp: GhostSuggestionEditOperation = { + type: "+", + line: 5, + oldLine: 5, + newLine: 5, + content: "const num = addNumbers", + } + + file.addOperation(deleteOp) + file.addOperation(addOp) + file.sortGroups() + + mockPosition = new vscode.Position(5, 15) // After "add" + + const result = await provider.provideInlineCompletionItems( + mockDocument, + mockPosition, + mockContext, + mockToken, + ) + + // Should show inline completion with ONLY the suffix + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + const items = result as vscode.InlineCompletionItem[] + expect(items.length).toBe(1) + + const completionText = items[0].insertText as string + expect(completionText).toBe("Numbers") // Only the suffix + expect(completionText).not.toContain("const num = add") // No duplication + }) + + it("should handle first line common prefix with multi-line addition (// → implement...)", async () => { + // Test: typing "// " should complete with "implement function..." + function code + // First line should strip "// " prefix + mockDocument = { + uri: vscode.Uri.file("/test/file.ts"), + lineCount: 10, + lineAt: (line: number) => { + if (line === 5) { + return { + text: "// ", + range: new vscode.Range(line, 0, line, 3), + } + } + return { + text: `line ${line}`, + range: new vscode.Range(line, 0, line, 10), + } + }, + } as any + + const file = suggestions.addFile(mockDocument.uri) + + // LLM creates: delete "// ", add "// implement function..." + function + const deleteOp: GhostSuggestionEditOperation = { + type: "-", + line: 5, + oldLine: 5, + newLine: 5, + content: "// ", + } + + // Addition has multiple lines, first starting with "// implement..." + file.addOperation(deleteOp) + file.addOperation({ + type: "+", + line: 6, + oldLine: 6, + newLine: 6, + content: "// implement function to add two numbers", + }) + file.addOperation({ + type: "+", + line: 7, + oldLine: 6, + newLine: 7, + content: "function addNumbers(a: number, b: number): number {", + }) + file.addOperation({ + type: "+", + line: 8, + oldLine: 6, + newLine: 8, + content: " return a + b;", + }) + file.addOperation({ + type: "+", + line: 9, + oldLine: 6, + newLine: 9, + content: "}", + }) + file.sortGroups() + + mockPosition = new vscode.Position(5, 3) // After "// " + + const result = await provider.provideInlineCompletionItems( + mockDocument, + mockPosition, + mockContext, + mockToken, + ) + + // Should show inline completion + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + const items = result as vscode.InlineCompletionItem[] + expect(items.length).toBe(1) + + const completionText = items[0].insertText as string + // Should start with "implement..." not "// implement..." + expect(completionText.startsWith("implement")).toBe(true) + expect(completionText).toContain("function addNumbers") + expect(completionText.startsWith("// ")).toBe(false) // Should strip the "// " prefix + }) + + it("should not add extra newlines when content already starts with newline", async () => { + // Test: ensure we don't double-add newlines + mockDocument = { + uri: vscode.Uri.file("/test/file.ts"), + lineCount: 10, + lineAt: (line: number) => { + if (line === 5) { + return { + text: "// comment", + range: new vscode.Range(line, 0, line, 10), + } + } + return { + text: `line ${line}`, + range: new vscode.Range(line, 0, line, 10), + } + }, + } as any + + const file = suggestions.addFile(mockDocument.uri) + + // Addition that already starts with newline + const addOp: GhostSuggestionEditOperation = { + type: "+", + line: 6, + oldLine: 6, + newLine: 6, + content: "\nfunction test() {\n return true;\n}", + } + + file.addOperation(addOp) + file.sortGroups() + + mockPosition = new vscode.Position(5, 10) + + const result = await provider.provideInlineCompletionItems( + mockDocument, + mockPosition, + mockContext, + mockToken, + ) + + expect(result).toBeDefined() + const items = result as vscode.InlineCompletionItem[] + expect(items.length).toBe(1) + + const completionText = items[0].insertText as string + // Should not have double newlines + expect(completionText.startsWith("\n\n")).toBe(false) + // Should start with single newline + expect(completionText.startsWith("\n")).toBe(true) + }) + + it("should handle empty line deletion with multi-group additions as inline completion", async () => { + // Test: cursor on empty line, LLM suggests comment + function + // Creates: Group 0 (delete '', add comment), Group 1 (add function lines) + // Should combine all and show as inline completion + mockDocument = { + uri: vscode.Uri.file("/test/file.ts"), + lineCount: 12, + lineAt: (line: number) => { + if (line === 10) { + return { + text: "", + range: new vscode.Range(line, 0, line, 0), + } + } + return { + text: `line ${line}`, + range: new vscode.Range(line, 0, line, 10), + } + }, + } as any + + const file = suggestions.addFile(mockDocument.uri) + + // Group 0: modification - add comment + const commentOp: GhostSuggestionEditOperation = { + type: "+", + line: 10, + oldLine: 11, + newLine: 10, + content: "// implement function to add two numbers", + } + + file.addOperation(commentOp) + + // Group 1: pure additions - function lines + file.addOperation({ + type: "+", + line: 11, + oldLine: 11, + newLine: 11, + content: "function addTwoNumbers(a: number, b: number): number {", + }) + file.addOperation({ + type: "+", + line: 12, + oldLine: 11, + newLine: 12, + content: " return a + b;", + }) + file.addOperation({ + type: "+", + line: 13, + oldLine: 11, + newLine: 13, + content: "}", + }) + + file.sortGroups() + + mockPosition = new vscode.Position(10, 0) // Cursor on empty line + + const result = await provider.provideInlineCompletionItems( + mockDocument, + mockPosition, + mockContext, + mockToken, + ) + + // Should show inline completion combining comment + function + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + const items = result as vscode.InlineCompletionItem[] + expect(items.length).toBe(1) + + const completionText = items[0].insertText as string + // Should contain both comment and function + expect(completionText).toContain("// implement function") + expect(completionText).toContain("function addTwoNumbers") + expect(completionText).toContain("return a + b") + }) + + it("should combine modification suffix with subsequent additions (functio → functions + function)", async () => { + // Test: typing "// next use both these functio" should complete to: + // "ns" on same line + function on next lines + mockDocument = { + uri: vscode.Uri.file("/test/file.ts"), + lineCount: 20, + lineAt: (line: number) => { + if (line === 15) { + return { + text: "// next use both these functio", + range: new vscode.Range(line, 0, line, 30), + } + } + return { + text: `line ${line}`, + range: new vscode.Range(line, 0, line, 10), + } + }, + } as any + + const file = suggestions.addFile(mockDocument.uri) + + // Group 0: modification - "functio" → "functions" + const deleteOp: GhostSuggestionEditOperation = { + type: "-", + line: 15, + oldLine: 15, + newLine: 15, + content: "// next use both these functio", + } + + const addOp: GhostSuggestionEditOperation = { + type: "+", + line: 15, + oldLine: 16, + newLine: 15, + content: "// next use both these functions", + } + + file.addOperation(deleteOp) + file.addOperation(addOp) + + // Group 1: pure additions - function lines + file.addOperation({ + type: "+", + line: 16, + oldLine: 16, + newLine: 16, + content: "function useBothFunctions(a: number, b: number): { sum: number; product: number } {", + }) + file.addOperation({ + type: "+", + line: 17, + oldLine: 16, + newLine: 17, + content: " const sum = addNumbers(a, b);", + }) + file.addOperation({ + type: "+", + line: 18, + oldLine: 16, + newLine: 18, + content: " const product = multiplyNumbers(a, b);", + }) + file.addOperation({ + type: "+", + line: 19, + oldLine: 16, + newLine: 19, + content: " return { sum, product };", + }) + file.addOperation({ + type: "+", + line: 20, + oldLine: 16, + newLine: 20, + content: "}", + }) + + file.sortGroups() + + mockPosition = new vscode.Position(15, 30) // After "functio" + + const result = await provider.provideInlineCompletionItems( + mockDocument, + mockPosition, + mockContext, + mockToken, + ) + + // Should show inline completion + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + const items = result as vscode.InlineCompletionItem[] + expect(items.length).toBe(1) + + const completionText = items[0].insertText as string + // Should contain "ns" suffix on same line + expect(completionText).toContain("ns") + // Should contain function code on next lines + expect(completionText).toContain("function useBothFunctions") + expect(completionText).toContain("const sum = addNumbers") + // Should NOT duplicate existing text + expect(completionText).not.toContain("// next use both these functio") + }) + it("should show inline completion for comment prefix scenario", async () => { + // Test from comment-prefix spec: deletion+addition pattern where added content starts with deleted + const uri = vscode.Uri.parse("file:///test.ts") + + // Mock document with "// impl" on line 0 + mockDocument = { + uri, + getText: () => "// impl", + lineAt: (line: number) => ({ + text: line === 0 ? "// impl" : "", + range: new vscode.Range(line, 0, line, line === 0 ? 7 : 0), + }), + lineCount: 1, + offsetAt: (position: vscode.Position) => position.character, + positionAt: (offset: number) => new vscode.Position(0, offset), + } as any + + // Add file with deletion+addition pattern where added content starts with deleted + const file = suggestions.addFile(uri) + + // Group 0: Delete "// impl" at line 0 + const deleteOp: GhostSuggestionEditOperation = { + type: "-", + line: 0, + oldLine: 0, + newLine: 0, + content: "// impl", + } + file.addOperation(deleteOp) + + // Group 1: Add "// impl\nfunction implementation() {...}" at line 1 + const addOps: GhostSuggestionEditOperation[] = [ + { + type: "+", + line: 1, + oldLine: 2, + newLine: 1, + content: "// impl", + }, + { + type: "+", + line: 2, + oldLine: 2, + newLine: 2, + content: "function implementation() {", + }, + { + type: "+", + line: 3, + oldLine: 2, + newLine: 3, + content: " // Function implementation", + }, + { + type: "+", + line: 4, + oldLine: 2, + newLine: 4, + content: "}", + }, + ] + addOps.forEach((op) => file.addOperation(op)) + + suggestions.sortGroups() // Automatically selects first group + + // Cursor is at end of "// impl" (position 7) + const position = new vscode.Position(0, 7) + const context: vscode.InlineCompletionContext = { + triggerKind: 0, + selectedCompletionInfo: undefined, + } + + // Should provide inline completion with the suffix after common prefix + const items = await provider.provideInlineCompletionItems(mockDocument, position, context, { + isCancellationRequested: false, + } as any) + + expect(items).toBeDefined() + expect(Array.isArray(items)).toBe(true) + + if (Array.isArray(items) && items.length > 0) { + const item = items[0] + + // Should show function implementation after the comment + expect(item.insertText).toContain("function implementation()") + + // Should not show the "// impl" prefix since it's already typed + expect(item.insertText).not.toMatch(/^\/\/ impl/) + + // Should include newline before the function since it's multi-line + expect(item.insertText).toMatch(/^\n/) + } + }) + + it("should use target line without offset for modification groups", async () => { + // This test verifies the fix: modification groups should use deletion line + // without offset adjustments + const uri = vscode.Uri.parse("file:///test.ts") + + // Mock document with "// impl" on line 0 + mockDocument = { + uri, + getText: () => "// impl", + lineAt: (line: number) => ({ + text: line === 0 ? "// impl" : "", + range: new vscode.Range(line, 0, line, line === 0 ? 7 : 0), + }), + lineCount: 1, + offsetAt: (position: vscode.Position) => position.character, + positionAt: (offset: number) => new vscode.Position(0, offset), + } as any + + const file = suggestions.addFile(uri) + + // Deletion at line 0 + file.addOperation({ + type: "-", + line: 0, + oldLine: 0, + newLine: 0, + content: "// impl", + }) + + // Addition at line 1 (with common prefix) + file.addOperation({ + type: "+", + line: 1, + oldLine: 2, + newLine: 1, + content: "// implementation", + }) + + suggestions.sortGroups() // Automatically selects first group + + // Cursor at line 0 where the deletion/modification is + const position = new vscode.Position(0, 7) + const context: vscode.InlineCompletionContext = { + triggerKind: 0, + selectedCompletionInfo: undefined, + } + + const items = await provider.provideInlineCompletionItems(mockDocument, position, context, { + isCancellationRequested: false, + } as any) + + // Should provide completion because target line (0) is within distance threshold + expect(items).toBeDefined() + expect(Array.isArray(items)).toBe(true) + if (Array.isArray(items)) { + expect(items.length).toBeGreaterThan(0) + } + }) + }) + + describe("updateSuggestions", () => { + it("should update suggestions reference", () => { + const newSuggestions = new GhostSuggestionsState() + const file = newSuggestions.addFile(mockDocument.uri) + file.addOperation({ + type: "+", + line: 1, + oldLine: 1, + newLine: 1, + content: "test", + }) + + provider.updateSuggestions(newSuggestions) + + // Verify provider now uses the new suggestions + // This will be reflected in the next call to provideInlineCompletionItems + expect(() => provider.updateSuggestions(newSuggestions)).not.toThrow() + }) + }) + + describe("dispose", () => { + it("should dispose cleanly", () => { + expect(() => provider.dispose()).not.toThrow() + }) + }) +}) diff --git a/src/services/ghost/__tests__/GhostProvider.spec.ts b/src/services/ghost/__tests__/GhostProvider.spec.ts index 35fcfa48f40..babc5262beb 100644 --- a/src/services/ghost/__tests__/GhostProvider.spec.ts +++ b/src/services/ghost/__tests__/GhostProvider.spec.ts @@ -171,7 +171,7 @@ describe("GhostProvider", () => { const processedFinal = normalizedForComparison(normalizedFinal) const processedExpected = normalizedForComparison(normalizedExpected) expect(processedFinal).toBe(processedExpected) - } else if (testCaseName === "partial-mixed-operations") { + } else if (testCaseName === "comment-prefix-completion" || testCaseName === "partial-mixed-operations") { // For partial-mixed-operations, compare without whitespace const strippedFinal = normalizedFinal.replace(/\s+/g, "") const strippedExpected = normalizedExpected.replace(/\s+/g, "") @@ -205,6 +205,10 @@ describe("GhostProvider", () => { it("should apply function rename and var to const changes from files", async () => { await runFileBasedTest("function-rename-var-to-const") }) + + it("should handle comment prefix completion without duplication", async () => { + await runFileBasedTest("comment-prefix-completion") + }) }) describe("Sequential application", () => { diff --git a/src/services/ghost/__tests__/GhostStreamingParser.test.ts b/src/services/ghost/__tests__/GhostStreamingParser.test.ts index 1a9d5355efc..39398789f7c 100644 --- a/src/services/ghost/__tests__/GhostStreamingParser.test.ts +++ b/src/services/ghost/__tests__/GhostStreamingParser.test.ts @@ -1,5 +1,5 @@ import { GhostStreamingParser, findBestMatch } from "../GhostStreamingParser" -import { GhostSuggestionContext } from "../types" +import { GhostSuggestionContext, GhostSuggestionEditOperation } from "../types" // Mock vscode module vi.mock("vscode", () => ({ @@ -175,6 +175,102 @@ function fibonacci(n: number): number { expect(result.suggestions.hasSuggestions()).toBe(true) }) + it("should not create extra newline when replacing cursor marker on empty line", () => { + // Mock document with cursor marker on an empty line (line 17) + const mockDocumentWithCursor: any = { + uri: { toString: () => "/test/file.ts", fsPath: "/test/file.ts" }, + getText: () => `function test() { + return true; +} + +// Some other code +function another() { + const x = 1; +} + +// Line 16 +<<>> +// Line 18`, + languageId: "typescript", + } + + const contextWithCursor = { + document: mockDocumentWithCursor, + } + + parser.initialize(contextWithCursor) + + // This is the exact response from the logs showing the issue + const changeWithCursor = `>>]]>` + + const result = parser.parseResponse(changeWithCursor) + + expect(result.hasNewSuggestions).toBe(true) + expect(result.suggestions.hasSuggestions()).toBe(true) + + // Verify the result doesn't have an extra blank line before "// Line 18" + // The suggestion should replace the empty line (with cursor marker) with the new code + const file = result.suggestions.getFile(mockDocumentWithCursor.uri.toString()) + expect(file).toBeDefined() + + // Check that operations don't add unnecessary blank lines + const operations = file!.getAllOperations() + const additionLines = operations + .filter((op: GhostSuggestionEditOperation) => op.type === "+") + .map((op: GhostSuggestionEditOperation) => op.content) + + // The first line should be the comment, not an empty line + expect(additionLines[0]).toBe("// implement function to calculate factorial") + }) + + it("should not create extra newline when cursor marker is at start of empty line", () => { + // Mock document with cursor marker at the very start of an empty line + const mockDocumentWithCursor: any = { + uri: { toString: () => "/test/file.ts", fsPath: "/test/file.ts" }, + getText: () => `function implementYetAnotherFeature() { + // Implementation of yet another described functionality + console.log("Yet another feature implemented"); +} +<<>>`, + languageId: "typescript", + } + + const contextWithCursor = { + document: mockDocumentWithCursor, + } + + parser.initialize(contextWithCursor) + + // Response that should replace the marker and empty line + const changeWithCursor = `>>]]>` + + const result = parser.parseResponse(changeWithCursor) + + expect(result.hasNewSuggestions).toBe(true) + expect(result.suggestions.hasSuggestions()).toBe(true) + + // Verify no extra blank line is created + const file = result.suggestions.getFile(mockDocumentWithCursor.uri.toString()) + expect(file).toBeDefined() + + const operations = file!.getAllOperations() + // There should be a deletion (the newline) and additions (the new function) + const deletions = operations.filter((op: GhostSuggestionEditOperation) => op.type === "-") + const additions = operations.filter((op: GhostSuggestionEditOperation) => op.type === "+") + + // Should have a deletion for the newline + expect(deletions.length).toBeGreaterThan(0) + // Should have additions for the new function + expect(additions.length).toBeGreaterThan(0) + }) + it("should handle malformed XML gracefully", () => { const malformedXml = `` @@ -200,6 +296,170 @@ function fibonacci(n: number): number { expect(result.isComplete).toBe(true) expect(result.suggestions.hasSuggestions()).toBe(false) }) + + it("should not create extra newline when there's an empty line before the cursor marker", () => { + // Mock document with an empty line before the cursor marker + const mockDocumentWithCursor: any = { + uri: { toString: () => "/test/file.ts", fsPath: "/test/file.ts" }, + getText: () => `// impl +function implementation() { + // This is the implementation of the functionality described in the comment + console.log('Functionality implemented'); +} + +<<>>`, + languageId: "typescript", + } + + const contextWithCursor = { + document: mockDocumentWithCursor, + } + + parser.initialize(contextWithCursor) + + // Response that should replace the marker and consume the empty line before it + const changeWithCursor = `>>]]>` + + const result = parser.parseResponse(changeWithCursor) + + expect(result.hasNewSuggestions).toBe(true) + expect(result.suggestions.hasSuggestions()).toBe(true) + + // Verify the empty line before the marker is consumed + const file = result.suggestions.getFile(mockDocumentWithCursor.uri.toString()) + expect(file).toBeDefined() + + const operations = file!.getAllOperations() + const deletions = operations.filter((op: GhostSuggestionEditOperation) => op.type === "-") + const additions = operations.filter((op: GhostSuggestionEditOperation) => op.type === "+") + + // Should have a deletion for the empty line + expect(deletions.length).toBeGreaterThan(0) + // Should have additions for the new function + expect(additions.length).toBeGreaterThan(0) + + // First addition should be the comment + const additionLines = additions.map((op: GhostSuggestionEditOperation) => op.content) + expect(additionLines[0]).toBe("// Add new functionality here") + }) + // TODO: this should be turned back on when we have the latest parser + it.skip("should add newline when cursor marker is at end of line with content", () => { + // Mock document with cursor marker at the end of a comment line + const mockDocumentWithCursor: any = { + uri: { toString: () => "/test/file.ts", fsPath: "/test/file.ts" }, + getText: () => `// impl +function implementFeature() { + // Implementation code here +} + +// implement another feature + +function implementAnotherFeature() { + // Implementation code for another feature +} + +// implement function to add two numbers<<>>`, + languageId: "typescript", + } + + const contextWithCursor = { + document: mockDocumentWithCursor, + } + + parser.initialize(contextWithCursor) + + // Response that should add a newline before the function + const changeWithCursor = `>>]]>` + + const result = parser.parseResponse(changeWithCursor) + + expect(result.hasNewSuggestions).toBe(true) + expect(result.suggestions.hasSuggestions()).toBe(true) + + // Verify the function is on a new line, not concatenated with the comment + const file = result.suggestions.getFile(mockDocumentWithCursor.uri.toString()) + expect(file).toBeDefined() + + const operations = file!.getAllOperations() + const additions = operations.filter((op: GhostSuggestionEditOperation) => op.type === "+") + + // Should have additions for the new function + expect(additions.length).toBeGreaterThan(0) + + // Check that we have both the comment line and the function (as separate additions) + const commentAddition = additions.find((op: GhostSuggestionEditOperation) => + op.content.includes("// implement function to add two numbers"), + ) + const functionAddition = additions.find((op: GhostSuggestionEditOperation) => + op.content.includes("function addNumbers"), + ) + + // Both should exist as separate operations + expect(commentAddition).toBeDefined() + expect(functionAddition).toBeDefined() + + // The function should be on a later line than the comment + expect(functionAddition!.line).toBeGreaterThan(commentAddition!.line) + }) + + it("should not add extra newline when LLM response starts with newline for cursor marker on its own line", () => { + // This test reproduces the exact issue from the logs where the LLM returns + // a replacement that starts with a newline when the cursor marker is on its own line + const mockDocumentWithCursor: any = { + uri: { toString: () => "/test/file.ts", fsPath: "/test/file.ts" }, + getText: () => `// implement function add two +function addTwo(a: number, b: number): number { + return a + b; +} + + +// implement function subtract two +function subtractTwo(a: number, b: number): number { + return a - b; +} +// implement function multiply two +<<>>`, + languageId: "typescript", + } + + const contextWithCursor = { + document: mockDocumentWithCursor, + } + + parser.initialize(contextWithCursor) + + // The LLM response starts with a newline after CDATA (this is the bug scenario) + const changeWithCursor = `>>]]>` + + const result = parser.parseResponse(changeWithCursor) + + expect(result.hasNewSuggestions).toBe(true) + expect(result.suggestions.hasSuggestions()).toBe(true) + + // Verify NO extra blank line is created between the comment and the function + const file = result.suggestions.getFile(mockDocumentWithCursor.uri.toString()) + expect(file).toBeDefined() + + const operations = file!.getAllOperations() + const additions = operations.filter((op: GhostSuggestionEditOperation) => op.type === "+") + + // Should have additions for the new function + expect(additions.length).toBeGreaterThan(0) + + // The first addition should be the function, not an empty line + // Check that the first line of content is the function declaration + const firstAddition = additions[0] + expect(firstAddition.content).toMatch(/^function multiplyTwo/) + }) }) describe("findBestMatch", () => { diff --git a/src/services/ghost/__tests__/GhostSuggestions.test.ts b/src/services/ghost/__tests__/GhostSuggestions.test.ts index 0893cf72f95..5c6b5ace0b5 100644 --- a/src/services/ghost/__tests__/GhostSuggestions.test.ts +++ b/src/services/ghost/__tests__/GhostSuggestions.test.ts @@ -1,582 +1,317 @@ -import * as vscode from "vscode" import { GhostSuggestionsState } from "../GhostSuggestions" -import { GhostSuggestionEditOperation } from "../types" +import * as vscode from "vscode" -describe("GhostSuggestions", () => { - let ghostSuggestions: GhostSuggestionsState +// Mock vscode module +vi.mock("vscode", () => ({ + Uri: { + file: (path: string) => ({ + toString: () => path, + fsPath: path, + }), + }, +})) + +describe("GhostSuggestionsState", () => { + let suggestions: GhostSuggestionsState let mockUri: vscode.Uri beforeEach(() => { - ghostSuggestions = new GhostSuggestionsState() + suggestions = new GhostSuggestionsState() mockUri = vscode.Uri.file("/test/file.ts") }) - describe("selectClosestGroup", () => { - it("should select the closest group to a selection", () => { - const file = ghostSuggestions.addFile(mockUri) + describe("sortGroups", () => { + it("should filter out deletion operations with empty content", () => { + const file = suggestions.addFile(mockUri) - // Add operations with large distances to ensure separate groups - const operation1: GhostSuggestionEditOperation = { - line: 1, - oldLine: 1, - newLine: 1, - type: "+", - content: "line 1", - } - const operation2: GhostSuggestionEditOperation = { - line: 50, - oldLine: 50, - newLine: 50, - type: "+", - content: "line 50", - } - const operation3: GhostSuggestionEditOperation = { - line: 100, - oldLine: 100, - newLine: 100, + // Add operations that simulate the user's scenario + // Group 1: deletion of empty string + addition + file.addOperation({ + type: "-", + line: 33, + oldLine: 33, + newLine: 33, + content: "", + }) + file.addOperation({ type: "+", - content: "line 100", - } - - file.addOperation(operation1) - file.addOperation(operation2) - file.addOperation(operation3) - - file.sortGroups() - - const groups = file.getGroupsOperations() - - // Test the selectClosestGroup functionality regardless of how many groups exist - if (groups.length === 1) { - // All operations are in one group - test that it selects the group - const selection = new vscode.Selection(45, 0, 55, 0) // Closest to operation2 at line 50 - file.selectClosestGroup(selection) - expect(file.getSelectedGroup()).toBe(0) // Only group - } else { - // Multiple groups exist - test that it selects the closest one - expect(groups.length).toBeGreaterThan(1) - const selection = new vscode.Selection(45, 0, 55, 0) // Closest to operation2 at line 50 - file.selectClosestGroup(selection) - // Should select whichever group contains the operation closest to line 50 - expect(file.getSelectedGroup()).not.toBeNull() - } - }) - - it("should select group when selection overlaps with operation", () => { - const file = ghostSuggestions.addFile(mockUri) - - const operation1: GhostSuggestionEditOperation = { - line: 5, - oldLine: 5, - newLine: 5, + line: 33, + oldLine: 35, + newLine: 33, + content: "// implement function to divide two numbers", + }) + + // Group 2: only deletion of empty string + file.addOperation({ + type: "-", + line: 34, + oldLine: 34, + newLine: 33, + content: "", + }) + + // Group 3: multiple additions + file.addOperation({ type: "+", - content: "line 5", - } - const operation2: GhostSuggestionEditOperation = { - line: 50, - oldLine: 50, - newLine: 50, + line: 34, + oldLine: 35, + newLine: 34, + content: "function divideNumbers(a: number, b: number): number {", + }) + file.addOperation({ type: "+", - content: "line 50", - } - - file.addOperation(operation1) - file.addOperation(operation2) - - file.sortGroups() - - // Create a selection that includes line 50 - const selection = new vscode.Selection(49, 0, 51, 0) - file.selectClosestGroup(selection) - - // Should select a group (distance is 0 since selection overlaps) - expect(file.getSelectedGroup()).not.toBeNull() + line: 35, + oldLine: 35, + newLine: 35, + content: " if (b === 0) {", + }) + + // Before sorting and filtering + const groupsBefore = file.getGroupsOperations() + expect(groupsBefore.length).toBe(3) + + // Sort and filter + suggestions.sortGroups() + + // After sorting and filtering + const groupsAfter = file.getGroupsOperations() + + // Group 2 (only empty deletion) should be removed entirely + expect(groupsAfter.length).toBe(2) + + // Group 1 should only have the addition (empty deletion filtered out) + const group1 = groupsAfter[0] + expect(group1.length).toBe(1) + expect(group1[0].type).toBe("+") + expect(group1[0].content).toBe("// implement function to divide two numbers") + + // Group 3 should have all additions + const group2 = groupsAfter[1] + expect(group2.length).toBe(2) + expect(group2.every((op) => op.type === "+")).toBe(true) }) - it("should select first group when selection is before all operations", () => { - const file = ghostSuggestions.addFile(mockUri) + it("should not filter out deletions with non-empty content", () => { + const file = suggestions.addFile(mockUri) - const operation1: GhostSuggestionEditOperation = { + // Add a deletion with actual content + file.addOperation({ + type: "-", line: 10, oldLine: 10, newLine: 10, - type: "+", - content: "line 10", - } - const operation2: GhostSuggestionEditOperation = { - line: 20, - oldLine: 20, - newLine: 20, - type: "+", - content: "line 20", - } - - file.addOperation(operation1) - file.addOperation(operation2) - - file.sortGroups() + content: "const x = 1;", + }) - // Create a selection before all operations - const selection = new vscode.Selection(1, 0, 3, 0) - file.selectClosestGroup(selection) - - expect(file.getSelectedGroup()).toBe(0) // First group (operation1) - }) - - it("should select group closest to selection when selection is after all operations", () => { - const file = ghostSuggestions.addFile(mockUri) - - const operation1: GhostSuggestionEditOperation = { + // Add an addition + file.addOperation({ + type: "+", line: 10, oldLine: 10, newLine: 10, - type: "+", - content: "line 10", - } - const operation2: GhostSuggestionEditOperation = { - line: 50, - oldLine: 50, - newLine: 50, - type: "+", - content: "line 50", - } - - file.addOperation(operation1) - file.addOperation(operation2) + content: "const x = 2;", + }) - file.sortGroups() - - // Create a selection after all operations (closer to operation2) - const selection = new vscode.Selection(60, 0, 65, 0) - file.selectClosestGroup(selection) - - // Should select a group (the one with operation closest to the selection) - expect(file.getSelectedGroup()).not.toBeNull() - }) + suggestions.sortGroups() - it("should handle empty groups", () => { - const file = ghostSuggestions.addFile(mockUri) - - const selection = new vscode.Selection(10, 0, 15, 0) - file.selectClosestGroup(selection) + const groups = file.getGroupsOperations() + expect(groups.length).toBe(1) + expect(groups[0].length).toBe(2) - expect(file.getSelectedGroup()).toBeNull() + // Both operations should still be present + const hasDeletion = groups[0].some((op) => op.type === "-" && op.content === "const x = 1;") + const hasAddition = groups[0].some((op) => op.type === "+" && op.content === "const x = 2;") + expect(hasDeletion).toBe(true) + expect(hasAddition).toBe(true) }) - it("should select group with multiple operations closest to selection", () => { - const file = ghostSuggestions.addFile(mockUri) + it("should handle multiple groups with mixed empty and non-empty deletions", () => { + const file = suggestions.addFile(mockUri) - // Create a group with multiple operations - const operation1: GhostSuggestionEditOperation = { + // Group 1: Empty deletion only (should be removed) + file.addOperation({ + type: "-", line: 5, oldLine: 5, newLine: 5, - type: "+", - content: "line 5", - } - const operation2: GhostSuggestionEditOperation = { - line: 6, - oldLine: 6, - newLine: 6, - type: "+", - content: "line 6", - } - const operation3: GhostSuggestionEditOperation = { - line: 20, - oldLine: 20, - newLine: 20, - type: "+", - content: "line 20", - } - - file.addOperation(operation1) - file.addOperation(operation2) // Should be in same group as operation1 - file.addOperation(operation3) // Should be in different group - - file.sortGroups() - - // Create a selection closer to the first group - const selection = new vscode.Selection(8, 0, 10, 0) - file.selectClosestGroup(selection) - - expect(file.getSelectedGroup()).toBe(0) // First group - }) - }) - - describe("addOperation grouping rules", () => { - it("should create modification group (delete on line N, add on line N+1) with highest priority", () => { - const file = ghostSuggestions.addFile(mockUri) + content: "", + }) - // Add a delete operation on line 5 - const deleteOp: GhostSuggestionEditOperation = { - line: 5, - oldLine: 5, - newLine: 6, + // Group 2: Non-empty deletion and addition (should be kept) + file.addOperation({ type: "-", - content: "old content", - } - file.addOperation(deleteOp) - - // Add an add operation on line 6 (next line) - should form modification group - const addOp: GhostSuggestionEditOperation = { - line: 6, - oldLine: 5, - newLine: 6, + line: 10, + oldLine: 10, + newLine: 10, + content: "old code", + }) + file.addOperation({ type: "+", - content: "new content", - } - file.addOperation(addOp) - - const groups = file.getGroupsOperations() - expect(groups.length).toBe(1) - expect(groups[0].length).toBe(2) - expect(groups[0]).toContainEqual(deleteOp) - expect(groups[0]).toContainEqual(addOp) - }) - - it("should move operation from existing group to create modification group", () => { - const file = ghostSuggestions.addFile(mockUri) + line: 10, + oldLine: 10, + newLine: 10, + content: "new code", + }) - // Add consecutive delete operations (should be in same group) - const deleteOp1: GhostSuggestionEditOperation = { - line: 5, - oldLine: 5, - newLine: 6, + // Group 3: Empty deletion + addition (deletion filtered, group kept) + file.addOperation({ type: "-", - content: "line 5", - } - const deleteOp2: GhostSuggestionEditOperation = { - line: 6, - oldLine: 6, - newLine: 6, - type: "-", - content: "line 6", - } - file.addOperation(deleteOp1) - file.addOperation(deleteOp2) - - // Add an add operation on line 6 (after deleteOp1) - should move deleteOp1 to new modification group - const addOp: GhostSuggestionEditOperation = { - line: 6, - oldLine: 5, - newLine: 6, + line: 15, + oldLine: 15, + newLine: 15, + content: "", + }) + file.addOperation({ type: "+", - content: "new line 6", - } - file.addOperation(addOp) - - const groups = file.getGroupsOperations() - expect(groups.length).toBe(2) - - // Find the modification group - const modificationGroup = groups.find( - (group) => group.some((op) => op.type === "+") && group.some((op) => op.type === "-"), - ) - expect(modificationGroup).toBeDefined() - expect(modificationGroup!.length).toBe(2) - expect(modificationGroup).toContainEqual(deleteOp1) - expect(modificationGroup).toContainEqual(addOp) - - // Find the delete-only group - const deleteGroup = groups.find((group) => group.every((op) => op.type === "-") && group.length === 1) - expect(deleteGroup).toBeDefined() - expect(deleteGroup).toContainEqual(deleteOp2) - }) - - it("should group consecutive delete operations", () => { - const file = ghostSuggestions.addFile(mockUri) - - const deleteOp1: GhostSuggestionEditOperation = { - line: 5, - oldLine: 5, - newLine: 5, - type: "-", - content: "line 5", - } - const deleteOp2: GhostSuggestionEditOperation = { - line: 6, - oldLine: 6, - newLine: 6, - type: "-", - content: "line 6", - } - const deleteOp3: GhostSuggestionEditOperation = { - line: 7, - oldLine: 7, - newLine: 7, - type: "-", - content: "line 7", - } + line: 15, + oldLine: 15, + newLine: 15, + content: "added line", + }) - file.addOperation(deleteOp1) - file.addOperation(deleteOp2) - file.addOperation(deleteOp3) + suggestions.sortGroups() const groups = file.getGroupsOperations() - expect(groups.length).toBe(1) - expect(groups[0].length).toBe(3) - expect(groups[0]).toContainEqual(deleteOp1) - expect(groups[0]).toContainEqual(deleteOp2) - expect(groups[0]).toContainEqual(deleteOp3) - }) - - it("should group consecutive add operations", () => { - const file = ghostSuggestions.addFile(mockUri) - const addOp1: GhostSuggestionEditOperation = { - line: 5, - oldLine: 5, - newLine: 5, - type: "+", - content: "line 5", - } - const addOp2: GhostSuggestionEditOperation = { - line: 6, - oldLine: 6, - newLine: 6, - type: "+", - content: "line 6", - } - const addOp3: GhostSuggestionEditOperation = { - line: 7, - oldLine: 7, - newLine: 7, - type: "+", - content: "line 7", - } + // Should have 2 groups (group 1 removed entirely) + expect(groups.length).toBe(2) - file.addOperation(addOp1) - file.addOperation(addOp2) - file.addOperation(addOp3) + // First group should have both operations (non-empty deletion) + expect(groups[0].length).toBe(2) + expect(groups[0].some((op) => op.type === "-" && op.content === "old code")).toBe(true) - const groups = file.getGroupsOperations() - expect(groups.length).toBe(1) - expect(groups[0].length).toBe(3) - expect(groups[0]).toContainEqual(addOp1) - expect(groups[0]).toContainEqual(addOp2) - expect(groups[0]).toContainEqual(addOp3) + // Second group should only have the addition (empty deletion filtered) + expect(groups[1].length).toBe(1) + expect(groups[1][0].type).toBe("+") + expect(groups[1][0].content).toBe("added line") }) - it("should create separate groups for non-consecutive operations", () => { - const file = ghostSuggestions.addFile(mockUri) + it("should filter out addition operations with empty content", () => { + const file = suggestions.addFile(mockUri) - const addOp1: GhostSuggestionEditOperation = { - line: 5, - oldLine: 5, - newLine: 5, + // Add operations with empty additions + file.addOperation({ type: "+", - content: "line 5", - } - const addOp2: GhostSuggestionEditOperation = { line: 10, oldLine: 10, newLine: 10, + content: "", + }) + file.addOperation({ type: "+", - content: "line 10", - } // Gap of 5 lines - - file.addOperation(addOp1) - file.addOperation(addOp2) - - const groups = file.getGroupsOperations() - expect(groups.length).toBe(2) - expect(groups[0]).toContainEqual(addOp1) - expect(groups[1]).toContainEqual(addOp2) - }) - - it("should create modification group when delete is followed by add on next line", () => { - const file = ghostSuggestions.addFile(mockUri) + line: 11, + oldLine: 11, + newLine: 11, + content: "actual content", + }) - const deleteOp: GhostSuggestionEditOperation = { - line: 5, - oldLine: 5, - newLine: 6, - type: "-", - content: "line 5", - } - const addOp: GhostSuggestionEditOperation = { - line: 6, - oldLine: 5, - newLine: 6, + // Group with only empty addition (should be removed) + file.addOperation({ type: "+", - content: "line 6", - } // Next line - should form modification group + line: 20, + oldLine: 20, + newLine: 20, + content: "", + }) - file.addOperation(deleteOp) - file.addOperation(addOp) + suggestions.sortGroups() const groups = file.getGroupsOperations() - expect(groups.length).toBe(1) // Should be in one modification group - expect(groups[0].length).toBe(2) - expect(groups[0]).toContainEqual(deleteOp) - expect(groups[0]).toContainEqual(addOp) - }) - - it("should not group different operation types when not consecutive", () => { - const file = ghostSuggestions.addFile(mockUri) - const deleteOp: GhostSuggestionEditOperation = { - line: 5, - oldLine: 5, - newLine: 5, - type: "-", - content: "line 5", - } - const addOp: GhostSuggestionEditOperation = { - line: 7, - oldLine: 7, - newLine: 7, - type: "+", - content: "line 7", - } // Gap of 1 line - should not form modification group - - file.addOperation(deleteOp) - file.addOperation(addOp) - - const groups = file.getGroupsOperations() - expect(groups.length).toBe(2) - expect(groups[0]).toContainEqual(deleteOp) - expect(groups[1]).toContainEqual(addOp) + // Should only have 1 group (second group removed, first group filtered) + expect(groups.length).toBe(1) + expect(groups[0].length).toBe(1) + expect(groups[0][0].content).toBe("actual content") }) - it("should handle reverse consecutive operations (adding before existing)", () => { - const file = ghostSuggestions.addFile(mockUri) + it("should filter out both empty additions and empty deletions", () => { + const file = suggestions.addFile(mockUri) - const addOp1: GhostSuggestionEditOperation = { - line: 6, - oldLine: 6, - newLine: 6, - type: "+", - content: "line 6", - } - const addOp2: GhostSuggestionEditOperation = { + // Group 1: Empty deletion + empty addition (should be removed entirely) + file.addOperation({ + type: "-", line: 5, oldLine: 5, newLine: 5, + content: "", + }) + file.addOperation({ type: "+", - content: "line 5", - } // Before existing - - file.addOperation(addOp1) - file.addOperation(addOp2) - - const groups = file.getGroupsOperations() - expect(groups.length).toBe(1) - expect(groups[0].length).toBe(2) - expect(groups[0]).toContainEqual(addOp1) - expect(groups[0]).toContainEqual(addOp2) - }) - - it("should prioritize modification groups over same-type groups", () => { - const file = ghostSuggestions.addFile(mockUri) - - // Create a group of consecutive add operations - const addOp1: GhostSuggestionEditOperation = { - line: 6, - oldLine: 5, - newLine: 6, - type: "+", - content: "line 6", - } - const addOp2: GhostSuggestionEditOperation = { - line: 7, - oldLine: 7, - newLine: 7, - type: "+", - content: "line 7", - } - file.addOperation(addOp1) - file.addOperation(addOp2) - - // Add a delete operation on line 5 - should move addOp1 to modification group (delete line 5, add line 6) - const deleteOp: GhostSuggestionEditOperation = { line: 5, oldLine: 5, - newLine: 6, - type: "-", - content: "old line 5", - } - file.addOperation(deleteOp) - - const groups = file.getGroupsOperations() - expect(groups.length).toBe(2) - - // Find modification group - const modificationGroup = groups.find( - (group) => group.some((op) => op.type === "+") && group.some((op) => op.type === "-"), - ) - expect(modificationGroup).toBeDefined() - expect(modificationGroup!.length).toBe(2) - expect(modificationGroup).toContainEqual(addOp1) - expect(modificationGroup).toContainEqual(deleteOp) - - // Find remaining add group - const addGroup = groups.find((group) => group.every((op) => op.type === "+") && group.length === 1) - expect(addGroup).toBeDefined() - expect(addGroup).toContainEqual(addOp2) - }) - - it("should handle complex mixed operations scenario", () => { - const file = ghostSuggestions.addFile(mockUri) + newLine: 5, + content: "", + }) - // Add operations in mixed order - const deleteOp1: GhostSuggestionEditOperation = { - line: 5, - oldLine: 5, - newLine: 6, - type: "-", - content: "line 5", - } - const deleteOp2: GhostSuggestionEditOperation = { - line: 7, - oldLine: 7, - newLine: 7, + // Group 2: Empty deletion + non-empty addition (deletion filtered, group kept) + file.addOperation({ type: "-", - content: "line 7", - } - const addOp1: GhostSuggestionEditOperation = { line: 10, oldLine: 10, newLine: 10, + content: "", + }) + file.addOperation({ type: "+", - content: "line 10", - } - const addOp2: GhostSuggestionEditOperation = { - line: 11, - oldLine: 11, - newLine: 11, + line: 10, + oldLine: 10, + newLine: 10, + content: "valid addition", + }) + + // Group 3: Non-empty deletion + empty addition (addition filtered, group kept) + file.addOperation({ + type: "-", + line: 15, + oldLine: 15, + newLine: 15, + content: "valid deletion", + }) + file.addOperation({ type: "+", - content: "line 11", - } - const modifyAdd: GhostSuggestionEditOperation = { - line: 6, - oldLine: 5, - newLine: 6, + line: 15, + oldLine: 15, + newLine: 15, + content: "", + }) + + // Group 4: Both non-empty (should be kept as is) + file.addOperation({ + type: "-", + line: 20, + oldLine: 20, + newLine: 20, + content: "old code", + }) + file.addOperation({ type: "+", - content: "new line 6", - } + line: 20, + oldLine: 20, + newLine: 20, + content: "new code", + }) - file.addOperation(deleteOp1) - file.addOperation(deleteOp2) - file.addOperation(addOp1) - file.addOperation(addOp2) - file.addOperation(modifyAdd) // Should create modification group with deleteOp1 (delete line 5, add line 6) + suggestions.sortGroups() const groups = file.getGroupsOperations() + + // Should have 3 groups (group 1 removed entirely) expect(groups.length).toBe(3) - // Should have: modification group (delete line 5, add line 6), delete group (line 7), add group (lines 10-11) - const modificationGroup = groups.find( - (group) => group.some((op) => op.type === "+") && group.some((op) => op.type === "-"), - ) - expect(modificationGroup).toBeDefined() - expect(modificationGroup!.length).toBe(2) + // Group 1 (originally group 2): Only addition + expect(groups[0].length).toBe(1) + expect(groups[0][0].type).toBe("+") + expect(groups[0][0].content).toBe("valid addition") - const deleteGroup = groups.find((group) => group.every((op) => op.type === "-") && group.length === 1) - expect(deleteGroup).toBeDefined() + // Group 2 (originally group 3): Only deletion + expect(groups[1].length).toBe(1) + expect(groups[1][0].type).toBe("-") + expect(groups[1][0].content).toBe("valid deletion") - const addGroup = groups.find((group) => group.every((op) => op.type === "+") && group.length === 2) - expect(addGroup).toBeDefined() + // Group 3 (originally group 4): Both operations + expect(groups[2].length).toBe(2) + expect(groups[2].some((op) => op.type === "-" && op.content === "old code")).toBe(true) + expect(groups[2].some((op) => op.type === "+" && op.content === "new code")).toBe(true) }) }) }) diff --git a/src/services/ghost/__tests__/__test_cases__/comment-prefix-completion/expected.js b/src/services/ghost/__tests__/__test_cases__/comment-prefix-completion/expected.js new file mode 100644 index 00000000000..2b10c2aa847 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/comment-prefix-completion/expected.js @@ -0,0 +1,4 @@ +// implement +function implement() { + // Implementation code here +} diff --git a/src/services/ghost/__tests__/__test_cases__/comment-prefix-completion/input.js b/src/services/ghost/__tests__/__test_cases__/comment-prefix-completion/input.js new file mode 100644 index 00000000000..5231ab26cef --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/comment-prefix-completion/input.js @@ -0,0 +1 @@ +// implement <<>> diff --git a/src/services/ghost/__tests__/__test_cases__/comment-prefix-completion/response.txt b/src/services/ghost/__tests__/__test_cases__/comment-prefix-completion/response.txt new file mode 100644 index 00000000000..8eb803479b4 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/comment-prefix-completion/response.txt @@ -0,0 +1,4 @@ +>>]]> \ No newline at end of file diff --git a/src/services/ghost/__tests__/__test_cases__/ruby-indented-cursor/expected.rb b/src/services/ghost/__tests__/__test_cases__/ruby-indented-cursor/expected.rb new file mode 100644 index 00000000000..54096c31a19 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/ruby-indented-cursor/expected.rb @@ -0,0 +1,4 @@ +class HelloWorld + def initialize + puts "Hello, World!" + end \ No newline at end of file diff --git a/src/services/ghost/__tests__/__test_cases__/ruby-indented-cursor/input.rb b/src/services/ghost/__tests__/__test_cases__/ruby-indented-cursor/input.rb new file mode 100644 index 00000000000..223f8fee5c0 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/ruby-indented-cursor/input.rb @@ -0,0 +1,2 @@ +class HelloWorld + <<>> \ No newline at end of file diff --git a/src/services/ghost/__tests__/__test_cases__/ruby-indented-cursor/response.txt b/src/services/ghost/__tests__/__test_cases__/ruby-indented-cursor/response.txt new file mode 100644 index 00000000000..9a671942661 --- /dev/null +++ b/src/services/ghost/__tests__/__test_cases__/ruby-indented-cursor/response.txt @@ -0,0 +1,3 @@ +>>]]> \ No newline at end of file diff --git a/src/services/ghost/index.ts b/src/services/ghost/index.ts index 7e0e885517f..4192e63424f 100644 --- a/src/services/ghost/index.ts +++ b/src/services/ghost/index.ts @@ -38,6 +38,11 @@ export const registerGhostProvider = (context: vscode.ExtensionContext, cline: C ghost.applySelectedSuggestions() }), ) + context.subscriptions.push( + vscode.commands.registerCommand("kilo-code.ghost.acceptInlineCompletion", async () => { + ghost.acceptInlineCompletion() + }), + ) context.subscriptions.push( vscode.commands.registerCommand("kilo-code.ghost.goToNextSuggestion", async () => { await ghost.selectNextSuggestion()