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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/claude.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Claude Web UI</title>
<script type="module" crossorigin src="/assets/index-DmNT3Myo.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-4MVeF-MR.css">
<script type="module" crossorigin src="/assets/index-CEBB4MNK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-hrmZ3Zk6.css">
</head>
<body class="bg-dark-950 text-dark-100">
<div id="root"></div>

View File

@@ -806,6 +806,7 @@ wss.on('connection', async (ws, req) => {
if (pending.type === 'initialize') {
isInitialized = true;
console.log(`[${sessionId}] Control protocol initialized`);
console.log(`[${sessionId}] Available commands:`, JSON.stringify(response.response?.commands, null, 2));
// Send available commands/models to frontend
sendToClient('control_initialized', {
commands: response.response?.commands,

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