Quellcode durchsuchen

Move serial terminal from logs drawer to Table Control page

- Relocate serial terminal UI and logic from Layout to TableControlPage
  for better contextual placement with other table controls
- Add auto-connect feature that detects main connection port
- Update LED pixel order default from GRB to RGB (WS2815/WS2811)
- Use apiClient for consistent WebSocket URL construction

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris vor 3 Wochen
Ursprung
Commit
93606ad3ea

+ 59 - 357
frontend/src/components/layout/Layout.tsx

@@ -97,7 +97,6 @@ export function Layout() {
 
   // Logs drawer state
   const [isLogsOpen, setIsLogsOpen] = useState(false)
-  const [logsDrawerTab, setLogsDrawerTab] = useState<'logs' | 'terminal'>('logs')
   const [logsDrawerHeight, setLogsDrawerHeight] = useState(256) // Default 256px (h-64)
   const [isResizing, setIsResizing] = useState(false)
   const isResizingRef = useRef(false)
@@ -146,15 +145,6 @@ export function Layout() {
     }
   }, [])
 
-  // Serial terminal state
-  const [serialPorts, setSerialPorts] = useState<string[]>([])
-  const [selectedSerialPort, setSelectedSerialPort] = useState('')
-  const [serialConnected, setSerialConnected] = useState(false)
-  const [serialCommand, setSerialCommand] = useState('')
-  const [serialHistory, setSerialHistory] = useState<Array<{ type: 'cmd' | 'resp' | 'error'; text: string; time: string }>>([])
-  const [serialLoading, setSerialLoading] = useState(false)
-  const serialOutputRef = useRef<HTMLDivElement>(null)
-
   // Now Playing bar state
   const [isNowPlayingOpen, setIsNowPlayingOpen] = useState(false)
   const [openNowPlayingExpanded, setOpenNowPlayingExpanded] = useState(false)
@@ -401,129 +391,6 @@ export function Layout() {
     URL.revokeObjectURL(url)
   }
 
