Compare commits

...

16 commits
1.2.4 ... main

Author SHA1 Message Date
Barrca
b995101048 1.2.7 2026-05-30 23:18:05 +02:00
Barrca
86601be0f0 feat: Codex device sign-in modal with copy and open page buttons
On mobile, show a modal with the one-time code, copy-to-clipboard, and open sign-in page actions instead of auto-opening the browser.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 23:17:53 +02:00
Barrca
d900ef1ca6 1.2.6 2026-05-30 23:09:40 +02:00
Barrca
1c61d23cc6 fix: Codex ChatGPT sign-in on Android via device code flow
Use OpenAI device auth on mobile instead of localhost OAuth, and open the verification URL in the system browser via Capacitor AppLauncher.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 23:09:27 +02:00
Barrca
69817bffc2 1.2.5 2026-05-30 23:03:21 +02:00
Barrca
b1933fb823 fix: store API keys and Codex tokens in Obsidian keychain
Move credentials out of vault-synced data.json into SecretStorage, with automatic migration for existing users and persisted Codex refresh. Requires Obsidian 1.11.4+.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 23:03:05 +02:00
Barrca
053119ed15
Merge pull request #17 from FelixIsaac/felix/codex-subscription-provider
feat: Codex provider, note-aware context, built-in slash commands + UX improvements
2026-05-23 12:41:15 +02:00
Felix Isaac Lim
05dc259155 feat: built-in slash commands + Ctrl+Shift+Space default hotkey 2026-05-23 11:32:36 +08:00
Felix Isaac Lim
71a6447f98 feat: inject note context (title, heading, surrounding paragraphs) into system prompt 2026-05-23 11:31:56 +08:00
Felix Isaac Lim
15fe7f02c5 feat: model UX improvements + input/output edge cases
- model dropdown shows description for each option
- custom model field shows validation warning when empty
- guard against empty model before API call
- truncate inputs >60k chars with notice
- trim whitespace from output; clearer error for reasoning-only responses
- auto-reset model to gpt-5.4-mini when switching to Codex provider

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:37:10 +08:00
Felix Isaac Lim
3abc20fe6e fix: edge cases — port clash, 403/429 errors, reasoning-only, model reset, token warning
- pre-check port 1455 before OAuth flow to catch clashes early
- 403: friendly message suggesting gpt-5.4-mini fallback
- 429: "subscription limit reached" instead of raw error
- add response.output_text.done SSE path for reasoning-only responses
- reset model to gpt-5.4-mini when switching to Codex provider
- 10s notice on session expiry prompting user to re-sign-in in settings
- warn user tokens stored in plaintext data.json when signed in

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:33:24 +08:00
Felix Isaac Lim
d51f10b33e style: run prettier to fix format check
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:30:12 +08:00
Felix Isaac Lim
1024c37991 fix: correct SSE delta parsing + custom model input
response.output_text.delta has delta as plain string not object; fix
parseSseText to handle it. Also fix custom model dropdown — selecting
Custom now sets model to "" so isCustom triggers correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:27:35 +08:00
Felix Isaac Lim
b389c5991d fix: use Obsidian requestUrl instead of fetch (CSP bypass), add model dropdown
native fetch blocked by Obsidian CSP for external domains; requestUrl
bypasses it. Also adds Codex model dropdown with known models + custom
fallback, updates normalizeModel for gpt-5.5/5.4-mini/5.3-codex-spark.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:18:39 +08:00
Barrca
5f21d0bdd5
Merge pull request #16 from FelixIsaac/felix/codex-subscription-provider
feat: Codex (ChatGPT subscription) provider
2026-05-22 23:41:59 +02:00
Felix Isaac Lim
6880043c8b feat: add Codex (ChatGPT subscription) provider
OAuth PKCE flow to auth w/ ChatGPT Plus/Pro; stores tokens in plugin
settings. Calls chatgpt.com/backend-api/codex/responses (responses API)
instead of chat completions. Auto-refreshes tokens. Sign in/out UI in
settings tab.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 05:33:00 +08:00
14 changed files with 1255 additions and 51 deletions

View file

@ -1,11 +1,11 @@
{ {
"id": "inlineai", "id": "inlineai",
"name": "InlineAI", "name": "InlineAI",
"version": "1.2.4", "version": "1.2.7",
"minAppVersion": "0.15.0", "minAppVersion": "1.11.4",
"description": "AI-powered suggestions, contextual edits, and advanced text transformations directly into your editor.", "description": "AI-powered suggestions, contextual edits, and advanced text transformations directly into your editor.",
"author": "FBarrca", "author": "FBarrca",
"authorUrl": "https://github.com/FBarrca/", "authorUrl": "https://github.com/FBarrca/",
"fundingUrl": "https://www.buymeacoffee.com/FBarrCa", "fundingUrl": "https://www.buymeacoffee.com/FBarrCa",
"isDesktopOnly": false "isDesktopOnly": false
} }

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "obsidian-inlineai", "name": "obsidian-inlineai",
"version": "1.2.4", "version": "1.2.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "obsidian-inlineai", "name": "obsidian-inlineai",
"version": "1.2.4", "version": "1.2.7",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.18.4", "@codemirror/autocomplete": "^6.18.4",

View file

