Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/markdown-autocomplete-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Minor improvement to markdown autocomplete suggestions
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -381,6 +382,7 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte
prefix,
suffix,
model: model.getModelName() || "",
languageId,
})

if (processedText) {
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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("")
})
})
16 changes: 16 additions & 0 deletions src/services/ghost/classic-auto-complete/language-filters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { AutocompleteSuggestion } from "../uselessSuggestionFilter"
import { markdownFilter } from "./markdown"

export type LanguageFilter = (params: AutocompleteSuggestion) => string

const languageFilters: Record<string, LanguageFilter> = {
markdown: markdownFilter,
}

export function applyLanguageFilter(params: AutocompleteSuggestion & { languageId: string }): string {
const filter = languageFilters[params.languageId]
if (!filter) {
return params.suggestion
}
return filter(params)
}
Original file line number Diff line number Diff line change
@@ -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'
* <suggestion>
* ```
*
* # some heading
* </suggestion>
*
* 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)
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { postprocessCompletion } from "../../continuedev/core/autocomplete/postprocessing/index.js"
import { applyLanguageFilter } from "./language-filters"

export type AutocompleteSuggestion = {
suggestion: string
Expand Down Expand Up @@ -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
Expand All @@ -154,15 +157,25 @@ 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,
})
) {
return undefined
}

return processedSuggestion
return languageFilteredSuggestion
}