-  // Serial terminal functions
-  const fetchSerialPorts = async () => {
-    try {
-      const response = await fetch('/list_serial_ports')
-      const data = await response.json()
-      // API returns array directly, not wrapped in object
-      setSerialPorts(Array.isArray(data) ? data : [])
-    } catch {
-      toast.error('Failed to fetch serial ports')
-    }
-  }
-
-  const handleSerialConnect = async () => {
-    if (!selectedSerialPort) {
-      toast.error('Please select a port')
-      return
-    }
-
-    setSerialLoading(true)
-    try {
-      const response = await fetch('/api/debug-serial/open', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ port: selectedSerialPort }),
-      })
-      const data = await response.json()
-      if (data.success) {
-        setSerialConnected(true)
-        addSerialHistory('resp', `Connected to ${selectedSerialPort}`)
-        toast.success(`Connected to ${selectedSerialPort}`)
-      } else {
-        throw new Error(data.detail || 'Connection failed')
-      }
-    } catch (error) {
-      addSerialHistory('error', `Failed to connect: ${error}`)
-      toast.error('Failed to connect to serial port')
-    } finally {
-      setSerialLoading(false)
-    }
-  }
-
-  const handleSerialDisconnect = async () => {
-    setSerialLoading(true)
-    try {
-      await fetch('/api/debug-serial/close', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ port: selectedSerialPort }),
-      })
-      setSerialConnected(false)
-      addSerialHistory('resp', 'Disconnected')
-      toast.success('Disconnected from serial port')
-    } catch {
-      toast.error('Failed to disconnect')
-    } finally {
-      setSerialLoading(false)
-    }
-  }
-
-  const addSerialHistory = (type: 'cmd' | 'resp' | 'error', text: string) => {
-    const time = new Date().toLocaleTimeString()
-    setSerialHistory((prev) => [...prev.slice(-200), { type, text, time }])
-    setTimeout(() => {
-      if (serialOutputRef.current) {
-        serialOutputRef.current.scrollTop = serialOutputRef.current.scrollHeight
-      }
-    }, 10)
-  }
-
-  const serialInputRef = useRef<HTMLInputElement>(null)
-
-  const handleSerialSend = async () => {
-    if (!serialCommand.trim() || !serialConnected || serialLoading) return
-
-    const cmd = serialCommand.trim()
-    setSerialCommand('')
-    setSerialLoading(true)
-    addSerialHistory('cmd', cmd)
-
-    try {
-      const response = await fetch('/api/debug-serial/send', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ port: selectedSerialPort, command: cmd }),
-      })
-      const data = await response.json()
-      if (data.success) {
-        if (data.responses && data.responses.length > 0) {
-          data.responses.forEach((line: string) => addSerialHistory('resp', line))
-        } else {
-          addSerialHistory('resp', '(no response)')
-        }
-      } else {
-        addSerialHistory('error', data.detail || 'Command failed')
-      }
-    } catch (error) {
-      addSerialHistory('error', `Error: ${error}`)
-    } finally {
-      setSerialLoading(false)
-      // Keep focus on input after sending
-      serialInputRef.current?.focus()
-    }
-  }
-
-  const handleSerialKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
-    if (e.key === 'Enter' && !e.shiftKey) {
-      e.preventDefault()
-      e.stopPropagation()
-      // Keep focus on the input
-      const input = e.currentTarget
-      handleSerialSend()
-      // Ensure focus stays on input
-      requestAnimationFrame(() => input.focus())
-    }
-  }
-
-  // Fetch serial ports when terminal tab is selected
-  useEffect(() => {
-    if (isLogsOpen && logsDrawerTab === 'terminal') {
-      fetchSerialPorts()
-    }
-  }, [isLogsOpen, logsDrawerTab])
-
   const handleRestart = async () => {
     if (!confirm('Are you sure you want to restart Docker containers?')) return
 
@@ -1245,139 +1112,45 @@ export function Layout() {
               <div className="w-12 h-1 rounded-full bg-border group-hover:bg-primary transition-colors" />
             </div>
 
-            {/* Tab Header */}
+            {/* Logs Header */}
             <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50">
               <div className="flex items-center gap-3">
-                {/* Tab Buttons */}
-                <div className="flex rounded-md border bg-background p-0.5">
-                  <button
-                    onClick={() => setLogsDrawerTab('logs')}
-                    className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
-                      logsDrawerTab === 'logs'
-                        ? 'bg-primary text-primary-foreground'
-                        : 'hover:bg-muted'
-                    }`}
-                  >
-                    Logs
-                  </button>
-                  <button
-                    onClick={() => setLogsDrawerTab('terminal')}
-                    className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
-                      logsDrawerTab === 'terminal'
-                        ? 'bg-primary text-primary-foreground'
-                        : 'hover:bg-muted'
-                    }`}
-                  >
-                    Serial Terminal
-                  </button>
-                </div>
-
-                {/* Logs tab controls */}
-                {logsDrawerTab === 'logs' && (
-                  <>
-                    <select
-                      value={logLevelFilter}
-                      onChange={(e) => setLogLevelFilter(e.target.value)}
-                      className="text-xs bg-background border rounded px-2 py-1"
-                    >
-                      <option value="ALL">All Levels</option>
-                      <option value="DEBUG">Debug</option>
-                      <option value="INFO">Info</option>
-                      <option value="WARNING">Warning</option>
-                      <option value="ERROR">Error</option>
-                    </select>
-                    <span className="text-xs text-muted-foreground">
-                      {filteredLogs.length} entries
-                    </span>
-                  </>
-                )}
-
-                {/* Serial terminal controls */}
-                {logsDrawerTab === 'terminal' && (
-                  <div className="flex items-center gap-2">
-                    <select
-                      value={selectedSerialPort}
-                      onChange={(e) => setSelectedSerialPort(e.target.value)}
-                      disabled={serialConnected || serialLoading}
-                      className="text-xs bg-background border rounded px-2 py-1 min-w-[140px]"
-                    >
-                      <option value="">Select port...</option>
-                      {serialPorts.map((port) => (
-                        <option key={port} value={port}>{port}</option>
-                      ))}
-                    </select>
-                    <button
-                      onClick={fetchSerialPorts}
-                      disabled={serialConnected || serialLoading}
-                      className="text-xs text-muted-foreground hover:text-foreground"
-                      title="Refresh ports"
-                    >
-                      <span className="material-icons text-sm">refresh</span>
-                    </button>
-                    {!serialConnected ? (
-                      <Button
-                        size="sm"
-                        onClick={handleSerialConnect}
-                        disabled={!selectedSerialPort || serialLoading}
-                        className="h-6 text-xs px-2"
-                      >
-                        Connect
-                      </Button>
-                    ) : (
-                      <Button
-                        size="sm"
-                        variant="outline"
-                        onClick={handleSerialDisconnect}
-                        disabled={serialLoading}
-                        className="h-6 text-xs px-2"
-                      >
-                        Disconnect
-                      </Button>
-                    )}
-                    {serialConnected && (
-                      <span className="flex items-center gap-1 text-xs text-green-600">
-                        <span className="w-2 h-2 rounded-full bg-green-500" />
-                        Connected
-                      </span>
-                    )}
-                  </div>
-                )}
+                <span className="text-sm font-medium">Application Logs</span>
+                <select
+                  value={logLevelFilter}
+                  onChange={(e) => setLogLevelFilter(e.target.value)}
+                  className="text-xs bg-background border rounded px-2 py-1"
+                >
+                  <option value="ALL">All Levels</option>
+                  <option value="DEBUG">Debug</option>
+                  <option value="INFO">Info</option>
+                  <option value="WARNING">Warning</option>
+                  <option value="ERROR">Error</option>
+                </select>
+                <span className="text-xs text-muted-foreground">
+                  {filteredLogs.length} entries
+                </span>
               </div>
 
               <div className="flex items-center gap-1">
