Rework the whole codebase

This commit is contained in:
FBarrca 2025-01-04 11:24:21 +01:00
parent 71f2db3669
commit c1a1176469
13 changed files with 1026 additions and 950 deletions

164
package-lock.json generated
View file

@ -9,6 +9,8 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@codemirror/commands": "^6.7.1",
"@codemirror/merge": "^6.7.5",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.34.1",
"@langchain/core": "^0.3.16",
@ -16,7 +18,8 @@
"@langchain/openai": "^0.3.11",
"diff": "^7.0.0",
"dotenv": "^16.4.5",
"ollama": "^0.5.9"
"jest-diff": "^29.7.0",
"simple-text-diff": "^1.7.0"
},
"devDependencies": {
"@types/codemirror": "^5.60.15",
@ -30,6 +33,42 @@
"typescript": "^5.6.3"
}
},
"node_modules/@codemirror/commands": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.7.1.tgz",
"integrity": "sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.10.8",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.8.tgz",
"integrity": "sha512-wcP8XPPhDH2vTqf181U8MbZnW+tDyPYy0UzVOa+oHORjyT+mhhom9vBd7dApJwoDz9Nb/a8kHjJIsuA/t8vNFw==",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.1.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/merge": {
"version": "6.7.5",
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.7.5.tgz",
"integrity": "sha512-cLmL8sJR4+4d4/E7gJpDFfWfYp8DRN4uVsqb2JkufPANv5Y9rgkFZvbnFtcPMhc6VFlpGA+bBFpS/uOaCwy95w==",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/highlight": "^1.0.0",
"style-mod": "^4.1.0"
}
},
"node_modules/@codemirror/state": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz",
@ -573,6 +612,17 @@
"dev": true,
"peer": true
},
"node_modules/@jest/schemas": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
"integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
"dependencies": {
"@sinclair/typebox": "^0.27.8"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/@langchain/core": {
"version": "0.3.16",
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.16.tgz",
@ -641,6 +691,27 @@
"@langchain/core": ">=0.2.26 <0.4.0"
}
},
"node_modules/@lezer/common": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
"integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA=="
},
"node_modules/@lezer/highlight": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
"integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
"integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -676,6 +747,11 @@
"node": ">= 8"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
"integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="
},
"node_modules/@types/codemirror": {
"version": "5.60.15",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.15.tgz",
@ -1003,8 +1079,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"peer": true,
"dependencies": {
"color-convert": "^2.0.1"
},
@ -1113,8 +1187,6 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"peer": true,
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
@ -1130,8 +1202,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"peer": true,
"dependencies": {
"color-name": "~1.1.4"
},
@ -1142,9 +1212,7 @@
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"peer": true
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/combined-stream": {
"version": "1.0.8",
@ -1239,6 +1307,14 @@
"node": ">=0.3.1"
}
},
"node_modules/diff-sequences": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
"integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@ -1532,6 +1608,11 @@
"dev": true,
"peer": true
},
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="
},
"node_modules/fast-glob": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
@ -1772,8 +1853,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"peer": true,
"engines": {
"node": ">=8"
}
@ -1889,6 +1968,28 @@
"dev": true,
"peer": true
},
"node_modules/jest-diff": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz",
"integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==",
"dependencies": {
"chalk": "^4.0.0",
"diff-sequences": "^29.6.3",
"jest-get-type": "^29.6.3",
"pretty-format": "^29.7.0"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/jest-get-type": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
"integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==",
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/js-tiktoken": {
"version": "1.0.15",
"resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.15.tgz",
@ -2374,6 +2475,30 @@
"node": ">= 0.8.0"
}
},
"node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
"integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
"dependencies": {
"@jest/schemas": "^29.6.3",
"ansi-styles": "^5.0.0",
"react-is": "^18.0.0"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -2404,6 +2529,11 @@
}
]
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@ -2507,6 +2637,14 @@
"node": ">=8"
}
},
"node_modules/simple-text-diff": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/simple-text-diff/-/simple-text-diff-1.7.0.tgz",
"integrity": "sha512-IQzeFnG66XPGWS2UjeVFGDAcUk2LFJEbTv52Vb4/YzNzWrBVwzHcNUdNxF0y7QK8NkdW43LX6mljroDyBfPK2g==",
"dependencies": {
"fast-diff": "^1.2.0"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@ -2542,8 +2680,6 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"peer": true,
"dependencies": {
"has-flag": "^4.0.0"
},

View file

@ -23,6 +23,8 @@
"typescript": "^5.6.3"
},
"dependencies": {
"@codemirror/commands": "^6.7.1",
"@codemirror/merge": "^6.7.5",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.34.1",
"@langchain/core": "^0.3.16",
@ -30,6 +32,7 @@
"@langchain/openai": "^0.3.11",
"diff": "^7.0.0",
"dotenv": "^16.4.5",
"ollama": "^0.5.9"
"jest-diff": "^29.7.0",
"simple-text-diff": "^1.7.0"
}
}

View file

