Skip to content
103 changes: 94 additions & 9 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,10 +219,15 @@ export class ChatApiManager {
}
}

private async callCodexProvider(systemMessage: string, message: string): Promise<string> {
private async callCodexProvider(
systemMessage: string,
message: string,
): Promise<string> {
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.";
}

Expand All @@ -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}`);
Expand Down Expand Up @@ -290,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);
Comment on lines +331 to +342

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use the actual editor selection range for context slicing.

Line 331 can resolve the wrong occurrence when selected text repeats, which may attach unrelated context to the model prompt.

💡 Proposed fix
-			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 sel = cm.state.selection.main;
+			const selectionStart = sel.from;
+			const selectionEnd = sel.to;
+			const before = doc.slice(0, selectionStart);
+			const after = doc.slice(selectionEnd);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api.ts` around lines 331 - 342, Replace the fragile doc.indexOf-based
search with the actual editor selection range: use the provided selection range
start/end (e.g., selectionRange.start and selectionRange.end or the editor's
selectionStart/selectionEnd variables) to compute selectionStart and
selectionEnd, then derive before = doc.slice(0, selectionStart) and after =
doc.slice(selectionEnd); keep the existing fallback to docBeforeCursor or
cursor-based slicing only if the explicit selection range is missing or invalid.
Ensure you update references that currently use selectionStart, selectionText
and cursor so they rely on the canonical selection indices.


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").
Expand Down Expand Up @@ -332,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);
}

/**
Expand Down
91 changes: 69 additions & 22 deletions src/codex-auth.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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))
Expand Down Expand Up @@ -57,8 +60,12 @@ function extractAccountId(accessToken: string): string | null {
return auth?.user_id ?? auth?.account_id ?? null;
}

async function exchangeCode(code: string, verifier: string): Promise<CodexTokens | null> {
const res = await fetch(TOKEN_URL, {
async function exchangeCode(
code: string,
verifier: string,
): Promise<CodexTokens | null> {
const res = await requestUrl({
url: TOKEN_URL,
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
Expand All @@ -67,12 +74,13 @@ async function exchangeCode(code: string, verifier: string): Promise<CodexTokens
code,
code_verifier: verifier,
redirect_uri: REDIRECT_URI,
}),
}).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;

const accountId = extractAccountId(json.access_token);
Expand All @@ -86,20 +94,24 @@ async function exchangeCode(code: string, verifier: string): Promise<CodexTokens
};
}

export async function refreshCodexToken(tokens: CodexTokens): Promise<CodexTokens | null> {
const res = await fetch(TOKEN_URL, {
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.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 {
Expand All @@ -117,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<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");
});
}

export async function startCodexOAuthFlow(): 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();

Expand Down Expand Up @@ -169,7 +207,9 @@ export async function startCodexOAuthFlow(): Promise<CodexTokens | null> {
}

res.writeHead(200, { "Content-Type": "text/html" });
res.end("<html><body><h2>Signed in! You can close this tab.</h2></body></html>");
res.end(
"<html><body><h2>Signed in! You can close this tab.</h2></body></html>",
);

const tokens = await exchangeCode(code, verifier);
if (!tokens) {
Expand All @@ -180,22 +220,29 @@ export async function startCodexOAuthFlow(): Promise<CodexTokens | null> {

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,
);
});
}
Loading
Loading