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