Files
Atomizer/atomizer-dashboard/frontend/src/components/ClaudeTerminal.tsx
Anto01 7c700c4606 feat: Dashboard improvements and configuration updates
Dashboard:
- Enhanced terminal components (ClaudeTerminal, GlobalClaudeTerminal)
- Improved MarkdownRenderer for better documentation display
- Updated convergence plots (ConvergencePlot, PlotlyConvergencePlot)
- Refined Home, Analysis, Dashboard, Setup, Results pages
- Added StudyContext improvements
- Updated vite.config for better dev experience

Configuration:
- Updated CLAUDE.md with latest instructions
- Enhanced launch_dashboard.py
- Updated config.py settings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 13:47:05 -05:00

417 lines
14 KiB
TypeScript

import React, { useEffect, useRef, useState, useCallback } from 'react';
import { Terminal } from 'xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import 'xterm/css/xterm.css';
import {
Terminal as TerminalIcon,
Maximize2,
Minimize2,
X,
RefreshCw,
AlertCircle,
FolderOpen,
Plus
} from 'lucide-react';
import { useStudy } from '../context/StudyContext';
import { useClaudeTerminal } from '../context/ClaudeTerminalContext';
interface ClaudeTerminalProps {
isExpanded?: boolean;
onToggleExpand?: () => void;
onClose?: () => void;
}
export const ClaudeTerminal: React.FC<ClaudeTerminalProps> = ({
isExpanded = false,
onToggleExpand,
onClose
}) => {
const { selectedStudy } = useStudy();
const { setIsConnected: setGlobalConnected } = useClaudeTerminal();
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [isConnected, setIsConnectedLocal] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [_error, setError] = useState<string | null>(null);
const [cliAvailable, setCliAvailable] = useState<boolean | null>(null);
const [contextSet, setContextSet] = useState(false);
const [settingContext, setSettingContext] = useState(false);
// Sync local connection state to global context
const setIsConnected = useCallback((connected: boolean) => {
setIsConnectedLocal(connected);
setGlobalConnected(connected);
}, [setGlobalConnected]);
// Check CLI availability
useEffect(() => {
fetch('/api/terminal/status')
.then(res => res.json())
.then(data => {
setCliAvailable(data.available);
if (!data.available) {
setError(data.message);
}
})
.catch(() => {
setCliAvailable(false);
setError('Could not check Claude Code CLI status');
});
}, []);
// Initialize terminal
useEffect(() => {
if (!terminalRef.current || xtermRef.current) return;
const term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: '"JetBrains Mono", "Fira Code", Consolas, monospace',
theme: {
background: '#0f172a',
foreground: '#e2e8f0',
cursor: '#60a5fa',
cursorAccent: '#0f172a',
selectionBackground: '#334155',
black: '#1e293b',
red: '#ef4444',
green: '#22c55e',
yellow: '#eab308',
blue: '#3b82f6',
magenta: '#a855f7',
cyan: '#06b6d4',
white: '#f1f5f9',
brightBlack: '#475569',
brightRed: '#f87171',
brightGreen: '#4ade80',
brightYellow: '#facc15',
brightBlue: '#60a5fa',
brightMagenta: '#c084fc',
brightCyan: '#22d3ee',
brightWhite: '#f8fafc',
},
allowProposedApi: true,
});
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
term.loadAddon(fitAddon);
term.loadAddon(webLinksAddon);
term.open(terminalRef.current);
// Initial fit
setTimeout(() => fitAddon.fit(), 0);
xtermRef.current = term;
fitAddonRef.current = fitAddon;
// Welcome message
term.writeln('\x1b[1;36m╔══════════════════════════════════════════════════════════╗\x1b[0m');
term.writeln('\x1b[1;36m║\x1b[0m \x1b[1;37mClaude Code Terminal\x1b[0m \x1b[1;36m║\x1b[0m');
term.writeln('\x1b[1;36m║\x1b[0m \x1b[90mFull Claude Code experience in the Atomizer dashboard\x1b[0m \x1b[1;36m║\x1b[0m');
term.writeln('\x1b[1;36m╚══════════════════════════════════════════════════════════╝\x1b[0m');
term.writeln('');
if (cliAvailable === false) {
term.writeln('\x1b[1;31mError:\x1b[0m Claude Code CLI not found.');
term.writeln('Install with: \x1b[1;33mnpm install -g @anthropic-ai/claude-code\x1b[0m');
} else {
term.writeln('\x1b[90mClick "Connect" to start a Claude Code session.\x1b[0m');
term.writeln('\x1b[90mClaude will have access to CLAUDE.md and .claude/ skills.\x1b[0m');
}
term.writeln('');
return () => {
term.dispose();
xtermRef.current = null;
};
}, [cliAvailable]);
// Handle resize
useEffect(() => {
const handleResize = () => {
if (fitAddonRef.current) {
fitAddonRef.current.fit();
// Send resize to backend
if (wsRef.current?.readyState === WebSocket.OPEN && xtermRef.current) {
wsRef.current.send(JSON.stringify({
type: 'resize',
cols: xtermRef.current.cols,
rows: xtermRef.current.rows
}));
}
}
};
window.addEventListener('resize', handleResize);
// Also fit when expanded state changes
setTimeout(handleResize, 100);
return () => window.removeEventListener('resize', handleResize);
}, [isExpanded]);
// Connect to terminal WebSocket
const connect = useCallback(() => {
if (!xtermRef.current || wsRef.current?.readyState === WebSocket.OPEN) return;
setIsConnecting(true);
setError(null);
// Let backend determine the working directory (ATOMIZER_ROOT)
// Pass study_id as parameter so we can inform Claude about the context
const studyParam = selectedStudy?.id ? `?study_id=${selectedStudy.id}` : '';
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}/api/terminal/claude${studyParam}`);
ws.onopen = () => {
setIsConnected(true);
setIsConnecting(false);
xtermRef.current?.clear();
xtermRef.current?.writeln('\x1b[1;32mConnected to Claude Code\x1b[0m');
if (selectedStudy?.id) {
xtermRef.current?.writeln(`\x1b[90mStudy: \x1b[1;33m${selectedStudy.id}\x1b[0m \x1b[90m- Click "Set Context" to initialize\x1b[0m`);
}
xtermRef.current?.writeln('');
// Send initial resize
if (xtermRef.current) {
ws.send(JSON.stringify({
type: 'resize',
cols: xtermRef.current.cols,
rows: xtermRef.current.rows
}));
}
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
switch (message.type) {
case 'output':
xtermRef.current?.write(message.data);
break;
case 'started':
xtermRef.current?.writeln(`\x1b[90m${message.message}\x1b[0m`);
break;
case 'exit':
xtermRef.current?.writeln('');
xtermRef.current?.writeln(`\x1b[33mClaude Code exited with code ${message.code}\x1b[0m`);
setIsConnected(false);
break;
case 'error':
xtermRef.current?.writeln(`\x1b[1;31mError: ${message.message}\x1b[0m`);
setError(message.message);
break;
}
} catch (e) {
// Raw output
xtermRef.current?.write(event.data);
}
};
ws.onerror = () => {
setError('WebSocket connection error');
setIsConnecting(false);
};
ws.onclose = () => {
setIsConnected(false);
setIsConnecting(false);
xtermRef.current?.writeln('');
xtermRef.current?.writeln('\x1b[90mDisconnected from Claude Code\x1b[0m');
};
// Handle terminal input
const disposable = xtermRef.current.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }));
}
});
wsRef.current = ws;
return () => {
disposable.dispose();
};
}, [selectedStudy]);
// Disconnect
const disconnect = useCallback(() => {
if (wsRef.current) {
wsRef.current.send(JSON.stringify({ type: 'stop' }));
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
setContextSet(false);
}, []);
// Set study context - sends context message to Claude silently
const setStudyContext = useCallback(() => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
setSettingContext(true);
let contextMessage: string;
if (selectedStudy?.id) {
// Existing study context
contextMessage =
`You are helping with Atomizer optimization. ` +
`First read: .claude/skills/00_BOOTSTRAP.md for task routing. ` +
`Then follow the Protocol Execution Framework. ` +
`Study context: Working on "${selectedStudy.id}" at studies/${selectedStudy.id}/. ` +
`Use atomizer conda env. Acknowledge briefly.`;
} else {
// No study selected - offer to create new study
contextMessage =
`You are helping with Atomizer optimization. ` +
`First read: .claude/skills/00_BOOTSTRAP.md for task routing. ` +
`No study is currently selected. ` +
`Read .claude/skills/guided-study-wizard.md and help the user create a new optimization study. ` +
`Use atomizer conda env. Start the guided wizard by asking what they want to optimize.`;
}
wsRef.current.send(JSON.stringify({ type: 'input', data: contextMessage + '\n' }));
// Mark as done after Claude has had time to process
setTimeout(() => {
setSettingContext(false);
setContextSet(true);
}, 500);
}, [selectedStudy]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (wsRef.current) {
wsRef.current.close();
}
};
}, []);
return (
<div className={`flex flex-col bg-dark-800 rounded-xl border border-dark-600 overflow-hidden ${
isExpanded ? 'fixed inset-4 z-50' : 'h-full'
}`}>
{/* Header */}
<div className="px-4 py-3 border-b border-dark-600 flex items-center justify-between bg-dark-800">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center">
<TerminalIcon className="w-4 h-4 text-white" />
</div>
<div>
<span className="font-medium text-white">Claude Code</span>
{selectedStudy && (
<span className="ml-2 text-xs bg-dark-700 px-2 py-0.5 rounded text-dark-300">
{selectedStudy.id}
</span>
)}
</div>
{/* Connection status indicator */}
<div className={`w-2 h-2 rounded-full ml-2 ${
isConnected ? 'bg-green-500' : 'bg-dark-500'
}`} />
</div>
<div className="flex items-center gap-1">
{/* Connect/Disconnect button */}
<button
onClick={isConnected ? disconnect : connect}
disabled={isConnecting || cliAvailable === false}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-2 ${
isConnected
? 'bg-red-600/20 text-red-400 hover:bg-red-600/30'
: 'bg-green-600/20 text-green-400 hover:bg-green-600/30'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{isConnecting ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : null}
{isConnected ? 'Disconnect' : 'Connect'}
</button>
{/* Set Context button - works for both existing study and new study creation */}
<button
onClick={setStudyContext}
disabled={!isConnected || settingContext || contextSet}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-2 ${
contextSet
? 'bg-green-600/20 text-green-400'
: !isConnected
? 'bg-dark-600 text-dark-400'
: selectedStudy?.id
? 'bg-primary-600/20 text-primary-400 hover:bg-primary-600/30'
: 'bg-yellow-600/20 text-yellow-400 hover:bg-yellow-600/30'
} disabled:opacity-50 disabled:cursor-not-allowed`}
title={
!isConnected
? 'Connect first to set context'
: contextSet
? 'Context already set'
: selectedStudy?.id
? `Set context to study: ${selectedStudy.id}`
: 'Start guided study creation wizard'
}
>
{settingContext ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : selectedStudy?.id ? (
<FolderOpen className="w-3 h-3" />
) : (
<Plus className="w-3 h-3" />
)}
{contextSet ? 'Context Set' : selectedStudy?.id ? 'Set Context' : 'New Study'}
</button>
{onToggleExpand && (
<button
onClick={onToggleExpand}
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
>
{isExpanded ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
)}
{onClose && (
<button
onClick={onClose}
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* CLI not available warning */}
{cliAvailable === false && (
<div className="px-4 py-3 bg-yellow-900/20 border-b border-yellow-800/30 flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-yellow-500" />
<span className="text-yellow-400 text-sm">
Claude Code CLI not found. Install with: npm install -g @anthropic-ai/claude-code
</span>
</div>
)}
{/* Terminal */}
<div className="flex-1 p-2 bg-[#0f172a]">
<div ref={terminalRef} className="h-full" />
</div>
{/* Footer */}
<div className="px-4 py-2 border-t border-dark-600 bg-dark-800">
<p className="text-xs text-dark-500 text-center">
Claude Code has access to CLAUDE.md instructions and .claude/ skills for Atomizer optimization
</p>
</div>
</div>
);
};
export default ClaudeTerminal;