diff --git a/src/api.ts b/src/api.ts index 97144f4..28d9ee8 100644 --- a/src/api.ts +++ b/src/api.ts @@ -8,6 +8,14 @@ import { App, MarkdownView, Notice } from "obsidian"; import { EditorView } from "@codemirror/view"; import { setGeneratedResponseEffect } from "./modules/AIExtension"; import { parseCommand } from "./modules/commands/parser"; +import { MessageQueue } from "./modules/messageHistory/queue"; + +const MESSAGE_HISTORY_LIMIT = 20; + +export type HistoryMessage = { + mode: string; + userPrompt: string; +}; /** * Class to manage interactions with different chat APIs. @@ -16,7 +24,7 @@ export class ChatApiManager { private chatClient: ChatOpenAI | ChatOllama | ChatGoogleGenerativeAI | null; private app: App; private settings: InlineAISettings; - + private messageHistory: MessageQueue; /** * Initializes the ChatApiManager with the given settings. * @param settings - Configuration settings for the chat API. @@ -26,6 +34,7 @@ export class ChatApiManager { this.app = app; this.chatClient = this.initializeChatClient(settings); this.settings = settings; + this.messageHistory = new MessageQueue(MESSAGE_HISTORY_LIMIT); } /** @@ -138,25 +147,14 @@ export class ChatApiManager { return "⚠️ 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 or an error message. - */ - public async callCursor(userRequest: string): Promise { - 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 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(prompt: string, selectedText: string): Promise { - prompt = parseCommand(prompt, this.settings.commandPrefix, this.settings.customCommands); + public async callSelection(userPrompt: string, selectedText: string): Promise { + userPrompt = parseCommand(userPrompt, this.settings.commandPrefix, this.settings.customCommands); let isCursor = false; if (selectedText.trim().length === 0) { @@ -164,21 +162,23 @@ export class ChatApiManager { } const systemPrompt = isCursor ? this.settings.cursorPrompt : this.settings.selectionPrompt; - let userPrompt = ``; + let finalUserPrompt = ``; + const mode = isCursor ? "cursor" : "selection"; + this.messageHistory.enqueue({ mode, userPrompt }); if (isCursor) { - userPrompt = ` - **Task:** ${prompt} + finalUserPrompt = ` + **Task:** ${userPrompt} **Output:**`; } else { - userPrompt = ` - **Task:** ${prompt} + finalUserPrompt = ` + **Task:** ${userPrompt} **Input:** ${selectedText} **Output:**`; } - return this.handleEditorUpdate(systemPrompt, userPrompt); + return this.handleEditorUpdate(systemPrompt, finalUserPrompt); } /** @@ -193,4 +193,8 @@ export class ChatApiManager { } this.chatClient = newChatClient; } + + public getMessageHistory(): HistoryMessage[] { + return this.messageHistory.getItems(); + } } diff --git a/src/modules/WidgetExtension.ts b/src/modules/WidgetExtension.ts index cc57cb6..fa7d85a 100644 --- a/src/modules/WidgetExtension.ts +++ b/src/modules/WidgetExtension.ts @@ -47,6 +47,9 @@ class FloatingWidget extends WidgetType { private acceptButton!: HTMLButtonElement; private discardButton!: HTMLButtonElement; + // Track the current index in message history for up/down navigation + private messageHistoryIndex: number | null = null; + constructor(chatApiManager: ChatApiManager, selectionInfo: SelectionInfo | null, plugin: InlineAIChatPlugin) { super(); this.chatApiManager = chatApiManager; @@ -111,6 +114,7 @@ class FloatingWidget extends WidgetType { this.textFieldView = undefined; this.outerEditorView = null; + this.messageHistoryIndex = null; } private dismissTooltip() { @@ -159,7 +163,81 @@ class FloatingWidget extends WidgetType { }, 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; + + 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, @@ -174,6 +252,13 @@ class FloatingWidget extends WidgetType { 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; + }); } private createSubmitButton() { @@ -223,6 +308,8 @@ class FloatingWidget extends WidgetType { this.toggleLoading(false); }); + // Reset message history navigation after submit + this.messageHistoryIndex = null; } /** diff --git a/src/modules/messageHistory/queue.ts b/src/modules/messageHistory/queue.ts new file mode 100644 index 0000000..ddbd63d --- /dev/null +++ b/src/modules/messageHistory/queue.ts @@ -0,0 +1,55 @@ +/** + * MessageQueue is a generic fixed-length queue for storing recent chat messages. + * - When a new item is enqueued and the queue is full, the oldest item is removed. + * - Duplicate consecutive items are not added. + * - Provides standard queue operations: enqueue, dequeue, peek, size, isEmpty, and getItems. + */ +export class MessageQueue { + private items: T[] = []; + private maxLength: number; + + constructor(maxLength: number) { + this.maxLength = maxLength; + } + + // Adds an item to the queue + enqueue(item: T): void { + // If the new item is exactly the same as the previous one, do not add it + if (this.items.length > 0) { + const lastItem = this.items[this.items.length - 1]; + if (lastItem === item) { + return; + } + } + if (this.items.length >= this.maxLength) { + // Remove the oldest item if the queue is full + this.items.shift(); + } + this.items.push(item); + } + + // Removes and returns the item at the front of the queue + dequeue(): T | undefined { + return this.items.shift(); + } + + // Returns the item at the front of the queue without removing it + peek(): T | undefined { + return this.items[0]; + } + + // Returns the current size of the queue + size(): number { + return this.items.length; + } + + // Checks if the queue is empty + isEmpty(): boolean { + return this.items.length === 0; + } + + // Returns the items in the queue + getItems(): T[] { + return [...this.items]; + } +}