From b389c5991dde89bd637abed11942a67f1f018b17 Mon Sep 17 00:00:00 2001 From: Felix Isaac Lim <38658663+FelixIsaac@users.noreply.github.com> Date: Sat, 23 May 2026 06:18:39 +0800 Subject: [PATCH 1/7] 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 --- src/codex-auth.ts | 22 +++++++++------- src/codex-client.ts | 18 ++++++++----- src/settings.ts | 64 ++++++++++++++++++++++++++++++++++++++------- 3 files changed, 79 insertions(+), 25 deletions(-) diff --git a/src/codex-auth.ts b/src/codex-auth.ts index be18945..eebf05c 100644 --- a/src/codex-auth.ts +++ b/src/codex-auth.ts @@ -1,5 +1,5 @@ import * as http from "http"; -import { Notice } from "obsidian"; +import { Notice, requestUrl } from "obsidian"; const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; @@ -58,7 +58,8 @@ function extractAccountId(accessToken: string): string | null { } async function exchangeCode(code: string, verifier: string): Promise { - const res = await fetch(TOKEN_URL, { + const res = await requestUrl({ + url: TOKEN_URL, method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -67,12 +68,13 @@ async function exchangeCode(code: string, verifier: string): Promise= 300) return null; - const json = await res.json() as any; + const json = res.json as any; if (!json.access_token || !json.refresh_token) return null; const accountId = extractAccountId(json.access_token); @@ -87,19 +89,21 @@ async function exchangeCode(code: string, verifier: string): Promise { - const res = await fetch(TOKEN_URL, { + 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.ok) return null; + if (res.status < 200 || res.status >= 300) return null; - const json = await res.json() as any; + const json = res.json as any; if (!json.access_token || !json.refresh_token) return null; return { diff --git a/src/codex-client.ts b/src/codex-client.ts index 94e830e..b6a4f99 100644 --- a/src/codex-client.ts +++ b/src/codex-client.ts @@ -1,3 +1,5 @@ +import { requestUrl } from "obsidian"; + const CODEX_API_URL = "https://chatgpt.com/backend-api/codex/responses"; interface ResponsesInput { @@ -19,13 +21,16 @@ interface ResponsesBody { 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 "gpt-5.1-codex"; + return m; // pass through unknown models as-is } function parseSseText(sseBody: string): string { @@ -102,7 +107,8 @@ export async function callCodexApi( include: ["reasoning.encrypted_content"], }; - const res = await fetch(CODEX_API_URL, { + const res = await requestUrl({ + url: CODEX_API_URL, method: "POST", headers: { "Content-Type": "application/json", @@ -113,14 +119,14 @@ export async function callCodexApi( "accept": "text/event-stream", }, body: JSON.stringify(body), + throw: false, }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`Codex API ${res.status}: ${text.slice(0, 200)}`); + if (res.status < 200 || res.status >= 300) { + throw new Error(`Codex API ${res.status}: ${res.text.slice(0, 200)}`); } - const rawText = await res.text(); + const rawText = res.text; const result = parseSseText(rawText); if (!result) throw new Error("Codex returned empty response"); return result; diff --git a/src/settings.ts b/src/settings.ts index 06f7beb..956dd8e 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -128,17 +128,61 @@ export class InlineAISettingsTab extends PluginSettingTab { } // Model setting - 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(); + if (this.plugin.settings.provider === "codex") { + const CODEX_MODELS = [ + { value: "gpt-5.5", label: "GPT-5.5 (recommended)" }, + { value: "gpt-5.4-mini", label: "GPT-5.4 mini (faster)" }, + { value: "gpt-5.3-codex-spark", label: "GPT-5.3 Codex Spark (Pro only)" }, + { value: "gpt-5.2-codex", label: "GPT-5.2 Codex" }, + { value: "gpt-5.1-codex", label: "GPT-5.1 Codex" }, + { value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" }, + { value: "codex-mini-latest", label: "Codex Mini" }, + { value: "custom", label: "Custom…" }, + ]; + const isCustom = !CODEX_MODELS.some( + (m) => m.value === this.plugin.settings.model && m.value !== "custom", + ); + const dropdownValue = isCustom ? "custom" : this.plugin.settings.model; + + new Setting(containerEl) + .setName("Model") + .setDesc("Select a Codex model.") + .addDropdown((dd) => { + CODEX_MODELS.forEach((m) => dd.addOption(m.value, m.label)); + dd.setValue(dropdownValue).onChange(async (value) => { + if (value !== "custom") { + this.plugin.settings.model = value; + await this.saveSettings(); + } + this.display(); }); - }); + }); + + if (isCustom || dropdownValue === "custom") { + new Setting(containerEl) + .setName("Custom model ID") + .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(); + await this.saveSettings(); + }); + }); + } + } 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) if ( From 1024c3799199603dd6301ef2339f4265ef43f4fe Mon Sep 17 00:00:00 2001 From: Felix Isaac Lim <38658663+FelixIsaac@users.noreply.github.com> Date: Sat, 23 May 2026 06:27:35 +0800 Subject: [PATCH 2/7] fix: correct SSE delta parsing + custom model input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/codex-client.ts | 31 +++++++++++++------------------ src/settings.ts | 6 ++---- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/src/codex-client.ts b/src/codex-client.ts index b6a4f99..a50db24 100644 --- a/src/codex-client.ts +++ b/src/codex-client.ts @@ -44,26 +44,21 @@ function parseSseText(sseBody: string): string { try { const json = JSON.parse(data) as any; - // Responses API emits output[].content[].text deltas - for (const output of json.output ?? []) { - for (const content of output.content ?? []) { - if (content.type === "output_text" && content.text) { - parts.push(content.text); - } - } - } - // Delta format - const delta = json.delta; - if (delta?.type === "output_text" && delta.text) { - parts.push(delta.text); + + // response.output_text.delta — delta is a plain string + if (json.type === "response.output_text.delta" && typeof json.delta === "string") { + parts.push(json.delta); } - // Snapshot format (non-streaming final) + + // response.completed — full text in output[].content[] if (json.type === "response.completed") { - const output = json.response?.output ?? []; - for (const item of output) { - for (const c of item.content ?? []) { - if (c.type === "output_text" && c.text) { - parts.push(c.text); + // only use completed if we got no deltas (requestUrl buffers whole response) + if (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); + } } } } diff --git a/src/settings.ts b/src/settings.ts index 956dd8e..68cf3af 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -150,10 +150,8 @@ export class InlineAISettingsTab extends PluginSettingTab { .addDropdown((dd) => { CODEX_MODELS.forEach((m) => dd.addOption(m.value, m.label)); dd.setValue(dropdownValue).onChange(async (value) => { - if (value !== "custom") { - this.plugin.settings.model = value; - await this.saveSettings(); - } + this.plugin.settings.model = value === "custom" ? "" : value; + await this.saveSettings(); this.display(); }); }); From d51f10b33ebd2d69380e8dca4723330f5ea53382 Mon Sep 17 00:00:00 2001 From: Felix Isaac Lim <38658663+FelixIsaac@users.noreply.github.com> Date: Sat, 23 May 2026 06:30:12 +0800 Subject: [PATCH 3/7] style: run prettier to fix format check Co-Authored-By: Claude Sonnet 4.6 --- src/api.ts | 30 ++++++++++++++++++++++-------- src/codex-auth.ts | 41 +++++++++++++++++++++++++++++------------ src/codex-client.ts | 34 +++++++++++++++++++++++----------- src/settings.ts | 44 ++++++++++++++++++++++++++++++++------------ 4 files changed, 106 insertions(+), 43 deletions(-) diff --git a/src/api.ts b/src/api.ts index de10b20..d23401c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -219,10 +219,15 @@ export class ChatApiManager { } } - private async callCodexProvider(systemMessage: string, message: string): Promise { + private async callCodexProvider( + systemMessage: string, + message: string, + ): Promise { const s = this.settings; if (!s.codexAccess || !s.codexRefresh || !s.codexAccountId) { - new Notice("⚠️ Codex: not signed in — open Settings → InlineAI and click 'Sign in with ChatGPT'"); + new Notice( + "⚠️ Codex: not signed in — open Settings → InlineAI and click 'Sign in with ChatGPT'", + ); return "⚠️ Codex not authenticated."; } @@ -234,18 +239,27 @@ export class ChatApiManager { accountId: s.codexAccountId, }; - const accessToken = await getValidCodexToken(tokens, async (refreshed) => { - this.settings.codexAccess = refreshed.access; - this.settings.codexRefresh = refreshed.refresh; - this.settings.codexExpires = refreshed.expires; - }); + const accessToken = await getValidCodexToken( + tokens, + async (refreshed) => { + this.settings.codexAccess = refreshed.access; + this.settings.codexRefresh = refreshed.refresh; + this.settings.codexExpires = refreshed.expires; + }, + ); if (!accessToken) { new Notice("⚠️ Codex: session expired — please sign in again"); return "⚠️ Codex session expired."; } - return await callCodexApi(systemMessage, message, accessToken, s.codexAccountId, s.model); + return await callCodexApi( + systemMessage, + message, + accessToken, + s.codexAccountId, + s.model, + ); } catch (error: any) { console.error("Codex error:", error); new Notice(`❌ Codex: ${error.message}`); diff --git a/src/codex-auth.ts b/src/codex-auth.ts index eebf05c..545d571 100644 --- a/src/codex-auth.ts +++ b/src/codex-auth.ts @@ -15,7 +15,10 @@ export interface CodexTokens { accountId: string; } -async function generatePKCE(): Promise<{ verifier: string; challenge: string }> { +async function generatePKCE(): Promise<{ + verifier: string; + challenge: string; +}> { const array = new Uint8Array(32); crypto.getRandomValues(array); const verifier = btoa(String.fromCharCode(...array)) @@ -57,7 +60,10 @@ function extractAccountId(accessToken: string): string | null { return auth?.user_id ?? auth?.account_id ?? null; } -async function exchangeCode(code: string, verifier: string): Promise { +async function exchangeCode( + code: string, + verifier: string, +): Promise { const res = await requestUrl({ url: TOKEN_URL, method: "POST", @@ -88,7 +94,9 @@ async function exchangeCode(code: string, verifier: string): Promise { +export async function refreshCodexToken( + tokens: CodexTokens, +): Promise { const res = await requestUrl({ url: TOKEN_URL, method: "POST", @@ -173,7 +181,9 @@ export async function startCodexOAuthFlow(): Promise { } res.writeHead(200, { "Content-Type": "text/html" }); - res.end("

Signed in! You can close this tab.

"); + res.end( + "

Signed in! You can close this tab.

", + ); const tokens = await exchangeCode(code, verifier); if (!tokens) { @@ -184,22 +194,29 @@ export async function startCodexOAuthFlow(): Promise { server.on("error", (e: any) => { if (e.code === "EADDRINUSE") { - new Notice("❌ Codex: port 1455 in use — close other Codex sessions first"); + new Notice( + "❌ Codex: port 1455 in use — close other Codex sessions first", + ); } done(null); }); server.listen(CALLBACK_PORT, "127.0.0.1", () => { window.open(url.toString()); - new Notice("🔐 Codex: browser opened — complete sign-in to continue"); + new Notice( + "🔐 Codex: browser opened — complete sign-in to continue", + ); }); // Timeout after 5 minutes - setTimeout(() => { - if (!resolved) { - new Notice("⚠️ Codex: sign-in timed out"); - done(null); - } - }, 5 * 60 * 1000); + setTimeout( + () => { + if (!resolved) { + new Notice("⚠️ Codex: sign-in timed out"); + done(null); + } + }, + 5 * 60 * 1000, + ); }); } diff --git a/src/codex-client.ts b/src/codex-client.ts index a50db24..78cac82 100644 --- a/src/codex-client.ts +++ b/src/codex-client.ts @@ -22,12 +22,18 @@ interface ResponsesBody { 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 === "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 @@ -46,7 +52,10 @@ function parseSseText(sseBody: string): string { 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") { + if ( + json.type === "response.output_text.delta" && + typeof json.delta === "string" + ) { parts.push(json.delta); } @@ -56,7 +65,10 @@ function parseSseText(sseBody: string): string { if (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") { + if ( + c.type === "output_text" && + typeof c.text === "string" + ) { parts.push(c.text); } } @@ -107,11 +119,11 @@ export async function callCodexApi( method: "POST", headers: { "Content-Type": "application/json", - "Authorization": `Bearer ${accessToken}`, + Authorization: `Bearer ${accessToken}`, "chatgpt-account-id": accountId, "OpenAI-Beta": "responses=experimental", - "originator": "codex_cli_rs", - "accept": "text/event-stream", + originator: "codex_cli_rs", + accept: "text/event-stream", }, body: JSON.stringify(body), throw: false, diff --git a/src/settings.ts b/src/settings.ts index 68cf3af..f19a3d5 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -100,7 +100,9 @@ export class InlineAISettingsTab extends PluginSettingTab { : "Not signed in — click to authenticate with your ChatGPT Plus/Pro subscription.", ) .addButton((btn) => { - btn.setButtonText(isSignedIn ? "Sign out" : "Sign in with ChatGPT") + btn.setButtonText( + isSignedIn ? "Sign out" : "Sign in with ChatGPT", + ) .setCta() .onClick(async () => { if (isSignedIn) { @@ -111,15 +113,23 @@ export class InlineAISettingsTab extends PluginSettingTab { await this.saveSettings(); this.display(); } else { - new Notice("Opening browser for ChatGPT sign-in…"); + new Notice( + "Opening browser for ChatGPT sign-in…", + ); const tokens = await startCodexOAuthFlow(); if (tokens) { - this.plugin.settings.codexAccess = tokens.access; - this.plugin.settings.codexRefresh = tokens.refresh; - this.plugin.settings.codexExpires = tokens.expires; - this.plugin.settings.codexAccountId = tokens.accountId; + this.plugin.settings.codexAccess = + tokens.access; + this.plugin.settings.codexRefresh = + tokens.refresh; + this.plugin.settings.codexExpires = + tokens.expires; + this.plugin.settings.codexAccountId = + tokens.accountId; await this.saveSettings(); - new Notice("✅ Codex: signed in successfully"); + new Notice( + "✅ Codex: signed in successfully", + ); this.display(); } } @@ -132,7 +142,10 @@ export class InlineAISettingsTab extends PluginSettingTab { const CODEX_MODELS = [ { value: "gpt-5.5", label: "GPT-5.5 (recommended)" }, { value: "gpt-5.4-mini", label: "GPT-5.4 mini (faster)" }, - { value: "gpt-5.3-codex-spark", label: "GPT-5.3 Codex Spark (Pro only)" }, + { + value: "gpt-5.3-codex-spark", + label: "GPT-5.3 Codex Spark (Pro only)", + }, { value: "gpt-5.2-codex", label: "GPT-5.2 Codex" }, { value: "gpt-5.1-codex", label: "GPT-5.1 Codex" }, { value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" }, @@ -140,9 +153,13 @@ export class InlineAISettingsTab extends PluginSettingTab { { value: "custom", label: "Custom…" }, ]; const isCustom = !CODEX_MODELS.some( - (m) => m.value === this.plugin.settings.model && m.value !== "custom", + (m) => + m.value === this.plugin.settings.model && + m.value !== "custom", ); - const dropdownValue = isCustom ? "custom" : this.plugin.settings.model; + const dropdownValue = isCustom + ? "custom" + : this.plugin.settings.model; new Setting(containerEl) .setName("Model") @@ -150,7 +167,8 @@ export class InlineAISettingsTab extends PluginSettingTab { .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; + this.plugin.settings.model = + value === "custom" ? "" : value; await this.saveSettings(); this.display(); }); @@ -161,7 +179,9 @@ export class InlineAISettingsTab extends PluginSettingTab { .setName("Custom model ID") .addText((text) => { text.setPlaceholder("e.g., gpt-5.1-codex") - .setValue(isCustom ? this.plugin.settings.model : "") + .setValue( + isCustom ? this.plugin.settings.model : "", + ) .inputEl.addEventListener("blur", async () => { this.plugin.settings.model = text.getValue(); await this.saveSettings(); From 3abc20fe6e2395ab246ecf4706f8c6c5d80a10f7 Mon Sep 17 00:00:00 2001 From: Felix Isaac Lim <38658663+FelixIsaac@users.noreply.github.com> Date: Sat, 23 May 2026 06:33:24 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20edge=20cases=20=E2=80=94=20port=20cl?= =?UTF-8?q?ash,=20403/429=20errors,=20reasoning-only,=20model=20reset,=20t?= =?UTF-8?q?oken=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/codex-auth.ts | 28 +++++++++++++++++++++++++++- src/codex-client.ts | 44 +++++++++++++++++++++++++++++++------------- src/settings.ts | 25 +++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 14 deletions(-) diff --git a/src/codex-auth.ts b/src/codex-auth.ts index 545d571..b1b6b12 100644 --- a/src/codex-auth.ts +++ b/src/codex-auth.ts @@ -129,13 +129,39 @@ export async function getValidCodexToken( if (tokens.expires > Date.now() + 60_000) return tokens.access; const refreshed = await refreshCodexToken(tokens); - if (!refreshed) return null; + 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 { + 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"); + }); +} + export async function startCodexOAuthFlow(): Promise { + 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(); diff --git a/src/codex-client.ts b/src/codex-client.ts index 78cac82..51d3587 100644 --- a/src/codex-client.ts +++ b/src/codex-client.ts @@ -59,18 +59,24 @@ function parseSseText(sseBody: string): string { parts.push(json.delta); } - // response.completed — full text in output[].content[] - if (json.type === "response.completed") { - // only use completed if we got no deltas (requestUrl buffers whole response) - if (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); - } + // 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); } } } @@ -130,7 +136,19 @@ export async function callCodexApi( }); if (res.status < 200 || res.status >= 300) { - throw new Error(`Codex API ${res.status}: ${res.text.slice(0, 200)}`); + 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; diff --git a/src/settings.ts b/src/settings.ts index f19a3d5..a2a1cd3 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -73,6 +73,15 @@ export class InlineAISettingsTab extends PluginSettingTab { .addOption("codex", "Codex (ChatGPT subscription)") .setValue(this.plugin.settings.provider) .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 | "openai" | "ollama" @@ -80,6 +89,15 @@ export class InlineAISettingsTab extends PluginSettingTab { | "custom" | "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(); this.display(); }), @@ -135,6 +153,13 @@ export class InlineAISettingsTab extends PluginSettingTab { } }); }); + + if (isSignedIn) { + containerEl.createEl("p", { + text: "⚠️ Auth tokens are stored in plaintext in your vault's data.json. Do not commit or share this file.", + cls: "setting-item-description", + }); + } } // Model setting From 15fe7f02c5fdb9b194926265b217c1d66283e609 Mon Sep 17 00:00:00 2001 From: Felix Isaac Lim <38658663+FelixIsaac@users.noreply.github.com> Date: Sat, 23 May 2026 06:37:10 +0800 Subject: [PATCH 5/7] 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 --- src/codex-client.ts | 20 +++++++++++-- src/settings.ts | 71 ++++++++++++++++++++++++++++++++++++++------- 2 files changed, 78 insertions(+), 13 deletions(-) diff --git a/src/codex-client.ts b/src/codex-client.ts index 51d3587..ff2f4d2 100644 --- a/src/codex-client.ts +++ b/src/codex-client.ts @@ -89,6 +89,8 @@ function parseSseText(sseBody: string): string { return parts.join(""); } +const MAX_INPUT_CHARS = 60_000; // ~15k tokens, well under Codex limits + export async function callCodexApi( systemMessage: string, userMessage: string, @@ -96,8 +98,17 @@ export async function callCodexApi( accountId: string, model: string, ): Promise { + 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: [ @@ -109,7 +120,7 @@ export async function callCodexApi( { type: "message", role: "user", - content: [{ type: "input_text", text: userMessage }], + content: [{ type: "input_text", text: truncatedUser }], }, ], instructions: "", @@ -152,7 +163,10 @@ export async function callCodexApi( } const rawText = res.text; - const result = parseSseText(rawText); - if (!result) throw new Error("Codex returned empty response"); + 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; } diff --git a/src/settings.ts b/src/settings.ts index a2a1cd3..9a0aa84 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -164,18 +164,51 @@ export class InlineAISettingsTab extends PluginSettingTab { // Model setting if (this.plugin.settings.provider === "codex") { - const CODEX_MODELS = [ - { value: "gpt-5.5", label: "GPT-5.5 (recommended)" }, - { value: "gpt-5.4-mini", label: "GPT-5.4 mini (faster)" }, + const CODEX_MODELS: { + value: string; + label: string; + desc: string; + }[] = [ + { + 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", }, - { value: "gpt-5.2-codex", label: "GPT-5.2 Codex" }, - { value: "gpt-5.1-codex", label: "GPT-5.1 Codex" }, - { value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" }, - { value: "codex-mini-latest", label: "Codex Mini" }, - { value: "custom", label: "Custom…" }, ]; const isCustom = !CODEX_MODELS.some( (m) => @@ -185,10 +218,16 @@ export class InlineAISettingsTab extends PluginSettingTab { const dropdownValue = isCustom ? "custom" : this.plugin.settings.model; + const selectedModel = CODEX_MODELS.find( + (m) => m.value === dropdownValue, + ); new Setting(containerEl) .setName("Model") - .setDesc("Select a Codex 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) => { @@ -202,16 +241,28 @@ export class InlineAISettingsTab extends PluginSettingTab { 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(); + 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) From 71a6447f989e733225a569f24e49f3a013a6e4c0 Mon Sep 17 00:00:00 2001 From: Felix Isaac Lim <38658663+FelixIsaac@users.noreply.github.com> Date: Sat, 23 May 2026 11:31:56 +0800 Subject: [PATCH 6/7] feat: inject note context (title, heading, surrounding paragraphs) into system prompt --- src/api.ts | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/src/api.ts b/src/api.ts index d23401c..0b6f6cc 100644 --- a/src/api.ts +++ b/src/api.ts @@ -304,6 +304,73 @@ export class ChatApiManager { 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. * @param userPrompt - The transformation prompt (e.g., "Add Emojis"). @@ -346,7 +413,11 @@ export class ChatApiManager { **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); } /** From 05dc2591554d5ca919f42e5050ba6055cc89d5c2 Mon Sep 17 00:00:00 2001 From: Felix Isaac Lim <38658663+FelixIsaac@users.noreply.github.com> Date: Sat, 23 May 2026 11:32:36 +0800 Subject: [PATCH 7/7] feat: built-in slash commands + Ctrl+Shift+Space default hotkey --- src/main.ts | 2 +- src/modules/commands/parser.ts | 7 +++--- src/modules/commands/source.ts | 41 ++++++++++++++++++++++++++++++++-- src/settings.ts | 6 ++++- 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/main.ts b/src/main.ts index a125996..6f98a57 100644 --- a/src/main.ts +++ b/src/main.ts @@ -79,7 +79,7 @@ export default class InlineAIChatPlugin extends Plugin { cmEditor.dispatch({ effects }); } }, - hotkeys: [], + hotkeys: [{ modifiers: ["Ctrl", "Shift"], key: " " }], }); this.addCommand({ id: "accept-tooltip", diff --git a/src/modules/commands/parser.ts b/src/modules/commands/parser.ts index 5057279..e5af489 100644 --- a/src/modules/commands/parser.ts +++ b/src/modules/commands/parser.ts @@ -1,15 +1,16 @@ -import { SlashCommand } from "./source"; +import { SlashCommand, BUILT_IN_COMMANDS } from "./source"; export function parseCommand( userInput: string, prefix: string, customCommands: SlashCommand[], ): string { - for (const command of customCommands) { + const allCommands = [...BUILT_IN_COMMANDS, ...customCommands]; + for (const command of allCommands) { const commandPattern = `${prefix}${command.keyword}`; if (userInput.includes(commandPattern)) { return userInput.replace(commandPattern, command.prompt); } } - return userInput; // Return original text if no command matches + return userInput; } diff --git a/src/modules/commands/source.ts b/src/modules/commands/source.ts index 99410fa..6842d7b 100644 --- a/src/modules/commands/source.ts +++ b/src/modules/commands/source.ts @@ -17,6 +17,41 @@ export interface SlashCommand { 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 function createSlashCommandSource( options: { @@ -28,12 +63,13 @@ function createSlashCommandSource( }, ) { const { prefix, customCommands } = options; + const allCommands = [...BUILT_IN_COMMANDS, ...customCommands]; return (context: CompletionContext) => { let word = context.matchBefore(new RegExp(`^\\${prefix}\\w*`)); if (!word || (word.from == word.to && !context.explicit)) return null; return { from: word.from + 1, - options: customCommands.map((cmd) => ({ + options: allCommands.map((cmd) => ({ label: cmd.keyword, type: undefined, detail: cmd.prompt, @@ -82,7 +118,8 @@ export function createSlashCommandHighlighter({ } buildDecorations(view: EditorView) { - const keywords = customCommands + const allCommands = [...BUILT_IN_COMMANDS, ...customCommands]; + const keywords = allCommands .map((cmd) => cmd.keyword) .join("|"); const regexp = new RegExp(`\\${prefix}(${keywords})\\b`, "g"); diff --git a/src/settings.ts b/src/settings.ts index 9a0aa84..1f2d9d4 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,7 +1,7 @@ import { App, PluginSettingTab, Setting, Notice } from "obsidian"; import MyPlugin from "./main"; 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"; // Interface for the settings @@ -414,6 +414,10 @@ export class InlineAISettingsTab extends PluginSettingTab { containerEl.createEl("p", { 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 new Setting(containerEl)