Compare commits

..

38 commits
1.1.5 ... 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
Barrca
3912da8047 [release] Bump version to 1.2.4 2025-09-19 17:28:48 +02:00
Barrca
2a0be90245 [fix] Fomatting of exports 2025-09-19 17:27:31 +02:00
Barrca
3e25d3e8d1 [feat] Add Azure OpenAI support to ChatApiManager and update settings interface for Azure configuration. 2025-09-19 17:25:45 +02:00
Barrca
149953d1bd [release] Release 1.2.3 2025-09-12 20:22:03 +02:00
Barrca
59960a9b1c [feat] Enhance extension with focus guard to prevent editor re-renders during widget interactions. This is better than the previous fix. 2025-09-12 20:20:29 +02:00
Barrca
2ec2eabdb2 [release] Release 1.2.2 2025-09-12 18:50:40 +02:00
Barrca
958533a0f0 [fix] Added event listeners to manage focus transitions and prevent unwanted re-renders in the outer editor. Specially for codeblocks 2025-09-12 18:48:49 +02:00
Barrca
ad60944974 Reformat the code with the new prettier config 2025-09-12 18:47:27 +02:00
Barrca
41e73ea28a Refactor project configuration: remove .gptignore, add .prettierignore and .prettierrc, update package-lock.json with new dependencies including husky and lint-staged, and configure GitHub Actions for Prettier checks. Also, set up husky pre-commit hook to run tests. 2025-09-12 18:46:02 +02:00
Barrca
9e95818df3
Merge pull request #11 from FBarrca/dependabot/npm_and_yarn/form-data-4.0.4
Bump form-data from 4.0.1 to 4.0.4
2025-08-22 09:35:31 +02:00
dependabot[bot]
95136efdbb
Bump form-data from 4.0.1 to 4.0.4
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.1 to 4.0.4.
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.1...v4.0.4)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-21 20:55:37 +00:00
Barrca
3f8dda5ac0
Update README.md 2025-07-09 18:49:18 +02:00
Barrca
5e878508cc Update version to 1.2.1 2025-05-17 20:44:37 +02:00
Barrca
46ef109abf Update event listener for FloatingWidget to use window instead of document for click outside detection. 2025-05-17 20:43:16 +02:00
Barrca
f0f35ac3fa Disable icons in autocomplete suggestions 2025-05-17 20:19:51 +02:00
Barrca
495e8742ff Enhance FloatingWidget to manage event listeners for dismissal on click outside and Escape key, including support for popout windows.
Rework to fix issues with dismissing on floating windows outside the main editor.
2025-05-17 20:13:21 +02:00
Barrca
8b37439f84 Improve some CSS issues with popup windows 2025-05-17 19:12:11 +02:00
Barrca
b62e2894d5 Release 1.2.0 2025-05-16 22:44:12 +02:00
Barrca
171994ccef Reinitialize ChatApiManagermessage queue based on settings toggle 2025-05-16 22:43:02 +02:00
Barrca
d2bd638b4e Refactor advanced section header in settings to use createEl method 2025-05-16 22:42:08 +02:00
Barrca
47c92dbd94 Add message history toggle in settings and conditionally enqueue messages in ChatApiManager 2025-05-13 21:51:50 +02:00
Barrca
031efb3ae4 Add basic message history 2025-05-13 21:28:40 +02:00
27 changed files with 3538 additions and 860 deletions

25
.github/workflows/format.yml vendored Normal file
View file

@ -0,0 +1,25 @@
name: Format Check
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
prettier:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
run: npm ci
- name: Prettier check
run: npx prettier --check .

View file

