diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index 8fa4eee..babb434 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -332,6 +332,18 @@ export function ChatViewer({ // Get the appropriate adapter for this protocol const adapter = useMemo(() => getAdapter(protocol), [protocol]); + // Slash command search for action autocomplete + const searchCommands = useCallback( + async (query: string) => { + const availableActions = adapter.getActions(); + const lowerQuery = query.toLowerCase(); + return availableActions.filter((action) => + action.name.toLowerCase().includes(lowerQuery), + ); + }, + [adapter], + ); + // State for retry trigger const [retryCount, setRetryCount] = useState(0); @@ -800,6 +812,7 @@ export function ChatViewer({ placeholder="Type a message..." searchProfiles={searchProfiles} searchEmojis={searchEmojis} + searchCommands={searchCommands} onSubmit={(content, emojiTags) => { if (content.trim()) { handleSend(content, replyTo, emojiTags); diff --git a/src/components/editor/MentionEditor.tsx b/src/components/editor/MentionEditor.tsx index 1d592af..a4b31cf 100644 --- a/src/components/editor/MentionEditor.tsx +++ b/src/components/editor/MentionEditor.tsx @@ -23,8 +23,13 @@ import { EmojiSuggestionList, type EmojiSuggestionListHandle, } from "./EmojiSuggestionList"; +import { + SlashCommandSuggestionList, + type SlashCommandSuggestionListHandle, +} from "./SlashCommandSuggestionList"; import type { ProfileSearchResult } from "@/services/profile-search"; import type { EmojiSearchResult } from "@/services/emoji-search"; +import type { ChatAction } from "@/types/chat-actions"; import { nip19 } from "nostr-tools"; /** @@ -50,6 +55,7 @@ export interface MentionEditorProps { onSubmit?: (content: string, emojiTags: EmojiTag[]) => void; searchProfiles: (query: string) => Promise; searchEmojis?: (query: string) => Promise; + searchCommands?: (query: string) => Promise; autoFocus?: boolean; className?: string; } @@ -152,6 +158,7 @@ export const MentionEditor = forwardRef< onSubmit, searchProfiles, searchEmojis, + searchCommands, autoFocus = false, className = "", }, @@ -335,6 +342,101 @@ export const MentionEditor = forwardRef< [searchEmojis], ); + // Create slash command suggestion configuration for / commands + const slashCommandSuggestion: Omit | null = + useMemo( + () => + searchCommands + ? { + char: "/", + allowSpaces: false, + items: async ({ query }) => { + return await searchCommands(query); + }, + render: () => { + let component: ReactRenderer; + let popup: TippyInstance[]; + let editorRef: any; + + return { + onStart: (props) => { + editorRef = props.editor; + component = new ReactRenderer( + SlashCommandSuggestionList, + { + props: { + items: props.items, + command: props.command, + onClose: () => { + popup[0]?.hide(); + }, + }, + editor: props.editor, + }, + ); + + if (!props.clientRect) { + return; + } + + popup = tippy("body", { + getReferenceClientRect: + props.clientRect as () => DOMRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "top-start", + }); + }, + + onUpdate(props) { + component.updateProps({ + items: props.items, + command: props.command, + }); + + if (!props.clientRect) { + return; + } + + popup[0]?.setProps({ + getReferenceClientRect: + props.clientRect as () => DOMRect, + }); + }, + + onKeyDown(props) { + if (props.event.key === "Escape") { + popup[0]?.hide(); + return true; + } + + // Ctrl/Cmd+Enter submits the message + if ( + props.event.key === "Enter" && + (props.event.ctrlKey || props.event.metaKey) + ) { + popup[0]?.hide(); + handleSubmitRef.current(editorRef); + return true; + } + + return component.ref?.onKeyDown(props.event) ?? false; + }, + + onExit() { + popup[0]?.destroy(); + component.destroy(); + }, + }; + }, + } + : null, + [searchCommands], + ); + // Helper function to serialize editor content with mentions and emojis const serializeContent = useCallback( (editorInstance: any): SerializedContent => { @@ -503,8 +605,46 @@ export const MentionEditor = forwardRef< ); } + // Add slash command extension if search is provided + if (slashCommandSuggestion) { + const SlashCommand = Mention.extend({ + name: "slashCommand", + }); + + exts.push( + SlashCommand.configure({ + HTMLAttributes: { + class: "slash-command", + }, + suggestion: { + ...slashCommandSuggestion, + command: ({ editor, props }: any) => { + // props is the ChatAction + // Replace the entire content with just the command + editor + .chain() + .focus() + .deleteRange({ from: 0, to: editor.state.doc.content.size }) + .insertContentAt(0, [ + { type: "text", text: `/${props.name}` }, + ]) + .run(); + }, + }, + renderLabel({ node }) { + return `/${node.attrs.label}`; + }, + }), + ); + } + return exts; - }, [mentionSuggestion, emojiSuggestion, placeholder]); + }, [ + mentionSuggestion, + emojiSuggestion, + slashCommandSuggestion, + placeholder, + ]); const editor = useEditor({ extensions, diff --git a/src/components/editor/SlashCommandSuggestionList.tsx b/src/components/editor/SlashCommandSuggestionList.tsx new file mode 100644 index 0000000..3f383c5 --- /dev/null +++ b/src/components/editor/SlashCommandSuggestionList.tsx @@ -0,0 +1,112 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; +import type { ChatAction } from "@/types/chat-actions"; +import { Terminal } from "lucide-react"; + +export interface SlashCommandSuggestionListProps { + items: ChatAction[]; + command: (item: ChatAction) => void; + onClose?: () => void; +} + +export interface SlashCommandSuggestionListHandle { + onKeyDown: (event: KeyboardEvent) => boolean; +} + +export const SlashCommandSuggestionList = forwardRef< + SlashCommandSuggestionListHandle, + SlashCommandSuggestionListProps +>(({ items, command, onClose }, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0); + const listRef = useRef(null); + + // Keyboard navigation + useImperativeHandle(ref, () => ({ + onKeyDown: (event: KeyboardEvent) => { + if (event.key === "ArrowUp") { + setSelectedIndex((prev) => (prev + items.length - 1) % items.length); + return true; + } + + if (event.key === "ArrowDown") { + setSelectedIndex((prev) => (prev + 1) % items.length); + return true; + } + + if (event.key === "Enter" && !event.ctrlKey && !event.metaKey) { + if (items[selectedIndex]) { + command(items[selectedIndex]); + } + return true; + } + + if (event.key === "Escape") { + onClose?.(); + return true; + } + + return false; + }, + })); + + // Scroll selected item into view + useEffect(() => { + const selectedElement = listRef.current?.children[selectedIndex]; + if (selectedElement) { + selectedElement.scrollIntoView({ + block: "nearest", + }); + } + }, [selectedIndex]); + + // Reset selected index when items change + useEffect(() => { + setSelectedIndex(0); + }, [items]); + + if (items.length === 0) { + return ( +
+ No commands available +
+ ); + } + + return ( +
+ {items.map((item, index) => ( + + ))} +
+ ); +}); + +SlashCommandSuggestionList.displayName = "SlashCommandSuggestionList";