@ -1,6 +1,6 @@
{ {
"name": "obsidian-inlineai", "name": "obsidian-inlineai",
"version": "1.2.4", "version": "1.2.7",
"description": "Cursor or Copilot like Inline AI interface for Obsidian", "description": "Cursor or Copilot like Inline AI interface for Obsidian",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View file

@ -10,6 +10,9 @@ import {
import { InlineAISettings } from "./settings"; import { InlineAISettings } from "./settings";
import { App, MarkdownView, Notice } from "obsidian"; import { App, MarkdownView, Notice } from "obsidian";
import { EditorView } from "@codemirror/view"; import { EditorView } from "@codemirror/view";
import { callCodexApi } from "./codex-client";
import { getValidCodexToken } from "./codex-auth";
import { getApiKey, getCodexTokens, setCodexTokens } from "./credentials";
import { setGeneratedResponseEffect } from "./modules/AIExtension"; import { setGeneratedResponseEffect } from "./modules/AIExtension";
import { parseCommand } from "./modules/commands/parser"; import { parseCommand } from "./modules/commands/parser";
import { MessageQueue } from "./modules/messageHistory/queue"; import { MessageQueue } from "./modules/messageHistory/queue";
@ -97,8 +100,9 @@ export class ChatApiManager {
} }
switch (settings.provider) { switch (settings.provider) {
case "openai": case "openai": {
if (!settings.apiKey) { const apiKey = getApiKey(this.app);
if (!apiKey) {
new Notice( new Notice(
"⚠️ OpenAI API key is required. Please check your settings.", "⚠️ OpenAI API key is required. Please check your settings.",
); );
@ -107,20 +111,24 @@ export class ChatApiManager {
return new ChatOpenAI({ return new ChatOpenAI({
modelName: settings.model, modelName: settings.model,
temperature: 0, temperature: 0,
apiKey: settings.apiKey, apiKey,
}); });
}
case "ollama": case "ollama":
return new ChatOllama({ return new ChatOllama({
model: settings.model, model: settings.model,
}); });
case "gemini": case "gemini": {
const apiKey = getApiKey(this.app);
return new ChatGoogleGenerativeAI({ return new ChatGoogleGenerativeAI({
model: settings.model, model: settings.model,
apiKey: settings.apiKey, apiKey: apiKey ?? undefined,
}); });
case "azure": }
if (!settings.apiKey || !settings.azureEndpoint) { case "azure": {
const apiKey = getApiKey(this.app);
if (!apiKey || !settings.azureEndpoint) {
new Notice( new Notice(
"⚠️ API key and Azure endpoint are required for Azure provider.", "⚠️ API key and Azure endpoint are required for Azure provider.",
); );
@ -139,15 +147,17 @@ export class ChatApiManager {
} }
return new AzureChatOpenAI({ return new AzureChatOpenAI({
azureOpenAIApiKey: settings.apiKey, azureOpenAIApiKey: apiKey,
azureOpenAIApiInstanceName: instanceName, azureOpenAIApiInstanceName: instanceName,
azureOpenAIApiDeploymentName: settings.model, azureOpenAIApiDeploymentName: settings.model,
azureOpenAIApiVersion: azureOpenAIApiVersion:
settings.azureApiVersion || "2024-02-15-preview", settings.azureApiVersion || "2024-02-15-preview",
temperature: 0, temperature: 0,
}); });
case "custom": }
if (!settings.apiKey || !settings.customURL) { case "custom": {
const apiKey = getApiKey(this.app);
if (!apiKey || !settings.customURL) {
new Notice( new Notice(
"⚠️ API key and custom base URL are required for custom providers.", "⚠️ API key and custom base URL are required for custom providers.",
); );
@ -156,12 +166,17 @@ export class ChatApiManager {
return new ChatOpenAI({ return new ChatOpenAI({
modelName: settings.model, modelName: settings.model,
temperature: 0, temperature: 0,
openAIApiKey: settings.apiKey, openAIApiKey: apiKey,
// 'configuration.basePath' is the recognized property // 'configuration.basePath' is the recognized property
configuration: { configuration: {
baseURL: settings.customURL.trim(), baseURL: settings.customURL.trim(),
}, },
}); });
}
case "codex":
// Handled directly in callApi — no LangChain client needed
return null;
default: default:
new Notice(`⚠️ Unsupported provider: ${settings.provider}`); new Notice(`⚠️ Unsupported provider: ${settings.provider}`);
@ -184,6 +199,10 @@ export class ChatApiManager {
systemMessage: string, systemMessage: string,
message: string, message: string,
): Promise<string> { ): Promise<string> {
if (this.settings.provider === "codex") {
return this.callCodexProvider(systemMessage, message);
}
if (!this.chatClient) { if (!this.chatClient) {
new Notice( new Notice(
"⚠️ Chat client is not initialized. Please check your settings.", "⚠️ Chat client is not initialized. Please check your settings.",
@ -209,6 +228,45 @@ export class ChatApiManager {
} }
} }
private async callCodexProvider(
systemMessage: string,
message: string,
): Promise<string> {
const tokens = getCodexTokens(this.app);
if (!tokens?.access || !tokens.refresh || !tokens.accountId) {
new Notice(
"⚠️ Codex: not signed in — open Settings → InlineAI and click 'Sign in with ChatGPT'",
);
return "⚠️ Codex not authenticated.";
}
try {
const accessToken = await getValidCodexToken(
tokens,
async (refreshed) => {
setCodexTokens(this.app, refreshed);
},
);
if (!accessToken) {
new Notice("⚠️ Codex: session expired — please sign in again");
return "⚠️ Codex session expired.";
}
return await callCodexApi(
systemMessage,
message,
accessToken,
tokens.accountId,
this.settings.model,
);
} catch (error: any) {
console.error("Codex error:", error);
new Notice(`❌ Codex: ${error.message}`);
return "⚠️ Codex request failed.";
}
}
/** /**
* Handles user input and updates the editor with the response. * Handles user input and updates the editor with the response.
* @param systemPrompt - The system prompt to send to the chat API. * @param systemPrompt - The system prompt to send to the chat API.
@ -246,6 +304,73 @@ export class ChatApiManager {
return "⚠️ Failed to process request."; return "⚠️ Failed to process request.";
} }
} }
private extractNoteContext(selectionText: string): string {
try {
const file = this.app.workspace.getActiveFile();
const noteTitle = file?.basename ?? "";
const markdownView =
this.app.workspace.getActiveViewOfType(MarkdownView);
if (!markdownView) return "";
const cm = (markdownView.editor as any).cm as EditorView;
const doc = cm.state.doc.toString();
const cursor = cm.state.selection.main.from;
// Find nearest heading above cursor
const docBeforeCursor = doc.slice(0, cursor);
const lines = docBeforeCursor.split("\n");
let nearestHeading = "";
for (let i = lines.length - 1; i >= 0; i--) {
if (/^#{1,3}\s/.test(lines[i])) {
nearestHeading = lines[i].replace(/^#+\s*/, "").trim();
break;
}
}
// Get surrounding paragraphs (split by blank lines)
const selectionStart = doc.indexOf(
selectionText,
Math.max(0, cursor - selectionText.length - 200),
);
const before =
selectionStart > 0
? doc.slice(0, selectionStart)
: docBeforeCursor;
const after =
selectionStart >= 0
? doc.slice(selectionStart + selectionText.length)
: doc.slice(cursor);
const beforeParas = before
.split(/\n\n+/)
.filter((p) => p.trim())
.slice(-3);
const afterParas = after
.split(/\n\n+/)
.filter((p) => p.trim())
.slice(0, 3);
if (beforeParas.length === 0 && afterParas.length === 0) return "";
const MAX_CONTEXT_CHARS = 1500;
let contextStr = "";
if (noteTitle) contextStr += `Note: ${noteTitle}\n`;
if (nearestHeading) contextStr += `Section: ${nearestHeading}\n`;
if (beforeParas.length > 0)
contextStr += `\nContext before:\n${beforeParas.join("\n\n")}`;
if (afterParas.length > 0)
contextStr += `\n\nContext after:\n${afterParas.join("\n\n")}`;
if (contextStr.length > MAX_CONTEXT_CHARS) {
contextStr = contextStr.slice(0, MAX_CONTEXT_CHARS) + "\n[…]";
}
return contextStr.trim();
} catch {
return "";
}
}
/** /**
* Processes selected text using the specified prompt and transformation. * Processes selected text using the specified prompt and transformation.
* @param userPrompt - The transformation prompt (e.g., "Add Emojis"). * @param userPrompt - The transformation prompt (e.g., "Add Emojis").
@ -288,7 +413,11 @@ export class ChatApiManager {
**Output:**`; **Output:**`;
} }
return this.handleEditorUpdate(systemPrompt, finalUserPrompt); const noteContext = this.extractNoteContext(selectedText);
const enhancedSystemPrompt = noteContext
? `${systemPrompt}\n\n---\nDocument context (for reference only — do not include in output):\n${noteContext}`
: systemPrompt;
return this.handleEditorUpdate(enhancedSystemPrompt, finalUserPrompt);
} }
/** /**

445
src/codex-auth.ts Normal file
View file

@ -0,0 +1,445 @@
import * as http from "http";
import { App, Notice, Platform, requestUrl } from "obsidian";
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
const TOKEN_URL = "https://auth.openai.com/oauth/token";
const REDIRECT_URI = "http://localhost:1455/auth/callback";
const DEVICE_USERCODE_URL =
"https://auth.openai.com/api/accounts/deviceauth/usercode";
const DEVICE_TOKEN_URL =
"https://auth.openai.com/api/accounts/deviceauth/token";
const DEVICE_VERIFICATION_URL = "https://auth.openai.com/codex/device";
const DEVICE_REDIRECT_URI = "https://auth.openai.com/deviceauth/callback";
const SCOPE = "openid profile email offline_access";
const CALLBACK_PORT = 1455;
const DEVICE_AUTH_TIMEOUT_MS = 15 * 60 * 1000;
const DEVICE_POLL_SAFETY_MARGIN_MS = 3000;
export interface CodexTokens {
access: string;
refresh: string;
expires: number;
accountId: string;
}
/** Open a URL in the system browser (works on Android/iOS where window.open often fails). */
export function openExternalUrl(url: string): void {
if (Platform.isMobileApp) {
const plugins = (
window as unknown as {
Capacitor?: { Plugins?: Record<string, unknown> };
}
).Capacitor?.Plugins as
| {
AppLauncher?: {
openUrl: (opts: { url: string }) => Promise<unknown>;
};
Browser?: {
open: (opts: { url: string }) => Promise<unknown>;
};
}
| undefined;
if (plugins?.AppLauncher?.openUrl) {
void plugins.AppLauncher.openUrl({ url });
return;
}
if (plugins?.Browser?.open) {
void plugins.Browser.open({ url });
return;
}
const link = document.createElement("a");
link.href = url;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
link.remove();
return;
}
window.open(url);
}
async function generatePKCE(): Promise<{
verifier: string;
challenge: string;
}> {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const verifier = btoa(String.fromCharCode(...array))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest("SHA-256", data);
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
return { verifier, challenge };
}
function randomState(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
}
export async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fall through to legacy fallback
}
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand("copy");
textarea.remove();
return copied;
} catch {
return false;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function decodeJWT(token: string): Record<string, any> | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
return JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
} catch {
return null;
}
}
function extractAccountId(accessToken: string): string | null {
const payload = decodeJWT(accessToken);
if (!payload) return null;
const auth = payload["https://api.openai.com/auth"];
return auth?.user_id ?? auth?.account_id ?? null;
}
async function exchangeAuthorizationCode(
code: string,
verifier: string,
redirectUri: string,
): Promise<CodexTokens | null> {
const res = await requestUrl({
url: TOKEN_URL,
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code,
code_verifier: verifier,
redirect_uri: redirectUri,
}).toString(),
throw: false,
});
if (res.status < 200 || res.status >= 300) return null;
const json = res.json as any;
if (!json.access_token || !json.refresh_token) return null;
const accountId = extractAccountId(json.access_token);
if (!accountId) return null;
return {
access: json.access_token,
refresh: json.refresh_token,
expires: Date.now() + json.expires_in * 1000,
accountId,
};
}
export async function refreshCodexToken(
tokens: CodexTokens,
): Promise<CodexTokens | null> {
const res = await requestUrl({
url: TOKEN_URL,
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: tokens.refresh,
client_id: CLIENT_ID,
}).toString(),
throw: false,
});
if (res.status < 200 || res.status >= 300) return null;
const json = res.json as any;
if (!json.access_token || !json.refresh_token) return null;
return {
access: json.access_token,
refresh: json.refresh_token,
expires: Date.now() + json.expires_in * 1000,
accountId: tokens.accountId,
};
}
export async function getValidCodexToken(
tokens: CodexTokens,
onRefresh: (t: CodexTokens) => Promise<void>,
): Promise<string | null> {
if (tokens.expires > Date.now() + 60_000) return tokens.access;
const refreshed = await refreshCodexToken(tokens);
if (!refreshed) {
new Notice(
"⚠️ Codex: session expired — open Settings → InlineAI to sign in again",
10000,
);
return null;
}
await onRefresh(refreshed);
return refreshed.access;
}
function isPortInUse(port: number): Promise<boolean> {
return new Promise((resolve) => {
const tester = http.createServer();
tester.once("error", () => resolve(true));
tester.once("listening", () => {
tester.close();
resolve(false);
});
tester.listen(port, "127.0.0.1");
});
}
async function startCodexDeviceAuthFlow(app: App): Promise<CodexTokens | null> {
const userCodeRes = await requestUrl({
url: DEVICE_USERCODE_URL,
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id: CLIENT_ID }),
throw: false,
});
if (userCodeRes.status < 200 || userCodeRes.status >= 300) {
new Notice(
"❌ Codex: device sign-in unavailable — try again later",
8000,
);
return null;
}
const json = userCodeRes.json as Record<string, unknown>;
const deviceAuthId =
typeof json.device_auth_id === "string" ? json.device_auth_id : null;
const userCode =
typeof json.user_code === "string"
? json.user_code
: typeof json.usercode === "string"
? json.usercode
: null;
const intervalSec = Math.max(
parseInt(String(json.interval ?? "5"), 10) || 5,
1,
);
if (!deviceAuthId || !userCode) {
new Notice("❌ Codex: invalid device sign-in response");
return null;
}
let cancelled = false;
const { CodexDeviceAuthModal } = await import("./codex-device-auth-modal");
const modal = new CodexDeviceAuthModal(
app,
userCode,
DEVICE_VERIFICATION_URL,
() => {
cancelled = true;
},
);
modal.open();
try {
const startedAt = Date.now();
const pollIntervalMs = intervalSec * 1000 + DEVICE_POLL_SAFETY_MARGIN_MS;
while (!cancelled && Date.now() - startedAt < DEVICE_AUTH_TIMEOUT_MS) {
const pollRes = await requestUrl({
url: DEVICE_TOKEN_URL,
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device_auth_id: deviceAuthId,
user_code: userCode,
}),
throw: false,
});
if (pollRes.status >= 200 && pollRes.status < 300) {
const pollJson = pollRes.json as Record<string, unknown>;
const authCode =
typeof pollJson.authorization_code === "string"
? pollJson.authorization_code
: null;
const codeVerifier =
typeof pollJson.code_verifier === "string"
? pollJson.code_verifier
: null;
if (!authCode || !codeVerifier) {
new Notice("❌ Codex: invalid device sign-in response");
return null;
}
const tokens = await exchangeAuthorizationCode(
authCode,
codeVerifier,
DEVICE_REDIRECT_URI,
);
if (!tokens) {
new Notice("❌ Codex: failed to exchange auth code for tokens");
}
return tokens;
}
if (pollRes.status !== 403 && pollRes.status !== 404) {
new Notice("❌ Codex: device sign-in failed");
return null;
}
await sleep(pollIntervalMs);
}
if (!cancelled) {
new Notice("⚠️ Codex: sign-in timed out");
}
return null;
} finally {
modal.closeByApp();
}
}
async function startCodexDesktopOAuthFlow(): Promise<CodexTokens | null> {
if (await isPortInUse(CALLBACK_PORT)) {
new Notice(
"❌ Codex: port 1455 is already in use — close the Codex CLI or any other app using it, then try again",
8000,
);
return null;
}
const { verifier, challenge } = await generatePKCE();
const state = randomState();
const url = new URL(AUTHORIZE_URL);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("redirect_uri", REDIRECT_URI);
url.searchParams.set("scope", SCOPE);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", state);
url.searchParams.set("id_token_add_organizations", "true");
url.searchParams.set("codex_cli_simplified_flow", "true");
url.searchParams.set("originator", "codex_cli_rs");
return new Promise((resolve) => {
let resolved = false;
let server: http.Server | null = null;
const done = (tokens: CodexTokens | null) => {
if (resolved) return;
resolved = true;
server?.close();
resolve(tokens);
};
server = http.createServer(async (req, res) => {
if (!req.url?.startsWith("/auth/callback")) {
res.writeHead(404);
res.end();
return;
}
const params = new URL(req.url, "http://localhost").searchParams;
const code = params.get("code");
const returnedState = params.get("state");
if (!code || returnedState !== state) {
res.writeHead(400);
res.end("Invalid callback");
done(null);
return;
}
res.writeHead(200, { "Content-Type": "text/html" });
res.end(
"<html><body><h2>Signed in! You can close this tab.</h2></body></html>",
);
const tokens = await exchangeAuthorizationCode(
code,
verifier,
REDIRECT_URI,
);
if (!tokens) {
new Notice("❌ Codex: failed to exchange auth code for tokens");
}
done(tokens);
});
server.on("error", (e: any) => {
if (e.code === "EADDRINUSE") {
new Notice(
"❌ Codex: port 1455 in use — close other Codex sessions first",
);
}
done(null);
});
server.listen(CALLBACK_PORT, "127.0.0.1", () => {
openExternalUrl(url.toString());
new Notice(
"🔐 Codex: browser opened — complete sign-in to continue",
);
});
setTimeout(
() => {
if (!resolved) {
new Notice("⚠️ Codex: sign-in timed out");
done(null);
}
},
5 * 60 * 1000,
);
});
}
export async function startCodexOAuthFlow(app: App): Promise<CodexTokens | null> {
if (Platform.isMobileApp) {
return startCodexDeviceAuthFlow(app);
}
return startCodexDesktopOAuthFlow();
}

