From 7156f1aaa0e04759d276de8732188a567a2000a7 Mon Sep 17 00:00:00 2001 From: Nikolas Syring Date: Thu, 18 Dec 2025 10:49:47 +0100 Subject: [PATCH] feat: Add slash commands and help dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/server.js | 32 +++++++++- frontend/src/components/ChatInput.jsx | 20 +++++++ frontend/src/components/ChatPanel.jsx | 61 ++++++++++++++++++- frontend/src/components/HelpDialog.jsx | 74 ++++++++++++++++++++++++ frontend/src/contexts/SessionContext.jsx | 40 ++++++++++++- frontend/src/hooks/useClaudeSession.js | 10 +++- 6 files changed, 229 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/HelpDialog.jsx diff --git a/backend/server.js b/backend/server.js index 6a19746..d15cc7e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -837,6 +837,20 @@ wss.on('connection', async (ws, req) => { // Note: ExitPlanMode is now handled exclusively via Control Protocol (can_use_tool) // to avoid tool_result/control_response confusion and message compaction issues + // Debug: Write all events to file for context analysis + const debugLogPath = '/tmp/claude-events-debug.jsonl'; + const debugEntry = { + timestamp: new Date().toISOString(), + sessionId, + event + }; + try { + const fs = await import('fs'); + fs.appendFileSync(debugLogPath, JSON.stringify(debugEntry) + '\n'); + } catch (e) { + // Ignore write errors + } + sendToClient('claude_event', { event }); } catch (e) { // Non-JSON output, send as raw @@ -848,10 +862,26 @@ wss.on('connection', async (ws, req) => { }); // Handle stderr - claudeProcess.stderr.on('data', (data) => { + claudeProcess.stderr.on('data', async (data) => { const content = data.toString(); // Always log stderr for SSH connections (exit code 255 debugging) if (DEBUG || isSSH) console.log(`[${sessionId}] stderr:`, content); + + // Debug: Write stderr to file for context analysis + const debugLogPath = '/tmp/claude-events-debug.jsonl'; + const debugEntry = { + timestamp: new Date().toISOString(), + sessionId, + type: 'stderr', + content + }; + try { + const fs = await import('fs'); + fs.appendFileSync(debugLogPath, JSON.stringify(debugEntry) + '\n'); + } catch (e) { + // Ignore write errors + } + sendToClient('stderr', { content }); }); diff --git a/frontend/src/components/ChatInput.jsx b/frontend/src/components/ChatInput.jsx index 0378144..cbfa981 100644 --- a/frontend/src/components/ChatInput.jsx +++ b/frontend/src/components/ChatInput.jsx @@ -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 (
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 */} + setShowHelp(false)} /> ); }); diff --git a/frontend/src/components/HelpDialog.jsx b/frontend/src/components/HelpDialog.jsx new file mode 100644 index 0000000..2abaefa --- /dev/null +++ b/frontend/src/components/HelpDialog.jsx @@ -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 ( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+ {/* Header */} +
+
+
+ +
+

Slash Commands

+
+ +
+ + {/* Commands list */} +
+ {COMMANDS.map((cmd) => { + const Icon = cmd.icon; + return ( +
+ +
+
+ {cmd.name} + {cmd.disabled && ( + soon + )} +
+

{cmd.description}

+
+
+ ); + })} +
+ + {/* Footer */} +
+

+ Type / in the input field to see suggestions +

+
+
+
+ ); +}); diff --git a/frontend/src/contexts/SessionContext.jsx b/frontend/src/contexts/SessionContext.jsx index 47e84ce..c7628a7 100644 --- a/frontend/src/contexts/SessionContext.jsx +++ b/frontend/src/contexts/SessionContext.jsx @@ -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 ( diff --git a/frontend/src/hooks/useClaudeSession.js b/frontend/src/hooks/useClaudeSession.js index d2eee62..8121bd5 100644 --- a/frontend/src/hooks/useClaudeSession.js +++ b/frontend/src/hooks/useClaudeSession.js @@ -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 })), }; }