-                {logsDrawerTab === 'logs' && (
-                  <>
-                    <Button
-                      variant="ghost"
-                      size="icon-sm"
-                      onClick={handleCopyLogs}
-                      className="rounded-full"
-                      title="Copy logs"
-                    >
-                      <span className="material-icons-outlined text-base">content_copy</span>
-                    </Button>
-                    <Button
-                      variant="ghost"
-                      size="icon-sm"
-                      onClick={handleDownloadLogs}
-                      className="rounded-full"
-                      title="Download logs"
-                    >
-                      <span className="material-icons-outlined text-base">download</span>
-                    </Button>
-                  </>
-                )}
-                {logsDrawerTab === 'terminal' && serialHistory.length > 0 && (
-                  <Button
-                    variant="ghost"
-                    size="icon-sm"
-                    onClick={() => setSerialHistory([])}
-                    className="rounded-full"
-                    title="Clear history"
-                  >
-                    <span className="material-icons-outlined text-base">delete_sweep</span>
-                  </Button>
-                )}
+                <Button
+                  variant="ghost"
+                  size="icon-sm"
+                  onClick={handleCopyLogs}
+                  className="rounded-full"
+                  title="Copy logs"
+                >
+                  <span className="material-icons-outlined text-base">content_copy</span>
+                </Button>
+                <Button
+                  variant="ghost"
+                  size="icon-sm"
+                  onClick={handleDownloadLogs}
+                  className="rounded-full"
+                  title="Download logs"
+                >
+                  <span className="material-icons-outlined text-base">download</span>
+                </Button>
                 <Button
                   variant="ghost"
                   size="icon-sm"
