diff --git a/.cmux/review.md b/.cmux/review.md new file mode 100644 index 000000000..879bce61a --- /dev/null +++ b/.cmux/review.md @@ -0,0 +1,5 @@ +Please review the following code with attention to: +- Code quality and readability +- Potential bugs or edge cases +- Performance considerations +- Best practices diff --git a/src/App.stories.tsx b/src/App.stories.tsx index 0a44d052f..f222367f8 100644 --- a/src/App.stories.tsx +++ b/src/App.stories.tsx @@ -84,6 +84,10 @@ function setupMockAPI(options: { install: () => undefined, onStatus: () => () => undefined, }, + prompts: { + list: () => Promise.resolve([]), + read: () => Promise.resolve(null), + }, ...options.apiOverrides, }; diff --git a/src/browser/api.ts b/src/browser/api.ts index 4be41e43d..b109f53b6 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -270,6 +270,11 @@ const webApi: IPCApi = { return wsManager.on(IPC_CHANNELS.UPDATE_STATUS, callback as (data: unknown) => void); }, }, + prompts: { + list: (workspaceId) => invokeIPC(IPC_CHANNELS.PROMPTS_LIST, workspaceId), + read: (workspaceId, promptName) => + invokeIPC(IPC_CHANNELS.PROMPTS_READ, workspaceId, promptName), + }, }; if (typeof window.api === "undefined") { diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 6ec09c3b6..c0438e6ba 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useCallback, useEffect, useId } from "react"; import { cn } from "@/lib/utils"; import { CommandSuggestions, COMMAND_SUGGESTION_KEYS } from "./CommandSuggestions"; +import { PromptSuggestions, PROMPT_SUGGESTION_KEYS } from "./PromptSuggestions"; import type { Toast } from "./ChatInputToast"; import { ChatInputToast } from "./ChatInputToast"; import { createCommandToast, createErrorToast } from "./ChatInputToasts"; @@ -24,6 +25,8 @@ import { getSlashCommandSuggestions, type SlashSuggestion, } from "@/utils/slashCommands/suggestions"; +import { usePrompts } from "@/hooks/usePrompts"; +import type { PromptSuggestion } from "@/utils/promptSuggestions"; import { TooltipWrapper, Tooltip, HelpIndicator } from "./Tooltip"; import { matchesKeybind, formatKeybind, KEYBINDS, isEditableElement } from "@/utils/ui/keybinds"; import { ModelSelector, type ModelSelectorRef } from "./ModelSelector"; @@ -85,14 +88,25 @@ export const ChatInput: React.FC = ({ const [providerNames, setProviderNames] = useState([]); const [toast, setToast] = useState(null); const [imageAttachments, setImageAttachments] = useState([]); + const handleToastDismiss = useCallback(() => { setToast(null); }, []); const inputRef = useRef(null); const modelSelectorRef = useRef(null); + + // Use the prompts hook to handle all prompt-related logic + const cursorPos = inputRef.current?.selectionStart ?? input.length; + const { + suggestions: promptSuggestions, + showSuggestions: showPromptSuggestions, + dismissSuggestions: dismissPromptSuggestions, + expandMentions: expandPromptMentions, + } = usePrompts({ workspaceId, input, cursorPos }); const [mode, setMode] = useMode(); const { recentModels, addModel } = useModelLRU(); const commandListId = useId(); + const promptListId = useId(); const telemetry = useTelemetry(); // Get current send message options from shared hook (must be at component top level) @@ -339,13 +353,57 @@ export const ChatInput: React.FC = ({ [setInput] ); + const handlePromptSelect = useCallback( + (suggestion: PromptSuggestion) => { + // Replace the "@partial" at cursor position with "@full-name" + const textarea = inputRef.current; + if (!textarea) return; + + const cursorPos = textarea.selectionStart; + const textBeforeCursor = input.slice(0, cursorPos); + + // Find the last @ before the cursor + const lastAtIndex = textBeforeCursor.lastIndexOf("@"); + if (lastAtIndex === -1) return; + + // Extract text after the @ + const textAfter = input.slice(lastAtIndex + 1); + + // Find where the partial mention ends (space, newline, or end of string) + const endMatch = /[\s\n]/.exec(textAfter); + const endIndex = + endMatch?.index !== undefined ? lastAtIndex + 1 + endMatch.index : input.length; + + // Build the new input with the completed mention + const before = input.slice(0, lastAtIndex); + const after = input.slice(endIndex); + const newInput = `${before}${suggestion.replacement}${after}`; + + setInput(newInput); + + // Set cursor position after the completed mention + const newCursorPos = before.length + suggestion.replacement.length; + setTimeout(() => { + if (textarea) { + textarea.selectionStart = newCursorPos; + textarea.selectionEnd = newCursorPos; + textarea.focus(); + } + }, 0); + }, + [input, setInput] + ); + const handleSend = async () => { // Allow sending if there's text or images if ((!input.trim() && imageAttachments.length === 0) || disabled || isSending || isCompacting) { return; } - const messageText = input.trim(); + let messageText = input.trim(); + + // Expand prompt mentions before sending + messageText = await expandPromptMentions(messageText); try { // Parse command @@ -663,7 +721,7 @@ export const ChatInput: React.FC = ({ return; } - // Note: ESC handled by VimTextArea (for mode transitions) and CommandSuggestions (for dismissal) + // Note: ESC handled by VimTextArea (for mode transitions), CommandSuggestions, and PromptSuggestions (for dismissal) // Edit canceling is Ctrl+Q, stream interruption is Ctrl+C // Don't handle keys if command suggestions are visible @@ -675,6 +733,15 @@ export const ChatInput: React.FC = ({ return; // Let CommandSuggestions handle it } + // Don't handle keys if prompt suggestions are visible + if ( + showPromptSuggestions && + promptSuggestions.length > 0 && + PROMPT_SUGGESTION_KEYS.includes(e.key) + ) { + return; // Let PromptSuggestions handle it + } + // Handle send message (Shift+Enter for newline is default behavior) if (matchesKeybind(e, KEYBINDS.SEND_MESSAGE)) { e.preventDefault(); @@ -717,6 +784,14 @@ export const ChatInput: React.FC = ({ ariaLabel="Slash command suggestions" listId={commandListId} /> +
= ({ onPaste={handlePaste} onDragOver={handleDragOver} onDrop={handleDrop} - suppressKeys={showCommandSuggestions ? COMMAND_SUGGESTION_KEYS : undefined} + suppressKeys={ + showCommandSuggestions + ? COMMAND_SUGGESTION_KEYS + : showPromptSuggestions + ? PROMPT_SUGGESTION_KEYS + : undefined + } placeholder={placeholder} disabled={!editingMessage && (disabled || isSending || isCompacting)} aria-label={editingMessage ? "Edit your last message" : "Message Claude"} aria-autocomplete="list" aria-controls={ - showCommandSuggestions && commandSuggestions.length > 0 ? commandListId : undefined + showCommandSuggestions && commandSuggestions.length > 0 + ? commandListId + : showPromptSuggestions && promptSuggestions.length > 0 + ? promptListId + : undefined + } + aria-expanded={ + (showCommandSuggestions && commandSuggestions.length > 0) || + (showPromptSuggestions && promptSuggestions.length > 0) } - aria-expanded={showCommandSuggestions && commandSuggestions.length > 0} />
diff --git a/src/components/PromptSuggestions.tsx b/src/components/PromptSuggestions.tsx new file mode 100644 index 000000000..81bec46b1 --- /dev/null +++ b/src/components/PromptSuggestions.tsx @@ -0,0 +1,127 @@ +import React, { useState, useEffect } from "react"; +import { cn } from "@/lib/utils"; +import type { PromptSuggestion } from "@/utils/promptSuggestions"; + +// Export the keys that PromptSuggestions handles +export const PROMPT_SUGGESTION_KEYS = ["Tab", "ArrowUp", "ArrowDown", "Escape"]; + +// Props interface +interface PromptSuggestionsProps { + suggestions: PromptSuggestion[]; + onSelectSuggestion: (suggestion: PromptSuggestion) => void; + onDismiss: () => void; + isVisible: boolean; + ariaLabel?: string; + listId?: string; +} + +// Main component +export const PromptSuggestions: React.FC = ({ + suggestions, + onSelectSuggestion, + onDismiss, + isVisible, + ariaLabel = "Prompt suggestions", + listId, +}) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + // Reset selection whenever suggestions change + useEffect(() => { + setSelectedIndex(0); + }, [suggestions]); + + // Handle keyboard navigation + useEffect(() => { + if (!isVisible || suggestions.length === 0) return; + + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((i) => (i + 1) % suggestions.length); + break; + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((i) => (i - 1 + suggestions.length) % suggestions.length); + break; + case "Tab": + if (!e.shiftKey && suggestions.length > 0) { + e.preventDefault(); + onSelectSuggestion(suggestions[selectedIndex]); + } + break; + case "Escape": + e.preventDefault(); + onDismiss(); + break; + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isVisible, suggestions, selectedIndex, onSelectSuggestion, onDismiss]); + + // Click outside handler + useEffect(() => { + if (!isVisible) return; + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (!target.closest("[data-prompt-suggestions]")) { + onDismiss(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isVisible, onDismiss]); + + if (!isVisible || suggestions.length === 0) { + return null; + } + + const activeSuggestion = suggestions[selectedIndex] ?? suggestions[0]; + const resolvedListId = listId ?? `prompt-suggestions-list`; + + return ( +
+ {suggestions.map((suggestion, index) => ( +
setSelectedIndex(index)} + onClick={() => onSelectSuggestion(suggestion)} + id={`${resolvedListId}-option-${suggestion.id}`} + role="option" + aria-selected={index === selectedIndex} + className={cn( + "px-2.5 py-1.5 cursor-pointer transition-colors duration-150 flex items-center justify-between gap-3 hover:bg-accent-darker", + index === selectedIndex ? "bg-accent-darker" : "bg-transparent" + )} + > +
@{suggestion.name}
+
+ {suggestion.location === "repo" ? "📁 repo" : "🏠 system"} +
+
+ ))} +
+ Tab to complete • ↑↓ to navigate • Esc to dismiss +
+
+ ); +}; diff --git a/src/constants/ipc-constants.ts b/src/constants/ipc-constants.ts index be42fd9ad..ddb882024 100644 --- a/src/constants/ipc-constants.ts +++ b/src/constants/ipc-constants.ts @@ -49,6 +49,10 @@ export const IPC_CHANNELS = { UPDATE_STATUS: "update:status", UPDATE_STATUS_SUBSCRIBE: "update:status:subscribe", + // Prompt channels + PROMPTS_LIST: "prompts:list", + PROMPTS_READ: "prompts:read", + // Dynamic channel prefixes WORKSPACE_CHAT_PREFIX: "workspace:chat:", WORKSPACE_METADATA: "workspace:metadata", diff --git a/src/hooks/usePrompts.ts b/src/hooks/usePrompts.ts new file mode 100644 index 000000000..9de1ef861 --- /dev/null +++ b/src/hooks/usePrompts.ts @@ -0,0 +1,111 @@ +import { useState, useEffect, useCallback } from "react"; +import { + getPromptSuggestions, + extractPromptMentions, + expandPromptMentions, + type PromptSuggestion, +} from "@/utils/promptSuggestions"; + +interface UsePromptsOptions { + workspaceId: string; + input: string; + cursorPos?: number; +} + +interface UsePromptsReturn { + availablePrompts: Array<{ name: string; path: string; location: "repo" | "system" }>; + suggestions: PromptSuggestion[]; + showSuggestions: boolean; + dismissSuggestions: () => void; + expandMentions: (text: string) => Promise; +} + +/** + * Hook for managing prompt mentions in chat input + * + * Handles: + * - Loading available prompts from workspace + * - Generating suggestions based on input + * - Expanding @mentions to their content + */ +export function usePrompts({ workspaceId, input, cursorPos }: UsePromptsOptions): UsePromptsReturn { + const [availablePrompts, setAvailablePrompts] = useState< + Array<{ name: string; path: string; location: "repo" | "system" }> + >([]); + const [suggestions, setSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const [manuallyDismissed, setManuallyDismissed] = useState(false); + + // Load available prompts for the workspace + useEffect(() => { + let isMounted = true; + + const loadPrompts = async () => { + try { + const prompts = await window.api.prompts.list(workspaceId); + if (isMounted && Array.isArray(prompts)) { + setAvailablePrompts(prompts); + } + } catch (error) { + console.error("Failed to load prompts:", error); + } + }; + + void loadPrompts(); + + return () => { + isMounted = false; + }; + }, [workspaceId]); + + // Generate suggestions based on input + useEffect(() => { + const pos = cursorPos ?? input.length; + const newSuggestions = getPromptSuggestions(input, pos, availablePrompts); + setSuggestions(newSuggestions); + setShowSuggestions(newSuggestions.length > 0 && !manuallyDismissed); + + // Reset manual dismissal when input changes + if (manuallyDismissed) { + setManuallyDismissed(false); + } + }, [input, cursorPos, availablePrompts, manuallyDismissed]); + + // Expand @mentions in text to their actual content + const expandMentions = useCallback( + async (text: string): Promise => { + const mentions = extractPromptMentions(text); + if (mentions.length === 0) { + return text; + } + + const promptContents = new Map(); + for (const mention of mentions) { + try { + const content = await window.api.prompts.read(workspaceId, mention); + if (content) { + promptContents.set(mention, content); + } + } catch (error) { + console.error(`Failed to read prompt "${mention}":`, error); + } + } + + return expandPromptMentions(text, promptContents); + }, + [workspaceId] + ); + + const dismissSuggestions = useCallback(() => { + setManuallyDismissed(true); + setShowSuggestions(false); + }, []); + + return { + availablePrompts, + suggestions, + showSuggestions, + dismissSuggestions, + expandMentions, + }; +} diff --git a/src/preload.ts b/src/preload.ts index a42e597e9..fd6ef515f 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -136,6 +136,11 @@ const api: IPCApi = { }; }, }, + prompts: { + list: (workspaceId: string) => ipcRenderer.invoke(IPC_CHANNELS.PROMPTS_LIST, workspaceId), + read: (workspaceId: string, promptName: string) => + ipcRenderer.invoke(IPC_CHANNELS.PROMPTS_READ, workspaceId, promptName), + }, }; // Expose the API along with platform/versions diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 465d9ad21..98c82b6cd 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -28,6 +28,7 @@ import { createBashTool } from "@/services/tools/bash"; import type { BashToolResult } from "@/types/tools"; import { secretsToRecord } from "@/types/secrets"; import { DisposableTempDir } from "@/services/tempDir"; +import { listPrompts, findAndReadPrompt, type PromptFile } from "@/utils/main/promptFiles"; /** * IpcMain - Manages all IPC handlers and service coordination @@ -144,6 +145,7 @@ export class IpcMain { this.registerWorkspaceHandlers(ipcMain); this.registerProviderHandlers(ipcMain); this.registerProjectHandlers(ipcMain); + this.registerPromptHandlers(ipcMain); this.registerSubscriptionHandlers(ipcMain); this.registered = true; } @@ -1260,4 +1262,58 @@ export class IpcMain { } return null; } + + /** + * Get prompt directories for a workspace + * @returns Tuple of [repoPromptsDir, systemPromptsDir] or null if workspace not found + */ + private getPromptDirectories(workspaceId: string): [string, string] | null { + const workspace = this.config.findWorkspace(workspaceId); + if (!workspace) { + return null; + } + + const repoPromptsDir = path.join(workspace.workspacePath, ".cmux"); + const systemPromptsDir = path.join(this.config.rootDir, "prompts"); + + return [repoPromptsDir, systemPromptsDir]; + } + + private registerPromptHandlers(ipcMain: ElectronIpcMain): void { + ipcMain.handle( + IPC_CHANNELS.PROMPTS_LIST, + async (_event, workspaceId: string): Promise => { + try { + const dirs = this.getPromptDirectories(workspaceId); + if (!dirs) { + return []; + } + + const [repoPromptsDir, systemPromptsDir] = dirs; + return await listPrompts(repoPromptsDir, systemPromptsDir); + } catch (error) { + log.error("Failed to list prompts:", error); + return []; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.PROMPTS_READ, + async (_event, workspaceId: string, promptName: string): Promise => { + try { + const dirs = this.getPromptDirectories(workspaceId); + if (!dirs) { + return null; + } + + const [repoPromptsDir, systemPromptsDir] = dirs; + return await findAndReadPrompt(promptName, repoPromptsDir, systemPromptsDir); + } catch (error) { + log.error(`Failed to read prompt "${promptName}":`, error); + return null; + } + } + ); + } } diff --git a/src/types/ipc.ts b/src/types/ipc.ts index e513ba3b7..23169d7b7 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -248,6 +248,12 @@ export interface IPCApi { install(): void; onStatus(callback: (status: UpdateStatus) => void): () => void; }; + prompts: { + list( + workspaceId: string + ): Promise>; + read(workspaceId: string, promptName: string): Promise; + }; } // Update status type (matches updater service) diff --git a/src/utils/main/promptFiles.ts b/src/utils/main/promptFiles.ts new file mode 100644 index 000000000..2d8dd8efd --- /dev/null +++ b/src/utils/main/promptFiles.ts @@ -0,0 +1,136 @@ +import * as fs from "fs/promises"; +import * as path from "path"; + +/** + * Prompt file information + */ +export interface PromptFile { + /** Name of the prompt (filename without .md extension) */ + name: string; + /** Full path to the prompt file */ + path: string; + /** Location where the prompt was found (repo or system) */ + location: "repo" | "system"; +} + +/** + * Finds all markdown prompt files in a directory + * + * @param directory - Directory to search for prompt files + * @param location - Whether this is a repo or system directory + * @returns Array of prompt files found + */ +async function findPromptsInDirectory( + directory: string, + location: "repo" | "system" +): Promise { + try { + const entries = await fs.readdir(directory, { withFileTypes: true }); + const prompts: PromptFile[] = []; + + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith(".md")) { + const name = entry.name.slice(0, -3); // Remove .md extension + prompts.push({ + name, + path: path.join(directory, entry.name), + location, + }); + } + } + + return prompts; + } catch { + // Directory doesn't exist or can't be read + return []; + } +} + +/** + * Lists all available prompt files across multiple directories + * + * Searches in priority order: + * 1. Repository .cmux directory (if provided) + * 2. System ~/.cmux/prompts directory (if provided) + * + * Repository prompts take precedence over system prompts with the same name. + * + * @param repoPromptsDir - Path to repository .cmux directory (optional) + * @param systemPromptsDir - Path to system ~/.cmux/prompts directory (optional) + * @returns Array of available prompts (deduplicated, repo prompts override system) + */ +export async function listPrompts( + repoPromptsDir?: string, + systemPromptsDir?: string +): Promise { + const repoPrompts = repoPromptsDir ? await findPromptsInDirectory(repoPromptsDir, "repo") : []; + const systemPrompts = systemPromptsDir + ? await findPromptsInDirectory(systemPromptsDir, "system") + : []; + + // Deduplicate: repo prompts override system prompts with same name + const promptMap = new Map(); + + // Add system prompts first + for (const prompt of systemPrompts) { + promptMap.set(prompt.name, prompt); + } + + // Override with repo prompts + for (const prompt of repoPrompts) { + promptMap.set(prompt.name, prompt); + } + + return Array.from(promptMap.values()).sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Reads the content of a prompt file + * + * @param promptPath - Full path to the prompt file + * @returns Content of the prompt file, or null if it doesn't exist + */ +export async function readPrompt(promptPath: string): Promise { + try { + return await fs.readFile(promptPath, "utf-8"); + } catch { + return null; + } +} + +/** + * Finds a prompt by name across multiple directories + * + * Searches in priority order: + * 1. Repository .cmux directory + * 2. System ~/.cmux/prompts directory + * + * @param promptName - Name of the prompt (without .md extension) + * @param repoPromptsDir - Path to repository .cmux directory (optional) + * @param systemPromptsDir - Path to system ~/.cmux/prompts directory (optional) + * @returns Content of the prompt, or null if not found + */ +export async function findAndReadPrompt( + promptName: string, + repoPromptsDir?: string, + systemPromptsDir?: string +): Promise { + const filename = `${promptName}.md`; + + // Try repo directory first + if (repoPromptsDir) { + const repoPath = path.join(repoPromptsDir, filename); + const content = await readPrompt(repoPath); + if (content !== null) { + return content; + } + } + + // Try system directory + if (systemPromptsDir) { + const systemPath = path.join(systemPromptsDir, filename); + return await readPrompt(systemPath); + } + + return null; +} diff --git a/src/utils/promptSuggestions.test.ts b/src/utils/promptSuggestions.test.ts new file mode 100644 index 000000000..5b21079c4 --- /dev/null +++ b/src/utils/promptSuggestions.test.ts @@ -0,0 +1,118 @@ +import { describe, test, expect } from "bun:test"; +import { + getPromptSuggestions, + extractPromptMentions, + expandPromptMentions, +} from "./promptSuggestions"; + +describe("promptSuggestions", () => { + const mockPrompts = [ + { name: "test-prompt", path: "/path/to/test-prompt.md", location: "system" as const }, + { name: "explain", path: "/path/to/explain.md", location: "system" as const }, + { name: "repo-prompt", path: "/repo/.cmux/repo-prompt.md", location: "repo" as const }, + ]; + + describe("getPromptSuggestions", () => { + test("returns all prompts when input is just @", () => { + const input = "@"; + const suggestions = getPromptSuggestions(input, input.length, mockPrompts); + expect(suggestions.length).toBe(3); + }); + + test("filters prompts by partial name", () => { + const input = "@test"; + const suggestions = getPromptSuggestions(input, input.length, mockPrompts); + expect(suggestions.length).toBe(1); + expect(suggestions[0].name).toBe("test-prompt"); + }); + + test("returns empty array when no @ present", () => { + const input = "no at sign"; + const suggestions = getPromptSuggestions(input, input.length, mockPrompts); + expect(suggestions).toEqual([]); + }); + + test("returns empty array when space follows @", () => { + const input = "@ "; + const suggestions = getPromptSuggestions(input, input.length, mockPrompts); + expect(suggestions).toEqual([]); + }); + + test("sorts repo prompts before system prompts", () => { + const input = "@"; + const suggestions = getPromptSuggestions(input, input.length, mockPrompts); + expect(suggestions[0].location).toBe("repo"); + expect(suggestions[1].location).toBe("system"); + expect(suggestions[2].location).toBe("system"); + }); + + test("only considers @ before cursor position", () => { + const input = "@test @explain"; + // Cursor is after "test" (position 5) + const suggestions = getPromptSuggestions(input, 5, mockPrompts); + expect(suggestions.length).toBe(1); + expect(suggestions[0].name).toBe("test-prompt"); + }); + + test("ignores @ after cursor position", () => { + const input = "@explain @test"; + // Cursor is after "explain" (position 8) + const suggestions = getPromptSuggestions(input, 8, mockPrompts); + expect(suggestions.length).toBe(1); + expect(suggestions[0].name).toBe("explain"); + }); + }); + + describe("extractPromptMentions", () => { + test("extracts single mention", () => { + const mentions = extractPromptMentions("Please @explain this"); + expect(mentions).toEqual(["explain"]); + }); + + test("extracts multiple mentions", () => { + const mentions = extractPromptMentions("Use @test-prompt and @explain"); + expect(mentions).toEqual(["test-prompt", "explain"]); + }); + + test("handles hyphens and underscores", () => { + const mentions = extractPromptMentions("@my-prompt @another_one"); + expect(mentions).toEqual(["my-prompt", "another_one"]); + }); + + test("returns empty array when no mentions", () => { + const mentions = extractPromptMentions("no mentions here"); + expect(mentions).toEqual([]); + }); + }); + + describe("expandPromptMentions", () => { + const promptContents = new Map([ + ["explain", "Please explain in detail:"], + ["test", "This is a test"], + ]); + + test("expands single mention", () => { + const input = "Please @explain how it works"; + const expanded = expandPromptMentions(input, promptContents); + expect(expanded).toBe("Please Please explain in detail: how it works"); + }); + + test("expands multiple mentions", () => { + const input = "@test @explain"; + const expanded = expandPromptMentions(input, promptContents); + expect(expanded).toBe("This is a test Please explain in detail:"); + }); + + test("leaves unknown mentions unchanged", () => { + const input = "@unknown mention"; + const expanded = expandPromptMentions(input, promptContents); + expect(expanded).toBe("@unknown mention"); + }); + + test("handles mention at word boundary", () => { + const input = "@test-prompt"; // Should not match "@test" if full name is "test-prompt" + const expanded = expandPromptMentions(input, promptContents); + expect(expanded).toBe("@test-prompt"); // No expansion because "test" != "test-prompt" + }); + }); +}); diff --git a/src/utils/promptSuggestions.ts b/src/utils/promptSuggestions.ts new file mode 100644 index 000000000..60f3d5844 --- /dev/null +++ b/src/utils/promptSuggestions.ts @@ -0,0 +1,116 @@ +/** + * Prompt mention (@) suggestions generation + */ + +export interface PromptSuggestion { + id: string; + name: string; + location: "repo" | "system"; + replacement: string; +} + +/** + * Get prompt suggestions for the current input + * + * Returns suggestions when the input contains "@" followed by optional characters + * at the current cursor position. + * + * @param input - Current input text + * @param cursorPos - Current cursor position in the input + * @param prompts - Available prompts from IPC + * @returns Array of matching prompt suggestions + */ +export function getPromptSuggestions( + input: string, + cursorPos: number, + prompts: Array<{ name: string; path: string; location: "repo" | "system" }> +): PromptSuggestion[] { + // Get text before cursor + const textBeforeCursor = input.slice(0, cursorPos); + + // Find the last "@" before the cursor + const lastAtIndex = textBeforeCursor.lastIndexOf("@"); + if (lastAtIndex === -1) { + return []; + } + + // Get the text between @ and cursor + const afterAt = textBeforeCursor.slice(lastAtIndex + 1); + + // If there's a space after @, don't show suggestions + if (afterAt.includes(" ") || afterAt.includes("\n")) { + return []; + } + + // Get the partial prompt name (text between @ and cursor) + // This supports typing like "@my-prom" -> shows "my-prompt" + const partialName = afterAt.toLowerCase(); + + // Filter prompts that match the partial name + return prompts + .filter((prompt) => { + if (!partialName) { + return true; // Show all if just "@" + } + return prompt.name.toLowerCase().startsWith(partialName); + }) + .map((prompt) => ({ + id: `prompt:${prompt.name}`, + name: prompt.name, + location: prompt.location, + replacement: `@${prompt.name}`, + })) + .sort((a, b) => { + // Sort by location (repo first), then by name + if (a.location !== b.location) { + return a.location === "repo" ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); +} + +/** + * Extract all prompt mentions from input text + * + * @param input - Input text to parse + * @returns Array of prompt names mentioned with "@" + */ +export function extractPromptMentions(input: string): string[] { + // Match @ patterns (alphanumeric, hyphens, underscores) + const mentionRegex = /@([\w-]+)/g; + const mentions: string[] = []; + let match; + + while ((match = mentionRegex.exec(input)) !== null) { + mentions.push(match[1]); + } + + return mentions; +} + +/** + * Replace prompt mentions with their content + * + * @param input - Input text containing @mentions + * @param promptContents - Map of prompt name to content + * @returns Input with @mentions expanded to content + */ +export function expandPromptMentions(input: string, promptContents: Map): string { + let result = input; + + // Replace each mention with its content + // Use negative lookahead/lookbehind to ensure exact word boundaries + for (const [name, content] of promptContents) { + const mentionPattern = new RegExp(`@${escapeRegex(name)}(?![\\w-])`, "g"); + result = result.replace(mentionPattern, content); + } + + return result; +} + +/** + * Escape special regex characters in a string + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/tests/ipcMain/prompts.test.ts b/tests/ipcMain/prompts.test.ts new file mode 100644 index 000000000..1cbb0d7b7 --- /dev/null +++ b/tests/ipcMain/prompts.test.ts @@ -0,0 +1,194 @@ +import { shouldRunIntegrationTests, createTestEnvironment, cleanupTestEnvironment } from "./setup"; +import { IPC_CHANNELS } from "../../src/constants/ipc-constants"; +import { createTempGitRepo, cleanupTempGitRepo } from "./helpers"; +import { detectDefaultTrunkBranch } from "../../src/git"; +import * as fs from "fs/promises"; +import * as path from "path"; + +// Skip all tests if TEST_INTEGRATION is not set +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +describeIntegration("IpcMain prompts integration tests", () => { + test.concurrent( + "should list prompts from both system and repo directories", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + // Create a workspace + const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); + const createResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_CREATE, + tempGitRepo, + "test-prompts", + trunkBranch + ); + expect(createResult.success).toBe(true); + + const workspaceId = createResult.metadata.id; + + // Create system-level prompts + const systemPromptsDir = path.join(env.config.rootDir, "prompts"); + await fs.mkdir(systemPromptsDir, { recursive: true }); + await fs.writeFile( + path.join(systemPromptsDir, "system-prompt.md"), + "This is a system-level prompt" + ); + await fs.writeFile( + path.join(systemPromptsDir, "shared-prompt.md"), + "System version of shared prompt" + ); + + // Create repo-level prompts + const repoPromptsDir = path.join(createResult.metadata.namedWorkspacePath, ".cmux"); + await fs.mkdir(repoPromptsDir, { recursive: true }); + await fs.writeFile( + path.join(repoPromptsDir, "repo-prompt.md"), + "This is a repo-level prompt" + ); + await fs.writeFile( + path.join(repoPromptsDir, "shared-prompt.md"), + "Repo version of shared prompt (should override system)" + ); + + // List prompts + const prompts = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROMPTS_LIST, workspaceId); + + expect(prompts).toHaveLength(3); + + // Verify all prompts are present + const promptNames = prompts.map((p: { name: string }) => p.name).sort(); + expect(promptNames).toEqual(["repo-prompt", "shared-prompt", "system-prompt"]); + + // Verify locations + const repoPrompt = prompts.find((p: { name: string }) => p.name === "repo-prompt"); + expect(repoPrompt.location).toBe("repo"); + + const systemPrompt = prompts.find((p: { name: string }) => p.name === "system-prompt"); + expect(systemPrompt.location).toBe("system"); + + // Verify repo prompts override system prompts + const sharedPrompt = prompts.find((p: { name: string }) => p.name === "shared-prompt"); + expect(sharedPrompt.location).toBe("repo"); + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + } + }, + 15000 + ); + + test.concurrent( + "should read prompt content from correct location", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + // Create a workspace + const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); + const createResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_CREATE, + tempGitRepo, + "test-read-prompts", + trunkBranch + ); + expect(createResult.success).toBe(true); + + const workspaceId = createResult.metadata.id; + + // Create system-level prompt + const systemPromptsDir = path.join(env.config.rootDir, "prompts"); + await fs.mkdir(systemPromptsDir, { recursive: true }); + const systemContent = "System prompt content"; + await fs.writeFile(path.join(systemPromptsDir, "test-prompt.md"), systemContent); + + // Create repo-level prompt (should override) + const repoPromptsDir = path.join(createResult.metadata.namedWorkspacePath, ".cmux"); + await fs.mkdir(repoPromptsDir, { recursive: true }); + const repoContent = "Repo prompt content (overrides system)"; + await fs.writeFile(path.join(repoPromptsDir, "test-prompt.md"), repoContent); + + // Read prompt - should get repo version + const content = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.PROMPTS_READ, + workspaceId, + "test-prompt" + ); + + expect(content).toBe(repoContent); + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + } + }, + 15000 + ); + + test.concurrent( + "should return null for non-existent prompt", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + // Create a workspace + const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); + const createResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_CREATE, + tempGitRepo, + "test-missing-prompts", + trunkBranch + ); + expect(createResult.success).toBe(true); + + const workspaceId = createResult.metadata.id; + + // Try to read non-existent prompt + const content = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.PROMPTS_READ, + workspaceId, + "non-existent-prompt" + ); + + expect(content).toBeNull(); + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + } + }, + 15000 + ); + + test.concurrent( + "should return empty list for workspace without prompts", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + // Create a workspace + const trunkBranch = await detectDefaultTrunkBranch(tempGitRepo); + const createResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_CREATE, + tempGitRepo, + "test-no-prompts", + trunkBranch + ); + expect(createResult.success).toBe(true); + + const workspaceId = createResult.metadata.id; + + // List prompts (should be empty) + const prompts = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROMPTS_LIST, workspaceId); + + expect(prompts).toEqual([]); + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + } + }, + 15000 + ); +});