feat: slash command autocomplete in chat input

Type '/' to see available OpenClaw commands (/status, /reasoning,
/verbose, /model, /compact, /reset, /help) with descriptions.
Navigate with arrow keys, select with Tab/Enter.
Supports EN/FR i18n.
This commit is contained in:
Nicolas Varrot
2026-02-14 15:57:39 +00:00
parent 2138cbd124
commit be631a4df7
5 changed files with 145 additions and 3 deletions

View File

@@ -2,6 +2,8 @@ import { useState, useRef, useEffect, useCallback, lazy, Suspense } from 'react'
import { Send, Square, Paperclip, X, FileText, Eye, EyeOff } from 'lucide-react';
import { useT } from '../hooks/useLocale';
import { useSendShortcut } from '../hooks/useSendShortcut';
import { SlashCommandMenu } from './SlashCommands';
import { shouldShowSlashMenu } from '../lib/slashUtils';
const ReactMarkdown = lazy(() => import('react-markdown'));
const remarkGfm = import('remark-gfm').then(m => m.default);
@@ -93,6 +95,7 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
const [files, setFiles] = useState<FileAttachment[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const [showPreview, setShowPreview] = useState(() => localStorage.getItem('pinchchat-md-preview') === '1');
const [showSlash, setShowSlash] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -176,6 +179,7 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
onSend(trimmed || ' ', attachments);
setText('');
setFiles([]);
setShowSlash(false);
// Clear draft for this session after sending
if (sessionKey) draftsRef.current.delete(sessionKey);
};
@@ -241,7 +245,13 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
onDrop={handleDrop}
>
<div className="max-w-4xl mx-auto">
<div className={`rounded-3xl border bg-[var(--pc-bg-surface)]/40 p-3 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] transition-colors ${isDragOver ? 'border-[var(--pc-accent-dim)] bg-[var(--pc-accent-glow)]' : 'border-pc-border'}`}>
<div className={`relative rounded-3xl border bg-[var(--pc-bg-surface)]/40 p-3 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] transition-colors ${isDragOver ? 'border-[var(--pc-accent-dim)] bg-[var(--pc-accent-glow)]' : 'border-pc-border'}`}>
<SlashCommandMenu
query={text}
visible={showSlash}
onSelect={(cmd) => { setText(cmd); setShowSlash(shouldShowSlashMenu(cmd)); textareaRef.current?.focus(); }}
onClose={() => setShowSlash(false)}
/>
{/* File previews */}
{files.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3 px-1">
@@ -309,7 +319,7 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
id="chat-input"
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onChange={(e) => { setText(e.target.value); setShowSlash(shouldShowSlashMenu(e.target.value)); }}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={t('chat.inputPlaceholder')}

View File

@@ -0,0 +1,110 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useT } from '../hooks/useLocale';
import type { TranslationKey } from '../lib/i18n';
export interface SlashCommand {
command: string;
args?: string;
descKey: TranslationKey;
}
const COMMANDS: SlashCommand[] = [
{ command: '/status', descKey: 'slash.status' },
{ command: '/reasoning', args: 'on|off|stream', descKey: 'slash.reasoning' },
{ command: '/verbose', args: 'on|off', descKey: 'slash.verbose' },
{ command: '/model', args: '<model>', descKey: 'slash.model' },
{ command: '/compact', descKey: 'slash.compact' },
{ command: '/reset', descKey: 'slash.reset' },
{ command: '/help', descKey: 'slash.help' },
];
interface Props {
query: string;
visible: boolean;
onSelect: (command: string) => void;
onClose: () => void;
}
export function SlashCommandMenu({ query, visible, onSelect, onClose }: Props) {
const t = useT();
const [selectedIndex, setSelectedIndex] = useState(0);
const menuRef = useRef<HTMLDivElement>(null);
const filtered = COMMANDS.filter(cmd =>
cmd.command.startsWith(query.toLowerCase().split(' ')[0] || '/')
);
// Reset selection when query changes
useEffect(() => {
setSelectedIndex(0); // eslint-disable-line react-hooks/set-state-in-effect -- intentional: reset index on query change
}, [query]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (!visible || filtered.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(i => (i + 1) % filtered.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(i => (i - 1 + filtered.length) % filtered.length);
} else if (e.key === 'Tab' || e.key === 'Enter') {
if (filtered.length > 0) {
e.preventDefault();
e.stopPropagation();
const cmd = filtered[selectedIndex];
onSelect(cmd.args ? cmd.command + ' ' : cmd.command);
}
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}, [visible, filtered, selectedIndex, onSelect, onClose]);
useEffect(() => {
if (visible) {
document.addEventListener('keydown', handleKeyDown, true);
return () => document.removeEventListener('keydown', handleKeyDown, true);
}
}, [visible, handleKeyDown]);
useEffect(() => {
if (menuRef.current) {
const item = menuRef.current.children[selectedIndex] as HTMLElement;
item?.scrollIntoView({ block: 'nearest' });
}
}, [selectedIndex]);
if (!visible || filtered.length === 0) return null;
return (
<div
ref={menuRef}
className="absolute bottom-full left-0 right-0 mb-2 mx-3 max-h-[200px] overflow-y-auto rounded-2xl border border-pc-border bg-[var(--pc-bg-surface)] backdrop-blur-xl shadow-lg z-50"
role="listbox"
aria-label={t('slash.commands')}
>
{filtered.map((cmd, i) => (
<button
key={cmd.command}
role="option"
aria-selected={i === selectedIndex}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors ${
i === selectedIndex
? 'bg-[var(--pc-accent-glow)] text-pc-text'
: 'text-pc-text-secondary hover:bg-[var(--pc-hover)]'
}`}
onMouseDown={(e) => {
e.preventDefault();
onSelect(cmd.args ? cmd.command + ' ' : cmd.command);
}}
onMouseEnter={() => setSelectedIndex(i)}
>
<span className="font-mono font-semibold text-pc-accent-light">{cmd.command}</span>
{cmd.args && <span className="text-xs text-pc-text-muted font-mono">{cmd.args}</span>}
<span className="ml-auto text-xs text-pc-text-muted">{t(cmd.descKey)}</span>
</button>
))}
</div>
);
}