feat: Add dynamic slash commands from Claude with UI improvements
- Load available commands from Claude at session start (control_initialized event) - Display commands in autocomplete dropdown with search in name and description - Group commands into "UI Commands" and "Claude Commands" sections - Shorten display names by removing common prefixes (taches-cc-resources:, claude-mem:) - Blacklist TUI-only commands (vim, terminal-setup, ide, etc.) - Add max-height with scrollbar for long command lists - Implement auto-scroll to keep selected command visible during keyboard navigation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,27 +36,43 @@ function saveHistory(history) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Available slash commands for autocomplete
|
||||
const COMMANDS = [
|
||||
{ name: 'compact', description: 'Compact context (summarize conversation)' },
|
||||
{ name: 'help', description: 'Show available commands' },
|
||||
{ name: 'clear', description: 'Clear chat history' },
|
||||
{ name: 'export', description: 'Export chat as Markdown' },
|
||||
{ name: 'scroll', description: 'Scroll to top or bottom' },
|
||||
{ name: 'new', description: 'Start a new session' },
|
||||
{ name: 'info', description: 'Show session info' },
|
||||
{ name: 'history', description: 'Search input history (/history <term>)' },
|
||||
// Built-in UI slash commands (handled locally)
|
||||
const UI_COMMANDS = [
|
||||
{ name: 'compact', description: 'Compact context (summarize conversation)', isBuiltin: true },
|
||||
{ name: 'help', description: 'Show available commands', isBuiltin: true },
|
||||
{ name: 'clear', description: 'Clear chat history', isBuiltin: true },
|
||||
{ name: 'export', description: 'Export chat as Markdown', isBuiltin: true },
|
||||
{ name: 'scroll', description: 'Scroll to top or bottom', isBuiltin: true },
|
||||
{ name: 'new', description: 'Start a new session', isBuiltin: true },
|
||||
{ name: 'info', description: 'Show session info', isBuiltin: true },
|
||||
{ name: 'history', description: 'Search input history (/history <term>)', isBuiltin: true },
|
||||
];
|
||||
|
||||
export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isProcessing, placeholder, sessionId }) {
|
||||
// Commands that don't make sense in the web UI (TUI-only or handled differently)
|
||||
const COMMAND_BLACKLIST = [
|
||||
'help', // We have our own help
|
||||
'clear', // We have our own clear
|
||||
'config', // TUI config
|
||||
'model', // Model selection via UI
|
||||
'permissions', // Handled via permission mode selector
|
||||
'login', // Auth handled differently
|
||||
'logout', // Auth handled differently
|
||||
'status', // TUI status
|
||||
'vim', // TUI vim mode
|
||||
'terminal-setup', // TUI terminal setup
|
||||
'ide', // TUI IDE integration
|
||||
];
|
||||
|
||||
export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isProcessing, placeholder, sessionId, availableCommands = [] }) {
|
||||
// Use uncontrolled input for performance - no React re-render on every keystroke
|
||||
const textareaRef = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const commandRefs = useRef([]);
|
||||
|
||||
// These states don't change on every keystroke, so they're fine
|
||||
const [showCommands, setShowCommands] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [filteredCommands, setFilteredCommands] = useState(COMMANDS);
|
||||
const [filteredCommands, setFilteredCommands] = useState(UI_COMMANDS);
|
||||
const [inputHistory] = useState(() => loadHistory());
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const [savedInput, setSavedInput] = useState('');
|
||||
@@ -296,6 +312,16 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-scroll to keep selected command visible
|
||||
useEffect(() => {
|
||||
if (showCommands && commandRefs.current[selectedIndex]) {
|
||||
commandRefs.current[selectedIndex].scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, [selectedIndex, showCommands]);
|
||||
|
||||
// Handle input changes for slash command detection (lightweight, no state updates for value)
|
||||
const handleInput = useCallback(() => {
|
||||
const value = textareaRef.current?.value || '';
|
||||
@@ -303,8 +329,23 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
||||
// Show command menu when typing "/" at the start
|
||||
if (value.startsWith('/')) {
|
||||
const query = value.slice(1).toLowerCase();
|
||||
const filtered = COMMANDS.filter(cmd =>
|
||||
cmd.name.toLowerCase().includes(query)
|
||||
|
||||
// Combine UI commands with Claude commands (filtered by blacklist)
|
||||
const claudeCommands = (availableCommands || [])
|
||||
.filter(cmd => !COMMAND_BLACKLIST.includes(cmd.name))
|
||||
.map(cmd => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
argumentHint: cmd.argumentHint,
|
||||
isBuiltin: false
|
||||
}));
|
||||
|
||||
const allCommands = [...UI_COMMANDS, ...claudeCommands];
|
||||
|
||||
// Search in both name and description
|
||||
const filtered = allCommands.filter(cmd =>
|
||||
cmd.name.toLowerCase().includes(query) ||
|
||||
cmd.description.toLowerCase().includes(query)
|
||||
);
|
||||
setFilteredCommands(filtered);
|
||||
setShowCommands(filtered.length > 0);
|
||||
@@ -312,7 +353,7 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
||||
} else {
|
||||
setShowCommands(false);
|
||||
}
|
||||
}, []);
|
||||
}, [availableCommands]);
|
||||
|
||||
return (
|
||||
<form
|
||||
@@ -421,20 +462,61 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
||||
<div className="flex-1 relative">
|
||||
{/* Command autocomplete dropdown */}
|
||||
{showCommands && (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-2 bg-dark-800 border border-dark-700 rounded-lg shadow-xl overflow-hidden z-10">
|
||||
{filteredCommands.map((cmd, index) => (
|
||||
<button
|
||||
key={cmd.name}
|
||||
type="button"
|
||||
onClick={() => selectCommand(cmd.name)}
|
||||
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors
|
||||
${index === selectedIndex ? 'bg-orange-600/20 text-orange-400' : 'hover:bg-dark-700 text-dark-200'}`}
|
||||
>
|
||||
<Command className="w-4 h-4 text-dark-500" />
|
||||
<span className="font-medium">/{cmd.name}</span>
|
||||
<span className="text-dark-500 text-sm">{cmd.description}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="absolute bottom-full left-0 right-0 mb-2 bg-dark-800 border border-dark-700 rounded-lg shadow-xl overflow-hidden z-10 max-h-80 overflow-y-auto">
|
||||
{/* UI Commands section */}
|
||||
{filteredCommands.filter(c => c.isBuiltin).length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-1 text-[10px] uppercase tracking-wider text-dark-500 bg-dark-850 border-b border-dark-700">
|
||||
UI Commands
|
||||
</div>
|
||||
{filteredCommands.map((cmd, index) => {
|
||||
if (!cmd.isBuiltin) return null;
|
||||
return (
|
||||
<button
|
||||
key={cmd.name}
|
||||
ref={el => commandRefs.current[index] = el}
|
||||
type="button"
|
||||
onClick={() => selectCommand(cmd.name)}
|
||||
className={`w-full px-3 py-1.5 flex items-center gap-2 text-left transition-colors text-xs
|
||||
${index === selectedIndex ? 'bg-orange-600/20 text-orange-400' : 'hover:bg-dark-700 text-dark-200'}`}
|
||||
>
|
||||
<Command className="w-3 h-3 text-dark-500 flex-shrink-0" />
|
||||
<span className="font-medium text-dark-100">/{cmd.name}</span>
|
||||
<span className="text-dark-500 truncate">{cmd.description}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{/* Claude Commands section */}
|
||||
{filteredCommands.filter(c => !c.isBuiltin).length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-1 text-[10px] uppercase tracking-wider text-dark-500 bg-dark-850 border-b border-dark-700 border-t">
|
||||
Claude Commands
|
||||
</div>
|
||||
{filteredCommands.map((cmd, index) => {
|
||||
if (cmd.isBuiltin) return null;
|
||||
// Shorten display name by removing common prefixes
|
||||
const displayName = cmd.name
|
||||
.replace(/^taches-cc-resources:/, '')
|
||||
.replace(/^claude-mem:/, '');
|
||||
return (
|
||||
<button
|
||||
key={cmd.name}
|
||||
ref={el => commandRefs.current[index] = el}
|
||||
type="button"
|
||||
onClick={() => selectCommand(cmd.name)}
|
||||
className={`w-full px-3 py-1.5 flex items-center gap-2 text-left transition-colors text-xs
|
||||
${index === selectedIndex ? 'bg-orange-600/20 text-orange-400' : 'hover:bg-dark-700 text-dark-200'}`}
|
||||
>
|
||||
<Command className="w-3 h-3 text-orange-500/50 flex-shrink-0" />
|
||||
<span className="font-medium text-dark-100 flex-shrink-0">/{displayName}</span>
|
||||
<span className="text-dark-500 truncate">{cmd.description}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Bot } from 'lucide-react';
|
||||
|
||||
// Wrapper to compute placeholder inside and prevent parent re-renders from affecting input
|
||||
const MemoizedChatInput = memo(function MemoizedChatInput({
|
||||
onSend, onStop, disabled, isProcessing, sessionId, connected, active
|
||||
onSend, onStop, disabled, isProcessing, sessionId, connected, active, availableCommands
|
||||
}) {
|
||||
const placeholder = !connected
|
||||
? 'Connecting...'
|
||||
@@ -25,6 +25,7 @@ const MemoizedChatInput = memo(function MemoizedChatInput({
|
||||
isProcessing={isProcessing}
|
||||
sessionId={sessionId}
|
||||
placeholder={placeholder}
|
||||
availableCommands={availableCommands}
|
||||
/>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
@@ -36,7 +37,8 @@ const MemoizedChatInput = memo(function MemoizedChatInput({
|
||||
prevProps.connected === nextProps.connected &&
|
||||
prevProps.active === nextProps.active &&
|
||||
prevProps.onSend === nextProps.onSend &&
|
||||
prevProps.onStop === nextProps.onStop
|
||||
prevProps.onStop === nextProps.onStop &&
|
||||
prevProps.availableCommands === nextProps.availableCommands
|
||||
);
|
||||
});
|
||||
|
||||
@@ -260,6 +262,7 @@ export const ChatPanel = memo(function ChatPanel({ sessionId }) {
|
||||
sessionId={session.claudeSessionId}
|
||||
connected={session.connected}
|
||||
active={session.active}
|
||||
availableCommands={session.availableCommands}
|
||||
/>
|
||||
|
||||
{/* Permission Dialog */}
|
||||
|
||||
@@ -38,6 +38,7 @@ function createSessionState(id, host = 'neko', project = '/home/sumdex/projects'
|
||||
},
|
||||
permissionMode: 'default',
|
||||
controlInitialized: false,
|
||||
availableCommands: [], // Slash commands from Claude
|
||||
pendingPermission: null,
|
||||
unreadCount: 0,
|
||||
currentContext: null, // For dynamic tab naming
|
||||
@@ -156,11 +157,12 @@ export function SessionProvider({ children }) {
|
||||
const updateSession = useCallback((sessionId, updates) => {
|
||||
setSessions(prev => {
|
||||
if (!prev[sessionId]) return prev;
|
||||
const newUpdates = typeof updates === 'function' ? updates(prev[sessionId]) : updates;
|
||||
return {
|
||||
...prev,
|
||||
[sessionId]: {
|
||||
...prev[sessionId],
|
||||
...(typeof updates === 'function' ? updates(prev[sessionId]) : updates),
|
||||
...newUpdates,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -444,6 +446,7 @@ export function SessionProvider({ children }) {
|
||||
case 'control_initialized': {
|
||||
updateSession(sessionId, {
|
||||
controlInitialized: true,
|
||||
availableCommands: data.commands || [],
|
||||
});
|
||||
// Restore saved permission mode after control is initialized
|
||||
// Read from localStorage since sessions state may be stale in this callback
|
||||
|
||||
Reference in New Issue
Block a user