Compare commits

...

33 commits
1.2.0 ... 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
27 changed files with 3501 additions and 990 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 name: Build and Release Obsidian Plugin
on: on:
push: push:
tags: tags:
- '*' # Triggers on any tag - "*" # Triggers on any tag
env: env:
PLUGIN_NAME: obsidian-inlineAI PLUGIN_NAME: obsidian-inlineAI
jobs: jobs:
build-and-release: build-and-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# 1. Checkout the repository # 1. Checkout the repository
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v3 uses: actions/checkout@v3
# 2. Set up Node.js environment with caching # 2. Set up Node.js environment with caching
- name: Set Up Node.js - name: Set Up Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 'lts/*' # Use the latest LTS version node-version: "lts/*" # Use the latest LTS version
cache: 'npm' cache: "npm"
# 3. Install dependencies and build the project # 3. Install dependencies and build the project
- name: Install and Build - name: Install and Build
run: | run: |
npm ci npm ci
npm run build --if-present npm run build --if-present
# 4. Prepare release assets # 4. Prepare release assets
- name: Prepare Release Assets - name: Prepare Release Assets
run: | run: |
mkdir -p $PLUGIN_NAME mkdir -p $PLUGIN_NAME
cp main.js manifest.json styles.css $PLUGIN_NAME cp main.js manifest.json styles.css $PLUGIN_NAME
zip -r ${PLUGIN_NAME}.zip $PLUGIN_NAME zip -r ${PLUGIN_NAME}.zip $PLUGIN_NAME
# 5. Retrieve the tag name # 5. Retrieve the tag name
- name: Get Tag Name - name: Get Tag Name
id: get_tag id: get_tag
run: echo "tag_name=$(git describe --tags --abbrev=0)" >> $GITHUB_OUTPUT run: echo "tag_name=$(git describe --tags --abbrev=0)" >> $GITHUB_OUTPUT
# 6. Create a new release and upload all assets in one step # 6. Create a new release and upload all assets in one step
- name: Create Release and Upload Assets - name: Create Release and Upload Assets
uses: ncipollo/release-action@v1 uses: ncipollo/release-action@v1
with: with:
tag: ${{ steps.get_tag.outputs.tag_name }} tag: ${{ steps.get_tag.outputs.tag_name }}
name: ${{ steps.get_tag.outputs.tag_name }} name: ${{ steps.get_tag.outputs.tag_name }}
artifacts: | artifacts: |
${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }}.zip
main.js main.js
manifest.json manifest.json
styles.css styles.css
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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 ### Initial Setup
1. **Set up your API key**: 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**: 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**: 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 ### 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 ## 🙏 Feedback and Support
We value your feedback and aim to make InlineAI the ultimate AI writing assistant: 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 git clone https://github.com/FBarrca/obsidian-inlineai.git
``` ```
2. Install dependencies: 2. Install dependencies:
```bash ```bash
@ -116,6 +104,7 @@ Want to contribute? Heres how:
npm install npm install
``` ```
3. Build the plugin: 3. Build the plugin:
```bash ```bash

View file

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

