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:
Claude
2026-01-12 20:46:51 +00:00
parent bf1ffe5b0f
commit fa88370c0c
3 changed files with 266 additions and 1 deletions

View File

@@ -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);

View File

@@ -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,

View 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";