@ -1,71 +1,148 @@
// api.ts
import { ChatOpenAI } from "@langchain/openai";
import { ChatOllama } from "@langchain/ollama"; // Replace with actual Ollama import
import {
generateFineGrainedConflictWithinLine,
generateFineGrainedConflictWithinLineString,
} from "./helpers/conflictMarkers";
import { ChatOllama } from "@langchain/ollama";
import { SystemMessage, HumanMessage, AIMessage } from "@langchain/core/messages";
import { MyPluginSettings } from "./settings";
import { App, MarkdownView } from "obsidian";
import { EditorView } from "@codemirror/view";
import { setAIResponseEffect } from "./modules/AIExtension";
// Function to initialize the appropriate chat client based on provider
function initializeChatClient(settings: MyPluginSettings) {
if (settings.provider === "openai") {
if (!settings.apiKey) {
throw new Error("OpenAI API key is required when using OpenAI as the provider.");
/**
* Class to manage interactions with different chat APIs.
*/
export class ChatApiManager {
private chatClient: ChatOpenAI | ChatOllama;
private app: App;
/**
* Initializes the ChatApiManager with the given settings.
* @param settings - Configuration settings for the chat API.
*/
constructor(private settings: MyPluginSettings, app: App) {
this.app = app;
this.chatClient = this.initializeChatClient(settings);
}
/**
* 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 or ChatOllama.
* @throws Error if the provider is unsupported or required settings are missing.
*/
private initializeChatClient(settings: MyPluginSettings): ChatOpenAI | ChatOllama {
switch (settings.provider) {
case "openai":
if (!settings.apiKey) {
throw new Error("OpenAI API key is required when using OpenAI as the provider.");
}
return new ChatOpenAI({
modelName: settings.model,
temperature: 0, // Set temperature to 0 for deterministic outputs
apiKey: settings.apiKey,
});
case "ollama":
return new ChatOllama({
model: settings.model,
// Add other necessary configurations for Ollama if needed
});
default:
throw new Error(`Unsupported provider: ${settings.provider}`);
}
return new ChatOpenAI({
modelName: settings.model,
temperature: 0, // Set temperature to 0 for deterministic outputs
apiKey: settings.apiKey,
});
} else if (settings.provider === "ollama") {
// Initialize Ollama client. Replace with actual initialization code.
return new ChatOllama({
model: settings.model,
// Add other necessary configurations for Ollama if needed
});
} else {
throw new Error(`Unsupported provider: ${settings.provider}`);
}
}
export async function callApi(
content: string,
context: string,
settings: MyPluginSettings
): Promise<{ generated: string; diff: string }> {
// Initialize the appropriate chat client based on settings
const chat = initializeChatClient(settings);
// Prepare the messages array using LangChain message classes
const messages = [
new SystemMessage(
"Reply to the user request, do not add any intro messages or any alternative outputs, just do one example of what you are told"
),
new HumanMessage(`\`\`\`\n${context}\n${content}\`\`\``),
];
try {
// Call the chat model with the prepared messages
const aiMessage: AIMessage = await chat.call(messages);
const generatedContentString = aiMessage.content.toString();
// Log the fine-grained conflict for debugging or analysis
console.log(
generateFineGrainedConflictWithinLine(context, generatedContentString)
);
return {
generated: generatedContentString,
diff: generateFineGrainedConflictWithinLineString(
context,
generatedContentString
),
};
} catch (error) {
console.error("Error calling the chat model:", error);
throw new Error("Failed to generate response from the chat model.");
}
/**
* 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.
* @throws Error if the API call fails.
*/
public async callApi(systemMessage: string, message: string): Promise<string> {
const messages = [
new SystemMessage(systemMessage),
new HumanMessage(message),
];
try {
const aiMessage: AIMessage = await this.chatClient.invoke(messages);
return aiMessage.content.toString();
} catch (error) {
console.error("Error calling the chat model:", error);
throw new Error("Failed to generate response from the chat model.");
}
}
/**
* 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.
*/
private async handleEditorUpdate(systemPrompt: string, userRequest: string): Promise<string> {
try {
const response = await this.callApi(systemPrompt, userRequest);
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!markdownView) return "";
const mainEditorView = (markdownView.editor as any).cm as EditorView;
mainEditorView?.dispatch({
effects: setAIResponseEffect.of({ airesponse: response, prompt: userRequest }),
});
return response;
} catch (error) {
console.error("Error processing request:", error);
throw new Error("Failed to process request.");
}
}
/**
* Handles user input and generates a response using the cursor API.
* @param userRequest - The user's request to process.
* @returns The AI-generated response.
*/
public async callCursor(userRequest: string): Promise<string> {
const systemPrompt = "You are a helpful assistant. Please help the user with the following request:";
return this.handleEditorUpdate(systemPrompt, userRequest);
}
/**
* Processes selected text using the specified prompt and transformation.
* @param prompt - The transformation prompt (e.g., "Add Emojis").
* @param selectedText - The selected text to transform.
* @returns The transformed text.
*/
public async callSelection(prompt: string, selectedText: string): Promise<string> {
const systemPrompt = `
**System Prompt:**
You are an advanced language model that performs text transformations based on specific instructions. Your task is to process input text to produce the desired output based on a given transformation type. You can handle tasks like adding emojis, making text longer or shorter, and converting text into tables among many others. Follow the examples provided to guide your responses.
It is **very important** that you follow the examples. Do not add anything at the start of the output like "Output:" or "Here's arephrased version of the input text:" or anything similar. Just provide the transformed text.
**Examples:**
---
**Task:** Add Emojis.
**Prompt:** Add relevant emojis to make the text more engaging.
**Input:**
"Let's celebrate the success of our project."
**Output:**
"🎉 Let's celebrate the success of our project! 🚀👏"
---
`;
const userPrompt = `
**Task:** ${prompt}
**Input:**
${selectedText}
**Output:**`
return this.handleEditorUpdate(systemPrompt, userPrompt);
}
}

View file

@ -1,331 +0,0 @@
import { EditorView, placeholder, keymap } from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import {
ButtonComponent,
setIcon,
Notice,
App,
MarkdownView,
EditorPosition,
} from "obsidian";
import { callApi } from "../api";
export class TooltipWidget {
dom: HTMLDivElement;
editorView: EditorView;
submitButton: ButtonComponent;
acceptButton: ButtonComponent;
discardButton: ButtonComponent;
reloadButton: ButtonComponent;
loader: HTMLDivElement;
selectedText: string;
promptText: string;
generatedText: string;
diff: string;
app: App;
selectionRange: { from: EditorPosition; to: EditorPosition } | null;
generatedTextRange: { from: EditorPosition; to: EditorPosition } | null;
constructor(app: App, selectedText: string) {
this.app = app;
this.selectedText = selectedText;
this.promptText = "";
this.generatedText = "";
this.diff = "";
this.selectionRange = null;
this.generatedTextRange = null;
this.dom = document.createElement("div");
this.dom.className = "cm-tooltip";
setIcon(this.dom, "pencil");
// First Stage: Prompt Editor
const editorDom = document.createElement("div");
editorDom.className = "cm-tooltip-editor";
this.dom.appendChild(editorDom);
this.editorView = new EditorView({
state: EditorState.create({
doc: "",
extensions: [
placeholder("Ask copilot"),
keymap.of([
{
key: "Enter",
run: () => {
this.submitAction();
return true;
},
preventDefault: true,
},
]),
],
}),
parent: editorDom,
});
// Buttons
this.submitButton = this.createButton(
"",
"submit-button tooltip-button",
() => this.submitAction()
);
setIcon(this.submitButton.buttonEl, "send-horizontal");
this.loader = document.createElement("div");
this.loader.className = "loader";
this.loader.style.display = "none";
this.dom.appendChild(this.loader);
this.acceptButton = this.createButton(
"Accept",
"accept-button tooltip-button",
() => this.acceptAction()
);
this.acceptButton.buttonEl.style.display = "none";
this.discardButton = this.createButton(
"Discard",
"discard-button tooltip-button",
() => this.discardAction()
);
this.discardButton.buttonEl.style.display = "none";
this.reloadButton = this.createButton(
"",
"submit-button reload-button tooltip-button",
() => this.reloadAction()
);
setIcon(this.reloadButton.buttonEl, "rotate-cw");
this.reloadButton.buttonEl.style.display = "none";
this.editorView.requestMeasure({
read: () => {
this.editorView.dom.style.height = "100%";
this.editorView.dom.style.width = "100%";
},
});
}
/**
* Helper method to create a button with specified properties.
* @param text - The text to display on the button.
* @param classNames - Space-separated list of CSS class names.
* @param onClick - The callback function to execute on button click.
* @returns A ButtonComponent instance.
*/
private createButton(
text: string,
classNames: string,
onClick: () => void
): ButtonComponent {
const button = new ButtonComponent(this.dom);
classNames
.split(" ")
.forEach((cls) => button.buttonEl.classList.add(cls));
if (text) {
button.setButtonText(text);
}
button.setTooltip(text || "Reload");
button.onClick(onClick);
return button;
}
/**
* Handles the submit action when the user submits their prompt.
*/
private async submitAction(): Promise<void> {
const userInput = this.editorView.state.doc.toString();
this.promptText = userInput;
console.log("Selected Text:", this.selectedText);
this.submitButton.setDisabled(true);
this.submitButton.buttonEl.style.display = "none";
this.loader.style.display = "block";
try {
const response = await callApi(userInput, this.selectedText, (this.app as any).plugins.getPlugin("inlineAI").settings);
this.generatedText = response.generated;
this.diff = response.diff;
// Automatically paste the diff into the editor
const markdownView =
this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
const editor = markdownView.editor;
// Save the current selection range
const from = editor.getCursor("from");
const to = editor.getCursor("to");
this.selectionRange = { from: { ...from }, to: { ...to } };
// Replace the selection with the diff
editor.replaceSelection(this.diff);
// After replacement, get the new cursor position
const endPos = editor.getCursor();
this.generatedTextRange = {
from: { ...from },
to: { ...endPos },
};
} else {
new Notice("Failed to find the active Markdown editor.");
}
// Transition to second stage
this.editorView.dom.style.display = "none";
this.loader.style.display = "none";
this.acceptButton.buttonEl.style.display = "block";
this.discardButton.buttonEl.style.display = "block";
this.reloadButton.buttonEl.style.display = "block";
} catch (error) {
new Notice("Error: " + (error as Error).message);
this.submitButton.setDisabled(false);
this.submitButton.buttonEl.style.display = "block";
this.loader.style.display = "none";
}
}
/**
* Handles the accept action, allowing the user to accept the generated text.
*/
private acceptAction(): void {
const markdownView =
this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView && this.generatedTextRange) {
const editor = markdownView.editor;
// Replace the diff with the generated text
editor.replaceRange(
this.generatedText,
this.generatedTextRange.from,
this.generatedTextRange.to
);
} else {
new Notice("Failed to find the active Markdown editor.");
}
this.destroy();
}
/**
* Handles the discard action, allowing the user to discard the generated text.
*/
private discardAction(): void {
const markdownView =
this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView && this.generatedTextRange) {
const editor = markdownView.editor;
// Replace the diff with the original selected text
editor.replaceRange(
this.selectedText,
this.generatedTextRange.from,
this.generatedTextRange.to
);
} else {
new Notice("Failed to find the active Markdown editor.");
}
this.destroy();
}
/**
* Handles the reload action, re-fetching the generated text and updating the editor.
*/
private async reloadAction(): Promise<void> {
// Step 1: Disable the reload button and show the loader
this.reloadButton.setDisabled(true);
this.loader.style.display = "block";
this.reloadButton.buttonEl.style.display = "none";
// Step 2: Hide the accept and discard buttons
this.acceptButton.buttonEl.style.display = "none";
this.discardButton.buttonEl.style.display = "none";
const markdownView =
this.app.workspace.getActiveViewOfType(MarkdownView);
if (!markdownView || !this.generatedTextRange) {
new Notice("Failed to find the active Markdown editor.");
// Re-enable the reload button and hide the loader
this.reloadButton.setDisabled(false);
this.loader.style.display = "none";
this.reloadButton.buttonEl.style.display = "block";
// Re-show the accept and discard buttons in case of failure
this.acceptButton.buttonEl.style.display = "block";
this.discardButton.buttonEl.style.display = "block";
return;
}
const editor = markdownView.editor;
// Step 3: Restore the original selected text
editor.replaceRange(
this.selectedText,
this.generatedTextRange.from,
this.generatedTextRange.to
);
// Step 4: Update the generatedTextRange to the original selection range
if (this.selectionRange) {
this.generatedTextRange = {
from: { ...this.selectionRange.from },
to: { ...this.selectionRange.to },
};
}
try {
const userInput = this.editorView.state.doc.toString();
this.promptText = userInput;
// Step 5: Call the API to get the new generated text
const response = await callApi(this.promptText, this.selectedText, (this.app as any).plugins.getPlugin("inlineAI").settings);
this.generatedText = response.generated;
this.diff = response.diff;
// Step 6: Replace the original text with the new diff
editor.replaceRange(
this.diff,
this.generatedTextRange.from,
this.generatedTextRange.to
);
// Step 7: Update the generatedTextRange to the new diff's range
const newLines = this.diff.split("\n").length;
const lastLineLength = this.diff.split("\n").pop()!.length;
this.generatedTextRange.to = {
line: this.generatedTextRange.from.line + newLines - 1,
ch:
newLines === 1
? this.generatedTextRange.from.ch + this.diff.length
: lastLineLength,
};
} catch (error) {
// Step 8: Handle any errors that occur during the API call
new Notice("Error: " + (error as Error).message);
// Optionally, you might want to restore the original text if desired
// editor.replaceRange(this.selectedText, this.generatedTextRange.from, this.generatedTextRange.to);
} finally {
// Step 9: Re-enable the reload button and hide the loader
this.reloadButton.setDisabled(false);
this.loader.style.display = "none";
this.reloadButton.buttonEl.style.display = "block";
// Step 10: Re-show the accept and discard buttons after reload completes
this.acceptButton.buttonEl.style.display = "block";
this.discardButton.buttonEl.style.display = "block";
}
}
/**
* Destroys the tooltip widget, cleaning up the DOM and editor view.
*/
destroy(): void {
this.editorView.destroy();
this.dom.remove();
}
/**
* Mounts the tooltip widget, focusing the editor.
*/
mount(): void {
setTimeout(() => {
this.editorView.focus();
}, 0);
}
}