172
src/codex-client.ts Normal file
View file

@ -0,0 +1,172 @@
import { requestUrl } from "obsidian";
const CODEX_API_URL = "https://chatgpt.com/backend-api/codex/responses";
interface ResponsesInput {
type: "message";
role: "developer" | "user" | "assistant";
content: Array<{ type: "input_text"; text: string }>;
}
interface ResponsesBody {
model: string;
input: ResponsesInput[];
instructions: string;
store: false;
stream: true;
reasoning: { effort: string; summary: string };
text: { verbosity: string };
include: string[];
}
function normalizeModel(model: string): string {
const m = model.toLowerCase().trim();
if (m === "gpt-5.5" || m.includes("gpt-5.5")) return "gpt-5.5";
if (m === "gpt-5.4-mini" || m.includes("gpt-5.4-mini"))
return "gpt-5.4-mini";
if (m.includes("gpt-5.3-codex-spark") || m.includes("codex-spark"))
return "gpt-5.3-codex-spark";
if (m.includes("gpt-5.2-codex") || m.includes("gpt 5.2 codex"))
return "gpt-5.2-codex";
if (m.includes("gpt-5.1-codex-max") || m.includes("codex-max"))
return "gpt-5.1-codex-max";
if (m.includes("codex-mini-latest") || m.includes("codex-mini"))
return "codex-mini-latest";
if (m.includes("gpt-5.1-codex") || m.includes("codex"))
return "gpt-5.1-codex";
if (m.includes("gpt-5.2")) return "gpt-5.2";
if (m.includes("gpt-5.1")) return "gpt-5.1";
return m; // pass through unknown models as-is
}
function parseSseText(sseBody: string): string {
const lines = sseBody.split("\n");
const parts: string[] = [];
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") break;
try {
const json = JSON.parse(data) as any;
// response.output_text.delta — delta is a plain string
if (
json.type === "response.output_text.delta" &&
typeof json.delta === "string"
) {
parts.push(json.delta);
}
// response.output_text.done — full text for this content part
if (
json.type === "response.output_text.done" &&
typeof json.text === "string" &&
parts.length === 0
) {
parts.push(json.text);
}
// response.completed — fallback if no deltas/done events
if (json.type === "response.completed" && parts.length === 0) {
for (const item of json.response?.output ?? []) {
for (const c of item.content ?? []) {
if (
c.type === "output_text" &&
typeof c.text === "string"
) {
parts.push(c.text);
}
}
}
}
} catch {
// ignore malformed lines
}
}
return parts.join("");
}
const MAX_INPUT_CHARS = 60_000; // ~15k tokens, well under Codex limits
export async function callCodexApi(
systemMessage: string,
userMessage: string,
accessToken: string,
accountId: string,
model: string,
): Promise<string> {
if (!model.trim())
throw new Error("No model selected — set one in Settings → InlineAI");
const normalizedModel = normalizeModel(model);
// Truncate very long inputs to avoid silent API failures
const truncatedUser =
userMessage.length > MAX_INPUT_CHARS
? userMessage.slice(0, MAX_INPUT_CHARS) + "\n\n[…truncated]"
: userMessage;
const body: ResponsesBody = {
model: normalizedModel,
input: [
{
type: "message",
role: "developer",
content: [{ type: "input_text", text: systemMessage }],
},
{
type: "message",
role: "user",
content: [{ type: "input_text", text: truncatedUser }],
},
],
instructions: "",
store: false,
stream: true,
reasoning: { effort: "medium", summary: "auto" },
text: { verbosity: "medium" },
include: ["reasoning.encrypted_content"],
};
const res = await requestUrl({
url: CODEX_API_URL,
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
"chatgpt-account-id": accountId,
"OpenAI-Beta": "responses=experimental",
originator: "codex_cli_rs",
accept: "text/event-stream",
},
body: JSON.stringify(body),
throw: false,
});
if (res.status < 200 || res.status >= 300) {
let msg = `Codex API error ${res.status}`;
try {
const err = JSON.parse(res.text)?.error;
if (res.status === 429) {
msg =
"Subscription limit reached — wait a moment and try again";
} else if (res.status === 403) {
msg = `Model not available on your plan${err?.message ? ": " + err.message : " — try gpt-5.4-mini instead"}`;
} else if (err?.message) {
msg = err.message;
}
} catch {}
throw new Error(msg);
}
const rawText = res.text;
const result = parseSseText(rawText).trim();
if (!result)
throw new Error(
"Codex returned an empty response — the model may only have produced reasoning tokens. Try a different prompt or model.",
);
return result;
}