@ -1,57 +1,57 @@
name: Build and Release Obsidian Plugin
on:
push:
tags:
- '*' # Triggers on any tag
push:
tags:
- "*" # Triggers on any tag
env:
PLUGIN_NAME: obsidian-inlineAI
PLUGIN_NAME: obsidian-inlineAI
jobs:
build-and-release:
runs-on: ubuntu-latest
build-and-release:
runs-on: ubuntu-latest
steps:
# 1. Checkout the repository
- name: Checkout Repository
uses: actions/checkout@v3
steps:
# 1. Checkout the repository
- name: Checkout Repository
uses: actions/checkout@v3
# 2. Set up Node.js environment with caching
- name: Set Up Node.js
uses: actions/setup-node@v3
with:
node-version: 'lts/*' # Use the latest LTS version
cache: 'npm'
# 2. Set up Node.js environment with caching
- name: Set Up Node.js
uses: actions/setup-node@v3
with:
node-version: "lts/*" # Use the latest LTS version
cache: "npm"
# 3. Install dependencies and build the project
- name: Install and Build
run: |
npm ci
npm run build --if-present
# 3. Install dependencies and build the project
- name: Install and Build
run: |
npm ci
npm run build --if-present
# 4. Prepare release assets
- name: Prepare Release Assets
run: |
mkdir -p $PLUGIN_NAME
cp main.js manifest.json styles.css $PLUGIN_NAME
zip -r ${PLUGIN_NAME}.zip $PLUGIN_NAME
# 4. Prepare release assets
- name: Prepare Release Assets
run: |
mkdir -p $PLUGIN_NAME
cp main.js manifest.json styles.css $PLUGIN_NAME
zip -r ${PLUGIN_NAME}.zip $PLUGIN_NAME
# 5. Retrieve the tag name
- name: Get Tag Name
id: get_tag
run: echo "tag_name=$(git describe --tags --abbrev=0)" >> $GITHUB_OUTPUT
# 5. Retrieve the tag name
- name: Get Tag Name
id: get_tag
run: echo "tag_name=$(git describe --tags --abbrev=0)" >> $GITHUB_OUTPUT
# 6. Create a new release and upload all assets in one step
- name: Create Release and Upload Assets
uses: ncipollo/release-action@v1
with:
tag: ${{ steps.get_tag.outputs.tag_name }}
name: ${{ steps.get_tag.outputs.tag_name }}
artifacts: |
${{ env.PLUGIN_NAME }}.zip
main.js
manifest.json
styles.css
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# 6. Create a new release and upload all assets in one step
- name: Create Release and Upload Assets
uses: ncipollo/release-action@v1
with:
tag: ${{ steps.get_tag.outputs.tag_name }}
name: ${{ steps.get_tag.outputs.tag_name }}
artifacts: |
${{ env.PLUGIN_NAME }}.zip
main.js
manifest.json
styles.css
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,4 +0,0 @@
node_modules/*
.git/*
package-lock.json
main.js

0
.husky/pre-commit Normal file
View file

5
.prettierignore Normal file
View file

@ -0,0 +1,5 @@
node_modules
dist
build
main.js

1
.prettierrc Normal file
View file

@ -0,0 +1 @@
{}

View file

@ -59,15 +59,14 @@
### Initial Setup
1. **Set up your API key**:
- Open the plugin settings in Obsidian.
- Enter your API key for OpenAI, Ollama, Gemini or any supported model provider.
- Open the plugin settings in Obsidian.
- Enter your API key for OpenAI, Ollama, Gemini or any supported model provider.
2. **Choose a model**:
- Supported models include `gpt-4`, `llama3.2`, and others.
- Supported models include `gpt-4`, `llama3.2`, and others.
3. **Configure prompts**:
- Define system and transformation prompts in settings for customized interactions.
- Define system and transformation prompts in settings for customized interactions.
### How to Use
@ -76,18 +75,6 @@
---
---
## 🔮 Roadmap
InlineAI is actively evolving! Heres whats next:
- **Tool use**: Save your commonly used tools as commands for easy use.
Stay updated on our progress via the [GitHub Project Board](https://github.com/FBarrca/obsidian-inlineai/projects).
---
## 🙏 Feedback and Support
We value your feedback and aim to make InlineAI the ultimate AI writing assistant:
@ -109,6 +96,7 @@ Want to contribute? Heres how:
git clone https://github.com/FBarrca/obsidian-inlineai.git
```
2. Install dependencies:
```bash
@ -116,6 +104,7 @@ Want to contribute? Heres how:
npm install
```
3. Build the plugin:
```bash

View file

@ -1,8 +1,8 @@
{
"id": "inlineai",
"name": "InlineAI",
"version": "1.1.5",
"minAppVersion": "0.15.0",
"version": "1.2.7",
"minAppVersion": "1.11.4",
"description": "AI-powered suggestions, contextual edits, and advanced text transformations directly into your editor.",
"author": "FBarrca",
"authorUrl": "https://github.com/FBarrca/",

839
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,15 @@
{
"name": "obsidian-inlineai",
"version": "1.1.5",
"version": "1.2.7",
"description": "Cursor or Copilot like Inline AI interface for Obsidian",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
"version": "node version-bump.mjs && git add manifest.json versions.json",
"format": "prettier --write .",
"format:check": "prettier --check .",
"prepare": "husky"
},
"keywords": [],
"author": "FBarrca",
@ -18,9 +21,17 @@
"@typescript-eslint/parser": "^8.11.0",
"builtin-modules": "^4.0.0",
"esbuild": "^0.25.0",
"husky": "^9.1.7",
"lint-staged": "^15.5.2",
"obsidian": "latest",
"prettier": "^3.6.2",
"typescript": "^5.6.3"
},
"lint-staged": {
"*.{ts,tsx,js,jsx,css,scss,md,json,yml,yaml}": [
"prettier --write"
]
},
"dependencies": {
"@codemirror/autocomplete": "^6.18.4",
"@codemirror/commands": "^6.8.0",

View file

@ -1,196 +1,439 @@
// api.ts
import { ChatOpenAI } from "@langchain/openai";
import { ChatOpenAI, AzureChatOpenAI } from "@langchain/openai";
import { ChatOllama } from "@langchain/ollama";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { SystemMessage, HumanMessage, AIMessage } from "@langchain/core/messages";
import {
SystemMessage,
HumanMessage,
AIMessage,
} from "@langchain/core/messages";
import { InlineAISettings } from "./settings";
import { App, MarkdownView, Notice } from "obsidian";
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 { parseCommand } from "./modules/commands/parser";
import { MessageQueue } from "./modules/messageHistory/queue";
const MESSAGE_HISTORY_LIMIT = 20;
export type HistoryMessage = {
mode: string;
userPrompt: string;
};
/**
* Class to manage interactions with different chat APIs.
*/
export class ChatApiManager {
private chatClient: ChatOpenAI | ChatOllama | ChatGoogleGenerativeAI | null;
private app: App;
private settings: InlineAISettings;
private chatClient:
| ChatOpenAI
| ChatOllama
| ChatGoogleGenerativeAI
| AzureChatOpenAI
| null;
private app: App;
private settings: InlineAISettings;
private messageHistory: MessageQueue<HistoryMessage>;
/**
* Initializes the ChatApiManager with the given settings.
* @param settings - Configuration settings for the chat API.
* @param app - The Obsidian App instance.
*/
constructor(settings: InlineAISettings, app: App) {
this.app = app;
this.chatClient = this.initializeChatClient(settings);
this.settings = settings;
this.messageHistory = new MessageQueue<HistoryMessage>(
MESSAGE_HISTORY_LIMIT,
);
}
/**
* Initializes the ChatApiManager with the given settings.
* @param settings - Configuration settings for the chat API.
* @param app - The Obsidian App instance.
*/
constructor(settings: InlineAISettings, app: App) {
this.app = app;
this.chatClient = this.initializeChatClient(settings);
this.settings = settings;
}
/**
* Extracts the instance name from an Azure endpoint URL.
* @param endpoint - The Azure endpoint URL.
* @returns The instance name or null if invalid.
*/
private extractAzureInstanceName(endpoint: string): string | null {
const trimmedEndpoint = endpoint.trim();
/**
* Initializes the appropriate chat client based on the provider specified in settings.
* @param settings - Configuration settings for the chat API.
* @returns An instance of ChatOpenAI, ChatOllama, or null if initialization fails.
*/
private initializeChatClient(settings: InlineAISettings): ChatOpenAI | ChatOllama | ChatGoogleGenerativeAI | null {
try {
switch (settings.provider) {
case "openai":
if (!settings.apiKey) {
new Notice("⚠️ OpenAI API key is required. Please check your settings.");
return null;
}
return new ChatOpenAI({
modelName: settings.model,
temperature: 0,
apiKey: settings.apiKey,
});
// Match both openai.azure.com and cognitiveservices.azure.com formats
const openaiMatch = trimmedEndpoint.match(
/https:\/\/([^.]+)\.openai\.azure\.com/,
);
if (openaiMatch) {
return openaiMatch[1];
}
case "ollama":
return new ChatOllama({
model: settings.model,
});
case "gemini":
return new ChatGoogleGenerativeAI({
model: settings.model,
apiKey: settings.apiKey,
});
case "custom":
if (!settings.apiKey || !settings.customURL) {
new Notice("⚠️ API key and custom base URL are required for custom providers.");
return null;
}
return new ChatOpenAI({
modelName: settings.model,
temperature: 0,
openAIApiKey: settings.apiKey,
// 'configuration.basePath' is the recognized property
configuration: {
baseURL: settings.customURL.trim(),
},
});
const cognitiveservicesMatch = trimmedEndpoint.match(
/https:\/\/([^.]+)\.cognitiveservices\.azure\.com/,
);
if (cognitiveservicesMatch) {
return cognitiveservicesMatch[1];
}
default:
new Notice(`⚠️ Unsupported provider: ${settings.provider}`);
return null;
}
} catch (error: any) {
console.error("Error initializing chat client:", error);
new Notice(`❌ Error initializing chat client: ${error.message}`);
return null;
}
}
return null;
}
/**
* Calls the chat API with the provided content and context.
* @param systemMessage - The system message to send to the chat API.
* @param message - The user's message to send to the chat API.
* @returns A promise that resolves with the generated content or an error message.
*/
public async callApi(systemMessage: string, message: string): Promise<string> {
if (!this.chatClient) {
new Notice("⚠️ Chat client is not initialized. Please check your settings.");
return "⚠️ Chat client is not available.";
}
/**
* Initializes the appropriate chat client based on the provider specified in settings.
* @param settings - Configuration settings for the chat API.
* @returns An instance of ChatOpenAI, ChatOllama, AzureChatOpenAI, or null if initialization fails.
*/
private initializeChatClient(
settings: InlineAISettings,
):
| ChatOpenAI
| ChatOllama
| ChatGoogleGenerativeAI
| AzureChatOpenAI
| null {
try {
if (settings.messageHistory) {
this.messageHistory = new MessageQueue<HistoryMessage>(
MESSAGE_HISTORY_LIMIT,
);
} else {
this.messageHistory = new MessageQueue<HistoryMessage>(0);
}
const messages = [
new SystemMessage(systemMessage),
new HumanMessage(message),
];
switch (settings.provider) {
case "openai": {
const apiKey = getApiKey(this.app);
if (!apiKey) {
new Notice(
"⚠️ OpenAI API key is required. Please check your settings.",
);
return null;
}
return new ChatOpenAI({
modelName: settings.model,
temperature: 0,
apiKey,
});
}
try {
const aiMessage: AIMessage = await this.chatClient.invoke(messages);
return aiMessage.content.toString();
} catch (error: any) {
console.error("Error calling the chat model:", error);
new Notice(`❌ Error calling the chat model: ${error.message}`);
return "⚠️ Failed to generate a response. Please try again later.";
}
}
case "ollama":
return new ChatOllama({
model: settings.model,
});
case "gemini": {
const apiKey = getApiKey(this.app);
return new ChatGoogleGenerativeAI({
model: settings.model,
apiKey: apiKey ?? undefined,
});
}
case "azure": {
const apiKey = getApiKey(this.app);
if (!apiKey || !settings.azureEndpoint) {
new Notice(
"⚠️ API key and Azure endpoint are required for Azure provider.",
);
return null;
}
/**
* Handles user input and updates the editor with the response.
* @param systemPrompt - The system prompt to send to the chat API.
* @param userRequest - The user's request to process.
* @returns The AI-generated response or an error message.
*/
private async handleEditorUpdate(systemPrompt: string, userRequest: string): Promise<string> {
try {
const response = await this.callApi(systemPrompt, userRequest);
if (!response) return "⚠️ No response generated.";
// Extract instance name from the endpoint URL
const instanceName = this.extractAzureInstanceName(
settings.azureEndpoint,
);
if (!instanceName) {
new Notice(
"⚠️ Invalid Azure endpoint format. Expected: https://your-resource.openai.azure.com",
);
return null;
}
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!markdownView) {
new Notice("⚠️ No active Markdown editor found.");
return "";
}
return new AzureChatOpenAI({
azureOpenAIApiKey: apiKey,
azureOpenAIApiInstanceName: instanceName,
azureOpenAIApiDeploymentName: settings.model,
azureOpenAIApiVersion:
settings.azureApiVersion || "2024-02-15-preview",
temperature: 0,
});
}
case "custom": {
const apiKey = getApiKey(this.app);
if (!apiKey || !settings.customURL) {
new Notice(
"⚠️ API key and custom base URL are required for custom providers.",
);
return null;
}
return new ChatOpenAI({
modelName: settings.model,
temperature: 0,
openAIApiKey: apiKey,
// 'configuration.basePath' is the recognized property
configuration: {
baseURL: settings.customURL.trim(),
},
});
}
const mainEditorView = (markdownView.editor as any).cm as EditorView;
mainEditorView?.dispatch({
effects: setGeneratedResponseEffect.of({ airesponse: response, prompt: userRequest }),
});
case "codex":
// Handled directly in callApi — no LangChain client needed
return null;
return response;
} catch (error: any) {
console.error("Error processing request:", error);
new Notice(`❌ Error processing request: ${error.message}`);
return "⚠️ Failed to process request.";
}
}
default:
new Notice(`⚠️ Unsupported provider: ${settings.provider}`);
return null;
}
} catch (error: any) {
console.error("Error initializing chat client:", error);
new Notice(`❌ Error initializing chat client: ${error.message}`);
return null;
}
}
/**
* Handles user input and generates a response using the cursor API.
* @param userRequest - The user's request to process.
* @returns The AI-generated response or an error message.
*/
public async callCursor(userRequest: string): Promise<string> {
const systemPrompt = "You are a helpful assistant. Please help the user with the following request:";
return this.handleEditorUpdate(systemPrompt, userRequest);
}
/**
* Calls the chat API with the provided content and context.
* @param systemMessage - The system message to send to the chat API.
* @param message - The user's message to send to the chat API.
* @returns A promise that resolves with the generated content or an error message.
*/
public async callApi(
systemMessage: string,
message: string,
): Promise<string> {
if (this.settings.provider === "codex") {
return this.callCodexProvider(systemMessage, message);
}
/**
* Processes selected text using the specified prompt and transformation.
* @param prompt - The transformation prompt (e.g., "Add Emojis").
* @param selectedText - The selected text to transform.
* @returns The transformed text or an error message.
*/
public async callSelection(prompt: string, selectedText: string): Promise<string> {
prompt = parseCommand(prompt, this.settings.commandPrefix, this.settings.customCommands);
if (!this.chatClient) {
new Notice(
"⚠️ Chat client is not initialized. Please check your settings.",
);
return "⚠️ Chat client is not available.";
}
let isCursor = false;
if (selectedText.trim().length === 0) {
isCursor = true;
}
const messages = [
new SystemMessage(systemMessage),
new HumanMessage(message),
];
const systemPrompt = isCursor ? this.settings.cursorPrompt : this.settings.selectionPrompt;
let userPrompt = ``;
try {
const aiMessage = await this.chatClient.invoke(messages);
if (typeof aiMessage === "string") {
return aiMessage;
}
return aiMessage.content.toString();
} catch (error: any) {
console.error("Error calling the chat model:", error);
new Notice(`❌ Error calling the chat model: ${error.message}`);
return "⚠️ Failed to generate a response. Please try again later.";
}
}
if (isCursor) {
userPrompt = `
**Task:** ${prompt}
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.
* @param systemPrompt - The system prompt to send to the chat API.
* @param userRequest - The user's request to process.
* @returns The AI-generated response or an error message.
*/
private async handleEditorUpdate(
systemPrompt: string,
userRequest: string,
): Promise<string> {
try {
const response = await this.callApi(systemPrompt, userRequest);
if (!response) return "⚠️ No response generated.";
const markdownView =
this.app.workspace.getActiveViewOfType(MarkdownView);
if (!markdownView) {
new Notice("⚠️ No active Markdown editor found.");
return "";
}
const mainEditorView = (markdownView.editor as any)
.cm as EditorView;
mainEditorView?.dispatch({
effects: setGeneratedResponseEffect.of({
airesponse: response,
prompt: userRequest,
}),
});
return response;
} catch (error: any) {
console.error("Error processing request:", error);
new Notice(`❌ Error processing request: ${error.message}`);
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").
* @param selectedText - The selected text to transform.
* @returns The transformed text or an error message.
*/
public async callSelection(
userPrompt: string,
selectedText: string,
): Promise<string> {
userPrompt = parseCommand(
userPrompt,
this.settings.commandPrefix,
this.settings.customCommands,
);
let isCursor = false;
if (selectedText.trim().length === 0) {
isCursor = true;
}
const systemPrompt = isCursor
? this.settings.cursorPrompt
: this.settings.selectionPrompt;
let finalUserPrompt = ``;
const mode = isCursor ? "cursor" : "selection";
if (this.settings.messageHistory) {
this.messageHistory.enqueue({ mode, userPrompt });
}
if (isCursor) {
finalUserPrompt = `
**Task:** ${userPrompt}
**Output:**`;
} else {
userPrompt = `
**Task:** ${prompt}
} else {
finalUserPrompt = `
**Task:** ${userPrompt}
**Input:**
${selectedText}
**Output:**`;
}
return this.handleEditorUpdate(systemPrompt, userPrompt);
}
}
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);
}
/**
* Updates the manager's settings and reinitializes the chat client.
* @param settings - New configuration settings for the chat API.
*/
public updateSettings(settings: InlineAISettings): void {
this.settings = settings;
const newChatClient = this.initializeChatClient(settings);
if (!newChatClient) {
return;
}
this.chatClient = newChatClient;
}
/**
* Updates the manager's settings and reinitializes the chat client.
* @param settings - New configuration settings for the chat API.
*/
public updateSettings(settings: InlineAISettings): void {
this.settings = settings;
const newChatClient = this.initializeChatClient(settings);
if (!newChatClient) {
return;
}
this.chatClient = newChatClient;
}
public getMessageHistory(): HistoryMessage[] {
return this.messageHistory.getItems();
}
}

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