View file

@ -1,185 +0,0 @@
import { diffLines, diffWords, Change } from "diff";
/**
* Configuration for custom conflict markers.
*/
const CONFLICT_MARKERS = {
currentStart: "[-",
currentEnd: "-]",
incomingStart: "{+",
incomingEnd: "+}",
separator: " | ",
};
/**
* Generates fine-grained custom conflict markers between two strings.
*
* @param base - The original string (optional).
* @param current - The string from the current branch.
* @param incoming - The string from the incoming branch.
* @returns The merged string with custom conflict markers.
*/
export function generateCustomConflictMarkers(
base: string | null,
current: string,
incoming: string
): string {
// If base is provided, perform a three-way merge.
// Otherwise, perform a two-way comparison.
if (base) {
return threeWayMerge(base, current, incoming);
} else {
return twoWayConflict(current, incoming);
}
}
/**
* Performs a two-way comparison and adds fine-grained custom conflict markers where differences are found.
*
* @param current - The string from the current branch.
* @param incoming - The string from the incoming branch.
* @returns The merged string with fine-grained custom conflict markers.
*/
function twoWayConflict(current: string, incoming: string): string {
const currentLines: string[] = current.split("\n");
const incomingLines: string[] = incoming.split("\n");
const maxLength: number = Math.max(
currentLines.length,
incomingLines.length
);
let merged: string = "";
for (let i = 0; i < maxLength; i++) {
const currentLine: string | undefined = currentLines[i];
const incomingLine: string | undefined = incomingLines[i];
if (currentLine === incomingLine) {
merged += currentLine !== undefined ? currentLine + "\n" : "";
} else {
const fineGrainedConflict = generateFineGrainedConflictWithinLine(
currentLine || "",
incomingLine || ""
);
merged += `${CONFLICT_MARKERS.currentStart} CURRENT ${CONFLICT_MARKERS.currentEnd}\n${fineGrainedConflict.current}\n${CONFLICT_MARKERS.incomingStart} INCOMING ${CONFLICT_MARKERS.incomingEnd}\n${fineGrainedConflict.incoming}\n`;
}
}
return merged;
}
/**
* Performs a three-way merge using the base, current, and incoming strings with fine-grained custom conflict markers.
*
* @param base - The original string.
* @param current - The string from the current branch.
* @param incoming - The string from the incoming branch.
* @returns The merged string with fine-grained custom conflict markers.
*/
function threeWayMerge(
base: string,
current: string,
incoming: string
): string {
const diffsCurrent: Change[] = diffLines(base, current);
const diffsIncoming: Change[] = diffLines(base, incoming);
let merged: string = "";
let i: number = 0,
j: number = 0;
while (i < diffsCurrent.length && j < diffsIncoming.length) {
const partCurrent: Change = diffsCurrent[i];
const partIncoming: Change = diffsIncoming[j];
if (partCurrent.value === partIncoming.value) {
merged += partCurrent.value;
i++;
j++;
} else {
// For simplicity, treat as conflict and generate fine-grained markers
const fineGrainedConflict = generateFineGrainedConflictWithinLine(
partCurrent.value,
partIncoming.value
);
merged += `${CONFLICT_MARKERS.currentStart} CURRENT ${CONFLICT_MARKERS.currentEnd}\n${fineGrainedConflict.current}\n${CONFLICT_MARKERS.incomingStart} INCOMING ${CONFLICT_MARKERS.incomingEnd}\n${fineGrainedConflict.incoming}\n`;
i++;
j++;
}
}
// Append any remaining parts
while (i < diffsCurrent.length) {
merged += diffsCurrent[i].value;
i++;
}
while (j < diffsIncoming.length) {
merged += diffsIncoming[j].value;
j++;
}
return merged;
}
/**
* Generates fine-grained custom conflict markers within a single line by performing a word-level diff.
*
* @param currentLine - The current branch's line.
* @param incomingLine - The incoming branch's line.
* @returns An object containing the current and incoming lines with inline conflict markers.
*/
export function generateFineGrainedConflictWithinLine(
currentLine: string,
incomingLine: string
): { current: string; incoming: string } {
const diff = diffWords(currentLine, incomingLine);
let currentConflict: string = "";
let incomingConflict: string = "";
diff.forEach((part) => {
if (part.added) {
// Text only in incoming
incomingConflict += `${CONFLICT_MARKERS.incomingStart}${part.value}${CONFLICT_MARKERS.incomingEnd}`;
} else if (part.removed) {
// Text only in current
currentConflict += `${CONFLICT_MARKERS.currentStart}${part.value}${CONFLICT_MARKERS.currentEnd}`;
} else {
// Text present in both
currentConflict += part.value;
incomingConflict += part.value;
}
});
return { current: currentConflict, incoming: incomingConflict };
}
/**
* Generates fine-grained custom conflict markers within a single line by performing a word-level diff.
*
* @param currentLine - The current branch's line.
* @param incomingLine - The incoming branch's line.
* @returns A single string with inline conflict markers.
*/
export function generateFineGrainedConflictWithinLineString(
currentLine: string,
incomingLine: string
): string {
const diff = diffWords(currentLine, incomingLine);
let conflict: string = "";
diff.forEach((part) => {
if (part.added) {
// Text only in incoming
conflict += `{+${part.value}+}`;
} else if (part.removed) {
// Text only in current
conflict += `[-${part.value}-]`;
} else {
// Text present in both
conflict += part.value;
}
});
return conflict;
}