View file

@ -0,0 +1,66 @@
import { App, Modal, Notice } from "obsidian";
import { copyToClipboard, openExternalUrl } from "./codex-auth";
export class CodexDeviceAuthModal extends Modal {
private closedByApp = false;
constructor(
app: App,
private userCode: string,
private verificationUrl: string,
private onCancel: () => void,
) {
super(app);
}
closeByApp(): void {
this.closedByApp = true;
this.close();
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
this.titleEl.setText("Sign in with ChatGPT");
contentEl.createEl("p", {
text: "Enter this one-time code on the ChatGPT sign-in page:",
});
contentEl.createEl("div", {
cls: "inlineai-codex-device-code",
text: this.userCode,
});
const buttonRow = contentEl.createDiv({ cls: "inlineai-codex-device-buttons" });
buttonRow.createEl("button", { text: "Copy code" }).addEventListener(
"click",
async () => {
const copied = await copyToClipboard(this.userCode);
new Notice(
copied ? "✅ Code copied to clipboard" : "❌ Could not copy code",
);
},
);
buttonRow
.createEl("button", { text: "Open sign-in page", cls: "mod-cta" })
.addEventListener("click", () => {
openExternalUrl(this.verificationUrl);
});
contentEl.createEl("p", {
cls: "setting-item-description",
text: "Complete sign-in in your browser, then return here. This dialog closes automatically when sign-in succeeds.",
});
}
onClose(): void {
const { contentEl } = this;
contentEl.empty();
if (!this.closedByApp) {
this.onCancel();
}
}
}

