feat: Add slash commands and help dialog
- Restore slash command autocomplete (type "/" to see suggestions) - Add /compact command to trigger context compaction - Add /clear command to clear chat history - Add /help command with styled modal dialog - Add HelpDialog component with command list - Add system event handler for context warnings - Add debug logging for all Claude events to /tmp/claude-events-debug.jsonl TODO: Parse context warning from Claude events when identified 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,7 @@ function saveHistory(history) {
|
||||
|
||||
// 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' },
|
||||
@@ -295,6 +296,24 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
||||
}
|
||||
};
|
||||
|
||||
// Handle input changes for slash command detection (lightweight, no state updates for value)
|
||||
const handleInput = useCallback(() => {
|
||||
const value = textareaRef.current?.value || '';
|
||||
|
||||
// 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)
|
||||
);
|
||||
setFilteredCommands(filtered);
|
||||
setShowCommands(filtered.length > 0);
|
||||
setSelectedIndex(0);
|
||||
} else {
|
||||
setShowCommands(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
@@ -423,6 +442,7 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
||||
ref={textareaRef}
|
||||
defaultValue=""
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
placeholder={isProcessing ? 'Type to send a follow-up message...' : placeholder}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { MessageList } from './MessageList';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { StatusBar } from './StatusBar';
|
||||
import { PermissionDialog } from './PermissionDialog';
|
||||
import { HelpDialog } from './HelpDialog';
|
||||
import { useSessionManager } from '../contexts/SessionContext';
|
||||
import { Bot } from 'lucide-react';
|
||||
|
||||
@@ -102,6 +103,7 @@ function useMemoizedSession(sessionId) {
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
clearMessages,
|
||||
setCompacting,
|
||||
changePermissionMode,
|
||||
respondToPermission,
|
||||
} = useSessionManager();
|
||||
@@ -121,9 +123,10 @@ function useMemoizedSession(sessionId) {
|
||||
send: (msg, attachments) => sendMessage(sessionId, msg, attachments),
|
||||
stopGeneration: () => stopGeneration(sessionId),
|
||||
clearMessages: () => clearMessages(sessionId),
|
||||
setCompacting: (value) => setCompacting(sessionId, value),
|
||||
changePermissionMode: (mode) => changePermissionMode(sessionId, mode),
|
||||
respondToPermission: (reqId, allow) => respondToPermission(sessionId, reqId, allow),
|
||||
}), [sessionId, startClaudeSession, stopClaudeSession, sendMessage, stopGeneration, clearMessages, changePermissionMode, respondToPermission]);
|
||||
}), [sessionId, startClaudeSession, stopClaudeSession, sendMessage, stopGeneration, clearMessages, setCompacting, changePermissionMode, respondToPermission]);
|
||||
|
||||
return { session: sessionWithMessages, ...actions };
|
||||
}
|
||||
@@ -136,10 +139,13 @@ export const ChatPanel = memo(function ChatPanel({ sessionId }) {
|
||||
send,
|
||||
stopGeneration,
|
||||
clearMessages,
|
||||
setCompacting,
|
||||
changePermissionMode,
|
||||
respondToPermission,
|
||||
} = useMemoizedSession(sessionId);
|
||||
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
const handleClearError = useCallback(() => {
|
||||
// We'd need to add this to the session context
|
||||
// For now, errors auto-clear on next action
|
||||
@@ -151,9 +157,55 @@ export const ChatPanel = memo(function ChatPanel({ sessionId }) {
|
||||
const stopGenerationRef = useRef(stopGeneration);
|
||||
stopGenerationRef.current = stopGeneration;
|
||||
|
||||
// Refs for slash command handlers
|
||||
const clearMessagesRef = useRef(clearMessages);
|
||||
clearMessagesRef.current = clearMessages;
|
||||
const setCompactingRef = useRef(setCompacting);
|
||||
setCompactingRef.current = setCompacting;
|
||||
const setShowHelpRef = useRef(setShowHelp);
|
||||
setShowHelpRef.current = setShowHelp;
|
||||
|
||||
// These callbacks never change identity, preventing ChatInput re-renders
|
||||
const handleSendMessage = useCallback((message, attachments = []) => {
|
||||
if (message.trim() || attachments.length > 0) {
|
||||
const trimmed = message.trim();
|
||||
|
||||
// Handle slash commands locally
|
||||
if (trimmed.startsWith('/')) {
|
||||
const [cmd, ...args] = trimmed.slice(1).split(' ');
|
||||
const arg = args.join(' ');
|
||||
|
||||
switch (cmd.toLowerCase()) {
|
||||
case 'clear':
|
||||
clearMessagesRef.current();
|
||||
return;
|
||||
|
||||
case 'help':
|
||||
// Show help dialog
|
||||
setShowHelpRef.current(true);
|
||||
return;
|
||||
|
||||
case 'compact':
|
||||
// Set compacting state immediately, then send to Claude
|
||||
setCompactingRef.current(true);
|
||||
sendRef.current('/compact' + (arg ? ' ' + arg : ''), []);
|
||||
return;
|
||||
|
||||
case 'new':
|
||||
case 'export':
|
||||
case 'scroll':
|
||||
case 'info':
|
||||
case 'history':
|
||||
// TODO: Implement these
|
||||
alert(`/${cmd} is not yet implemented`);
|
||||
return;
|
||||
|
||||
default:
|
||||
// Unknown command - send to Claude as-is (might be a Claude command)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed || attachments.length > 0) {
|
||||
sendRef.current(message, attachments);
|
||||
}
|
||||
}, []);
|
||||
@@ -215,6 +267,9 @@ export const ChatPanel = memo(function ChatPanel({ sessionId }) {
|
||||
onAllow={(requestId) => respondToPermission(requestId, true)}
|
||||
onDeny={(requestId) => respondToPermission(requestId, false)}
|
||||
/>
|
||||
|
||||
{/* Help Dialog */}
|
||||
<HelpDialog open={showHelp} onClose={() => setShowHelp(false)} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
74
frontend/src/components/HelpDialog.jsx
Normal file
74
frontend/src/components/HelpDialog.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { memo } from 'react';
|
||||
import { X, Terminal, Trash2, HelpCircle, FileDown, PlusCircle, Zap } from 'lucide-react';
|
||||
|
||||
const COMMANDS = [
|
||||
{ name: '/compact', description: 'Summarize conversation to free context', icon: Zap, color: 'text-purple-400' },
|
||||
{ name: '/clear', description: 'Clear chat history', icon: Trash2, color: 'text-red-400' },
|
||||
{ name: '/help', description: 'Show this help', icon: HelpCircle, color: 'text-cyan-400' },
|
||||
{ name: '/new', description: 'Start new session (coming soon)', icon: PlusCircle, color: 'text-green-400', disabled: true },
|
||||
{ name: '/export', description: 'Export chat (coming soon)', icon: FileDown, color: 'text-yellow-400', disabled: true },
|
||||
];
|
||||
|
||||
export const HelpDialog = memo(function HelpDialog({ open, onClose }) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="relative bg-dark-800 border border-dark-600 rounded-xl shadow-2xl w-full max-w-md mx-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-dark-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-cyan-500/20 rounded-lg">
|
||||
<Terminal className="w-5 h-5 text-cyan-400" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">Slash Commands</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Commands list */}
|
||||
<div className="p-4 space-y-2">
|
||||
{COMMANDS.map((cmd) => {
|
||||
const Icon = cmd.icon;
|
||||
return (
|
||||
<div
|
||||
key={cmd.name}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg bg-dark-900/50 ${cmd.disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 ${cmd.color}`} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono text-dark-100">{cmd.name}</code>
|
||||
{cmd.disabled && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-dark-700 text-dark-400 rounded">soon</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-dark-400 mt-0.5">{cmd.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-3 bg-dark-900/50 border-t border-dark-700">
|
||||
<p className="text-xs text-dark-500 text-center">
|
||||
Type <code className="text-orange-400">/</code> in the input field to see suggestions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -389,6 +389,33 @@ export function SessionProvider({ children }) {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'system': {
|
||||
// System events from Claude - log full event for debugging
|
||||
console.log(`[${sessionId}] System event:`, event.subtype, JSON.stringify(event, null, 2));
|
||||
|
||||
// Check for context info in system init event
|
||||
if (event.subtype === 'init') {
|
||||
// Log all fields to find context info
|
||||
console.log(`[${sessionId}] System init fields:`, Object.keys(event));
|
||||
}
|
||||
|
||||
// Parse context message if present
|
||||
if (event.message) {
|
||||
const contextMatch = event.message.match(/Context left[^:]*:\s*(\d+)%/i);
|
||||
if (contextMatch) {
|
||||
const contextLeft = parseInt(contextMatch[1], 10);
|
||||
console.log(`[${sessionId}] Context left:`, contextLeft + '%');
|
||||
updateSession(sessionId, (session) => ({
|
||||
stats: {
|
||||
...(session.stats || {}),
|
||||
contextLeftPercent: contextLeft,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.log(`[${sessionId}] Unhandled claude event:`, type, event);
|
||||
}
|
||||
@@ -795,6 +822,16 @@ export function SessionProvider({ children }) {
|
||||
updateSession(sessionId, { unreadCount: 0 });
|
||||
}, [updateSession]);
|
||||
|
||||
// Set compacting state
|
||||
const setCompacting = useCallback((sessionId, value) => {
|
||||
updateSession(sessionId, {
|
||||
stats: {
|
||||
...(sessions[sessionId]?.stats || {}),
|
||||
isCompacting: value,
|
||||
},
|
||||
});
|
||||
}, [updateSession, sessions]);
|
||||
|
||||
// Change permission mode
|
||||
const changePermissionMode = useCallback((sessionId, mode) => {
|
||||
const ws = wsRefs.current[sessionId];
|
||||
@@ -900,6 +937,7 @@ export function SessionProvider({ children }) {
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
clearMessages,
|
||||
setCompacting,
|
||||
|
||||
// Permissions
|
||||
changePermissionMode,
|
||||
@@ -909,7 +947,7 @@ export function SessionProvider({ children }) {
|
||||
createSession, closeSession, removeSession, renameSession, updateSessionConfig,
|
||||
setFocusedSessionId, markAsRead, reorderTabs, addToSplit, removeFromSplit, clearSplit,
|
||||
connectSession, disconnectSession, startClaudeSession, stopClaudeSession,
|
||||
sendMessage, stopGeneration, clearMessages, changePermissionMode, respondToPermission,
|
||||
sendMessage, stopGeneration, clearMessages, setCompacting, changePermissionMode, respondToPermission,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -423,9 +423,12 @@ export function useClaudeSession() {
|
||||
isCompacting: false,
|
||||
}));
|
||||
}
|
||||
// Detect compacting
|
||||
if (event.message.includes('compact')) {
|
||||
// Detect compacting start/end from Claude's system messages
|
||||
const msg = event.message.toLowerCase();
|
||||
if (msg.includes('compacting') || msg.includes('summarizing conversation')) {
|
||||
setSessionStats(prev => ({ ...prev, isCompacting: true }));
|
||||
} else if (msg.includes('compacted') || msg.includes('summarized')) {
|
||||
setSessionStats(prev => ({ ...prev, isCompacting: false }));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
@@ -657,6 +660,7 @@ export function useClaudeSession() {
|
||||
changePermissionMode,
|
||||
respondToPermission,
|
||||
setError,
|
||||
setMessages
|
||||
setMessages,
|
||||
setCompacting: (value) => setSessionStats(prev => ({ ...prev, isCompacting: value })),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user