View file

@ -1,21 +1,27 @@
// main.ts
import { Plugin, MarkdownView, App } from "obsidian";
import {
cursorTooltipExtension,
showTooltipEffect,
} from "./modules/tooltipExtension";
import { EditorView } from "@codemirror/view";
import { conflictMarkers } from "./modules/diffExtension";
import { MyPluginSettings, DEFAULT_SETTINGS, MyPluginSettingTab } from "./settings";
import { commandEffect, selectionOverlayWidget } from "./modules/WidgetExtension";
import { ChatApiManager } from "./api";
import { AIResponseField } from "./modules/AIExtension";
import { selectionHighlightField, selectionInfoField, setSelectionInfoEffect } from "./modules/SelectionSate";
import { diffField } from "./modules/diffExtension";
export default class MyPlugin extends Plugin {
settings: MyPluginSettings = DEFAULT_SETTINGS;
async onload() {
await this.loadSettings();
const chatapi = new ChatApiManager(this.settings, this.app);
// Register the cursor tooltip extension
this.registerEditorExtension(cursorTooltipExtension(this.app));
this.registerEditorExtension(conflictMarkers());
this.registerEditorExtension([
selectionOverlayWidget(chatapi),
AIResponseField,
selectionInfoField,
selectionHighlightField,
diffField,
]);
// Add command to show tooltip
this.addCommand({
@ -24,20 +30,39 @@ export default class MyPlugin extends Plugin {
callback: () => {
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
// Dispatch the effect to show the tooltip
((markdownView.editor as any).cm as EditorView).dispatch({
effects: showTooltipEffect.of(null),
});
const cmEditor = (markdownView.editor as any).cm as EditorView;
// Grab the main selection range
const { from, to } = cmEditor.state.selection.main;
const effects = [];
if (from !== to) {
// If there is a real selection, store it
const selectedText = cmEditor.state.doc.sliceString(from, to);
effects.push(
setSelectionInfoEffect.of({ from, to, text: selectedText })
);
} else {
// If no selection, you could clear it or do nothing
effects.push(setSelectionInfoEffect.of(null));
}
// Also trigger the overlay
effects.push(commandEffect.of(null));
// Dispatch all effects in one go
cmEditor.dispatch({ effects });
}
},
hotkeys: [
{
modifiers: ["Mod"], // "Mod" stands for "Ctrl" on Windows/Linux and "Cmd" on macOS
key: "a", // You can change this key to your preference
modifiers: ["Mod"],
key: "a",
},
],
});
// Add settings tab
this.addSettingTab(new MyPluginSettingTab(this.app, this));
}

View file

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

View file

@ -0,0 +1,68 @@
// modules/SelectionState.ts
import { StateEffect, StateField } from "@codemirror/state";
import { Decoration, DecorationSet } from "@codemirror/view";
import { EditorView } from "@codemirror/view";
import { dismmisTooltipEffect } from "./WidgetExtension";
/**
* Interface describing the selection range and text.
*/
export interface SelectionInfo {
from: number;
to: number;
text: string;
}
/**
* Effect used to set or clear the selection info.
*/
export const setSelectionInfoEffect = StateEffect.define<SelectionInfo | null>();
/**
* Field that holds the most recently preserved selection info.
*/
export const selectionInfoField = 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(dismmisTooltipEffect))) {
return null;
}
return value;
},
});
/**
* Decoration to highlight the selected text.
*/
const highlightDecoration = Decoration.mark({
class: "cm-selectionBackground", // CSS class for highlighting
});
/**
* StateField that manages the decoration set for highlighting.
*/
export const selectionHighlightField = StateField.define<DecorationSet>({
create(state) {
const info = state.field(selectionInfoField);
if (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(selectionInfoField);
if (info) {
return Decoration.set([highlightDecoration.range(info.from, info.to)]);
}
return Decoration.none;
},
provide: f => EditorView.decorations.from(f),
});

View file

@ -0,0 +1,259 @@
// modules/WidgetExtension.ts
import {
EditorState,
StateEffect,
StateField,
} from "@codemirror/state";
import {
EditorView,
Decoration,
DecorationSet,
WidgetType,
placeholder,
keymap,
} from "@codemirror/view";
import { setIcon } from "obsidian";
import { ChatApiManager } from "../api";
import { selectionInfoField, SelectionInfo } from "./SelectionSate";
// Trigger the effect with a keybinding or command
export const commandEffect = StateEffect.define<null>();
export const dismmisTooltipEffect = StateEffect.define<null>();
export const acceptTooltipEffect = StateEffect.define<null>();
export const discardTooltipEffect = StateEffect.define<null>();
export const reloadTooltipEffect = StateEffect.define<null>();
/**
* Custom widget to display an overlay near the cursor.
*/
class CursorOverlayWidget extends WidgetType {
private chatApiManager: ChatApiManager;
private selectionInfo: SelectionInfo | null;
dom = document.createElement("div");
innerDom = document.createElement("div");
editorView?: EditorView;
private submitButton!: HTMLButtonElement;
private loaderElement!: HTMLElement;
constructor(chatApiManager: ChatApiManager, selectionInfo: SelectionInfo | null) {
super();
this.chatApiManager = chatApiManager;
this.selectionInfo = selectionInfo;
this.dom.className = "cm-cursor-overlay";
this.dom.style.userSelect = "none";
this.innerDom.className = "cm-cursor-overlay-inner";
this.dom.appendChild(this.innerDom);
}
toDOM(): HTMLElement {
this.createPencilIcon();
this.createInputField();
this.createSubmitButton();
this.createLoader();
setTimeout(() => {
this.editorView?.focus();
}, 0);
// Add event listeners for dismissal
const onClickOutside = (event: MouseEvent) => {
if (!this.dom.contains(event.target as Node)) {
this.dismissTooltip();
}
};
const onEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
this.dismissTooltip();
}
};
document.addEventListener("mousedown", onClickOutside);
document.addEventListener("keydown", onEscape);
// Cleanup listeners in destroy
this.dom.addEventListener("destroy", () => {
document.removeEventListener("mousedown", onClickOutside);
document.removeEventListener("keydown", onEscape);
});
return this.dom;
}
destroy(): void {
console.log("CursorOverlayWidget destroyed");
this.editorView?.destroy();
this.innerDom.innerHTML = "";
this.editorView = undefined;
}
private dismissTooltip() {
const view = this.editorView;
if (view) {
view.dispatch({
effects: dismmisTooltipEffect.of(null),
});
}
}
private createPencilIcon() {
if (!this.innerDom.querySelector(".cm-pencil-icon")) {
const icon = document.createElement("div");
icon.className = "cm-pencil-icon";
this.innerDom.appendChild(icon);
setIcon(icon, "pencil");
}
}
private createInputField() {
const editorDom = document.createElement("div");
editorDom.className = "cm-tooltip-editor";
editorDom.style.userSelect = "text";
this.innerDom.appendChild(editorDom);
this.editorView = new EditorView({
state: EditorState.create({
doc: "",
extensions: [
placeholder("Ask copilot"),
keymap.of([
{
key: "Enter",
run: () => {
this.submitAction();
return true;
},
preventDefault: true,
},
]),
],
}),
parent: editorDom,
});
}
private createSubmitButton() {
this.submitButton = document.createElement("button");
this.submitButton.textContent = "Submit";
this.submitButton.className = "submit-button tooltip-button";
setIcon(this.submitButton, "send-horizontal");
this.submitButton.onclick = () => {
this.submitAction();
};
this.innerDom.appendChild(this.submitButton);
}
private createLoader() {
this.loaderElement = document.createElement("div");
this.loaderElement.className = "loader";
this.loaderElement.style.display = "none";
this.innerDom.appendChild(this.loaderElement);
}
/**
* Handles the submit action by calling the AI with the user input and the selected text.
*/
private submitAction() {
const userPrompt = this.editorView?.state.doc.toString() ?? "";
console.log("User Input:", userPrompt);
if (!userPrompt.trim()) {
console.warn("Empty input. Submission aborted.");
return;
}
// Grab the selected text from the stored selection info
const selectedText = this.selectionInfo?.text ?? "";
// Show loader
this.toggleLoading(true);
this.chatApiManager
.callSelection(userPrompt, selectedText)
.then((aiResponse) => {
// Optionally handle or display aiResponse
// we dont use it here as we are dispatching the effect from the method
})
.catch((error) => {
console.error("Error calling AI:", error);
})
.finally(() => {
// Hide loader
this.toggleLoading(false);
});
}
/**
* Toggles the visibility of the submit button and loader.
* @param isLoading - Whether to show the loader.
*/
private toggleLoading(isLoading: boolean) {
if (isLoading) {
this.submitButton.style.display = "none";
this.loaderElement.style.display = "flex";
} else {
this.submitButton.style.display = "inline-block";
this.loaderElement.style.display = "none";
}
}
}
/**
* Build decorations for the first non-empty selection range.
* Also obtains the entire selection info from the state
* and passes it to the CursorOverlayWidget constructor.
*/
function getSelectionOverlayDecorations(
state: EditorState,
chatApiManager: ChatApiManager
): DecorationSet {
// If there's no actual selection, bail out
const firstSelectedRange = state.selection.ranges.find((range) => !range.empty);
if (!firstSelectedRange) return Decoration.none;
// Get the entire SelectionInfo (which includes the text)
const selectionInfo = state.field(selectionInfoField, false) ?? null;
// Create the widget decoration at the selection start (or end)
const deco = Decoration.widget({
widget: new CursorOverlayWidget(chatApiManager, selectionInfo),
above: true,
inline: false,
}).range(firstSelectedRange.from);
return Decoration.set([deco]);
}
/**
* Defines the selection overlay field with access to ChatApiManager.
*/
function TooltipFiled(chatApiManager: ChatApiManager) {
return StateField.define<DecorationSet>({
create(state) {
return getSelectionOverlayDecorations(state, chatApiManager);
},
update(decorations, tr) {
// Recompute if the user triggers the command
if (tr.effects.some((e) => e.is(commandEffect))) {
return getSelectionOverlayDecorations(tr.state, chatApiManager);
}
// Or dismiss it
if (tr.effects.some((e) => e.is(dismmisTooltipEffect))) {
return Decoration.none;
}
// Otherwise, return the existing overlay
return decorations;
},
provide: (field) => EditorView.decorations.from(field),
});
}
/**
* Extension enabling selection overlay widgets.
*/
export function selectionOverlayWidget(chatApiManager: ChatApiManager) {
return [TooltipFiled(chatApiManager)];
}