124
src/credentials.ts Normal file
View file

@ -0,0 +1,124 @@
import { App } from "obsidian";
import { CodexTokens } from "./codex-auth";
export const API_KEY_ID = "inlineai-api-key";
export const CODEX_AUTH_ID = "inlineai-codex-auth";
/** Minimal SecretStorage surface (Obsidian 1.11.4+). */
interface SecretStorageApi {
getSecret(id: string): string | null;
setSecret(id: string, secret: string): void;
}
/** Legacy fields that may exist in data.json from older plugin versions. */
export interface LegacySecretFields {
apiKey?: string;
codexAccess?: string;
codexRefresh?: string;
codexExpires?: number;
codexAccountId?: string;
}
export function isSecretStorageAvailable(app: App): boolean {
return "secretStorage" in app && (app as { secretStorage?: unknown }).secretStorage != null;
}
function getSecretStorage(app: App): SecretStorageApi | null {
if (!isSecretStorageAvailable(app)) return null;
return (app as unknown as { secretStorage: SecretStorageApi }).secretStorage;
}
export function getApiKey(app: App): string | null {
const storage = getSecretStorage(app);
if (!storage) return null;
const value = storage.getSecret(API_KEY_ID);
return value && value.length > 0 ? value : null;
}
export function setApiKey(app: App, key: string): void {
const storage = getSecretStorage(app);
if (!storage) return;
storage.setSecret(API_KEY_ID, key.length === 0 ? "" : key);
}
export function clearApiKey(app: App): void {
setApiKey(app, "");
}
function parseCodexTokens(raw: string): CodexTokens | null {
try {
const parsed = JSON.parse(raw) as CodexTokens;
if (
typeof parsed.access === "string" &&
typeof parsed.refresh === "string" &&
typeof parsed.expires === "number" &&
typeof parsed.accountId === "string" &&
parsed.access.length > 0 &&
parsed.accountId.length > 0
) {
return parsed;
}
} catch {
// invalid JSON
}
return null;
}
export function getCodexTokens(app: App): CodexTokens | null {
const storage = getSecretStorage(app);
if (!storage) return null;
const raw = storage.getSecret(CODEX_AUTH_ID);
if (!raw || raw.length === 0) return null;
return parseCodexTokens(raw);
}
export function setCodexTokens(app: App, tokens: CodexTokens): void {
const storage = getSecretStorage(app);
if (!storage) return;
storage.setSecret(CODEX_AUTH_ID, JSON.stringify(tokens));
}
export function clearCodexTokens(app: App): void {
const storage = getSecretStorage(app);
if (!storage) return;
storage.setSecret(CODEX_AUTH_ID, "");
}
/**
* Moves plaintext secrets from data.json into SecretStorage.
* Returns true if any legacy fields were migrated (caller should re-save settings).
*/
export function migrateLegacySecrets(
app: App,
settings: LegacySecretFields,
): boolean {
if (!isSecretStorageAvailable(app)) return false;
let migrated = false;
if (settings.apiKey && settings.apiKey.length > 0) {
setApiKey(app, settings.apiKey);
delete settings.apiKey;
migrated = true;
}
if (
settings.codexAccess &&
settings.codexRefresh &&
settings.codexAccountId
) {
setCodexTokens(app, {
access: settings.codexAccess,
refresh: settings.codexRefresh,
expires: settings.codexExpires ?? 0,
accountId: settings.codexAccountId,
});
delete settings.codexAccess;
delete settings.codexRefresh;
delete settings.codexExpires;
delete settings.codexAccountId;
migrated = true;
}
return migrated;
}

