diff --git a/FEEDBACK.md b/FEEDBACK.md index 1cd6e2d..50e5d61 100644 --- a/FEEDBACK.md +++ b/FEEDBACK.md @@ -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 diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index f45ad97..930166c 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -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([]); const [isDragOver, setIsDragOver] = useState(false); const [showPreview, setShowPreview] = useState(() => localStorage.getItem('pinchchat-md-preview') === '1'); + const [showSlash, setShowSlash] = useState(false); const textareaRef = useRef(null); const fileInputRef = useRef(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} >
-
+
+ { setText(cmd); setShowSlash(shouldShowSlashMenu(cmd)); textareaRef.current?.focus(); }} + onClose={() => setShowSlash(false)} + /> {/* File previews */} {files.length > 0 && (
@@ -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')} diff --git a/src/components/SlashCommands.tsx b/src/components/SlashCommands.tsx new file mode 100644 index 0000000..d9fddb1 --- /dev/null +++ b/src/components/SlashCommands.tsx @@ -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: '', 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(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 ( +
+ {filtered.map((cmd, i) => ( + + ))} +
+ ); +} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 1afc31b..360c8be 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -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 = { @@ -281,6 +289,14 @@ const fr: Record = { '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; diff --git a/src/lib/slashUtils.ts b/src/lib/slashUtils.ts new file mode 100644 index 0000000..dfdc481 --- /dev/null +++ b/src/lib/slashUtils.ts @@ -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'); +}