fix(vscode): remove next edit deleted lines cleanly

This commit is contained in:
kiloconnect[bot]
2026-05-27 11:10:57 +00:00
parent 7834aeb2b3
commit c6d35f1987
5 changed files with 148 additions and 14 deletions
@@ -160,7 +160,8 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion
const diffStartLineInFile = suggestion.editableRegionStartLine + prefixLines
const diffEndLineInFile = suggestion.editableRegionStartLine + currentLines.length - 1 - suffixLines
const trimmedReplacement = proposedLines.slice(prefixLines, proposedLines.length - suffixLines).join("\n")
const trimmedLines = proposedLines.slice(prefixLines, proposedLines.length - suffixLines)
const trimmedReplacement = trimmedLines.join("\n")
nesLog(`diff at lines [${diffStartLineInFile}..${diffEndLineInFile}], cursor at line ${position.line}, ${trimmedReplacement.length} chars`)
@@ -168,8 +169,9 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion
// For off-cursor diffs, stash the suggestion in the manager — it renders a
// decoration-based "jump to next edit" affordance and Tab handles the move/apply.
const isPureInsertion = diffEndLineInFile < diffStartLineInFile
if (isPureInsertion || diffStartLineInFile !== position.line) {
this.stashOffCursorSuggestion(document, diffStartLineInFile, diffEndLineInFile, trimmedReplacement, isPureInsertion, suggestion)
const removesLines = trimmedLines.length === 0
if (isPureInsertion || removesLines || diffStartLineInFile !== position.line) {
this.stashOffCursorSuggestion(document, diffStartLineInFile, diffEndLineInFile, trimmedReplacement, isPureInsertion, removesLines, suggestion)
return undefined
}
// Same-line diff: clear any prior off-cursor pending state so we don't render
@@ -201,7 +203,7 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion
// Native ghost text cannot alter text before the cursor; present that edit
// through the decoration/apply flow rather than silently discarding it.
if (!cursorLineProposed.startsWith(cursorLineText.slice(0, position.character))) {
this.stashOffCursorSuggestion(document, diffStartLine, diffEndLine, trimmedReplacement, false, suggestion)
this.stashOffCursorSuggestion(document, diffStartLine, diffEndLine, trimmedReplacement, false, false, suggestion)
return undefined
}
const insertText = [cursorLineProposed.slice(position.character), ...proposedLines.slice(prefixLines + 1, proposedLines.length - suffixLines)].join("\n")
@@ -209,7 +211,7 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion
// A single-line insert spanning non-blank lines below the cursor can't be
// represented as inline ghost text — route it to the decoration path.
if (renderEndLine > position.line && !insertText.includes("\n")) {
this.stashOffCursorSuggestion(document, diffStartLine, diffEndLine, trimmedReplacement, false, suggestion)
this.stashOffCursorSuggestion(document, diffStartLine, diffEndLine, trimmedReplacement, false, false, suggestion)
return undefined
}
const renderRange = new vscode.Range(position, new vscode.Position(renderEndLine, document.lineAt(renderEndLine).range.end.character))
@@ -246,6 +248,7 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion
diffEndLine: number,
trimmedReplacement: string,
isPureInsertion: boolean,
removesLines: boolean,
suggestion: SuggestionResult,
): void {
const mgr = this.deps.suggestionManager
@@ -287,6 +290,7 @@ export class NextEditInlineCompletionProvider implements vscode.InlineCompletion
diffStartLine,
diffEndLine,
replacement: trimmedReplacement,
removesLines,
originalText: document.getText(originalRange),
})
nesLog(`replace suggestion stashed at lines [${diffStartLine}..${diffEndLine}]`)
@@ -1,6 +1,6 @@
import * as vscode from "vscode"
import { nesLog } from "./log"
import { planInsertion } from "./pendingEdit"
import { planInsertion, planReplacement } from "./pendingEdit"
const PENDING_CONTEXT_KEY = "kilo-code.nextEdit.hasPendingSuggestion"
const CHAIN_DELAY_MS = 60
@@ -15,6 +15,8 @@ export type PendingNextEdit =
diffEndLine: number
/** New text to substitute for [diffStartLine, diffEndLine]. */
replacement: string
/** Whether the suggestion omits complete lines rather than rewriting one as blank. */
removesLines: boolean
/** Snapshot of the original text — used to detect drift. */
originalText: string
}
@@ -210,7 +212,15 @@ export class NextEditSuggestionManager implements vscode.Disposable {
nesLog(`document drifted since suggestion was made — dropping range [${p.diffStartLine}..${p.diffEndLine}]`)
return
}
ok = await editor.edit((b) => b.replace(range, p.replacement))
const edit = planReplacement(p, {
lineCount: editor.document.lineCount,
end: (line) => editor.document.lineAt(line).range.end.character,
})
const target = new vscode.Range(
new vscode.Position(edit.start.line, edit.start.character),
new vscode.Position(edit.end.line, edit.end.character),
)
ok = await editor.edit((b) => b.replace(target, edit.text))
nesLog(`applied replace at lines [${p.diffStartLine}..${p.diffEndLine}] (ok=${ok})`)
}
if (ok) chainNextPrediction()
@@ -46,6 +46,18 @@ type Subject = {
): vscode.InlineCompletionItem[] | undefined
}
function doc(text: string): vscode.TextDocument {
const lines = text.split("\n")
return {
lineCount: lines.length,
lineAt: (line: number) => ({
text: lines[line],
range: { end: new vscode.Position(line, lines[line].length) },
}),
getText: () => text,
} as unknown as vscode.TextDocument
}
describe("NextEditInlineCompletionProvider", () => {
it("stashes same-line rewrites before the cursor for decorated acceptance", () => {
const mgr = { clear: vi.fn(), setPending: vi.fn() }
@@ -73,4 +85,46 @@ describe("NextEditInlineCompletionProvider", () => {
)
provider.dispose()
})
it("stashes complete-line deletion intent for acceptance", () => {
const mgr = { clear: vi.fn(), setPending: vi.fn() }
const provider = new NextEditInlineCompletionProvider({
connectionService: {} as KiloConnectionService,
suggestionManager: mgr as unknown as NextEditSuggestionManager,
})
const out = (provider as unknown as Subject).toCompletionItems(doc("before\nremove\nafter"), new vscode.Position(1, 0), {
replacement: "before\nafter",
editableRegionStartLine: 0,
editableRegionEndLine: 2,
latencyMs: 1,
})
expect(out).toBeUndefined()
expect(mgr.setPending).toHaveBeenCalledWith(
expect.objectContaining({ kind: "replace", replacement: "", removesLines: true }),
)
provider.dispose()
})
it("does not classify a blank-line rewrite as deletion", () => {
const mgr = { clear: vi.fn(), setPending: vi.fn() }
const provider = new NextEditInlineCompletionProvider({
connectionService: {} as KiloConnectionService,
suggestionManager: mgr as unknown as NextEditSuggestionManager,
})
const out = (provider as unknown as Subject).toCompletionItems(doc("before\nremove\nafter"), new vscode.Position(0, 0), {
replacement: "before\n\nafter",
editableRegionStartLine: 0,
editableRegionEndLine: 2,
latencyMs: 1,
})
expect(out).toBeUndefined()
expect(mgr.setPending).toHaveBeenCalledWith(
expect.objectContaining({ kind: "replace", replacement: "", removesLines: false }),
)
provider.dispose()
})
})
@@ -1,14 +1,19 @@
type Input = {
diffStartLine: number
replacement: string
}
type Document = {
lineCount: number
end(line: number): number
}
export function planInsertion(input: Input, document: Document) {
type Insertion = {
diffStartLine: number
replacement: string
}
type Replacement = Insertion & {
diffEndLine: number
removesLines: boolean
}
export function planInsertion(input: Insertion, document: Document) {
if (input.diffStartLine < document.lineCount) {
return { line: input.diffStartLine, character: 0, text: input.replacement }
}
@@ -16,3 +21,23 @@ export function planInsertion(input: Input, document: Document) {
const text = input.replacement.endsWith("\n") ? input.replacement.slice(0, -1) : input.replacement
return { line, character: document.end(line), text: `\n${text}` }
}
export function planReplacement(input: Replacement, document: Document) {
const end = { line: input.diffEndLine, character: document.end(input.diffEndLine) }
if (!input.removesLines) {
return { start: { line: input.diffStartLine, character: 0 }, end, text: input.replacement }
}
if (input.diffEndLine < document.lineCount - 1) {
return {
start: { line: input.diffStartLine, character: 0 },
end: { line: input.diffEndLine + 1, character: 0 },
text: input.replacement,
}
}
if (input.diffStartLine === 0) return { start: { line: 0, character: 0 }, end, text: input.replacement }
return {
start: { line: input.diffStartLine - 1, character: document.end(input.diffStartLine - 1) },
end,
text: input.replacement,
}
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test"
import { planInsertion } from "../../src/services/autocomplete/next-edit/pendingEdit"
import { planInsertion, planReplacement } from "../../src/services/autocomplete/next-edit/pendingEdit"
describe("planInsertion", () => {
it("appends after the final unterminated line at EOF", () => {
@@ -20,3 +20,44 @@ describe("planInsertion", () => {
expect(edit).toEqual({ line: 1, character: 0, text: "second\n" })
})
})
describe("planReplacement", () => {
it("removes a middle line through the following separator", () => {
const edit = planReplacement(
{ diffStartLine: 1, diffEndLine: 1, replacement: "", removesLines: true },
{ lineCount: 3, end: (line) => [6, 6, 5][line] },
)
expect(edit).toEqual({
start: { line: 1, character: 0 },
end: { line: 2, character: 0 },
text: "",
})
})
it("removes a final line through the preceding separator", () => {
const edit = planReplacement(
{ diffStartLine: 1, diffEndLine: 1, replacement: "", removesLines: true },
{ lineCount: 2, end: (line) => [6, 6][line] },
)
expect(edit).toEqual({
start: { line: 0, character: 6 },
end: { line: 1, character: 6 },
text: "",
})
})
it("preserves a line intentionally rewritten as blank", () => {
const edit = planReplacement(
{ diffStartLine: 1, diffEndLine: 1, replacement: "", removesLines: false },
{ lineCount: 3, end: (line) => [6, 6, 5][line] },
)
expect(edit).toEqual({
start: { line: 1, character: 0 },
end: { line: 1, character: 6 },
text: "",
})
})
})