diff --git a/.changeset/cmdv-image-paste-macos.md b/.changeset/cmdv-image-paste-macos.md new file mode 100644 index 00000000000..778e74b47b4 --- /dev/null +++ b/.changeset/cmdv-image-paste-macos.md @@ -0,0 +1,9 @@ +--- +"kilo-code": patch +--- + +Support Cmd+V for pasting images on macOS in VSCode terminal + +- Detect empty bracketed paste (when clipboard contains image instead of text) +- Trigger clipboard image check on empty paste or paste timeout +- Add Cmd+V (meta key) support alongside Ctrl+V for image paste diff --git a/.changeset/crisp-rabbits-lick.md b/.changeset/crisp-rabbits-lick.md new file mode 100644 index 00000000000..7d89183c0d4 --- /dev/null +++ b/.changeset/crisp-rabbits-lick.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Faster autocomplete when using the Mistral provider diff --git a/.changeset/fix-vscode-paste-truncation.md b/.changeset/fix-vscode-paste-truncation.md new file mode 100644 index 00000000000..36a1c97f667 --- /dev/null +++ b/.changeset/fix-vscode-paste-truncation.md @@ -0,0 +1,9 @@ +--- +"kilo-code": patch +--- + +Fix paste truncation in VSCode terminal + +- Prevent React StrictMode cleanup from interrupting paste operations +- Remove `completePaste()` and `clearBuffers()` from useEffect cleanup +- Paste buffer refs now persist across React re-mounts and flush properly when paste end marker is received diff --git a/.changeset/new-taxes-accept.md b/.changeset/new-taxes-accept.md new file mode 100644 index 00000000000..d9248a54c67 --- /dev/null +++ b/.changeset/new-taxes-accept.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Disable structured outputs for Anthropic models, because the tool schema doesn't yet support it diff --git a/.changeset/smooth-wombats-stand.md b/.changeset/smooth-wombats-stand.md new file mode 100644 index 00000000000..b779d265825 --- /dev/null +++ b/.changeset/smooth-wombats-stand.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Filter unhelpful suggestions in chat autocomplete diff --git a/.roo/rules-translate/AGENTS.md b/.kilocode/skills/translation/SKILL.md similarity index 91% rename from .roo/rules-translate/AGENTS.md rename to .kilocode/skills/translation/SKILL.md index 0ef1f2cf80e..ee8d6bf2cbc 100644 --- a/.roo/rules-translate/AGENTS.md +++ b/.kilocode/skills/translation/SKILL.md @@ -1,17 +1,13 @@ -# AGENTS.md - -This file provides guidance to agents when working with code in this repository in Translate mode. - -## Workflow - -This workflow requires Orchestrator mode. +--- +name: translation +description: Guidelines for translating and localizing the Kilo Code extension, including language-specific rules for German, Simplified Chinese, and Traditional Chinese. +--- -Execute `node scripts/find-missing-translations.js` in Code mode to find all missing translations. +# Translation Guidelines -For each language that is missing translations: +This file provides guidance to agents when working with translations in this repository. -- For each JSON file that is missing translations: - - Start a separate subtask in Translate mode for this language and JSON file to add the missing translations. Do not try to process mutliple languages or JSON files in one subtask. +For the translation workflow, use the `/add-missing-translations` command or see `.kilocode/workflows/add-missing-translations.md`. --- @@ -311,42 +307,13 @@ For each language that is missing translations: ### Common Patterns -```markdown -<<<<<<< BEFORE -"dragFiles": "按住shift拖动文件" -======= -"dragFiles": "Shift+拖拽文件" - -> > > > > > > AFTER - -<<<<<<< BEFORE -"description": "启用后,Kilo Code 将能够与 MCP 服务器交互以获取高级功能。" -======= -"description": "启用后 Kilo Code 可与 MCP 服务交互获取高级功能。" - -> > > > > > > AFTER - -<<<<<<< BEFORE -"cannotUndo": "此操作无法撤消。" -======= -"cannotUndo": "此操作不可逆。" - -> > > > > > > AFTER - -<<<<<<< BEFORE -"hold shift to drag in files" → "按住shift拖动文件" -======= -"hold shift to drag in files" → "Shift+拖拽文件" - -> > > > > > > AFTER - -<<<<<<< BEFORE -"Double click to edit" → "双击进行编辑" -======= -"Double click to edit" → "双击编辑" - -> > > > > > > AFTER -``` +| Original | Avoid | Preferred | +| ------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------- | +| `"dragFiles"` | `"按住shift拖动文件"` | `"Shift+拖拽文件"` | +| `"description"` | `"启用后,Kilo Code 将能够与 MCP 服务器交互以获取高级功能。"` | `"启用后 Kilo Code 可与 MCP 服务交互获取高级功能。"` | +| `"cannotUndo"` | `"此操作无法撤消。"` | `"此操作不可逆。"` | +| `"hold shift to drag in files"` | `"按住shift拖动文件"` | `"Shift+拖拽文件"` | +| `"Double click to edit"` | `"双击进行编辑"` | `"双击编辑"` | ### Common Pitfalls diff --git a/.kilocode/workflows/add-missing-translations.md b/.kilocode/workflows/add-missing-translations.md new file mode 100644 index 00000000000..a6ca0dc8453 --- /dev/null +++ b/.kilocode/workflows/add-missing-translations.md @@ -0,0 +1,22 @@ +# Add missing translations + +This workflow requires Orchestrator mode. + +Execute `node scripts/find-missing-translations.js` in Code mode to find all missing translations. + +For each language that is missing translations: + +- For each JSON file that is missing translations: + - Start a separate subtask in Translate mode for this language and JSON file to add the missing translations. Do not try to process multiple languages or JSON files in one subtask. + +## Translation Guidelines + +When translating, follow these key rules: + +1. **Supported Languages**: ar, ca, cs, de, en, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, th, tr, uk, vi, zh-CN, zh-TW +2. **Voice**: Always use informal speech (e.g., "du" not "Sie" in German) +3. **Technical Terms**: Don't translate "token", "API", "prompt" and domain-specific technical terms +4. **Placeholders**: Keep `{{variable}}` placeholders exactly as in the English source +5. **Validation**: Run `node scripts/find-missing-translations.js` to validate changes + +For comprehensive translation guidelines including language-specific rules, see `.kilocode/skills/translation/SKILL.md`. diff --git a/AGENTS.md b/AGENTS.md index 7cc4a4fb167..da2137db93d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,11 +2,13 @@ Kilo Code is an open source AI coding agent for VS Code that generates code from natural language, automates tasks, and supports 500+ AI models. -## Mode-Specific Rules +## Skills -For mode-specific guidance, see the following files: +- **Translation**: `.kilocode/skills/translation/SKILL.md` - Translation and localization guidelines -- **Translate mode**: `.roo/rules-translate/AGENTS.md` - Translation and localization guidelines +## Workflows + +- **Add Missing Translations**: `.kilocode/workflows/add-missing-translations.md` - Run `/add-missing-translations` to find and fix missing translations ## Changesets diff --git a/cli/src/state/atoms/keyboard.ts b/cli/src/state/atoms/keyboard.ts index 166e4767583..31bd9529d3a 100644 --- a/cli/src/state/atoms/keyboard.ts +++ b/cli/src/state/atoms/keyboard.ts @@ -889,10 +889,10 @@ function handleTextInputKeys(get: Getter, set: Setter, key: Key) { } function handleGlobalHotkeys(get: Getter, set: Setter, key: Key): boolean { - // Debug logging for key detection - if (key.ctrl || key.sequence === "\x16") { + // Debug logging for key detection (Ctrl or Meta/Cmd keys) + if (key.ctrl || key.meta || key.sequence === "\x16") { logs.debug( - `Key detected: name=${key.name}, ctrl=${key.ctrl}, meta=${key.meta}, sequence=${JSON.stringify(key.sequence)}`, + `Key detected: name=${key.name}, ctrl=${key.ctrl}, meta=${key.meta}, shift=${key.shift}, paste=${key.paste}, sequence=${JSON.stringify(key.sequence)}`, "clipboard", ) } @@ -916,9 +916,9 @@ function handleGlobalHotkeys(get: Getter, set: Setter, key: Key): boolean { } break case "v": - // Ctrl+V - check for clipboard image - if (key.ctrl) { - logs.debug("Detected Ctrl+V via key.name", "clipboard") + // Ctrl+V or Cmd+V (macOS) - check for clipboard image + if (key.ctrl || key.meta) { + logs.debug(`Detected ${key.meta ? "Cmd" : "Ctrl"}+V via key.name`, "clipboard") // Handle clipboard image paste asynchronously handleClipboardImagePaste(get, set).catch((err) => logs.error("Unhandled clipboard paste error", "clipboard", { error: err }), @@ -977,10 +977,19 @@ function handleGlobalHotkeys(get: Getter, set: Setter, key: Key): boolean { } /** - * Handle clipboard image paste (Ctrl+V) + * Atom to trigger clipboard image paste from external components (e.g., KeyboardProvider) + * This is used when a paste timeout occurs (e.g., Cmd+V with image in clipboard) + */ +export const triggerClipboardImagePasteAtom = atom(null, async (get, set, fallbackText?: string) => { + await handleClipboardImagePaste(get, set, fallbackText) +}) + +/** + * Handle clipboard image paste (Ctrl+V or Cmd+V on macOS) * Saves clipboard image to a temp file and inserts @path reference into text buffer + * If fallbackText is provided and no image is found, broadcasts the fallback text as a paste event */ -async function handleClipboardImagePaste(get: Getter, set: Setter): Promise { +async function handleClipboardImagePaste(get: Getter, set: Setter, fallbackText?: string): Promise { logs.debug("handleClipboardImagePaste called", "clipboard") try { // Check if clipboard has an image @@ -988,8 +997,19 @@ async function handleClipboardImagePaste(get: Getter, set: Setter): Promise { const currentBuffer = pasteBufferRef.current - if (isPasteRef.current && currentBuffer) { + const wasPasting = isPasteRef.current + + // Reset paste state + setPasteMode(false) + isPasteRef.current = false + pasteBufferRef.current = "" + + if (wasPasting) { // Normalize line endings: convert \r\n and \r to \n // This handles different line ending formats from various terminals/platforms - const normalizedBuffer = currentBuffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n") - - broadcastKey(createPasteKey(normalizedBuffer)) - setPasteMode(false) - isPasteRef.current = false - pasteBufferRef.current = "" + const normalizedBuffer = currentBuffer ? currentBuffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n") : "" + // Always check clipboard for image first (prioritize image over text) + // If no image found, the fallback text will be used + // This handles: Cmd+V with image file copied from Finder (terminal sends filename as text) + triggerClipboardImagePaste(normalizedBuffer || undefined) } - }, [broadcastKey, setPasteMode]) + }, [setPasteMode, triggerClipboardImagePaste]) useEffect(() => { // Save original raw mode state @@ -414,9 +421,10 @@ export function KeyboardProvider({ children, config = {} }: KeyboardProviderProp // Clear timers clearBackslashTimer() - // Flush any pending buffers - completePaste() - clearBuffers() + // DON'T flush paste buffers here - React StrictMode causes re-mounts + // that would interrupt an in-progress paste operation. + // The paste buffer refs persist across re-mounts and will be + // properly flushed when the paste end marker is received. } }, [ stdin, @@ -431,6 +439,7 @@ export function KeyboardProvider({ children, config = {} }: KeyboardProviderProp setKittyProtocol, pasteBuffer, kittyBuffer, + triggerClipboardImagePaste, isKittyEnabled, isDebugEnabled, completePaste, diff --git a/src/api/providers/__tests__/kilocode-openrouter.spec.ts b/src/api/providers/__tests__/kilocode-openrouter.spec.ts index 016eac8fb40..c1138019118 100644 --- a/src/api/providers/__tests__/kilocode-openrouter.spec.ts +++ b/src/api/providers/__tests__/kilocode-openrouter.spec.ts @@ -259,26 +259,6 @@ describe("KilocodeOpenrouterHandler", () => { expect(handler.supportsFim()).toBe(false) }) - it("completeFim handles errors correctly", async () => { - const handler = new KilocodeOpenrouterHandler({ - ...mockOptions, - kilocodeModel: "mistral/codestral-latest", - }) - - const mockResponse = { - ok: false, - status: 500, - statusText: "Internal Server Error", - text: vitest.fn().mockResolvedValue("Error details"), - } - - global.fetch = vitest.fn().mockResolvedValue(mockResponse) - - await expect(handler.completeFim("prefix", "suffix")).rejects.toThrow( - "FIM streaming failed: 500 Internal Server Error - Error details", - ) - }) - it("streamFim yields chunks correctly", async () => { const handler = new KilocodeOpenrouterHandler({ ...mockOptions, diff --git a/src/api/providers/__tests__/mistral-fim.spec.ts b/src/api/providers/__tests__/mistral-fim.spec.ts new file mode 100644 index 00000000000..b9ebade8fac --- /dev/null +++ b/src/api/providers/__tests__/mistral-fim.spec.ts @@ -0,0 +1,180 @@ +// kilocode_change - new file +// npx vitest run src/api/providers/__tests__/mistral-fim.spec.ts + +// Mock vscode first to avoid import errors +vitest.mock("vscode", () => ({})) + +import { MistralHandler } from "../mistral" +import { ApiHandlerOptions } from "../../../shared/api" +import { streamSse } from "../../../services/continuedev/core/fetch/stream" + +// Mock the stream module +vitest.mock("../../../services/continuedev/core/fetch/stream", () => ({ + streamSse: vitest.fn(), +})) + +// Mock delay +vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) })) + +describe("MistralHandler FIM support", () => { + const mockOptions: ApiHandlerOptions = { + mistralApiKey: "test-api-key", + apiModelId: "codestral-latest", + } + + beforeEach(() => vitest.clearAllMocks()) + + describe("supportsFim", () => { + it("returns true for codestral models", () => { + const handler = new MistralHandler({ + ...mockOptions, + apiModelId: "codestral-latest", + }) + + expect(handler.supportsFim()).toBe(true) + }) + + it("returns true for codestral-2405", () => { + const handler = new MistralHandler({ + ...mockOptions, + apiModelId: "codestral-2405", + }) + + expect(handler.supportsFim()).toBe(true) + }) + + it("returns false for non-codestral models", () => { + const handler = new MistralHandler({ + ...mockOptions, + apiModelId: "mistral-large-latest", + }) + + expect(handler.supportsFim()).toBe(false) + }) + + it("returns true when no model is specified (defaults to codestral-latest)", () => { + const handler = new MistralHandler({ + mistralApiKey: "test-api-key", + }) + + // Default model is codestral-latest, which supports FIM + expect(handler.supportsFim()).toBe(true) + }) + }) + + describe("streamFim", () => { + it("yields chunks correctly", async () => { + const handler = new MistralHandler({ + ...mockOptions, + apiModelId: "codestral-latest", + }) + + // Mock streamSse to return the expected data + ;(streamSse as any).mockImplementation(async function* () { + yield { choices: [{ delta: { content: "chunk1" } }] } + yield { choices: [{ delta: { content: "chunk2" } }] } + yield { choices: [{ delta: { content: "chunk3" } }] } + }) + + const mockResponse = { + ok: true, + status: 200, + statusText: "OK", + } as Response + + global.fetch = vitest.fn().mockResolvedValue(mockResponse) + + const chunks: string[] = [] + + for await (const chunk of handler.streamFim("prefix", "suffix")) { + chunks.push(chunk) + } + + expect(chunks).toEqual(["chunk1", "chunk2", "chunk3"]) + expect(streamSse).toHaveBeenCalledWith(mockResponse) + }) + + it("handles errors correctly", async () => { + const handler = new MistralHandler({ + ...mockOptions, + apiModelId: "codestral-latest", + }) + + const mockResponse = { + ok: false, + status: 400, + statusText: "Bad Request", + text: vitest.fn().mockResolvedValue("Invalid request"), + } + + global.fetch = vitest.fn().mockResolvedValue(mockResponse) + + const generator = handler.streamFim("prefix", "suffix") + await expect(generator.next()).rejects.toThrow("FIM streaming failed: 400 Bad Request - Invalid request") + }) + + it("uses correct endpoint for codestral models", async () => { + const handler = new MistralHandler({ + ...mockOptions, + apiModelId: "codestral-latest", + }) + + ;(streamSse as any).mockImplementation(async function* () { + yield { choices: [{ delta: { content: "test" } }] } + }) + + const mockResponse = { + ok: true, + status: 200, + statusText: "OK", + } as Response + + global.fetch = vitest.fn().mockResolvedValue(mockResponse) + + const generator = handler.streamFim("prefix", "suffix") + await generator.next() + + expect(global.fetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: "https://codestral.mistral.ai/v1/fim/completions", + }), + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer test-api-key", + }), + }), + ) + }) + + it("uses custom codestral URL when provided", async () => { + const handler = new MistralHandler({ + ...mockOptions, + apiModelId: "codestral-latest", + mistralCodestralUrl: "https://custom.codestral.url", + }) + + ;(streamSse as any).mockImplementation(async function* () { + yield { choices: [{ delta: { content: "test" } }] } + }) + + const mockResponse = { + ok: true, + status: 200, + statusText: "OK", + } as Response + + global.fetch = vitest.fn().mockResolvedValue(mockResponse) + + const generator = handler.streamFim("prefix", "suffix") + await generator.next() + + expect(global.fetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: "https://custom.codestral.url/v1/fim/completions", + }), + expect.any(Object), + ) + }) + }) +}) diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index 2dc6a3dc1cb..ee34fd4f52f 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -71,10 +71,6 @@ describe("OpenRouterHandler", () => { openRouterModelId: "anthropic/claude-sonnet-4", } - // kilocode_change start - const anthropicBetaHeaderValue = "fine-grained-tool-streaming-2025-05-14,structured-outputs-2025-11-13" - // kilocode_change end - beforeEach(() => vitest.clearAllMocks()) it("initializes with correct options", () => { @@ -208,13 +204,7 @@ describe("OpenRouterHandler", () => { top_p: undefined, transforms: ["middle-out"], }), - // kilocode_change start - expect.objectContaining({ - headers: expect.objectContaining({ - "x-anthropic-beta": anthropicBetaHeaderValue, - }), - }), - // kilocode_change end + { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }, ) }) @@ -239,16 +229,9 @@ describe("OpenRouterHandler", () => { await handler.createMessage("test", []).next() - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ transforms: ["middle-out"] }), - // kilocode_change start - expect.objectContaining({ - headers: expect.objectContaining({ - "x-anthropic-beta": anthropicBetaHeaderValue, - }), - }), - // kilocode_change end - ) + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ transforms: ["middle-out"] }), { + headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" }, + }) }) it("adds cache control for supported models", async () => { @@ -290,13 +273,7 @@ describe("OpenRouterHandler", () => { }), ]), }), - // kilocode_change start - expect.objectContaining({ - headers: expect.objectContaining({ - "x-anthropic-beta": anthropicBetaHeaderValue, - }), - }), - // kilocode_change end + { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }, ) }) @@ -537,13 +514,7 @@ describe("OpenRouterHandler", () => { messages: [{ role: "user", content: "test prompt" }], stream: false, }, - // kilocode_change start - expect.objectContaining({ - headers: expect.objectContaining({ - "x-anthropic-beta": anthropicBetaHeaderValue, - }), - }), - // kilocode_change end + { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }, ) }) diff --git a/src/api/providers/kilocode-openrouter.ts b/src/api/providers/kilocode-openrouter.ts index 6bcb55ffa27..a09e00e03ba 100644 --- a/src/api/providers/kilocode-openrouter.ts +++ b/src/api/providers/kilocode-openrouter.ts @@ -148,14 +148,6 @@ export class KilocodeOpenrouterHandler extends OpenRouterHandler { return modelId.includes("codestral") } - async completeFim(prefix: string, suffix: string, taskId?: string): Promise { - let result = "" - for await (const chunk of this.streamFim(prefix, suffix, taskId)) { - result += chunk - } - return result - } - async *streamFim( prefix: string, suffix: string, diff --git a/src/api/providers/kilocode/IFimProvider.ts b/src/api/providers/kilocode/IFimProvider.ts index 487301dc86b..dc964b79c48 100644 --- a/src/api/providers/kilocode/IFimProvider.ts +++ b/src/api/providers/kilocode/IFimProvider.ts @@ -13,15 +13,6 @@ export interface IFimProvider { */ supportsFim(): boolean - /** - * Complete code between a prefix and suffix (non-streaming) - * @param prefix - The code before the cursor/insertion point - * @param suffix - The code after the cursor/insertion point - * @param taskId - Optional task ID for tracking - * @returns The completed code string - */ - completeFim(prefix: string, suffix: string, taskId?: string): Promise - /** * Stream code completion between a prefix and suffix * @param prefix - The code before the cursor/insertion point diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index 96d2c332552..6ef99acd93f 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -11,6 +11,9 @@ import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +import { DEFAULT_HEADERS } from "./constants" // kilocode_change +import { streamSse } from "../../services/continuedev/core/fetch/stream" // kilocode_change +import type { CompletionUsage } from "./openrouter" // kilocode_change // Type helper to handle thinking chunks from Mistral API // The SDK includes ThinkChunk but TypeScript has trouble with the discriminated union @@ -209,4 +212,74 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand throw error } } + + // kilocode_change start + supportsFim(): boolean { + const modelId = this.options.apiModelId ?? mistralDefaultModelId + return modelId.startsWith("codestral-") + } + + async *streamFim( + prefix: string, + suffix: string, + _taskId?: string, + onUsage?: (usage: CompletionUsage) => void, + ): AsyncGenerator { + const { id: model, maxTokens } = this.getModel() + + // Get the base URL for the model + // copy pasted from constructor, be sure to keep in sync + const baseUrl = model.startsWith("codestral-") + ? this.options.mistralCodestralUrl || "https://codestral.mistral.ai" + : "https://api.mistral.ai" + + const endpoint = new URL("v1/fim/completions", baseUrl) + + const headers: Record = { + ...DEFAULT_HEADERS, + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${this.options.mistralApiKey}`, + } + + // temperature: 0.2 is mentioned as a sane example in mistral's docs + const temperature = 0.2 + const requestMaxTokens = 256 + + const response = await fetch(endpoint, { + method: "POST", + body: JSON.stringify({ + model, + prompt: prefix, + suffix, + max_tokens: Math.min(requestMaxTokens, maxTokens ?? requestMaxTokens), + temperature, + stream: true, + }), + headers, + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`FIM streaming failed: ${response.status} ${response.statusText} - ${errorText}`) + } + + for await (const data of streamSse(response)) { + const content = data.choices?.[0]?.delta?.content + if (content) { + yield content + } + + // Call usage callback when available + // Note: Mistral FIM API returns usage in the final chunk with prompt_tokens and completion_tokens + if (data.usage && onUsage) { + onUsage({ + prompt_tokens: data.usage.prompt_tokens, + completion_tokens: data.usage.completion_tokens, + total_tokens: data.usage.total_tokens, + }) + } + } + } + // kilocode_change end } diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 97a7fef3ac4..f51eeb05134 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -315,8 +315,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH // kilocode_change start const requestOptions = this.customRequestOptions(metadata) ?? { headers: {} } if (modelId.startsWith("anthropic/")) { - requestOptions.headers["x-anthropic-beta"] = - "fine-grained-tool-streaming-2025-05-14,structured-outputs-2025-11-13" + requestOptions.headers["x-anthropic-beta"] = "fine-grained-tool-streaming-2025-05-14" } // kilocode_change end @@ -566,8 +565,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH // kilocode_change start const requestOptions = this.customRequestOptions() ?? { headers: {} } if (modelId.startsWith("anthropic/")) { - requestOptions.headers["x-anthropic-beta"] = - "fine-grained-tool-streaming-2025-05-14,structured-outputs-2025-11-13" + requestOptions.headers["x-anthropic-beta"] = "fine-grained-tool-streaming-2025-05-14" } // kilocode_change end diff --git a/src/services/ghost/GhostModel.ts b/src/services/ghost/GhostModel.ts index 50019360dd2..3e9a74ffea4 100644 --- a/src/services/ghost/GhostModel.ts +++ b/src/services/ghost/GhostModel.ts @@ -1,3 +1,4 @@ +// kilocode_change new file import { modelIdKeysByProvider, ProviderName } from "@roo-code/types" import { ApiHandler, buildApiHandler } from "../../api" import { ProviderSettingsManager } from "../../core/config/ProviderSettingsManager" @@ -9,6 +10,33 @@ import { KilocodeOpenrouterHandler } from "../../api/providers/kilocode-openrout import { PROVIDERS } from "../../../webview-ui/src/components/settings/constants" import { ResponseMetaData } from "./types" +/** + * Interface for handlers that support FIM (Fill-in-the-Middle) completions. + * Uses duck typing - any handler implementing these methods can be used for FIM. + */ +interface FimCapableHandler { + supportsFim(): boolean + streamFim( + prefix: string, + suffix: string, + taskId?: string, + onUsage?: (usage: CompletionUsage) => void, + ): AsyncGenerator + getModel(): { id: string; info: any; maxTokens?: number } + getTotalCost?(usage: CompletionUsage): number +} + +/** + * Type guard to check if a handler supports FIM operations using duck typing. + */ +function isFimCapable(handler: ApiHandler): handler is ApiHandler & FimCapableHandler { + return ( + typeof (handler as any).supportsFim === "function" && + typeof (handler as any).streamFim === "function" && + (handler as any).supportsFim() === true + ) +} + // Convert PROVIDERS array to a lookup map for display names const PROVIDER_DISPLAY_NAMES = Object.fromEntries(PROVIDERS.map(({ value, label }) => [value, label])) as Record< ProviderName, @@ -92,15 +120,13 @@ export class GhostModel { return false } - if (this.apiHandler instanceof KilocodeOpenrouterHandler) { - return this.apiHandler.supportsFim() - } - - return false + // Use duck typing to check if the handler supports FIM + return isFimCapable(this.apiHandler) } /** - * Generate FIM completion using the FIM API endpoint + * Generate FIM completion using the FIM API endpoint. + * Uses duck typing to support any handler that implements supportsFim() and streamFim(). */ public async generateFimResponse( prefix: string, @@ -113,12 +139,8 @@ export class GhostModel { throw new Error("API handler is not initialized. Please check your configuration.") } - if (!(this.apiHandler instanceof KilocodeOpenrouterHandler)) { - throw new Error("FIM is only supported for KiloCode provider") - } - - if (!this.apiHandler.supportsFim()) { - throw new Error("Current model does not support FIM completions") + if (!isFimCapable(this.apiHandler)) { + throw new Error("Current provider/model does not support FIM completions") } console.log("USED MODEL (FIM)", this.apiHandler.getModel()) @@ -131,7 +153,9 @@ export class GhostModel { onChunk(chunk) } - const cost = usage ? this.apiHandler.getTotalCost(usage) : 0 + // Calculate cost if the handler supports it (duck typing) + const cost = + usage && typeof this.apiHandler.getTotalCost === "function" ? this.apiHandler.getTotalCost(usage) : 0 const inputTokens = usage?.prompt_tokens ?? 0 const outputTokens = usage?.completion_tokens ?? 0 const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens ?? 0 diff --git a/src/services/ghost/chat-autocomplete/ChatTextAreaAutocomplete.ts b/src/services/ghost/chat-autocomplete/ChatTextAreaAutocomplete.ts index 334c426392f..4e961d959b8 100644 --- a/src/services/ghost/chat-autocomplete/ChatTextAreaAutocomplete.ts +++ b/src/services/ghost/chat-autocomplete/ChatTextAreaAutocomplete.ts @@ -2,13 +2,10 @@ import * as vscode from "vscode" import { GhostModel } from "../GhostModel" import { ProviderSettingsManager } from "../../../core/config/ProviderSettingsManager" import { AutocompleteContext, VisibleCodeContext } from "../types" -import { ApiStreamChunk } from "../../../api/transform/stream" import { removePrefixOverlap } from "../../continuedev/core/autocomplete/postprocessing/removePrefixOverlap.js" import { AutocompleteTelemetry } from "../classic-auto-complete/AutocompleteTelemetry" +import { postprocessGhostSuggestion } from "../classic-auto-complete/uselessSuggestionFilter" -/** - * Service for providing FIM-based autocomplete suggestions in ChatTextArea - */ export class ChatTextAreaAutocomplete { private model: GhostModel private providerSettingsManager: ProviderSettingsManager @@ -24,14 +21,6 @@ export class ChatTextAreaAutocomplete { return this.model.reload(this.providerSettingsManager) } - /** - * Check if we can successfully make a FIM request. - * Validates that model is loaded, has valid API handler, and supports FIM. - */ - isFimAvailable(): boolean { - return this.model.hasValidCredentials() && this.model.supportsFim() - } - async getCompletion(userText: string, visibleCodeContext?: VisibleCodeContext): Promise<{ suggestion: string }> { const startTime = Date.now() @@ -147,9 +136,6 @@ TASK: Complete the user's message naturally. - Return ONLY the completion text (what comes next), no explanations.` } - /** - * Build the prefix for FIM completion with visible code context and additional sources - */ private async buildPrefix(userText: string, visibleCodeContext?: VisibleCodeContext): Promise { const contextParts: string[] = [] @@ -179,9 +165,6 @@ TASK: Complete the user's message naturally. return contextParts.join("\n") } - /** - * Get clipboard content for context - */ private async getClipboardContext(): Promise { try { const text = await vscode.env.clipboard.readText() @@ -195,51 +178,30 @@ TASK: Complete the user's message naturally. return null } - /** - * Clean the suggestion by removing any leading repetition of user text - * and filtering out unwanted patterns like comments - */ - private cleanSuggestion(suggestion: string, userText: string): string { - let cleaned = suggestion - - cleaned = removePrefixOverlap(cleaned, userText) - - const firstNewline = cleaned.indexOf("\n") - if (firstNewline !== -1) { - cleaned = cleaned.substring(0, firstNewline) - } - cleaned = cleaned.trimEnd() // Do NOT trim the end of the suggestion + public cleanSuggestion(suggestion: string, userText: string): string { + let cleaned = postprocessGhostSuggestion({ + suggestion: removePrefixOverlap(suggestion, userText), + prefix: userText, + suffix: "", // Chat textarea has no suffix + model: this.model.getModelName() ?? "unknown", + }) - // Filter out suggestions that start with comment patterns - // This happens because the context uses // prefixes for labels - if (this.isUnwantedSuggestion(cleaned)) { + if (cleaned === undefined) { return "" } - return cleaned - } - - /** - * Check if suggestion should be filtered out - */ - public isUnwantedSuggestion(suggestion: string): boolean { - // Filter comment-starting suggestions - if (suggestion.startsWith("//") || suggestion.startsWith("/*") || suggestion.startsWith("*")) { - return true - } - // Filter suggestions that look like code rather than natural language - // This includes preprocessor directives (#include) and markdown headers - // Chat is for natural language, not formatted documents - if (suggestion.startsWith("#")) { - return true + if (cleaned.match(/^(\/\/|\/\*|\*|#)/)) { + return "" } - // Filter suggestions that are just punctuation or whitespace - if (suggestion.length < 2 || /^[\s\p{P}]+$/u.test(suggestion)) { - return true + // Chat-specific: truncate at first newline for single-line suggestions + const firstNewline = cleaned.indexOf("\n") + if (firstNewline !== -1) { + cleaned = cleaned.substring(0, firstNewline) } + cleaned = cleaned.trimEnd() - return false + return cleaned } } diff --git a/src/services/ghost/chat-autocomplete/__tests__/ChatTextAreaAutocomplete.spec.ts b/src/services/ghost/chat-autocomplete/__tests__/ChatTextAreaAutocomplete.spec.ts index 26d1a26dff4..0d6bd41d254 100644 --- a/src/services/ghost/chat-autocomplete/__tests__/ChatTextAreaAutocomplete.spec.ts +++ b/src/services/ghost/chat-autocomplete/__tests__/ChatTextAreaAutocomplete.spec.ts @@ -45,46 +45,41 @@ describe("ChatTextAreaAutocomplete", () => { }) }) - describe("isFimAvailable", () => { - it("should return false when model is not loaded", () => { - const result = autocomplete.isFimAvailable() - expect(result).toBe(false) - }) - }) - - describe("isUnwantedSuggestion", () => { - it("should filter code patterns (comments, preprocessor, short/empty)", () => { - const filter = autocomplete.isUnwantedSuggestion.bind(autocomplete) - - // Comments - expect(filter("// comment")).toBe(true) - expect(filter("/* comment")).toBe(true) - expect(filter("*")).toBe(true) + describe("cleanSuggestion", () => { + it("should filter code patterns (comments, preprocessor)", () => { + // Comments - filtered by the regex check in cleanSuggestion + expect(autocomplete.cleanSuggestion("// comment", "")).toBe("") + expect(autocomplete.cleanSuggestion("/* comment", "")).toBe("") + expect(autocomplete.cleanSuggestion("* something", "")).toBe("") // Code patterns - expect(filter("#include")).toBe(true) - expect(filter("# Header")).toBe(true) + expect(autocomplete.cleanSuggestion("#include", "")).toBe("") + expect(autocomplete.cleanSuggestion("# Header", "")).toBe("") + }) - // Meaningless content - expect(filter("")).toBe(true) - expect(filter("a")).toBe(true) - expect(filter("...")).toBe(true) + it("should filter empty content", () => { + // Empty content is filtered by postprocessGhostSuggestion + expect(autocomplete.cleanSuggestion("", "")).toBe("") }) it("should accept natural language suggestions", () => { - const filter = autocomplete.isUnwantedSuggestion.bind(autocomplete) - - expect(filter("Hello world")).toBe(false) - expect(filter("Can you help me")).toBe(false) - expect(filter("test123")).toBe(false) - expect(filter("What's up?")).toBe(false) + expect(autocomplete.cleanSuggestion("Hello world", "")).toBe("Hello world") + expect(autocomplete.cleanSuggestion("Can you help me", "")).toBe("Can you help me") + expect(autocomplete.cleanSuggestion("test123", "")).toBe("test123") + expect(autocomplete.cleanSuggestion("What's up?", "")).toBe("What's up?") }) it("should accept symbols in middle of text", () => { - const filter = autocomplete.isUnwantedSuggestion.bind(autocomplete) + expect(autocomplete.cleanSuggestion("Text with # in middle", "")).toBe("Text with # in middle") + expect(autocomplete.cleanSuggestion("Hello // but not a comment", "")).toBe("Hello // but not a comment") + }) + + it("should truncate at first newline", () => { + expect(autocomplete.cleanSuggestion("First line\nSecond line", "")).toBe("First line") + }) - expect(filter("Text with # in middle")).toBe(false) - expect(filter("Hello // but not a comment")).toBe(false) + it("should remove prefix overlap", () => { + expect(autocomplete.cleanSuggestion("Hello world", "Hello ")).toBe("world") }) }) }) diff --git a/src/services/ghost/classic-auto-complete/__tests__/uselessSuggestionFilter.test.ts b/src/services/ghost/classic-auto-complete/__tests__/uselessSuggestionFilter.test.ts index d6b888b3cf3..b0ee68f4092 100644 --- a/src/services/ghost/classic-auto-complete/__tests__/uselessSuggestionFilter.test.ts +++ b/src/services/ghost/classic-auto-complete/__tests__/uselessSuggestionFilter.test.ts @@ -250,4 +250,22 @@ return 1 `), ).toBe(true) }) + + it("treats as duplication when suggestion repeats the same phrase from the prefix", () => { + // User types "We are going to start from" and suggestion repeats "the beginning. We are going to start from the beginning..." + expect( + isDuplication( + `We are going to start from <<>>`, + ), + ).toBe(true) + }) + + it("treats as duplication when suggestion ends with non-word characters but still has repetitive phrases", () => { + // Suggestion ends with "..." but the repeating phrase should still be detected + expect( + isDuplication( + `<<>>`, + ), + ).toBe(true) + }) }) diff --git a/src/services/ghost/classic-auto-complete/uselessSuggestionFilter.ts b/src/services/ghost/classic-auto-complete/uselessSuggestionFilter.ts index a32334e12ba..114b8d7a2b7 100644 --- a/src/services/ghost/classic-auto-complete/uselessSuggestionFilter.ts +++ b/src/services/ghost/classic-auto-complete/uselessSuggestionFilter.ts @@ -18,6 +18,11 @@ export function suggestionConsideredDuplication(params: AutocompleteSuggestion): return true } + // Check if the suggestion contains repetitive phrases that continue from the prefix + if (containsRepetitivePhraseFromPrefix(params)) { + return true + } + // When the suggestion isn't a full line or set of lines, normalize by including // the rest of the line in the prefix/suffix and check with the completed line(s) const normalized = normalizeToCompleteLine(params) @@ -58,6 +63,43 @@ function DuplicatesFromEdgeLines(params: AutocompleteSuggestion): boolean { return false } +/** + * Detects when a suggestion's tail is repeating itself - a common LLM failure mode. + * For example: "the beginning. We are going to start from the beginning. We are going to start from the beginning..." + * The suggestion gets stuck in a loop repeating the same phrase. + */ +function containsRepetitivePhraseFromPrefix(params: AutocompleteSuggestion): boolean { + const suggestion = params.suggestion + const phraseLength = 30 // Phrase length to check for repetition + const minRepetitions = 3 // Minimum number of repetitions to consider it repetitive + + // Only check suggestions that are long enough to contain repetition + if (suggestion.length < phraseLength * minRepetitions) { + return false + } + + // Strip non-word characters from the right before selecting the tail + // This handles cases like "...the beginning..." where trailing punctuation would break detection + const strippedSuggestion = suggestion.replace(/\W+$/, "") + + if (strippedSuggestion.length < phraseLength) { + return false + } + + // Extract a phrase from the end of the stripped suggestion + const phrase = strippedSuggestion.slice(-phraseLength) + + // Count how many times this phrase appears in the original suggestion + let count = 0 + let pos = 0 + while ((pos = suggestion.indexOf(phrase, pos)) !== -1) { + count++ + pos += phrase.length + } + + return count >= minRepetitions +} + /** * Normalizes partial-line suggestions by expanding them to the full current line: * (prefix line tail) + (suggestion first line) + (suffix line head).