@ -30,7 +30,7 @@ It is **very important** that you follow the examples. Do not add anything at th
| John | 30 | Engineer|
---
`
`;
export const cursorPrompt = `
You are an advanced language model specialized in following specific instructions to create and process markdown documents. Always use **Obsidian-flavored Markdown** syntax in your responses whenever applicable.

View file

@ -1,12 +1,26 @@
// main.ts
import { Plugin, MarkdownView, App } from "obsidian";
import { EditorView } from "@codemirror/view";
import { InlineAISettings, DEFAULT_SETTINGS, InlineAISettingsTab } from "./settings";
import { acceptTooltipEffect, commandEffect, dismissTooltipEffect, FloatingTooltipExtension } from "./modules/WidgetExtension";
import {
InlineAISettings,
DEFAULT_SETTINGS,
InlineAISettingsTab,
} from "./settings";
import {
acceptTooltipEffect,
commandEffect,
dismissTooltipEffect,
FloatingTooltipExtension,
} from "./modules/WidgetExtension";
import { ChatApiManager } from "./api";
import { generatedResponseState } from "./modules/AIExtension";
import { buildSelectionHiglightState, currentSelectionState, setSelectionInfoEffect } from "./modules/SelectionState";
import {
buildSelectionHiglightState,
currentSelectionState,
setSelectionInfoEffect,
} from "./modules/SelectionState";
import { diffExtension } from "./modules/diffExtension";
import { migrateLegacySecrets, LegacySecretFields } from "./credentials";
export default class InlineAIChatPlugin extends Plugin {
settings: InlineAISettings = DEFAULT_SETTINGS;
@ -21,7 +35,7 @@ export default class InlineAIChatPlugin extends Plugin {
generatedResponseState,
currentSelectionState,
buildSelectionHiglightState,
diffExtension
diffExtension,
]);
// Add command to show tooltip
@ -29,9 +43,11 @@ export default class InlineAIChatPlugin extends Plugin {
id: "show-cursor-tooltip",
name: "Show cursor tooltip",
callback: () => {
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
const markdownView =
this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
const cmEditor = (markdownView.editor as any).cm as EditorView;
const cmEditor = (markdownView.editor as any)
.cm as EditorView;
// Grab the main selection range
const { from, to } = cmEditor.state.selection.main;
@ -39,13 +55,22 @@ export default class InlineAIChatPlugin extends Plugin {
if (from !== to) {
// If there is a real selection, store it
const selectedText = cmEditor.state.doc.sliceString(from, to);
const selectedText = cmEditor.state.doc.sliceString(
from,
to,
);
effects.push(
setSelectionInfoEffect.of({ from, to, text: selectedText })
setSelectionInfoEffect.of({
from,
to,
text: selectedText,
}),
);
} else {
// If no selection, store cursor position instead of null
effects.push(setSelectionInfoEffect.of({ from, to, text: "" }));
effects.push(
setSelectionInfoEffect.of({ from, to, text: "" }),
);
}
// Also trigger the overlay
@ -55,18 +80,22 @@ export default class InlineAIChatPlugin extends Plugin {
cmEditor.dispatch({ effects });
}
},
hotkeys: [
],
hotkeys: [{ modifiers: ["Ctrl", "Shift"], key: " " }],
});
this.addCommand({
id: "accept-tooltip",
name: "Accept tooltip suggestion",
callback: () => {
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
const markdownView =
this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
const cmEditor = (markdownView.editor as any).cm as EditorView;
const cmEditor = (markdownView.editor as any)
.cm as EditorView;
const response = cmEditor.state.field(generatedResponseState, false);
const response = cmEditor.state.field(
generatedResponseState,
false,
);
if (response) {
cmEditor.dispatch({
effects: acceptTooltipEffect.of(null),
@ -76,25 +105,28 @@ export default class InlineAIChatPlugin extends Plugin {
});
}
}
}
},
});
this.addCommand({
id: "discard-tooltip",
name: "Discard tooltip suggestion",
callback: () => {
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
const markdownView =
this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
const cmEditor = (markdownView.editor as any).cm as EditorView;
const response = cmEditor.state.field(generatedResponseState, false);
const cmEditor = (markdownView.editor as any)
.cm as EditorView;
const response = cmEditor.state.field(
generatedResponseState,
false,
);
if (response) {
cmEditor.dispatch({
effects: dismissTooltipEffect.of(null),
});
}
}
}
},
});
// Add settings tab
@ -106,7 +138,11 @@ export default class InlineAIChatPlugin extends Plugin {
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
const raw: LegacySecretFields = (await this.loadData()) ?? {};
if (migrateLegacySecrets(this.app, raw)) {
await this.saveData(raw);
}
this.settings = Object.assign({}, DEFAULT_SETTINGS, raw);
}
async saveSettings() {

View file

@ -1,38 +1,36 @@
import {
StateEffect,
StateField,
} from "@codemirror/state";
import { StateEffect, StateField } from "@codemirror/state";
import { dismissTooltipEffect } from "./WidgetExtension";
// Custom structure that has a airesponse and context fields
export interface AIResponse {
airesponse: string;
prompt: string;
airesponse: string;
prompt: string;
}
/**
* State Effect to set the AI response.
*/
export const setGeneratedResponseEffect = StateEffect.define<AIResponse | null>();
export const setGeneratedResponseEffect =
StateEffect.define<AIResponse | null>();
// State field of type text to store the response from the API
export const generatedResponseState = StateField.define<AIResponse | null>({
create() {
return null;
},
update(value, tr) {
// Check if the transaction contains an effect to set the response
if (tr.effects.some((e) => e.is(setGeneratedResponseEffect))) {
const effect = tr.effects.find((e) => e.is(setGeneratedResponseEffect));
return effect ? effect.value : value;
}
// if we geta dismmisTooltipEffect we should clear the response
if (tr.effects.some((e) => e.is(dismissTooltipEffect))) {
return null;
}
return value;
},
create() {
return null;
},
update(value, tr) {
// Check if the transaction contains an effect to set the response
if (tr.effects.some((e) => e.is(setGeneratedResponseEffect))) {
const effect = tr.effects.find((e) =>
e.is(setGeneratedResponseEffect),
);
return effect ? effect.value : value;
}
// if we geta dismmisTooltipEffect we should clear the response
if (tr.effects.some((e) => e.is(dismissTooltipEffect))) {
return null;
}
return value;
},
});

View file

@ -8,62 +8,67 @@ import { dismissTooltipEffect } from "./WidgetExtension";
* Interface describing the selection range and text.
*/
export interface SelectionInfo {
from: number;
to: number;
text: string;
from: number;
to: number;
text: string;
}
/**
* Effect used to set or clear the selection info.
*/
export const setSelectionInfoEffect = StateEffect.define<SelectionInfo | null>();
export const setSelectionInfoEffect =
StateEffect.define<SelectionInfo | null>();
/**
* Field that holds the most recently preserved selection info.
*/
export const currentSelectionState = StateField.define<SelectionInfo | null>({
create() {
return null;
},
update(value, tr) {
// Look for a setSelectionInfoEffect in this transaction
const effect = tr.effects.find(e => e.is(setSelectionInfoEffect));
if (effect) {
return effect.value;
} else if (tr.effects.some(e => e.is(dismissTooltipEffect))) {
return null;
}
create() {
return null;
},
update(value, tr) {
// Look for a setSelectionInfoEffect in this transaction
const effect = tr.effects.find((e) => e.is(setSelectionInfoEffect));
if (effect) {
return effect.value;
} else if (tr.effects.some((e) => e.is(dismissTooltipEffect))) {
return null;
}
return value;
},
return value;
},
});
/**
* Decoration to highlight the selected text.
*/
const highlightDecoration = Decoration.mark({
class: "cm-selectionBackground", // CSS class for highlighting
class: "cm-selectionBackground", // CSS class for highlighting
});
/**
* StateField that manages the decoration set for highlighting.
*/
export const buildSelectionHiglightState = StateField.define<DecorationSet>({
create(state) {
const info = state.field(currentSelectionState);
if (info && info.from !== info.to) {
console.log("info", info);
return Decoration.set([highlightDecoration.range(info.from, info.to)]);
}
return Decoration.none;
},
update(decos, tr) {
// Check if selectionInfoField has changed
const info = tr.state.field(currentSelectionState);
if (info && info.from !== info.to) {
return Decoration.set([highlightDecoration.range(info.from, info.to)]);
}
return Decoration.none;
},
provide: f => EditorView.decorations.from(f),
create(state) {
const info = state.field(currentSelectionState);
if (info && info.from !== info.to) {
console.log("info", info);
return Decoration.set([
highlightDecoration.range(info.from, info.to),
]);
}
return Decoration.none;
},
update(decos, tr) {
// Check if selectionInfoField has changed
const info = tr.state.field(currentSelectionState);
if (info && info.from !== info.to) {
return Decoration.set([
highlightDecoration.range(info.from, info.to),
]);
}
return Decoration.none;
},
provide: (f) => EditorView.decorations.from(f),
});

View file

@ -1,331 +1,531 @@
// modules/WidgetExtension.ts
import {
EditorState,
StateEffect,
StateField,
} from "@codemirror/state";
import { EditorState, StateEffect, StateField } from "@codemirror/state";
import {
EditorView,
Decoration,
DecorationSet,
WidgetType,
placeholder,
keymap,
EditorView,
Decoration,
DecorationSet,
WidgetType,
placeholder,
keymap,
} from "@codemirror/view";
import { setIcon } from "obsidian";
import { ChatApiManager } from "../api";
import { currentSelectionState, SelectionInfo } from "./SelectionState";
import { createSlashCommandHighlighter, slashCommandAutocompletion } from "./commands/source";
import {
createSlashCommandHighlighter,
slashCommandAutocompletion,
} from "./commands/source";
import InlineAIChatPlugin from "src/main";
// Some existing exports
export const commandEffect = StateEffect.define<null>();
export const dismissTooltipEffect = StateEffect.define<null>();
export const acceptTooltipEffect = StateEffect.define<null>();
class FloatingWidget extends WidgetType {
private chatApiManager: ChatApiManager;
private selectionInfo: SelectionInfo | null;
private plugin: InlineAIChatPlugin;
private chatApiManager: ChatApiManager;
private selectionInfo: SelectionInfo | null;
private plugin: InlineAIChatPlugin;
private outerEditorView: EditorView | null = null;
private outerEditorView: EditorView | null = null;
private dom: HTMLElement;
private innerDom: HTMLElement;
private dom: HTMLElement;
private innerDom: HTMLElement;
private textFieldView?: EditorView;
// Primary Action Buttons
private submitButton!: HTMLButtonElement;
private loaderElement!: HTMLElement;
private textFieldView?: EditorView;
// Primary Action Buttons
private submitButton!: HTMLButtonElement;
private loaderElement!: HTMLElement;
//Secondary Action Buttons
private acceptButton!: HTMLButtonElement;
private discardButton!: HTMLButtonElement;
//Secondary Action Buttons
private acceptButton!: HTMLButtonElement;
private discardButton!: HTMLButtonElement;
constructor(chatApiManager: ChatApiManager, selectionInfo: SelectionInfo | null, plugin: InlineAIChatPlugin) {
super();
this.chatApiManager = chatApiManager;
this.selectionInfo = selectionInfo;
this.plugin = plugin;
// Track the current index in message history for up/down navigation
private messageHistoryIndex: number | null = null;
// Create main DOM structure using createEl
this.dom = createEl("div", { cls: "cm-cursor-overlay", attr: { style: "user-select: none;" } });
this.innerDom = this.dom.createEl("div", { cls: "cm-cursor-overlay-inner" });
}
// Store references to event listeners for cleanup
private onClickOutside: ((event: MouseEvent) => void) | null = null;
private onEscape: ((event: KeyboardEvent) => void) | null = null;
private escapeWindows: Window[] = [];
/**
* Overriding toDOM(view: EditorView) instead of just toDOM().
*/
public override toDOM(view: EditorView): HTMLElement {
// Capture the outer EditorView
this.outerEditorView = view;
// (Focus suppression moved to diffExtension focusGuardPlugin)
this.createPencilIcon();
this.createInputField();
this.createSubmitButton();
this.createLoader();
constructor(
chatApiManager: ChatApiManager,
selectionInfo: SelectionInfo | null,
plugin: InlineAIChatPlugin,
) {
super();
this.chatApiManager = chatApiManager;
this.selectionInfo = selectionInfo;
this.plugin = plugin;
setTimeout(() => {
this.textFieldView?.focus();
}, 0);
// Create main DOM structure using createEl
this.dom = createEl("div", {
cls: "cm-cursor-overlay",
attr: { style: "user-select: none;" },
});
this.innerDom = this.dom.createEl("div", {
cls: "cm-cursor-overlay-inner",
});
}
// Setup "click outside" and "Escape" dismissal
const onClickOutside = (event: MouseEvent) => {
if (!this.dom.contains(event.target as Node)) {
this.dismissTooltip();
}
};
/**
* Overriding toDOM(view: EditorView) instead of just toDOM().
*/
public override toDOM(view: EditorView): HTMLElement {
// Capture the outer EditorView
this.outerEditorView = view;
const onEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
this.dismissTooltip();
}
};
this.createPencilIcon();
this.createInputField();
this.createSubmitButton();
this.createLoader();
document.addEventListener("mousedown", onClickOutside);
document.addEventListener("keydown", onEscape);
setTimeout(() => {
this.textFieldView?.focus();
}, 0);
// Cleanup
this.dom.addEventListener("destroy", () => {
document.removeEventListener("mousedown", onClickOutside);
document.removeEventListener("keydown", onEscape);
});
// Setup "click outside" and "Escape" dismissal
this.onClickOutside = (event: MouseEvent) => {
if (!this.dom.contains(event.target as Node)) {
this.dismissTooltip();
}
};
return this.dom;
}
// Escape key: listen on all windows
this.onEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
this.dismissTooltip();
}
};
public override destroy(): void {
this.textFieldView?.destroy();
this.innerDom.empty();
// Always add to main window
window.addEventListener("mousedown", this.onClickOutside);
window.addEventListener("keydown", this.onEscape);
this.submitButton.remove();
this.loaderElement.remove();
// Try to add to all popout windows (if any)
this.escapeWindows = [window];
// @ts-ignore
if (window.app && window.app.openPopouts instanceof Function) {
// Obsidian 1.4+ openPopouts returns array of popout windows
// @ts-ignore
const popouts: Window[] = window.app.openPopouts();
for (const popout of popouts) {
try {
popout.addEventListener("keydown", this.onEscape!);
this.escapeWindows.push(popout);
} catch (e) {
// ignore
}
}
} else if (
(window as any).app &&
Array.isArray((window as any).app?.workspace?.getWindows?.())
) {
// Legacy: app.workspace.getWindows() returns array of windows
const popouts: Window[] = (
window as any
).app.workspace.getWindows();
for (const popout of popouts) {
try {
popout.addEventListener("keydown", this.onEscape!);
this.escapeWindows.push(popout);
} catch (e) {
// ignore
}
}
}
if (this.acceptButton) this.acceptButton.remove();
if (this.discardButton) this.discardButton.remove();
// Cleanup
this.dom.addEventListener("destroy", () => {
document.removeEventListener("mousedown", this.onClickOutside!);
for (const win of this.escapeWindows) {
try {
win.removeEventListener("keydown", this.onEscape!);
} catch (e) {
// ignore
}
}
this.escapeWindows = [];
});
this.textFieldView = undefined;
this.outerEditorView = null;
}
// Add a body-level flag so we can style/coordinate behavior if needed
document.body.classList.add("inlineai-widget-open");
private dismissTooltip() {
if (this.outerEditorView) {
this.outerEditorView.dispatch({
effects: dismissTooltipEffect.of(null),
});
}
}
// Focus guard is handled globally by diffExtension's focusGuardPlugin
return this.dom;
}
private createPencilIcon() {
if (!this.innerDom.querySelector(".cm-pencil-icon")) {
const icon = this.innerDom.createEl("div", { cls: "cm-pencil-icon" });
setIcon(icon, "pencil");
}
}
public override destroy(): void {
this.textFieldView?.destroy();
this.innerDom.empty();
private createInputField() {
const editorDom = this.innerDom.createEl("div", { cls: "cm-tooltip-editor", attr: { style: "user-select: text;" } });
this.submitButton.remove();
this.loaderElement.remove();
this.textFieldView = new EditorView({
state: EditorState.create({
doc: "",
extensions: [
// 1) Show a placeholder in the input field
placeholder("Ask copilot"),
// 2) Add key bindings (including default ones for typical editor commands)
keymap.of([
{
key: "Enter",
run: () => {
this.submitAction()
return true
},
preventDefault: true,
},
{
key: "Mod-a",
run: (view) => {
const doc = view.state.doc;
view.dispatch({
selection: { anchor: 0, head: doc.length },
});
return true;
},
preventDefault: true,
},
]),
// 3) Enable slash-command autocompletion
slashCommandAutocompletion({
prefix: this.plugin.settings.commandPrefix,
customCommands: this.plugin.settings.customCommands
}),
createSlashCommandHighlighter({
prefix: this.plugin.settings.commandPrefix,
customCommands: this.plugin.settings.customCommands
})
],
}),
parent: editorDom,
})
if (this.acceptButton) this.acceptButton.remove();
if (this.discardButton) this.discardButton.remove();
}
// Remove flag/class
document.body.classList.remove("inlineai-widget-open");
private createSubmitButton() {
this.submitButton = this.innerDom.createEl("button", {
cls: "submit-button tooltip-button",
text: "Submit",
});
setIcon(this.submitButton, "send-horizontal");
// Focus suppression cleanup no longer needed (handled by focusGuardPlugin)
this.submitButton.onclick = () => {
this.submitAction();
};
}
this.textFieldView = undefined;
this.outerEditorView = null;
this.messageHistoryIndex = null;
}
private createLoader() {
this.loaderElement = this.innerDom.createEl("div", { cls: "loader" });
this.toggleLoading(false);
}
private dismissTooltip() {
if (this.outerEditorView) {
this.outerEditorView.dispatch({
effects: dismissTooltipEffect.of(null),
});
}
}
/**
* Handles the submit action by calling the AI with the user input and the selected text.
*/
private submitAction() {
const userPrompt = this.textFieldView?.state.doc.toString() ?? "";
private createPencilIcon() {
if (!this.innerDom.querySelector(".cm-pencil-icon")) {
const icon = this.innerDom.createEl("div", {
cls: "cm-pencil-icon",
});
setIcon(icon, "pencil");
}
}
if (!userPrompt.trim()) {
console.warn("Empty input. Submission aborted.");
return;
}
private createInputField() {
const editorDom = this.innerDom.createEl("div", {
cls: "cm-tooltip-editor",
attr: { style: "user-select: text;" },
});
// Grab the selected text from the stored selection info
const selectedText = this.selectionInfo?.text ?? "";
this.textFieldView = new EditorView({
state: EditorState.create({
doc: "",
extensions: [
// 1) Show a placeholder in the input field
placeholder("Ask copilot"),
// 2) Add key bindings (including default ones for typical editor commands)
keymap.of([
{
key: "Enter",
run: () => {
this.submitAction();
return true;
},
preventDefault: true,
},
{
key: "Mod-a",
run: (view) => {
const doc = view.state.doc;
view.dispatch({
selection: { anchor: 0, head: doc.length },
});
return true;
},
preventDefault: true,
},
{
key: "ArrowUp",
run: () => {
const messageHistory =
this.chatApiManager.getMessageHistory();
if (
messageHistory.length === 0 ||
!this.textFieldView
) {
return true;
}
// Show loader
this.toggleLoading(true);
// If not started, set to last index
if (this.messageHistoryIndex === null) {
this.messageHistoryIndex =
messageHistory.length - 1;
} else if (this.messageHistoryIndex > 0) {
this.messageHistoryIndex--;
}
// Clamp to 0
if (this.messageHistoryIndex < 0)
this.messageHistoryIndex = 0;
this.chatApiManager
.callSelection(userPrompt, selectedText)
.then((aiResponse) => {
this.showActionButtons();
})
.catch((error) => {
console.error("Error calling AI:", error);
})
.finally(() => {
// Hide loader
this.toggleLoading(false);
});
const msg =
messageHistory[this.messageHistoryIndex]
.userPrompt;
this.textFieldView.dispatch({
changes: {
from: 0,
to: this.textFieldView.state.doc.length,
insert: msg,
},
});
// Move cursor to end
this.textFieldView.dispatch({
selection: {
anchor: msg.length,
head: msg.length,
},
});
return true;
},
preventDefault: true,
},
{
key: "ArrowDown",
run: () => {
const messageHistory =
this.chatApiManager.getMessageHistory();
if (
messageHistory.length === 0 ||
!this.textFieldView
) {
return true;
}
if (this.messageHistoryIndex === null) {
// Do nothing if not in history navigation
return true;
}
if (
this.messageHistoryIndex <
messageHistory.length - 1
) {
this.messageHistoryIndex++;
const msg =
messageHistory[this.messageHistoryIndex]
.userPrompt;
this.textFieldView.dispatch({
changes: {
from: 0,
to: this.textFieldView.state.doc
.length,
insert: msg,
},
});
// Move cursor to end
this.textFieldView.dispatch({
selection: {
anchor: msg.length,
head: msg.length,
},
});
} else {
// If at the end, clear the field and exit history navigation
this.messageHistoryIndex = null;
this.textFieldView.dispatch({
changes: {
from: 0,
to: this.textFieldView.state.doc
.length,
insert: "",
},
});
}
return true;
},
preventDefault: true,
},
]),
}
// 3) Enable slash-command autocompletion
slashCommandAutocompletion({
prefix: this.plugin.settings.commandPrefix,
customCommands: this.plugin.settings.customCommands,
}),
createSlashCommandHighlighter({
prefix: this.plugin.settings.commandPrefix,
customCommands: this.plugin.settings.customCommands,
}),
],
}),
parent: editorDom,
});
/**
* Toggles the visibility of the submit button and loader.
* @param isLoading - Whether to show the loader.
*/
private toggleLoading(isLoading: boolean) {
if (isLoading) {
this.submitButton.classList.add("hidden");
this.loaderElement.classList.remove("hidden");
} else {
this.submitButton.classList.remove("hidden");
this.loaderElement.classList.add("hidden");
}
}
// Reset messageHistoryIndex when user types or changes input
this.textFieldView.dom.addEventListener("input", () => {
// Only reset if the input is not the result of an up/down navigation
// (i.e., if the value doesn't match the current history entry)
// But for simplicity, always reset if user types
this.messageHistoryIndex = null;
});
}
private createSubmitButton() {
this.submitButton = this.innerDom.createEl("button", {
cls: "submit-button tooltip-button",
text: "Submit",
});
setIcon(this.submitButton, "send-horizontal");
/**
* Transitions the widget to show Accept, Discard, and Reload buttons.
*/
private showActionButtons() {
this.submitButton.classList.add("hidden");
this.createAcceptButton();
this.createDiscardButton();
}
this.submitButton.onclick = () => {
this.submitAction();
};
}
/**
* Creates the Accept button.
*/
private createAcceptButton() {
if (!this.acceptButton) {
this.acceptButton = this.innerDom.createEl("button", {
cls: "accept-button tooltip-button primary-action",
text: "Accept",
});
setIcon(this.acceptButton, "check");
private createLoader() {
this.loaderElement = this.innerDom.createEl("div", { cls: "loader" });
this.toggleLoading(false);
}
this.acceptButton.onclick = () => {
this.acceptAction();
};
/**
* Handles the submit action by calling the AI with the user input and the selected text.
*/
private submitAction() {
// Before doing anything, return focus to the main editor to prevent
// Obsidian from re-rendering code blocks due to the editor losing focus.
// After submitting, users typically review diffs and click Accept/Discard,
// so keeping focus on the outer editor avoids preview toggles.
this.outerEditorView?.focus();
this.innerDom.appendChild(this.acceptButton);
}
}
const userPrompt = this.textFieldView?.state.doc.toString() ?? "";
/**
* Creates the Discard button.
*/
private createDiscardButton() {
if (!this.discardButton) {
this.discardButton = this.innerDom.createEl("button", {
cls: "discard-button tooltip-button",
text: "Discard",
});
setIcon(this.discardButton, "cross");
if (!userPrompt.trim()) {
console.warn("Empty input. Submission aborted.");
return;
}
this.discardButton.onclick = () => {
this.discardAction();
};
// Grab the selected text from the stored selection info
const selectedText = this.selectionInfo?.text ?? "";
this.innerDom.appendChild(this.discardButton);
}
}
// Show loader
this.toggleLoading(true);
this.chatApiManager
.callSelection(userPrompt, selectedText)
.then((aiResponse) => {
this.showActionButtons();
})
.catch((error) => {
console.error("Error calling AI:", error);
})
.finally(() => {
// Hide loader
this.toggleLoading(false);
});
/**
* Handles the Accept action.
* Confirms the result, applies changes, and closes the tooltip.
*/
private acceptAction() {
if (this.outerEditorView) {
this.outerEditorView.dispatch({
effects: acceptTooltipEffect.of(null),
});
}
this.dismissTooltip();
}
// Reset message history navigation after submit
this.messageHistoryIndex = null;
}
private discardAction() {
this.dismissTooltip();
}
/**
* Toggles the visibility of the submit button and loader.
* @param isLoading - Whether to show the loader.
*/
private toggleLoading(isLoading: boolean) {
if (isLoading) {
this.submitButton.classList.add("hidden");
this.loaderElement.classList.remove("hidden");
} else {
this.submitButton.classList.remove("hidden");
this.loaderElement.classList.add("hidden");
}
}
/**
* Transitions the widget to show Accept, Discard, and Reload buttons.
*/
private showActionButtons() {
this.submitButton.classList.add("hidden");
this.createAcceptButton();
this.createDiscardButton();
}
/**
* Creates the Accept button.
*/
private createAcceptButton() {
if (!this.acceptButton) {
this.acceptButton = this.innerDom.createEl("button", {
cls: "accept-button tooltip-button primary-action",
text: "Accept",
});
setIcon(this.acceptButton, "check");
// Prevent the button from stealing focus from the editor
this.acceptButton.addEventListener("mousedown", (e) => {
e.preventDefault();
e.stopPropagation();
});
this.acceptButton.onclick = () => {
this.acceptAction();
};
this.innerDom.appendChild(this.acceptButton);
}
}
/**
* Creates the Discard button.
*/
private createDiscardButton() {
if (!this.discardButton) {
this.discardButton = this.innerDom.createEl("button", {
cls: "discard-button tooltip-button",
text: "Discard",
});
setIcon(this.discardButton, "cross");
// Prevent the button from stealing focus from the editor
this.discardButton.addEventListener("mousedown", (e) => {
e.preventDefault();
e.stopPropagation();
});
this.discardButton.onclick = () => {
this.discardAction();
};
this.innerDom.appendChild(this.discardButton);
}
}
/**
* Handles the Accept action.
* Confirms the result, applies changes, and closes the tooltip.
*/
private acceptAction() {
if (this.outerEditorView) {
this.outerEditorView.dispatch({
effects: acceptTooltipEffect.of(null),
});
}
this.dismissTooltip();
}
private discardAction() {
this.dismissTooltip();
}
}
/**
* Build decorations for the first non-empty selection range.
*/
function renderFloatingWidget(
state: EditorState,
chatApiManager: ChatApiManager,
plugin: InlineAIChatPlugin
state: EditorState,
chatApiManager: ChatApiManager,
plugin: InlineAIChatPlugin,
): DecorationSet {
const firstSelectedRange = state.selection.ranges.find((range) => !range.empty) ?? state.selection.main;
const firstSelectedRange =
state.selection.ranges.find((range) => !range.empty) ??
state.selection.main;
const selectionInfo = state.field(currentSelectionState, false) ?? null;
const selectionInfo = state.field(currentSelectionState, false) ?? null;
const deco = Decoration.widget({
widget: new FloatingWidget(chatApiManager, selectionInfo, plugin),
above: true,
inline: true,
side: -9999,
}).range(firstSelectedRange.from);
const deco = Decoration.widget({
widget: new FloatingWidget(chatApiManager, selectionInfo, plugin),
above: true,
inline: true,
return Decoration.set([deco]);
side: -1,
}).range(firstSelectedRange.from);
return Decoration.set([deco]);
}
/**
@ -338,30 +538,36 @@ function renderFloatingWidget(
* When the user dismisses the tooltip, it clears the decoration set.
* Otherwise, it returns the existing decoration set.
*/
function FloatingTooltipState(chatApiManager: ChatApiManager, plugin: InlineAIChatPlugin) {
return StateField.define<DecorationSet>({
create(state) {
return Decoration.none;
},
update(decorations, tr) {
// Recompute if the user triggers the command
if (tr.effects.some((e) => e.is(commandEffect))) {
return renderFloatingWidget(tr.state, chatApiManager, plugin);
}
// Or dismiss it
if (tr.effects.some((e) => e.is(dismissTooltipEffect))) {
return Decoration.none;
}
// Otherwise, return the existing overlay
return decorations;
},
provide: (field) => EditorView.decorations.from(field),
});
function FloatingTooltipState(
chatApiManager: ChatApiManager,
plugin: InlineAIChatPlugin,
) {
return StateField.define<DecorationSet>({
create(state) {
return Decoration.none;
},
update(decorations, tr) {
// Recompute if the user triggers the command
if (tr.effects.some((e) => e.is(commandEffect))) {
return renderFloatingWidget(tr.state, chatApiManager, plugin);
}
// Or dismiss it
if (tr.effects.some((e) => e.is(dismissTooltipEffect))) {
return Decoration.none;
}
// Otherwise, return the existing overlay
return decorations;
},
provide: (field) => EditorView.decorations.from(field),
});
}
/**
* Extension enabling selection overlay widgets.
*/
export function FloatingTooltipExtension(chatApiManager: ChatApiManager, plugin: InlineAIChatPlugin) {
return [FloatingTooltipState(chatApiManager, plugin)];
export function FloatingTooltipExtension(
chatApiManager: ChatApiManager,
plugin: InlineAIChatPlugin,
) {
return [FloatingTooltipState(chatApiManager, plugin)];
}

View file

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

View file

@ -1,85 +1,155 @@
import { autocompletion, CompletionContext } from "@codemirror/autocomplete"
import { Extension, Range } from "@codemirror/state"
import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view"
import { autocompletion, CompletionContext } from "@codemirror/autocomplete";
import { Extension, Range } from "@codemirror/state";
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
} from "@codemirror/view";
/**
*
*
* Interface describing a slash command.
*/
export interface SlashCommand {
keyword: string;
prompt: string;
keyword: 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
function createSlashCommandSource(options: {
prefix: string,
customCommands: SlashCommand[]
} = {
prefix: '/',
customCommands: []
}) {
const { prefix, customCommands } = options;
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 => ({
label: cmd.keyword,
type: undefined,
detail: cmd.prompt,
}))
}
}
function createSlashCommandSource(
options: {
prefix: string;
customCommands: SlashCommand[];
} = {
prefix: "/",
customCommands: [],
},
) {
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: allCommands.map((cmd) => ({
label: cmd.keyword,
type: undefined,
detail: cmd.prompt,
})),
};
};
}
// Create the extension that uses our custom completion source.
export function slashCommandAutocompletion(options: { prefix: string, customCommands: SlashCommand[] } = { prefix: '/', customCommands: [] }) {
return autocompletion({
override: [createSlashCommandSource(options)],
tooltipClass: () => "tooltip-autocomplete",
optionClass: () => "completion-label",
})
export function slashCommandAutocompletion(
options: { prefix: string; customCommands: SlashCommand[] } = {
prefix: "/",
customCommands: [],
},
) {
return autocompletion({
override: [createSlashCommandSource(options)],
tooltipClass: () => "tooltip-autocomplete",
optionClass: () => "completion-label",
icons: false,
});
}
// Create a decoration for highlighting slash commands
const slashCommandMark = Decoration.mark({ class: "cm-slash-command" })
const slashCommandMark = Decoration.mark({ class: "cm-slash-command" });
export function createSlashCommandHighlighter({ prefix, customCommands }: { prefix: string, customCommands: SlashCommand[] }) {
return ViewPlugin.fromClass(class {
decorations: DecorationSet
export function createSlashCommandHighlighter({
prefix,
customCommands,
}: {
prefix: string;
customCommands: SlashCommand[];
}) {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view)
}
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view)
}
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view: EditorView) {
const keywords = customCommands.map(cmd => cmd.keyword).join('|')
const regexp = new RegExp(`\\${prefix}(${keywords})\\b`, 'g')
buildDecorations(view: EditorView) {
const allCommands = [...BUILT_IN_COMMANDS, ...customCommands];
const keywords = allCommands
.map((cmd) => cmd.keyword)
.join("|");
const regexp = new RegExp(`\\${prefix}(${keywords})\\b`, "g");
const decorations = view.visibleRanges.flatMap(({ from, to }) => {
const text = view.state.doc.sliceString(from, to)
return [...text.matchAll(regexp)]
.map(match =>
match.index !== undefined
? slashCommandMark.range(from + match.index, from + match.index + 1 + match[1].length)
: null
)
.filter((dec): dec is Range<Decoration> => dec !== null)
})
const decorations = view.visibleRanges.flatMap(
({ from, to }) => {
const text = view.state.doc.sliceString(from, to);
return [...text.matchAll(regexp)]
.map((match) =>
match.index !== undefined
? slashCommandMark.range(
from + match.index,
from +
match.index +
1 +
match[1].length,
)
: null,
)
.filter(
(dec): dec is Range<Decoration> => dec !== null,
);
},
);
return Decoration.set(decorations)
}
}, {
decorations: v => v.decorations
})
return Decoration.set(decorations);
}
},
{
decorations: (v) => v.decorations,
},
);
}

View file

@ -1,21 +1,20 @@
// modules/diffExtension.ts
import { EditorState, StateField, RangeSetBuilder } from "@codemirror/state";
import {
EditorState,
StateField,
RangeSetBuilder,
} from "@codemirror/state";
import {
Decoration,
DecorationSet,
EditorView,
WidgetType,
ViewPlugin,
ViewUpdate,
Decoration,
DecorationSet,
EditorView,
WidgetType,
ViewPlugin,
ViewUpdate,
} from "@codemirror/view";
import DiffMatchPatch from "diff-match-patch";
import { acceptTooltipEffect, dismissTooltipEffect } from "./WidgetExtension";
import { generatedResponseState, setGeneratedResponseEffect } from "./AIExtension";
import {
generatedResponseState,
setGeneratedResponseEffect,
} from "./AIExtension";
import { currentSelectionState } from "./SelectionState";
/**
@ -23,30 +22,43 @@ import { currentSelectionState } from "./SelectionState";
* Improves accessibility by using appropriate ARIA attributes.
*/
class ChangeContentWidget extends WidgetType {
constructor(
private readonly content: string,
private readonly type: 'added' | 'removed'
) {
super();
}
constructor(
private readonly content: string,
private readonly type: "added" | "removed",
) {
super();
}
toDOM(): HTMLElement {
const wrapper = document.createElement("span");
wrapper.className = `cm-change-widget cm-change-${this.type}`;
wrapper.textContent = this.content;
toDOM(): HTMLElement {
const wrapper = document.createElement("span");
wrapper.className = `cm-change-widget cm-change-${this.type}`;
wrapper.textContent = this.content;
// Accessibility: Provide ARIA label
wrapper.setAttribute(
"aria-label",
this.type === 'added' ? "Added content" : "Removed content"
);
return wrapper;
}
// Accessibility: Provide ARIA label
wrapper.setAttribute(
"aria-label",
this.type === "added" ? "Added content" : "Removed content",
);
ignoreEvent(): boolean {
// Decide whether to ignore events on the widget
return false;
}
// Prevent mouse interactions from stealing focus from the editor
// which can trigger Obsidian to re-render code blocks. We don't want
// these inline diff widgets to affect editor selection or focus.
wrapper.addEventListener("mousedown", (e) => {
e.preventDefault();
e.stopPropagation();
});
wrapper.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
});
return wrapper;
}
ignoreEvent(): boolean {
// Ensure events on diff widgets are ignored by the outer editor
// to avoid focus/selection changes that can trigger re-renders.
return true;
}
}
/**
@ -56,58 +68,57 @@ class ChangeContentWidget extends WidgetType {
* @returns A DecorationSet with the appropriate widgets.
*/
function generateDiffView(state: EditorState): DecorationSet {
try {
// Retrieve the AI response and the current context text from the state
const response = state.field(generatedResponseState);
const context = state.field(currentSelectionState);
try {
// Retrieve the AI response and the current context text from the state
const response = state.field(generatedResponseState);
const context = state.field(currentSelectionState);
const aiText: string = response?.airesponse ?? "";
const contextText: string = context?.text ?? "";
const aiText: string = response?.airesponse ?? "";
const contextText: string = context?.text ?? "";
// Use diff_match_patch instead of diffWords
const dmp = new DiffMatchPatch();
let diffs = dmp.diff_main(contextText, aiText);
// Use diff_match_patch instead of diffWords
const dmp = new DiffMatchPatch();
let diffs = dmp.diff_main(contextText, aiText);
// Perform semantic cleanup
dmp.diff_cleanupSemantic(diffs);
// Perform semantic cleanup
dmp.diff_cleanupSemantic(diffs);
// Initialize RangeSetBuilder for efficient decoration construction
const builder = new RangeSetBuilder<Decoration>();
let currentPos = context?.from ?? 0;
// Initialize RangeSetBuilder for efficient decoration construction
const builder = new RangeSetBuilder<Decoration>();
let currentPos = context?.from ?? 0;
diffs.forEach(([op, text]) => {
const length = text.length;
diffs.forEach(([op, text]) => {
const length = text.length;
if (op === DiffMatchPatch.DIFF_INSERT) {
// AI text added
const widget = new ChangeContentWidget(text, "added");
builder.add(
currentPos,
currentPos,
Decoration.widget({ widget, side: 1 }),
);
} else if (op === DiffMatchPatch.DIFF_DELETE) {
// Context text removed
const widget = new ChangeContentWidget(text, "removed");
// Attach the widget over the removed range
builder.add(
currentPos,
currentPos + length,
Decoration.widget({ widget, side: -1 }),
);
currentPos += length;
} else {
// No change, move currentPos forward
currentPos += length;
}
});
if (op === DiffMatchPatch.DIFF_INSERT) {
// AI text added
const widget = new ChangeContentWidget(text, "added");
builder.add(
currentPos,
currentPos,
Decoration.widget({ widget, side: 1 })
);
} else if (op === DiffMatchPatch.DIFF_DELETE) {
// Context text removed
const widget = new ChangeContentWidget(text, "removed");
// Attach the widget over the removed range
builder.add(
currentPos,
currentPos + length,
Decoration.widget({ widget, side: -1 })
);
currentPos += length;
} else {
// No change, move currentPos forward
currentPos += length;
}
});
return builder.finish();
} catch (error) {
console.error("Error generating diff view:", error);
return Decoration.none;
}
return builder.finish();
} catch (error) {
console.error("Error generating diff view:", error);
return Decoration.none;
}
}
/**
@ -117,69 +128,141 @@ function generateDiffView(state: EditorState): DecorationSet {
* @param view - The EditorView instance.
*/
function dispatchAIChanges(state: EditorState, view: EditorView): void {
try {
// Grab the AI text and selection info (original context)
const response = state.field(generatedResponseState);
const context = state.field(currentSelectionState);
try {
// Grab the AI text and selection info (original context)
const response = state.field(generatedResponseState);
const context = state.field(currentSelectionState);
const aiText: string = response?.airesponse ?? "";
const selectionFrom = context?.from ?? 0;
const selectionTo = context?.to ?? 0;
const aiText: string = response?.airesponse ?? "";
const selectionFrom = context?.from ?? 0;
const selectionTo = context?.to ?? 0;
// Dispatch the transaction to apply the AI changes
view.dispatch({
changes: { from: selectionFrom, to: selectionTo, insert: aiText },
});
} catch (error) {
console.error("Error applying diff changes:", error);
}
// Dispatch the transaction to apply the AI changes
view.dispatch({
changes: { from: selectionFrom, to: selectionTo, insert: aiText },
});
} catch (error) {
console.error("Error applying diff changes:", error);
}
}
export const diffDecorationState = StateField.define<DecorationSet>({
create(): DecorationSet {
return Decoration.none;
},
update(decorations: DecorationSet, tr) {
// Check if we got the AI response effect
if (tr.effects.some(e => e.is(setGeneratedResponseEffect))) {
return generateDiffView(tr.state);
}
create(): DecorationSet {
return Decoration.none;
},
update(decorations: DecorationSet, tr) {
// Check if we got the AI response effect
if (tr.effects.some((e) => e.is(setGeneratedResponseEffect))) {
return generateDiffView(tr.state);
}
// Check if the dismiss tooltip effect is present
const hasDismissEffect = tr.effects.some(e => e.is(dismissTooltipEffect));
if (hasDismissEffect) {
return Decoration.none;
}
// Check if the dismiss tooltip effect is present
const hasDismissEffect = tr.effects.some((e) =>
e.is(dismissTooltipEffect),
);
if (hasDismissEffect) {
return Decoration.none;
}
// Retain the existing decorations if no relevant changes
return decorations;
},
provide: (field) => EditorView.decorations.from(field),
// Retain the existing decorations if no relevant changes
return decorations;
},
provide: (field) => EditorView.decorations.from(field),
});
/**
* Plugin to handle applying diff changes when the accept tooltip effect is triggered.
*/
const applyDiffPlugin = ViewPlugin.fromClass(class {
update(update: ViewUpdate) {
// Iterate through all transactions in the update
for (const transaction of update.transactions) {
for (const effect of transaction.effects) {
if (effect.is(acceptTooltipEffect)) {
// Apply the diff changes by dispatching the transaction
setTimeout(() => {
dispatchAIChanges(update.state, update.view);
}, 0);
}
}
}
}
});
const applyDiffPlugin = ViewPlugin.fromClass(
class {
update(update: ViewUpdate) {
// Iterate through all transactions in the update
for (const transaction of update.transactions) {
for (const effect of transaction.effects) {
if (effect.is(acceptTooltipEffect)) {
// Apply the diff changes by dispatching the transaction
setTimeout(() => {
dispatchAIChanges(update.state, update.view);
}, 0);
}
}
}
}
},
);
/**
* Exported extension to be included in the EditorView.
* Declared after focusGuardPlugin so it is initialized in order.
*/
/**
* Focus guard to suppress editor-level blur/focusout side effects while the
* InlineAI widget is open. This mirrors the behavior in WidgetExtension but
* ensures that moving focus between the diff overlay and the widget does not
* trigger Obsidian re-renders (e.g., code block previews).
*/
const focusGuardPlugin = ViewPlugin.fromClass(
class {
private onFocusOut: ((e: FocusEvent) => void) | null = null;
private onBlur: ((e: FocusEvent) => void) | null = null;
constructor(private view: EditorView) {
const handler = (evt: FocusEvent) => {
// Only guard when the InlineAI widget is open
if (document.body.classList.contains("inlineai-widget-open")) {
// Stop Obsidian/global listeners from reacting to focus loss
evt.stopImmediatePropagation?.();
evt.stopPropagation();
// Do not preventDefault so the focus can move as intended
}
};
this.onFocusOut = handler;
this.onBlur = handler;
// Attach on the editor root in capture phase to intercept early
this.view.dom.addEventListener("focusout", this.onFocusOut, true);
this.view.dom.addEventListener("blur", this.onBlur, true);
// Also attach at the document level to guard cases where focus moves
// between editor and non-editor nodes within the document
document.addEventListener("focusout", this.onFocusOut, true);
document.addEventListener("blur", this.onBlur, true);
}
destroy() {
try {
if (this.onFocusOut) {
this.view.dom.removeEventListener(
"focusout",
this.onFocusOut,
true,
);
document.removeEventListener(
"focusout",
this.onFocusOut,
true,
);
}
if (this.onBlur) {
this.view.dom.removeEventListener(
"blur",
this.onBlur,
true,
);
document.removeEventListener("blur", this.onBlur, true);
}
} catch (e) {
// ignore cleanup issues
}
}
},
);
// Export a single extension that includes the focus guard
export const diffExtension = [
diffDecorationState,
applyDiffPlugin,
diffDecorationState,
applyDiffPlugin,
focusGuardPlugin,
];

View file

@ -0,0 +1,55 @@
/**
* MessageQueue is a generic fixed-length queue for storing recent chat messages.
* - When a new item is enqueued and the queue is full, the oldest item is removed.
* - Duplicate consecutive items are not added.
* - Provides standard queue operations: enqueue, dequeue, peek, size, isEmpty, and getItems.
*/
export class MessageQueue<T> {
private items: T[] = [];
private maxLength: number;
constructor(maxLength: number) {
this.maxLength = maxLength;
}
// Adds an item to the queue
enqueue(item: T): void {
// If the new item is exactly the same as the previous one, do not add it
if (this.items.length > 0) {
const lastItem = this.items[this.items.length - 1];
if (lastItem === item) {
return;
}
}
if (this.items.length >= this.maxLength) {
// Remove the oldest item if the queue is full
this.items.shift();
}
this.items.push(item);
}
// Removes and returns the item at the front of the queue
dequeue(): T | undefined {
return this.items.shift();
}
// Returns the item at the front of the queue without removing it
peek(): T | undefined {
return this.items[0];
}
// Returns the current size of the queue
size(): number {
return this.items.length;
}
// Checks if the queue is empty
isEmpty(): boolean {
return this.items.length === 0;
}
// Returns the items in the queue
getItems(): T[] {
return [...this.items];
}
}

View file

@ -1,30 +1,43 @@
import { App, PluginSettingTab, Setting } from "obsidian";
import { App, PluginSettingTab, Setting, Notice, Platform } 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";
import {
clearCodexTokens,
getApiKey,
getCodexTokens,
isSecretStorageAvailable,
setApiKey,
setCodexTokens,
} from "./credentials";
// Interface for the settings
export interface InlineAISettings {
provider: "openai" | "ollama" | "custom" | "gemini";
provider: "openai" | "ollama" | "custom" | "gemini" | "azure" | "codex";
model: string;
apiKey?: string;
customURL?: string;
azureEndpoint?: string;
azureApiVersion?: string;
selectionPrompt: string;
cursorPrompt: string;
customCommands: SlashCommand[];
commandPrefix: string;
messageHistory: boolean;
}
// Default settings values
export const DEFAULT_SETTINGS: InlineAISettings = {
provider: "ollama",
model: "llama3.2",
apiKey: "",
customURL: "",
azureEndpoint: "",
azureApiVersion: "2024-02-15-preview",
selectionPrompt: selectionPrompt,
cursorPrompt: cursorPrompt,
customCommands: [],
commandPrefix: "/"
commandPrefix: "/",
messageHistory: false,
};
export class InlineAISettingsTab extends PluginSettingTab {
@ -45,48 +58,254 @@ export class InlineAISettingsTab extends PluginSettingTab {
const { containerEl } = this;
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
new Setting(containerEl)
.setName("Provider")
.setDesc("Choose between OpenAI, Ollama, or a custom OpenAI-compatible endpoint.")
.setDesc(
"Choose between OpenAI, Ollama, Azure OpenAI, Gemini, a custom OpenAI-compatible endpoint, or Codex (ChatGPT subscription).",
)
.addDropdown((dropdown) =>
dropdown
.addOption("openai", "OpenAI")
.addOption("ollama", "Ollama")
.addOption("azure", "Azure OpenAI")
.addOption("gemini", "Gemini")
.addOption("custom", "Custom/OpenAI-compatible")
.addOption("codex", "Codex (ChatGPT subscription)")
.setValue(this.plugin.settings.provider)
.onChange(async (value) => {
this.plugin.settings.provider = value as "openai" | "ollama" | "custom" | "gemini";
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"
| "azure"
| "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(); // 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
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 (this.plugin.settings.provider === "openai" || this.plugin.settings.provider === "custom" || this.plugin.settings.provider === "gemini") {
if (
this.plugin.settings.provider === "openai" ||
this.plugin.settings.provider === "custom" ||
this.plugin.settings.provider === "gemini" ||
this.plugin.settings.provider === "azure"
) {
new Setting(containerEl)
.setName("API key")
.setDesc("Enter your API key.")
.addText((text) => {
text.setPlaceholder("sk-...")
.setValue(this.plugin.settings.apiKey || "")
.setValue(getApiKey(this.app) ?? "")
.inputEl.addEventListener("blur", async () => {
this.plugin.settings.apiKey = text.getValue();
await this.saveSettings();
if (!isSecretStorageAvailable(this.app)) {
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,
);
});
});
}
@ -95,7 +314,9 @@ export class InlineAISettingsTab extends PluginSettingTab {
if (this.plugin.settings.provider === "custom") {
new Setting(containerEl)
.setName("Custom endpoint")
.setDesc("Enter your OpenAI-compatible base URL (e.g. https://api.groq.com/openai/v1).")
.setDesc(
"Enter your OpenAI-compatible base URL (e.g. https://api.groq.com/openai/v1).",
)
.addText((text) => {
text.setPlaceholder("https://api.mycustomhost.com/v1")
.setValue(this.plugin.settings.customURL || "")
@ -106,18 +327,61 @@ export class InlineAISettingsTab extends PluginSettingTab {
});
}
// Advanced Section
new Setting(containerEl).setName("Advanced").setHeading();
// Azure-specific settings
if (this.plugin.settings.provider === "azure") {
// Azure endpoint setting
new Setting(containerEl)
.setName("Azure endpoint")
.setDesc(
"Enter your Azure OpenAI endpoint URL (e.g. https://your-resource.openai.azure.com).",
)
.addText((text) => {
text.setPlaceholder(
"https://your-resource.openai.azure.com",
)
.setValue(this.plugin.settings.azureEndpoint || "")
.inputEl.addEventListener("blur", async () => {
this.plugin.settings.azureEndpoint = text
.getValue()
.trim();
await this.saveSettings();
});
});
// Azure API version setting
new Setting(containerEl)
.setName("Azure API version")
.setDesc("Enter the Azure OpenAI API version to use.")
.addText((text) => {
text.setPlaceholder("2024-02-15-preview")
.setValue(
this.plugin.settings.azureApiVersion ||
"2024-02-15-preview",
)
.inputEl.addEventListener("blur", async () => {
this.plugin.settings.azureApiVersion = text
.getValue()
.trim();
await this.saveSettings();
});
});
}
// Advanced Section
containerEl.createEl("h3", { text: "Advanced" });
// Selection Prompt setting
new Setting(containerEl)
.setName("Selection prompt")
.setDesc("System Prompt used when the tooltip is triggered with selected text.")
.setDesc(
"System Prompt used when the tooltip is triggered with selected text.",
)
.addTextArea((textarea) => {
textarea.setPlaceholder("e.g., Summarize the selected text.")
textarea
.setPlaceholder("e.g., Summarize the selected text.")
.setValue(this.plugin.settings.selectionPrompt)
.inputEl.addEventListener("blur", async () => {
this.plugin.settings.selectionPrompt = textarea.getValue();
this.plugin.settings.selectionPrompt =
textarea.getValue();
await this.saveSettings();
});
textarea.inputEl.classList.add("wide-text-settings");
@ -126,9 +390,14 @@ export class InlineAISettingsTab extends PluginSettingTab {
// Cursor Prompt setting
new Setting(containerEl)
.setName("Cursor prompt")
.setDesc("System Prompt used when the tooltip is triggered with selected text.")
.setDesc(
"System Prompt used when the tooltip is triggered with selected text.",
)
.addTextArea((textarea) => {
textarea.setPlaceholder("e.g., Generate text based on cursor position.")
textarea
.setPlaceholder(
"e.g., Generate text based on cursor position.",
)
.setValue(this.plugin.settings.cursorPrompt)
.inputEl.addEventListener("blur", async () => {
this.plugin.settings.cursorPrompt = textarea.getValue();
@ -137,19 +406,44 @@ export class InlineAISettingsTab extends PluginSettingTab {
textarea.inputEl.classList.add("wide-text-settings");
});
// Message History setting
new Setting(containerEl)
.setName("Message History")
.setDesc(
"Enable message history, you can navigate through the history using the up/down arrow keys.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.messageHistory)
.onChange(async (value) => {
this.plugin.settings.messageHistory = value;
await this.saveSettings();
});
});
// Custom Commands Section
containerEl.createEl("h3", { text: "Custom Commands" });
containerEl.createEl("p", { text: "Add your own custom commands. Triggered with the prefix defined in the Command Prefix setting." });
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)
.setName("Command Prefix")
.setDesc("The prefix used to trigger custom commands (e.g., /, !, #)")
.setDesc(
"The prefix used to trigger custom commands (e.g., /, !, #)",
)
.addText((text) => {
text.setPlaceholder("/")
.setValue(this.plugin.settings.commandPrefix)
.inputEl.addEventListener("blur", async () => {
this.plugin.settings.commandPrefix = text.getValue().charAt(0);
this.plugin.settings.commandPrefix = text
.getValue()
.charAt(0);
await this.saveSettings();
this.display();
});
@ -164,15 +458,18 @@ export class InlineAISettingsTab extends PluginSettingTab {
text.setValue(command.keyword)
.setPlaceholder("Command name")
.inputEl.addEventListener("blur", async () => {
this.plugin.settings.customCommands[index].keyword = text.getValue();
this.plugin.settings.customCommands[index].keyword =
text.getValue();
await this.saveSettings();
});
})
.addTextArea((textarea) => {
textarea.setValue(command.prompt)
textarea
.setValue(command.prompt)
.setPlaceholder("Command prompt")
.inputEl.addEventListener("blur", async () => {
this.plugin.settings.customCommands[index].prompt = textarea.getValue();
this.plugin.settings.customCommands[index].prompt =
textarea.getValue();
await this.saveSettings();
});
})
@ -181,25 +478,29 @@ export class InlineAISettingsTab extends PluginSettingTab {
.setIcon("trash")
.setTooltip("Delete this command")
.onClick(async () => {
this.plugin.settings.customCommands.splice(index, 1);
this.plugin.settings.customCommands.splice(
index,
1,
);
await this.saveSettings();
this.display();
})
}),
);
});
// Add new command button
new Setting(containerEl).addButton((btn) =>
btn
.setButtonText("Add Command")
.setCta()
.onClick(async () => {
this.plugin.settings.customCommands.push({ keyword: "new_command", prompt: "" });
this.plugin.settings.customCommands.push({
keyword: "new_command",
prompt: "",
});
await this.saveSettings();
this.display();
})
}),
);
}
}

View file

@ -27,7 +27,7 @@
.cm-tooltip-editor {
display: flex;
padding: 1px 0 1px 10px;
height: var(--line-height);
height: auto;
width: auto;
max-height: 100%;
align-items: center;
@ -46,11 +46,11 @@
/* Button Styles */
.submit-button {
height: var(--line-height);
height: auto;
}
.tooltip-button {
height: var(--line-height);
height: auto;
margin-left: 8px;
border: 1px solid var(--background-modifier-border-focus);
box-shadow: none !important;
@ -79,12 +79,16 @@
@keyframes l5 {
0%,
33% {
box-shadow: 10px 0 var(--text-accent), -10px 0 var(--text-faint);
box-shadow:
10px 0 var(--text-accent),
-10px 0 var(--text-faint);
background: var(--text-accent);
}
66%,
100% {
box-shadow: 10px 0 var(--text-faint), -10px 0 var(--text-accent);
box-shadow:
10px 0 var(--text-faint),
-10px 0 var(--text-accent);
background: var(--text-faint);
}
}
@ -118,11 +122,11 @@
height: 10em;
}
.tooltip-autocomplete{
.tooltip-autocomplete {
background-color: var(--background-primary-alt) !important;
color: var(--text-normal) !important;
width: 101.9% !important;
margin-left: -1.05rem;
margin-left: -1.1rem !important;
overflow: hidden;
border: 1px solid var(--background-modifier-border-focus);
border-radius: 4px;
@ -130,26 +134,46 @@
}
.tooltip-autocomplete li[aria-selected] {
background-color: var(--text-selection)!important;
background-color: var(--text-selection) !important;
color: var(--text-accent) !important;
}
.completion-label{
.completion-label {
font-size: var(--font-ui-small);
font-weight: var(--font-normal);
font-family: Arial, Helvetica, sans-serif !important;
border-radius: 4px;
margin-left: -1em;
white-space: nowrap;
}
.cm-tooltip.cm-tooltip-below {
top: 100%;
margin-top: 0.8em;
margin-top: 0.8em;
bottom: auto;
}
/* Slash command highlighting */
.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"
}