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:
@@ -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')}
|
||||
|
||||
110
src/components/SlashCommands.tsx
Normal file
110
src/components/SlashCommands.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -151,6 +151,14 @@ const en = {
|
||||
'chat.bookmarks': 'Bookmarks',
|
||||
'chat.export': 'Export conversation',
|
||||
'chat.contextCompacted': 'Context compacted — older messages cached locally',
|
||||
'slash.commands': 'Commands',
|
||||
'slash.status': 'Show session status & usage',
|
||||
'slash.reasoning': 'Toggle reasoning mode',
|
||||
'slash.verbose': 'Toggle verbose output',
|
||||
'slash.model': 'Switch model for this session',
|
||||
'slash.compact': 'Compact conversation context',
|
||||
'slash.reset': 'Reset the session',
|
||||
'slash.help': 'Show available commands',
|
||||
} as const;
|
||||
|
||||
const fr: Record<keyof typeof en, string> = {
|
||||
@@ -281,6 +289,14 @@ const fr: Record<keyof typeof en, string> = {
|
||||
'chat.bookmarks': 'Marque-pages',
|
||||
'chat.export': 'Exporter la conversation',
|
||||
'chat.contextCompacted': 'Contexte compacté — anciens messages en cache local',
|
||||
'slash.commands': 'Commandes',
|
||||
'slash.status': 'Afficher le statut et l\'utilisation',
|
||||
'slash.reasoning': 'Activer/désactiver le raisonnement',
|
||||
'slash.verbose': 'Activer/désactiver le mode verbeux',
|
||||
'slash.model': 'Changer de modèle pour cette session',
|
||||
'slash.compact': 'Compacter le contexte',
|
||||
'slash.reset': 'Réinitialiser la session',
|
||||
'slash.help': 'Afficher les commandes disponibles',
|
||||
};
|
||||
|
||||
export type TranslationKey = keyof typeof en;
|
||||
|
||||
5
src/lib/slashUtils.ts
Normal file
5
src/lib/slashUtils.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/** Check if slash command menu should be shown */
|
||||
export function shouldShowSlashMenu(text: string): boolean {
|
||||
const trimmed = text.trimStart();
|
||||
return trimmed.startsWith('/') && !trimmed.includes('\n');
|
||||
}
|
||||
Reference in New Issue
Block a user