@@ -1391,102 +1164,31 @@ export function Layout() {
             </div>
 
             {/* Logs Content */}
-            {logsDrawerTab === 'logs' && (
-              <div
-                ref={logsContainerRef}
-                className="h-[calc(100%-40px)] overflow-auto overscroll-contain p-3 font-mono text-xs space-y-0.5"
-              >
-                {filteredLogs.length > 0 ? (
-                  filteredLogs.map((log, i) => (
-                    <div key={i} className="py-0.5 flex gap-2">
-                      <span className="text-muted-foreground shrink-0">
-                        {formatTimestamp(log.timestamp)}
-                      </span>
-                      <span className={`shrink-0 font-semibold ${
-                        log.level === 'ERROR' ? 'text-red-500' :
-                        log.level === 'WARNING' ? 'text-amber-500' :
-                        log.level === 'DEBUG' ? 'text-muted-foreground' :
-                        'text-foreground'
-                      }`}>
-                        [{log.level || 'LOG'}]
-                      </span>
-                      <span className="break-all">{log.message || ''}</span>
-                    </div>
-                  ))
-                ) : (
-                  <p className="text-muted-foreground text-center py-4">No logs available</p>
-                )}
-              </div>
-            )}
-
-            {/* Serial Terminal Content */}
-            {logsDrawerTab === 'terminal' && (
-              <div className="h-[calc(100%-40px)] flex flex-col">
-                {/* Output area */}
-                <div
-                  ref={serialOutputRef}
-                  className="flex-1 overflow-auto overscroll-contain p-3 font-mono text-xs space-y-0.5 bg-black/5 dark:bg-white/5"
-                >
-                  {serialHistory.length > 0 ? (
-                    serialHistory.map((entry, i) => (
-                      <div key={i} className="py-0.5 flex gap-2">
-                        <span className="text-muted-foreground shrink-0">{entry.time}</span>
-                        {entry.type === 'cmd' ? (
-                          <>
-                            <span className="text-blue-500 shrink-0">&gt;</span>
-                            <span className="text-blue-600 dark:text-blue-400">{entry.text}</span>
-                          </>
-                        ) : entry.type === 'error' ? (
-                          <>
-                            <span className="text-red-500 shrink-0">!</span>
-                            <span className="text-red-500">{entry.text}</span>
-                          </>
-                        ) : (
-                          <>
-                            <span className="text-green-500 shrink-0">&lt;</span>
-                            <span className="text-foreground">{entry.text}</span>
-                          </>
-                        )}
-                      </div>
-                    ))
-                  ) : (
-                    <p className="text-muted-foreground text-center py-4">
-                      {serialConnected
-                        ? 'Type a command and press Enter to send'
-                        : 'Select a port and click Connect to start'}
-                    </p>
-                  )}
-                </div>
-                {/* Command input */}
-                <div className="flex items-center gap-3 px-3 py-3 pr-5 border-t bg-muted/30">
-                  <span className="text-muted-foreground font-mono text-base">&gt;</span>
-                  <input
-                    ref={serialInputRef}
-                    type="text"
-                    value={serialCommand}
-                    onChange={(e) => setSerialCommand(e.target.value)}
-                    onKeyDown={handleSerialKeyDown}
-                    disabled={!serialConnected}
-                    readOnly={serialLoading}
-                    placeholder={serialConnected ? 'Enter command (e.g., $, $$, ?, $H)' : 'Connect to send commands'}
-                    className="flex-1 bg-transparent border-none outline-none font-mono text-base placeholder:text-muted-foreground h-8"
-                    autoComplete="off"
-                  />
-                  <Button
-                    size="sm"
-                    onClick={handleSerialSend}
-                    disabled={!serialConnected || !serialCommand.trim() || serialLoading}
-                    className="h-8 px-4 shrink-0"
-                  >
-                    {serialLoading ? (
-                      <span className="material-icons animate-spin text-sm">sync</span>
-                    ) : (
-                      'Send'
-                    )}
-                  </Button>
-                </div>
-              </div>
-            )}
+            <div
+              ref={logsContainerRef}
+              className="h-[calc(100%-40px)] overflow-auto overscroll-contain p-3 font-mono text-xs space-y-0.5"
+            >
+              {filteredLogs.length > 0 ? (
+                filteredLogs.map((log, i) => (
+                  <div key={i} className="py-0.5 flex gap-2">
+                    <span className="text-muted-foreground shrink-0">
+                      {formatTimestamp(log.timestamp)}
+                    </span>
+                    <span className={`shrink-0 font-semibold ${
+                      log.level === 'ERROR' ? 'text-red-500' :
+                      log.level === 'WARNING' ? 'text-amber-500' :
+                      log.level === 'DEBUG' ? 'text-muted-foreground' :
+                      'text-foreground'
+                    }`}>
+                      [{log.level || 'LOG'}]
+                    </span>
+                    <span className="break-all">{log.message || ''}</span>
+                  </div>
+                ))
+              ) : (
+                <p className="text-muted-foreground text-center py-4">No logs available</p>
+              )}
+            </div>
           </>
         )}
       </div>

