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

@@ -717,7 +717,8 @@
## Item #63
- **Date:** 2026-02-13
- **Priority:** medium
- **Status:** open
- **Status:** done
- **Completed:** 2026-02-14 — commit `bd5ff6b`, tagged `v1.49.0`
- **Description:** Context compaction button — Add a button in the PinchChat UI to trigger OpenClaw's context summarize/compaction. When a session's token usage is high (e.g. near the limit), the user can click to compact the conversation history, summarizing older messages to free up context window space. OpenClaw should expose an API/tool for this. (Feedback from Bardak)
## Item #64

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

View File

@@ -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
View 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');
}