Reformat the code with the new prettier config

This commit is contained in:
Barrca 2025-09-12 18:47:27 +02:00
parent 41e73ea28a
commit ad60944974
17 changed files with 1191 additions and 998 deletions

View file

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

View file

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

View file

@ -1,2 +1 @@
{}

View file

@ -59,15 +59,14 @@
### Initial Setup
1. **Set up your API key**:
- Open the plugin settings in Obsidian.
- Enter your API key for OpenAI, Ollama, Gemini or any supported model provider.
- Open the plugin settings in Obsidian.
- Enter your API key for OpenAI, Ollama, Gemini or any supported model provider.
2. **Choose a model**:
- Supported models include `gpt-4`, `llama3.2`, and others.
- Supported models include `gpt-4`, `llama3.2`, and others.
3. **Configure prompts**:
- Define system and transformation prompts in settings for customized interactions.
- Define system and transformation prompts in settings for customized interactions.
### How to Use
@ -97,6 +96,7 @@ Want to contribute? Heres how:
git clone https://github.com/FBarrca/obsidian-inlineai.git
```
2. Install dependencies:
```bash
@ -104,6 +104,7 @@ Want to contribute? Heres how:
npm install
```
3. Build the plugin:
```bash

View file

@ -8,4 +8,4 @@
"authorUrl": "https://github.com/FBarrca/",
"fundingUrl": "https://www.buymeacoffee.com/FBarrCa",
"isDesktopOnly": false
}
}

View file