View file

@ -1,160 +1,185 @@
// src/conflictMarkers.ts
// modules/diffExtension.ts
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
EditorState,
StateEffect,
StateField,
RangeSetBuilder,
} from "@codemirror/state";
import {
Decoration,
DecorationSet,
EditorView,
WidgetType,
} from "@codemirror/view";
import { RangeSetBuilder } from "@codemirror/state";
import { diffWords, Change } from "diff";
import { dismmisTooltipEffect } from "./WidgetExtension";
import { AIResponseField, setAIResponseEffect } from "./AIExtension";
import { selectionInfoField } from "./SelectionSate";
// Configuration for chaff removal
const MIN_UNCHANGED_LENGTH = 5; // Minimum length of unchanged text to keep
/**
* Regular expressions to match conflict markers.
*
* - Current Conflict: [- ... -]
* - Incoming Conflict: {+ ... +}
* - Selected for InlineAI: <<InlineAI<< ... >>InlineAI>>
* Widget to display added or removed content.
* Improves accessibility by using appropriate ARIA attributes.
*/
const CURRENT_CONFLICT_REGEX = /\[-([\s\S]*?)-\]/g;
const INCOMING_CONFLICT_REGEX = /\{\+([\s\S]*?)\+\}/g;
const INLINE_AI_CONFLICT_REGEX = /<<InlineAI<<([\s\S]*?)>>InlineAI>>/g; // Added regex for InlineAI
class ChangeContentWidget extends WidgetType {
constructor(
private readonly content: string,
private readonly type: 'added' | 'removed'
) {
super();
}
/**
* Define decorations with corresponding CSS classes.
*/
const currentConflictDecoration = Decoration.mark({
class: "cm-conflict-current",
});
toDOM(): HTMLElement {
const wrapper = document.createElement("span");
wrapper.className = `cm-change-widget cm-change-${this.type}`;
wrapper.textContent = this.content;
const incomingConflictDecoration = Decoration.mark({
class: "cm-conflict-incoming",
});
// Accessibility: Provide ARIA label
wrapper.setAttribute(
"aria-label",
this.type === 'added' ? "Added content" : "Removed content"
);
const selectedForInlineAIDecoration = Decoration.mark({
// Renamed for clarity
class: "cm-selected-inline",
});
// Optional: Add tooltip or additional styling here
return wrapper;
}
// Decoration to hide conflict markers
const hideDecoration = Decoration.replace({
// This decoration replaces the matched text with nothing, effectively hiding it
// The `inclusive: false` ensures it doesn't affect surrounding text
inclusive: false,
});
/**
* ViewPlugin to detect and decorate conflict markers, including InlineAI markers.
*/
const conflictMarkersPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const text = view.state.doc.toString();
// Array to hold all decorations to be added
type DecorationEntry = {
from: number;
to: number;
decoration: Decoration;
};
const decorationsToAdd: DecorationEntry[] = [];
let match: RegExpExecArray | null;
// Helper function to collect decorations
const collectConflict = (
match: RegExpExecArray,
prefixLength: number,
suffixLength: number,
decoration: Decoration
) => {
const start = match.index;
const end = match.index + match[0].length;
const innerStart = start + prefixLength;
const innerEnd = end - suffixLength;
// Add decoration to highlight the inner content
decorationsToAdd.push({
from: innerStart,
to: innerEnd,
decoration,
});
// Add decorations to hide the prefix and suffix
decorationsToAdd.push({
from: start,
to: start + prefixLength,
decoration: hideDecoration,
});
decorationsToAdd.push({
from: end - suffixLength,
to: end,
decoration: hideDecoration,
});
};
// Match Current Conflicts
while ((match = CURRENT_CONFLICT_REGEX.exec(text)) !== null) {
collectConflict(match, 0, 0, currentConflictDecoration); // '[-' and '-]'
}
// Match Incoming Conflicts
while ((match = INCOMING_CONFLICT_REGEX.exec(text)) !== null) {
collectConflict(match, 0, 0, incomingConflictDecoration); // '{+' and '+}'
}
// Match InlineAI Conflicts
while ((match = INLINE_AI_CONFLICT_REGEX.exec(text)) !== null) {
// collectConflict(match, 12, 12, selectedForInlineAIDecoration); // '<<InlineAI<<' and '>>InlineAI>>'
collectConflict(match, 0, 0, selectedForInlineAIDecoration); // '<<InlineAI<<' and '>>InlineAI>>'
}
// Sort all decorations by 'from' position and 'startSide'
decorationsToAdd.sort((a, b) => {
if (a.from !== b.from) {
return a.from - b.from;
}
// Optional: Define startSide if necessary. Codemirror's RangeSetBuilder handles this internally.
return 0;
});
// Check for overlapping decorations and handle accordingly
let lastAddedTo = -1;
for (const deco of decorationsToAdd) {
if (deco.from < lastAddedTo) {
continue; // Skip overlapping decorations to prevent errors
}
builder.add(deco.from, deco.to, deco.decoration);
if (deco.to > lastAddedTo) {
lastAddedTo = deco.to;
}
}
return builder.finish();
}
},
{
decorations: (v) => v.decorations,
}
);
/**
* Export the conflictMarkers extension.
*/
export function conflictMarkers() {
return conflictMarkersPlugin;
ignoreEvent(): boolean {
// Decide whether to ignore events on the widget
return false;
}
}
/**
* Generates a DecorationSet representing the diff between AI response and context.
* @param state - The current editor state.
* @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(AIResponseField);
const context = state.field(selectionInfoField);
const aiText: string = response?.airesponse ?? "";
const contextText: string = context?.text ?? "";
// Debugging: Remove or conditionally enable in production
console.debug("Generating diff view", {
aiResponse: response,
contextText: context,
});
console.log(
"AI Response:",
response,
"Context Text:",
context
)
// Compute the word-level differences using the 'diff' package
const diffResult: Change[] = diffWords(contextText, aiText);
// Initialize RangeSetBuilder for efficient decoration construction
const builder = new RangeSetBuilder<Decoration>();
let currentPos = 0;
diffResult.forEach((part) => {
const { added, removed, value } = part;
const length = value.length;
if (added) {
// Highlight added text (AI response)
const widget = new ChangeContentWidget(value, 'added');
builder.add(
currentPos,
currentPos,
Decoration.widget({ widget, side: 1 })
);
} else if (removed) {
// Highlight removed text (from context)
const widget = new ChangeContentWidget(value, 'removed');
builder.add(
currentPos,
currentPos + length,
Decoration.widget({ widget, side: -1 })
);
currentPos += length;
} else {
// Unchanged text
if (length >= MIN_UNCHANGED_LENGTH) {
// Retain unchanged text if it meets the minimum length
currentPos += length;
} else {
// Skip small unchanged text to remove chaff
// Optionally, add a thin separator or annotation if desired
currentPos += length;
}
}
});
// Debugging: Remove or conditionally enable in production
console.debug("Diff result:", diffResult);
return builder.finish();
} catch (error) {
console.error("Error generating diff view:", error);
return Decoration.none;
}
}
/**
* Defines a StateField to manage the diff decorations.
*/
export const diffField = StateField.define<DecorationSet>({
create(): DecorationSet {
return Decoration.none;
},
update(decorations: DecorationSet, tr): DecorationSet {
let shouldRecompute = false;
// Check if the AI response effect is present
const hasAIResponseEffect = tr.effects.some(e => e.is(setAIResponseEffect));
if (hasAIResponseEffect) {
const effect = tr.effects.find(e => e.is(setAIResponseEffect));
if (effect && effect !== null) {
console.debug("Received AI response");
console.log("AI Response:", effect);
shouldRecompute = true;
} else {
console.error("Error in AI response effect");
return Decoration.none;
}
}
// Check if the dismiss tooltip effect is present
const hasDismissEffect = tr.effects.some(e => e.is(dismmisTooltipEffect));
if (hasDismissEffect) {
return Decoration.none;
}
// Check if the document or selection has changed
if (tr.docChanged || tr.selection) {
shouldRecompute = true;
}
if (shouldRecompute) {
return generateDiffView(tr.state);
}
// Retain the existing decorations if no relevant changes
return decorations;
},
provide: (field) => EditorView.decorations.from(field),
});
/**
* Exported extension to be included in the EditorView.
*/
export const diffExtension = [
diffField,
];