View file

@ -20,6 +20,7 @@ import {
setSelectionInfoEffect, setSelectionInfoEffect,
} from "./modules/SelectionState"; } from "./modules/SelectionState";
import { diffExtension } from "./modules/diffExtension"; import { diffExtension } from "./modules/diffExtension";
import { migrateLegacySecrets, LegacySecretFields } from "./credentials";
export default class InlineAIChatPlugin extends Plugin { export default class InlineAIChatPlugin extends Plugin {
settings: InlineAISettings = DEFAULT_SETTINGS; settings: InlineAISettings = DEFAULT_SETTINGS;
@ -79,7 +80,7 @@ export default class InlineAIChatPlugin extends Plugin {
cmEditor.dispatch({ effects }); cmEditor.dispatch({ effects });
} }
}, },
hotkeys: [], hotkeys: [{ modifiers: ["Ctrl", "Shift"], key: " " }],
}); });
this.addCommand({ this.addCommand({
id: "accept-tooltip", id: "accept-tooltip",
@ -137,11 +138,11 @@ export default class InlineAIChatPlugin extends Plugin {
} }
async loadSettings() { async loadSettings() {
this.settings = Object.assign( const raw: LegacySecretFields = (await this.loadData()) ?? {};
{}, if (migrateLegacySecrets(this.app, raw)) {
DEFAULT_SETTINGS, await this.saveData(raw);
await this.loadData(), }
); this.settings = Object.assign({}, DEFAULT_SETTINGS, raw);
} }
async saveSettings() { async saveSettings() {

View file

@ -1,15 +1,16 @@
import { SlashCommand } from "./source"; import { SlashCommand, BUILT_IN_COMMANDS } from "./source";
export function parseCommand( export function parseCommand(
userInput: string, userInput: string,
prefix: string, prefix: string,
customCommands: SlashCommand[], customCommands: SlashCommand[],
): string { ): string {
for (const command of customCommands) { const allCommands = [...BUILT_IN_COMMANDS, ...customCommands];
for (const command of allCommands) {
const commandPattern = `${prefix}${command.keyword}`; const commandPattern = `${prefix}${command.keyword}`;
if (userInput.includes(commandPattern)) { if (userInput.includes(commandPattern)) {
return userInput.replace(commandPattern, command.prompt); return userInput.replace(commandPattern, command.prompt);
} }
} }
return userInput; // Return original text if no command matches return userInput;
} }

View file

@ -17,6 +17,41 @@ export interface SlashCommand {
prompt: string; prompt: string;
} }
export const BUILT_IN_COMMANDS: SlashCommand[] = [
{
keyword: "fix",
prompt: "Fix grammar, spelling, and punctuation. Keep the original meaning and style. Output only the corrected text.",
},
{
keyword: "shorter",
prompt: "Make this text more concise. Remove filler words and redundancy. Keep all key information. Output only the shortened text.",
},
{
keyword: "longer",
prompt: "Expand this text with more detail, examples, or explanation. Keep the same tone. Output only the expanded text.",
},
{
keyword: "formal",
prompt: "Rewrite this text in a formal, professional tone. Output only the rewritten text.",
},
{
keyword: "casual",
prompt: "Rewrite this text in a friendly, casual tone. Output only the rewritten text.",
},
{
keyword: "bullets",
prompt: "Convert this text into a clear bullet-point list using Obsidian markdown. Output only the bullet list.",
},
{
keyword: "summarize",
prompt: "Write a concise summary of this text in 2-3 sentences. Output only the summary.",
},
{
keyword: "continue",
prompt: "Continue writing from where this text ends, matching the tone and style. Output only the continuation — do not repeat existing text.",
},
];
// Factory function that creates a completion source with custom parameters // Factory function that creates a completion source with custom parameters
function createSlashCommandSource( function createSlashCommandSource(
options: { options: {
@ -28,12 +63,13 @@ function createSlashCommandSource(
}, },
) { ) {
const { prefix, customCommands } = options; const { prefix, customCommands } = options;
const allCommands = [...BUILT_IN_COMMANDS, ...customCommands];
return (context: CompletionContext) => { return (context: CompletionContext) => {
let word = context.matchBefore(new RegExp(`^\\${prefix}\\w*`)); let word = context.matchBefore(new RegExp(`^\\${prefix}\\w*`));
if (!word || (word.from == word.to && !context.explicit)) return null; if (!word || (word.from == word.to && !context.explicit)) return null;
return { return {
from: word.from + 1, from: word.from + 1,
options: customCommands.map((cmd) => ({ options: allCommands.map((cmd) => ({
label: cmd.keyword, label: cmd.keyword,
type: undefined, type: undefined,
detail: cmd.prompt, detail: cmd.prompt,
@ -82,7 +118,8 @@ export function createSlashCommandHighlighter({
} }
buildDecorations(view: EditorView) { buildDecorations(view: EditorView) {
const keywords = customCommands const allCommands = [...BUILT_IN_COMMANDS, ...customCommands];
const keywords = allCommands
.map((cmd) => cmd.keyword) .map((cmd) => cmd.keyword)
.join("|"); .join("|");
const regexp = new RegExp(`\\${prefix}(${keywords})\\b`, "g"); const regexp = new RegExp(`\\${prefix}(${keywords})\\b`, "g");

View file

@ -1,13 +1,21 @@
import { App, PluginSettingTab, Setting } from "obsidian"; import { App, PluginSettingTab, Setting, Notice, Platform } from "obsidian";
import MyPlugin from "./main"; import MyPlugin from "./main";
import { cursorPrompt, selectionPrompt } from "./default_prompts"; import { cursorPrompt, selectionPrompt } from "./default_prompts";
import { SlashCommand } from "./modules/commands/source"; import { SlashCommand, BUILT_IN_COMMANDS } from "./modules/commands/source";
import { startCodexOAuthFlow } from "./codex-auth";
import {
clearCodexTokens,
getApiKey,
getCodexTokens,
isSecretStorageAvailable,
setApiKey,
setCodexTokens,
} from "./credentials";
// Interface for the settings // Interface for the settings
export interface InlineAISettings { export interface InlineAISettings {
provider: "openai" | "ollama" | "custom" | "gemini" | "azure"; provider: "openai" | "ollama" | "custom" | "gemini" | "azure" | "codex";
model: string; model: string;
apiKey?: string;
customURL?: string; customURL?: string;
azureEndpoint?: string; azureEndpoint?: string;
azureApiVersion?: string; azureApiVersion?: string;
@ -22,7 +30,6 @@ export interface InlineAISettings {
export const DEFAULT_SETTINGS: InlineAISettings = { export const DEFAULT_SETTINGS: InlineAISettings = {
provider: "ollama", provider: "ollama",
model: "llama3.2", model: "llama3.2",
apiKey: "",
customURL: "", customURL: "",
azureEndpoint: "", azureEndpoint: "",
azureApiVersion: "2024-02-15-preview", azureApiVersion: "2024-02-15-preview",
@ -51,11 +58,18 @@ export class InlineAISettingsTab extends PluginSettingTab {
const { containerEl } = this; const { containerEl } = this;
containerEl.empty(); containerEl.empty();
if (!isSecretStorageAvailable(this.app)) {
containerEl.createEl("p", {
text: "⚠️ InlineAI requires Obsidian 1.11.4 or later for secure credential storage. Please update Obsidian to use API keys and Codex sign-in.",
cls: "setting-item-description",
});
}
// Provider setting // Provider setting
new Setting(containerEl) new Setting(containerEl)
.setName("Provider") .setName("Provider")
.setDesc( .setDesc(
"Choose between OpenAI, Ollama, Azure OpenAI, Gemini, or a custom OpenAI-compatible endpoint.", "Choose between OpenAI, Ollama, Azure OpenAI, Gemini, a custom OpenAI-compatible endpoint, or Codex (ChatGPT subscription).",
) )
.addDropdown((dropdown) => .addDropdown((dropdown) =>
dropdown dropdown
@ -64,31 +78,209 @@ export class InlineAISettingsTab extends PluginSettingTab {
.addOption("azure", "Azure OpenAI") .addOption("azure", "Azure OpenAI")
.addOption("gemini", "Gemini") .addOption("gemini", "Gemini")
.addOption("custom", "Custom/OpenAI-compatible") .addOption("custom", "Custom/OpenAI-compatible")
.addOption("codex", "Codex (ChatGPT subscription)")
.setValue(this.plugin.settings.provider) .setValue(this.plugin.settings.provider)
.onChange(async (value) => { .onChange(async (value) => {
const CODEX_MODEL_IDS = [
"gpt-5.5",
"gpt-5.4-mini",
"gpt-5.3-codex-spark",
"gpt-5.2-codex",
"gpt-5.1-codex",
"gpt-5.1-codex-max",
"codex-mini-latest",
];
this.plugin.settings.provider = value as this.plugin.settings.provider = value as
| "openai" | "openai"
| "ollama" | "ollama"
| "azure" | "azure"
| "custom" | "custom"
| "gemini"; | "gemini"
| "codex";
// Reset model to a sane default when switching to Codex
if (
value === "codex" &&
!CODEX_MODEL_IDS.includes(
this.plugin.settings.model,
)
) {
this.plugin.settings.model = "gpt-5.4-mini";
}
await this.saveSettings(); await this.saveSettings();
this.display(); // Refresh UI to show/hide API key field this.display();
}), }),
); );
// Codex subscription auth section
if (this.plugin.settings.provider === "codex") {
const codexTokens = getCodexTokens(this.app);
const isSignedIn = !!(
codexTokens?.access && codexTokens?.accountId
);
new Setting(containerEl)
.setName("ChatGPT account")
.setDesc(
isSignedIn
? `Signed in (account: ${codexTokens!.accountId})`
: "Not signed in — click to authenticate with your ChatGPT Plus/Pro subscription.",
)
.addButton((btn) => {
btn.setButtonText(
isSignedIn ? "Sign out" : "Sign in with ChatGPT",
)
.setCta()
.onClick(async () => {
if (isSignedIn) {
clearCodexTokens(this.app);
this.display();
} else {
if (!isSecretStorageAvailable(this.app)) {
new Notice(
"⚠️ InlineAI requires Obsidian 1.11.4+ for Codex sign-in.",
);
return;
}
new Notice(
Platform.isMobileApp
? "Complete sign-in using the dialog that opens"
: "Opening browser for ChatGPT sign-in…",
);
const tokens = await startCodexOAuthFlow(this.app);
if (tokens) {
setCodexTokens(this.app, tokens);
new Notice(
"✅ Codex: signed in successfully",
);
this.display();
}
}
});
});
if (isSignedIn) {
containerEl.createEl("p", {
text: "Credentials are stored in Obsidian's keychain (Settings → Security). They are not synced with your vault and must be set up on each device.",
cls: "setting-item-description",
});
}
}
// Model setting // Model setting
new Setting(containerEl) if (this.plugin.settings.provider === "codex") {
.setName("Model") const CODEX_MODELS: {
.setDesc("Specify the model to use.") value: string;
.addText((text) => { label: string;
text.setPlaceholder("e.g., gpt-4o-mini") desc: string;
.setValue(this.plugin.settings.model) }[] = [
.inputEl.addEventListener("blur", async () => { {
this.plugin.settings.model = text.getValue(); value: "gpt-5.5",
label: "GPT-5.5",
desc: "Most capable — best for complex rewrites and reasoning",
},
{
value: "gpt-5.4-mini",
label: "GPT-5.4 mini ✦ recommended",
desc: "Fast and cost-efficient — ideal for inline edits",
},
{
value: "gpt-5.3-codex-spark",
label: "GPT-5.3 Codex Spark (Pro only)",
desc: "Near-instant iteration — requires ChatGPT Pro",
},
{
value: "gpt-5.2-codex",
label: "GPT-5.2 Codex",
desc: "Strong coding and structured writing",
},
{
value: "gpt-5.1-codex",
label: "GPT-5.1 Codex",
desc: "Balanced coding model",
},
{
value: "gpt-5.1-codex-max",
label: "GPT-5.1 Codex Max",
desc: "High-effort variant of GPT-5.1 Codex",
},
{
value: "codex-mini-latest",
label: "Codex Mini",
desc: "Lightest and fastest option",
},
{
value: "custom",
label: "Custom…",
desc: "Enter a model ID manually",
},
];
const isCustom = !CODEX_MODELS.some(
(m) =>
m.value === this.plugin.settings.model &&
m.value !== "custom",
);
const dropdownValue = isCustom
? "custom"
: this.plugin.settings.model;
const selectedModel = CODEX_MODELS.find(
(m) => m.value === dropdownValue,
);
new Setting(containerEl)
.setName("Model")
.setDesc(
selectedModel?.desc ??
"Select the model to use for Codex requests.",
)
.addDropdown((dd) => {
CODEX_MODELS.forEach((m) => dd.addOption(m.value, m.label));
dd.setValue(dropdownValue).onChange(async (value) => {
this.plugin.settings.model =
value === "custom" ? "" : value;
await this.saveSettings(); await this.saveSettings();
this.display();
}); });
}); });
if (isCustom || dropdownValue === "custom") {
new Setting(containerEl)
.setName("Custom model ID")
.setDesc(
"Enter the exact model ID as used by the Codex API.",
)
.addText((text) => {
text.setPlaceholder("e.g., gpt-5.1-codex")
.setValue(
isCustom ? this.plugin.settings.model : "",
)
.inputEl.addEventListener("blur", async () => {
this.plugin.settings.model = text
.getValue()
.trim();
await this.saveSettings();
this.display();
});
});
if (!this.plugin.settings.model.trim()) {
containerEl.createEl("p", {
text: "⚠️ No model ID entered — requests will fail until you set one.",
cls: "setting-item-description",
});
}
}
} else {
new Setting(containerEl)
.setName("Model")
.setDesc("Specify the model to use.")
.addText((text) => {
text.setPlaceholder("e.g., gpt-4o-mini")
.setValue(this.plugin.settings.model)
.inputEl.addEventListener("blur", async () => {
this.plugin.settings.model = text.getValue();
await this.saveSettings();
});
});
}
// API Key setting (conditionally displayed for OpenAI-supported endpoints) // API Key setting (conditionally displayed for OpenAI-supported endpoints)
if ( if (
@ -102,10 +294,18 @@ export class InlineAISettingsTab extends PluginSettingTab {
.setDesc("Enter your API key.") .setDesc("Enter your API key.")
.addText((text) => { .addText((text) => {
text.setPlaceholder("sk-...") text.setPlaceholder("sk-...")
.setValue(this.plugin.settings.apiKey || "") .setValue(getApiKey(this.app) ?? "")
.inputEl.addEventListener("blur", async () => { .inputEl.addEventListener("blur", async () => {
this.plugin.settings.apiKey = text.getValue(); if (!isSecretStorageAvailable(this.app)) {
await this.saveSettings(); new Notice(
"⚠️ InlineAI requires Obsidian 1.11.4+ to store API keys securely.",
);
return;
}
setApiKey(this.app, text.getValue());
this.plugin.chatapi.updateSettings(
this.plugin.settings,
);
}); });
}); });
} }
@ -226,6 +426,10 @@ export class InlineAISettingsTab extends PluginSettingTab {
containerEl.createEl("p", { containerEl.createEl("p", {
text: "Add your own custom commands. Triggered with the prefix defined in the Command Prefix setting.", text: "Add your own custom commands. Triggered with the prefix defined in the Command Prefix setting.",
}); });
containerEl.createEl("p", {
text: `Built-in: ${BUILT_IN_COMMANDS.map((c) => this.plugin.settings.commandPrefix + c.keyword).join(" • ")}`,
cls: "setting-item-description",
});
// Command Prefix setting // Command Prefix setting
new Setting(containerEl) new Setting(containerEl)

View file

@ -156,3 +156,24 @@
.cm-slash-command { .cm-slash-command {
color: var(--text-accent); color: var(--text-accent);
} }
/* Codex device sign-in modal */
.inlineai-codex-device-code {
font-family: var(--font-monospace);
font-size: 1.5em;
font-weight: 600;
letter-spacing: 0.08em;
text-align: center;
padding: 1em;
margin: 1em 0;
background: var(--background-secondary);
border-radius: 8px;
user-select: all;
}
.inlineai-codex-device-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.75em;
margin-top: 1em;
}

View file

@ -1,3 +1,7 @@
{ {
"1.0.0": "0.15.0" "1.0.0": "0.15.0",
} "1.2.4": "1.11.4",
"1.2.5": "1.11.4",
"1.2.6": "1.11.4",
"1.2.7": "1.11.4"
}