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:
@@ -837,6 +837,20 @@ wss.on('connection', async (ws, req) => {
|
|||||||
// Note: ExitPlanMode is now handled exclusively via Control Protocol (can_use_tool)
|
// Note: ExitPlanMode is now handled exclusively via Control Protocol (can_use_tool)
|
||||||
// to avoid tool_result/control_response confusion and message compaction issues
|
// 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 });
|
sendToClient('claude_event', { event });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Non-JSON output, send as raw
|
// Non-JSON output, send as raw
|
||||||
@@ -848,10 +862,26 @@ wss.on('connection', async (ws, req) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle stderr
|
// Handle stderr
|
||||||
claudeProcess.stderr.on('data', (data) => {
|
claudeProcess.stderr.on('data', async (data) => {
|
||||||
const content = data.toString();
|
const content = data.toString();
|
||||||
// Always log stderr for SSH connections (exit code 255 debugging)
|
// Always log stderr for SSH connections (exit code 255 debugging)
|
||||||
if (DEBUG || isSSH) console.log(`[${sessionId}] stderr:`, content);
|
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 });
|
sendToClient('stderr', { content });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ function saveHistory(history) {
|
|||||||
|
|
||||||
// Available slash commands for autocomplete
|
// Available slash commands for autocomplete
|
||||||
const COMMANDS = [
|
const COMMANDS = [
|
||||||
|
{ name: 'compact', description: 'Compact context (summarize conversation)' },
|
||||||
{ name: 'help', description: 'Show available commands' },
|
{ name: 'help', description: 'Show available commands' },
|
||||||
{ name: 'clear', description: 'Clear chat history' },
|
{ name: 'clear', description: 'Clear chat history' },
|
||||||
{ name: 'export', description: 'Export chat as Markdown' },
|
{ 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 (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
@@ -423,6 +442,7 @@ export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isP
|
|||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onInput={handleInput}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
placeholder={isProcessing ? 'Type to send a follow-up message...' : placeholder}
|
placeholder={isProcessing ? 'Type to send a follow-up message...' : placeholder}
|
||||||
disabled={disabled}
|
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 { MessageList } from './MessageList';
|
||||||
import { ChatInput } from './ChatInput';
|
import { ChatInput } from './ChatInput';
|
||||||
import { StatusBar } from './StatusBar';
|
import { StatusBar } from './StatusBar';
|
||||||
import { PermissionDialog } from './PermissionDialog';
|
import { PermissionDialog } from './PermissionDialog';
|
||||||
|
import { HelpDialog } from './HelpDialog';
|
||||||
import { useSessionManager } from '../contexts/SessionContext';
|
import { useSessionManager } from '../contexts/SessionContext';
|
||||||
import { Bot } from 'lucide-react';
|
import { Bot } from 'lucide-react';
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ function useMemoizedSession(sessionId) {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
stopGeneration,
|
stopGeneration,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
|
setCompacting,
|
||||||
changePermissionMode,
|
changePermissionMode,
|
||||||
respondToPermission,
|
respondToPermission,
|
||||||
} = useSessionManager();
|
} = useSessionManager();
|
||||||
@@ -121,9 +123,10 @@ function useMemoizedSession(sessionId) {
|
|||||||
send: (msg, attachments) => sendMessage(sessionId, msg, attachments),
|
send: (msg, attachments) => sendMessage(sessionId, msg, attachments),
|
||||||
stopGeneration: () => stopGeneration(sessionId),
|
stopGeneration: () => stopGeneration(sessionId),
|
||||||
clearMessages: () => clearMessages(sessionId),
|
clearMessages: () => clearMessages(sessionId),
|
||||||
|
setCompacting: (value) => setCompacting(sessionId, value),
|
||||||
changePermissionMode: (mode) => changePermissionMode(sessionId, mode),
|
changePermissionMode: (mode) => changePermissionMode(sessionId, mode),
|
||||||
respondToPermission: (reqId, allow) => respondToPermission(sessionId, reqId, allow),
|
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 };
|
return { session: sessionWithMessages, ...actions };
|
||||||
}
|
}
|
||||||
@@ -136,10 +139,13 @@ export const ChatPanel = memo(function ChatPanel({ sessionId }) {
|
|||||||
send,
|
send,
|
||||||
stopGeneration,
|
stopGeneration,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
|
setCompacting,
|
||||||
changePermissionMode,
|
changePermissionMode,
|
||||||
respondToPermission,
|
respondToPermission,
|
||||||
} = useMemoizedSession(sessionId);
|
} = useMemoizedSession(sessionId);
|
||||||
|
|
||||||
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
|
||||||
const handleClearError = useCallback(() => {
|
const handleClearError = useCallback(() => {
|
||||||
// We'd need to add this to the session context
|
// We'd need to add this to the session context
|
||||||
// For now, errors auto-clear on next action
|
// For now, errors auto-clear on next action
|
||||||
@@ -151,9 +157,55 @@ export const ChatPanel = memo(function ChatPanel({ sessionId }) {
|
|||||||
const stopGenerationRef = useRef(stopGeneration);
|
const stopGenerationRef = useRef(stopGeneration);
|
||||||
stopGenerationRef.current = 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
|
// These callbacks never change identity, preventing ChatInput re-renders
|
||||||
const handleSendMessage = useCallback((message, attachments = []) => {
|
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);
|
sendRef.current(message, attachments);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -215,6 +267,9 @@ export const ChatPanel = memo(function ChatPanel({ sessionId }) {
|
|||||||
onAllow={(requestId) => respondToPermission(requestId, true)}
|
onAllow={(requestId) => respondToPermission(requestId, true)}
|
||||||
onDeny={(requestId) => respondToPermission(requestId, false)}
|
onDeny={(requestId) => respondToPermission(requestId, false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Help Dialog */}
|
||||||
|
<HelpDialog open={showHelp} onClose={() => setShowHelp(false)} />
|
||||||
</div>
|
</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;
|
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:
|
default:
|
||||||
console.log(`[${sessionId}] Unhandled claude event:`, type, event);
|
console.log(`[${sessionId}] Unhandled claude event:`, type, event);
|
||||||
}
|
}
|
||||||
@@ -795,6 +822,16 @@ export function SessionProvider({ children }) {
|
|||||||
updateSession(sessionId, { unreadCount: 0 });
|
updateSession(sessionId, { unreadCount: 0 });
|
||||||
}, [updateSession]);
|
}, [updateSession]);
|
||||||
|
|
||||||
|
// Set compacting state
|
||||||
|
const setCompacting = useCallback((sessionId, value) => {
|
||||||
|
updateSession(sessionId, {
|
||||||
|
stats: {
|
||||||
|
...(sessions[sessionId]?.stats || {}),
|
||||||
|
isCompacting: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [updateSession, sessions]);
|
||||||
|
|
||||||
// Change permission mode
|
// Change permission mode
|
||||||
const changePermissionMode = useCallback((sessionId, mode) => {
|
const changePermissionMode = useCallback((sessionId, mode) => {
|
||||||
const ws = wsRefs.current[sessionId];
|
const ws = wsRefs.current[sessionId];
|
||||||
@@ -900,6 +937,7 @@ export function SessionProvider({ children }) {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
stopGeneration,
|
stopGeneration,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
|
setCompacting,
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
changePermissionMode,
|
changePermissionMode,
|
||||||
@@ -909,7 +947,7 @@ export function SessionProvider({ children }) {
|
|||||||
createSession, closeSession, removeSession, renameSession, updateSessionConfig,
|
createSession, closeSession, removeSession, renameSession, updateSessionConfig,
|
||||||
setFocusedSessionId, markAsRead, reorderTabs, addToSplit, removeFromSplit, clearSplit,
|
setFocusedSessionId, markAsRead, reorderTabs, addToSplit, removeFromSplit, clearSplit,
|
||||||
connectSession, disconnectSession, startClaudeSession, stopClaudeSession,
|
connectSession, disconnectSession, startClaudeSession, stopClaudeSession,
|
||||||
sendMessage, stopGeneration, clearMessages, changePermissionMode, respondToPermission,
|
sendMessage, stopGeneration, clearMessages, setCompacting, changePermissionMode, respondToPermission,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -423,9 +423,12 @@ export function useClaudeSession() {
|
|||||||
isCompacting: false,
|
isCompacting: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
// Detect compacting
|
// Detect compacting start/end from Claude's system messages
|
||||||
if (event.message.includes('compact')) {
|
const msg = event.message.toLowerCase();
|
||||||
|
if (msg.includes('compacting') || msg.includes('summarizing conversation')) {
|
||||||
setSessionStats(prev => ({ ...prev, isCompacting: true }));
|
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,
|
changePermissionMode,
|
||||||
respondToPermission,
|
respondToPermission,
|
||||||
setError,
|
setError,
|
||||||
setMessages
|
setMessages,
|
||||||
|
setCompacting: (value) => setSessionStats(prev => ({ ...prev, isCompacting: value })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user