View file

@ -1,142 +0,0 @@
import {
StateField,
EditorState,
StateEffect,
Transaction,
} from "@codemirror/state";
import { EditorView, showTooltip, type Tooltip } from "@codemirror/view";
import { TooltipWidget } from "../components/tooltipWidget";
import { App, MarkdownView } from "obsidian";
export const showTooltipEffect = StateEffect.define<null>();
export const dismissTooltipEffect = StateEffect.define<null>();
export function cursorTooltipExtension(app: App) {
return StateField.define<Tooltip | null>({
create() {
return null;
},
update(tooltip, tr) {
// Check if the transaction contains an effect to show the tooltip
if (tr.effects.some((e) => e.is(showTooltipEffect))) {
return getCursorTooltip(tr.state, app);
}
// Check if the transaction contains an effect to dismiss the tooltip
if (tr.effects.some((e) => e.is(dismissTooltipEffect))) {
return null;
}
// Keep the tooltip if it's already visible
if (tooltip && !shouldDismissTooltip(tr)) {
return tooltip;
}
return null;
},
provide: (field) => showTooltip.from(field),
});
}
function shouldDismissTooltip(tr: Transaction): boolean {
// Dismiss the tooltip if the user presses Escape
if (tr.selection) {
const { main } = tr.selection;
if (main.empty && tr.docChanged) {
return false; // Keep the tooltip open on document changes
}
}
// Add any additional conditions for dismissing the tooltip here
return false;
}
function getCursorTooltip(state: EditorState, app: App): Tooltip | null {
const { selection } = state;
const mainSelection = selection.main;
const { from, to, head, anchor } = mainSelection;
const currentLine = state.doc.lineAt(from);
const isSelectionActive = !mainSelection.empty;
let posAt = currentLine.from;
let above = true;
let selectedText = "";
if (isSelectionActive) {
selectedText = state.doc.sliceString(from, to);
if (head > anchor) {
console.log("Putting tooltip below");
const endLine = state.doc.lineAt(to);
if (endLine.number < state.doc.lines) {
const nextLine = state.doc.line(endLine.number + 1);
posAt = nextLine.from;
} else {
posAt = state.doc.length;
}
above = false;
} else if (head < anchor) {
console.log("Putting tooltip above");
const startLine = state.doc.lineAt(from);
posAt = startLine.from;
above = true;
} else {
posAt = currentLine.from;
above = true;
}
} else {
posAt = currentLine.from;
above = true;
}
const tooltipWidget = new TooltipWidget(app, selectedText);
return {
pos: posAt,
above,
arrow: true,
create: (view: EditorView) => {
const dom = tooltipWidget.dom;
// Add the event listener
const onClick = (event: MouseEvent) => {
if (!dom.contains(event.target as Node)) {
// Clicked outside the tooltip
view.dispatch({
effects: dismissTooltipEffect.of(null),
});
}
};
dom.addEventListener("mousedown", (event: MouseEvent) => {
event.stopPropagation(); // Prevent the event from propagating to the document
});
document.addEventListener("mousedown", (event: MouseEvent) => {
if (!dom.contains(event.target as Node)) {
// Clicked outside the tooltip
view.dispatch({
effects: dismissTooltipEffect.of(null),
});
}
});
const onEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
view.dispatch({
effects: dismissTooltipEffect.of(null),
});
}
};
// listen also for escape key
document.addEventListener("keydown", onEscape);
return {
dom: tooltipWidget.dom,
destroy: () => {
// Remove the event listener when the tooltip is destroyed
document.removeEventListener("mousedown", onClick);
document.removeEventListener("keydown", onEscape);
tooltipWidget.destroy();
},
mount: () => tooltipWidget.mount(),
};
},
};
}

