Merge pull request #17 from FelixIsaac/felix/codex-subscription-provider

feat: Codex provider, note-aware context, built-in slash commands + UX improvements
This commit is contained in:
Barrca 2026-05-23 12:41:15 +02:00 committed by GitHub
commit 053119ed15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 443 additions and 86 deletions

View file

@ -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.";
}
@ -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}`);
@ -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);
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").
@ -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);
}
/**

View file

@ -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";
@ -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,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({
@ -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);
@ -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 {
@ -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();
@ -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) {
@ -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,
);
});
}

View file

@ -1,3 +1,5 @@
import { requestUrl } from "obsidian";
const CODEX_API_URL = "https://chatgpt.com/backend-api/codex/responses";
interface ResponsesInput {
@ -19,13 +21,22 @@ interface ResponsesBody {
function normalizeModel(model: string): string {
const m = model.toLowerCase().trim();
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.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 {
@ -39,25 +50,32 @@ 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);
}
}
// response.output_text.delta — delta is a plain string
if (
json.type === "response.output_text.delta" &&
typeof json.delta === "string"
) {
parts.push(json.delta);
}
// Delta format
const delta = json.delta;
if (delta?.type === "output_text" && delta.text) {
parts.push(delta.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);
}
// Snapshot format (non-streaming final)
if (json.type === "response.completed") {
const output = json.response?.output ?? [];
for (const item of output) {
// 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" && c.text) {
if (
c.type === "output_text" &&
typeof c.text === "string"
) {
parts.push(c.text);
}
}
@ -71,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,
@ -78,8 +98,17 @@ export async function callCodexApi(
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: [
@ -91,7 +120,7 @@ export async function callCodexApi(
{
type: "message",
role: "user",
content: [{ type: "input_text", text: userMessage }],
content: [{ type: "input_text", text: truncatedUser }],
},
],
instructions: "",
@ -102,26 +131,42 @@ 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",
"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,
});
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) {
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 = await res.text();
const result = parseSseText(rawText);
if (!result) throw new Error("Codex returned empty response");
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

@ -79,7 +79,7 @@ export default class InlineAIChatPlugin extends Plugin {
cmEditor.dispatch({ effects });
}
},
hotkeys: [],
hotkeys: [{ modifiers: ["Ctrl", "Shift"], key: " " }],
});
this.addCommand({
id: "accept-tooltip",

View file

@ -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;
}

View file

@ -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");

View file

@ -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
@ -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();
}),
@ -100,7 +118,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,34 +131,152 @@ 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();
}
}
});
});
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
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();
if (this.plugin.settings.provider === "codex") {
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",
},
];
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();
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)
if (
@ -276,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)