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:
2025-12-19 06:58:05 +01:00
parent f133ad0576
commit 6df37b8a08
9 changed files with 248 additions and 585 deletions

View File

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

View File

@@ -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 */}

View File

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