103
styles copy.css Normal file
View file

@ -0,0 +1,103 @@
/* Tooltip Styles */
.cm-tooltip {
padding: 4px 8px;
background: var(--background-secondary) !important;
z-index: var(--layer-tooltip);
color: var(--text-normal);
border-radius: 4px;
font-size: var(--font-interface-theme);
width: 90%;
max-width: var(--file-line-width);
max-height: 200px;
border: 1px solid var(--background-modifier-border) !important;
display: flex;
flex-direction: row;
align-items: center;
@media (min-width: 1024px) {
margin-left: 10px;
}
}
.cm-tooltip-editor {
display: flex;
padding: 1px 0 1px 10px;
height: var(--line-height);
width: auto;
max-height: 100%;
align-items: center;
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-border-focus);
border-radius: 4px;
overflow: hidden;
margin-left: 8px;
flex: 1 0 0;
transition: flex-basis 0.3s ease;
}
/* Loader Animation */
.loader {
width: 5px;
aspect-ratio: 1;
border-radius: 50%;
color: var(--text-normal);
background: var(--background-primary);
animation: l5 1s infinite linear alternate;
margin: 0 20px 0 26px;
}
@keyframes l5 {
0%, 33% {
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);
background: var(--text-faint);
}
}
/* Button Styles */
.submit-button {
height: var(--line-height);
}
.reload-button {
padding-top: 3px;
padding-bottom: 1px;
}
.accept-button {
background-color: var(--interactive-accent) !important;
color: var(--text-on-accent) !important;
}
.tooltip-button {
height: 100%;
margin-left: 8px;
border: 1px solid var(--background-modifier-border-focus);
}
.cm-selected-inline {
background-color: var(--text-highlight-bg);
}
/* Tooltip Arrow */
.cm-tooltip-arrow {
width: 0;
height: 0;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-top: 7px solid var(--background-modifier-border) !important;
}
.cm-diff-added {
background-color: var(--color-green);
}
.cm-diff-removed {
background-color: var(--color-red);
text-decoration: line-through;
}

