diff --git a/bun.lock b/bun.lock
index 1d077eb4d..96fa01cab 100644
--- a/bun.lock
+++ b/bun.lock
@@ -750,7 +750,7 @@
"@tree-sitter-grammars/tree-sitter-markdown": ["@tree-sitter-grammars/tree-sitter-markdown@0.3.2", "", { "dependencies": { "node-addon-api": "^8.1.0", "node-gyp-build": "^4.8.1" }, "peerDependencies": { "tree-sitter": "^0.21.1" } }, "sha512-hQXCcDVvg2t4E8cn7zz6jjIBerzk9E9ZlHxJp5IrUOpY4s1YVpXJbMeWZks2/V7lmkPRnnkM8IrTbQ5ltwEOnA=="],
- "@tree-sitter-grammars/tree-sitter-vue": ["tree-sitter-vue@github:tree-sitter-grammars/tree-sitter-vue#ce8011a", { "dependencies": { "nan": "^2.18.0", "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4", "tree-sitter-html": "=0.23.2" } }, "tree-sitter-grammars-tree-sitter-vue-ce8011a"],
+ "@tree-sitter-grammars/tree-sitter-vue": ["tree-sitter-vue@github:tree-sitter-grammars/tree-sitter-vue#ce8011a", { "dependencies": { "nan": "^2.18.0", "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4", "tree-sitter-html": "=0.23.2" } }, "tree-sitter-grammars-tree-sitter-vue-ce8011a", "sha512-AmirHA7JluyVR8JF4Y15nOUvZlqxrt6r7fZVCEryv5aZzZ9D+AN3GTK1ga3abcPLZi9uHvTid9QWFPKYVcFMeg=="],
"@tree-sitter-grammars/tree-sitter-yaml": ["@tree-sitter-grammars/tree-sitter-yaml@0.7.1", "", { "dependencies": { "node-addon-api": "^8.3.1", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.4" }, "optionalPeers": ["tree-sitter"] }, "sha512-AynBwkIoQCTgjDR33bDUp9Mqq+YTco0is3n5hRApMqG9of/6A4eQsfC1/uSEeHSUyMQSYawcAWamsexnVpIP4Q=="],
@@ -1564,7 +1564,7 @@
"tree-sitter-dart": ["tree-sitter-dart@1.0.0", "", { "dependencies": { "nan": "^2.15.0" } }, "sha512-Ve5YMPJjjGW9LEsO+MngAOibQsw5obFp+bUT41pvwdcXWRwJImOWs3eaPi6AubEiBmc09qvhdvxeIXvxlhMnug=="],
- "tree-sitter-diff": ["tree-sitter-diff@github:the-mikedavis/tree-sitter-diff#2520c3f", { "dependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.1" }, "peerDependencies": { "tree-sitter": "^0.21.1" } }, "tree-sitter-grammars-tree-sitter-diff-2520c3f"],
+ "tree-sitter-diff": ["tree-sitter-diff@github:the-mikedavis/tree-sitter-diff#2520c3f", { "dependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.1" }, "peerDependencies": { "tree-sitter": "^0.21.1" } }, "tree-sitter-grammars-tree-sitter-diff-2520c3f", "sha512-UU1i69jdqh9PUJYiEkLo7h9Xjs+XWoYnN8P/n2OsKQgKVbOh+HQCyvRFYeCNIrxO7C5xu6btXLbsBy9W2Cue4w=="],
"tree-sitter-elisp": ["tree-sitter-elisp@1.6.1", "", { "dependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" } }, "sha512-ALJ50YOuqu2Z/qKyQMh3RT5OhQmTRc2K/4MZzPdd1eKyFdKPaYpG0ZmPyovNTXyW14Qn63SgY960el55wLwy8Q=="],
@@ -1592,7 +1592,7 @@
"tree-sitter-python": ["tree-sitter-python@0.23.6", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-yIM9z0oxKIxT7bAtPOhgoVl6gTXlmlIhue7liFT4oBPF/lha7Ha4dQBS82Av6hMMRZoVnFJI8M6mL+SwWoLD3A=="],
- "tree-sitter-rescript": ["tree-sitter-rescript@github:rescript-lang/tree-sitter-rescript#43c2f1f", { "dependencies": { "nan": "^2.15.0", "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "rescript-lang-tree-sitter-rescript-43c2f1f"],
+ "tree-sitter-rescript": ["tree-sitter-rescript@github:rescript-lang/tree-sitter-rescript#43c2f1f", { "dependencies": { "nan": "^2.15.0", "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "rescript-lang-tree-sitter-rescript-43c2f1f", "sha512-+Rd78bjD5vIfArmX5ppDPH5hcfxbcUpad/g6+xrhfVpERx8KgvJbBSitFmFEci76Oc3MgJMYTadE0SJgnXD+yg=="],
"tree-sitter-ruby": ["tree-sitter-ruby@0.23.1", "", { "dependencies": { "node-addon-api": "^8.2.2", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.21.1" }, "optionalPeers": ["tree-sitter"] }, "sha512-d9/RXgWjR6HanN7wTYhS5bpBQLz1VkH048Vm3CodPGyJVnamXMGb8oEhDypVCBq4QnHui9sTXuJBBP3WtCw5RA=="],
diff --git a/src/features/editor/components/editor.tsx b/src/features/editor/components/editor.tsx
index 1adf86d90..59f1f088f 100644
--- a/src/features/editor/components/editor.tsx
+++ b/src/features/editor/components/editor.tsx
@@ -153,7 +153,14 @@ export function Editor({
const vimModeEnabled = useSettingsStore((state) => state.settings.vimMode);
const setIsFindVisible = useUIState((state) => state.setIsFindVisible);
const aiCompletionEnabled = useSettingsStore((state) => state.settings.aiCompletion);
+ const aiAutocompleteProvider = useSettingsStore((state) => state.settings.aiAutocompleteProvider);
const aiAutocompleteModelId = useSettingsStore((state) => state.settings.aiAutocompleteModelId);
+ const aiAutocompleteCustomBaseUrl = useSettingsStore(
+ (state) => state.settings.aiAutocompleteCustomBaseUrl,
+ );
+ const aiAutocompleteCustomModelId = useSettingsStore(
+ (state) => state.settings.aiAutocompleteCustomModelId,
+ );
const inlineGitBlameEnabled = useSettingsStore((state) => state.settings.enableInlineGitBlame);
const gitGutterEnabled = useSettingsStore((state) => state.settings.enableGitGutter);
const vimMode = useVimStore.use.mode();
@@ -723,7 +730,10 @@ export function Editor({
useAutocomplete({
enabled: aiCompletionEnabled && !isPreviewMode && !readOnly,
- model: aiAutocompleteModelId,
+ provider: aiAutocompleteProvider,
+ model:
+ aiAutocompleteProvider === "custom" ? aiAutocompleteCustomModelId : aiAutocompleteModelId,
+ customBaseUrl: aiAutocompleteCustomBaseUrl,
filePath: filePath || null,
languageId: filePath ? getLanguageId(filePath) : null,
content,
@@ -1083,12 +1093,26 @@ export function Editor({
const lineText = lines[visualCursorLine] || "";
const cursorColumn = Math.min(cursorPosition.column, lineText.length);
+ const textAfterCursorOnLine = lineText.slice(cursorColumn);
+ if (textAfterCursorOnLine.trim().length > 0) return null;
+
const cursorX = getAccurateCursorX(lineText, cursorColumn, fontSize, fontFamily, tabSize);
+ const previewLines: Array<{ text: string; index: number }> = [];
+
+ for (const [index, text] of normalized.split("\n").entries()) {
+ if (index > 0 && lines[visualCursorLine + index]?.trim()) {
+ break;
+ }
+ previewLines.push({ text, index });
+ }
+
+ if (previewLines.every((line) => line.text.length === 0)) return null;
return {
- text: normalized,
+ lines: previewLines,
top: visualCursorLine * lineHeight + EDITOR_CONSTANTS.EDITOR_PADDING_TOP,
- left: cursorX + EDITOR_CONSTANTS.EDITOR_PADDING_LEFT,
+ firstLineLeft: cursorX + EDITOR_CONSTANTS.EDITOR_PADDING_LEFT,
+ continuationLeft: EDITOR_CONSTANTS.EDITOR_PADDING_LEFT,
};
}, [
autocompleteCompletion,
@@ -1330,7 +1354,7 @@ export function Editor({
style={{
position: "absolute",
top: `${inlineAutocompletePreview.top}px`,
- left: `${inlineAutocompletePreview.left}px`,
+ left: 0,
fontSize: `${fontSize}px`,
fontFamily,
lineHeight: `${lineHeight}px`,
@@ -1339,7 +1363,24 @@ export function Editor({
color: "var(--text-lighter, #94a3b8)",
}}
>
- {inlineAutocompletePreview.text}
+ {inlineAutocompletePreview.lines.map((line) => {
+ if (line.text.length === 0) return null;
+ return (
+
+ {line.text}
+
+ );
+ })}
)}
diff --git a/src/features/editor/hooks/use-autocomplete.ts b/src/features/editor/hooks/use-autocomplete.ts
index cdbbc61bb..d7885326f 100644
--- a/src/features/editor/hooks/use-autocomplete.ts
+++ b/src/features/editor/hooks/use-autocomplete.ts
@@ -7,7 +7,9 @@ import {
interface UseAutocompleteOptions {
enabled: boolean;
+ provider: "openrouter" | "custom";
model: string;
+ customBaseUrl: string;
filePath: string | null;
languageId: string | null;
content: string;
@@ -24,16 +26,6 @@ const COMPLETION_OVERLAP_SCAN_LIMIT = 256;
const WORD_LIKE_TRIGGER_REGEX = /[\w\]})>"'`.]/;
const CONTEXT_FOLLOWUP_TRIGGER_REGEX = /[\w\]})>"'`.{;=:[\],(]/;
-const DEBUG_AUTOCOMPLETE = false;
-
-function debugLog(message: string, payload?: Record) {
- if (!DEBUG_AUTOCOMPLETE) return;
- if (payload) {
- console.log(`[Autocomplete] ${message}`, payload);
- return;
- }
- console.log(`[Autocomplete] ${message}`);
-}
function isWhitespace(char: string): boolean {
return char === " " || char === "\t" || char === "\n" || char === "\r";
@@ -107,7 +99,9 @@ function shouldTriggerForCharacter(content: string, cursorOffset: number): boole
export function useAutocomplete({
enabled,
+ provider,
model,
+ customBaseUrl,
filePath,
languageId,
content,
@@ -129,6 +123,7 @@ export function useAutocomplete({
const managedPolicy = enterprisePolicy?.managedMode ? enterprisePolicy : null;
const isPro = subscriptionStatus === "pro";
const useByok = managedPolicy ? managedPolicy.allowByok && !isPro : !isPro;
+ const needsAthasAuth = provider !== "custom";
useEffect(() => {
return () => {
@@ -152,24 +147,14 @@ export function useAutocomplete({
if (
!enabled ||
- !isAuthenticated ||
+ (needsAthasAuth && !isAuthenticated) ||
(managedPolicy ? !managedPolicy.aiCompletionEnabled : false) ||
+ !model.trim() ||
+ (provider === "custom" && !customBaseUrl.trim()) ||
hasActiveFolds ||
lastInputTimestamp === 0 ||
cursorOffset <= 0
) {
- if (didUserType) {
- debugLog("skip-prereq", {
- enabled,
- isAuthenticated,
- subscriptionStatus,
- enterpriseManaged: Boolean(managedPolicy),
- aiCompletionEnabled: managedPolicy ? managedPolicy.aiCompletionEnabled : true,
- hasActiveFolds,
- lastInputTimestamp,
- cursorOffset,
- });
- }
setAutocompleteCompletion(null);
return;
}
@@ -181,12 +166,6 @@ export function useAutocomplete({
}
if (!shouldTriggerForCharacter(content, cursorOffset)) {
- const previousSignificantChar = getPreviousNonWhitespaceChar(content, cursorOffset - 2);
- debugLog("skip-trigger-char", {
- charBeforeCursor: content[cursorOffset - 1] || "",
- previousChar: content[cursorOffset - 2] || "",
- previousSignificantChar,
- });
setAutocompleteCompletion(null);
return;
}
@@ -209,13 +188,6 @@ export function useAutocomplete({
abortControllerRef.current = abortController;
try {
- debugLog("request", {
- model,
- filePath: filePath || "untitled",
- languageId: languageId || "unknown",
- cursorOffset,
- });
-
const result = await requestAutocomplete(
{
model,
@@ -224,7 +196,25 @@ export function useAutocomplete({
filePath: filePath || undefined,
languageId: languageId || undefined,
},
- { useByok },
+ {
+ useByok: provider === "custom" ? false : useByok,
+ provider,
+ customBaseUrl,
+ onChunk: (partialCompletion) => {
+ if (abortController.signal.aborted || requestIdRef.current !== requestId) {
+ return;
+ }
+
+ const normalizedText = normalizeCompletionText(
+ partialCompletion,
+ beforeCursor,
+ afterCursor,
+ );
+ if (!normalizedText) return;
+
+ setAutocompleteCompletion({ text: normalizedText, cursorOffset });
+ },
+ },
);
if (abortController.signal.aborted || requestIdRef.current !== requestId) {
@@ -233,22 +223,16 @@ export function useAutocomplete({
const text = result.completion;
if (!text) {
- debugLog("empty-completion");
setAutocompleteCompletion(null);
return;
}
const normalizedText = normalizeCompletionText(text, beforeCursor, afterCursor);
if (!normalizedText) {
- debugLog("empty-normalized-completion");
setAutocompleteCompletion(null);
return;
}
- debugLog("suggestion-ready", {
- rawLength: text.length,
- normalizedLength: normalizedText.length,
- });
setAutocompleteCompletion({ text: normalizedText, cursorOffset });
} catch (error) {
if (abortController.signal.aborted || requestIdRef.current !== requestId) {
@@ -276,16 +260,18 @@ export function useAutocomplete({
};
}, [
enabled,
+ provider,
+ needsAthasAuth,
isAuthenticated,
managedPolicy,
useByok,
- subscriptionStatus,
filePath,
hasActiveFolds,
lastInputTimestamp,
cursorOffset,
content,
model,
+ customBaseUrl,
languageId,
setAutocompleteCompletion,
]);
diff --git a/src/features/editor/services/editor-autocomplete-service.ts b/src/features/editor/services/editor-autocomplete-service.ts
index 372a38b0f..65536b322 100644
--- a/src/features/editor/services/editor-autocomplete-service.ts
+++ b/src/features/editor/services/editor-autocomplete-service.ts
@@ -5,6 +5,7 @@ import { getApiBase } from "@/utils/api-base";
const API_BASE = getApiBase();
const OPENROUTER_PROVIDER_ID = "openrouter";
+const CUSTOM_AUTOCOMPLETE_PROVIDER_ID = "autocomplete-custom";
const BYOK_HEADER = "X-OpenRouter-Api-Key";
export interface AutocompleteRequest {
@@ -15,6 +16,13 @@ export interface AutocompleteRequest {
languageId?: string;
}
+interface AutocompleteOptions {
+ useByok?: boolean;
+ provider?: "openrouter" | "custom";
+ customBaseUrl?: string;
+ onChunk?: (completion: string) => void;
+}
+
export interface AutocompleteModel {
id: string;
name: string;
@@ -68,8 +76,12 @@ function parseModelListFromUnknown(payload: unknown): AutocompleteModel[] {
export async function requestAutocomplete(
request: AutocompleteRequest,
- options?: { useByok?: boolean },
+ options?: AutocompleteOptions,
): Promise<{ completion: string }> {
+ if (options?.provider === "custom") {
+ return requestCustomAutocomplete(request, options.customBaseUrl, options.onChunk);
+ }
+
const token = await getAuthToken();
if (!token) {
throw new AutocompleteError("Not authenticated", 401);
@@ -110,6 +122,284 @@ export async function requestAutocomplete(
};
}
+function normalizeCustomBaseUrl(baseUrl: string | undefined): string {
+ const trimmed = baseUrl?.trim().replace(/\/+$/, "") || "";
+ if (!trimmed) {
+ throw new AutocompleteError("Custom autocomplete base URL is required.", 400);
+ }
+
+ return trimmed.endsWith("/chat/completions") ? trimmed : `${trimmed}/chat/completions`;
+}
+
+function extractCustomCompletion(payload: unknown): string {
+ if (!payload || typeof payload !== "object") return "";
+
+ const nestedData = (payload as { data?: unknown }).data;
+ if (nestedData && typeof nestedData === "object") {
+ const nestedCompletion = extractCustomCompletion(nestedData);
+ if (nestedCompletion) return nestedCompletion;
+ }
+
+ const directCandidate = payload as {
+ completion?: unknown;
+ text?: unknown;
+ insertText?: unknown;
+ suggestion?: unknown;
+ };
+ for (const value of [
+ directCandidate.completion,
+ directCandidate.text,
+ directCandidate.insertText,
+ directCandidate.suggestion,
+ ]) {
+ if (typeof value === "string") return value;
+ }
+
+ const choices = (payload as { choices?: unknown }).choices;
+ if (!Array.isArray(choices)) return "";
+ const firstChoice = choices[0];
+ if (!firstChoice || typeof firstChoice !== "object") return "";
+ const message = (firstChoice as { message?: unknown }).message;
+ if (message && typeof message === "object") {
+ const content = (message as { content?: unknown }).content;
+ if (typeof content === "string") return content;
+ }
+ const text = (firstChoice as { text?: unknown }).text;
+ return typeof text === "string" ? text : "";
+}
+
+function extractCustomStreamChunk(payload: unknown): string {
+ if (!payload || typeof payload !== "object") return "";
+
+ const nestedData = (payload as { data?: unknown }).data;
+ if (nestedData && typeof nestedData === "object") {
+ const nestedChunk = extractCustomStreamChunk(nestedData);
+ if (nestedChunk) return nestedChunk;
+ }
+
+ const choices = (payload as { choices?: unknown }).choices;
+ if (Array.isArray(choices)) {
+ const firstChoice = choices[0];
+ if (firstChoice && typeof firstChoice === "object") {
+ const delta = (firstChoice as { delta?: unknown }).delta;
+ if (delta && typeof delta === "object") {
+ const content = (delta as { content?: unknown }).content;
+ if (typeof content === "string") return content;
+ }
+
+ const text = (firstChoice as { text?: unknown }).text;
+ if (typeof text === "string") return text;
+ }
+ }
+
+ return extractCustomCompletion(payload);
+}
+
+function cleanCustomCompletion(completion: string): string {
+ let cleaned = completion.replace(/\r\n/g, "\n").trimEnd();
+
+ const fencedMatch = cleaned.match(/^```(?:[\w-]+)?\n([\s\S]*?)\n```$/);
+ if (fencedMatch?.[1]) {
+ cleaned = fencedMatch[1].trimEnd();
+ }
+
+ return cleaned;
+}
+
+type CustomAutocompleteRequestBody = {
+ model: string;
+ stream: boolean;
+ temperature: number;
+ max_tokens: number;
+ messages: Array<{
+ role: "system" | "user";
+ content: string;
+ }>;
+};
+
+function buildCustomAutocompleteRequestBody(
+ request: AutocompleteRequest,
+ stream: boolean,
+): CustomAutocompleteRequestBody {
+ return {
+ model: request.model,
+ stream,
+ temperature: 0.2,
+ max_tokens: 96,
+ messages: [
+ {
+ role: "system",
+ content: [
+ "You are an inline code autocomplete engine.",
+ "Return only the exact text to insert at the cursor.",
+ "Do not return markdown fences, explanations, or the whole file.",
+ "Do not repeat text that already appears before or after the cursor.",
+ "Do not close or recreate outer syntax unless that is the immediate next insertion.",
+ "Prefer a short continuation: one token, one attribute, one expression, or a few lines at most.",
+ ].join(" "),
+ },
+ {
+ role: "user",
+ content: [
+ request.filePath ? `File: ${request.filePath}` : "File: untitled",
+ request.languageId ? `Language: ${request.languageId}` : "Language: unknown",
+ "",
+ request.beforeCursor,
+ "",
+ "",
+ request.afterCursor,
+ "",
+ ].join("\n"),
+ },
+ ],
+ };
+}
+
+async function requestCustomAutocomplete(
+ request: AutocompleteRequest,
+ customBaseUrl: string | undefined,
+ onChunk?: (completion: string) => void,
+): Promise<{ completion: string }> {
+ if (!request.model.trim()) {
+ throw new AutocompleteError("Custom autocomplete model is required.", 400);
+ }
+
+ const apiKey = await getProviderApiToken(CUSTOM_AUTOCOMPLETE_PROVIDER_ID);
+ const url = normalizeCustomBaseUrl(customBaseUrl);
+ const requestBody = buildCustomAutocompleteRequestBody(request, true);
+
+ const response = await tauriFetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "text/event-stream, application/json",
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
+ },
+ body: JSON.stringify(requestBody),
+ });
+
+ const contentType = response.headers.get("content-type") || "";
+ if (response.ok && response.body && contentType.includes("text/event-stream")) {
+ const completion = await readCustomStreamingCompletion(response, onChunk);
+ return { completion: cleanCustomCompletion(completion) };
+ }
+
+ let body: unknown = null;
+ try {
+ body = await response.json();
+ } catch {
+ body = null;
+ }
+
+ if (!response.ok) {
+ const error = body && typeof body === "object" ? (body as { error?: unknown }).error : null;
+ const message =
+ error &&
+ typeof error === "object" &&
+ typeof (error as { message?: unknown }).message === "string"
+ ? (error as { message: string }).message
+ : `Custom autocomplete request failed (${response.status})`;
+ if (requestBody.stream) {
+ return requestCustomAutocompleteNonStreaming(
+ request,
+ url,
+ apiKey,
+ buildCustomAutocompleteRequestBody(request, false),
+ );
+ }
+
+ throw new AutocompleteError(message, response.status);
+ }
+
+ const completion = cleanCustomCompletion(extractCustomCompletion(body));
+ return { completion };
+}
+
+async function readCustomStreamingCompletion(
+ response: Response,
+ onChunk?: (completion: string) => void,
+): Promise {
+ const reader = response.body?.getReader();
+ if (!reader) {
+ return "";
+ }
+
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let completion = "";
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || "";
+
+ for (const line of lines) {
+ const trimmedLine = line.trim();
+ if (!trimmedLine || trimmedLine.startsWith("event:")) continue;
+ if (trimmedLine === "data: [DONE]") return completion;
+ if (!trimmedLine.startsWith("data:")) continue;
+
+ const json = trimmedLine.slice("data:".length).trim();
+ if (!json) continue;
+
+ try {
+ const chunk = extractCustomStreamChunk(JSON.parse(json));
+ if (!chunk) continue;
+ completion += chunk;
+ onChunk?.(cleanCustomCompletion(completion));
+ } catch (error) {
+ console.warn("Failed to parse autocomplete stream chunk:", error);
+ }
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ }
+
+ return completion;
+}
+
+async function requestCustomAutocompleteNonStreaming(
+ request: AutocompleteRequest,
+ url: string,
+ apiKey: string | null,
+ requestBody: CustomAutocompleteRequestBody,
+): Promise<{ completion: string }> {
+ const response = await tauriFetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
+ },
+ body: JSON.stringify(requestBody),
+ });
+
+ let body: unknown = null;
+ try {
+ body = await response.json();
+ } catch {
+ body = null;
+ }
+
+ if (!response.ok) {
+ const error = body && typeof body === "object" ? (body as { error?: unknown }).error : null;
+ const message =
+ error &&
+ typeof error === "object" &&
+ typeof (error as { message?: unknown }).message === "string"
+ ? (error as { message: string }).message
+ : `Custom autocomplete request failed (${response.status})`;
+ throw new AutocompleteError(message, response.status);
+ }
+
+ const completion = cleanCustomCompletion(extractCustomCompletion(body));
+ return { completion };
+}
+
export async function fetchAutocompleteModels(): Promise {
const response = await tauriFetch(`${API_BASE}/api/ai/autocomplete/models`, {
method: "GET",
diff --git a/src/features/settings/components/tabs/ai-settings.tsx b/src/features/settings/components/tabs/ai-settings.tsx
index 63e7c89e8..6c8aa4540 100644
--- a/src/features/settings/components/tabs/ai-settings.tsx
+++ b/src/features/settings/components/tabs/ai-settings.tsx
@@ -47,6 +47,7 @@ import {
storeProviderApiToken,
} from "@/features/ai/services/ai-token-service";
const DEFAULT_AUTOCOMPLETE_MODEL_ID = "mistralai/devstral-small";
+const CUSTOM_AUTOCOMPLETE_PROVIDER_ID = "autocomplete-custom";
const DEFAULT_AUTOCOMPLETE_MODELS = [
{ id: "mistralai/devstral-small", name: "Devstral Small 1.1" },
{ id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
@@ -75,6 +76,15 @@ export const AISettings = () => {
const [autocompleteModels, setAutocompleteModels] = useState(DEFAULT_AUTOCOMPLETE_MODELS);
const [isLoadingAutocompleteModels, setIsLoadingAutocompleteModels] = useState(false);
const [autocompleteModelError, setAutocompleteModelError] = useState(null);
+ const [customAutocompleteModelInput, setCustomAutocompleteModelInput] = useState(
+ settings.aiAutocompleteCustomModelId,
+ );
+ const [customAutocompleteBaseUrlInput, setCustomAutocompleteBaseUrlInput] = useState(
+ settings.aiAutocompleteCustomBaseUrl,
+ );
+ const [customAutocompleteApiKeyInput, setCustomAutocompleteApiKeyInput] = useState("");
+ const [hasCustomAutocompleteApiKey, setHasCustomAutocompleteApiKey] = useState(false);
+ const [isSavingCustomAutocompleteApiKey, setIsSavingCustomAutocompleteApiKey] = useState(false);
const [isApiKeyManagerOpen, setIsApiKeyManagerOpen] = useState(false);
// Ollama URL state
@@ -229,6 +239,60 @@ export const AISettings = () => {
void loadAutocompleteModels();
}, []);
+ useEffect(() => {
+ setCustomAutocompleteModelInput(settings.aiAutocompleteCustomModelId);
+ }, [settings.aiAutocompleteCustomModelId]);
+
+ useEffect(() => {
+ setCustomAutocompleteBaseUrlInput(settings.aiAutocompleteCustomBaseUrl);
+ }, [settings.aiAutocompleteCustomBaseUrl]);
+
+ useEffect(() => {
+ void (async () => {
+ const token = await getProviderApiToken(CUSTOM_AUTOCOMPLETE_PROVIDER_ID);
+ setHasCustomAutocompleteApiKey(Boolean(token));
+ })();
+ }, []);
+
+ const handleSaveCustomAutocompleteApiKey = async () => {
+ const token = customAutocompleteApiKeyInput.trim();
+ if (!token) return;
+
+ setIsSavingCustomAutocompleteApiKey(true);
+ try {
+ await storeProviderApiToken(CUSTOM_AUTOCOMPLETE_PROVIDER_ID, token);
+ setHasCustomAutocompleteApiKey(true);
+ setCustomAutocompleteApiKeyInput("");
+ showToast({ message: "Custom autocomplete API key saved", type: "success" });
+ } catch {
+ showToast({ message: "Failed to save custom autocomplete API key", type: "error" });
+ } finally {
+ setIsSavingCustomAutocompleteApiKey(false);
+ }
+ };
+
+ const handleRemoveCustomAutocompleteApiKey = async () => {
+ setIsSavingCustomAutocompleteApiKey(true);
+ try {
+ await removeProviderApiToken(CUSTOM_AUTOCOMPLETE_PROVIDER_ID);
+ setHasCustomAutocompleteApiKey(false);
+ setCustomAutocompleteApiKeyInput("");
+ showToast({ message: "Custom autocomplete API key removed", type: "success" });
+ } catch {
+ showToast({ message: "Failed to remove custom autocomplete API key", type: "error" });
+ } finally {
+ setIsSavingCustomAutocompleteApiKey(false);
+ }
+ };
+
+ const commitCustomAutocompleteModel = () => {
+ updateSetting("aiAutocompleteCustomModelId", customAutocompleteModelInput);
+ };
+
+ const commitCustomAutocompleteBaseUrl = () => {
+ updateSetting("aiAutocompleteCustomBaseUrl", customAutocompleteBaseUrlInput);
+ };
+
const providersNeedingAuth = getAvailableProviders().filter(
(p) => p.requiresAuth && !p.requiresApiKey,
);
@@ -480,41 +544,180 @@ export const AISettings = () => {
{settings.aiCompletion && (
<>
- updateSetting("aiAutocompleteModelId", getDefaultSetting("aiAutocompleteModelId"))
+ updateSetting("aiAutocompleteProvider", getDefaultSetting("aiAutocompleteProvider"))
}
canReset={
- settings.aiAutocompleteModelId !== getDefaultSetting("aiAutocompleteModelId")
+ settings.aiAutocompleteProvider !== getDefaultSetting("aiAutocompleteProvider")
}
>
-
-
-
+ ) : (
+
+
+
+ )}
+ {settings.aiAutocompleteProvider === "custom" && (
+ <>
+
+ updateSetting(
+ "aiAutocompleteCustomBaseUrl",
+ getDefaultSetting("aiAutocompleteCustomBaseUrl"),
+ )
+ }
+ canReset={
+ settings.aiAutocompleteCustomBaseUrl !==
+ getDefaultSetting("aiAutocompleteCustomBaseUrl")
+ }
+ >
+
+ setCustomAutocompleteBaseUrlInput(event.currentTarget.value)
+ }
+ onBlur={commitCustomAutocompleteBaseUrl}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.currentTarget.blur();
+ }
+ }}
+ placeholder="http://localhost:11434/v1"
+ size="xs"
+ className={SETTINGS_CONTROL_WIDTHS.xwide}
+ disabled={!aiCompletionAllowedByPolicy}
+ />
+
+
+
+
+ setCustomAutocompleteApiKeyInput(event.currentTarget.value)
+ }
+ placeholder={hasCustomAutocompleteApiKey ? "Saved" : "API key"}
+ size="xs"
+ className={SETTINGS_CONTROL_WIDTHS.wide}
+ disabled={!aiCompletionAllowedByPolicy || isSavingCustomAutocompleteApiKey}
+ />
+
+ {hasCustomAutocompleteApiKey && (
+
+ )}
+
+
+ >
+ )}
{autocompleteModelError && (
diff --git a/src/features/settings/config/default-settings.ts b/src/features/settings/config/default-settings.ts
index 3c1a2f8e0..523fcae79 100644
--- a/src/features/settings/config/default-settings.ts
+++ b/src/features/settings/config/default-settings.ts
@@ -16,6 +16,7 @@ import type { Settings } from "@/features/settings/types/settings";
export const DEFAULT_AI_PROVIDER_ID = "anthropic";
export const DEFAULT_AI_MODEL_ID = "claude-sonnet-4-6";
export const DEFAULT_AI_AUTOCOMPLETE_MODEL_ID = "mistralai/devstral-small";
+export const DEFAULT_AI_AUTOCOMPLETE_CUSTOM_BASE_URL = "";
export const defaultSettings: Settings = {
// General
@@ -65,7 +66,10 @@ export const defaultSettings: Settings = {
aiChatWidth: 400,
isAIChatVisible: false,
aiCompletion: true,
+ aiAutocompleteProvider: "openrouter",
aiAutocompleteModelId: DEFAULT_AI_AUTOCOMPLETE_MODEL_ID,
+ aiAutocompleteCustomBaseUrl: DEFAULT_AI_AUTOCOMPLETE_CUSTOM_BASE_URL,
+ aiAutocompleteCustomModelId: "",
aiDefaultSessionMode: "",
aiSkills: [],
ollamaBaseUrl: "http://localhost:11434",
diff --git a/src/features/settings/lib/settings-normalization.ts b/src/features/settings/lib/settings-normalization.ts
index ad6394704..2b9385c7b 100644
--- a/src/features/settings/lib/settings-normalization.ts
+++ b/src/features/settings/lib/settings-normalization.ts
@@ -175,6 +175,12 @@ function normalizeAISettings(settings: Settings): Settings {
AI_AUTOCOMPLETE_MODEL_MIGRATIONS[normalizedSettings.aiAutocompleteModelId] ||
normalizedSettings.aiAutocompleteModelId ||
DEFAULT_AI_AUTOCOMPLETE_MODEL_ID;
+ normalizedSettings.aiAutocompleteProvider =
+ normalizedSettings.aiAutocompleteProvider === "custom" ? "custom" : "openrouter";
+ normalizedSettings.aiAutocompleteCustomBaseUrl =
+ normalizedSettings.aiAutocompleteCustomBaseUrl?.trim() || "";
+ normalizedSettings.aiAutocompleteCustomModelId =
+ normalizedSettings.aiAutocompleteCustomModelId?.trim() || "";
normalizedSettings.aiSkills = normalizeAISkills(normalizedSettings.aiSkills);
return normalizedSettings;
@@ -295,5 +301,17 @@ export function normalizeSettingValue(
return normalizeAISkills(value as Settings["aiSkills"]) as Settings[K];
}
+ if (key === "aiAutocompleteProvider") {
+ return (value === "custom" ? "custom" : "openrouter") as Settings[K];
+ }
+
+ if (key === "aiAutocompleteCustomBaseUrl") {
+ return (value as string).trim() as Settings[K];
+ }
+
+ if (key === "aiAutocompleteCustomModelId") {
+ return (value as string).trim() as Settings[K];
+ }
+
return value;
}
diff --git a/src/features/settings/lib/settings-sync.ts b/src/features/settings/lib/settings-sync.ts
index e222e5275..e207fc4d1 100644
--- a/src/features/settings/lib/settings-sync.ts
+++ b/src/features/settings/lib/settings-sync.ts
@@ -54,7 +54,10 @@ type SyncableSettingsKey =
| "aiChatWidth"
| "isAIChatVisible"
| "aiCompletion"
+ | "aiAutocompleteProvider"
| "aiAutocompleteModelId"
+ | "aiAutocompleteCustomBaseUrl"
+ | "aiAutocompleteCustomModelId"
| "aiDefaultSessionMode"
| "aiSkills"
| "ollamaBaseUrl"
@@ -136,7 +139,10 @@ const SYNCABLE_SETTINGS_KEYS: SyncableSettingsKey[] = [
"aiChatWidth",
"isAIChatVisible",
"aiCompletion",
+ "aiAutocompleteProvider",
"aiAutocompleteModelId",
+ "aiAutocompleteCustomBaseUrl",
+ "aiAutocompleteCustomModelId",
"aiDefaultSessionMode",
"aiSkills",
"ollamaBaseUrl",
diff --git a/src/features/settings/types/settings.ts b/src/features/settings/types/settings.ts
index fd67e3dbd..56651f7bc 100644
--- a/src/features/settings/types/settings.ts
+++ b/src/features/settings/types/settings.ts
@@ -57,7 +57,10 @@ export interface Settings {
aiChatWidth: number;
isAIChatVisible: boolean;
aiCompletion: boolean;
+ aiAutocompleteProvider: "openrouter" | "custom";
aiAutocompleteModelId: string;
+ aiAutocompleteCustomBaseUrl: string;
+ aiAutocompleteCustomModelId: string;
aiDefaultSessionMode: string;
aiSkills: AIChatSkill[];
ollamaBaseUrl: string;
diff --git a/vite.config.ts b/vite.config.ts
index ac7ec038a..95fb28ec7 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -56,8 +56,9 @@ export default defineConfig({
}
: undefined,
watch: {
- // 3. tell vite to ignore watching `src-tauri` and `interceptor`
- ignored: ["**/src-tauri/**", "**/interceptor/**"],
+ // 3. tell vite to ignore app-owned files that should not reload the
+ // editor while they are being edited from inside the editor itself.
+ ignored: ["**/src-tauri/**", "**/interceptor/**", "**/index.html"],
},
},
});