}>
- Save as Note
-
- {selectedChain === "vault_qa" && (
-
-
-
- {currentChain === "llm_chain" && "chat"}
- {currentChain === "vault_qa" && "vault QA (basic)"}
- {currentChain === "copilot_plus" && "copilot plus (alpha)"}
-
-
+ const handleAddContext = () => {
+ const excludeNotes = [
+ ...contextNotes.map((note) => note.path),
+ ...(includeActiveNote && activeNote ? [activeNote.path] : []),
+ ].filter(Boolean) as string[];
-
-
- handleChainChange({ value: "llm_chain" })}>
- chat
-
- handleChainChange({ value: "vault_qa" })}
- disabled={!isIndexLoaded}
- className={!isIndexLoaded ? "disabled-menu-item" : ""}
- >
- vault QA (basic) {!isIndexLoaded && "(index not loaded)"}
-
- handleChainChange({ value: "copilot_plus" })}
- disabled={!isIndexLoaded}
- className={!isIndexLoaded ? "disabled-menu-item" : ""}
- >
- copilot plus (alpha) {!isIndexLoaded && "(index not loaded)"}
-
-
-
-
+ new AddContextNoteModal({
+ app,
+ onNoteSelect: (note) => {
+ if (activeNote && note.path === activeNote.path) {
+ setIncludeActiveNote(true);
+ // Remove the note from contextNotes if it exists there
+ setContextNotes((prev) => prev.filter((n) => n.path !== note.path));
+ } else {
+ setContextNotes((prev) => [...prev, note]);
+ }
+ },
+ excludeNotes,
+ }).open();
+ };
+
+ const handleRemoveContext = (path: string) => {
+ if (activeNote && path === activeNote.path) {
+ setIncludeActiveNote(false);
+ } else {
+ setContextNotes((prev) => prev.filter((note) => note.path !== path));
+ }
+ };
+
+ return (
+
+
+ {currentChain === ChainType.COPILOT_PLUS_CHAIN && (
+
+ )}
+
+
{
+ onNewChat(false);
+ }}
+ Icon={}
+ >
+ New Chat
+ {!settings.autosaveChat && (Unsaved history will be lost)
}
+
+
}>
+ Save as Note
+
+ {selectedChain === "vault_qa" && (
+
}
+ >
+ Refresh Index for Vault
+
+ )}
+
+
+
+ {currentChain === "llm_chain" && "chat"}
+ {currentChain === "vault_qa" && "vault QA (basic)"}
+ {currentChain === "copilot_plus" && "copilot plus (alpha)"}
+
+
+
+
+
+ handleChainChange({ value: "llm_chain" })}>
+ chat
+
+ handleChainChange({ value: "vault_qa" })}
+ disabled={!isIndexLoaded}
+ className={!isIndexLoaded ? "disabled-menu-item" : ""}
+ >
+ vault QA (basic) {!isIndexLoaded && "(index not loaded)"}
+
+ handleChainChange({ value: "copilot_plus" })}
+ disabled={!isIndexLoaded}
+ className={!isIndexLoaded ? "disabled-menu-item" : ""}
+ >
+ copilot plus (alpha) {!isIndexLoaded && "(index not loaded)"}
+
+
+
+
+
+
);
diff --git a/src/components/ChatComponents/ChatInput.tsx b/src/components/ChatComponents/ChatInput.tsx
index 17ee634f..7df2c410 100644
--- a/src/components/ChatComponents/ChatInput.tsx
+++ b/src/components/ChatComponents/ChatInput.tsx
@@ -2,19 +2,25 @@ import { CustomModel, SetChainOptions } from "@/aiParams";
import { ChainType } from "@/chainFactory";
import { ListPromptModal } from "@/components/ListPromptModal";
import { NoteTitleModal } from "@/components/NoteTitleModal";
+import { ContextProcessor } from "@/contextProcessor";
import { CustomPromptProcessor } from "@/customPromptProcessor";
+import { COPILOT_TOOL_NAMES } from "@/LLMProviders/intentAnalyzer";
+import { Mention } from "@/mentions/Mention";
import { CopilotSettings } from "@/settings/SettingsPage";
import { ChatMessage } from "@/sharedState";
+import { extractNoteTitles } from "@/utils";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
-import { ChevronUp, Command, CornerDownLeft, StopCircle } from "lucide-react";
+import { ArrowBigUp, ChevronUp, Command, CornerDownLeft, Image, StopCircle } from "lucide-react";
import { App, Platform, TFile, Vault } from "obsidian";
import React, { useEffect, useRef, useState } from "react";
+import { AddImageModal } from "../AddImageModal";
import ChatControls from "./ChatControls";
+import { TooltipActionButton } from "./TooltipActionButton";
interface ChatInputProps {
inputMessage: string;
setInputMessage: (message: string) => void;
- handleSendMessage: () => void;
+ handleSendMessage: (toolCalls?: string[]) => void;
isGenerating: boolean;
chatIsVisible: boolean;
onStopGenerating: () => void;
@@ -32,6 +38,14 @@ interface ChatInputProps {
vault: Vault;
vault_qa_strategy: string;
isIndexLoadedPromise: Promise
;
+ contextNotes: TFile[];
+ setContextNotes: React.Dispatch>;
+ includeActiveNote: boolean;
+ setIncludeActiveNote: (include: boolean) => void;
+ mention: Mention;
+ selectedImages: File[];
+ onAddImage: (files: File[]) => void;
+ setSelectedImages: React.Dispatch>;
debug?: boolean;
}
@@ -58,15 +72,70 @@ const ChatInput: React.FC = ({
vault,
vault_qa_strategy,
isIndexLoadedPromise,
+ contextNotes,
+ setContextNotes,
+ includeActiveNote,
+ setIncludeActiveNote,
+ mention,
+ selectedImages,
+ onAddImage,
+ setSelectedImages,
debug,
}) => {
const [shouldFocus, setShouldFocus] = useState(false);
const [historyIndex, setHistoryIndex] = useState(-1);
const [tempInput, setTempInput] = useState("");
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
+ const [contextUrls, setContextUrls] = useState([]);
const textAreaRef = useRef(null);
const containerRef = useRef(null);
+ const debounce = any>(
+ fn: T,
+ delay: number
+ ): ((...args: Parameters) => void) => {
+ let timeoutId: NodeJS.Timeout;
+ return (...args: Parameters) => {
+ clearTimeout(timeoutId);
+ timeoutId = setTimeout(() => fn(...args), delay);
+ };
+ };
+
+ // Debounce the context update to prevent excessive re-renders
+ const debouncedUpdateContext = debounce(
+ async (
+ inputValue: string,
+ setContextNotes: React.Dispatch>,
+ currentContextNotes: TFile[],
+ app: App
+ ) => {
+ const noteTitles = extractNoteTitles(inputValue);
+
+ const notesToAdd = await Promise.all(
+ noteTitles.map(async (title) => {
+ const files = app.vault.getMarkdownFiles();
+ const file = files.find((file) => file.basename === title);
+ if (file) {
+ return Object.assign(file, { wasAddedViaReference: true }) as TFile & {
+ wasAddedViaReference: boolean;
+ };
+ }
+ return undefined;
+ })
+ );
+
+ const validNotes = notesToAdd.filter(
+ (note): note is TFile & { wasAddedViaReference: boolean } =>
+ note !== undefined && !currentContextNotes.some((existing) => existing.path === note.path)
+ );
+
+ if (validNotes.length > 0) {
+ setContextNotes((prev) => [...prev, ...validNotes]);
+ }
+ },
+ 50
+ );
+
const handleInputChange = async (event: React.ChangeEvent) => {
const inputValue = event.target.value;
const cursorPos = event.target.selectionStart;
@@ -74,10 +143,26 @@ const ChatInput: React.FC = ({
setInputMessage(inputValue);
adjustTextareaHeight();
+ // Extract URLs and update mentions
+ const urls = mention.extractAllUrls(inputValue);
+
+ // Update URLs in context, ensuring uniqueness
+ const newUrls = urls.filter((url) => !contextUrls.includes(url));
+ if (newUrls.length > 0) {
+ // Use Set to ensure uniqueness
+ setContextUrls((prev) => Array.from(new Set([...prev, ...newUrls])));
+ }
+
+ // Update context with debouncing
+ debouncedUpdateContext(inputValue, setContextNotes, contextNotes, app);
+
+ // Handle other input triggers
if (cursorPos >= 2 && inputValue.slice(cursorPos - 2, cursorPos) === "[[") {
showNoteTitleModal(cursorPos);
} else if (inputValue === "/") {
showCustomPromptModal();
+ } else if (inputValue.slice(-1) === "@" && currentChain === ChainType.COPILOT_PLUS_CHAIN) {
+ showCopilotPlusOptionsModal();
}
};
@@ -95,11 +180,31 @@ const ChatInput: React.FC = ({
const showNoteTitleModal = (cursorPos: number) => {
const fetchNoteTitles = async () => {
const noteTitles = app.vault.getMarkdownFiles().map((file: TFile) => file.basename);
+ const contextProcessor = ContextProcessor.getInstance();
- new NoteTitleModal(app, noteTitles, (noteTitle: string) => {
+ new NoteTitleModal(app, noteTitles, async (noteTitle: string) => {
const before = inputMessage.slice(0, cursorPos - 2);
const after = inputMessage.slice(cursorPos - 1);
- setInputMessage(`${before}[[${noteTitle}]]${after}`);
+ const newInputMessage = `${before}[[${noteTitle}]]${after}`;
+ setInputMessage(newInputMessage);
+
+ // Manually invoke debouncedUpdateContext
+ debouncedUpdateContext(newInputMessage, setContextNotes, contextNotes, app);
+
+ const activeNote = app.workspace.getActiveFile();
+ const noteFile = app.vault.getMarkdownFiles().find((file) => file.basename === noteTitle);
+
+ if (noteFile) {
+ await contextProcessor.addNoteToContext(
+ noteFile,
+ vault,
+ contextNotes,
+ activeNote,
+ setContextNotes,
+ setIncludeActiveNote
+ );
+ }
+
// Add a delay to ensure the cursor is set after inputMessage is updated
setTimeout(() => {
if (textAreaRef.current) {
@@ -109,7 +214,6 @@ const ChatInput: React.FC = ({
}, 0);
}).open();
};
-
fetchNoteTitles();
};
@@ -127,6 +231,13 @@ const ChatInput: React.FC = ({
}).open();
};
+ const showCopilotPlusOptionsModal = () => {
+ const options = COPILOT_TOOL_NAMES;
+ new ListPromptModal(app, options, (selectedOption: string) => {
+ setInputMessage(inputMessage + selectedOption + " ");
+ }).open();
+ };
+
useEffect(() => {
setShouldFocus(chatIsVisible);
}, [chatIsVisible]);
@@ -147,6 +258,21 @@ const ChatInput: React.FC = ({
const lines = value.split("\n");
const currentLineIndex = value.substring(0, selectionStart).split("\n").length - 1;
+ // Check for Cmd+Shift+Enter (Mac) or Ctrl+Shift+Enter (Windows)
+ if (e.key === "Enter" && e.shiftKey && (Platform.isMacOS ? e.metaKey : e.ctrlKey)) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (currentChain === ChainType.COPILOT_PLUS_CHAIN) {
+ handleSendMessage(["@vault"]);
+ } else {
+ handleSendMessage();
+ }
+ setHistoryIndex(-1);
+ setTempInput("");
+ return;
+ }
+
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
@@ -196,6 +322,53 @@ const ChatInput: React.FC = ({
}
};
+ useEffect(() => {
+ // Get all note titles that are referenced using [[note]] syntax in the input
+ const currentTitles = new Set(extractNoteTitles(inputMessage));
+ // Get all URLs mentioned in the input
+ const currentUrls = mention.extractAllUrls(inputMessage);
+ // Get the currently open note in the editor
+ const activeNote = app.workspace.getActiveFile();
+
+ setContextNotes((prev) =>
+ prev.filter((note) => {
+ // Check if this note was added by typing [[note]] in the input
+ // as opposed to being added via the "Add Note to Context" button
+ const wasAddedViaReference = (note as any).wasAddedViaReference === true;
+
+ // Special handling for the active note (currently open in editor)
+ if (note.path === activeNote?.path) {
+ if (wasAddedViaReference) {
+ // Case 1: Active note was added by typing [[note]]
+ // Keep it only if its title is still in the input
+ // This ensures it's removed when you delete the [[note]] reference
+ return currentTitles.has(note.basename);
+ } else {
+ // Case 2: Active note was NOT added by [[note]], but by the includeActiveNote toggle
+ // Keep it only if includeActiveNote is true
+ // This handles the "Include active note" toggle in the UI
+ return includeActiveNote;
+ }
+ } else {
+ // Handling for all other notes (not the active note)
+ if (wasAddedViaReference) {
+ // Case 3: Other note was added by typing [[note]]
+ // Keep it only if its title is still in the input
+ // This ensures it's removed when you delete the [[note]] reference
+ return currentTitles.has(note.basename);
+ } else {
+ // Case 4: Other note was added via "Add Note to Context" button
+ // Always keep these notes as they were manually added
+ return true;
+ }
+ }
+ })
+ );
+
+ // Remove any URLs that are no longer present in the input
+ setContextUrls((prev) => prev.filter((url) => currentUrls.includes(url)));
+ }, [inputMessage, includeActiveNote]);
+
return (
= ({
settings={settings}
vault_qa_strategy={vault_qa_strategy}
isIndexLoadedPromise={isIndexLoadedPromise}
+ app={app}
+ contextNotes={contextNotes}
+ setContextNotes={setContextNotes}
+ includeActiveNote={includeActiveNote}
+ setIncludeActiveNote={setIncludeActiveNote}
+ contextUrls={contextUrls}
+ onRemoveUrl={(url: string) => setContextUrls((prev) => prev.filter((u) => u !== url))}
debug={debug}
/>
+ {selectedImages.length > 0 && (
+
+ {selectedImages.map((file, index) => (
+
+
})
+
+
+ ))}
+
+ )}
+