View file

@ -1,24 +1,30 @@
/* Tooltip Styles */
.cm-tooltip {
padding: 4px 8px;
.cm-cursor-overlay {
left: -10px;
top: -40px;
width: 100%;
position: absolute;
align-items: center;
background: var(--background-secondary) !important;
z-index: var(--layer-tooltip);
color: var(--text-normal);
border-radius: 4px;
font-size: var(--font-interface-theme);
width: 90%;
max-width: var(--file-line-width);
max-height: 200px;
border: 1px solid var(--background-modifier-border) !important;
user-select: none;
padding: 4px 8px;
border-radius: 4px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
z-index: 1000;
visibility: visible;
}
.cm-cursor-overlay-inner {
display: flex;
flex-direction: row;
align-items: center;
@media (min-width: 1024px) {
margin-left: 10px;
}
overflow: hidden;
}
.cm-tooltip-editor {
display: flex;
padding: 1px 0 1px 10px;
@ -35,6 +41,22 @@
transition: flex-basis 0.3s ease;
}
.cm-cursor-overlay .cm-editor {
user-select: text; /* allow text interaction inside the mini-editor */
}
/* Button Styles */
.submit-button {
height: var(--line-height);
}
.tooltip-button {
height: 100%;
margin-left: 8px;
border: 1px solid var(--background-modifier-border-focus);
}
/* Loader Animation */
.loader {
width: 5px;
@ -57,47 +79,25 @@
}
}
/* Button Styles */
.submit-button {
height: var(--line-height);
.cm-change-widget {
font-size: 0.9em;
margin-left: 4px;
color: #555;
background-color: #f0f0f0;
padding: 2px 4px;
border-radius: 4px;
}
.reload-button {
padding-top: 3px;
padding-bottom: 1px;
.cm-change-added {
background-color: rgba(144, 238, 144, 0.5); /* Slightly darker green for added content */
}
.accept-button {
background-color: var(--interactive-accent) !important;
color: var(--text-on-accent) !important;
.cm-change-removed {
background-color: rgba(255, 99, 71, 0.5); /* Slightly darker red for removed content */
}
.tooltip-button {
height: 100%;
margin-left: 8px;
border: 1px solid var(--background-modifier-border-focus);
}
/* Conflict Styles */
.cm-conflict-current {
background-color: rgba(255, 0, 0, 0.2);
padding-left: 2px;
}
.cm-conflict-incoming {
background-color: rgba(0, 255, 0, 0.2);
padding-left: 2px;
}
.cm-selected-inline {
background-color: var(--text-highlight-bg);
}
/* Tooltip Arrow */
.cm-tooltip-arrow {
width: 0;
height: 0;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-top: 7px solid var(--background-modifier-border) !important;
}
.cm-selection-highlight {
background-color: rgba(255, 255, 0, 0.4); /* Semi-transparent yellow */
/* You can add more styles as needed */
}