Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions PROMPTS_FEATURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Custom Prompt Mentions (@)

## Overview
You can now reference reusable prompt templates using `@mention` syntax in the chat input. These prompts are expanded before being sent to the AI.

## How to Use

1. **Type `@` in the chat input** - An autocomplete menu will appear showing available prompts
2. **Start typing a prompt name** - The list will filter to matching prompts
3. **Select a prompt** - Use Tab, arrow keys, or click to insert the prompt name
4. **Send your message** - The `@prompt-name` will be automatically replaced with the prompt's content

## Example
```
@explain React hooks
```

When sent, this becomes:
```
Please provide a clear, comprehensive explanation of the topic.

Include:
- Key concepts
- Examples where applicable
- Common pitfalls or misunderstandings

React hooks
```

## Creating Prompts

### System-wide prompts
Create `.md` files in `~/.cmux/prompts/`:
```bash
echo "Your prompt content here" > ~/.cmux/prompts/my-prompt.md
```

### Repository-specific prompts
Create `.md` files in your project's `.cmux/` directory:
```bash
mkdir -p .cmux
echo "Your repo-specific prompt" > .cmux/review.md
```

## Prompt Priority
- Repository prompts (`.cmux/`) override system prompts (`~/.cmux/prompts/`)
- This allows per-project customization

## Features
- **Autocomplete**: Shows all available prompts with filtering
- **Keyboard navigation**: Tab to complete, arrow keys to navigate, Esc to dismiss
- **Visual indicators**: 📁 for repo prompts, 🏠 for system prompts
- **Multiple mentions**: Use multiple `@prompts` in a single message
- **Markdown support**: Prompts can contain any markdown content
4 changes: 4 additions & 0 deletions src/App.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ function setupMockAPI(options: {
install: () => undefined,
onStatus: () => () => undefined,
},
prompts: {
list: () => Promise.resolve([]),
read: () => Promise.resolve(null),
},
...options.apiOverrides,
};

Expand Down
4 changes: 4 additions & 0 deletions src/browser/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,10 @@ 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") {
Expand Down
117 changes: 112 additions & 5 deletions src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -24,6 +25,12 @@ import {
getSlashCommandSuggestions,
type SlashSuggestion,
} from "@/utils/slashCommands/suggestions";
import {
getPromptSuggestions,
extractPromptMentions,
expandPromptMentions,
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";
Expand Down Expand Up @@ -82,6 +89,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({
const [isSending, setIsSending] = useState(false);
const [showCommandSuggestions, setShowCommandSuggestions] = useState(false);
const [commandSuggestions, setCommandSuggestions] = useState<SlashSuggestion[]>([]);
const [showPromptSuggestions, setShowPromptSuggestions] = useState(false);
const [promptSuggestions, setPromptSuggestions] = useState<PromptSuggestion[]>([]);
const [availablePrompts, setAvailablePrompts] = useState<
Array<{ name: string; path: string; location: "repo" | "system" }>
>([]);
const [providerNames, setProviderNames] = useState<string[]>([]);
const [toast, setToast] = useState<Toast | null>(null);
const [imageAttachments, setImageAttachments] = useState<ImageAttachment[]>([]);
Expand All @@ -93,6 +105,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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)
Expand Down Expand Up @@ -205,6 +218,13 @@ export const ChatInput: React.FC<ChatInputProps> = ({
setShowCommandSuggestions(suggestions.length > 0);
}, [input, providerNames]);

// Watch input for prompt mentions (@)
useEffect(() => {
const suggestions = getPromptSuggestions(input, availablePrompts);
setPromptSuggestions(suggestions);
setShowPromptSuggestions(suggestions.length > 0);
}, [input, availablePrompts]);

// Load provider names for suggestions
useEffect(() => {
let isMounted = true;
Expand All @@ -227,6 +247,28 @@ export const ChatInput: React.FC<ChatInputProps> = ({
};
}, []);

// Load available prompts for the current 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]);

// Allow external components (e.g., CommandPalette) to insert text
useEffect(() => {
const handler = (e: Event) => {
Expand Down Expand Up @@ -339,13 +381,48 @@ export const ChatInput: React.FC<ChatInputProps> = ({
[setInput]
);

const handlePromptSelect = useCallback(
(suggestion: PromptSuggestion) => {
// Replace the "@partial" with "@full-name"
const lastAtIndex = input.lastIndexOf("@");
if (lastAtIndex !== -1) {
const before = input.slice(0, lastAtIndex);
const after = input.slice(lastAtIndex);
// Find where the partial mention ends (space or end of string)
const spaceIndex = after.indexOf(" ");
const afterMention = spaceIndex === -1 ? "" : after.slice(spaceIndex);
setInput(`${before}${suggestion.replacement}${afterMention}`);
}
setShowPromptSuggestions(false);
inputRef.current?.focus();
},
[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
const mentions = extractPromptMentions(messageText);
if (mentions.length > 0) {
const promptContents = new Map<string, string>();
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);
}
}
messageText = expandPromptMentions(messageText, promptContents);
}

try {
// Parse command
Expand Down Expand Up @@ -663,7 +740,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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
Expand All @@ -675,6 +752,15 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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();
Expand Down Expand Up @@ -717,6 +803,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({
ariaLabel="Slash command suggestions"
listId={commandListId}
/>
<PromptSuggestions
suggestions={promptSuggestions}
onSelectSuggestion={handlePromptSelect}
onDismiss={() => setShowPromptSuggestions(false)}
isVisible={showPromptSuggestions}
ariaLabel="Prompt mention suggestions"
listId={promptListId}
/>
<div className="flex items-end gap-2.5" data-component="ChatInputControls">
<VimTextArea
ref={inputRef}
Expand All @@ -728,15 +822,28 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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}
/>
</div>
<ImageAttachments images={imageAttachments} onRemove={handleRemoveImage} />
Expand Down
Loading
Loading