/** * Serial Monitor — multi-board tabbed view. * Each board has its own tab with serial output, input, and clear button. */ import React, { useRef, useEffect, useState, useCallback } from 'react'; import { useSimulatorStore, getBoardSimulator } from '../../store/useSimulatorStore'; import { RP2040Simulator } from '../../simulation/RP2040Simulator'; import type { BoardKind } from '../../types/board'; import { BOARD_KIND_LABELS } from '../../types/board'; // Short labels for tabs const BOARD_SHORT_LABEL: Record = { 'arduino-uno': 'Uno', 'arduino-nano': 'Nano', 'arduino-mega': 'Mega', 'raspberry-pi-pico': 'Pico', 'raspberry-pi-3': 'Pi 3B', 'esp32': 'ESP32', 'esp32-s3': 'ESP32-S3', 'esp32-c3': 'ESP32-C3', }; const BOARD_ICON: Record = { 'arduino-uno': '⬤', 'arduino-nano': '▪', 'arduino-mega': '▬', 'raspberry-pi-pico': '◆', 'raspberry-pi-3': '⬛', 'esp32': '⬡', 'esp32-s3': '⬡', 'esp32-c3': '⬡', }; const BOARD_COLOR: Record = { 'arduino-uno': '#4fc3f7', 'arduino-nano': '#4fc3f7', 'arduino-mega': '#4fc3f7', 'raspberry-pi-pico': '#ce93d8', 'raspberry-pi-3': '#ef9a9a', 'esp32': '#a5d6a7', 'esp32-s3': '#a5d6a7', 'esp32-c3': '#a5d6a7', }; export const SerialMonitor: React.FC = () => { const boards = useSimulatorStore((s) => s.boards); const activeBoardId = useSimulatorStore((s) => s.activeBoardId); const serialWriteToBoard = useSimulatorStore((s) => s.serialWriteToBoard); const clearBoardSerialOutput = useSimulatorStore((s) => s.clearBoardSerialOutput); // Active tab defaults to activeBoardId, updates when active board changes const [activeTabId, setActiveTabId] = useState(null); // Track last-seen serial output length per board for unread dots const [lastSeenLen, setLastSeenLen] = useState>({}); const [inputValue, setInputValue] = useState(''); const [lineEnding, setLineEnding] = useState<'none' | 'nl' | 'cr' | 'both'>('nl'); const [autoscroll, setAutoscroll] = useState(true); const outputRef = useRef(null); // Sync active tab to activeBoardId when it changes useEffect(() => { if (activeBoardId) setActiveTabId(activeBoardId); }, [activeBoardId]); // Fallback: if activeTab is gone, pick first board const resolvedTabId = (boards.find((b) => b.id === activeTabId) ? activeTabId : boards[0]?.id) ?? null; const activeBoard = boards.find((b) => b.id === resolvedTabId); // Mark tab as read when it becomes active useEffect(() => { if (resolvedTabId && activeBoard) { setLastSeenLen((prev) => ({ ...prev, [resolvedTabId]: activeBoard.serialOutput.length })); } }, [resolvedTabId, activeBoard?.serialOutput.length]); // Auto-scroll when output changes on the visible tab useEffect(() => { if (autoscroll && outputRef.current) { outputRef.current.scrollTop = outputRef.current.scrollHeight; } }, [activeBoard?.serialOutput, autoscroll]); const handleSend = useCallback(() => { if (!resolvedTabId) return; if (!inputValue && lineEnding === 'none') return; let text = inputValue; switch (lineEnding) { case 'nl': text += '\n'; break; case 'cr': text += '\r'; break; case 'both': text += '\r\n'; break; } serialWriteToBoard(resolvedTabId, text); setInputValue(''); }, [resolvedTabId, inputValue, lineEnding, serialWriteToBoard]); const isMicroPython = activeBoard?.languageMode === 'micropython'; const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); handleSend(); return; } // MicroPython REPL control characters if (resolvedTabId && e.ctrlKey) { const sim = getBoardSimulator(resolvedTabId); if (sim instanceof RP2040Simulator && sim.isMicroPythonMode()) { if (e.key === 'c' || e.key === 'C') { e.preventDefault(); sim.serialWriteByte(0x03); // Ctrl+C — keyboard interrupt } else if (e.key === 'd' || e.key === 'D') { e.preventDefault(); sim.serialWriteByte(0x04); // Ctrl+D — soft reset } } } }, [handleSend, resolvedTabId]); const handleTabClick = (boardId: string) => { setActiveTabId(boardId); const board = boards.find((b) => b.id === boardId); if (board) { setLastSeenLen((prev) => ({ ...prev, [boardId]: board.serialOutput.length })); } }; if (boards.length === 0) { return (
Serial Monitor
Add a board to start the serial monitor.
); } return (
{/* Tab strip */}
{boards.map((board) => { const isActive = board.id === resolvedTabId; const color = BOARD_COLOR[board.boardKind]; const hasUnread = (board.serialOutput.length) > (lastSeenLen[board.id] ?? 0); return ( ); })} {/* Right-side controls */}
{isMicroPython && ( MicroPython REPL )} {activeBoard?.serialBaudRate != null && activeBoard.serialBaudRate > 0 && !isMicroPython && ( {activeBoard.serialBaudRate.toLocaleString()} baud )}
{/* Output area */}
        {activeBoard?.serialOutput ||
          (activeBoard?.running ? 'Waiting for serial data...\n' : 'Start simulation to see serial output.\n')}
      
{/* Input row */}
setInputValue(e.target.value)} onKeyDown={handleKeyDown} placeholder={isMicroPython ? 'Type Python expression... (Ctrl+C to interrupt)' : 'Type message to send...'} style={styles.input} disabled={!activeBoard?.running} />
); }; const styles: Record = { container: { display: 'flex', flexDirection: 'column', height: '100%', background: '#1e1e1e', borderTop: '1px solid #333', fontFamily: 'monospace', fontSize: 13, minHeight: 0, }, tabStrip: { display: 'flex', alignItems: 'center', background: '#252526', borderBottom: '1px solid #333', minHeight: 32, flexShrink: 0, overflow: 'hidden', }, tab: { background: 'none', border: 'none', borderBottom: '2px solid transparent', color: '#999', padding: '5px 12px', cursor: 'pointer', fontSize: 11, fontWeight: 600, fontFamily: 'inherit', display: 'flex', alignItems: 'center', gap: 2, whiteSpace: 'nowrap', position: 'relative', }, tabActive: { background: 'rgba(255,255,255,0.04)', }, tabControls: { marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8, paddingRight: 8, flexShrink: 0, }, unreadDot: { width: 6, height: 6, borderRadius: '50%', background: '#4fc3f7', flexShrink: 0, marginLeft: 3, }, title: { color: '#cccccc', fontWeight: 600, fontSize: 12, }, baudRate: { color: '#569cd6', fontSize: 11, fontFamily: 'monospace', background: '#1e1e1e', border: '1px solid #3a3a3a', borderRadius: 3, padding: '1px 6px', }, autoscrollLabel: { color: '#999', fontSize: 11, display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', }, checkbox: { margin: 0, cursor: 'pointer', }, clearBtn: { background: 'transparent', border: '1px solid #555', color: '#ccc', padding: '2px 8px', borderRadius: 3, cursor: 'pointer', fontSize: 11, }, output: { flex: 1, margin: 0, padding: 8, color: '#00ff41', background: '#0a0a0a', overflowY: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-all', minHeight: 0, fontSize: 13, lineHeight: '1.4', }, inputRow: { display: 'flex', gap: 4, padding: 4, background: '#252526', borderTop: '1px solid #333', flexShrink: 0, }, input: { flex: 1, background: '#1e1e1e', border: '1px solid #444', color: '#ccc', padding: '4px 8px', borderRadius: 3, fontFamily: 'monospace', fontSize: 12, outline: 'none', }, select: { background: '#1e1e1e', border: '1px solid #444', color: '#ccc', padding: '4px', borderRadius: 3, fontSize: 11, outline: 'none', }, sendBtn: { background: '#0e639c', border: 'none', color: '#fff', padding: '4px 12px', borderRadius: 3, cursor: 'pointer', fontSize: 12, fontWeight: 600, }, };