mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge branch 'main' into dog-hydrogen
This commit is contained in:
@@ -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)!
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Using xAI Grok with Kilo Code"
|
||||
description: "Connect xAI's Grok models to Kilo Code. Use a SuperGrok subscription via OAuth or a paid API key. Guide to setup in VS Code and the CLI."
|
||||
description: "Connect xAI's Grok models to Kilo Code. Use a SuperGrok or X Premium subscription via OAuth or a paid API key. Guide to setup in VS Code and the CLI."
|
||||
sidebar_label: xAI (Grok)
|
||||
---
|
||||
|
||||
@@ -12,33 +12,33 @@ xAI is the company behind Grok, a large language model known for its conversatio
|
||||
|
||||
Kilo Code supports two ways to connect xAI:
|
||||
|
||||
- **SuperGrok subscription (OAuth):** If you subscribe to SuperGrok, you can sign in with OAuth — no separate API key or pay-as-you-go charges required.
|
||||
- **SuperGrok or X Premium subscription (OAuth):** If you subscribe to SuperGrok or X Premium, you can sign in with OAuth — no separate API key or pay-as-you-go charges required.
|
||||
- **API key:** For pay-as-you-go access via the xAI API.
|
||||
|
||||
---
|
||||
|
||||
## Option 1: SuperGrok Subscription (OAuth)
|
||||
## Option 1: SuperGrok or X Premium Subscription (OAuth)
|
||||
|
||||
If you have an active [SuperGrok subscription](https://x.ai/grok), you can authenticate with xAI using OAuth and use Grok models directly without needing a separate API key.
|
||||
If you have an active [SuperGrok or X Premium subscription](https://x.ai/grok), you can authenticate with xAI using OAuth and use Grok models directly without needing a separate API key.
|
||||
|
||||
### Why use SuperGrok?
|
||||
### Why use SuperGrok or X Premium?
|
||||
|
||||
- **No API billing:** Usage counts against your SuperGrok subscription, not a pay-per-token API account.
|
||||
- **No API billing:** Usage counts against your subscription, not a pay-per-token API account.
|
||||
- **OAuth login — no API keys:** Sign in through your browser and Kilo Code handles token management automatically.
|
||||
- **Automatic token refresh:** Kilo Code refreshes your access token in the background so long-running sessions stay authenticated.
|
||||
|
||||
{% callout type="note" %}
|
||||
SuperGrok subscription access works with Kilo Code's core functionality (VS Code extension and CLI). For cloud features such as Cloud Agents or KiloClaw, use the [Kilo Gateway](/docs/gateway) — the Gateway supports xAI via [BYOK](/docs/getting-started/byok) with an API key (OAuth/SuperGrok is not supported through the Gateway).
|
||||
SuperGrok and X Premium subscription access works with Kilo Code's core functionality (VS Code extension and CLI). For cloud features such as Cloud Agents or KiloClaw, use the [Kilo Gateway](/docs/gateway) — the Gateway supports xAI via [BYOK](/docs/getting-started/byok) with an API key (OAuth and subscription-based access are not supported through the Gateway).
|
||||
{% /callout %}
|
||||
|
||||
### Setup with SuperGrok
|
||||
### Setup with SuperGrok / X Premium
|
||||
|
||||
{% tabs %}
|
||||
{% tab label="VSCode (Legacy)" %}
|
||||
|
||||
1. Open Kilo Code settings (click the gear icon {% codicon name="gear" /%} in the Kilo Code panel).
|
||||
2. In **API Provider**, select **xAI**.
|
||||
3. Click **Sign in with xAI (SuperGrok)**.
|
||||
3. Click **Sign in with xAI (SuperGrok / X Premium)**.
|
||||
4. Complete the authorization flow in your browser.
|
||||
5. Back in Kilo Code settings, select your desired Grok model.
|
||||
6. Save.
|
||||
@@ -46,22 +46,22 @@ SuperGrok subscription access works with Kilo Code's core functionality (VS Code
|
||||
{% /tab %}
|
||||
{% tab label="VSCode" %}
|
||||
|
||||
Open **Settings** (gear icon) and go to the **Providers** tab. Click **Show more providers**, then search for or select **xAI**. Choose the **xAI Grok OAuth (SuperGrok Subscription)** sign-in option and complete the OAuth flow in your browser.
|
||||
Open **Settings** (gear icon) and go to the **Providers** tab. Click **Show more providers**, then search for or select **xAI**. Choose the **xAI Grok OAuth (SuperGrok / X Premium)** sign-in option and complete the OAuth flow in your browser.
|
||||
|
||||
For headless or remote environments (VPS, SSH, Docker, WSL) where a browser redirect to `127.0.0.1` is not reachable, choose **xAI Grok OAuth (Headless / Remote / VPS)** instead. You will be shown a short code to enter at a URL you open on any device with a browser.
|
||||
|
||||
{% /tab %}
|
||||
{% tab label="CLI" %}
|
||||
|
||||
Run the auth command and follow the xAI SuperGrok sign-in flow:
|
||||
Run the auth command and follow the xAI sign-in flow:
|
||||
|
||||
```bash
|
||||
kilo auth login --provider xai
|
||||
```
|
||||
|
||||
Kilo Code offers two OAuth methods at the prompt:
|
||||
Kilo Code offers three methods at the prompt:
|
||||
|
||||
- **xAI Grok OAuth (SuperGrok Subscription)** — opens `https://auth.x.ai` in your browser for a standard PKCE OAuth flow. Best for local desktop environments.
|
||||
- **xAI Grok OAuth (SuperGrok / X Premium)** — opens `https://auth.x.ai` in your browser for a standard PKCE OAuth flow. Best for local desktop environments.
|
||||
- **xAI Grok OAuth (Headless / Remote / VPS)** — uses the RFC 8628 device-code flow. The CLI displays a short code and a URL; open the URL on any device with a browser, enter the code, and the CLI completes the login. Use this when running on a VPS, behind SSH, inside Docker, WSL, or CI where `127.0.0.1:56121` is not accessible from your browser.
|
||||
- **Manually enter API Key** — fall back to a standard API key if you prefer.
|
||||
|
||||
@@ -76,9 +76,9 @@ Then set your default model:
|
||||
{% /tab %}
|
||||
{% /tabs %}
|
||||
|
||||
### Tips for SuperGrok
|
||||
### Tips for SuperGrok and X Premium
|
||||
|
||||
- **Subscription required:** You need an active SuperGrok subscription. This option will not work with a free xAI account.
|
||||
- **Subscription required:** You need an active SuperGrok or X Premium subscription. This option will not work with a free xAI account.
|
||||
- **Sign out:** To disconnect in VS Code, use the "Disconnect" button in the provider settings. In the CLI, run `kilo auth logout` and choose xAI.
|
||||
- **Port 56121:** The browser OAuth flow (PKCE) starts a short-lived local server on `127.0.0.1:56121` to receive the OAuth callback. If another application is already using that port, use the headless device-code method instead.
|
||||
- **Token rotation:** xAI rotates refresh tokens on each use. Kilo Code persists the latest tokens automatically. If you run Kilo Code from multiple processes simultaneously, the first refresh can invalidate the other process's token — re-run `kilo auth login --provider xai` to restore the session.
|
||||
@@ -87,7 +87,7 @@ Then set your default model:
|
||||
|
||||
## Option 2: API Key
|
||||
|
||||
If you prefer pay-as-you-go access or do not have a SuperGrok subscription, you can use an xAI API key.
|
||||
If you prefer pay-as-you-go access or do not have a SuperGrok or X Premium subscription, you can use an xAI API key.
|
||||
|
||||
### Getting an API Key
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.artifacts
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
+392
@@ -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
|
||||
}
|
||||
+352
@@ -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: 3–5 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)
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/**"],
|
||||
|
||||
Reference in New Issue
Block a user