diff --git a/.changeset/markdown-autocomplete-filter.md b/.changeset/markdown-autocomplete-filter.md new file mode 100644 index 00000000000..d26954c4298 --- /dev/null +++ b/.changeset/markdown-autocomplete-filter.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Minor improvement to markdown autocomplete suggestions diff --git a/src/services/ghost/classic-auto-complete/GhostInlineCompletionProvider.ts b/src/services/ghost/classic-auto-complete/GhostInlineCompletionProvider.ts index a18c36ea6d8..9a40420b8e3 100644 --- a/src/services/ghost/classic-auto-complete/GhostInlineCompletionProvider.ts +++ b/src/services/ghost/classic-auto-complete/GhostInlineCompletionProvider.ts @@ -370,6 +370,7 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte suffix: string, model: GhostModel, telemetryContext: AutocompleteContext, + languageId?: string, ): FillInAtCursorSuggestion { if (!suggestionText) { this.telemetry?.captureSuggestionFiltered("empty_response", telemetryContext) @@ -381,6 +382,7 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte prefix, suffix, model: model.getModelName() || "", + languageId, }) if (processedText) { @@ -681,9 +683,9 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte } try { - // Curry processSuggestion with prefix, suffix, model, and telemetry context + // Curry processSuggestion with prefix, suffix, model, telemetry context, and languageId const curriedProcessSuggestion = (text: string) => - this.processSuggestion(text, prefix, suffix, this.model, telemetryContext) + this.processSuggestion(text, prefix, suffix, this.model, telemetryContext, languageId) const result = prompt.strategy === "fim" diff --git a/src/services/ghost/classic-auto-complete/language-filters/__tests__/markdown.spec.ts b/src/services/ghost/classic-auto-complete/language-filters/__tests__/markdown.spec.ts new file mode 100644 index 00000000000..7db4d2eaf87 --- /dev/null +++ b/src/services/ghost/classic-auto-complete/language-filters/__tests__/markdown.spec.ts @@ -0,0 +1,84 @@ +import { isInsideCodeBlock, removeSpuriousNewlinesBeforeCodeBlockClosingFences } from "../markdown" + +describe("isInsideCodeBlock", () => { + it("returns false for normal markdown", () => { + expect(isInsideCodeBlock("# Heading\n\nSome text")).toBe(false) + }) + + it("returns true for an unclosed fenced block", () => { + expect(isInsideCodeBlock("```ruby\nputs 'hello'")).toBe(true) + }) + + it("returns false when the fenced block is closed", () => { + expect(isInsideCodeBlock("```ruby\nputs 'hello'\n```\n\nMore text")).toBe(false) + }) + + it("returns true when the last fenced block is unclosed", () => { + const prefix = `# Example + +\`\`\`javascript +const x = 1 +\`\`\` + +Some text + +\`\`\`typescript +const y: number = 2` + expect(isInsideCodeBlock(prefix)).toBe(true) + }) +}) + +describe("removeSpuriousNewlinesBeforeCodeBlockClosingFences", () => { + it("strips ALL leading newline(s) before a closing fence when prefix ends with a newline", () => { + const prefix = "```ruby\nputs 'yo'\n" + + const cases: Array<{ suggestion: string; expected: string }> = [ + { suggestion: "\n```", expected: "```" }, + { suggestion: "\n\n```\n\n# some heading", expected: "```\n\n# some heading" }, + { suggestion: "\r\n```\r\n\r\n# heading", expected: "```\r\n\r\n# heading" }, + ] + + for (const { suggestion, expected } of cases) { + const result = removeSpuriousNewlinesBeforeCodeBlockClosingFences({ suggestion, prefix, suffix: "" }) + expect(result).toBe(expected) + } + }) + + it("keeps exactly ONE leading newline before a closing fence when prefix does NOT end with a newline", () => { + const prefix = "```ruby\nputs 'yo'" + + const cases: Array<{ suggestion: string; expected: string }> = [ + { suggestion: "\n```", expected: "\n```" }, + { suggestion: "\n\n```", expected: "\n```" }, + { suggestion: "\n\n```\n\n# some heading", expected: "\n```\n\n# some heading" }, + { suggestion: "\r\n```\r\n\r\n# heading", expected: "\r\n```\r\n\r\n# heading" }, + { suggestion: "\r\n\r\n```\r\n\r\n# heading", expected: "\r\n```\r\n\r\n# heading" }, + ] + + for (const { suggestion, expected } of cases) { + const result = removeSpuriousNewlinesBeforeCodeBlockClosingFences({ suggestion, prefix, suffix: "" }) + expect(result).toBe(expected) + } + }) + + it("does not change suggestions that start with a newline but are not a closing fence", () => { + const prefix = "```ruby\nputs 'yo'" + const suggestion = "\nmore code here" + const result = removeSpuriousNewlinesBeforeCodeBlockClosingFences({ suggestion, prefix, suffix: "" }) + expect(result).toBe("\nmore code here") + }) + + it("does not run when not inside a code block", () => { + const prefix = "# Heading\n\nSome text" + const suggestion = "\n```ruby\nputs 'yo'\n```" + const result = removeSpuriousNewlinesBeforeCodeBlockClosingFences({ suggestion, prefix, suffix: "" }) + expect(result).toBe("\n```ruby\nputs 'yo'\n```") + }) + + it("handles empty suggestion", () => { + const prefix = "```ruby\nputs 'yo'" + const suggestion = "" + const result = removeSpuriousNewlinesBeforeCodeBlockClosingFences({ suggestion, prefix, suffix: "" }) + expect(result).toBe("") + }) +}) diff --git a/src/services/ghost/classic-auto-complete/language-filters/index.ts b/src/services/ghost/classic-auto-complete/language-filters/index.ts new file mode 100644 index 00000000000..d5cd5976a18 --- /dev/null +++ b/src/services/ghost/classic-auto-complete/language-filters/index.ts @@ -0,0 +1,16 @@ +import type { AutocompleteSuggestion } from "../uselessSuggestionFilter" +import { markdownFilter } from "./markdown" + +export type LanguageFilter = (params: AutocompleteSuggestion) => string + +const languageFilters: Record = { + markdown: markdownFilter, +} + +export function applyLanguageFilter(params: AutocompleteSuggestion & { languageId: string }): string { + const filter = languageFilters[params.languageId] + if (!filter) { + return params.suggestion + } + return filter(params) +} diff --git a/src/services/ghost/classic-auto-complete/language-filters/markdown.ts b/src/services/ghost/classic-auto-complete/language-filters/markdown.ts new file mode 100644 index 00000000000..63f43b66023 --- /dev/null +++ b/src/services/ghost/classic-auto-complete/language-filters/markdown.ts @@ -0,0 +1,75 @@ +import type { AutocompleteSuggestion } from "../uselessSuggestionFilter" + +/** + * Detects if the cursor is inside a markdown code block. + */ +export function isInsideCodeBlock(prefix: string): boolean { + // Match code fence patterns: ``` at the start of a line (optionally followed by language) + // We need to count opening fences and closing fences + const lines = prefix.split("\n") + let insideCodeBlock = false + + for (const line of lines) { + const trimmed = line.trim() + // A line that starts with ``` toggles the code block state + if (trimmed.startsWith("```")) { + insideCodeBlock = !insideCodeBlock + } + } + + return insideCodeBlock +} + +/** + * Removes spurious newlines before code block closing fences in suggestions. + * + * When inside a code block, LLMs often suggest closing the block with an extra + * newline before the closing ```. This filter removes that extra newline. + * + * Example problem: + * ```ruby + * puts 'yo' + * + * ``` + * + * # some heading + * + * + * The suggestion starts with "\n```" but should start with "```" to properly + * close the code block without adding an extra blank line. + * + * @param params - The filter parameters + * @returns The filtered suggestion + */ +export function removeSpuriousNewlinesBeforeCodeBlockClosingFences(params: AutocompleteSuggestion): string { + const { suggestion, prefix } = params + + // Only apply this filter when inside a code block + if (!isInsideCodeBlock(prefix)) { + return suggestion + } + + // If the suggestion is about to close the fence, treat leading newlines differently depending on whether the + // cursor is already at the start of a line (i.e. prefix ends with a newline). + const startsWithNewlinesThenFence = /^(?:\r?\n)+(?=```)/.test(suggestion) + if (!startsWithNewlinesThenFence) { + return suggestion + } + + const prefixEndsWithNewline = /(?:\r?\n)$/.test(prefix) + if (prefixEndsWithNewline) { + // Cursor is already at a fresh line -> remove all leading newlines before the closing fence. + return suggestion.replace(/^(?:\r?\n)+(?=```)/, "") + } + + // Cursor is at end of a line -> keep exactly ONE newline before the closing fence. + return suggestion.replace(/^(\r?\n)(?:\r?\n)+(?=```)/, "$1") +} + +type MarkdownSuggestionFilter = (params: AutocompleteSuggestion) => string + +const markdownFilters: MarkdownSuggestionFilter[] = [removeSpuriousNewlinesBeforeCodeBlockClosingFences] + +export function markdownFilter(params: AutocompleteSuggestion): string { + return markdownFilters.reduce((suggestion, filter) => filter({ ...params, suggestion }), params.suggestion) +} diff --git a/src/services/ghost/classic-auto-complete/uselessSuggestionFilter.ts b/src/services/ghost/classic-auto-complete/uselessSuggestionFilter.ts index 114b8d7a2b7..ba2f994042e 100644 --- a/src/services/ghost/classic-auto-complete/uselessSuggestionFilter.ts +++ b/src/services/ghost/classic-auto-complete/uselessSuggestionFilter.ts @@ -1,4 +1,5 @@ import { postprocessCompletion } from "../../continuedev/core/autocomplete/postprocessing/index.js" +import { applyLanguageFilter } from "./language-filters" export type AutocompleteSuggestion = { suggestion: string @@ -135,11 +136,13 @@ function normalizeToCompleteLine(params: AutocompleteSuggestion): AutocompleteSu * @param params.prefix - The text before the cursor position * @param params.suffix - The text after the cursor position * @param params.model - The model string (e.g., "codestral", "qwen3", etc.) + * @param params.languageId - Optional language ID for language-specific filtering * @returns The processed suggestion text, or undefined if it should be filtered out */ export function postprocessGhostSuggestion( params: AutocompleteSuggestion & { model: string + languageId?: string }, ): string | undefined { // First, run through the continuedev postprocessing pipeline @@ -154,9 +157,19 @@ export function postprocessGhostSuggestion( return undefined } + // Apply language-specific filtering if languageId is provided + const languageFilteredSuggestion = params.languageId + ? applyLanguageFilter({ + suggestion: processedSuggestion, + prefix: params.prefix, + suffix: params.suffix, + languageId: params.languageId, + }) + : processedSuggestion + if ( suggestionConsideredDuplication({ - suggestion: processedSuggestion, + suggestion: languageFilteredSuggestion, prefix: params.prefix, suffix: params.suffix, }) @@ -164,5 +177,5 @@ export function postprocessGhostSuggestion( return undefined } - return processedSuggestion + return languageFilteredSuggestion }