@ -2,7 +2,11 @@
import { ChatOpenAI } from "@langchain/openai";
import { ChatOllama } from "@langchain/ollama";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { SystemMessage, HumanMessage, AIMessage } from "@langchain/core/messages";
import {
SystemMessage,
HumanMessage,
AIMessage,
} from "@langchain/core/messages";
import { InlineAISettings } from "./settings";
import { App, MarkdownView, Notice } from "obsidian";
import { EditorView } from "@codemirror/view";
@ -13,196 +17,228 @@ import { MessageQueue } from "./modules/messageHistory/queue";
const MESSAGE_HISTORY_LIMIT = 20;
export type HistoryMessage = {
mode: string;
userPrompt: string;
mode: string;
userPrompt: string;
};
/**
* Class to manage interactions with different chat APIs.
*/
export class ChatApiManager {
private chatClient: ChatOpenAI | ChatOllama | ChatGoogleGenerativeAI | null;
private app: App;
private settings: InlineAISettings;
private messageHistory: MessageQueue<HistoryMessage>;
/**
* Initializes the ChatApiManager with the given settings.
* @param settings - Configuration settings for the chat API.
* @param app - The Obsidian App instance.
*/
constructor(settings: InlineAISettings, app: App) {
this.app = app;
this.chatClient = this.initializeChatClient(settings);
this.settings = settings;
this.messageHistory = new MessageQueue<HistoryMessage>(MESSAGE_HISTORY_LIMIT);
}
private chatClient: ChatOpenAI | ChatOllama | ChatGoogleGenerativeAI | null;
private app: App;
private settings: InlineAISettings;
private messageHistory: MessageQueue<HistoryMessage>;
/**
* Initializes the ChatApiManager with the given settings.
* @param settings - Configuration settings for the chat API.
* @param app - The Obsidian App instance.
*/
constructor(settings: InlineAISettings, app: App) {
this.app = app;
this.chatClient = this.initializeChatClient(settings);
this.settings = settings;
this.messageHistory = new MessageQueue<HistoryMessage>(
MESSAGE_HISTORY_LIMIT,
);
}
/**
* Initializes the appropriate chat client based on the provider specified in settings.
* @param settings - Configuration settings for the chat API.
* @returns An instance of ChatOpenAI, ChatOllama, or null if initialization fails.
*/
private initializeChatClient(settings: InlineAISettings): ChatOpenAI | ChatOllama | ChatGoogleGenerativeAI | null {
try {
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,
});
/**
* Initializes the appropriate chat client based on the provider specified in settings.
* @param settings - Configuration settings for the chat API.
* @returns An instance of ChatOpenAI, ChatOllama, or null if initialization fails.
*/
private initializeChatClient(
settings: InlineAISettings,
): ChatOpenAI | ChatOllama | ChatGoogleGenerativeAI | null {
try {
if (settings.messageHistory) {
this.messageHistory = new MessageQueue<HistoryMessage>(
MESSAGE_HISTORY_LIMIT,
);
} else {
this.messageHistory = new MessageQueue<HistoryMessage>(0);
}
case "ollama":
return new ChatOllama({
model: settings.model,
});
case "gemini":
return new ChatGoogleGenerativeAI({
model: settings.model,
apiKey: settings.apiKey,
});
case "custom":
if (!settings.apiKey || !settings.customURL) {
new Notice("⚠️ API key and custom base URL are required for custom providers.");
return null;
}
return new ChatOpenAI({
modelName: settings.model,
temperature: 0,
openAIApiKey: settings.apiKey,
// 'configuration.basePath' is the recognized property
configuration: {
baseURL: settings.customURL.trim(),
},
});
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,
});
default:
new Notice(`⚠️ Unsupported provider: ${settings.provider}`);
return null;
}
} catch (error: any) {
console.error("Error initializing chat client:", error);
new Notice(`❌ Error initializing chat client: ${error.message}`);
return null;
}
}
case "ollama":
return new ChatOllama({
model: settings.model,
});
case "gemini":
return new ChatGoogleGenerativeAI({
model: settings.model,
apiKey: settings.apiKey,
});
case "custom":
if (!settings.apiKey || !settings.customURL) {
new Notice(
"⚠️ API key and custom base URL are required for custom providers.",
);
return null;
}
return new ChatOpenAI({
modelName: settings.model,
temperature: 0,
openAIApiKey: settings.apiKey,
// 'configuration.basePath' is the recognized property
configuration: {
baseURL: settings.customURL.trim(),
},
});
/**
* 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.";
}
default:
new Notice(`⚠️ Unsupported provider: ${settings.provider}`);
return null;
}
} catch (error: any) {
console.error("Error initializing chat client:", error);
new Notice(`❌ Error initializing chat client: ${error.message}`);
return null;
}
}
const messages = [
new SystemMessage(systemMessage),
new HumanMessage(message),
];
/**
* 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.";
}
try {
const aiMessage: AIMessage = await this.chatClient.invoke(messages);
return aiMessage.content.toString();
} catch (error: any) {
console.error("Error calling the chat model:", error);
new Notice(`❌ Error calling the chat model: ${error.message}`);
return "⚠️ Failed to generate a response. Please try again later.";
}
}
const messages = [
new SystemMessage(systemMessage),
new HumanMessage(message),
];
/**
* 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.";
try {
const aiMessage: AIMessage = await this.chatClient.invoke(messages);
return aiMessage.content.toString();
} catch (error: any) {
console.error("Error calling the chat model:", error);
new Notice(`❌ Error calling the chat model: ${error.message}`);
return "⚠️ Failed to generate a response. Please try again later.";
}
}
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!markdownView) {
new Notice("⚠️ No active Markdown editor found.");
return "";
}
/**
* 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 mainEditorView = (markdownView.editor as any).cm as EditorView;
mainEditorView?.dispatch({
effects: setGeneratedResponseEffect.of({ airesponse: response, prompt: userRequest }),
});
const markdownView =
this.app.workspace.getActiveViewOfType(MarkdownView);
if (!markdownView) {
new Notice("⚠️ No active Markdown editor found.");
return "";
}
return response;
} catch (error: any) {
console.error("Error processing request:", error);
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);
const mainEditorView = (markdownView.editor as any)
.cm as EditorView;
mainEditorView?.dispatch({
effects: setGeneratedResponseEffect.of({
airesponse: response,
prompt: userRequest,
}),
});
let isCursor = false;
if (selectedText.trim().length === 0) {
isCursor = true;
}
return response;
} catch (error: any) {
console.error("Error processing request:", error);
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,
);
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 });
}
let isCursor = false;
if (selectedText.trim().length === 0) {
isCursor = true;
}
if (isCursor) {
finalUserPrompt = `
const systemPrompt = isCursor
? this.settings.cursorPrompt
: this.settings.selectionPrompt;
let finalUserPrompt = ``;
const mode = isCursor ? "cursor" : "selection";
if (this.settings.messageHistory) {
this.messageHistory.enqueue({ mode, userPrompt });
}
if (isCursor) {
finalUserPrompt = `
**Task:** ${userPrompt}
**Output:**`;
} else {
finalUserPrompt = `
} else {
finalUserPrompt = `
**Task:** ${userPrompt}
**Input:**
${selectedText}
**Output:**`;
}
return this.handleEditorUpdate(systemPrompt, finalUserPrompt);
}
}
return this.handleEditorUpdate(systemPrompt, finalUserPrompt);
}
/**
* Updates the manager's settings and reinitializes the chat client.
* @param settings - New configuration settings for the chat API.
*/
public updateSettings(settings: InlineAISettings): void {
this.settings = settings;
const newChatClient = this.initializeChatClient(settings);
if (!newChatClient) {
return;
}
this.chatClient = newChatClient;
}
/**
* Updates the manager's settings and reinitializes the chat client.
* @param settings - New configuration settings for the chat API.
*/
public updateSettings(settings: InlineAISettings): void {
this.settings = settings;
const newChatClient = this.initializeChatClient(settings);
if (!newChatClient) {
return;
}
this.chatClient = newChatClient;
}
public getMessageHistory(): HistoryMessage[] {
return this.messageHistory.getItems();
}
public getMessageHistory(): HistoryMessage[] {
return this.messageHistory.getItems();
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -26,7 +26,7 @@ export const DEFAULT_SETTINGS: InlineAISettings = {
cursorPrompt: cursorPrompt,
customCommands: [],
commandPrefix: "/",
messageHistory: false
messageHistory: false,
};
export class InlineAISettingsTab extends PluginSettingTab {
@ -50,7 +50,9 @@ export class InlineAISettingsTab extends PluginSettingTab {
// Provider setting
new Setting(containerEl)
.setName("Provider")
.setDesc("Choose between OpenAI, Ollama, or a custom OpenAI-compatible endpoint.")
.setDesc(
"Choose between OpenAI, Ollama, or a custom OpenAI-compatible endpoint.",
)
.addDropdown((dropdown) =>
dropdown
.addOption("openai", "OpenAI")
@ -59,10 +61,14 @@ export class InlineAISettingsTab extends PluginSettingTab {
.addOption("custom", "Custom/OpenAI-compatible")
.setValue(this.plugin.settings.provider)
.onChange(async (value) => {
this.plugin.settings.provider = value as "openai" | "ollama" | "custom" | "gemini";
this.plugin.settings.provider = value as
| "openai"
| "ollama"
| "custom"
| "gemini";
await this.saveSettings();
this.display(); // Refresh UI to show/hide API key field
})
}),
);
// Model setting
@ -79,7 +85,11 @@ export class InlineAISettingsTab extends PluginSettingTab {
});
// 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"
) {
new Setting(containerEl)
.setName("API key")
.setDesc("Enter your API key.")
@ -97,7 +107,9 @@ export class InlineAISettingsTab extends PluginSettingTab {
if (this.plugin.settings.provider === "custom") {
new Setting(containerEl)
.setName("Custom endpoint")
.setDesc("Enter your OpenAI-compatible base URL (e.g. https://api.groq.com/openai/v1).")
.setDesc(
"Enter your OpenAI-compatible base URL (e.g. https://api.groq.com/openai/v1).",
)
.addText((text) => {
text.setPlaceholder("https://api.mycustomhost.com/v1")
.setValue(this.plugin.settings.customURL || "")
@ -113,12 +125,16 @@ export class InlineAISettingsTab extends PluginSettingTab {
// Selection Prompt setting
new Setting(containerEl)
.setName("Selection prompt")
.setDesc("System Prompt used when the tooltip is triggered with selected text.")
.setDesc(
"System Prompt used when the tooltip is triggered with selected text.",
)
.addTextArea((textarea) => {
textarea.setPlaceholder("e.g., Summarize the selected text.")
textarea
.setPlaceholder("e.g., Summarize the selected text.")
.setValue(this.plugin.settings.selectionPrompt)
.inputEl.addEventListener("blur", async () => {
this.plugin.settings.selectionPrompt = textarea.getValue();
this.plugin.settings.selectionPrompt =
textarea.getValue();
await this.saveSettings();
});
textarea.inputEl.classList.add("wide-text-settings");
@ -127,9 +143,14 @@ export class InlineAISettingsTab extends PluginSettingTab {
// Cursor Prompt setting
new Setting(containerEl)
.setName("Cursor prompt")
.setDesc("System Prompt used when the tooltip is triggered with selected text.")
.setDesc(
"System Prompt used when the tooltip is triggered with selected text.",
)
.addTextArea((textarea) => {
textarea.setPlaceholder("e.g., Generate text based on cursor position.")
textarea
.setPlaceholder(
"e.g., Generate text based on cursor position.",
)
.setValue(this.plugin.settings.cursorPrompt)
.inputEl.addEventListener("blur", async () => {
this.plugin.settings.cursorPrompt = textarea.getValue();
@ -141,28 +162,37 @@ export class InlineAISettingsTab extends PluginSettingTab {
// Message History setting
new Setting(containerEl)
.setName("Message History")
.setDesc("Enable message history, you can navigate through the history using the up/down arrow keys.")
.setDesc(
"Enable message history, you can navigate through the history using the up/down arrow keys.",
)
.addToggle((toggle) => {
toggle.setValue(this.plugin.settings.messageHistory)
toggle
.setValue(this.plugin.settings.messageHistory)
.onChange(async (value) => {
this.plugin.settings.messageHistory = value;
await this.saveSettings();
});
});
// Custom Commands Section
containerEl.createEl("h3", { text: "Custom Commands" });
containerEl.createEl("p", { text: "Add your own custom commands. Triggered with the prefix defined in the Command Prefix setting." });
containerEl.createEl("p", {
text: "Add your own custom commands. Triggered with the prefix defined in the Command Prefix setting.",
});
// Command Prefix setting
new Setting(containerEl)
.setName("Command Prefix")
.setDesc("The prefix used to trigger custom commands (e.g., /, !, #)")
.setDesc(
"The prefix used to trigger custom commands (e.g., /, !, #)",
)
.addText((text) => {
text.setPlaceholder("/")
.setValue(this.plugin.settings.commandPrefix)
.inputEl.addEventListener("blur", async () => {
this.plugin.settings.commandPrefix = text.getValue().charAt(0);
this.plugin.settings.commandPrefix = text
.getValue()
.charAt(0);
await this.saveSettings();
this.display();
});
@ -177,15 +207,18 @@ export class InlineAISettingsTab extends PluginSettingTab {
text.setValue(command.keyword)
.setPlaceholder("Command name")
.inputEl.addEventListener("blur", async () => {
this.plugin.settings.customCommands[index].keyword = text.getValue();
this.plugin.settings.customCommands[index].keyword =
text.getValue();
await this.saveSettings();
});
})
.addTextArea((textarea) => {
textarea.setValue(command.prompt)
textarea
.setValue(command.prompt)
.setPlaceholder("Command prompt")
.inputEl.addEventListener("blur", async () => {
this.plugin.settings.customCommands[index].prompt = textarea.getValue();
this.plugin.settings.customCommands[index].prompt =
textarea.getValue();
await this.saveSettings();
});
})
@ -194,25 +227,29 @@ export class InlineAISettingsTab extends PluginSettingTab {
.setIcon("trash")
.setTooltip("Delete this command")
.onClick(async () => {
this.plugin.settings.customCommands.splice(index, 1);
this.plugin.settings.customCommands.splice(
index,
1,
);
await this.saveSettings();
this.display();
})
}),
);
});
// Add new command button
new Setting(containerEl).addButton((btn) =>
btn
.setButtonText("Add Command")
.setCta()
.onClick(async () => {
this.plugin.settings.customCommands.push({ keyword: "new_command", prompt: "" });
this.plugin.settings.customCommands.push({
keyword: "new_command",
prompt: "",
});
await this.saveSettings();
this.display();
})
}),
);
}
}

View file

@ -79,12 +79,16 @@
@keyframes l5 {
0%,
33% {
box-shadow: 10px 0 var(--text-accent), -10px 0 var(--text-faint);
box-shadow:
10px 0 var(--text-accent),
-10px 0 var(--text-faint);
background: var(--text-accent);
}
66%,
100% {
box-shadow: 10px 0 var(--text-faint), -10px 0 var(--text-accent);
box-shadow:
10px 0 var(--text-faint),
-10px 0 var(--text-accent);
background: var(--text-faint);
}
}
@ -118,7 +122,7 @@
height: 10em;
}
.tooltip-autocomplete{
.tooltip-autocomplete {
background-color: var(--background-primary-alt) !important;
color: var(--text-normal) !important;
width: 101.9% !important;
@ -130,11 +134,11 @@
}
.tooltip-autocomplete li[aria-selected] {
background-color: var(--text-selection)!important;
background-color: var(--text-selection) !important;
color: var(--text-accent) !important;
}
.completion-label{
.completion-label {
font-size: var(--font-ui-small);
font-weight: var(--font-normal);
font-family: Arial, Helvetica, sans-serif !important;
@ -144,12 +148,11 @@
.cm-tooltip.cm-tooltip-below {
top: 100%;
margin-top: 0.8em;
margin-top: 0.8em;
bottom: auto;
}
/* Slash command highlighting */
.cm-slash-command {
color: var(--text-accent);
}
color: var(--text-accent);
}