Merge pull request #10621 from Kilo-Org/mark/mercury-next-edit-integration-v2

Add Mercury Next Edit as an opt-in autocomplete mode
This commit is contained in:
Mark IJbema
2026-05-27 17:15:57 +02:00
committed by GitHub
64 changed files with 4238 additions and 118 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Add Mercury Next Edit as an opt-in autocomplete mode. Predicts multi-line edits beyond the cursor (including off-cursor and pure-insertion edits) and surfaces them with a Tab-to-jump / Tab-to-apply affordance. Select "Mercury Next Edit" under the autocomplete model setting to enable it (requires an Inception API key). Thanks [@tfiras](https://github.com/tfiras)!
+2
View File
@@ -47,6 +47,8 @@
<!-- packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts -->
- <https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services>
<!-- packages/opencode/src/cli/cmd/github.ts -->
- <https://docs.inceptionlabs.ai/capabilities/next-edit>
<!-- packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts -->
- <https://docs.mistral.ai/capabilities/reasoning/adjustable>
<!-- packages/opencode/src/provider/transform.ts -->
- <https://docs.venice.ai/overview/guides/reasoning-models#reasoning-effort>
+1
View File
@@ -0,0 +1 @@
.artifacts
+4 -1
View File
@@ -19,6 +19,8 @@
".": "./src/index.ts",
"./autocomplete": "./src/autocomplete.ts",
"./fim": "./src/fim.ts",
"./edit": "./src/edit.ts",
"./edit-prompt": "./src/edit-prompt.ts",
"./tui": "./src/tui.ts"
},
"files": [
@@ -26,7 +28,8 @@
],
"scripts": {
"typecheck": "tsgo --noEmit",
"build": "tsc"
"build": "tsc",
"test:ci": "mkdir -p .artifacts/unit && bun test test --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
},
"dependencies": {
"@kilocode/plugin": "workspace:*",
+24 -4
View File
@@ -4,7 +4,7 @@ export type DirectAutocompleteProviderID = Exclude<AutocompleteProviderID, "kilo
export interface AutocompleteModelDef {
/** Stable combined value for internal comparisons. */
readonly id: string
/** Model value stored in settings and sent to the FIM API. */
/** Model value stored in settings and sent to the autocomplete API. */
readonly modelID: string
/** Human-readable label shown in settings. */
readonly label: string
@@ -12,12 +12,18 @@ export interface AutocompleteModelDef {
readonly providerID: AutocompleteProviderID
/** Provider display name for status bar / telemetry. */
readonly provider: string
/** Full model ID sent upstream by the FIM route. */
/** Full model ID sent upstream by the autocomplete route. */
readonly requestModel: string
/** Provider key to use for direct BYOK FIM. Empty means Kilo Gateway. */
/** Provider key to use for direct BYOK. Empty means Kilo Gateway. */
readonly directProvider?: DirectAutocompleteProviderID
/** FIM request temperature. */
/** Request temperature. */
readonly temperature: number
/**
* Which gateway endpoint this model targets. Defaults to "fim" if omitted
* (back-compat with existing entries). Models with `kind: "edit"` route
* through `/kilo/edit` and use Mercury's Next Edit pipeline.
*/
readonly kind?: "fim" | "edit"
}
const models: AutocompleteModelDef[] = [
@@ -59,6 +65,20 @@ const models: AutocompleteModelDef[] = [
directProvider: "inception",
temperature: 0,
},
{
// Same wire-level model as `mercury-edit-2`, but routed through the
// Mercury Next Edit endpoint instead of FIM. Picked by users who want
// multi-line next-edit predictions with the jump-to-edit UX.
id: "inception/mercury-next-edit",
modelID: "mercury-next-edit",
label: "Mercury Next Edit",
providerID: "inception",
provider: "Inception",
requestModel: "mercury-edit-2",
directProvider: "inception",
temperature: 0,
kind: "edit",
},
]
export const AUTOCOMPLETE_MODELS: readonly AutocompleteModelDef[] = models
+107
View File
@@ -0,0 +1,107 @@
/**
* Mercury Next Edit prompt assembly. Lives in the gateway so every client
* (VS Code, JetBrains, TUI) sends the same structured editor context and the
* Mercury-specific sentinel format is defined in exactly one place.
*
* Tag set is defined by the model and must be reproduced verbatim — see
* https://docs.inceptionlabs.ai/capabilities/next-edit
*/
const RECENTLY_VIEWED_SNIPPETS_OPEN = "<|recently_viewed_code_snippets|>"
const RECENTLY_VIEWED_SNIPPETS_CLOSE = "<|/recently_viewed_code_snippets|>"
const RECENTLY_VIEWED_SNIPPET_OPEN = "<|recently_viewed_code_snippet|>"
const RECENTLY_VIEWED_SNIPPET_CLOSE = "<|/recently_viewed_code_snippet|>"
const CURRENT_FILE_CONTENT_OPEN = "<|current_file_content|>"
const CURRENT_FILE_CONTENT_CLOSE = "<|/current_file_content|>"
const CODE_TO_EDIT_OPEN = "<|code_to_edit|>"
const CODE_TO_EDIT_CLOSE = "<|/code_to_edit|>"
const EDIT_DIFF_HISTORY_OPEN = "<|edit_diff_history|>"
const EDIT_DIFF_HISTORY_CLOSE = "<|/edit_diff_history|>"
const CURSOR = "<|cursor|>"
/** Trailing token that tells the model this is a next-edit (not chat) request. */
const UNIQUE_TOKEN = "<|!@#IS_NEXT_EDIT!@#|>"
export interface MercuryRecentSnippet {
filepath: string
content: string
}
/** Editor-derived context a client sends; the gateway turns it into a prompt. */
export interface MercuryEditContext {
currentFilePath: string
currentFileContent: string
cursorLine: number
cursorCharacter: number
editableRegionStartLine: number
editableRegionEndLine: number
recentlyViewedSnippets: MercuryRecentSnippet[]
editDiffHistory: string[]
}
function insertCursorToken(lines: string[], cursorLine: number, cursorCharacter: number): string[] {
if (cursorLine < 0 || cursorLine >= lines.length) return lines
const line = lines[cursorLine]
const safeChar = Math.min(Math.max(cursorCharacter, 0), line.length)
const next = line.slice(0, safeChar) + CURSOR + line.slice(safeChar)
return [...lines.slice(0, cursorLine), next, ...lines.slice(cursorLine + 1)]
}
export function recentlyViewedSnippetsBlock(snippets: MercuryRecentSnippet[]): string {
const inner = snippets
.map((s) =>
[RECENTLY_VIEWED_SNIPPET_OPEN, `code_snippet_file_path: ${s.filepath}`, s.content, RECENTLY_VIEWED_SNIPPET_CLOSE].join("\n"),
)
.join("\n")
return [RECENTLY_VIEWED_SNIPPETS_OPEN, inner, RECENTLY_VIEWED_SNIPPETS_CLOSE].join("\n")
}
export function currentFileContentBlock(
currentFilePath: string,
currentFileContent: string,
editableRegionStartLine: number,
editableRegionEndLine: number,
cursorLine: number,
cursorCharacter: number,
): string {
const rawLines = currentFileContent.split("\n")
const withCursor = insertCursorToken(rawLines, cursorLine, cursorCharacter)
const start = Math.max(0, Math.min(editableRegionStartLine, withCursor.length))
const end = Math.max(start, Math.min(editableRegionEndLine, withCursor.length - 1))
const instrumented = [
...withCursor.slice(0, start),
CODE_TO_EDIT_OPEN,
...withCursor.slice(start, end + 1),
CODE_TO_EDIT_CLOSE,
...withCursor.slice(end + 1),
]
return [CURRENT_FILE_CONTENT_OPEN, `current_file_path: ${currentFilePath}`, instrumented.join("\n"), CURRENT_FILE_CONTENT_CLOSE].join("\n")
}
export function editDiffHistoryBlock(diffs: string[]): string {
// Each unidiff from `diff.createPatch` opens with an Index line + separator we
// strip. Diffs are blank-line separated so the model reads them as distinct hunks.
const trimmed = diffs.map((d) => {
const lines = d.split("\n")
return lines.length > 2 ? lines.slice(2).join("\n") : d
})
return [EDIT_DIFF_HISTORY_OPEN, trimmed.join("\n\n"), EDIT_DIFF_HISTORY_CLOSE].join("\n")
}
export function buildMercuryEditPrompt(ctx: MercuryEditContext): string {
return [
recentlyViewedSnippetsBlock(ctx.recentlyViewedSnippets),
"",
currentFileContentBlock(
ctx.currentFilePath,
ctx.currentFileContent,
ctx.editableRegionStartLine,
ctx.editableRegionEndLine,
ctx.cursorLine,
ctx.cursorCharacter,
),
"",
editDiffHistoryBlock(ctx.editDiffHistory),
"",
UNIQUE_TOKEN,
].join("\n")
}
+61
View File
@@ -0,0 +1,61 @@
import { getAutocompleteModel, type DirectAutocompleteProviderID } from "./autocomplete.js"
/**
* Env var(s) consulted as a fallback for BYOK keys when the provider hasn't
* been authenticated via the gateway's Auth store. Mirrors `DIRECT_FIM_ENV`.
*/
export const DIRECT_EDIT_ENV: Record<DirectAutocompleteProviderID, string[]> = {
mistral: ["MISTRAL_API_KEY"],
inception: ["INCEPTION_API_KEY"],
}
export type EditTarget =
| { provider: "inception"; model: string; url: string }
| { provider: "kilo"; model: string; url: string }
/** Shape of the upstream (Mercury) chat/edit completion response we read from. */
export interface EditUpstreamResponse {
choices?: Array<{ message?: { content?: string } }>
usage?: { prompt_tokens?: number; completion_tokens?: number }
}
const INCEPTION_EDIT_URL = "https://api.inceptionlabs.ai/v1/edit/completions"
/**
* Pick the upstream edit endpoint for a (provider, model) pair. Only Inception
* is wired up today — Mercury is the only model family with a documented
* /v1/edit/completions endpoint. Mistral does not expose a comparable surface.
*/
export function resolveEditTarget(provider?: string, model?: string): EditTarget {
const info = getAutocompleteModel(provider, model)
if (info.kind === "edit" && info.directProvider === "inception") {
return { provider: "inception", model: info.requestModel, url: INCEPTION_EDIT_URL }
}
// Kilo Gateway does not currently proxy an edit endpoint; callers should
// fall back to FIM. We still return a kilo target so the handler can surface
// a 400 rather than silently routing somewhere unexpected.
return { provider: "kilo", model: info.requestModel, url: "" }
}
/**
* Mercury wraps the rewritten editable region in a triple-backtick fence,
* sometimes with a language tag and sometimes with `<|code_to_edit|>` sentinels
* inside. Strip all of that down to the bare code. Shared by both the hono and
* the Effect HttpApi edit handlers so the parsing can't drift between them.
*/
export function extractFencedBody(message: string): string {
if (!message) return ""
const fenceOpen = message.indexOf("```")
if (fenceOpen === -1) return message
const afterFenceOpen = message.indexOf("\n", fenceOpen + 3)
if (afterFenceOpen === -1) return ""
// A missing closing fence means the replacement was truncated. Applying a
// partial editable region can delete valid trailing code, so suppress it.
const fenceClose = message.indexOf("```", afterFenceOpen + 1)
if (fenceClose === -1) return ""
let body = message.slice(afterFenceOpen + 1, fenceClose)
if (body.endsWith("\n")) body = body.slice(0, -1)
body = body.replace(/^<\|code_to_edit\|>\n?/, "")
body = body.replace(/\n?<\|\/code_to_edit\|>$/, "")
return body
}
+90
View File
@@ -0,0 +1,90 @@
import { DIRECT_EDIT_ENV, extractFencedBody, resolveEditTarget, type EditTarget, type EditUpstreamResponse } from "../edit.js"
import { buildMercuryEditPrompt, type MercuryEditContext } from "../edit-prompt.js"
import type { DirectAutocompleteProviderID } from "../autocomplete.js"
import type { AuthStore } from "./handlers.js"
type Auth = Pick<AuthStore, "get">
const EDIT_TIMEOUT_MS = 30_000
const MAX_TOKENS_DEFAULT = 512
async function getProviderKey(Auth: Auth, provider: DirectAutocompleteProviderID): Promise<string | undefined> {
const auth = await Auth.get(provider)
if (auth?.type === "api") return auth.key
return DIRECT_EDIT_ENV[provider].map((key) => process.env[key]).find(Boolean)
}
export function createEditHandler(Auth: Auth) {
return async (c: any) => {
const { provider, model, maxTokens, ...context } = c.req.valid("json")
const target = resolveEditTarget(provider, model)
if (target.provider !== "inception") {
return c.json({ error: "Next Edit currently requires the Inception provider (mercury-edit-2)." }, 400 as any)
}
const token = await getProviderKey(Auth, target.provider)
if (!token) {
return c.json({ error: `Missing ${target.provider} provider API key` }, 401 as any)
}
// Build the Mercury sentinel prompt here so every client only sends
// structured editor context.
const content = buildMercuryEditPrompt(context as MercuryEditContext)
const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(EDIT_TIMEOUT_MS)])
let response: Response
try {
response = await fetch(target.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
signal,
body: JSON.stringify({
model: target.model,
max_tokens: maxTokens ?? MAX_TOKENS_DEFAULT,
// Mercury rejects role:"system" on this endpoint — must be a single
// user message. See the integration's constants.ts for context.
messages: [{ role: "user", content }],
}),
})
} catch (err) {
if (err instanceof DOMException && err.name === "TimeoutError") {
return c.json({ error: "Edit request timed out" }, 504 as any)
}
if (signal.aborted) return c.json({ error: "Edit request canceled" }, 499 as any)
throw err
}
if (!response.ok) {
const text = await safeText(response)
return c.json({ error: `Edit request failed: ${response.status} ${text}` }, response.status as any)
}
const json = (await response.json()) as EditUpstreamResponse
const replyContent = json.choices?.[0]?.message?.content ?? ""
const body = extractFencedBody(replyContent)
return c.json({
content: body,
usage: json.usage
? {
prompt_tokens: json.usage.prompt_tokens,
completion_tokens: json.usage.completion_tokens,
}
: undefined,
})
}
}
async function safeText(res: Response): Promise<string> {
try {
return await res.text()
} catch {
return "<unreadable>"
}
}
// Re-export the target type for tests + the opencode handler
export type { EditTarget }
@@ -11,6 +11,7 @@ import { KILO_API_BASE, HEADER_FEATURE, HEADER_ORGANIZATIONID } from "../api/con
import { buildKiloHeaders } from "../headers.js"
import type { ImportDeps, DrizzleDb } from "../cloud-sessions.js"
import { fetchCloudSession, fetchCloudSessionForImport, importSessionToDb } from "../cloud-sessions.js"
import { createEditHandler } from "./edit.js"
import { createFimHandler } from "./fim.js"
import {
GatewayError,
@@ -112,6 +113,16 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
currentOrgId: z.string().nullable(),
})
const EditCompletionResponse = z.object({
content: z.string(),
usage: z
.object({
prompt_tokens: z.number().optional(),
completion_tokens: z.number().optional(),
})
.optional(),
})
const FimStreamChunk = z.object({
choices: z
.array(
@@ -325,6 +336,44 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
),
createFimHandler(Auth),
)
.post(
"/edit",
describeRoute({
summary: "Next Edit completion",
description:
"Proxy a Mercury-style Next Edit request. The client supplies structured editor " +
"context; the gateway assembles the sentinel-tagged prompt and forwards to the upstream edit endpoint.",
operationId: "kilo.edit",
responses: {
200: {
description: "Next Edit completion",
content: {
"application/json": {
schema: resolver(EditCompletionResponse),
},
},
},
...errors(400, 401),
},
}),
validator(
"json",
z.object({
provider: z.string().optional(),
model: z.string().optional(),
maxTokens: z.number().optional(),
currentFilePath: z.string(),
currentFileContent: z.string(),
cursorLine: z.number(),
cursorCharacter: z.number(),
editableRegionStartLine: z.number(),
editableRegionEndLine: z.number(),
recentlyViewedSnippets: z.array(z.object({ filepath: z.string(), content: z.string() })),
editDiffHistory: z.array(z.string()),
}),
),
createEditHandler(Auth),
)
.post(
"/audio/transcriptions",
describeRoute({
@@ -0,0 +1,92 @@
import { describe, expect, test } from "bun:test"
import {
buildMercuryEditPrompt,
currentFileContentBlock,
editDiffHistoryBlock,
recentlyViewedSnippetsBlock,
} from "../src/edit-prompt"
describe("recentlyViewedSnippetsBlock", () => {
test("wraps in open/close sentinels even when empty", () => {
const out = recentlyViewedSnippetsBlock([])
expect(out.startsWith("<|recently_viewed_code_snippets|>")).toBe(true)
expect(out.endsWith("<|/recently_viewed_code_snippets|>")).toBe(true)
})
test("emits one inner block per snippet with the file-path header", () => {
const out = recentlyViewedSnippetsBlock([
{ filepath: "src/a.ts", content: "const a = 1" },
{ filepath: "src/b.ts", content: "const b = 2" },
])
expect(out).toContain("code_snippet_file_path: src/a.ts")
expect(out).toContain("code_snippet_file_path: src/b.ts")
expect(out).toContain("const a = 1")
expect(out).toContain("const b = 2")
})
})
describe("currentFileContentBlock", () => {
test("inserts <|cursor|> at the right character and wraps the editable region", () => {
const file = ["function foo() {", " return 1", "}"].join("\n")
const out = currentFileContentBlock("src/foo.ts", file, 1, 1, 1, 2)
expect(out).toContain("<|current_file_content|>")
expect(out).toContain("<|/current_file_content|>")
expect(out).toContain("current_file_path: src/foo.ts")
expect(out).toContain(" <|cursor|>return 1")
const openIdx = out.indexOf("<|code_to_edit|>")
const lineIdx = out.indexOf("return 1")
const closeIdx = out.indexOf("<|/code_to_edit|>")
expect(openIdx).toBeGreaterThan(-1)
expect(closeIdx).toBeGreaterThan(openIdx)
expect(lineIdx).toBeGreaterThan(openIdx)
expect(lineIdx).toBeLessThan(closeIdx)
})
test("clamps an out-of-range cursor instead of throwing", () => {
const out = currentFileContentBlock("p.ts", "only-line", 0, 0, 0, 9999)
expect(out).toContain("only-line<|cursor|>")
})
})
describe("editDiffHistoryBlock", () => {
test("strips the createPatch index+separator lines from each diff", () => {
const fakeDiff = ["Index: foo.ts", "===", "@@ -1,1 +1,1 @@", "-old", "+new"].join("\n")
const out = editDiffHistoryBlock([fakeDiff])
expect(out).toContain("@@ -1,1 +1,1 @@")
expect(out).not.toContain("Index: foo.ts")
expect(out.startsWith("<|edit_diff_history|>")).toBe(true)
expect(out.endsWith("<|/edit_diff_history|>")).toBe(true)
})
test("separates multiple diffs with a blank line", () => {
const diff1 = ["Index: a.ts", "===", "@@ -1,1 +1,1 @@", "-a", "+aa"].join("\n")
const diff2 = ["Index: b.ts", "===", "@@ -2,1 +2,1 @@", "-b", "+bb"].join("\n")
const out = editDiffHistoryBlock([diff1, diff2])
const idx1 = out.indexOf("@@ -1,1 +1,1 @@")
const idx2 = out.indexOf("@@ -2,1 +2,1 @@")
expect(idx2).toBeGreaterThan(idx1)
expect(out.slice(idx1, idx2)).toContain("\n\n")
})
})
describe("buildMercuryEditPrompt", () => {
test("assembles the three blocks in order and ends with the NES token", () => {
const out = buildMercuryEditPrompt({
currentFilePath: "p.ts",
currentFileContent: "a\nb\nc",
cursorLine: 1,
cursorCharacter: 0,
editableRegionStartLine: 1,
editableRegionEndLine: 1,
recentlyViewedSnippets: [],
editDiffHistory: [],
})
const snippetsIdx = out.indexOf("<|recently_viewed_code_snippets|>")
const fileIdx = out.indexOf("<|current_file_content|>")
const diffIdx = out.indexOf("<|edit_diff_history|>")
expect(snippetsIdx).toBeGreaterThan(-1)
expect(fileIdx).toBeGreaterThan(snippetsIdx)
expect(diffIdx).toBeGreaterThan(fileIdx)
expect(out.endsWith("<|!@#IS_NEXT_EDIT!@#|>")).toBe(true)
})
})
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, test } from "bun:test"
import { extractFencedBody, resolveEditTarget } from "../src/edit"
describe("Edit target resolution", () => {
test("routes the Inception next-edit model to Inception's edit endpoint", () => {
expect(resolveEditTarget("inception", "mercury-next-edit")).toEqual({
provider: "inception",
model: "mercury-edit-2",
url: "https://api.inceptionlabs.ai/v1/edit/completions",
})
})
test("does NOT route the FIM Mercury model to the edit endpoint", () => {
// `mercury-edit-2` (kind: fim) must fall through to the kilo placeholder,
// not the edit endpoint — only `mercury-next-edit` (kind: edit) is NES.
expect(resolveEditTarget("inception", "mercury-edit-2").provider).toBe("kilo")
})
test("falls back to a kilo placeholder (no upstream) for non-edit models", () => {
expect(resolveEditTarget("kilo", "mistralai/codestral-2508")).toEqual({
provider: "kilo",
model: "mistralai/codestral-2508",
url: "",
})
expect(resolveEditTarget()).toMatchObject({ provider: "kilo", url: "" })
})
})
describe("extractFencedBody", () => {
test("extracts a plain triple-backtick fenced body", () => {
expect(extractFencedBody("```\nconst x = 1\n```")).toBe("const x = 1")
})
test("handles a language tag on the opening fence", () => {
expect(extractFencedBody("```typescript\nconst x = 1\n```")).toBe("const x = 1")
})
test("strips embedded <|code_to_edit|> sentinels", () => {
expect(extractFencedBody("```\n<|code_to_edit|>\nconst x = 2\n<|/code_to_edit|>\n```")).toBe("const x = 2")
})
test("returns the raw message when there is no fence", () => {
expect(extractFencedBody("just text, no fence")).toBe("just text, no fence")
})
test("returns the empty string for empty input", () => {
expect(extractFencedBody("")).toBe("")
})
test("suppresses a replacement when the closing fence is missing", () => {
expect(extractFencedBody("```\nconst x = 1\nconst y = ")).toBe("")
})
test("preserves internal blank lines and indentation", () => {
const body = "def f():\n if True:\n\n return 1"
expect(extractFencedBody("```python\n" + body + "\n```")).toBe(body)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
def factorial(n):
if n <= 1:
return 1
@@ -0,0 +1,3 @@
COLOR_RED = "#ff0000"
COLOR_GREEN = "#00ff00"
COLOR_BLUE =
@@ -0,0 +1,5 @@
def calculate_total(items):
total = 0
for item in items:
total += item.price
return tot
@@ -0,0 +1,5 @@
def calculate_total(items):
total = 0
for item in items:
return total
@@ -0,0 +1,12 @@
class Stack:
def __init__(self):
self.items = []
def push(self, item):
self.items.append(item)
def pop(self):
def peek(self):
return self.items[-1] if self.items else None
@@ -0,0 +1,12 @@
def compute_user_score(u, w):
base = u * 10
bonus = w * 5
penalty = u - w
return base + bonus - penalty
def compute_user_score(user_id, weight):
base = u * 10
bonus = w * 5
penalty = u - w
return base + bonus - penalty
@@ -0,0 +1,4 @@
def sum_prices(items):
total = 0
for item in items:
return total
@@ -0,0 +1,7 @@
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
result = fib
@@ -0,0 +1,21 @@
class Queue:
def __init__(self):
self.items = []
def enqueue(self, item):
self.items.append(item)
def peek(self):
return self.items[0] if self.items else None
def size(self):
return len(self.items)
def is_empty(self):
return not self.items
def dequeue(self):
def clear(self):
self.items.clear()
@@ -0,0 +1,10 @@
def multiply(a: int, b: int) -> int:
return a * b
def subtract(a: int, b: int) -> int:
return a - b
def add(a, b):
return a + b
@@ -0,0 +1,11 @@
import datetime
def parse_iso_datetime(s):
"""Parse an ISO 8601 datetime string into a datetime.datetime."""
return datetime.datetime.fromisoformat(s)
def parse_iso_date(s):
return datetime.date.fromisoformat(s)
@@ -0,0 +1,13 @@
def add(a: int, b: int) -> int:
"""Return the sum of two integers."""
return a + b
def multiply(a: int, b: int) -> int:
"""Return the product of two integers."""
return a * b
def subtract(a: int, b: int) -> int:
"""Return a minus b."""
return a - b
@@ -0,0 +1,201 @@
# NES Test Playground — Instructions
These tests are designed so that the source files contain **no hints** about what Mercury is supposed to predict. All cursor placements and expected behaviors live here. Don't open this file inside the Dev Host while testing — keep it in a separate window so the model can't see it.
## One-time setup
1. **`bun run watch`** is already running for the kilocode extension.
2. In the kilocode VSCode window, press **F5** → opens the **Extension Development Host**.
3. In the Dev Host: `File → Open Folder…``packages/kilo-vscode/docs/nes-examples/` inside this repo.
4. Open Settings (`Cmd+,`), confirm:
- `kilo-code.new.autocomplete.enableAutoTrigger` → ✓ (default true)
- `kilo-code.new.autocomplete.model`**Mercury Next Edit (Inception)***NOT* "Mercury Edit 2", which is the classic FIM option
- `kilo-code.new.autocomplete.nextEdit.apiKey` → your `sk_...` key
- VSCode global `editor.inlineSuggest.enabled` → ✓
5. To watch the pipeline live: `View → Output` → pick the **"Kilo Code · Next Edit"** channel.
## Conventions used below
- **Cursor placement**: where to click in the file before waiting.
- **Expected (editor)**: what should appear on screen.
- **Expected (channel)**: a stripped-down line you should see in the Next Edit output channel.
- **Path**: which NES rendering path this exercises — same-line ghost / off-cursor replace / off-cursor insert / suppressed.
After each test, **don't accept** if you want to re-run it — the suggestion will edit the file. Either `Cmd+Z` after accept, or just navigate to the next test file.
---
## Core tests (Python)
### 01 — Finish a function body *(path: same-line insert)*
- **File**: `01_finish_function_body.py`
- **Cursor**: the empty indented line at the end of `factorial` (column 4).
- **Expected editor**: ghost text proposing the recursive case.
- **Expected channel**: `diff at lines [N..N], cursor at line N`, then `RENDER`.
### 02 — Pattern continuation *(path: same-line ghost)*
- **File**: `02_pattern_continuation.py`
- **Cursor**: end of the last line (after `COLOR_BLUE = `).
- **Expected editor**: ghost text appending a hex color.
- **Expected channel**: `diff at lines [N..N], cursor at line N`, then `RENDER`.
### 03 — Mid-identifier completion *(path: same-line ghost)*
- **File**: `03_typo_completion.py`
- **Cursor**: end of the file (after `return tot`).
- **Expected editor**: ghost text completing the identifier.
- **Path**: same-line.
### 04 — Loop body inference *(path: same-line insert)*
- **File**: `04_loop_body.py`
- **Cursor**: the empty indented line inside the `for` loop (column 8).
- **Expected editor**: ghost text proposing the accumulator update.
### 05 — Sibling method body *(path: same-line insert)*
- **File**: `05_class_method.py`
- **Cursor**: empty indented line inside `pop` (column 8).
- **Expected editor**: ghost text proposing the pop body.
---
## Advanced Python tests
### 07 — Multi-line rename refactor *(path: off-cursor replace)*
- **File**: `07_multiline_rename_refactor.py`
- **Cursor**: end of the line with `def compute_user_score(user_id, weight):` (the renamed signature). The body below still uses the old `u` / `w` names.
- **Expected editor**: strikethrough on the body lines + ghost showing the renamed body.
- **Tab**: jump, then apply.
### 08 — Mixed insert + replace *(path: off-cursor replace, multi-line)*
- **File**: `08_mixed_insert_and_replace.py`
- **Cursor**: end of line `total = 0`.
- **Expected editor**: a decoration spanning the for-loop area showing the corrected accumulator body. The proposed text is longer than the original.
### 10 — Mid-token completion *(path: same-line ghost)*
- **File**: `10_mid_token_completion.py`
- **Cursor**: end of the file (after `result = fib`).
- **Expected editor**: ghost extending the identifier and supplying a call.
### 11 — Stub method with implemented siblings *(path: same-line insert)*
- **File**: `11_fill_sibling_method.py`
- **Cursor**: empty indented line inside `dequeue` (column 8).
- **Expected editor**: ghost text filling in the FIFO body.
### 12 — Type annotation insertion *(path: same-line replace OR off-cursor replace)*
- **File**: `12_type_annotation_insertion.py`
- **Cursor**: on line `def add(a, b):` (anywhere on that line works; end-of-line is easiest).
- **Expected editor**: strikethrough + ghost showing the typed signature. May render as inline ghost depending on where you place the cursor on the line.
### 13 — Docstring generation *(path: same-line insert)*
- **File**: `13_docstring_generation.py`
- **Cursor**: empty indented line directly under `def parse_iso_date(s):` (column 4).
- **Expected editor**: ghost text starting with `"""` and a one-line description.
### 14 — No-op suppression (NEGATIVE) *(path: suppressed)*
- **File**: `14_no_op_suppression.py`
- **Cursor**: end of `return a + b`.
- **Expected editor**: NOTHING. No ghost, no decoration.
- **Expected channel**: either `identical replacement — no-op` or no `RENDER` line.
- **Fail mode**: any visible suggestion is a false positive.
---
## TypeScript
### ts_07 — Array transform *(same-line)*
- **File**: `ts_07_array_transform.ts`
- **Cursor**: end of `return users` inside `getActiveUserNames`.
- **Expected editor**: ghost text completing a `.filter(...).map(...)` chain.
### ts_08 — Param type annotations *(off-cursor replace)*
- **File**: `ts_08_param_types.ts`
- **Cursor**: end of file (after `main();`).
- **Expected editor**: strikethrough on `add(a, b)` signature + ghost showing the typed version.
### ts_09 — React event handler *(same-line insert)*
- **File**: `ts_09_jsx_handler.tsx`
- **Cursor**: empty indented line inside `handleClick` (column 4).
- **Expected editor**: ghost text incrementing `count`.
---
## Go
### go_07 — Error handling *(same-line insert, multi-line)*
- **File**: `go_07_error_handling.go`
- **Cursor**: empty line right after `data, err := os.ReadFile(path)`.
- **Expected editor**: ghost text proposing the canonical `if err != nil { return nil, err }` block.
### go_08 — Struct method body *(same-line insert)*
- **File**: `go_08_struct_method.go`
- **Cursor**: empty indented line inside `Area()`.
- **Expected editor**: ghost text computing area from `Width` and `Height`.
### go_09 — Goroutine + channel *(same-line insert, multi-line)*
- **File**: `go_09_goroutine_channel.go`
- **Cursor**: empty indented line inside the goroutine.
- **Expected editor**: ghost text producing values onto the channel and closing it.
---
## Rust
### rs_07 — Match-arm completion *(same-line)*
- **File**: `rs_07_match_arms.rs`
- **Cursor**: empty indented line inside the `match s {` body, after the `Square` arm (column 8).
- **Expected editor**: ghost text proposing the missing `Rectangle` and `Triangle` arms.
### rs_08 — Result chaining *(same-line ghost)*
- **File**: `rs_08_result_chain.rs`
- **Cursor**: end of the line `let n = s.trim()` (no semicolon yet).
- **Expected editor**: ghost text continuing the chain into a parsed `i32`.
### rs_09 — Lifetime annotation *(off-cursor replace)*
- **File**: `rs_09_lifetimes.rs`
- **Cursor**: end of the file (after `main`'s closing `}`).
- **Expected editor**: strikethrough on the `fn longest(...)` signature + ghost showing the lifetime-annotated version.
---
## JavaScript
### js_07 — Async/await *(same-line insert, multi-line)*
- **File**: `js_07_async_await.js`
- **Cursor**: empty indented line inside the `try {` block (column 8).
- **Expected editor**: ghost text completing fetch + json parse.
### js_08 — Express route handler *(same-line insert, multi-line)*
- **File**: `js_08_express_route.js`
- **Cursor**: empty indented line inside the GET handler (column 4).
- **Expected editor**: ghost text implementing get-by-id (lookup, 404, json response).
---
## SQL
### sql_07 — JOIN clause *(same-line ghost)*
- **File**: `sql_07_join.sql`
- **Cursor**: end of the line `FROM orders o`.
- **Expected editor**: ghost text completing the JOIN against `customers`.
### sql_08 — WHERE filter *(same-line ghost)*
- **File**: `sql_08_where_filter.sql`
- **Cursor**: end of the bare `WHERE` line.
- **Expected editor**: ghost text proposing a predicate.
---
## Markdown (negative)
### md_07 — Prose, should stay quiet *(suppressed)*
- **File**: `md_07_prose_negative.md`
- **Cursor**: end of the last sentence.
- **Expected editor**: NOTHING (ideally). If Mercury does propose a continuation of the prose, note it as a soft fail — code models writing your README isn't the v0 product.
---
## Troubleshooting
- **No log lines appearing**: confirm the output channel is "Kilo Code · Next Edit". Also confirm you reloaded the Dev Host after rebuilding.
- **`[NES] skip — no API key resolved`**: setting wasn't saved. Re-paste the key, hit Enter, reload.
- **`[NES] <- 400`**: regression on prompt shape — capture the body in the channel and ping the integration owner.
- **Visible suggestion that's not in this doc**: write it down. Unexpected wins (or false positives) are the most useful signal.
@@ -0,0 +1,21 @@
package main
import (
"fmt"
"os"
)
func loadConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
return data, nil
}
func main() {
cfg, err := loadConfig("config.json")
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(string(cfg))
}
@@ -0,0 +1,22 @@
package main
import "fmt"
type Rectangle struct {
Width float64
Height float64
}
func (r Rectangle) Perimeter() float64 {
return 2 * (r.Width + r.Height)
}
func (r Rectangle) Area() float64 {
}
func main() {
r := Rectangle{Width: 3, Height: 4}
fmt.Println("perimeter:", r.Perimeter())
fmt.Println("area:", r.Area())
}
@@ -0,0 +1,15 @@
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
}()
for v := range ch {
fmt.Println("got:", v)
}
}
@@ -0,0 +1,14 @@
async function fetchUser(id) {
try {
} catch (err) {
console.error("fetchUser failed", err)
return null
}
}
async function main() {
const user = await fetchUser(42)
console.log("user:", user)
}
main()
@@ -0,0 +1,20 @@
const app = {
get: (_path, _handler) => app,
post: (_path, _handler) => app,
listen: (_port, cb) => cb && cb(),
}
const users = [
{ id: 1, name: "ada" },
{ id: 2, name: "lin" },
]
app.get("/users/:id", (req, res) => {})
app.post("/users", (req, res) => {
const user = { id: users.length + 1, name: req.body.name }
users.push(user)
res.status(201).json(user)
})
app.listen(3000, () => console.log("listening on :3000"))
@@ -0,0 +1,10 @@
# Mercury Edit 2 — Quick Notes
Mercury Edit 2 is a small, fast model trained to predict the user's
next single edit given the current file, cursor position, and recent
edit history. It targets latency under 200 ms on typical files and
returns a unified-diff-like patch scoped to a window around the cursor.
Unlike chat-style completions, the model is biased toward minimal,
local changes — finishing a function body, fixing a typo, propagating
a rename — rather than generating new files from scratch.
@@ -0,0 +1,25 @@
enum Shape {
Circle(f64),
Square(f64),
Rectangle(f64, f64),
Triangle(f64, f64),
}
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Square(side) => side * side,
}
}
fn main() {
let shapes = vec![
Shape::Circle(1.0),
Shape::Rectangle(2.0, 3.0),
Shape::Triangle(4.0, 5.0),
];
for s in &shapes {
println!("area = {}", area(s));
}
}
@@ -0,0 +1,14 @@
fn parse_int(s: &str) -> Option<i32> {
let n = s.trim()
Some(n * 2)
}
fn main() {
let inputs = [" 21 ", "not-a-number", "10"];
for s in &inputs {
match parse_int(s) {
Some(v) => println!("{} -> {}", s, v),
None => println!("{} -> skipped", s),
}
}
}
@@ -0,0 +1,14 @@
fn longest(a: &str, b: &str) -> &str {
if a.len() >= b.len() {
a
} else {
b
}
}
fn main() {
let s1 = String::from("hello world");
let s2 = String::from("hi");
let out = longest(&s1, &s2);
println!("longest = {}", out);
}
@@ -0,0 +1,9 @@
SELECT
c.name,
SUM(o.total) AS total_spent
FROM orders o
WHERE o.created_at >= '2026-01-01'
GROUP BY c.name
ORDER BY total_spent DESC
LIMIT 10;
@@ -0,0 +1,4 @@
SELECT id, email
FROM users
WHERE
ORDER BY last_login_at DESC;
@@ -0,0 +1,17 @@
interface User {
id: number
name: string
active: boolean
}
function getActiveUserNames(users: User[]): string[] {
return users
}
const sample: User[] = [
{ id: 1, name: "ada", active: true },
{ id: 2, name: "lin", active: false },
{ id: 3, name: "rin", active: true },
]
console.log(getActiveUserNames(sample))
@@ -0,0 +1,19 @@
function double(x: number): number {
return x * 2
}
function add(a, b) {
return a + b
}
function negate(x: number): number {
return -x
}
function main(): void {
console.log(double(3))
console.log(add(2, 4))
console.log(negate(7))
}
main()
@@ -0,0 +1,18 @@
declare const React: {
useState: <T>(initial: T) => [T, (next: T) => void]
}
function Counter(): JSX.Element {
const [count, setCount] = React.useState<number>(0)
function handleClick() {}
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment</button>
</div>
)
}
export default Counter
+24 -2
View File
@@ -194,6 +194,16 @@
"title": "Cancel Suggested Edits",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.autocomplete.nextEdit.acceptOrJump",
"title": "Next Edit: Accept or Jump to Suggested Edit",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.autocomplete.nextEdit.dismiss",
"title": "Next Edit: Dismiss Pending Suggestion",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.agentManager.previousSession",
"title": "Agent Manager: Previous Session",
@@ -756,6 +766,16 @@
"key": "ctrl+l",
"mac": "cmd+l",
"when": "editorTextFocus && !editorTabMovesFocus && !inSnippetMode && kilocode.autocomplete.enableSmartInlineTaskKeybinding && github.copilot.completions.enabled"
},
{
"command": "kilo-code.new.autocomplete.nextEdit.acceptOrJump",
"key": "tab",
"when": "editorTextFocus && !editorTabMovesFocus && !inSnippetMode && !suggestWidgetVisible && kilo-code.nextEdit.hasPendingSuggestion"
},
{
"command": "kilo-code.new.autocomplete.nextEdit.dismiss",
"key": "escape",
"when": "editorTextFocus && !editorTabMovesFocus && !inSnippetMode && kilo-code.nextEdit.hasPendingSuggestion"
}
],
"configuration": {
@@ -826,13 +846,15 @@
"mistralai/codestral-2508",
"inception/mercury-edit-2",
"codestral-2508",
"mercury-edit-2"
"mercury-edit-2",
"mercury-next-edit"
],
"enumDescriptions": [
"Codestral via Kilo Gateway (default)",
"Mercury Edit 2 via Kilo Gateway",
"Codestral via your connected Mistral provider API key",
"Mercury Edit 2 via your connected Inception provider API key"
"Mercury Edit 2 (FIM) via your connected Inception provider API key",
"Mercury Next Edit (multi-line edit predictions with jump-to-edit UX) via your connected Inception provider API key"
],
"description": "Model to use for inline autocomplete suggestions"
},
@@ -6,6 +6,10 @@ import { AutocompleteStatusBar } from "./AutocompleteStatusBar"
import { AutocompleteCodeActionProvider } from "./AutocompleteCodeActionProvider"
import { AutocompleteInlineCompletionProvider } from "./classic-auto-complete/AutocompleteInlineCompletionProvider"
import { AutocompleteTelemetry } from "./classic-auto-complete/AutocompleteTelemetry"
import { NextEditInlineCompletionProvider } from "./next-edit/NextEditInlineCompletionProvider"
import { disposeLog } from "./next-edit/log"
import { NextEditSuggestionManager } from "./next-edit/NextEditSuggestionManager"
import { toAllowedMercuryRecentSnippets } from "./next-edit/recentSnippetsAdapter"
import type { KiloConnectionService } from "../cli-backend"
import { hasValidCredentials } from "./fim"
import { DEFAULT_AUTOCOMPLETE_MODEL, getAutocompleteModel } from "../../shared/autocomplete-models"
@@ -61,9 +65,15 @@ export class AutocompleteServiceManager {
// VSCode Providers
public readonly codeActionProvider: AutocompleteCodeActionProvider
public readonly inlineCompletionProvider: AutocompleteInlineCompletionProvider
public readonly nextEditProvider: NextEditInlineCompletionProvider
public readonly nextEditSuggestionManager: NextEditSuggestionManager
private inlineCompletionProviderDisposable: vscode.Disposable | null = null
private inlineCompletionProviderKind: "classic" | "next-edit" | null = null
private unsubscribeState: (() => void) | null = null
private unsubscribeEvent: (() => void) | null = null
// Resolved copy of the classic provider's ignore controller for synchronous
// snippet filtering. Null until the async initialize() resolves.
private ignoreControllerSync: { validateAccess(fsPath: string): boolean } | null = null
constructor(context: vscode.ExtensionContext, connectionService: KiloConnectionService) {
if (AutocompleteServiceManager._instance) {
@@ -90,6 +100,48 @@ export class AutocompleteServiceManager {
new AutocompleteTelemetry(),
(status) => this.handleFatalAutocompleteError(status),
)
// Cache the resolved ignore controller for synchronous snippet filtering.
void this.inlineCompletionProvider.ignoreController.then((ic) => {
this.ignoreControllerSync = ic
})
this.nextEditSuggestionManager = new NextEditSuggestionManager()
this.nextEditProvider = new NextEditInlineCompletionProvider({
connectionService,
suggestionManager: this.nextEditSuggestionManager,
isFileAllowed: async (fsPath) => {
const ignore = await this.inlineCompletionProvider.ignoreController
return ignore.validateAccess(fsPath)
},
getRecentlyViewedSnippets: () => {
// Reuse the LRU populated by the classic provider — keeps a single
// RecentlyVisitedRangesService instance instead of double-tracking.
// Suppress snippets until access checks are available, then include
// only content explicitly approved by the ignore controller.
const raw = this.inlineCompletionProvider.recentlyVisitedRangesService.getSnippets()
const ignore = this.ignoreControllerSync
if (!ignore) return []
return toAllowedMercuryRecentSnippets(raw, (path) => ignore.validateAccess(path))
},
onFatalError: (status) => this.handleFatalAutocompleteError(status),
onSuggestion: (event) => {
const eventName =
event.status === "error"
? TelemetryEventName.AUTOCOMPLETE_LLM_REQUEST_FAILED
: event.shown
? TelemetryEventName.AUTOCOMPLETE_LLM_SUGGESTION_RETURNED
: TelemetryEventName.AUTOCOMPLETE_LLM_REQUEST_COMPLETED
TelemetryProxy.capture(eventName, {
mode: "next-edit",
model: getAutocompleteModel(this.settings?.provider, this.settings?.model).id,
latencyMs: event.latencyMs,
inputTokens: event.inputTokens,
outputTokens: event.outputTokens,
shown: event.shown,
errorStatus: event.errorStatus,
})
},
})
// Reload when CLI backend connection state changes so autocomplete
// picks up the connected state even if it wasn't ready at startup.
@@ -136,25 +188,47 @@ export class AutocompleteServiceManager {
*/
private async ensureInlineCompletionProviderRegistration() {
const shouldBeRegistered = (this.settings?.enableAutoTrigger ?? false) && !this.isSnoozed()
const isRegistered = this.inlineCompletionProviderDisposable !== null
const info = getAutocompleteModel(this.settings?.provider, this.settings?.model)
const desiredKind: "classic" | "next-edit" = info.kind === "edit" ? "next-edit" : "classic"
// Already in the correct state — nothing to do
if (shouldBeRegistered === isRegistered) {
return
// Mode change while still enabled requires a swap: tear down the old
// registration so the new provider takes over.
if (
shouldBeRegistered &&
this.inlineCompletionProviderKind !== null &&
this.inlineCompletionProviderKind !== desiredKind
) {
if (this.inlineCompletionProviderKind === "next-edit") this.nextEditSuggestionManager.clear()
this.inlineCompletionProviderDisposable?.dispose()
this.inlineCompletionProviderDisposable = null
this.inlineCompletionProviderKind = null
}
if (!shouldBeRegistered && this.inlineCompletionProviderKind === "next-edit") {
this.nextEditSuggestionManager.clear()
}
const isRegistered = this.inlineCompletionProviderDisposable !== null
if (shouldBeRegistered === isRegistered) return
if (!shouldBeRegistered) {
this.inlineCompletionProviderDisposable!.dispose()
this.inlineCompletionProviderDisposable = null
this.inlineCompletionProviderKind = null
return
}
// Register classic provider (tracked via this.inlineCompletionProviderDisposable,
// not context.subscriptions, so re-registration on reconnect doesn't leak)
const provider: vscode.InlineCompletionItemProvider =
desiredKind === "next-edit" ? this.nextEditProvider : this.inlineCompletionProvider
this.inlineCompletionProviderDisposable = vscode.languages.registerInlineCompletionItemProvider(
{ scheme: "file" },
this.inlineCompletionProvider,
provider,
)
this.inlineCompletionProviderKind = desiredKind
}
/** Which provider is currently registered (`null` if none). */
public get currentMode(): "classic" | "next-edit" | null {
return this.inlineCompletionProviderKind
}
public async disable() {
@@ -410,10 +484,17 @@ export class AutocompleteServiceManager {
if (this.inlineCompletionProviderDisposable) {
this.inlineCompletionProviderDisposable.dispose()
this.inlineCompletionProviderDisposable = null
this.inlineCompletionProviderKind = null
}
// Dispose inline completion provider resources
this.inlineCompletionProvider.dispose()
this.nextEditProvider.dispose()
this.nextEditSuggestionManager.dispose()
// Drop the dedicated Next Edit OutputChannel so it doesn't leak across
// extension reloads.
disposeLog()
// Clear singleton instance
AutocompleteServiceManager._instance = null
@@ -111,13 +111,13 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
private connectionService: KiloConnectionService
private costTrackingCallback: CostTrackingCallback
private getSettings: () => AutocompleteServiceSettings | null
private recentlyVisitedRangesService: RecentlyVisitedRangesService
public readonly recentlyVisitedRangesService: RecentlyVisitedRangesService
private recentlyEditedTracker: RecentlyEditedTracker
private debounceTimer: NodeJS.Timeout | null = null
/** The pending request associated with the current debounce timer (if any) */
private debouncedPendingRequest: PendingRequest | null = null
private isFirstCall: boolean = true
private ignoreController: Promise<FileIgnoreController>
public readonly ignoreController: Promise<FileIgnoreController>
/** Abort controller for the current in-flight FIM request */
private fimAbortController: AbortController | null = null
private acceptedCommand: vscode.Disposable | null = null
@@ -1,6 +1,9 @@
import * as vscode from "vscode"
import { AutocompleteServiceManager } from "./AutocompleteServiceManager"
import { ensureBackendForAutocomplete } from "./ensure-backend"
import { nesLog } from "./next-edit/log"
import { INLINE_COMPLETION_ACCEPTED_COMMAND as NEXT_EDIT_ACCEPTED_COMMAND } from "./next-edit/NextEditInlineCompletionProvider"
import { chainNextPrediction } from "./next-edit/NextEditSuggestionManager"
import type { KiloConnectionService } from "../cli-backend"
export const registerAutocompleteProvider = (
@@ -42,6 +45,27 @@ export const registerAutocompleteProvider = (
await autocompleteManager.disable()
}),
)
// Fired by VSCode when the user accepts a Next Edit same-line ghost. Chains
// the next prediction so users can walk a refactor with repeated Tabs.
context.subscriptions.push(
vscode.commands.registerCommand(NEXT_EDIT_ACCEPTED_COMMAND, () => {
nesLog("suggestion accepted")
if (autocompleteManager.currentMode === "next-edit") chainNextPrediction()
}),
)
// Tab handler for off-cursor pending suggestions: first press teleports the
// cursor to the predicted edit, second press applies.
context.subscriptions.push(
vscode.commands.registerCommand("kilo-code.new.autocomplete.nextEdit.acceptOrJump", async () => {
await autocompleteManager.nextEditSuggestionManager.acceptOrJump()
}),
)
// Esc handler: dismiss the pending suggestion without applying.
context.subscriptions.push(
vscode.commands.registerCommand("kilo-code.new.autocomplete.nextEdit.dismiss", () => {
autocompleteManager.nextEditSuggestionManager.clear()
}),
)
// Register AutocompleteServiceManager Code Actions
context.subscriptions.push(
@@ -0,0 +1,105 @@
import type { KiloConnectionService } from "../../cli-backend"
import { nesLog, nesWarn } from "./log"
import type { MercuryEditRequestContext, MercuryEditSuggestion } from "./types"
const MERCURY_MAX_TOKENS = 512
const PROVIDER_ID = "inception"
const MODEL_ID = "mercury-next-edit"
type EditResponseData = { content?: string; usage?: { prompt_tokens?: number; completion_tokens?: number } }
export interface MercuryEditProviderOptions {
connectionService: KiloConnectionService
/** AbortSignal for cancellation (cursor moves, escape, etc.). */
signal?: AbortSignal
}
/**
* Thin wrapper around the SDK's `client.kilo.edit(...)` endpoint (non-streaming).
* The gateway (`packages/kilo-gateway/src/server/edit.ts`) handles auth, routing
* to Mercury's `/v1/edit/completions`, and unwrapping the triple-backtick fence —
* so the VSCode side only deals in already-parsed code.
*/
export class MercuryEditProvider {
constructor(private readonly options: MercuryEditProviderOptions) {}
async suggest(ctx: MercuryEditRequestContext): Promise<MercuryEditSuggestion | null> {
const start = Date.now()
nesLog(
`-> /kilo/edit model=${MODEL_ID} region=[${ctx.editableRegionStartLine},${ctx.editableRegionEndLine}] diffs=${ctx.editDiffHistory.length} snippets=${ctx.recentlyViewedSnippets.length}`,
)
const client = await this.options.connectionService.getClientAsync()
try {
// Send structured editor context; the gateway assembles the Mercury prompt.
const { data, error, response } = await client.kilo.edit(
{
provider: PROVIDER_ID,
model: MODEL_ID,
maxTokens: MERCURY_MAX_TOKENS,
currentFilePath: ctx.currentFilePath,
currentFileContent: ctx.currentFileContent,
cursorLine: ctx.cursorLine,
cursorCharacter: ctx.cursorCharacter,
editableRegionStartLine: ctx.editableRegionStartLine,
editableRegionEndLine: ctx.editableRegionEndLine,
recentlyViewedSnippets: ctx.recentlyViewedSnippets,
editDiffHistory: ctx.editDiffHistory,
},
{ signal: this.options.signal, throwOnError: false },
)
const latencyMs = Date.now() - start
if (error) {
// HTTP status lives on the Response object, not the parsed error body.
const status = typeof response?.status === "number" ? response.status : null
nesWarn(`<- error ${status ?? "?"} (${latencyMs}ms): ${safeStringify(error)}`)
throw new MercuryEditError(`Edit request failed: ${status ?? "?"} ${safeStringify(error)}`, status)
}
return this.parseSuccess(ctx, data, latencyMs)
} catch (err) {
if ((err as Error)?.name === "AbortError") throw err
if (err instanceof MercuryEditError) throw err
const msg = err instanceof Error ? err.message : String(err)
nesWarn(`<- transport error: ${msg}`)
throw new MercuryEditError(`Edit request failed: ${msg}`, null)
}
}
private parseSuccess(
ctx: MercuryEditRequestContext,
data: EditResponseData | undefined,
latencyMs: number,
): MercuryEditSuggestion | null {
const replacement = data?.content ?? null
const usage = data?.usage
nesLog(`<- ok (${latencyMs}ms) tokens=${usage?.completion_tokens ?? "?"} parsedChars=${replacement?.length ?? 0}`)
if (replacement === null || replacement.length === 0) return null
return {
replacement,
editableRegionStartLine: ctx.editableRegionStartLine,
editableRegionEndLine: ctx.editableRegionEndLine,
latencyMs,
inputTokens: usage?.prompt_tokens,
outputTokens: usage?.completion_tokens,
}
}
}
export class MercuryEditError extends Error {
constructor(
message: string,
public readonly status: number | null,
) {
super(message)
this.name = "MercuryEditError"
}
}
function safeStringify(value: unknown): string {
try {
if (typeof value === "string") return value
return JSON.stringify(value)
} catch {
return String(value)
}
}
@@ -0,0 +1,392 @@
import * as vscode from "vscode"
import type { KiloConnectionService } from "../../cli-backend"
import { computeEditableRegion } from "./editableRegion"
import { EditHistoryTracker } from "./editHistoryTracker"
import { nesLog } from "./log"
import { MercuryEditError, MercuryEditProvider } from "./MercuryEditProvider"
import type { NextEditSuggestionManager } from "./NextEditSuggestionManager"
import type { MercuryEditRequestContext, MercuryRecentSnippet } from "./types"
const INLINE_COMPLETION_ACCEPTED_COMMAND = "kilo-code.new.autocomplete.nextEdit.accepted"
const DEFAULT_DEBOUNCE_MS = 250
export interface NextEditProviderDeps {
/** Routes Mercury calls through the local Kilo gateway (handles auth + BYOK). */
connectionService: KiloConnectionService
/** Optional source of recently-viewed snippets (kilocode's VisibleCodeTracker can adapt to this). */
getRecentlyViewedSnippets?: (document: vscode.TextDocument) => MercuryRecentSnippet[]
/** Returns false for files that must not be sent to a server (.env etc). */
isFileAllowed: (fsPath: string) => Promise<boolean>
/** Telemetry hook fired on every suggestion result. */
onSuggestion?: (event: NextEditSuggestionEvent) => void
onFatalError?: (status: number | null) => void
/** Stash for diffs that don't land on the cursor's line — rendered as a jump affordance. */
suggestionManager?: NextEditSuggestionManager
}
export interface NextEditSuggestionEvent {
shown: boolean
latencyMs: number
status: "ok" | "no-replacement" | "error"
errorStatus?: number
inputTokens?: number
outputTokens?: number
}
/** A parsed Mercury suggestion plus the editable region it targets. */
type SuggestionResult = {
replacement: string
editableRegionStartLine: number
editableRegionEndLine: number
latencyMs: number
inputTokens?: number
outputTokens?: number
}
export class NextEditInlineCompletionProvider implements vscode.InlineCompletionItemProvider, vscode.Disposable {
private readonly editHistoryTracker: EditHistoryTracker
private debounceTimer: NodeJS.Timeout | null = null
private currentAbort: AbortController | null = null
constructor(private readonly deps: NextEditProviderDeps) {
this.editHistoryTracker = new EditHistoryTracker({ isFileAllowed: deps.isFileAllowed })
}
dispose(): void {
this.editHistoryTracker.dispose()
if (this.debounceTimer) clearTimeout(this.debounceTimer)
this.currentAbort?.abort()
}
async provideInlineCompletionItems(
document: vscode.TextDocument,
position: vscode.Position,
context: vscode.InlineCompletionContext,
token: vscode.CancellationToken,
): Promise<vscode.InlineCompletionItem[] | vscode.InlineCompletionList | undefined> {
if (document.uri.scheme !== "file") return undefined
if (this.deps.suggestionManager?.isPending()) return undefined
// Never send a file unless the access policy explicitly approves it.
if (!(await this.allowed(document.uri.fsPath))) return undefined
const isExplicit = context.triggerKind === vscode.InlineCompletionTriggerKind.Invoke
if (!isExplicit) {
await this.debounce(DEFAULT_DEBOUNCE_MS, token)
if (token.isCancellationRequested) return undefined
}
const abort = this.swapAbortController(token)
const ctx = await this.buildRequestContext(document, position)
const provider = new MercuryEditProvider({
connectionService: this.deps.connectionService,
signal: abort.signal,
})
try {
const suggestion = await provider.suggest(ctx)
if (!suggestion || token.isCancellationRequested) {
this.deps.onSuggestion?.({ shown: false, latencyMs: 0, status: "no-replacement" })
return undefined
}
return this.toCompletionItems(document, position, suggestion)
} catch (err) {
return this.handleError(err)
}
}
private async allowed(path: string): Promise<boolean> {
const allow = this.deps.isFileAllowed
if (!allow) return false
return allow(path).catch(() => false)
}
private swapAbortController(token: vscode.CancellationToken): AbortController {
this.currentAbort?.abort()
const abort = new AbortController()
this.currentAbort = abort
token.onCancellationRequested(() => abort.abort())
return abort
}
private async buildRequestContext(
document: vscode.TextDocument,
position: vscode.Position,
): Promise<MercuryEditRequestContext> {
const { startLine, endLine } = computeEditableRegion({
cursorLine: position.line,
totalLines: document.lineCount,
})
await this.editHistoryTracker.flush(document)
return {
// Mirror classic autocomplete's policy: never send an absolute fsPath upstream.
// Mercury only needs the path for language/context hints, and the workspace-relative
// form is what `recentlyViewedSnippets` already uses (see recentSnippetsAdapter.ts).
currentFilePath: vscode.workspace.asRelativePath(document.uri, false),
currentFileContent: document.getText(),
cursorLine: position.line,
cursorCharacter: position.character,
editableRegionStartLine: startLine,
editableRegionEndLine: endLine,
recentlyViewedSnippets: this.deps.getRecentlyViewedSnippets?.(document) ?? [],
editDiffHistory: await this.editHistoryTracker.getRecentDiffs(),
}
}
private toCompletionItems(
document: vscode.TextDocument,
position: vscode.Position,
suggestion: SuggestionResult,
): vscode.InlineCompletionItem[] | undefined {
const endLine = Math.min(suggestion.editableRegionEndLine, document.lineCount - 1)
const fullRange = new vscode.Range(
new vscode.Position(suggestion.editableRegionStartLine, 0),
document.lineAt(endLine).range.end,
)
const currentText = document.getText(fullRange)
if (currentText === suggestion.replacement) {
this.emitNotShown(suggestion)
return undefined
}
// Trim to minimal diff: skip identical leading and trailing lines.
const currentLines = currentText.split("\n")
const proposedLines = suggestion.replacement.split("\n")
let prefixLines = 0
while (
prefixLines < currentLines.length &&
prefixLines < proposedLines.length &&
currentLines[prefixLines] === proposedLines[prefixLines]
)
prefixLines++
let suffixLines = 0
while (
suffixLines < currentLines.length - prefixLines &&
suffixLines < proposedLines.length - prefixLines &&
currentLines[currentLines.length - 1 - suffixLines] === proposedLines[proposedLines.length - 1 - suffixLines]
)
suffixLines++
const diffStartLineInFile = suggestion.editableRegionStartLine + prefixLines
const diffEndLineInFile = suggestion.editableRegionStartLine + currentLines.length - 1 - suffixLines
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`,
)
// VSCode's inline ghost text only renders when the diff starts on the cursor's line.
// 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
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
// two competing affordances.
this.deps.suggestionManager?.clear()
return this.renderSameLineItem(
document,
position,
proposedLines,
prefixLines,
suffixLines,
diffStartLineInFile,
diffEndLineInFile,
trimmedReplacement,
suggestion,
)
}
/** Build the cursor-position ghost-text item for a same-line diff. */
private renderSameLineItem(
document: vscode.TextDocument,
position: vscode.Position,
proposedLines: string[],
prefixLines: number,
suffixLines: number,
diffStartLine: number,
diffEndLine: number,
trimmedReplacement: string,
suggestion: SuggestionResult,
): vscode.InlineCompletionItem[] | undefined {
const cursorLineText = document.lineAt(position.line).text
const cursorLineCurrent = cursorLineText.slice(position.character)
const cursorLineProposed = proposedLines[prefixLines]
// A pure deletion at the trim seam has no cursor-line replacement to render.
if (cursorLineProposed === undefined) {
this.emitNotShown(suggestion)
return undefined
}
// 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, false, suggestion)
return undefined
}
const insertText = [
cursorLineProposed.slice(position.character),
...proposedLines.slice(prefixLines + 1, proposedLines.length - suffixLines),
].join("\n")
const renderEndLine = pickRenderEndLine(document, position.line, diffEndLine, insertText)
// 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, false, suggestion)
return undefined
}
const renderRange = new vscode.Range(
position,
new vscode.Position(renderEndLine, document.lineAt(renderEndLine).range.end.character),
)
if (document.getText(renderRange) === cursorLineCurrent && cursorLineCurrent === insertText) return undefined
const item = new vscode.InlineCompletionItem(insertText, renderRange, {
command: INLINE_COMPLETION_ACCEPTED_COMMAND,
title: "Next Edit Accepted",
})
nesLog(
`RENDER range=[${renderRange.start.line}:${renderRange.start.character}..${renderRange.end.line}:${renderRange.end.character}] insertChars=${insertText.length}`,
)
this.deps.onSuggestion?.({
shown: true,
latencyMs: suggestion.latencyMs,
status: "ok",
inputTokens: suggestion.inputTokens,
outputTokens: suggestion.outputTokens,
})
return [item]
}
private emitNotShown(suggestion: SuggestionResult): void {
this.deps.onSuggestion?.({
shown: false,
latencyMs: suggestion.latencyMs,
status: "no-replacement",
inputTokens: suggestion.inputTokens,
outputTokens: suggestion.outputTokens,
})
}
private stashOffCursorSuggestion(
document: vscode.TextDocument,
diffStartLine: number,
diffEndLine: number,
trimmedReplacement: string,
isPureInsertion: boolean,
removesLines: boolean,
suggestion: SuggestionResult,
): void {
const mgr = this.deps.suggestionManager
if (!mgr) {
// Manager wasn't wired — fall through silently. The classic path
// already covers same-line completions; this branch only matters in
// tests or misconfigured embeds.
this.emitNotShown(suggestion)
return
}
if (isPureInsertion) {
// The original text we snapshot must come from the line VSCode will see
// when the user later accepts. For mid-file inserts that's `diffStartLine`
// (the line that gets pushed down). For EOF inserts (diffStartLine ===
// lineCount) there is no such line; fall back to lineCount-1 (the last
// line, which will sit just above the inserted content). The
// SuggestionManager's drift guard knows to compare against this anchor.
const isEof = diffStartLine >= document.lineCount
const anchorLine = isEof
? Math.max(0, document.lineCount - 1)
: Math.max(0, Math.min(diffStartLine, document.lineCount - 1))
mgr.setPending({
kind: "insert",
document,
diffStartLine,
diffEndLine: diffStartLine,
replacement: trimmedReplacement + "\n",
originalText: document.lineAt(anchorLine).text,
})
nesLog(
`insert suggestion stashed at line ${diffStartLine} (anchor=${anchorLine}, eof=${isEof}, ${trimmedReplacement.length} chars)`,
)
} else {
const originalRange = new vscode.Range(
new vscode.Position(diffStartLine, 0),
new vscode.Position(diffEndLine, document.lineAt(diffEndLine).range.end.character),
)
mgr.setPending({
kind: "replace",
document,
diffStartLine,
diffEndLine,
replacement: trimmedReplacement,
removesLines,
originalText: document.getText(originalRange),
})
nesLog(`replace suggestion stashed at lines [${diffStartLine}..${diffEndLine}]`)
}
this.deps.onSuggestion?.({
shown: true,
latencyMs: suggestion.latencyMs,
status: "ok",
inputTokens: suggestion.inputTokens,
outputTokens: suggestion.outputTokens,
})
}
private handleError(err: unknown): undefined {
if ((err as Error)?.name === "AbortError") return undefined
const status = err instanceof MercuryEditError ? err.status : null
this.deps.onSuggestion?.({
shown: false,
latencyMs: 0,
status: "error",
errorStatus: status ?? undefined,
})
if (status === 401 || status === 402) this.deps.onFatalError?.(status)
return undefined
}
private debounce(ms: number, token: vscode.CancellationToken): Promise<void> {
if (this.debounceTimer) clearTimeout(this.debounceTimer)
return new Promise<void>((resolve) => {
this.debounceTimer = setTimeout(resolve, ms)
token.onCancellationRequested(() => {
if (this.debounceTimer) clearTimeout(this.debounceTimer)
resolve()
})
})
}
}
export { INLINE_COMPLETION_ACCEPTED_COMMAND }
/**
* VSCode's inline ghost text silently fails to render when the completion's
* range crosses a line boundary but the insert text has no newline (typical
* when Mercury implicitly drops a trailing blank line as file-end
* normalization). When that happens — and the lines past the cursor are
* blank — cap the range at the cursor's line so the ghost renders cleanly.
*/
function pickRenderEndLine(
document: vscode.TextDocument,
cursorLine: number,
diffEndLine: number,
insertText: string,
): number {
if (diffEndLine <= cursorLine) return diffEndLine
if (insertText.includes("\n")) return diffEndLine
for (let l = cursorLine + 1; l <= diffEndLine; l++) {
if (document.lineAt(l).text.trim() !== "") return diffEndLine
}
return cursorLine
}
@@ -0,0 +1,352 @@
import * as vscode from "vscode"
import { nesLog } from "./log"
import { planInsertion, planReplacement } from "./pendingEdit"
const PENDING_CONTEXT_KEY = "kilo-code.nextEdit.hasPendingSuggestion"
const CHAIN_DELAY_MS = 60
export type PendingNextEdit =
| {
kind: "replace"
document: vscode.TextDocument
/** Inclusive start line of the lines being replaced. */
diffStartLine: number
/** Inclusive end line of the lines being replaced. */
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
}
| {
kind: "insert"
document: vscode.TextDocument
/** Existing line before insertion, or `lineCount` when appending at EOF. */
diffStartLine: number
/** Same as diffStartLine for hint/jump-target purposes. */
diffEndLine: number
/** Lines to insert. Must end with a newline so existing content gets pushed down. */
replacement: string
/** Snapshot of the surrounding (single) line — used as a soft drift guard. */
originalText: string
}
/**
* Holds the currently-pending out-of-cursor NES suggestion and renders a
* jump-to-next-edit affordance via editor decorations. Same-line diffs are
* still handled by `InlineCompletionItem` (faster, native ghost text) — this
* manager is for everything else.
*
* Lifecycle: at most one pending suggestion at a time. A pending suggestion
* is cleared when the user accepts, dismisses, edits inside the diff range,
* or moves to a different document.
*/
export class NextEditSuggestionManager implements vscode.Disposable {
private pending: PendingNextEdit | null = null
private readonly subscriptions: vscode.Disposable[] = []
private readonly removedLineDecoration: vscode.TextEditorDecorationType
private readonly proposedLineDecoration: vscode.TextEditorDecorationType
private readonly hintDecoration: vscode.TextEditorDecorationType
constructor() {
// Tints + strikethrough on the lines that will be replaced or removed.
this.removedLineDecoration = vscode.window.createTextEditorDecorationType({
isWholeLine: true,
backgroundColor: new vscode.ThemeColor("diffEditor.removedLineBackground"),
overviewRulerColor: new vscode.ThemeColor("editorInfo.foreground"),
overviewRulerLane: vscode.OverviewRulerLane.Left,
textDecoration: "line-through; opacity: 0.65;",
})
// Inline `after` text showing the proposed replacement line.
this.proposedLineDecoration = vscode.window.createTextEditorDecorationType({
after: {
margin: "0 0 0 2em",
color: new vscode.ThemeColor("editorInfo.foreground"),
fontStyle: "italic",
},
})
// The one-line user-facing hint.
this.hintDecoration = vscode.window.createTextEditorDecorationType({
after: {
margin: "0 0 0 2em",
color: new vscode.ThemeColor("editorCodeLens.foreground"),
fontStyle: "italic",
},
})
// Dismiss when the document or selection moves in ways that invalidate
// the prediction.
this.subscriptions.push(
vscode.workspace.onDidChangeTextDocument((e) => {
const p = this.pending
if (!p) return
if (e.document !== p.document) return
// For "insert" we just confirm the anchor line is still there with its
// original content; for "replace" we re-check the full range.
let stillValid = true
try {
if (p.kind === "replace") {
const text = e.document.getText(
new vscode.Range(
new vscode.Position(p.diffStartLine, 0),
new vscode.Position(p.diffEndLine, e.document.lineAt(p.diffEndLine).range.end.character),
),
)
stillValid = text === p.originalText
} else {
// Insert mode: only invalidate if the anchor line shifted.
const anchorLine = Math.min(p.diffStartLine, e.document.lineCount - 1)
const anchorText = e.document.lineAt(anchorLine).text
stillValid = anchorText === p.originalText
}
} catch {
stillValid = false
}
if (!stillValid) this.clear()
}),
vscode.window.onDidChangeActiveTextEditor(() => this.clear()),
// When the cursor moves (e.g., post-jump), refresh the hint so it
// flips between "Tab to jump" and "Tab to apply".
vscode.window.onDidChangeTextEditorSelection((e) => {
if (!this.pending) return
if (e.textEditor.document !== this.pending.document) return
this.renderDecorations(this.pending)
}),
)
}
public isPending(): boolean {
return this.pending !== null
}
public getPending(): PendingNextEdit | null {
return this.pending
}
public setPending(p: PendingNextEdit): void {
this.clearDecorations()
this.pending = p
void vscode.commands.executeCommand("setContext", PENDING_CONTEXT_KEY, true)
// Hide any in-flight inline suggestion so it can't compete with our Tab handler.
void vscode.commands.executeCommand("editor.action.inlineSuggest.hide")
this.renderDecorations(p)
}
public clear(): void {
if (!this.pending) return
this.pending = null
this.clearDecorations()
void vscode.commands.executeCommand("setContext", PENDING_CONTEXT_KEY, false)
}
/** Tab handler — accept if cursor near the diff, else jump. */
public async acceptOrJump(): Promise<void> {
const p = this.pending
if (!p) return
const editor = vscode.window.activeTextEditor
if (!editor || editor.document !== p.document) {
this.clear()
return
}
const cursor = editor.selection.active
const inside =
p.kind === "replace"
? cursor.line >= p.diffStartLine && cursor.line <= p.diffEndLine
: cursor.line === p.diffStartLine || cursor.line === p.diffStartLine - 1
if (inside) {
await this.applyPending()
} else {
const targetLine = Math.min(p.diffStartLine, Math.max(0, p.document.lineCount - 1))
const targetChar = p.document.lineAt(targetLine).firstNonWhitespaceCharacterIndex
const target = new vscode.Position(targetLine, targetChar)
editor.selection = new vscode.Selection(target, target)
editor.revealRange(new vscode.Range(target, target), vscode.TextEditorRevealType.InCenterIfOutsideViewport)
nesLog(`jumped cursor ${cursor.line} -> ${target.line} (pending diff at [${p.diffStartLine}..${p.diffEndLine}])`)
// Refresh hint immediately so "Tab to apply" is shown.
this.renderDecorations(p)
}
}
private async applyPending(): Promise<void> {
const p = this.pending
if (!p) return
const editor = vscode.window.activeTextEditor
if (!editor || editor.document !== p.document) {
this.clear()
return
}
// Snapshot what we're about to do, then nuke pending state so the upcoming
// document change doesn't re-enter via the invalidation listener.
this.clearDecorations()
this.pending = null
void vscode.commands.executeCommand("setContext", PENDING_CONTEXT_KEY, false)
let ok = false
if (p.kind === "insert") {
// Re-validate before applying: the anchor line must still hold its
// original text. Without this, edits between the anchor and the insertion
// point can shift line numbers and land the insert in the wrong place.
const anchorLine = Math.min(p.diffStartLine, editor.document.lineCount - 1)
const anchorText = anchorLine >= 0 ? editor.document.lineAt(anchorLine).text : undefined
if (anchorText !== p.originalText) {
nesLog(`document drifted since suggestion was made — dropping insert at line ${p.diffStartLine}`)
return
}
const edit = planInsertion(p, {
lineCount: editor.document.lineCount,
end: (line) => editor.document.lineAt(line).range.end.character,
})
const pos = new vscode.Position(edit.line, edit.character)
ok = await editor.edit((b) => b.insert(pos, edit.text))
nesLog(`applied insert at line ${pos.line} (${edit.text.length} chars, ok=${ok})`)
} else {
const range = new vscode.Range(
new vscode.Position(p.diffStartLine, 0),
new vscode.Position(p.diffEndLine, p.document.lineAt(p.diffEndLine).range.end.character),
)
const currentInDoc = editor.document.getText(range)
if (currentInDoc !== p.originalText) {
nesLog(`document drifted since suggestion was made — dropping range [${p.diffStartLine}..${p.diffEndLine}]`)
return
}
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()
}
private renderDecorations(p: PendingNextEdit): void {
// Same document can be open in multiple splits — paint all of them so the
// user sees the decoration regardless of which split has focus.
const editors = vscode.window.visibleTextEditors.filter((e) => e.document === p.document)
if (editors.length === 0) return
const removedRanges: vscode.Range[] = []
const proposedAnnotations: vscode.DecorationOptions[] = []
if (p.kind === "replace") {
const originalLines = p.originalText.split("\n")
const proposedLines = p.replacement.split("\n")
const minLen = Math.min(originalLines.length, proposedLines.length)
for (let i = 0; i < minLen; i++) {
if (originalLines[i] === proposedLines[i]) continue
const lineNo = p.diffStartLine + i
const lineRange = p.document.lineAt(lineNo).range
removedRanges.push(lineRange)
proposedAnnotations.push({
range: new vscode.Range(lineRange.end, lineRange.end),
renderOptions: { after: { contentText: `${visualize(proposedLines[i])}` } },
})
}
// Pure deletions inside a replace
for (let i = minLen; i < originalLines.length; i++) {
const lineNo = p.diffStartLine + i
const lineRange = p.document.lineAt(lineNo).range
removedRanges.push(lineRange)
proposedAnnotations.push({
range: new vscode.Range(lineRange.end, lineRange.end),
renderOptions: { after: { contentText: `→ (removed)` } },
})
}
// Additions inside a replace — anchor on last shared line
if (proposedLines.length > originalLines.length) {
const tailLineNo = p.diffStartLine + originalLines.length - 1
const safeLine = Math.max(p.diffStartLine, Math.min(tailLineNo, p.diffEndLine))
const tailRange = p.document.lineAt(safeLine).range
const added = proposedLines.slice(originalLines.length).map(visualize).join(" ⏎ ")
proposedAnnotations.push({
range: new vscode.Range(tailRange.end, tailRange.end),
renderOptions: { after: { contentText: `+ ${added}` } },
})
}
} else {
// Pure insertion: anchor the ghost text on the existing line, no strikethrough.
const anchorLine = Math.min(p.diffStartLine, p.document.lineCount - 1)
const safeAnchor = Math.max(0, anchorLine)
const anchorRange = p.document.lineAt(safeAnchor).range
// Strip the trailing \n we appended for insertion semantics, then show each
// inserted line collapsed with a small separator.
const lines = p.replacement.replace(/\n$/, "").split("\n").map(visualize)
const inserted = lines.join(" ⏎ ")
proposedAnnotations.push({
range: new vscode.Range(anchorRange.end, anchorRange.end),
renderOptions: { after: { contentText: `+ ${inserted}` } },
})
}
// Hint anchor + cursor check use the active editor if it's one of ours,
// else fall back to the first visible editor for this document.
const active = vscode.window.activeTextEditor
const referenceEditor = active && editors.includes(active) ? active : editors[0]
const hintAnchor = Math.min(p.diffStartLine, p.document.lineCount - 1)
const hintLineEnd = p.document.lineAt(Math.max(0, hintAnchor)).range.end
const cursor = referenceEditor.selection.active
const cursorAtDiff =
p.kind === "replace"
? cursor.line >= p.diffStartLine && cursor.line <= p.diffEndLine
: cursor.line === p.diffStartLine || cursor.line === p.diffStartLine - 1
const hintText = cursorAtDiff ? " ↳ Tab to apply · Esc to dismiss" : " ↳ Tab to jump here · Esc to dismiss"
const hintOptions: vscode.DecorationOptions[] = [
{
range: new vscode.Range(hintLineEnd, hintLineEnd),
renderOptions: { after: { contentText: hintText } },
},
]
for (const editor of editors) {
editor.setDecorations(this.removedLineDecoration, removedRanges)
editor.setDecorations(this.proposedLineDecoration, proposedAnnotations)
editor.setDecorations(this.hintDecoration, hintOptions)
}
}
private clearDecorations(): void {
for (const editor of vscode.window.visibleTextEditors) {
editor.setDecorations(this.removedLineDecoration, [])
editor.setDecorations(this.proposedLineDecoration, [])
editor.setDecorations(this.hintDecoration, [])
}
}
public dispose(): void {
this.clear()
for (const s of this.subscriptions) s.dispose()
this.subscriptions.length = 0
this.removedLineDecoration.dispose()
this.proposedLineDecoration.dispose()
this.hintDecoration.dispose()
}
}
/**
* Re-invoke VSCode's inline-suggest UI after an accept so the provider fires
* again and surfaces the next prediction without the user having to type.
* This is the "Tab-Tab-Tab" walk-through-a-refactor UX from Cursor.
*
* A short delay lets the document change settle before we re-enter
* `provideInlineCompletionItems`, and gives the user a moment to abandon the
* chain by typing or moving the cursor.
*/
export function chainNextPrediction(delayMs = CHAIN_DELAY_MS): void {
setTimeout(() => {
void vscode.commands.executeCommand("editor.action.inlineSuggest.trigger")
}, delayMs)
}
function visualize(line: string): string {
// VSCode after-text decorations don't support newlines — collapse just in case.
// Also surface leading whitespace explicitly so it isn't visually swallowed.
const collapsed = line.replace(/\s+$/g, "").replace(/^\t+/, (t) => " ".repeat(t.length))
return collapsed.length > 120 ? collapsed.slice(0, 117) + "…" : collapsed
}
@@ -0,0 +1,9 @@
/**
* Editable-region sizing for Next Edit. Per the Mercury docs, region size
* dominates output latency; centering [-5, +10] around the cursor is the
* recommended starting point. (The Mercury prompt sentinel tokens live in the
* gateway — see packages/kilo-gateway/src/edit-prompt.ts.)
*/
export const DEFAULT_EDITABLE_REGION_TOP_MARGIN = 5
export const DEFAULT_EDITABLE_REGION_BOTTOM_MARGIN = 10
export const MAX_EDITABLE_REGION_LINES = 25
@@ -0,0 +1,168 @@
import { createPatch } from "diff"
import * as vscode from "vscode"
const DEFAULT_DEBOUNCE_MS = 1500
const DEFAULT_MAX_DIFFS = 5
type Options = {
debounceMs?: number
maxDiffs?: number
isFileAllowed: (fsPath: string) => Promise<boolean>
}
type Diff = {
key: string
patch: string
}
/**
* Tracks per-file snapshots and emits a workspace-wide chronological stream
* of range-based unidiffs after a short idle window. Cross-file history is
* intentional: Mercury uses recent edits from any file to infer user intent.
*
* Diffs are produced lazily; the tracker holds the previously-emitted state
* per file and computes the diff against the current document content when
* the debounce fires.
*/
export class EditHistoryTracker implements vscode.Disposable {
private readonly snapshots = new Map<string, string>()
private readonly pendingTimers = new Map<string, NodeJS.Timeout>()
private readonly diffs: Diff[] = []
private readonly subscriptions: vscode.Disposable[] = []
constructor(private readonly options: Options) {
const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS
// Seed snapshots on open so the FIRST edit in a freshly-opened file is
// captured in the diff history (otherwise the common "open, type, trigger"
// flow ships an empty edit-history block). Access checks happen before
// reading text so ignored documents are never retained as edit context.
this.subscriptions.push(
vscode.workspace.onDidOpenTextDocument((doc) => {
if (doc.uri.scheme !== "file") return
void this.seed(doc)
}),
)
for (const doc of vscode.workspace.textDocuments) {
if (doc.uri.scheme === "file") void this.seed(doc)
}
this.subscriptions.push(
vscode.workspace.onDidChangeTextDocument((event) => {
if (event.document.uri.scheme !== "file") return
if (event.contentChanges.length === 0) return
void this.scheduleSnapshotDiff(event.document, debounceMs)
}),
)
this.subscriptions.push(
vscode.workspace.onDidCloseTextDocument((doc) => {
const key = doc.uri.fsPath
const t = this.pendingTimers.get(key)
if (t) clearTimeout(t)
this.pendingTimers.delete(key)
this.snapshots.delete(key)
}),
)
}
/**
* Force the pending diff (if any) for `document` to be emitted now. Call
* this immediately before building a request so the freshest user edit
* makes it into the prompt.
*/
public async flush(document: vscode.TextDocument): Promise<void> {
const key = document.uri.fsPath
if (!(await this.allowed(key))) {
this.reject(key)
return
}
const t = this.pendingTimers.get(key)
if (t) clearTimeout(t)
this.pendingTimers.delete(key)
await this.emitDiffNow(document)
}
/** Workspace-wide oldest to newest, matching the Mercury prompt-history convention. */
public async getRecentDiffs(): Promise<string[]> {
const kept = (
await Promise.all(this.diffs.map(async (diff) => ((await this.allowed(diff.key)) ? diff : undefined)))
).filter((diff): diff is Diff => diff !== undefined)
this.diffs.splice(0, this.diffs.length, ...kept)
return kept.map((diff) => diff.patch)
}
public dispose(): void {
for (const t of this.pendingTimers.values()) clearTimeout(t)
this.pendingTimers.clear()
for (const s of this.subscriptions) s.dispose()
this.subscriptions.length = 0
}
private async seed(document: vscode.TextDocument): Promise<void> {
const key = document.uri.fsPath
if (this.snapshots.has(key)) return
if (!(await this.allowed(key))) {
this.reject(key)
return
}
if (!this.snapshots.has(key)) this.snapshots.set(key, document.getText())
}
private async scheduleSnapshotDiff(document: vscode.TextDocument, debounceMs: number): Promise<void> {
const key = document.uri.fsPath
if (!(await this.allowed(key))) {
this.reject(key)
return
}
if (!this.snapshots.has(key)) {
// Fallback seed for documents we never saw open (e.g. opened before the
// tracker existed). The triggering change is lost, but subsequent edits
// produce useful diffs.
this.snapshots.set(key, document.getText())
return
}
const existing = this.pendingTimers.get(key)
if (existing) clearTimeout(existing)
const timer = setTimeout(() => {
this.pendingTimers.delete(key)
void this.emitDiffNow(document)
}, debounceMs)
this.pendingTimers.set(key, timer)
}
private async emitDiffNow(document: vscode.TextDocument): Promise<void> {
const key = document.uri.fsPath
if (!(await this.allowed(key))) {
this.reject(key)
return
}
const previous = this.snapshots.get(key)
if (previous === undefined) return
const current = document.getText()
if (current === previous) return
const filename = vscode.workspace.asRelativePath(document.uri, false)
const patch = createPatch(filename, previous, current, undefined, undefined, { context: 1 })
// `createPatch` returns "" for identical inputs; guard anyway.
if (patch && patch.trim().length > 0) {
this.diffs.push({ key, patch })
const maxDiffs = this.options.maxDiffs ?? DEFAULT_MAX_DIFFS
if (this.diffs.length > maxDiffs) this.diffs.shift()
}
this.snapshots.set(key, current)
}
private async allowed(key: string): Promise<boolean> {
const allow = this.options.isFileAllowed
if (!allow) return false
return allow(key).catch(() => false)
}
private reject(key: string): void {
const timer = this.pendingTimers.get(key)
if (timer) clearTimeout(timer)
this.pendingTimers.delete(key)
this.snapshots.delete(key)
const kept = this.diffs.filter((diff) => diff.key !== key)
this.diffs.splice(0, this.diffs.length, ...kept)
}
}
@@ -0,0 +1,43 @@
import {
DEFAULT_EDITABLE_REGION_BOTTOM_MARGIN,
DEFAULT_EDITABLE_REGION_TOP_MARGIN,
MAX_EDITABLE_REGION_LINES,
} from "./constants"
export interface EditableRegionInputs {
cursorLine: number
totalLines: number
topMargin?: number
bottomMargin?: number
}
export interface EditableRegion {
startLine: number
endLine: number
}
/**
* Editable region selection per the Mercury docs: center [-top, +bottom] around
* the cursor, clipped to file bounds. Capped to MAX_EDITABLE_REGION_LINES (~25)
* because output tokens dominate latency.
*/
export function computeEditableRegion({
cursorLine,
totalLines,
topMargin = DEFAULT_EDITABLE_REGION_TOP_MARGIN,
bottomMargin = DEFAULT_EDITABLE_REGION_BOTTOM_MARGIN,
}: EditableRegionInputs): EditableRegion {
if (totalLines <= 0) return { startLine: 0, endLine: 0 }
const lastLine = totalLines - 1
let start = Math.max(0, cursorLine - topMargin)
let end = Math.min(lastLine, cursorLine + bottomMargin)
const span = end - start + 1
if (span > MAX_EDITABLE_REGION_LINES) {
const overflow = span - MAX_EDITABLE_REGION_LINES
// Prefer trimming below the cursor, where we have less semantic context.
end = Math.max(start, end - overflow)
}
return { startLine: start, endLine: end }
}
@@ -0,0 +1,37 @@
import * as vscode from "vscode"
const CHANNEL_NAME = "Kilo Code · Next Edit"
let channel: vscode.OutputChannel | null = null
function getChannel(): vscode.OutputChannel {
if (!channel) channel = vscode.window.createOutputChannel(CHANNEL_NAME)
return channel
}
function debugEnabled(): boolean {
// Toggled via env only — deliberately not a VSCode setting, to avoid adding
// new autocomplete config (config is migrating to the backend).
return process.env.KILO_NES_DEBUG === "1"
}
/**
* Append a single log line to the dedicated NES output channel. Always goes to
* the channel (so a user troubleshooting can flip it on without rebuilding);
* `console.log` is mirrored only when the debug setting is enabled.
*/
export function nesLog(message: string): void {
getChannel().appendLine(`[${new Date().toISOString()}] ${message}`)
if (debugEnabled()) console.log(`[NES] ${message}`)
}
/** Equivalent of `console.warn` for the channel. */
export function nesWarn(message: string): void {
getChannel().appendLine(`[${new Date().toISOString()}] WARN ${message}`)
if (debugEnabled()) console.warn(`[NES] ${message}`)
}
export function disposeLog(): void {
channel?.dispose()
channel = null
}
@@ -0,0 +1,43 @@
type Document = {
lineCount: number
end(line: number): number
}
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 }
}
const line = Math.max(0, document.lineCount - 1)
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,
}
}
@@ -0,0 +1,52 @@
import type { AutocompleteCodeSnippet } from "../continuedev/core/autocomplete/types"
import * as vscode from "vscode"
import type { MercuryRecentSnippet } from "./types"
const MAX_SNIPPET_LINES = 20
const MAX_SNIPPETS = 5
/**
* Convert kilocode's already-collected `RecentlyVisitedRangesService` output
* into the shape Mercury Edit expects for the `<|recently_viewed_code_snippets|>`
* block. Per docs: 35 snippets × ~20 lines, oldest → newest, excluding the
* currently active file (the service already filters that out).
*
* `RecentlyVisitedRangesService.getSnippets()` returns snippets newest→oldest;
* we reverse so Mercury sees them in chronological order.
*/
export function toMercuryRecentSnippets(
snippets: ReadonlyArray<Pick<AutocompleteCodeSnippet, "filepath" | "content">>,
): MercuryRecentSnippet[] {
return snippets
.slice(0, MAX_SNIPPETS)
.reverse()
.map((s) => ({
filepath: shortenPath(s.filepath),
content: trimToLines(s.content, MAX_SNIPPET_LINES),
}))
}
export function toAllowedMercuryRecentSnippets(
snippets: ReadonlyArray<Pick<AutocompleteCodeSnippet, "filepath" | "content">>,
allowed: (filepath: string) => boolean,
): MercuryRecentSnippet[] {
return toMercuryRecentSnippets(snippets.filter((snippet) => allowed(snippet.filepath)))
}
function trimToLines(content: string, maxLines: number): string {
const lines = content.split("\n")
if (lines.length <= maxLines) return content
// Center the trim window — keep the most semantically meaningful core.
const start = Math.floor((lines.length - maxLines) / 2)
return lines.slice(start, start + maxLines).join("\n")
}
function shortenPath(uri: string): string {
// Convert file:// URI strings to workspace-relative paths so the prompt is compact.
try {
const parsed = vscode.Uri.parse(uri)
return vscode.workspace.asRelativePath(parsed, false)
} catch {
return uri
}
}
@@ -0,0 +1,26 @@
export interface MercuryRecentSnippet {
filepath: string
content: string
}
export interface MercuryEditRequestContext {
currentFilePath: string
currentFileContent: string
cursorLine: number
cursorCharacter: number
editableRegionStartLine: number
editableRegionEndLine: number
recentlyViewedSnippets: MercuryRecentSnippet[]
editDiffHistory: string[]
}
export interface MercuryEditSuggestion {
/** The replacement text for lines [editableRegionStartLine, editableRegionEndLine]. */
replacement: string
editableRegionStartLine: number
editableRegionEndLine: number
/** Latency in milliseconds from request send to response parse. */
latencyMs: number
inputTokens?: number
outputTokens?: number
}
@@ -46,6 +46,10 @@ const mockVscode = {
version: "1.90.0",
workspace: {
workspaceFolders: [{ uri: { fsPath: "/repo" } }],
textDocuments: [] as Array<unknown>,
onDidOpenTextDocument: () => ({ dispose: noop }),
onDidChangeTextDocument: () => ({ dispose: noop }),
onDidCloseTextDocument: () => ({ dispose: noop }),
getConfiguration: () => ({
get: <T>(_key: string, value?: T) => value,
update: async () => {},
@@ -134,6 +138,13 @@ const mockVscode = {
public end: { line: number; character: number },
) {}
},
InlineCompletionItem: class {
constructor(
public insertText: string,
public range?: unknown,
public command?: unknown,
) {}
},
Disposable: class {
constructor(private callback: () => void = noop) {}
dispose() {
@@ -0,0 +1,37 @@
import { MAX_EDITABLE_REGION_LINES } from "../../src/services/autocomplete/next-edit/constants"
import { computeEditableRegion } from "../../src/services/autocomplete/next-edit/editableRegion"
describe("computeEditableRegion", () => {
it("returns the default [-5, +10] window around the cursor", () => {
const r = computeEditableRegion({ cursorLine: 20, totalLines: 100 })
expect(r.startLine).toBe(15)
expect(r.endLine).toBe(30)
})
it("clips at file start", () => {
const r = computeEditableRegion({ cursorLine: 2, totalLines: 50 })
expect(r.startLine).toBe(0)
expect(r.endLine).toBe(12)
})
it("clips at file end", () => {
const r = computeEditableRegion({ cursorLine: 49, totalLines: 50 })
expect(r.endLine).toBe(49)
expect(r.startLine).toBe(44)
})
it("caps the region at MAX_EDITABLE_REGION_LINES", () => {
const r = computeEditableRegion({
cursorLine: 100,
totalLines: 1000,
topMargin: 100,
bottomMargin: 100,
})
expect(r.endLine - r.startLine + 1).toBeLessThanOrEqual(MAX_EDITABLE_REGION_LINES)
})
it("handles an empty document gracefully", () => {
const r = computeEditableRegion({ cursorLine: 0, totalLines: 0 })
expect(r).toEqual({ startLine: 0, endLine: 0 })
})
})
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it } from "bun:test"
import * as vscode from "vscode"
import { EditHistoryTracker } from "../../src/services/autocomplete/next-edit/editHistoryTracker"
type Doc = vscode.TextDocument & { setText(text: string): void }
function doc(path: string, initial: string): Doc {
const state = { text: initial }
return {
uri: { fsPath: path, scheme: "file" },
getText: () => state.text,
setText: (text: string) => {
state.text = text
},
} as unknown as Doc
}
function docs(...items: Doc[]): void {
;(vscode.workspace.textDocuments as unknown as Doc[]).splice(0, Infinity, ...items)
}
function settle(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0))
}
afterEach(() => docs())
describe("EditHistoryTracker", () => {
it("retains chronological edits across files for Mercury context", async () => {
const a = doc("/workspace/a.ts", "const a = 1\n")
const b = doc("/workspace/b.ts", "const b = 1\n")
docs(a, b)
const tracker = new EditHistoryTracker({ isFileAllowed: async () => true })
await settle()
a.setText("const a = 2\n")
await tracker.flush(a)
b.setText("const b = 2\n")
await tracker.flush(b)
const diffs = await tracker.getRecentDiffs()
expect(diffs).toHaveLength(2)
expect(diffs[0]).toContain("a.ts")
expect(diffs[0]).toContain("+const a = 2")
expect(diffs[1]).toContain("b.ts")
expect(diffs[1]).toContain("+const b = 2")
tracker.dispose()
})
it("does not retain edits when the access policy is missing at runtime", async () => {
const a = doc("/workspace/a.ts", "const a = 1\n")
docs(a)
const tracker = new EditHistoryTracker({} as { isFileAllowed: (path: string) => Promise<boolean> })
await settle()
a.setText("const a = 2\n")
await tracker.flush(a)
expect(await tracker.getRecentDiffs()).toEqual([])
tracker.dispose()
})
it("never returns edits from denied documents", async () => {
const denied = new Set(["/workspace/.env"])
const safe = doc("/workspace/app.ts", "const safe = 1\n")
const secret = doc("/workspace/.env", "TOKEN=old\n")
docs(safe, secret)
const tracker = new EditHistoryTracker({ isFileAllowed: async (path) => !denied.has(path) })
await settle()
secret.setText("TOKEN=secret\n")
await tracker.flush(secret)
safe.setText("const safe = 2\n")
await tracker.flush(safe)
const diffs = await tracker.getRecentDiffs()
expect(diffs).toHaveLength(1)
expect(diffs[0]).toContain("app.ts")
expect(diffs[0]).not.toContain("TOKEN=secret")
denied.add("/workspace/app.ts")
expect(await tracker.getRecentDiffs()).toEqual([])
tracker.dispose()
})
})
@@ -0,0 +1,153 @@
import { describe, expect, it, mock } from "bun:test"
import * as vscode from "vscode"
import type { KiloConnectionService } from "../../src/services/cli-backend"
import {
NextEditInlineCompletionProvider,
type NextEditProviderDeps,
} from "../../src/services/autocomplete/next-edit/NextEditInlineCompletionProvider"
import type { NextEditSuggestionManager } from "../../src/services/autocomplete/next-edit/NextEditSuggestionManager"
type Subject = {
toCompletionItems(
document: vscode.TextDocument,
position: vscode.Position,
suggestion: {
replacement: string
editableRegionStartLine: number
editableRegionEndLine: number
latencyMs: number
},
): 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,
uri: { fsPath: "/workspace/test.ts", scheme: "file" },
} as unknown as vscode.TextDocument
}
describe("NextEditInlineCompletionProvider", () => {
it("does not send a document when the access policy is missing at runtime", async () => {
const connection = { getClientAsync: mock() }
const provider = new NextEditInlineCompletionProvider({
connectionService: connection,
} as unknown as NextEditProviderDeps)
const out = await provider.provideInlineCompletionItems(
doc("const value = 1"),
new vscode.Position(0, 0),
{} as vscode.InlineCompletionContext,
{} as vscode.CancellationToken,
)
expect(out).toBeUndefined()
expect(connection.getClientAsync).not.toHaveBeenCalled()
provider.dispose()
})
it("does not send a document when the access policy fails", async () => {
const connection = { getClientAsync: mock() }
const provider = new NextEditInlineCompletionProvider({
connectionService: connection as unknown as KiloConnectionService,
isFileAllowed: async () => Promise.reject(new Error("unavailable")),
})
const out = await provider.provideInlineCompletionItems(
doc("const value = 1"),
new vscode.Position(0, 0),
{} as vscode.InlineCompletionContext,
{} as vscode.CancellationToken,
)
expect(out).toBeUndefined()
expect(connection.getClientAsync).not.toHaveBeenCalled()
provider.dispose()
})
it("stashes same-line rewrites before the cursor for decorated acceptance", () => {
const mgr = { clear: mock(), setPending: mock() }
const provider = new NextEditInlineCompletionProvider({
connectionService: {} as KiloConnectionService,
isFileAllowed: async () => true,
suggestionManager: mgr as unknown as NextEditSuggestionManager,
})
const text = "const oldName = make()"
const document = {
lineCount: 1,
lineAt: () => ({ text, range: { end: new vscode.Position(0, text.length) } }),
getText: () => text,
} as unknown as vscode.TextDocument
const out = (provider as unknown as Subject).toCompletionItems(document, new vscode.Position(0, 13), {
replacement: "const newName = make()",
editableRegionStartLine: 0,
editableRegionEndLine: 0,
latencyMs: 1,
})
expect(out).toBeUndefined()
expect(mgr.setPending).toHaveBeenCalledWith(
expect.objectContaining({ kind: "replace", replacement: "const newName = make()" }),
)
provider.dispose()
})
it("stashes complete-line deletion intent for acceptance", () => {
const mgr = { clear: mock(), setPending: mock() }
const provider = new NextEditInlineCompletionProvider({
connectionService: {} as KiloConnectionService,
isFileAllowed: async () => true,
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: mock(), setPending: mock() }
const provider = new NextEditInlineCompletionProvider({
connectionService: {} as KiloConnectionService,
isFileAllowed: async () => true,
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()
})
})
@@ -0,0 +1,63 @@
import { describe, expect, it } from "bun:test"
import { planInsertion, planReplacement } from "../../src/services/autocomplete/next-edit/pendingEdit"
describe("planInsertion", () => {
it("appends after the final unterminated line at EOF", () => {
const edit = planInsertion(
{ diffStartLine: 2, replacement: "third\n" },
{ lineCount: 2, end: (line) => [5, 6][line] },
)
expect(edit).toEqual({ line: 1, character: 6, text: "\nthird" })
})
it("keeps insertion-before-line semantics for a trailing empty line", () => {
const edit = planInsertion(
{ diffStartLine: 1, replacement: "second\n" },
{ lineCount: 2, end: (line) => [5, 0][line] },
)
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: "",
})
})
})
@@ -0,0 +1,55 @@
import {
toAllowedMercuryRecentSnippets,
toMercuryRecentSnippets,
} from "../../src/services/autocomplete/next-edit/recentSnippetsAdapter"
describe("toMercuryRecentSnippets", () => {
it("returns an empty array when no snippets are supplied", () => {
expect(toMercuryRecentSnippets([])).toEqual([])
})
it("caps the number of snippets at 5", () => {
const snippets = Array.from({ length: 12 }, (_, i) => ({
filepath: `file://${i}.ts`,
content: `const x${i} = ${i}`,
}))
const out = toMercuryRecentSnippets(snippets)
expect(out.length).toBe(5)
})
it("reverses input order (service returns newest→oldest, Mercury wants oldest→newest)", () => {
const out = toMercuryRecentSnippets([
{ filepath: "a.ts", content: "newest" },
{ filepath: "b.ts", content: "middle" },
{ filepath: "c.ts", content: "oldest" },
])
expect(out.map((s) => s.content)).toEqual(["oldest", "middle", "newest"])
})
it("trims content above 20 lines to a centered window", () => {
const content = Array.from({ length: 50 }, (_, i) => `line${i}`).join("\n")
const [snippet] = toMercuryRecentSnippets([{ filepath: "x.ts", content }])
const lines = snippet.content.split("\n")
expect(lines.length).toBe(20)
// Center: lines should be drawn from somewhere in the middle of the input.
expect(lines[0]).toMatch(/^line[12]\d$/)
})
it("passes through filepath verbatim when not a parsable URI", () => {
const [out] = toMercuryRecentSnippets([{ filepath: "not a uri", content: "x" }])
expect(out.filepath).toBe("not a uri")
})
it("excludes denied snippets before constructing next edit request context", () => {
const out = toAllowedMercuryRecentSnippets(
[
{ filepath: "secrets/.env", content: "TOKEN=do-not-send" },
{ filepath: "src/app.ts", content: "const safe = true" },
],
(path) => !path.endsWith(".env"),
)
expect(out).toEqual([{ filepath: "src/app.ts", content: "const safe = true" }])
expect(JSON.stringify(out)).not.toContain("do-not-send")
})
})
@@ -144,6 +144,34 @@ export const FimBody = Schema.Struct({
temperature: Schema.optional(Schema.Finite),
})
// Next Edit (NES) — non-streaming. Clients send structured editor context; the
// gateway assembles the Mercury sentinel-tagged prompt (contract documented at
// https://docs.inceptionlabs.ai/capabilities/next-edit) so the prompt format
// lives in one place and is shared across editors.
export const EditBody = Schema.Struct({
provider: Schema.optional(Schema.String),
model: Schema.optional(Schema.String),
maxTokens: Schema.optional(Schema.Finite),
currentFilePath: Schema.String,
currentFileContent: Schema.String,
cursorLine: Schema.Finite,
cursorCharacter: Schema.Finite,
editableRegionStartLine: Schema.Finite,
editableRegionEndLine: Schema.Finite,
recentlyViewedSnippets: Schema.Array(Schema.Struct({ filepath: Schema.String, content: Schema.String })),
editDiffHistory: Schema.Array(Schema.String),
})
export const EditResponse = Schema.Struct({
content: Schema.String,
usage: Schema.optional(
Schema.Struct({
prompt_tokens: Schema.optional(Schema.Finite),
completion_tokens: Schema.optional(Schema.Finite),
}),
),
})
export const AudioTranscriptionsBody = Schema.Struct({
model: Schema.String,
input_audio: Schema.Struct({
@@ -195,6 +223,7 @@ export const KiloGatewayPaths = {
modes: `${root}/modes`,
profile: `${root}/profile`,
fim: `${root}/fim`,
edit: `${root}/edit`,
audioTranscriptions: `${root}/audio/transcriptions`,
notifications: `${root}/notifications`,
organization: `${root}/organization`,
@@ -239,6 +268,19 @@ export const KiloGatewayApi = HttpApi.make("kilo")
description: "Proxy a Fill-in-the-Middle completion request to the Kilo Gateway",
}),
),
HttpApiEndpoint.post("edit", KiloGatewayPaths.edit, {
payload: EditBody,
success: described(EditResponse, "Next Edit completion"),
error: [HttpApiError.BadRequest, HttpApiError.Unauthorized],
}).annotateMerge(
OpenApi.annotations({
identifier: "kilo.edit",
summary: "Next Edit completion",
description:
"Proxy a Mercury-style Next Edit request. The client supplies structured editor " +
"context; the gateway assembles the sentinel-tagged prompt and forwards to the upstream edit endpoint.",
}),
),
HttpApiEndpoint.post("audioTranscriptions", KiloGatewayPaths.audioTranscriptions, {
payload: AudioTranscriptionsBody,
success: described(TranscriptionResponse, "Transcription response"),
@@ -20,6 +20,8 @@ import {
fetchProfile,
} from "@kilocode/kilo-gateway"
import { DIRECT_FIM_ENV, requestMistralFim, resolveFimTarget } from "@kilocode/kilo-gateway/fim"
import { DIRECT_EDIT_ENV, extractFencedBody, resolveEditTarget } from "@kilocode/kilo-gateway/edit"
import { buildMercuryEditPrompt } from "@kilocode/kilo-gateway/edit-prompt"
import { buildKiloHeaders } from "@kilocode/kilo-gateway"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
@@ -36,7 +38,7 @@ import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
import { MessageTable, PartTable, SessionTable } from "@/session/session.sql"
import { Session } from "@/session/session"
import { Database } from "@/storage/db"
import { AudioTranscriptionsBody, FimBody } from "../groups/kilo-gateway"
import { AudioTranscriptionsBody, EditBody, FimBody } from "../groups/kilo-gateway"
const FIM_TIMEOUT_MS = 30_000
@@ -154,6 +156,82 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo",
)
})
const edit = Effect.fn("KiloGatewayHttpApi.edit")(function* (ctx: { payload: typeof EditBody.Type }) {
const target = resolveEditTarget(ctx.payload.provider, ctx.payload.model)
if (target.provider !== "inception") {
return yield* Effect.fail(new HttpApiError.BadRequest({}))
}
const token = yield* Effect.gen(function* () {
const item = yield* auth.get(target.provider).pipe(Effect.mapError(() => new HttpApiError.Unauthorized({})))
if (item?.type === "api") return item.key
return DIRECT_EDIT_ENV[target.provider].map((key) => process.env[key]).find(Boolean)
})
if (!token) return yield* Effect.fail(new HttpApiError.Unauthorized({}))
const request = yield* HttpServerRequest.HttpServerRequest
const signal =
request.source instanceof Request
? AbortSignal.any([request.source.signal, AbortSignal.timeout(FIM_TIMEOUT_MS)])
: AbortSignal.timeout(FIM_TIMEOUT_MS)
// Assemble the Mercury sentinel prompt from the structured context the
// client sent — same builder every editor frontend shares.
const content = buildMercuryEditPrompt({
currentFilePath: ctx.payload.currentFilePath,
currentFileContent: ctx.payload.currentFileContent,
cursorLine: ctx.payload.cursorLine,
cursorCharacter: ctx.payload.cursorCharacter,
editableRegionStartLine: ctx.payload.editableRegionStartLine,
editableRegionEndLine: ctx.payload.editableRegionEndLine,
recentlyViewedSnippets: [...ctx.payload.recentlyViewedSnippets],
editDiffHistory: [...ctx.payload.editDiffHistory],
})
const response = yield* Effect.promise(async () => {
return fetch(target.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
signal,
body: JSON.stringify({
model: target.model,
max_tokens: ctx.payload.maxTokens ?? 512,
// Mercury rejects role:"system" on this endpoint — must be a single user message.
messages: [{ role: "user", content }],
}),
})
})
if (!response.ok) {
// Pass the upstream status through (mirrors the FIM handler) so the
// client can distinguish auth/credit/rate-limit/server failures
// instead of collapsing everything to 400.
const text = yield* Effect.promise(() => response.text())
return HttpServerResponse.jsonUnsafe(
{ error: `Edit request failed: ${response.status} ${text}` },
{ status: response.status },
)
}
const json = yield* Effect.promise(() => response.json() as Promise<{
choices?: Array<{ message?: { content?: string } }>
usage?: { prompt_tokens?: number; completion_tokens?: number }
}>)
const raw = json.choices?.[0]?.message?.content ?? ""
const body = extractFencedBody(raw)
return {
content: body,
usage: json.usage
? {
prompt_tokens: json.usage.prompt_tokens,
completion_tokens: json.usage.completion_tokens,
}
: undefined,
}
})
const audioTranscriptions = Effect.fn("KiloGatewayHttpApi.audioTranscriptions")(function* (ctx: {
payload: typeof AudioTranscriptionsBody.Type
}) {
@@ -322,6 +400,7 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo",
.handle("profile", profile)
.handle("modes", modes)
.handle("fim", fim)
.handle("edit", edit)
.handle("audioTranscriptions", audioTranscriptions)
.handle("notifications", notifications)
.handle("organization", organization)
+62
View File
@@ -97,6 +97,8 @@ import type {
KilocodeSessionImportProjectResponses,
KilocodeSessionImportSessionErrors,
KilocodeSessionImportSessionResponses,
KiloEditErrors,
KiloEditResponses,
KiloFimErrors,
KiloFimResponses,
KiloModesResponses,
@@ -5797,6 +5799,66 @@ export class Kilo extends HeyApiClient {
})
}
/**
* Next Edit completion
*
* Proxy a Mercury-style Next Edit request. The user supplies the already-templated sentinel-tagged prompt in `content`; the gateway forwards to the upstream edit endpoint (currently Inception's /v1/edit/completions) and returns the unwrapped reply.
*/
public edit<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
provider?: string
model?: string
maxTokens?: number
currentFilePath?: string
currentFileContent?: string
cursorLine?: number
cursorCharacter?: number
editableRegionStartLine?: number
editableRegionEndLine?: number
recentlyViewedSnippets?: Array<{
filepath: string
content: string
}>
editDiffHistory?: Array<string>
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "provider" },
{ in: "body", key: "model" },
{ in: "body", key: "maxTokens" },
{ in: "body", key: "currentFilePath" },
{ in: "body", key: "currentFileContent" },
{ in: "body", key: "cursorLine" },
{ in: "body", key: "cursorCharacter" },
{ in: "body", key: "editableRegionStartLine" },
{ in: "body", key: "editableRegionEndLine" },
{ in: "body", key: "recentlyViewedSnippets" },
{ in: "body", key: "editDiffHistory" },
],
},
],
)
return (options?.client ?? this.client).post<KiloEditResponses, KiloEditErrors, ThrowOnError>({
url: "/kilo/edit",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Get Kilo notifications
*
+150 -101
View File
@@ -8,16 +8,18 @@ export type Event =
| EventServerConnected
| EventGlobalDisposed
| EventGlobalConfigUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow1
| EventTuiSessionSelect
| EventKilocodeAgentManagerStart
| EventIndexingStatus
| EventServerInstanceDisposed
| EventLspClientDiagnostics
| EventLspUpdated
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow1
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventSessionNetworkAsked
@@ -46,7 +48,6 @@ export type Event =
| EventSessionCompacted
| EventCommandExecuted
| EventProjectUpdated
| EventKilocodeAgentManagerStart
| EventVcsBranchUpdated
| EventKiloSessionsRemoteStatusChanged
| EventWorkspaceReady
@@ -91,7 +92,6 @@ export type Event =
| EventSessionNextCompactionStarted
| EventSessionNextCompactionDelta
| EventSessionNextCompactionEnded
| EventIndexingStatus
export type OAuth = {
type: "oauth"
@@ -118,6 +118,71 @@ export type WellKnownAuth = {
export type Auth = OAuth | ApiAuth | WellKnownAuth
export type EventTuiPromptAppend = {
id: string
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
id: string
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
id: string
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}
export type EventTuiSessionSelect = {
id: string
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby"
export type IndexingStatus = {
state: IndexingStatusState
message: string
processedFiles: number
totalFiles: number
percent: number
}
export type QuestionOption = {
/**
* Display text (1-5 words, concise)
@@ -180,61 +245,6 @@ export type QuestionRejected = {
requestID: string
}
export type EventTuiPromptAppend = {
id: string
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
id: string
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
id: string
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}
export type EventTuiSessionSelect = {
id: string
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type SessionNetworkWait = {
id: string
sessionID: string
@@ -857,16 +867,6 @@ export type Prompt = {
agents?: Array<PromptAgentAttachment>
}
export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby"
export type IndexingStatus = {
state: IndexingStatusState
message: string
processedFiles: number
totalFiles: number
percent: number
}
export type GlobalEvent = {
directory: string
project?: string
@@ -875,16 +875,18 @@ export type GlobalEvent = {
| EventServerConnected
| EventGlobalDisposed
| EventGlobalConfigUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventKilocodeAgentManagerStart
| EventIndexingStatus
| EventServerInstanceDisposed
| EventLspClientDiagnostics
| EventLspUpdated
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventTuiSessionSelect
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventSessionNetworkAsked
@@ -913,7 +915,6 @@ export type GlobalEvent = {
| EventSessionCompacted
| EventCommandExecuted
| EventProjectUpdated
| EventKilocodeAgentManagerStart
| EventVcsBranchUpdated
| EventKiloSessionsRemoteStatusChanged
| EventWorkspaceReady
@@ -958,7 +959,6 @@ export type GlobalEvent = {
| EventSessionNextCompactionStarted
| EventSessionNextCompactionDelta
| EventSessionNextCompactionEnded
| EventIndexingStatus
| SyncEventMessageUpdated
| SyncEventMessageRemoved
| SyncEventMessagePartUpdated
@@ -2544,6 +2544,30 @@ export type EventGlobalConfigUpdated = {
}
}
export type EventKilocodeAgentManagerStart = {
id: string
type: "kilocode.agent_manager.start"
properties: {
requestID: string
sessionID: string
mode: "worktree" | "local"
versions?: boolean
tasks: Array<{
prompt?: string
name?: string
branchName?: string
}>
}
}
export type EventIndexingStatus = {
id: string
type: "indexing.status"
properties: {
status: IndexingStatus
}
}
export type EventServerInstanceDisposed = {
id: string
type: "server.instance.disposed"
@@ -2845,22 +2869,6 @@ export type EventProjectUpdated = {
properties: Project
}
export type EventKilocodeAgentManagerStart = {
id: string
type: "kilocode.agent_manager.start"
properties: {
requestID: string
sessionID: string
mode: "worktree" | "local"
versions?: boolean
tasks: Array<{
prompt?: string
name?: string
branchName?: string
}>
}
}
export type EventVcsBranchUpdated = {
id: string
type: "vcs.branch.updated"
@@ -3387,14 +3395,6 @@ export type EventSessionNextCompactionEnded = {
}
}
export type EventIndexingStatus = {
id: string
type: "indexing.status"
properties: {
status: IndexingStatus
}
}
export type SessionInfo = {
id: string
parentID?: string
@@ -7780,6 +7780,55 @@ export type KiloFimResponses = {
export type KiloFimResponse = KiloFimResponses[keyof KiloFimResponses]
export type KiloEditData = {
body?: {
provider?: string
model?: string
maxTokens?: number
currentFilePath: string
currentFileContent: string
cursorLine: number
cursorCharacter: number
editableRegionStartLine: number
editableRegionEndLine: number
recentlyViewedSnippets: Array<{
filepath: string
content: string
}>
editDiffHistory: Array<string>
}
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/kilo/edit"
}
export type KiloEditErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type KiloEditError = KiloEditErrors[keyof KiloEditErrors]
export type KiloEditResponses = {
/**
* Next Edit completion
*/
200: {
content: string
usage?: {
prompt_tokens?: number
completion_tokens?: number
}
}
}
export type KiloEditResponse = KiloEditResponses[keyof KiloEditResponses]
export type KiloAudioTranscriptionsData = {
body?: {
model: string
+3
View File
@@ -24,6 +24,9 @@
"outputs": [".artifacts/unit/junit.xml"],
"passThroughEnv": ["*"]
},
"@kilocode/kilo-gateway#test:ci": {
"outputs": [".artifacts/unit/junit.xml"]
},
"@kilocode/kilo-docs#build": {
"dependsOn": ["^build"],
"outputs": [".next/**"],