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:
2025-12-18 10:49:47 +01:00
parent a3fcc3cb7f
commit 7156f1aaa0
6 changed files with 229 additions and 8 deletions

View File

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

View File

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

View File

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

View 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>
);
});

View File

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

View File

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