+ 2 - 2
frontend/src/pages/SettingsPage.tsx

@@ -1509,7 +1509,7 @@ export function SettingsPage() {
                 <div className="space-y-3">
                   <Label htmlFor="pixelOrder">Pixel Color Order</Label>
                   <Select
-                    value={ledConfig.pixel_order || 'GRB'}
+                    value={ledConfig.pixel_order || 'RGB'}
                     onValueChange={(value) =>
                       setLedConfig({ ...ledConfig, pixel_order: value })
                     }
@@ -1518,8 +1518,8 @@ export function SettingsPage() {
                       <SelectValue />
                     </SelectTrigger>
                     <SelectContent>
-                      <SelectItem value="GRB">GRB - WS2812/WS2812B (most common)</SelectItem>
                       <SelectItem value="RGB">RGB - WS2815/WS2811</SelectItem>
+                      <SelectItem value="GRB">GRB - WS2812/WS2812B</SelectItem>
                       <SelectItem value="GRBW">GRBW - SK6812 RGBW</SelectItem>
                       <SelectItem value="RGBW">RGBW - SK6812 variant</SelectItem>
                     </SelectContent>

+ 259 - 3
frontend/src/pages/TableControlPage.tsx

@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react'
+import { useState, useEffect, useRef } from 'react'
 import { toast } from 'sonner'
 import { Button } from '@/components/ui/button'
 import {
@@ -27,6 +27,7 @@ import {
   TooltipProvider,
   TooltipTrigger,
 } from '@/components/ui/tooltip'
+import { apiClient } from '@/lib/apiClient'
 
 export function TableControlPage() {
   const [speedInput, setSpeedInput] = useState('')
@@ -35,10 +36,20 @@ export function TableControlPage() {
   const [isLoading, setIsLoading] = useState<string | null>(null)
   const [isPatternRunning, setIsPatternRunning] = useState(false)
 
+  // Serial terminal state
+  const [serialPorts, setSerialPorts] = useState<string[]>([])
+  const [selectedSerialPort, setSelectedSerialPort] = useState('')
+  const [serialConnected, setSerialConnected] = useState(false)
+  const [serialCommand, setSerialCommand] = useState('')
+  const [serialHistory, setSerialHistory] = useState<Array<{ type: 'cmd' | 'resp' | 'error'; text: string; time: string }>>([])
+  const [serialLoading, setSerialLoading] = useState(false)
+  const [mainConnectionPort, setMainConnectionPort] = useState<string | null>(null)
+  const serialOutputRef = useRef<HTMLDivElement>(null)
+  const serialInputRef = useRef<HTMLInputElement>(null)
+
   // Connect to status WebSocket to get current speed and playback status
   useEffect(() => {
-    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
-    const ws = new WebSocket(`${protocol}//${window.location.host}/ws/status`)
+    const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/status'))
 
     ws.onmessage = (event) => {
       try {
@@ -183,6 +194,122 @@ export function TableControlPage() {
     }
   }
 
+  // Serial terminal functions
+  const fetchSerialPorts = async () => {
+    try {
+      const data = await apiClient.get<string[]>('/list_serial_ports')
+      setSerialPorts(Array.isArray(data) ? data : [])
+    } catch {
+      toast.error('Failed to fetch serial ports')
+    }
+  }
+
+  const fetchMainConnectionStatus = async () => {
+    try {
+      const data = await apiClient.get<{ connected: boolean; port?: string }>('/serial_status')
+      if (data.connected && data.port) {
+        setMainConnectionPort(data.port)
+        // Auto-select the connected port
+        setSelectedSerialPort(data.port)
+      }
+    } catch {
+      // Ignore errors
+    }
+  }
+
+  const handleSerialConnect = async () => {
+    if (!selectedSerialPort) {
+      toast.error('Please select a serial port')
+      return
+    }
+    setSerialLoading(true)
+    try {
+      await apiClient.post('/api/debug-serial/open', { port: selectedSerialPort })
+      setSerialConnected(true)
+      addSerialHistory('resp', `Connected to ${selectedSerialPort}`)
+      toast.success(`Connected to ${selectedSerialPort}`)
+    } catch (error) {
+      const errorMsg = error instanceof Error ? error.message : 'Unknown error'
+      addSerialHistory('error', `Failed to connect: ${errorMsg}`)
+      toast.error('Failed to connect to serial port')
+    } finally {
+      setSerialLoading(false)
+    }
+  }
+
+  const handleSerialDisconnect = async () => {
+    setSerialLoading(true)
+    try {
+      await apiClient.post('/api/debug-serial/close', { port: selectedSerialPort })
+      setSerialConnected(false)
+      addSerialHistory('resp', 'Disconnected')
+      toast.success('Disconnected from serial port')
+    } catch {
+      toast.error('Failed to disconnect')
+    } finally {
+      setSerialLoading(false)
+    }
+  }
+
+  const addSerialHistory = (type: 'cmd' | 'resp' | 'error', text: string) => {
+    const time = new Date().toLocaleTimeString()
+    setSerialHistory((prev) => [...prev.slice(-200), { type, text, time }])
+    setTimeout(() => {
+      if (serialOutputRef.current) {
+        serialOutputRef.current.scrollTop = serialOutputRef.current.scrollHeight
+      }
+    }, 10)
+  }
+
+  const handleSerialSend = async () => {
+    if (!serialCommand.trim() || !serialConnected || serialLoading) return
+
+    const cmd = serialCommand.trim()
+    setSerialCommand('')
+    setSerialLoading(true)
+    addSerialHistory('cmd', cmd)
+
+    try {
+      const data = await apiClient.post<{ responses?: string[]; detail?: string }>('/api/debug-serial/send', { port: selectedSerialPort, command: cmd })
+      if (data.responses) {
+        if (data.responses.length > 0) {
+          data.responses.forEach((line: string) => addSerialHistory('resp', line))
+        } else {
+          addSerialHistory('resp', '(no response)')
+        }
+      } else if (data.detail) {
+        addSerialHistory('error', data.detail || 'Command failed')
+      }
+    } catch (error) {
+      addSerialHistory('error', `Error: ${error}`)
+    } finally {
+      setSerialLoading(false)
+      setTimeout(() => serialInputRef.current?.focus(), 0)
+    }
+  }
+
+  const handleSerialKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+    if (e.key === 'Enter' && !e.shiftKey) {
+      e.preventDefault()
+      if (!serialLoading) {
+        handleSerialSend()
+      }
+    }
+  }
+
+  // Fetch serial ports and main connection status on mount
+  useEffect(() => {
+    fetchSerialPorts()
+    fetchMainConnectionStatus()
+  }, [])
+
+  // Auto-connect to the main connection port
+  useEffect(() => {
+    if (mainConnectionPort && selectedSerialPort === mainConnectionPort && !serialConnected && !serialLoading) {
+      handleSerialConnect()
+    }
+  }, [mainConnectionPort, selectedSerialPort])
+
   return (
     <TooltipProvider>
       <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-6">
@@ -490,6 +617,135 @@ export function TableControlPage() {
             </CardContent>
           </Card>
         </div>
+
+        {/* Serial Terminal */}
+        <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
+          <CardHeader className="pb-3">
+            <div className="flex items-center justify-between">
+              <div>
+                <CardTitle className="text-lg flex items-center gap-2">
+                  <span className="material-icons-outlined text-xl">terminal</span>
+                  Serial Terminal
+                </CardTitle>
+                <CardDescription>Send raw commands to the table controller</CardDescription>
+              </div>
+              <div className="flex items-center gap-2">
+                {/* Port selector */}
+                <select
+                  className="h-9 rounded-md border border-input bg-background px-3 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring"
+                  value={selectedSerialPort}
+                  onChange={(e) => setSelectedSerialPort(e.target.value)}
+                  disabled={serialConnected || serialLoading}
+                >
+                  <option value="">Select port...</option>
+                  {serialPorts.map((port) => (
+                    <option key={port} value={port}>{port}</option>
+                  ))}
+                </select>
+                <Button
+                  variant="ghost"
+                  size="icon"
+                  onClick={fetchSerialPorts}
+                  disabled={serialConnected || serialLoading}
+                  title="Refresh ports"
+                >
+                  <span className="material-icons-outlined">refresh</span>
+                </Button>
+                {!serialConnected ? (
+                  <Button
+                    size="sm"
+                    onClick={handleSerialConnect}
+                    disabled={!selectedSerialPort || serialLoading}
+                  >
+                    {serialLoading ? (
+                      <span className="material-icons-outlined animate-spin mr-1">sync</span>
+                    ) : (
+                      <span className="material-icons-outlined mr-1">power</span>
+                    )}
+                    Connect
+                  </Button>
+                ) : (
+                  <Button
+                    size="sm"
+                    variant="destructive"
+                    onClick={handleSerialDisconnect}
+                    disabled={serialLoading}
+                  >
+                    <span className="material-icons-outlined mr-1">power_off</span>
+                    Disconnect
+                  </Button>
+                )}
+                {serialHistory.length > 0 && (
+                  <Button
+                    variant="ghost"
+                    size="icon"
+                    onClick={() => setSerialHistory([])}
+                    title="Clear history"
+                  >
+                    <span className="material-icons-outlined">delete</span>
+                  </Button>
+                )}
+              </div>
+            </div>
+          </CardHeader>
+          <CardContent>
+            {/* Output area */}
+            <div
+              ref={serialOutputRef}
+              className="bg-black/90 rounded-md p-3 h-48 overflow-y-auto font-mono text-sm mb-3"
+            >
+              {serialHistory.length > 0 ? (
+                serialHistory.map((entry, i) => (
+                  <div
+                    key={i}
+                    className={`${
+                      entry.type === 'cmd'
+                        ? 'text-cyan-400'
+                        : entry.type === 'error'
+                          ? 'text-red-400'
+                          : 'text-green-400'
+                    }`}
+                  >
+                    <span className="text-gray-500 text-xs mr-2">{entry.time}</span>
+                    {entry.type === 'cmd' ? '> ' : ''}
+                    {entry.text}
+                  </div>
+                ))
+              ) : (
+                <div className="text-gray-500 italic">
+                  {serialConnected
+                    ? 'Ready. Enter a command below (e.g., $, $$, ?, $H)'
+                    : 'Connect to a serial port to send commands'}
+                </div>
+              )}
+            </div>
+
+            {/* Input area */}
+            <div className="flex gap-2">
+              <Input
+                ref={serialInputRef}
+                value={serialCommand}
+                onChange={(e) => setSerialCommand(e.target.value)}
+                onKeyDown={handleSerialKeyDown}
+                disabled={!serialConnected}
+                readOnly={serialLoading}
+                placeholder={serialConnected ? 'Enter command (e.g., $, $$, ?, $H)' : 'Connect to send commands'}
+                className="font-mono text-base h-11"
+              />
+              <Button
+                onClick={handleSerialSend}
+                disabled={!serialConnected || !serialCommand.trim() || serialLoading}
+                className="h-11 px-6"
+              >
+                {serialLoading ? (
+                  <span className="material-icons-outlined animate-spin">sync</span>
+                ) : (
+                  <span className="material-icons-outlined">send</span>
+                )}
+              </Button>
+            </div>
+          </CardContent>
+        </Card>
       </div>
     </TooltipProvider>
   )