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:
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
1
backend/public/assets/index-hrmZ3Zk6.css
Normal file
1
backend/public/assets/index-hrmZ3Zk6.css
Normal file
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/claude.svg" />
|
<link rel="icon" type="image/svg+xml" href="/claude.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Claude Web UI</title>
|
<title>Claude Web UI</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DmNT3Myo.js"></script>
|
<script type="module" crossorigin src="/assets/index-CEBB4MNK.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-4MVeF-MR.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-hrmZ3Zk6.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-dark-950 text-dark-100">
|
<body class="bg-dark-950 text-dark-100">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -806,6 +806,7 @@ wss.on('connection', async (ws, req) => {
|
|||||||
if (pending.type === 'initialize') {
|
if (pending.type === 'initialize') {
|
||||||
isInitialized = true;
|
isInitialized = true;
|
||||||
console.log(`[${sessionId}] Control protocol initialized`);
|
console.log(`[${sessionId}] Control protocol initialized`);
|
||||||
|
console.log(`[${sessionId}] Available commands:`, JSON.stringify(response.response?.commands, null, 2));
|
||||||
// Send available commands/models to frontend
|
// Send available commands/models to frontend
|
||||||
sendToClient('control_initialized', {
|
sendToClient('control_initialized', {
|
||||||
commands: response.response?.commands,
|
commands: response.response?.commands,
|
||||||
|
|||||||
@@ -36,27 +36,43 @@ function saveHistory(history) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Available slash commands for autocomplete
|
// Built-in UI slash commands (handled locally)
|
||||||
const COMMANDS = [
|
const UI_COMMANDS = [
|
||||||
{ name: 'compact', description: 'Compact context (summarize conversation)' },
|
{ name: 'compact', description: 'Compact context (summarize conversation)', isBuiltin: true },
|
||||||
{ name: 'help', description: 'Show available commands' },
|
{ name: 'help', description: 'Show available commands', isBuiltin: true },
|
||||||
{ name: 'clear', description: 'Clear chat history' },
|
{ name: 'clear', description: 'Clear chat history', isBuiltin: true },
|
||||||
{ name: 'export', description: 'Export chat as Markdown' },
|
{ name: 'export', description: 'Export chat as Markdown', isBuiltin: true },
|
||||||
{ name: 'scroll', description: 'Scroll to top or bottom' },
|
{ name: 'scroll', description: 'Scroll to top or bottom', isBuiltin: true },
|
||||||
{ name: 'new', description: 'Start a new session' },
|
{ name: 'new', description: 'Start a new session', isBuiltin: true },
|
||||||
{ name: 'info', description: 'Show session info' },
|
{ name: 'info', description: 'Show session info', isBuiltin: true },
|
||||||
{ name: 'history', description: 'Search input history (/history <term>)' },
|
{ 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
|
// Use uncontrolled input for performance - no React re-render on every keystroke
|
||||||
const textareaRef = useRef(null);
|
const textareaRef = useRef(null);
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
|
const commandRefs = useRef([]);
|
||||||
|
|
||||||
// These states don't change on every keystroke, so they're fine
|
// These states don't change on every keystroke, so they're fine
|
||||||
const [showCommands, setShowCommands] = useState(false);
|
const [showCommands, setShowCommands] = useState(false);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [filteredCommands, setFilteredCommands] = useState(COMMANDS);
|
const [filteredCommands, setFilteredCommands] = useState(UI_COMMANDS);
|
||||||
const [inputHistory] = useState(() => loadHistory());
|
const [inputHistory] = useState(() => loadHistory());
|
||||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||||
const [savedInput, setSavedInput] = useState('');
|
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)
|
// Handle input changes for slash command detection (lightweight, no state updates for value)
|
||||||
const handleInput = useCallback(() => {
|
const handleInput = useCallback(() => {
|
||||||
const value = textareaRef.current?.value || '';
|
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
|
// Show command menu when typing "/" at the start
|
||||||
if (value.startsWith('/')) {
|
if (value.startsWith('/')) {
|
||||||
const query = value.slice(1).toLowerCase();
|
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);
|
setFilteredCommands(filtered);
|
||||||
setShowCommands(filtered.length > 0);
|
setShowCommands(filtered.length > 0);
|
||||||
@@ -312,7 +353,7 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
|||||||
} else {
|
} else {
|
||||||
setShowCommands(false);
|
setShowCommands(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [availableCommands]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
@@ -421,20 +462,61 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
|||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
{/* Command autocomplete dropdown */}
|
{/* Command autocomplete dropdown */}
|
||||||
{showCommands && (
|
{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">
|
<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">
|
||||||
{filteredCommands.map((cmd, index) => (
|
{/* 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
|
<button
|
||||||
key={cmd.name}
|
key={cmd.name}
|
||||||
|
ref={el => commandRefs.current[index] = el}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => selectCommand(cmd.name)}
|
onClick={() => selectCommand(cmd.name)}
|
||||||
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors
|
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'}`}
|
${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" />
|
<Command className="w-3 h-3 text-dark-500 flex-shrink-0" />
|
||||||
<span className="font-medium">/{cmd.name}</span>
|
<span className="font-medium text-dark-100">/{cmd.name}</span>
|
||||||
<span className="text-dark-500 text-sm">{cmd.description}</span>
|
<span className="text-dark-500 truncate">{cmd.description}</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Bot } from 'lucide-react';
|
|||||||
|
|
||||||
// Wrapper to compute placeholder inside and prevent parent re-renders from affecting input
|
// Wrapper to compute placeholder inside and prevent parent re-renders from affecting input
|
||||||
const MemoizedChatInput = memo(function MemoizedChatInput({
|
const MemoizedChatInput = memo(function MemoizedChatInput({
|
||||||
onSend, onStop, disabled, isProcessing, sessionId, connected, active
|
onSend, onStop, disabled, isProcessing, sessionId, connected, active, availableCommands
|
||||||
}) {
|
}) {
|
||||||
const placeholder = !connected
|
const placeholder = !connected
|
||||||
? 'Connecting...'
|
? 'Connecting...'
|
||||||
@@ -25,6 +25,7 @@ const MemoizedChatInput = memo(function MemoizedChatInput({
|
|||||||
isProcessing={isProcessing}
|
isProcessing={isProcessing}
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
availableCommands={availableCommands}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, (prevProps, nextProps) => {
|
}, (prevProps, nextProps) => {
|
||||||
@@ -36,7 +37,8 @@ const MemoizedChatInput = memo(function MemoizedChatInput({
|
|||||||
prevProps.connected === nextProps.connected &&
|
prevProps.connected === nextProps.connected &&
|
||||||
prevProps.active === nextProps.active &&
|
prevProps.active === nextProps.active &&
|
||||||
prevProps.onSend === nextProps.onSend &&
|
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}
|
sessionId={session.claudeSessionId}
|
||||||
connected={session.connected}
|
connected={session.connected}
|
||||||
active={session.active}
|
active={session.active}
|
||||||
|
availableCommands={session.availableCommands}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Permission Dialog */}
|
{/* Permission Dialog */}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ function createSessionState(id, host = 'neko', project = '/home/sumdex/projects'
|
|||||||
},
|
},
|
||||||
permissionMode: 'default',
|
permissionMode: 'default',
|
||||||
controlInitialized: false,
|
controlInitialized: false,
|
||||||
|
availableCommands: [], // Slash commands from Claude
|
||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
currentContext: null, // For dynamic tab naming
|
currentContext: null, // For dynamic tab naming
|
||||||
@@ -156,11 +157,12 @@ export function SessionProvider({ children }) {
|
|||||||
const updateSession = useCallback((sessionId, updates) => {
|
const updateSession = useCallback((sessionId, updates) => {
|
||||||
setSessions(prev => {
|
setSessions(prev => {
|
||||||
if (!prev[sessionId]) return prev;
|
if (!prev[sessionId]) return prev;
|
||||||
|
const newUpdates = typeof updates === 'function' ? updates(prev[sessionId]) : updates;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[sessionId]: {
|
[sessionId]: {
|
||||||
...prev[sessionId],
|
...prev[sessionId],
|
||||||
...(typeof updates === 'function' ? updates(prev[sessionId]) : updates),
|
...newUpdates,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -444,6 +446,7 @@ export function SessionProvider({ children }) {
|
|||||||
case 'control_initialized': {
|
case 'control_initialized': {
|
||||||
updateSession(sessionId, {
|
updateSession(sessionId, {
|
||||||
controlInitialized: true,
|
controlInitialized: true,
|
||||||
|
availableCommands: data.commands || [],
|
||||||
});
|
});
|
||||||
// Restore saved permission mode after control is initialized
|
// Restore saved permission mode after control is initialized
|
||||||
// Read from localStorage since sessions state may be stale in this callback
|
// Read from localStorage since sessions state may be stale in this callback
|
||||||
|
|||||||
Reference in New Issue
Block a user