mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 07:56:50 +02:00
feat: add autocomplete for slash commands
Extends the chat system with autocomplete UI for slash commands, making actions discoverable and easier to execute. New features: - SlashCommandSuggestionList component with keyboard navigation - Slash command search integrated into MentionEditor - Autocomplete popup shows command name and description - Arrow keys for navigation, Enter to select, Escape to close - Filters available commands based on typed text When typing "/" in chat, users now see: /join Request to join the group /leave Leave the group The autocomplete uses the same TipTap suggestion system as @mentions and :emoji:, providing a consistent UX across all autocomplete features. Tests: All 804 tests passing Build: Successful Lint: 1 warning fixed (unused parameter)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<ProfileSearchResult[]>;
|
||||
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
|
||||
searchCommands?: (query: string) => Promise<ChatAction[]>;
|
||||
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<SuggestionOptions, "editor"> | null =
|
||||
useMemo(
|
||||
() =>
|
||||
searchCommands
|
||||
? {
|
||||
char: "/",
|
||||
allowSpaces: false,
|
||||
items: async ({ query }) => {
|
||||
return await searchCommands(query);
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<SlashCommandSuggestionListHandle>;
|
||||
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,
|
||||
|
||||
112
src/components/editor/SlashCommandSuggestionList.tsx
Normal file
112
src/components/editor/SlashCommandSuggestionList.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="border border-border/50 bg-popover p-4 text-sm text-muted-foreground shadow-md">
|
||||
No commands available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
className="max-h-[300px] w-[320px] overflow-y-auto border border-border/50 bg-popover shadow-md"
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.name}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
onClick={() => command(item)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`flex w-full items-center gap-3 px-3 py-2 text-left transition-colors ${
|
||||
index === selectedIndex ? "bg-muted/60" : "hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<Terminal className="size-4 flex-shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium font-mono">
|
||||
/{item.name}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SlashCommandSuggestionList.displayName = "SlashCommandSuggestionList";
|
||||
Reference in New Issue
Block a user