839
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,11 +1,18 @@
// api.ts // api.ts
import { ChatOpenAI } from "@langchain/openai"; import { ChatOpenAI, AzureChatOpenAI } from "@langchain/openai";
import { ChatOllama } from "@langchain/ollama"; import { ChatOllama } from "@langchain/ollama";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; 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 { InlineAISettings } from "./settings";
import { App, MarkdownView, Notice } from "obsidian"; import { App, MarkdownView, Notice } from "obsidian";
import { EditorView } from "@codemirror/view"; import { EditorView } from "@codemirror/view";
import { callCodexApi } from "./codex-client";
import { getValidCodexToken } from "./codex-auth";
import { getApiKey, getCodexTokens, setCodexTokens } from "./credentials";
import { setGeneratedResponseEffect } from "./modules/AIExtension"; import { setGeneratedResponseEffect } from "./modules/AIExtension";
import { parseCommand } from "./modules/commands/parser"; import { parseCommand } from "./modules/commands/parser";
import { MessageQueue } from "./modules/messageHistory/queue"; import { MessageQueue } from "./modules/messageHistory/queue";
@ -13,196 +20,420 @@ import { MessageQueue } from "./modules/messageHistory/queue";
const MESSAGE_HISTORY_LIMIT = 20; const MESSAGE_HISTORY_LIMIT = 20;
export type HistoryMessage = { export type HistoryMessage = {
mode: string; mode: string;
userPrompt: string; userPrompt: string;
}; };
/** /**
* Class to manage interactions with different chat APIs. * Class to manage interactions with different chat APIs.
*/ */
export class ChatApiManager { export class ChatApiManager {
private chatClient: ChatOpenAI | ChatOllama | ChatGoogleGenerativeAI | null; private chatClient:
private app: App; | ChatOpenAI
private settings: InlineAISettings; | ChatOllama
private messageHistory: MessageQueue<HistoryMessage>; | ChatGoogleGenerativeAI
/** | AzureChatOpenAI
* Initializes the ChatApiManager with the given settings. | null;
* @param settings - Configuration settings for the chat API. private app: App;
* @param app - The Obsidian App instance. private settings: InlineAISettings;
*/ private messageHistory: MessageQueue<HistoryMessage>;
constructor(settings: InlineAISettings, app: App) { /**
this.app = app; * Initializes the ChatApiManager with the given settings.
this.chatClient = this.initializeChatClient(settings); * @param settings - Configuration settings for the chat API.
this.settings = settings; * @param app - The Obsidian App instance.
this.messageHistory = new MessageQueue<HistoryMessage>(MESSAGE_HISTORY_LIMIT); */
} 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 appropriate chat client based on the provider specified in settings. * Extracts the instance name from an Azure endpoint URL.
* @param settings - Configuration settings for the chat API. * @param endpoint - The Azure endpoint URL.
* @returns An instance of ChatOpenAI, ChatOllama, or null if initialization fails. * @returns The instance name or null if invalid.
*/ */
private initializeChatClient(settings: InlineAISettings): ChatOpenAI | ChatOllama | ChatGoogleGenerativeAI | null { private extractAzureInstanceName(endpoint: string): string | null {
try { const trimmedEndpoint = endpoint.trim();
if (settings.messageHistory) {
this.messageHistory = new MessageQueue<HistoryMessage>(MESSAGE_HISTORY_LIMIT);
} else {
this.messageHistory = new MessageQueue<HistoryMessage>(0);
}
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,
});
case "ollama": // Match both openai.azure.com and cognitiveservices.azure.com formats
return new ChatOllama({ const openaiMatch = trimmedEndpoint.match(
model: settings.model, /https:\/\/([^.]+)\.openai\.azure\.com/,
}); );
case "gemini": if (openaiMatch) {
return new ChatGoogleGenerativeAI({ return openaiMatch[1];
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(),
},
});
default: const cognitiveservicesMatch = trimmedEndpoint.match(
new Notice(`⚠️ Unsupported provider: ${settings.provider}`); /https:\/\/([^.]+)\.cognitiveservices\.azure\.com/,
return null; );
} if (cognitiveservicesMatch) {
} catch (error: any) { return cognitiveservicesMatch[1];
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.";
}
const messages = [ /**
new SystemMessage(systemMessage), * Initializes the appropriate chat client based on the provider specified in settings.
new HumanMessage(message), * @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);
}
try { switch (settings.provider) {
const aiMessage: AIMessage = await this.chatClient.invoke(messages); case "openai": {
return aiMessage.content.toString(); const apiKey = getApiKey(this.app);
} catch (error: any) { if (!apiKey) {
console.error("Error calling the chat model:", error); new Notice(
new Notice(`❌ Error calling the chat model: ${error.message}`); "⚠️ OpenAI API key is required. Please check your settings.",
return "⚠️ Failed to generate a response. Please try again later."; );
} return null;
} }
return new ChatOpenAI({
modelName: settings.model,
temperature: 0,
apiKey,
});
}
/** case "ollama":
* Handles user input and updates the editor with the response. return new ChatOllama({
* @param systemPrompt - The system prompt to send to the chat API. model: settings.model,
* @param userRequest - The user's request to process. });
* @returns The AI-generated response or an error message. case "gemini": {
*/ const apiKey = getApiKey(this.app);
private async handleEditorUpdate(systemPrompt: string, userRequest: string): Promise<string> { return new ChatGoogleGenerativeAI({
try { model: settings.model,
const response = await this.callApi(systemPrompt, userRequest); apiKey: apiKey ?? undefined,
if (!response) return "⚠️ No response generated."; });
}
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;
}
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); // Extract instance name from the endpoint URL
if (!markdownView) { const instanceName = this.extractAzureInstanceName(
new Notice("⚠️ No active Markdown editor found."); settings.azureEndpoint,
return ""; );
} if (!instanceName) {
new Notice(
"⚠️ Invalid Azure endpoint format. Expected: https://your-resource.openai.azure.com",
);
return null;
}
const mainEditorView = (markdownView.editor as any).cm as EditorView; return new AzureChatOpenAI({
mainEditorView?.dispatch({ azureOpenAIApiKey: apiKey,
effects: setGeneratedResponseEffect.of({ airesponse: response, prompt: userRequest }), 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(),
},
});
}
return response; case "codex":
} catch (error: any) { // Handled directly in callApi — no LangChain client needed
console.error("Error processing request:", error); return null;
new Notice(`❌ Error processing request: ${error.message}`);
return "⚠️ Failed to process request.";
}
}
/**
* 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; default:
if (selectedText.trim().length === 0) { new Notice(`⚠️ Unsupported provider: ${settings.provider}`);
isCursor = true; return null;
} }
} catch (error: any) {
console.error("Error initializing chat client:", error);
new Notice(`❌ Error initializing chat client: ${error.message}`);
return null;
}
}
const systemPrompt = isCursor ? this.settings.cursorPrompt : this.settings.selectionPrompt; /**
let finalUserPrompt = ``; * Calls the chat API with the provided content and context.
const mode = isCursor ? "cursor" : "selection"; * @param systemMessage - The system message to send to the chat API.
if (this.settings.messageHistory) { * @param message - The user's message to send to the chat API.
this.messageHistory.enqueue({ mode, userPrompt }); * @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);
}
if (isCursor) { if (!this.chatClient) {
finalUserPrompt = ` new Notice(
"⚠️ Chat client is not initialized. Please check your settings.",
);
return "⚠️ Chat client is not available.";
}
const messages = [
new SystemMessage(systemMessage),
new HumanMessage(message),
];
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.";
}
}
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} **Task:** ${userPrompt}
**Output:**`; **Output:**`;
} else { } else {
finalUserPrompt = ` finalUserPrompt = `
**Task:** ${userPrompt} **Task:** ${userPrompt}
**Input:** **Input:**
${selectedText} ${selectedText}
**Output:**`; **Output:**`;
} }
return this.handleEditorUpdate(systemPrompt, finalUserPrompt); const noteContext = this.extractNoteContext(selectedText);
} const enhancedSystemPrompt = noteContext
? `${systemPrompt}\n\n---\nDocument context (for reference only — do not include in output):\n${noteContext}`
: systemPrompt;
return this.handleEditorUpdate(enhancedSystemPrompt, finalUserPrompt);
}
/** /**
* Updates the manager's settings and reinitializes the chat client. * Updates the manager's settings and reinitializes the chat client.
* @param settings - New configuration settings for the chat API. * @param settings - New configuration settings for the chat API.
*/ */
public updateSettings(settings: InlineAISettings): void { public updateSettings(settings: InlineAISettings): void {
this.settings = settings; this.settings = settings;
const newChatClient = this.initializeChatClient(settings); const newChatClient = this.initializeChatClient(settings);
if (!newChatClient) { if (!newChatClient) {
return; return;
} }
this.chatClient = newChatClient; this.chatClient = newChatClient;
} }
public getMessageHistory(): HistoryMessage[] { public getMessageHistory(): HistoryMessage[] {
return this.messageHistory.getItems(); 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| | John | 30 | Engineer|
--- ---
` `;
export const cursorPrompt = ` 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. 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 // main.ts
import { Plugin, MarkdownView, App } from "obsidian"; import { Plugin, MarkdownView, App } from "obsidian";
import { EditorView } from "@codemirror/view"; import { EditorView } from "@codemirror/view";
import { InlineAISettings, DEFAULT_SETTINGS, InlineAISettingsTab } from "./settings"; import {
import { acceptTooltipEffect, commandEffect, dismissTooltipEffect, FloatingTooltipExtension } from "./modules/WidgetExtension"; InlineAISettings,
DEFAULT_SETTINGS,
InlineAISettingsTab,
} from "./settings";
import {
acceptTooltipEffect,
commandEffect,
dismissTooltipEffect,
FloatingTooltipExtension,
} from "./modules/WidgetExtension";
import { ChatApiManager } from "./api"; import { ChatApiManager } from "./api";
import { generatedResponseState } from "./modules/AIExtension"; 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 { diffExtension } from "./modules/diffExtension";
import { migrateLegacySecrets, LegacySecretFields } from "./credentials";
export default class InlineAIChatPlugin extends Plugin { export default class InlineAIChatPlugin extends Plugin {
settings: InlineAISettings = DEFAULT_SETTINGS; settings: InlineAISettings = DEFAULT_SETTINGS;
@ -21,7 +35,7 @@ export default class InlineAIChatPlugin extends Plugin {
generatedResponseState, generatedResponseState,
currentSelectionState, currentSelectionState,
buildSelectionHiglightState, buildSelectionHiglightState,
diffExtension diffExtension,
]); ]);
// Add command to show tooltip // Add command to show tooltip
@ -29,9 +43,11 @@ export default class InlineAIChatPlugin extends Plugin {
id: "show-cursor-tooltip", id: "show-cursor-tooltip",
name: "Show cursor tooltip", name: "Show cursor tooltip",
callback: () => { callback: () => {
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); const markdownView =
this.app.workspace.getActiveViewOfType(MarkdownView);
if (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 // Grab the main selection range
const { from, to } = cmEditor.state.selection.main; const { from, to } = cmEditor.state.selection.main;
@ -39,13 +55,22 @@ export default class InlineAIChatPlugin extends Plugin {
if (from !== to) { if (from !== to) {
// If there is a real selection, store it // 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( effects.push(
setSelectionInfoEffect.of({ from, to, text: selectedText }) setSelectionInfoEffect.of({
from,
to,
text: selectedText,
}),
); );
} else { } else {
// If no selection, store cursor position instead of null // 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 // Also trigger the overlay
@ -55,18 +80,22 @@ export default class InlineAIChatPlugin extends Plugin {
cmEditor.dispatch({ effects }); cmEditor.dispatch({ effects });
} }
}, },
hotkeys: [ hotkeys: [{ modifiers: ["Ctrl", "Shift"], key: " " }],
],
}); });
this.addCommand({ this.addCommand({
id: "accept-tooltip", id: "accept-tooltip",
name: "Accept tooltip suggestion", name: "Accept tooltip suggestion",
callback: () => { callback: () => {
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); const markdownView =
this.app.workspace.getActiveViewOfType(MarkdownView);
if (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) { if (response) {
cmEditor.dispatch({ cmEditor.dispatch({
effects: acceptTooltipEffect.of(null), effects: acceptTooltipEffect.of(null),
@ -76,25 +105,28 @@ export default class InlineAIChatPlugin extends Plugin {
}); });
} }
} }
} },
}); });
this.addCommand({ this.addCommand({
id: "discard-tooltip", id: "discard-tooltip",
name: "Discard tooltip suggestion", name: "Discard tooltip suggestion",
callback: () => { callback: () => {
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); const markdownView =
this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) { if (markdownView) {
const cmEditor = (markdownView.editor as any).cm as EditorView; const cmEditor = (markdownView.editor as any)
const response = cmEditor.state.field(generatedResponseState, false); .cm as EditorView;
const response = cmEditor.state.field(
generatedResponseState,
false,
);
if (response) { if (response) {
cmEditor.dispatch({ cmEditor.dispatch({
effects: dismissTooltipEffect.of(null), effects: dismissTooltipEffect.of(null),
}); });
} }
} }
} },
}); });
// Add settings tab // Add settings tab
@ -106,7 +138,11 @@ export default class InlineAIChatPlugin extends Plugin {
} }
async loadSettings() { 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() { async saveSettings() {

View file

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

View file

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

View file

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

View file

@ -1,12 +1,16 @@
import { SlashCommand } from "./source"; import { SlashCommand, BUILT_IN_COMMANDS } from "./source";
export function parseCommand(
export function parseCommand(userInput: string, prefix: string, customCommands: SlashCommand[]): string { userInput: string,
for (const command of customCommands) { prefix: string,
const commandPattern = `${prefix}${command.keyword}`; customCommands: SlashCommand[],
if (userInput.includes(commandPattern)) { ): string {
return userInput.replace(commandPattern, command.prompt); const allCommands = [...BUILT_IN_COMMANDS, ...customCommands];
} for (const command of allCommands) {
} const commandPattern = `${prefix}${command.keyword}`;
return userInput; // Return original text if no command matches 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 { autocompletion, CompletionContext } from "@codemirror/autocomplete";
import { Extension, Range } from "@codemirror/state" import { Extension, Range } from "@codemirror/state";
import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view" import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
} from "@codemirror/view";
/** /**
* *
* Interface describing a slash command. * Interface describing a slash command.
*/ */
export interface SlashCommand { export interface SlashCommand {
keyword: string; keyword: string;
prompt: string; prompt: string;
} }
export const BUILT_IN_COMMANDS: SlashCommand[] = [
{
keyword: "fix",
prompt: "Fix grammar, spelling, and punctuation. Keep the original meaning and style. Output only the corrected text.",
},
{
keyword: "shorter",
prompt: "Make this text more concise. Remove filler words and redundancy. Keep all key information. Output only the shortened text.",
},
{
keyword: "longer",
prompt: "Expand this text with more detail, examples, or explanation. Keep the same tone. Output only the expanded text.",
},
{
keyword: "formal",
prompt: "Rewrite this text in a formal, professional tone. Output only the rewritten text.",
},
{
keyword: "casual",
prompt: "Rewrite this text in a friendly, casual tone. Output only the rewritten text.",
},
{
keyword: "bullets",
prompt: "Convert this text into a clear bullet-point list using Obsidian markdown. Output only the bullet list.",
},
{
keyword: "summarize",
prompt: "Write a concise summary of this text in 2-3 sentences. Output only the summary.",
},
{
keyword: "continue",
prompt: "Continue writing from where this text ends, matching the tone and style. Output only the continuation — do not repeat existing text.",
},
];
// Factory function that creates a completion source with custom parameters // Factory function that creates a completion source with custom parameters
function createSlashCommandSource(options: { function createSlashCommandSource(
prefix: string, options: {
customCommands: SlashCommand[] prefix: string;
} = { customCommands: SlashCommand[];
prefix: '/', } = {
customCommands: [] prefix: "/",
}) { customCommands: [],
const { prefix, customCommands } = options; },
return (context: CompletionContext) => { ) {
let word = context.matchBefore(new RegExp(`^\\${prefix}\\w*`)) const { prefix, customCommands } = options;
if (!word || (word.from == word.to && !context.explicit)) const allCommands = [...BUILT_IN_COMMANDS, ...customCommands];
return null return (context: CompletionContext) => {
return { let word = context.matchBefore(new RegExp(`^\\${prefix}\\w*`));
from: word.from+1, if (!word || (word.from == word.to && !context.explicit)) return null;
options: (customCommands).map(cmd => ({ return {
label: cmd.keyword, from: word.from + 1,
type: undefined, options: allCommands.map((cmd) => ({
detail: cmd.prompt, label: cmd.keyword,
})) type: undefined,
} detail: cmd.prompt,
} })),
};
};
} }
// Create the extension that uses our custom completion source. // Create the extension that uses our custom completion source.
export function slashCommandAutocompletion(options: { prefix: string, customCommands: SlashCommand[] } = { prefix: '/', customCommands: [] }) { export function slashCommandAutocompletion(
return autocompletion({ options: { prefix: string; customCommands: SlashCommand[] } = {
override: [createSlashCommandSource(options)], prefix: "/",
tooltipClass: () => "tooltip-autocomplete", customCommands: [],
optionClass: () => "completion-label", },
}) ) {
return autocompletion({
override: [createSlashCommandSource(options)],
tooltipClass: () => "tooltip-autocomplete",
optionClass: () => "completion-label",
icons: false,
});
} }
// Create a decoration for highlighting slash commands // 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[] }) { export function createSlashCommandHighlighter({
return ViewPlugin.fromClass(class { prefix,
decorations: DecorationSet customCommands,
}: {
prefix: string;
customCommands: SlashCommand[];
}) {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) { constructor(view: EditorView) {
this.decorations = this.buildDecorations(view) this.decorations = this.buildDecorations(view);
} }
update(update: ViewUpdate) { update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) { if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view) this.decorations = this.buildDecorations(update.view);
} }
} }
buildDecorations(view: EditorView) { buildDecorations(view: EditorView) {
const keywords = customCommands.map(cmd => cmd.keyword).join('|') const allCommands = [...BUILT_IN_COMMANDS, ...customCommands];
const regexp = new RegExp(`\\${prefix}(${keywords})\\b`, 'g') const keywords = allCommands
.map((cmd) => cmd.keyword)
.join("|");
const regexp = new RegExp(`\\${prefix}(${keywords})\\b`, "g");
const decorations = view.visibleRanges.flatMap(({ from, to }) => { const decorations = view.visibleRanges.flatMap(
const text = view.state.doc.sliceString(from, to) ({ from, to }) => {
return [...text.matchAll(regexp)] const text = view.state.doc.sliceString(from, to);
.map(match => return [...text.matchAll(regexp)]
match.index !== undefined .map((match) =>
? slashCommandMark.range(from + match.index, from + match.index + 1 + match[1].length) match.index !== undefined
: null ? slashCommandMark.range(
) from + match.index,
.filter((dec): dec is Range<Decoration> => dec !== null) from +
}) match.index +
1 +
match[1].length,
)
: null,
)
.filter(
(dec): dec is Range<Decoration> => dec !== null,
);
},
);
return Decoration.set(decorations) return Decoration.set(decorations);
} }
}, { },
decorations: v => v.decorations {
}) decorations: (v) => v.decorations,
},
);
} }

View file

@ -1,21 +1,20 @@
// modules/diffExtension.ts // modules/diffExtension.ts
import { EditorState, StateField, RangeSetBuilder } from "@codemirror/state";
import { import {
EditorState, Decoration,
StateField, DecorationSet,
RangeSetBuilder, EditorView,
} from "@codemirror/state"; WidgetType,
import { ViewPlugin,
Decoration, ViewUpdate,
DecorationSet,
EditorView,
WidgetType,
ViewPlugin,
ViewUpdate,
} from "@codemirror/view"; } from "@codemirror/view";
import DiffMatchPatch from "diff-match-patch"; import DiffMatchPatch from "diff-match-patch";
import { acceptTooltipEffect, dismissTooltipEffect } from "./WidgetExtension"; import { acceptTooltipEffect, dismissTooltipEffect } from "./WidgetExtension";
import { generatedResponseState, setGeneratedResponseEffect } from "./AIExtension"; import {
generatedResponseState,
setGeneratedResponseEffect,
} from "./AIExtension";
import { currentSelectionState } from "./SelectionState"; import { currentSelectionState } from "./SelectionState";
/** /**
@ -23,30 +22,43 @@ import { currentSelectionState } from "./SelectionState";
* Improves accessibility by using appropriate ARIA attributes. * Improves accessibility by using appropriate ARIA attributes.
*/ */
class ChangeContentWidget extends WidgetType { class ChangeContentWidget extends WidgetType {
constructor( constructor(
private readonly content: string, private readonly content: string,
private readonly type: 'added' | 'removed' private readonly type: "added" | "removed",
) { ) {
super(); super();
} }
toDOM(): HTMLElement { toDOM(): HTMLElement {
const wrapper = document.createElement("span"); const wrapper = document.createElement("span");
wrapper.className = `cm-change-widget cm-change-${this.type}`; wrapper.className = `cm-change-widget cm-change-${this.type}`;
wrapper.textContent = this.content; wrapper.textContent = this.content;
// Accessibility: Provide ARIA label // Accessibility: Provide ARIA label
wrapper.setAttribute( wrapper.setAttribute(
"aria-label", "aria-label",
this.type === 'added' ? "Added content" : "Removed content" this.type === "added" ? "Added content" : "Removed content",
); );
return wrapper;
}
ignoreEvent(): boolean { // Prevent mouse interactions from stealing focus from the editor
// Decide whether to ignore events on the widget // which can trigger Obsidian to re-render code blocks. We don't want
return false; // 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. * @returns A DecorationSet with the appropriate widgets.
*/ */
function generateDiffView(state: EditorState): DecorationSet { function generateDiffView(state: EditorState): DecorationSet {
try { try {
// Retrieve the AI response and the current context text from the state // Retrieve the AI response and the current context text from the state
const response = state.field(generatedResponseState); const response = state.field(generatedResponseState);
const context = state.field(currentSelectionState); const context = state.field(currentSelectionState);
const aiText: string = response?.airesponse ?? ""; const aiText: string = response?.airesponse ?? "";
const contextText: string = context?.text ?? ""; const contextText: string = context?.text ?? "";
// Use diff_match_patch instead of diffWords // Use diff_match_patch instead of diffWords
const dmp = new DiffMatchPatch(); const dmp = new DiffMatchPatch();
let diffs = dmp.diff_main(contextText, aiText); let diffs = dmp.diff_main(contextText, aiText);
// Perform semantic cleanup // Perform semantic cleanup
dmp.diff_cleanupSemantic(diffs); dmp.diff_cleanupSemantic(diffs);
// Initialize RangeSetBuilder for efficient decoration construction // Initialize RangeSetBuilder for efficient decoration construction
const builder = new RangeSetBuilder<Decoration>(); const builder = new RangeSetBuilder<Decoration>();
let currentPos = context?.from ?? 0; let currentPos = context?.from ?? 0;
diffs.forEach(([op, text]) => {
const length = text.length;
diffs.forEach(([op, text]) => { if (op === DiffMatchPatch.DIFF_INSERT) {
const length = text.length; // 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) { return builder.finish();
// AI text added } catch (error) {
const widget = new ChangeContentWidget(text, "added"); console.error("Error generating diff view:", error);
builder.add( return Decoration.none;
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;
}
} }
/** /**
@ -117,69 +128,141 @@ function generateDiffView(state: EditorState): DecorationSet {
* @param view - The EditorView instance. * @param view - The EditorView instance.
*/ */
function dispatchAIChanges(state: EditorState, view: EditorView): void { function dispatchAIChanges(state: EditorState, view: EditorView): void {
try { try {
// Grab the AI text and selection info (original context) // Grab the AI text and selection info (original context)
const response = state.field(generatedResponseState); const response = state.field(generatedResponseState);
const context = state.field(currentSelectionState); const context = state.field(currentSelectionState);
const aiText: string = response?.airesponse ?? ""; const aiText: string = response?.airesponse ?? "";
const selectionFrom = context?.from ?? 0; const selectionFrom = context?.from ?? 0;
const selectionTo = context?.to ?? 0; const selectionTo = context?.to ?? 0;
// Dispatch the transaction to apply the AI changes // Dispatch the transaction to apply the AI changes
view.dispatch({ view.dispatch({
changes: { from: selectionFrom, to: selectionTo, insert: aiText }, changes: { from: selectionFrom, to: selectionTo, insert: aiText },
}); });
} catch (error) { } catch (error) {
console.error("Error applying diff changes:", error); console.error("Error applying diff changes:", error);
} }
} }
export const diffDecorationState = StateField.define<DecorationSet>({ export const diffDecorationState = StateField.define<DecorationSet>({
create(): DecorationSet { create(): DecorationSet {
return Decoration.none; return Decoration.none;
}, },
update(decorations: DecorationSet, tr) { update(decorations: DecorationSet, tr) {
// Check if we got the AI response effect // Check if we got the AI response effect
if (tr.effects.some(e => e.is(setGeneratedResponseEffect))) { if (tr.effects.some((e) => e.is(setGeneratedResponseEffect))) {
return generateDiffView(tr.state); return generateDiffView(tr.state);
} }
// Check if the dismiss tooltip effect is present // Check if the dismiss tooltip effect is present
const hasDismissEffect = tr.effects.some(e => e.is(dismissTooltipEffect)); const hasDismissEffect = tr.effects.some((e) =>
if (hasDismissEffect) { e.is(dismissTooltipEffect),
return Decoration.none; );
} if (hasDismissEffect) {
return Decoration.none;
}
// Retain the existing decorations if no relevant changes // Retain the existing decorations if no relevant changes
return decorations; return decorations;
}, },
provide: (field) => EditorView.decorations.from(field), provide: (field) => EditorView.decorations.from(field),
}); });
/** /**
* Plugin to handle applying diff changes when the accept tooltip effect is triggered. * Plugin to handle applying diff changes when the accept tooltip effect is triggered.
*/ */
const applyDiffPlugin = ViewPlugin.fromClass(class { const applyDiffPlugin = ViewPlugin.fromClass(
update(update: ViewUpdate) { class {
// Iterate through all transactions in the update update(update: ViewUpdate) {
for (const transaction of update.transactions) { // Iterate through all transactions in the update
for (const effect of transaction.effects) { for (const transaction of update.transactions) {
if (effect.is(acceptTooltipEffect)) { for (const effect of transaction.effects) {
// Apply the diff changes by dispatching the transaction if (effect.is(acceptTooltipEffect)) {
setTimeout(() => { // Apply the diff changes by dispatching the transaction
dispatchAIChanges(update.state, update.view); setTimeout(() => {
}, 0); dispatchAIChanges(update.state, update.view);
} }, 0);
} }
} }
} }
}); }
},
);
/** /**
* Exported extension to be included in the EditorView. * 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 = [ export const diffExtension = [
diffDecorationState, diffDecorationState,
applyDiffPlugin, applyDiffPlugin,
focusGuardPlugin,
]; ];

View file

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

View file

@ -1,14 +1,24 @@
import { App, PluginSettingTab, Setting } from "obsidian"; import { App, PluginSettingTab, Setting, Notice, Platform } from "obsidian";
import MyPlugin from "./main"; import MyPlugin from "./main";
import { cursorPrompt, selectionPrompt } from "./default_prompts"; import { cursorPrompt, selectionPrompt } from "./default_prompts";
import { SlashCommand } from "./modules/commands/source"; import { SlashCommand, BUILT_IN_COMMANDS } from "./modules/commands/source";
import { startCodexOAuthFlow } from "./codex-auth";
import {
clearCodexTokens,
getApiKey,
getCodexTokens,
isSecretStorageAvailable,
setApiKey,
setCodexTokens,
} from "./credentials";
// Interface for the settings // Interface for the settings
export interface InlineAISettings { export interface InlineAISettings {
provider: "openai" | "ollama" | "custom" | "gemini"; provider: "openai" | "ollama" | "custom" | "gemini" | "azure" | "codex";
model: string; model: string;
apiKey?: string;
customURL?: string; customURL?: string;
azureEndpoint?: string;
azureApiVersion?: string;
selectionPrompt: string; selectionPrompt: string;
cursorPrompt: string; cursorPrompt: string;
customCommands: SlashCommand[]; customCommands: SlashCommand[];
@ -20,13 +30,14 @@ export interface InlineAISettings {
export const DEFAULT_SETTINGS: InlineAISettings = { export const DEFAULT_SETTINGS: InlineAISettings = {
provider: "ollama", provider: "ollama",
model: "llama3.2", model: "llama3.2",
apiKey: "",
customURL: "", customURL: "",
azureEndpoint: "",
azureApiVersion: "2024-02-15-preview",
selectionPrompt: selectionPrompt, selectionPrompt: selectionPrompt,
cursorPrompt: cursorPrompt, cursorPrompt: cursorPrompt,
customCommands: [], customCommands: [],
commandPrefix: "/", commandPrefix: "/",
messageHistory: false messageHistory: false,
}; };
export class InlineAISettingsTab extends PluginSettingTab { export class InlineAISettingsTab extends PluginSettingTab {
@ -47,48 +58,254 @@ export class InlineAISettingsTab extends PluginSettingTab {
const { containerEl } = this; const { containerEl } = this;
containerEl.empty(); containerEl.empty();
if (!isSecretStorageAvailable(this.app)) {
containerEl.createEl("p", {
text: "⚠️ InlineAI requires Obsidian 1.11.4 or later for secure credential storage. Please update Obsidian to use API keys and Codex sign-in.",
cls: "setting-item-description",
});
}
// Provider setting // Provider setting
new Setting(containerEl) new Setting(containerEl)
.setName("Provider") .setName("Provider")
.setDesc("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) => .addDropdown((dropdown) =>
dropdown dropdown
.addOption("openai", "OpenAI") .addOption("openai", "OpenAI")
.addOption("ollama", "Ollama") .addOption("ollama", "Ollama")
.addOption("azure", "Azure OpenAI")
.addOption("gemini", "Gemini") .addOption("gemini", "Gemini")
.addOption("custom", "Custom/OpenAI-compatible") .addOption("custom", "Custom/OpenAI-compatible")
.addOption("codex", "Codex (ChatGPT subscription)")
.setValue(this.plugin.settings.provider) .setValue(this.plugin.settings.provider)
.onChange(async (value) => { .onChange(async (value) => {
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(); await this.saveSettings();
this.display(); // Refresh UI to show/hide API key field this.display();
}) }),
); );
// Codex subscription auth section
if (this.plugin.settings.provider === "codex") {
const codexTokens = getCodexTokens(this.app);
const isSignedIn = !!(
codexTokens?.access && codexTokens?.accountId
);
new Setting(containerEl)
.setName("ChatGPT account")
.setDesc(
isSignedIn
? `Signed in (account: ${codexTokens!.accountId})`
: "Not signed in — click to authenticate with your ChatGPT Plus/Pro subscription.",
)
.addButton((btn) => {
btn.setButtonText(
isSignedIn ? "Sign out" : "Sign in with ChatGPT",
)
.setCta()
.onClick(async () => {
if (isSignedIn) {
clearCodexTokens(this.app);
this.display();
} else {
if (!isSecretStorageAvailable(this.app)) {
new Notice(
"⚠️ InlineAI requires Obsidian 1.11.4+ for Codex sign-in.",
);
return;
}
new Notice(
Platform.isMobileApp
? "Complete sign-in using the dialog that opens"
: "Opening browser for ChatGPT sign-in…",
);
const tokens = await startCodexOAuthFlow(this.app);
if (tokens) {
setCodexTokens(this.app, tokens);
new Notice(
"✅ Codex: signed in successfully",
);
this.display();
}
}
});
});
if (isSignedIn) {
containerEl.createEl("p", {
text: "Credentials are stored in Obsidian's keychain (Settings → Security). They are not synced with your vault and must be set up on each device.",
cls: "setting-item-description",
});
}
}
// Model setting // Model setting
new Setting(containerEl) if (this.plugin.settings.provider === "codex") {
.setName("Model") const CODEX_MODELS: {
.setDesc("Specify the model to use.") value: string;
.addText((text) => { label: string;
text.setPlaceholder("e.g., gpt-4o-mini") desc: string;
.setValue(this.plugin.settings.model) }[] = [
.inputEl.addEventListener("blur", async () => { {
this.plugin.settings.model = text.getValue(); value: "gpt-5.5",
label: "GPT-5.5",
desc: "Most capable — best for complex rewrites and reasoning",
},
{
value: "gpt-5.4-mini",
label: "GPT-5.4 mini ✦ recommended",
desc: "Fast and cost-efficient — ideal for inline edits",
},
{
value: "gpt-5.3-codex-spark",
label: "GPT-5.3 Codex Spark (Pro only)",
desc: "Near-instant iteration — requires ChatGPT Pro",
},
{
value: "gpt-5.2-codex",
label: "GPT-5.2 Codex",
desc: "Strong coding and structured writing",
},
{
value: "gpt-5.1-codex",
label: "GPT-5.1 Codex",
desc: "Balanced coding model",
},
{
value: "gpt-5.1-codex-max",
label: "GPT-5.1 Codex Max",
desc: "High-effort variant of GPT-5.1 Codex",
},
{
value: "codex-mini-latest",
label: "Codex Mini",
desc: "Lightest and fastest option",
},
{
value: "custom",
label: "Custom…",
desc: "Enter a model ID manually",
},
];
const isCustom = !CODEX_MODELS.some(
(m) =>
m.value === this.plugin.settings.model &&
m.value !== "custom",
);
const dropdownValue = isCustom
? "custom"
: this.plugin.settings.model;
const selectedModel = CODEX_MODELS.find(
(m) => m.value === dropdownValue,
);
new Setting(containerEl)
.setName("Model")
.setDesc(
selectedModel?.desc ??
"Select the model to use for Codex requests.",
)
.addDropdown((dd) => {
CODEX_MODELS.forEach((m) => dd.addOption(m.value, m.label));
dd.setValue(dropdownValue).onChange(async (value) => {
this.plugin.settings.model =
value === "custom" ? "" : value;
await this.saveSettings(); await this.saveSettings();
this.display();
}); });
}); });
if (isCustom || dropdownValue === "custom") {
new Setting(containerEl)
.setName("Custom model ID")
.setDesc(
"Enter the exact model ID as used by the Codex API.",
)
.addText((text) => {
text.setPlaceholder("e.g., gpt-5.1-codex")
.setValue(
isCustom ? this.plugin.settings.model : "",
)
.inputEl.addEventListener("blur", async () => {
this.plugin.settings.model = text
.getValue()
.trim();
await this.saveSettings();
this.display();
});
});
if (!this.plugin.settings.model.trim()) {
containerEl.createEl("p", {
text: "⚠️ No model ID entered — requests will fail until you set one.",
cls: "setting-item-description",
});
}
}
} else {
new Setting(containerEl)
.setName("Model")
.setDesc("Specify the model to use.")
.addText((text) => {
text.setPlaceholder("e.g., gpt-4o-mini")
.setValue(this.plugin.settings.model)
.inputEl.addEventListener("blur", async () => {
this.plugin.settings.model = text.getValue();
await this.saveSettings();
});
});
}
// API Key setting (conditionally displayed for OpenAI-supported endpoints) // API Key setting (conditionally displayed for OpenAI-supported endpoints)
if (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) new Setting(containerEl)
.setName("API key") .setName("API key")
.setDesc("Enter your API key.") .setDesc("Enter your API key.")
.addText((text) => { .addText((text) => {
text.setPlaceholder("sk-...") text.setPlaceholder("sk-...")
.setValue(this.plugin.settings.apiKey || "") .setValue(getApiKey(this.app) ?? "")
.inputEl.addEventListener("blur", async () => { .inputEl.addEventListener("blur", async () => {
this.plugin.settings.apiKey = text.getValue(); if (!isSecretStorageAvailable(this.app)) {
await this.saveSettings(); new Notice(
"⚠️ InlineAI requires Obsidian 1.11.4+ to store API keys securely.",
);
return;
}
setApiKey(this.app, text.getValue());
this.plugin.chatapi.updateSettings(
this.plugin.settings,
);
}); });
}); });
} }
@ -97,7 +314,9 @@ export class InlineAISettingsTab extends PluginSettingTab {
if (this.plugin.settings.provider === "custom") { if (this.plugin.settings.provider === "custom") {
new Setting(containerEl) new Setting(containerEl)
.setName("Custom endpoint") .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) => { .addText((text) => {
text.setPlaceholder("https://api.mycustomhost.com/v1") text.setPlaceholder("https://api.mycustomhost.com/v1")
.setValue(this.plugin.settings.customURL || "") .setValue(this.plugin.settings.customURL || "")
@ -108,17 +327,61 @@ export class InlineAISettingsTab extends PluginSettingTab {
}); });
} }
// 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 // Advanced Section
containerEl.createEl("h3", { text: "Advanced" }); containerEl.createEl("h3", { text: "Advanced" });
// Selection Prompt setting // Selection Prompt setting
new Setting(containerEl) new Setting(containerEl)
.setName("Selection prompt") .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) => { .addTextArea((textarea) => {
textarea.setPlaceholder("e.g., Summarize the selected text.") textarea
.setPlaceholder("e.g., Summarize the selected text.")
.setValue(this.plugin.settings.selectionPrompt) .setValue(this.plugin.settings.selectionPrompt)
.inputEl.addEventListener("blur", async () => { .inputEl.addEventListener("blur", async () => {
this.plugin.settings.selectionPrompt = textarea.getValue(); this.plugin.settings.selectionPrompt =
textarea.getValue();
await this.saveSettings(); await this.saveSettings();
}); });
textarea.inputEl.classList.add("wide-text-settings"); textarea.inputEl.classList.add("wide-text-settings");
@ -127,9 +390,14 @@ export class InlineAISettingsTab extends PluginSettingTab {
// Cursor Prompt setting // Cursor Prompt setting
new Setting(containerEl) new Setting(containerEl)
.setName("Cursor prompt") .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) => { .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) .setValue(this.plugin.settings.cursorPrompt)
.inputEl.addEventListener("blur", async () => { .inputEl.addEventListener("blur", async () => {
this.plugin.settings.cursorPrompt = textarea.getValue(); this.plugin.settings.cursorPrompt = textarea.getValue();
@ -141,28 +409,41 @@ export class InlineAISettingsTab extends PluginSettingTab {
// Message History setting // Message History setting
new Setting(containerEl) new Setting(containerEl)
.setName("Message History") .setName("Message History")
.setDesc("Enable message history, you can navigate through the history using the up/down arrow keys.") .setDesc(
"Enable message history, you can navigate through the history using the up/down arrow keys.",
)
.addToggle((toggle) => { .addToggle((toggle) => {
toggle.setValue(this.plugin.settings.messageHistory) toggle
.setValue(this.plugin.settings.messageHistory)
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.messageHistory = value; this.plugin.settings.messageHistory = value;
await this.saveSettings(); await this.saveSettings();
}); });
}); });
// Custom Commands Section // Custom Commands Section
containerEl.createEl("h3", { text: "Custom Commands" }); 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 // Command Prefix setting
new Setting(containerEl) new Setting(containerEl)
.setName("Command Prefix") .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) => { .addText((text) => {
text.setPlaceholder("/") text.setPlaceholder("/")
.setValue(this.plugin.settings.commandPrefix) .setValue(this.plugin.settings.commandPrefix)
.inputEl.addEventListener("blur", async () => { .inputEl.addEventListener("blur", async () => {
this.plugin.settings.commandPrefix = text.getValue().charAt(0); this.plugin.settings.commandPrefix = text
.getValue()
.charAt(0);
await this.saveSettings(); await this.saveSettings();
this.display(); this.display();
}); });
@ -177,15 +458,18 @@ export class InlineAISettingsTab extends PluginSettingTab {
text.setValue(command.keyword) text.setValue(command.keyword)
.setPlaceholder("Command name") .setPlaceholder("Command name")
.inputEl.addEventListener("blur", async () => { .inputEl.addEventListener("blur", async () => {
this.plugin.settings.customCommands[index].keyword = text.getValue(); this.plugin.settings.customCommands[index].keyword =
text.getValue();
await this.saveSettings(); await this.saveSettings();
}); });
}) })
.addTextArea((textarea) => { .addTextArea((textarea) => {
textarea.setValue(command.prompt) textarea
.setValue(command.prompt)
.setPlaceholder("Command prompt") .setPlaceholder("Command prompt")
.inputEl.addEventListener("blur", async () => { .inputEl.addEventListener("blur", async () => {
this.plugin.settings.customCommands[index].prompt = textarea.getValue(); this.plugin.settings.customCommands[index].prompt =
textarea.getValue();
await this.saveSettings(); await this.saveSettings();
}); });
}) })
@ -194,25 +478,29 @@ export class InlineAISettingsTab extends PluginSettingTab {
.setIcon("trash") .setIcon("trash")
.setTooltip("Delete this command") .setTooltip("Delete this command")
.onClick(async () => { .onClick(async () => {
this.plugin.settings.customCommands.splice(index, 1); this.plugin.settings.customCommands.splice(
index,
1,
);
await this.saveSettings(); await this.saveSettings();
this.display(); this.display();
}) }),
); );
}); });
// Add new command button // Add new command button
new Setting(containerEl).addButton((btn) => new Setting(containerEl).addButton((btn) =>
btn btn
.setButtonText("Add Command") .setButtonText("Add Command")
.setCta() .setCta()
.onClick(async () => { .onClick(async () => {
this.plugin.settings.customCommands.push({ keyword: "new_command", prompt: "" }); this.plugin.settings.customCommands.push({
keyword: "new_command",
prompt: "",
});
await this.saveSettings(); await this.saveSettings();
this.display(); this.display();
}) }),
); );
} }
} }

View file

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

View file

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