From c6d35f1987947ca79f3fb17e758e2f8e85a2ca66 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 11:10:57 +0000 Subject: [PATCH] fix(vscode): remove next edit deleted lines cleanly --- .../NextEditInlineCompletionProvider.ts | 14 +++-- .../next-edit/NextEditSuggestionManager.ts | 14 ++++- .../NextEditInlineCompletionProvider.spec.ts | 54 +++++++++++++++++++ .../autocomplete/next-edit/pendingEdit.ts | 37 ++++++++++--- .../tests/unit/next-edit-pending-edit.test.ts | 43 ++++++++++++++- 5 files changed, 148 insertions(+), 14 deletions(-) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts index 0c04e28512..1e1695c1b4 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditInlineCompletionProvider.ts @@ -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}]`) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts index 8787abe51b..8be10b9348 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/NextEditSuggestionManager.ts @@ -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() diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts index 273f187f26..f33cf81939 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/__tests__/NextEditInlineCompletionProvider.spec.ts @@ -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() + }) }) diff --git a/packages/kilo-vscode/src/services/autocomplete/next-edit/pendingEdit.ts b/packages/kilo-vscode/src/services/autocomplete/next-edit/pendingEdit.ts index 164ca7b19b..bea2c9c1d8 100644 --- a/packages/kilo-vscode/src/services/autocomplete/next-edit/pendingEdit.ts +++ b/packages/kilo-vscode/src/services/autocomplete/next-edit/pendingEdit.ts @@ -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, + } +} diff --git a/packages/kilo-vscode/tests/unit/next-edit-pending-edit.test.ts b/packages/kilo-vscode/tests/unit/next-edit-pending-edit.test.ts index 713c30bc64..1f6fdb0c6a 100644 --- a/packages/kilo-vscode/tests/unit/next-edit-pending-edit.test.ts +++ b/packages/kilo-vscode/tests/unit/next-edit-pending-edit.test.ts @@ -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: "", + }) + }) +})