{Platform.isMacOS ? (
<>
@@ -366,6 +374,7 @@ export class CustomCommandChatModal extends Modal {
private configs: {
selectedText: string;
command: CustomCommand;
+ systemPrompt?: string;
}
) {
super(app);
@@ -374,7 +383,7 @@ export class CustomCommandChatModal extends Modal {
onOpen() {
const { contentEl } = this;
this.root = createRoot(contentEl);
- const { selectedText, command } = this.configs;
+ const { selectedText, command, systemPrompt } = this.configs;
const handleInsert = (message: string) => {
insertIntoEditor(message);
@@ -392,6 +401,7 @@ export class CustomCommandChatModal extends Modal {
command={command}
onInsert={handleInsert}
onReplace={handleReplace}
+ systemPrompt={systemPrompt}
/>
);
}
diff --git a/src/commands/constants.ts b/src/commands/constants.ts
index a9d605b0..4fc882ac 100644
--- a/src/commands/constants.ts
+++ b/src/commands/constants.ts
@@ -2,6 +2,7 @@ import { CustomCommand } from "@/commands/type";
export const LEGACY_SELECTED_TEXT_PLACEHOLDER = "{copilot-selection}";
export const COMMAND_NAME_MAX_LENGTH = 50;
+export const QUICK_COMMAND_CODE_BLOCK = "copilotquickcommand";
export const EMPTY_COMMAND: CustomCommand = {
title: "",
content: "",
diff --git a/src/commands/contextMenu.ts b/src/commands/contextMenu.ts
index a9ee365c..30134e60 100644
--- a/src/commands/contextMenu.ts
+++ b/src/commands/contextMenu.ts
@@ -3,31 +3,51 @@ import { getCachedCustomCommands } from "@/commands/state";
import { COMMAND_IDS } from "@/constants";
import { Menu } from "obsidian";
import { CustomCommand } from "./type";
+import { isLivePreviewModeOn } from "@/utils";
export function registerContextMenu(menu: Menu) {
+ // Create the main "Copilot" submenu
menu.addItem((item) => {
- item.setTitle("Copilot: Add selection to chat context").onClick(() => {
- (app as any).commands.executeCommandById(
- `copilot:${COMMAND_IDS.ADD_SELECTION_TO_CHAT_CONTEXT}`
- );
+ item.setTitle("Copilot");
+ (item as any).setSubmenu();
+
+ const submenu = (item as any).submenu;
+ if (!submenu) return;
+
+ // Add the main selection command
+ submenu.addItem((subItem: any) => {
+ subItem.setTitle("Add selection to chat context").onClick(() => {
+ (app as any).commands.executeCommandById(
+ `copilot:${COMMAND_IDS.ADD_SELECTION_TO_CHAT_CONTEXT}`
+ );
+ });
});
- });
- // Add separator if there are custom commands too
- const commands = getCachedCustomCommands();
- const visibleCustomCommands = commands.filter(
- (command: CustomCommand) => command.showInContextMenu
- );
- if (visibleCustomCommands.length > 0) {
- menu.addSeparator();
- }
+ if (isLivePreviewModeOn()) {
+ submenu.addItem((subItem: any) => {
+ subItem.setTitle("Trigger quick command").onClick(() => {
+ (app as any).commands.executeCommandById(`copilot:${COMMAND_IDS.TRIGGER_QUICK_COMMAND}`);
+ });
+ });
+ }
- sortCommandsByOrder(
- commands.filter((command: CustomCommand) => command.showInContextMenu)
- ).forEach((command: CustomCommand) => {
- menu.addItem((item) => {
- item.setTitle(`Copilot: ${command.title}`).onClick(() => {
- (app as any).commands.executeCommandById(`copilot:${getCommandId(command.title)}`);
+ // Get custom commands
+ const commands = getCachedCustomCommands();
+ const visibleCustomCommands = commands.filter(
+ (command: CustomCommand) => command.showInContextMenu
+ );
+
+ // Add separator if there are custom commands
+ if (visibleCustomCommands.length > 0) {
+ submenu.addSeparator();
+ }
+
+ // Add custom commands to submenu
+ sortCommandsByOrder(visibleCustomCommands).forEach((command: CustomCommand) => {
+ submenu.addItem((subItem: any) => {
+ subItem.setTitle(command.title).onClick(() => {
+ (app as any).commands.executeCommandById(`copilot:${getCommandId(command.title)}`);
+ });
});
});
});
diff --git a/src/commands/customCommandRegister.ts b/src/commands/customCommandRegister.ts
index f1814110..21bc80a1 100644
--- a/src/commands/customCommandRegister.ts
+++ b/src/commands/customCommandRegister.ts
@@ -18,7 +18,7 @@ import {
updateCachedCommand,
} from "@/commands/state";
import { CustomCommandManager } from "@/commands/customCommandManager";
-import { logError, logInfo } from "@/logger";
+import { logError } from "@/logger";
/** This manager is used to register custom commands as obsidian commands */
export class CustomCommandRegister {
@@ -69,7 +69,6 @@ export class CustomCommandRegister {
return;
}
const customCommand = await parseCustomCommandFile(file);
- logInfo("command file modified", file.path, customCommand);
this.registerCommand(customCommand);
updateCachedCommand(customCommand, customCommand.title);
},
@@ -88,7 +87,6 @@ export class CustomCommandRegister {
return;
}
try {
- logInfo("new command file created", file.path);
let customCommand = await parseCustomCommandFile(file);
if (!hasOrderFrontmatter(file)) {
// Compute the correct order for the new command
@@ -125,7 +123,6 @@ export class CustomCommandRegister {
}
// Register the new command if it's still a custom command file
if (isCustomCommandFile(file)) {
- logInfo("command file renamed", file.path);
const parsedCommand = await parseCustomCommandFile(file);
this.registerCommand(parsedCommand);
updateCachedCommand(parsedCommand, parsedCommand.title);
diff --git a/src/commands/customCommandUtils.test.ts b/src/commands/customCommandUtils.test.ts
index e7198c63..9b56d53e 100644
--- a/src/commands/customCommandUtils.test.ts
+++ b/src/commands/customCommandUtils.test.ts
@@ -49,8 +49,15 @@ describe("processedPrompt()", () => {
// Set default implementations for critical mocks
(extractNoteFiles as jest.Mock).mockReturnValue([]);
- // Create mock objects
- mockVault = {} as Vault;
+ // Create mock objects with adapter.stat
+ mockVault = {
+ adapter: {
+ stat: jest.fn().mockResolvedValue({
+ ctime: Date.now(),
+ mtime: Date.now(),
+ }),
+ },
+ } as unknown as Vault;
mockActiveNote = {
path: "path/to/active/note.md",
basename: "Active Note",
@@ -81,7 +88,7 @@ describe("processedPrompt()", () => {
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
- "This is a {variable} and {selectedText}.\n\nselectedText:\n\nhere is some selected text 12345\n\nvariable:\n\n## Variable Note\n\nhere is the note content for note0"
+ 'This is a {variable} and {selected_text}.\n\n
\nhere is some selected text 12345\n\n\n
\n\n## Variable Note\n\nhere is the note content for note0\n\n'
);
expect(result.includedFiles).toContain(mockActiveNote);
});
@@ -114,7 +121,7 @@ describe("processedPrompt()", () => {
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
- "This is a {variable1} and {variable2}.\n\nvariable1:\n\n## Variable1 Note\n\nhere is the note content for note0\n\nvariable2:\n\n## Variable2 Note\n\nnote content for note1"
+ 'This is a {variable1} and {variable2}.\n\n
\n\n## Variable1 Note\n\nhere is the note content for note0\n\n\n\n
\n\n## Variable2 Note\n\nnote content for note1\n\n'
);
expect(result.includedFiles).toContain(mockNote1);
expect(result.includedFiles).toContain(mockNote2);
@@ -135,7 +142,7 @@ describe("processedPrompt()", () => {
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
- "Rewrite the following text {selectedText}\n\nselectedText:\n\nhere is some selected text 12345"
+ "Rewrite the following text {selected_text}\n\n
\nhere is some selected text 12345\n"
);
expect(result.includedFiles).toEqual([]);
});
@@ -159,7 +166,7 @@ describe("processedPrompt()", () => {
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
- "This is the active note: {activenote}\n\nactivenote:\n\n## Active Note\n\nContent of the active note"
+ 'This is the active note: {activenote}\n\n
\n\n## Active Note\n\nContent of the active note\n\n'
);
expect(result.includedFiles).toContain(mockActiveNote);
expect(getFileContent).toHaveBeenCalledWith(mockActiveNote, mockVault);
@@ -225,7 +232,7 @@ describe("processedPrompt()", () => {
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
- "Notes related to {#tag} are:\n\n#tag:\n\n## Tagged Note\n\nNote content for #tag"
+ 'Notes related to {#tag} are:\n\n
\n\n## Tagged Note\n\nNote content for #tag\n\n'
);
expect(result.includedFiles).toContain(mockNoteForTag);
});
@@ -264,7 +271,7 @@ describe("processedPrompt()", () => {
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
- "Notes related to {#tag1,#tag2,#tag3} are:\n\n#tag1,#tag2,#tag3:\n\n## Tagged Note 1\n\nNote content for #tag1\n\n## Tagged Note 2\n\nNote content for #tag2"
+ 'Notes related to {#tag1,#tag2,#tag3} are:\n\n
\n\n## Tagged Note 1\n\nNote content for #tag1\n\n\n\n## Tagged Note 2\n\nNote content for #tag2\n\n'
);
expect(result.includedFiles).toContain(mockNoteForTag1);
expect(result.includedFiles).toContain(mockNoteForTag2);
@@ -282,9 +289,11 @@ describe("processedPrompt()", () => {
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toContain("Content of [[Test Note]] is important");
- expect(result.processedPrompt).toContain(
- "Title: [[Test Note]]\nPath: Test Note.md\n\nTest note content"
- );
+ expect(result.processedPrompt).toContain("
");
+ expect(result.processedPrompt).toContain("Test Note");
+ expect(result.processedPrompt).toContain("Test Note.md");
+ expect(result.processedPrompt).toContain("Test note content");
+ expect(result.processedPrompt).toContain("");
expect(result.includedFiles).toContain(mockTestNote);
});
@@ -311,7 +320,7 @@ describe("processedPrompt()", () => {
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
- "Content of {[[Test Note]]} is important. Look at [[Test Note]].\n\n[[Test Note]]:\n\n## Test Note\n\nTest note content"
+ 'Content of {[[Test Note]]} is important. Look at [[Test Note]].\n\n
\n\n## Test Note\n\nTest note content\n\n'
);
// Note: extractNoteFiles will still find [[Test Note]], but processPrompt should skip adding it again because it's already in includedFiles from the variable processing
expect(result.includedFiles).toEqual([mockNoteFile]);
@@ -356,7 +365,11 @@ describe("processedPrompt()", () => {
"{[[Note1]]} content and [[Note2]] are both important"
);
expect(result.processedPrompt).toContain("## Note1\n\nNote1 content");
- expect(result.processedPrompt).toContain("Title: [[Note2]]\nPath: Note2.md\n\nNote2 content");
+ expect(result.processedPrompt).toContain("
");
+ expect(result.processedPrompt).toContain("Note2");
+ expect(result.processedPrompt).toContain("Note2.md");
+ expect(result.processedPrompt).toContain("Note2 content");
+ expect(result.processedPrompt).toContain("");
// Note2 is added via [[Note2]] processing
expect(result.includedFiles).toEqual(expect.arrayContaining([mockNote1, mockNote2]));
expect(result.includedFiles.length).toBe(2);
@@ -385,9 +398,17 @@ describe("processedPrompt()", () => {
const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toContain("[[Note1]] is related to [[Note2]] and [[Note3]].");
- expect(result.processedPrompt).toContain("Title: [[Note1]]\nPath: Note1.md\n\nNote1 content");
- expect(result.processedPrompt).toContain("Title: [[Note2]]\nPath: Note2.md\n\nNote2 content");
- expect(result.processedPrompt).toContain("Title: [[Note3]]\nPath: Note3.md\n\nNote3 content");
+ // All notes should be in note_context format
+ expect(result.processedPrompt).toContain("
");
+ expect(result.processedPrompt).toContain("Note1");
+ expect(result.processedPrompt).toContain("Note1.md");
+ expect(result.processedPrompt).toContain("Note1 content");
+ expect(result.processedPrompt).toContain("Note2");
+ expect(result.processedPrompt).toContain("Note2.md");
+ expect(result.processedPrompt).toContain("Note2 content");
+ expect(result.processedPrompt).toContain("Note3");
+ expect(result.processedPrompt).toContain("Note3.md");
+ expect(result.processedPrompt).toContain("Note3 content");
expect(result.includedFiles).toEqual(expect.arrayContaining([mockNote1, mockNote2, mockNote3]));
expect(result.includedFiles.length).toBe(3);
});
@@ -428,7 +449,7 @@ describe("processedPrompt()", () => {
// Check that getFileContent was called with the active note at least once
expect(getFileContent).toHaveBeenCalledWith(mockActiveNote, mockVault);
expect(result.processedPrompt).toBe(
- "This is the active note: {activeNote}. And again: {activeNote}\n\nactiveNote:\n\n## Active Note\n\nContent of the active note"
+ 'This is the active note: {activeNote}. And again: {activeNote}\n\n\n\n## Active Note\n\nContent of the active note\n\n'
);
expect(result.includedFiles).toContain(mockActiveNote);
});
@@ -450,7 +471,7 @@ describe("processedPrompt()", () => {
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
- "Summarize this: {selectedText}\n\nselectedText (entire active note):\n\nContent of the active note"
+ 'Summarize this: {selected_text}\n\n\nContent of the active note\n'
);
// Active note should be included because of {}
expect(result.includedFiles).toContain(mockActiveNote);
@@ -476,7 +497,7 @@ describe("processedPrompt()", () => {
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
- "Summarize this: {selectedText}. Additional info: {activeNote}\n\nselectedText (entire active note):\n\nContent of the active note"
+ 'Summarize this: {selected_text}. Additional info: {activeNote}\n\n\nContent of the active note\n'
);
// Ensure getFileContent was called for the {} replacement
expect(getFileContent).toHaveBeenCalledWith(mockActiveNote, mockVault);
@@ -501,7 +522,7 @@ describe("processedPrompt()", () => {
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
- "Analyze this: {selectedText}\n\nselectedText:\n\nThis is the selected text"
+ "Analyze this: {selected_text}\n\n\nThis is the selected text\n"
);
// Active note should not be included when selected text is present for {}
expect(result.includedFiles).toEqual([]);
@@ -539,7 +560,7 @@ describe("processedPrompt()", () => {
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
expect(result.processedPrompt).toBe(
- "This is a test prompt with {invalidVariable} name and {activeNote}\n\nactiveNote:\n\n## Active Note\n\nActive Note Content"
+ 'This is a test prompt with {invalidVariable} name and {activeNote}\n\n\n\n## Active Note\n\nActive Note Content\n\n'
);
expect(result.includedFiles).toContain(mockActiveNote);
// Expect the warning for the invalid variable
diff --git a/src/commands/customCommandUtils.ts b/src/commands/customCommandUtils.ts
index ba06627d..b33bcb3d 100644
--- a/src/commands/customCommandUtils.ts
+++ b/src/commands/customCommandUtils.ts
@@ -6,9 +6,10 @@ import {
COPILOT_COMMAND_SLASH_ENABLED,
EMPTY_COMMAND,
LEGACY_SELECTED_TEXT_PLACEHOLDER,
+ QUICK_COMMAND_CODE_BLOCK,
} from "@/commands/constants";
import { CustomCommand } from "@/commands/type";
-import { normalizePath, Notice, TAbstractFile, TFile, Vault } from "obsidian";
+import { normalizePath, Notice, TAbstractFile, TFile, Vault, Editor } from "obsidian";
import { getSettings } from "@/settings/model";
import {
updateCachedCommands,
@@ -25,7 +26,12 @@ import {
getNotesFromTags,
processVariableNameForNotePath,
} from "@/utils";
-import { NOTE_CONTEXT_PROMPT_TAG } from "@/constants";
+import {
+ NOTE_CONTEXT_PROMPT_TAG,
+ SELECTED_TEXT_TAG,
+ VARIABLE_TAG,
+ VARIABLE_NOTE_TAG,
+} from "@/constants";
export function validateCommandName(
name: string,
@@ -195,8 +201,8 @@ export async function processCommandPrompt(
const processedPrompt = result.processedPrompt;
- if (processedPrompt.includes("{selectedText}") || skipAppendingSelectedText) {
- // Containing {selectedText} means the prompt was using the custom prompt
+ if (processedPrompt.includes(`{${SELECTED_TEXT_TAG}}`) || skipAppendingSelectedText) {
+ // Containing {selected_text} means the prompt was using the custom prompt
// processor way of handling the selected text. No need to go through the
// legacy placeholder.
return processedPrompt;
@@ -211,7 +217,16 @@ export async function processCommandPrompt(
// `{copilot-selection}` is found, append the selected text to the prompt.
const index = processedPrompt.indexOf(LEGACY_SELECTED_TEXT_PLACEHOLDER);
if (index === -1 && selectedText.trim()) {
- return processedPrompt + "\n\n" + selectedText + "";
+ return (
+ processedPrompt +
+ "\n\n<" +
+ SELECTED_TEXT_TAG +
+ ">" +
+ selectedText +
+ "" +
+ SELECTED_TEXT_TAG +
+ ">"
+ );
}
return (
processedPrompt.slice(0, index) +
@@ -257,7 +272,7 @@ async function extractVariablesFromPrompt(
if (activeNote) {
const content = await getFileContent(activeNote, vault);
if (content) {
- variableResult.content = `## ${getFileName(activeNote)}\n\n${content}`;
+ variableResult.content = `<${VARIABLE_NOTE_TAG}>\n## ${getFileName(activeNote)}\n\n${content}\n${VARIABLE_NOTE_TAG}>`;
variableResult.files.push(activeNote);
}
} else {
@@ -274,7 +289,9 @@ async function extractVariablesFromPrompt(
for (const file of noteFiles) {
const content = await getFileContent(file, vault);
if (content) {
- notesContent.push(`## ${getFileName(file)}\n\n${content}`);
+ notesContent.push(
+ `<${VARIABLE_NOTE_TAG}>\n## ${getFileName(file)}\n\n${content}\n${VARIABLE_NOTE_TAG}>`
+ );
variableResult.files.push(file);
}
}
@@ -286,7 +303,9 @@ async function extractVariablesFromPrompt(
for (const file of noteFiles) {
const content = await getFileContent(file, vault);
if (content) {
- notesContent.push(`## ${getFileName(file)}\n\n${content}`);
+ notesContent.push(
+ `<${VARIABLE_NOTE_TAG}>\n## ${getFileName(file)}\n\n${content}\n${VARIABLE_NOTE_TAG}>`
+ );
variableResult.files.push(file);
}
}
@@ -353,16 +372,16 @@ export async function processPrompt(
let activeNoteContent: string | null = null;
if (processedPrompt.includes("{}")) {
- processedPrompt = processedPrompt.replace(/\{\}/g, "{selectedText}");
+ processedPrompt = processedPrompt.replace(/\{\}/g, `{${SELECTED_TEXT_TAG}}`);
if (selectedText) {
- additionalInfo += `selectedText:\n\n${selectedText}`;
+ additionalInfo += `<${SELECTED_TEXT_TAG}>\n${selectedText}\n${SELECTED_TEXT_TAG}>`;
// Note: selectedText doesn't directly correspond to a file inclusion here
} else if (activeNote) {
activeNoteContent = await getFileContent(activeNote, vault);
- additionalInfo += `selectedText (entire active note):\n\n${activeNoteContent}`;
+ additionalInfo += `<${SELECTED_TEXT_TAG} type="active_note">\n${activeNoteContent || ""}\n${SELECTED_TEXT_TAG}>`;
includedFiles.add(activeNote); // Ensure active note is tracked if used for {}
} else {
- additionalInfo += `selectedText:\n\n(No selected text or active note available)`;
+ additionalInfo += `<${SELECTED_TEXT_TAG}>\n(No selected text or active note available)\n${SELECTED_TEXT_TAG}>`;
}
}
@@ -374,9 +393,9 @@ export async function processPrompt(
continue;
}
if (additionalInfo) {
- additionalInfo += `\n\n${varName}:\n\n${content}`;
+ additionalInfo += `\n\n<${VARIABLE_TAG} name="${varName}">\n${content}\n${VARIABLE_TAG}>`;
} else {
- additionalInfo += `${varName}:\n\n${content}`;
+ additionalInfo += `<${VARIABLE_TAG} name="${varName}">\n${content}\n${VARIABLE_TAG}>`;
}
}
@@ -388,7 +407,12 @@ export async function processPrompt(
if (!includedFiles.has(noteFile)) {
const noteContent = await getFileContent(noteFile, vault);
if (noteContent) {
- const noteContext = `<${NOTE_CONTEXT_PROMPT_TAG}> \n Title: [[${noteFile.basename}]]\nPath: ${noteFile.path}\n\n${noteContent}\n${NOTE_CONTEXT_PROMPT_TAG}>`;
+ // Get file metadata
+ const stats = await vault.adapter.stat(noteFile.path);
+ const ctime = stats ? new Date(stats.ctime).toISOString() : "Unknown";
+ const mtime = stats ? new Date(stats.mtime).toISOString() : "Unknown";
+
+ const noteContext = `<${NOTE_CONTEXT_PROMPT_TAG}>\n${noteFile.basename}\n${noteFile.path}\n${ctime}\n${mtime}\n\n${noteContent}\n\n${NOTE_CONTEXT_PROMPT_TAG}>`;
if (additionalInfo) {
additionalInfo += `\n\n`;
}
@@ -468,3 +492,68 @@ export async function ensureCommandFrontmatter(file: TFile, command: CustomComma
removePendingFileWrite(file.path);
}
}
+
+/**
+ * Removes all quick command code blocks from the editor while preserving cursor position and selection
+ * @param editor - The Obsidian editor instance
+ * @returns true if any blocks were removed, false otherwise
+ */
+export function removeQuickCommandBlocks(editor: Editor): boolean {
+ // Store original selection positions
+ const originalFrom = editor.getCursor("from");
+ const originalTo = editor.getCursor("to");
+
+ const content = editor.getValue();
+ const lines = content.split("\n");
+ let hasExisting = false;
+ const newLines = [];
+ let removedLinesBeforeFrom = 0;
+ let removedLinesBeforeTo = 0;
+ let i = 0;
+
+ while (i < lines.length) {
+ if (lines[i].trim() === `\`\`\`${QUICK_COMMAND_CODE_BLOCK}`) {
+ hasExisting = true;
+ const blockStartLine = i;
+
+ // Skip the opening line
+ i++;
+ // Skip until we find the closing ```
+ while (i < lines.length && lines[i].trim() !== "```") {
+ i++;
+ }
+ // Skip the closing line
+ i++;
+
+ const removedLineCount = i - blockStartLine;
+
+ // Calculate how many lines were removed before the selection positions
+ if (blockStartLine <= originalFrom.line) {
+ removedLinesBeforeFrom += removedLineCount;
+ }
+ if (blockStartLine <= originalTo.line) {
+ removedLinesBeforeTo += removedLineCount;
+ }
+ } else {
+ newLines.push(lines[i]);
+ i++;
+ }
+ }
+
+ // Update editor content and restore selection if we removed existing blocks
+ if (hasExisting) {
+ editor.setValue(newLines.join("\n"));
+
+ // Calculate new selection positions accounting for removed lines
+ const newFromLine = Math.max(0, originalFrom.line - removedLinesBeforeFrom);
+ const newToLine = Math.max(0, originalTo.line - removedLinesBeforeTo);
+
+ // Restore the selection
+ editor.setSelection(
+ { line: newFromLine, ch: originalFrom.ch },
+ { line: newToLine, ch: originalTo.ch }
+ );
+ }
+
+ return hasExisting;
+}
diff --git a/src/commands/customCommandUtils.xmlescape.test.ts b/src/commands/customCommandUtils.xmlescape.test.ts
new file mode 100644
index 00000000..3ec37b46
--- /dev/null
+++ b/src/commands/customCommandUtils.xmlescape.test.ts
@@ -0,0 +1,148 @@
+import { processPrompt } from "@/commands/customCommandUtils";
+import { TFile, Vault } from "obsidian";
+import { getFileContent, getFileName, getNotesFromPath } from "@/utils";
+
+// Mock the dependencies
+jest.mock("@/utils", () => ({
+ extractNoteFiles: jest.fn().mockReturnValue([]),
+ getFileContent: jest.fn(),
+ getFileName: jest.fn(),
+ getNotesFromPath: jest.fn(),
+ getNotesFromTags: jest.fn(),
+ processVariableNameForNotePath: jest.fn(),
+}));
+
+jest.mock("@/settings/model", () => ({
+ getSettings: jest.fn(() => ({
+ debug: false,
+ enableCustomPromptTemplating: true,
+ })),
+}));
+
+describe("XML Escaping in processPrompt", () => {
+ let mockVault: Vault;
+ let mockActiveNote: TFile;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockVault = {
+ adapter: {
+ stat: jest.fn().mockResolvedValue({
+ ctime: Date.now(),
+ mtime: Date.now(),
+ }),
+ },
+ } as unknown as Vault;
+
+ mockActiveNote = {
+ path: "path/to/active/note.md",
+ basename: "Active Note",
+ } as TFile;
+ });
+
+ it("should NOT escape XML special characters in selected text", async () => {
+ const customPrompt = "Process this: {}";
+ const selectedText = "content & \"quotes\" 'apostrophes'";
+
+ const result = await processPrompt(customPrompt, selectedText, mockVault, mockActiveNote);
+
+ // Should contain the original unescaped text
+ expect(result.processedPrompt).toContain(selectedText);
+ // Should NOT contain escaped characters
+ expect(result.processedPrompt).not.toContain("<");
+ expect(result.processedPrompt).not.toContain("&");
+ expect(result.processedPrompt).not.toContain(""");
+ expect(result.processedPrompt).not.toContain("'");
+ });
+
+ it("should NOT escape XML in variable names", async () => {
+ const customPrompt = 'Use {my"variable<>}';
+
+ const mockNote = {
+ basename: 'Note with & "chars"',
+ path: "special.md",
+ } as TFile;
+
+ (getNotesFromPath as jest.Mock).mockResolvedValue([mockNote]);
+ (getFileName as jest.Mock).mockReturnValue(mockNote.basename);
+ (getFileContent as jest.Mock).mockResolvedValue('Content with & special "chars"');
+
+ const result = await processPrompt(customPrompt, "", mockVault, mockActiveNote);
+
+ // Check variable name is NOT escaped in attribute
+ expect(result.processedPrompt).toContain('name="my"variable<>"');
+
+ // Check note title is NOT escaped
+ expect(result.processedPrompt).toContain('Note with & "chars"');
+
+ // Check content is NOT escaped
+ expect(result.processedPrompt).toContain('Content with & special "chars"');
+ });
+
+ it("should NOT escape XML in active note content", async () => {
+ const customPrompt = "Process {activeNote}";
+
+ mockActiveNote.basename = "Note \"XML\" & 'special' chars";
+
+ (getFileContent as jest.Mock).mockResolvedValue(
+ 'Content: & more'
+ );
+ (getFileName as jest.Mock).mockReturnValue(mockActiveNote.basename);
+
+ const result = await processPrompt(customPrompt, "", mockVault, mockActiveNote);
+
+ // Check basename is NOT escaped
+ expect(result.processedPrompt).toContain("Note \"XML\" & 'special' chars");
+
+ // Check content is NOT escaped
+ expect(result.processedPrompt).toContain('Content: & more');
+ });
+
+ it("should NOT escape XML in note paths and metadata", async () => {
+ const customPrompt = "[[Special Note]]";
+
+ const mockNote = {
+ basename: 'Special & "Note"',
+ path: 'folder/special&chars/"note".md',
+ } as TFile;
+
+ (getNotesFromPath as jest.Mock).mockResolvedValue([]);
+ jest.requireMock("@/utils").extractNoteFiles.mockReturnValue([mockNote]);
+ (getFileContent as jest.Mock).mockResolvedValue("Content with & and < and >");
+
+ const result = await processPrompt(customPrompt, "", mockVault, mockActiveNote);
+
+ // Check title is NOT escaped
+ expect(result.processedPrompt).toContain('Special & "Note"');
+
+ // Check path is NOT escaped
+ expect(result.processedPrompt).toContain('folder/special&chars/"note".md');
+
+ // Check content is NOT escaped
+ expect(result.processedPrompt).toContain("Content with & and < and >");
+ });
+
+ it("should NOT escape XML in tag variables", async () => {
+ const customPrompt = "Notes for {#tag&special}";
+
+ const mockNote = {
+ basename: 'Tagged & "Note"',
+ path: "tagged.md",
+ } as TFile;
+
+ (getNotesFromPath as jest.Mock).mockResolvedValue([]);
+ jest.requireMock("@/utils").getNotesFromTags.mockResolvedValue([mockNote]);
+ (getFileName as jest.Mock).mockReturnValue(mockNote.basename);
+ (getFileContent as jest.Mock).mockResolvedValue("Content: & ");
+
+ const result = await processPrompt(customPrompt, "", mockVault, mockActiveNote);
+
+ // Check tag variable name is NOT escaped
+ expect(result.processedPrompt).toContain('name="#tag&special"');
+
+ // Check content is NOT escaped
+ expect(result.processedPrompt).toContain('Tagged & "Note"');
+ expect(result.processedPrompt).toContain("Content: & ");
+ });
+});
diff --git a/src/commands/index.ts b/src/commands/index.ts
index 8b9dcc98..0a38a95a 100644
--- a/src/commands/index.ts
+++ b/src/commands/index.ts
@@ -2,7 +2,7 @@ import { addSelectedTextContext, getChainType } from "@/aiParams";
import { FileCache } from "@/cache/fileCache";
import { ProjectContextCache } from "@/cache/projectContextCache";
import { ChainType } from "@/chainFactory";
-import { AdhocPromptModal } from "@/components/modals/AdhocPromptModal";
+
import { DebugSearchModal } from "@/components/modals/DebugSearchModal";
import { OramaSearchModal } from "@/components/modals/OramaSearchModal";
import { RemoveFromIndexModal } from "@/components/modals/RemoveFromIndexModal";
@@ -10,13 +10,17 @@ import CopilotPlugin from "@/main";
import { getAllQAMarkdownContent } from "@/search/searchUtils";
import { CopilotSettings, getSettings, updateSetting } from "@/settings/model";
import { SelectedTextContext } from "@/types/message";
-import { Editor, Notice, TFile } from "obsidian";
+import { Editor, Notice, TFile, MarkdownView } from "obsidian";
import { v4 as uuidv4 } from "uuid";
import { COMMAND_IDS, COMMAND_NAMES, CommandId } from "../constants";
import { CustomCommandSettingsModal } from "@/commands/CustomCommandSettingsModal";
import { EMPTY_COMMAND } from "@/commands/constants";
import { getCachedCustomCommands } from "@/commands/state";
import { CustomCommandManager } from "@/commands/customCommandManager";
+import { QUICK_COMMAND_CODE_BLOCK } from "@/commands/constants";
+import { removeQuickCommandBlocks } from "@/commands/customCommandUtils";
+import { isLivePreviewModeOn } from "@/utils";
+import { ApplyCustomCommandModal } from "@/components/modals/ApplyCustomCommandModal";
/**
* Add a command to the plugin.
@@ -98,17 +102,45 @@ export function registerCommands(
plugin.newChat();
});
- addCommand(plugin, COMMAND_IDS.APPLY_ADHOC_PROMPT, async () => {
- const modal = new AdhocPromptModal(plugin.app, async (adhocPrompt: string) => {
- try {
- plugin.processCustomPrompt(COMMAND_IDS.APPLY_ADHOC_PROMPT, adhocPrompt);
- } catch (err) {
- console.error(err);
- new Notice("An error occurred.");
- }
- });
+ addCheckCommand(plugin, COMMAND_IDS.TRIGGER_QUICK_COMMAND, (checking: boolean) => {
+ const activeView = plugin.app.workspace.getActiveViewOfType(MarkdownView);
- modal.open();
+ if (checking) {
+ // Return true only if we're in live preview mode
+ return !!(isLivePreviewModeOn() && activeView && activeView.editor);
+ }
+
+ // Need to check this again because it can still be triggered via shortcut.
+ if (!isLivePreviewModeOn()) {
+ new Notice("Quick commands are only available in live preview mode.");
+ return false;
+ }
+
+ // When not checking, execute the command
+ if (!activeView || !activeView.editor) {
+ new Notice("No active editor found.");
+ return false;
+ }
+
+ const editor = activeView.editor;
+ const selectedText = editor.getSelection();
+
+ if (!selectedText.trim()) {
+ new Notice("Please select some text first. Selected text is required for quick commands.");
+ return false;
+ }
+
+ removeQuickCommandBlocks(editor);
+
+ // Get the current cursor/selection position (after potential content update)
+ const cursor = editor.getCursor("from");
+ const line = cursor.line;
+
+ // Insert the quick command code block above the selected text
+ const codeBlock = `\`\`\`${QUICK_COMMAND_CODE_BLOCK}\n\`\`\`\n`;
+ editor.replaceRange(codeBlock, { line, ch: 0 });
+
+ return true;
});
addCommand(plugin, COMMAND_IDS.CLEAR_LOCAL_COPILOT_INDEX, async () => {
@@ -359,4 +391,10 @@ export function registerCommands(
);
modal.open();
});
+
+ // Add command to apply a custom command
+ addCommand(plugin, COMMAND_IDS.APPLY_CUSTOM_COMMAND, () => {
+ const modal = new ApplyCustomCommandModal(plugin.app);
+ modal.open();
+ });
}
diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx
index 162208b8..76f94e4a 100644
--- a/src/components/Chat.tsx
+++ b/src/components/Chat.tsx
@@ -9,13 +9,13 @@ import {
useSelectedTextContexts,
} from "@/aiParams";
import { ChainType } from "@/chainFactory";
-import { processPrompt } from "@/commands/customCommandUtils";
+
import { ChatControls, reloadCurrentProject } from "@/components/chat-components/ChatControls";
import ChatInput from "@/components/chat-components/ChatInput";
import ChatMessages from "@/components/chat-components/ChatMessages";
import { NewVersionBanner } from "@/components/chat-components/NewVersionBanner";
import { ProjectList } from "@/components/chat-components/ProjectList";
-import { ABORT_REASON, COMMAND_IDS, EVENT_NAMES, LOADING_MESSAGES, USER_SENDER } from "@/constants";
+import { ABORT_REASON, EVENT_NAMES, LOADING_MESSAGES, USER_SENDER } from "@/constants";
import { AppContext, EventTargetContext } from "@/context";
import { useChatManager } from "@/hooks/useChatManager";
import { getAIResponse } from "@/langchainStream";
@@ -23,12 +23,7 @@ import ChainManager from "@/LLMProviders/chainManager";
import CopilotPlugin from "@/main";
import { Mention } from "@/mentions/Mention";
import { useIsPlusUser } from "@/plusUtils";
-import {
- getComposerOutputPrompt,
- getSettings,
- updateSetting,
- useSettingsValue,
-} from "@/settings/model";
+import { updateSetting, useSettingsValue } from "@/settings/model";
import { ChatUIState } from "@/state/ChatUIState";
import { FileParserManager } from "@/tools/FileParserManager";
import { err2String } from "@/utils";
@@ -141,11 +136,6 @@ const Chat: React.FC = ({
// Handle composer prompt
let displayText = inputMessage;
- const composerPrompt = await getComposerOutputPrompt();
- if (inputMessage.includes("@composer") && composerPrompt !== "") {
- displayText =
- inputMessage + "\n\n\n" + composerPrompt + "\n";
- }
// Add tool calls if present
if (toolCalls) {
@@ -170,7 +160,8 @@ const Chat: React.FC = ({
displayText,
context,
currentChain,
- includeActiveNote
+ includeActiveNote,
+ content.length > 0 ? content : undefined
);
// Add to user message history
@@ -253,6 +244,8 @@ const Chat: React.FC = ({
return;
}
+ // Clear current AI message and set loading state
+ setCurrentAiMessage("");
setLoading(true);
try {
const success = await chatUIState.regenerateMessage(
@@ -355,76 +348,6 @@ const Chat: React.FC = ({
]
);
- const createEffect = (
- eventType: string,
- promptFn: (selectedText: string, eventSubtype?: string) => string | Promise
- ) => {
- return () => {
- const debug = getSettings().debug;
- const handleSelection = async (event: CustomEvent) => {
- const messageWithPrompt = await promptFn(
- event.detail.selectedText,
- event.detail.eventSubtype
- );
-
- // Send the prompt message through ChatManager (simplified approach)
- try {
- const messageId = await chatUIState.sendMessage(
- messageWithPrompt,
- { notes: [], urls: [], selectedTextContexts: [] },
- currentChain,
- includeActiveNote
- );
-
- // Get the LLM message for AI processing
- const llmMessage = chatUIState.getLLMMessage(messageId);
- if (llmMessage) {
- setLoading(true);
- await getAIResponse(
- llmMessage,
- chainManager,
- addMessage,
- setCurrentAiMessage,
- setAbortController,
- {
- debug,
- ignoreSystemMessage: true,
- }
- );
- setLoading(false);
- }
- } catch (error) {
- console.error("Error processing adhoc prompt:", error);
- setLoading(false);
- }
- };
-
- eventTarget?.addEventListener(eventType, handleSelection);
-
- // Cleanup function to remove the event listener when the component unmounts
- return () => {
- eventTarget?.removeEventListener(eventType, handleSelection);
- };
- };
- };
-
- // eslint-disable-next-line react-hooks/exhaustive-deps
- useEffect(
- createEffect(COMMAND_IDS.APPLY_ADHOC_PROMPT, async (selectedText, customPrompt) => {
- if (!customPrompt) {
- return selectedText;
- }
- const result = await processPrompt(
- customPrompt,
- selectedText,
- app.vault,
- app.workspace.getActiveFile()
- );
- return result.processedPrompt; // Extract just the processed prompt string
- }),
- []
- );
-
// Expose handleSaveAsNote to parent
useEffect(() => {
if (onSaveChat) {
diff --git a/src/components/QuickCommand.tsx b/src/components/QuickCommand.tsx
new file mode 100644
index 00000000..6ae4cd52
--- /dev/null
+++ b/src/components/QuickCommand.tsx
@@ -0,0 +1,199 @@
+import React, { useState, useEffect, useRef } from "react";
+import { Notice, MarkdownView } from "obsidian";
+import { createRoot } from "react-dom/client";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Checkbox } from "@/components/ui/checkbox";
+import { ModelSelector } from "@/components/ui/ModelSelector";
+import { CustomCommandChatModal } from "@/commands/CustomCommandChatModal";
+import { useModelKey } from "@/aiParams";
+import { CustomCommand } from "@/commands/type";
+import { removeQuickCommandBlocks } from "@/commands/customCommandUtils";
+import CopilotPlugin from "@/main";
+
+interface QuickCommandProps {
+ plugin: CopilotPlugin;
+ onRemove: () => void;
+}
+
+export function QuickCommand({ plugin, onRemove }: QuickCommandProps) {
+ const [prompt, setPrompt] = useState("");
+ const [includeActiveNote, setIncludeActiveNote] = useState(true);
+ const [selectedText, setSelectedText] = useState("");
+ const [globalModelKey] = useModelKey();
+ const [selectedModelKey, setSelectedModelKey] = useState(globalModelKey);
+ const textareaRef = useRef(null);
+
+ // Get the currently selected text from the editor
+ useEffect(() => {
+ const activeView = plugin.app.workspace.getActiveViewOfType(MarkdownView);
+ if (activeView && activeView.editor) {
+ const currentSelection = activeView.editor.getSelection();
+ setSelectedText(currentSelection);
+ }
+ }, [plugin.app]);
+
+ // Auto-focus textarea on mount
+ useEffect(() => {
+ if (textareaRef.current) {
+ textareaRef.current.focus();
+ }
+ }, []);
+
+ const handleSubmit = async () => {
+ if (!prompt.trim()) {
+ new Notice("Please enter a prompt");
+ return;
+ }
+
+ const systemPrompt = `
+You are an AI assistant designed to execute user instructions with precision. Your responses should be:
+
+- Direct and focused: Address only what is explicitly requested
+- Concise: Avoid unnecessary elaboration unless the user asks for details
+- Context-aware: When text is selected or highlighted, treat it as the primary target for any requested action
+- Action-oriented: Prioritize completing the task over explaining the process
+
+Key principles:
+
+- Follow instructions literally and completely
+- Assume selected/highlighted text is the focus unless told otherwise
+- Use all provided context: Consider any additional information, examples, or constraints the user provides to better complete the task
+- Add explanations only when explicitly requested or when clarification is essential
+- Maintain the user's preferred format and style
+
+Response format: Match the format implied by the user's request (e.g., if they ask for a list, provide a list; if they ask for a rewrite, provide only the rewritten text).
+ `;
+
+ let userContent = prompt;
+ if (includeActiveNote) {
+ // Check if placeholders already exist to avoid duplication
+ const hasSelectedTextPlaceholder = userContent.includes("{}");
+ const hasActiveNotePlaceholder = /\{activenote\}/i.test(userContent);
+
+ // Only append placeholders that don't already exist
+ const placeholdersToAdd = [];
+ if (!hasSelectedTextPlaceholder) {
+ placeholdersToAdd.push("{}");
+ }
+ if (!hasActiveNotePlaceholder) {
+ placeholdersToAdd.push("{activeNote}");
+ }
+
+ if (placeholdersToAdd.length > 0) {
+ userContent += `\n\n${placeholdersToAdd.join("\n\n")}`;
+ }
+ }
+
+ const quickCommand: CustomCommand = {
+ title: "Quick Command",
+ content: userContent,
+ showInContextMenu: false,
+ showInSlashMenu: false,
+ order: 0,
+ modelKey: selectedModelKey,
+ lastUsedMs: Date.now(),
+ };
+
+ const modal = new CustomCommandChatModal(plugin.app, {
+ selectedText,
+ command: quickCommand,
+ systemPrompt,
+ });
+ modal.open();
+
+ onRemove();
+ };
+
+ const handleCancel = () => {
+ onRemove();
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ handleCancel();
+ } else if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ };
+
+ return (
+
+ );
+}
+
+export interface QuickCommandContainerProps {
+ plugin: CopilotPlugin;
+ element: HTMLElement;
+}
+
+export function createQuickCommandContainer({ plugin, element }: QuickCommandContainerProps) {
+ const container = document.createElement("div");
+ element.appendChild(container);
+
+ const root = createRoot(container);
+
+ const handleRemove = () => {
+ const activeView = plugin.app.workspace.getActiveViewOfType(MarkdownView);
+ if (activeView && activeView.editor) {
+ removeQuickCommandBlocks(activeView.editor);
+ }
+
+ root.unmount();
+ container.remove();
+ };
+
+ root.render();
+
+ return { root, container };
+}
diff --git a/src/components/chat-components/ChatInput.tsx b/src/components/chat-components/ChatInput.tsx
index 6d1e406c..0c8d9a25 100644
--- a/src/components/chat-components/ChatInput.tsx
+++ b/src/components/chat-components/ChatInput.tsx
@@ -7,42 +7,36 @@ import {
useProjectLoading,
} from "@/aiParams";
import { ChainType } from "@/chainFactory";
+import { CustomCommandManager } from "@/commands/customCommandManager";
+import { sortSlashCommands } from "@/commands/customCommandUtils";
+import { getCachedCustomCommands } from "@/commands/state";
import { AddContextNoteModal } from "@/components/modals/AddContextNoteModal";
import { AddImageModal } from "@/components/modals/AddImageModal";
import { ListPromptModal } from "@/components/modals/ListPromptModal";
import { Button } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { ModelDisplay } from "@/components/ui/model-display";
+import { ModelSelector } from "@/components/ui/ModelSelector";
import { ContextProcessor } from "@/contextProcessor";
-import { CustomCommandManager } from "@/commands/customCommandManager";
+import { cn } from "@/lib/utils";
import { COPILOT_TOOL_NAMES } from "@/LLMProviders/intentAnalyzer";
import { Mention } from "@/mentions/Mention";
-import { getModelKeyFromModel, useSettingsValue } from "@/settings/model";
+
+import { updateSetting, useSettingsValue } from "@/settings/model";
import { SelectedTextContext } from "@/types/message";
import { getToolDescription } from "@/tools/toolManager";
+import { extractNoteFiles, isAllowedFileForContext, isNoteTitleUnique } from "@/utils";
import {
- checkModelApiKey,
- err2String,
- extractNoteFiles,
- isAllowedFileForContext,
- isNoteTitleUnique,
-} from "@/utils";
-import {
- ArrowBigUp,
- ChevronDown,
- Command,
CornerDownLeft,
+ Database,
+ Globe,
Image,
Loader2,
StopCircle,
X,
+ Pen,
+ Sparkles,
+ Brain,
} from "lucide-react";
-import { App, Notice, Platform, TFile } from "obsidian";
+import { App, Platform, TFile } from "obsidian";
import React, {
forwardRef,
useCallback,
@@ -54,8 +48,6 @@ import React, {
} from "react";
import { useDropzone } from "react-dropzone";
import ContextControl from "./ContextControl";
-import { getCachedCustomCommands } from "@/commands/state";
-import { sortSlashCommands } from "@/commands/customCommandUtils";
interface ChatInputProps {
inputMessage: string;
@@ -104,22 +96,28 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
},
ref
) => {
- const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
const [contextUrls, setContextUrls] = useState([]);
const textAreaRef = useRef(null);
const containerRef = useRef(null);
const [currentModelKey, setCurrentModelKey] = useModelKey();
- const [modelError, setModelError] = useState(null);
const [currentChain] = useChainType();
const [isProjectLoading] = useProjectLoading();
+ const settings = useSettingsValue();
const [currentActiveNote, setCurrentActiveNote] = useState(() => {
const activeFile = app.workspace.getActiveFile();
return isAllowedFileForContext(activeFile) ? activeFile : null;
});
const [selectedProject, setSelectedProject] = useState(null);
- const settings = useSettingsValue();
const isCopilotPlus =
currentChain === ChainType.COPILOT_PLUS_CHAIN || currentChain === ChainType.PROJECT_CHAIN;
+
+ // Toggle states for vault, web search, composer, and autonomous agent
+ const [vaultToggle, setVaultToggle] = useState(false);
+ const [webToggle, setWebToggle] = useState(false);
+ const [composerToggle, setComposerToggle] = useState(false);
+ const [autonomousAgentToggle, setAutonomousAgentToggle] = useState(
+ settings.enableAutonomousAgent
+ );
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
const loadingMessages = [
"Loading the project context...",
@@ -133,6 +131,17 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
},
}));
+ // Sync autonomous agent toggle with settings and chain type
+ useEffect(() => {
+ if (currentChain === ChainType.PROJECT_CHAIN) {
+ // Force off in Projects mode
+ setAutonomousAgentToggle(false);
+ } else {
+ // In other modes, use the actual settings value
+ setAutonomousAgentToggle(settings.enableAutonomousAgent);
+ }
+ }, [settings.enableAutonomousAgent, currentChain]);
+
useEffect(() => {
if (currentChain === ChainType.PROJECT_CHAIN) {
setSelectedProject(getCurrentProject());
@@ -170,14 +179,24 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
return currentModelKey;
};
- const onSendMessage = (includeVault: boolean) => {
+ const onSendMessage = () => {
if (!isCopilotPlus) {
handleSendMessage();
return;
}
+ // Build tool calls based on toggle states
+ const toolCalls: string[] = [];
+ // Only add tool calls when autonomous agent is off
+ // When autonomous agent is on, it handles all tools internally
+ if (!autonomousAgentToggle) {
+ if (vaultToggle) toolCalls.push("@vault");
+ if (webToggle) toolCalls.push("@websearch");
+ if (composerToggle) toolCalls.push("@composer");
+ }
+
handleSendMessage({
- toolCalls: includeVault ? ["@vault"] : [],
+ toolCalls,
contextNotes,
urls: contextUrls,
});
@@ -336,14 +355,6 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.nativeEvent.isComposing) return;
- // 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();
- onSendMessage(true);
- return;
- }
-
if (e.key === "Enter") {
/**
* send msg:
@@ -359,7 +370,7 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
}
e.preventDefault();
- onSendMessage(false);
+ onSendMessage();
}
};
@@ -563,71 +574,19 @@ const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
Generating...