feat: add interactive terminal and dev server management

Add new features for interactive terminal sessions and dev server control:

Terminal Component:
- New Terminal.tsx component using xterm.js for full terminal emulation
- WebSocket-based PTY communication with bidirectional I/O
- Cross-platform support (Windows via winpty, Unix via built-in pty)
- Auto-reconnection with exponential backoff
- Fix duplicate WebSocket connection bug by checking CONNECTING state
- Add manual close flag to prevent auto-reconnect race conditions
- Add project tracking to avoid duplicate connects on initial activation

Dev Server Management:
- New DevServerControl.tsx for starting/stopping dev servers
- DevServerManager service for subprocess management
- WebSocket streaming of dev server output
- Project configuration service for reading package.json scripts

Backend Infrastructure:
- Terminal router with WebSocket endpoint for PTY I/O
- DevServer router for server lifecycle management
- Terminal session manager with callback-based output streaming
- Enhanced WebSocket schemas for terminal and dev server messages

UI Integration:
- New Terminal and Dev Server tabs in the main application
- Updated DebugLogViewer with improved UI and functionality
- Extended useWebSocket hook for terminal message handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Auto
2026-01-12 10:35:36 +02:00
parent b1473cdfb9
commit c1985eb285
22 changed files with 3360 additions and 66 deletions

View File

@@ -0,0 +1,512 @@
/**
* Interactive Terminal Component
*
* Full terminal emulation using xterm.js with WebSocket connection to the backend.
* Supports input/output streaming, terminal resizing, and reconnection handling.
*/
import { useEffect, useRef, useCallback, useState } from 'react'
import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
interface TerminalProps {
projectName: string
isActive: boolean
}
// WebSocket message types for terminal I/O
interface TerminalInputMessage {
type: 'input'
data: string // base64 encoded
}
interface TerminalResizeMessage {
type: 'resize'
cols: number
rows: number
}
interface TerminalOutputMessage {
type: 'output'
data: string // base64 encoded
}
interface TerminalExitMessage {
type: 'exit'
code: number
}
type TerminalServerMessage = TerminalOutputMessage | TerminalExitMessage
// Neobrutalism theme colors for xterm
const TERMINAL_THEME = {
background: '#1a1a1a',
foreground: '#ffffff',
cursor: '#ff006e', // --color-neo-accent
cursorAccent: '#1a1a1a',
selectionBackground: 'rgba(255, 0, 110, 0.3)',
selectionForeground: '#ffffff',
black: '#1a1a1a',
red: '#ff5400',
green: '#70e000',
yellow: '#ffd60a',
blue: '#00b4d8',
magenta: '#ff006e',
cyan: '#00b4d8',
white: '#ffffff',
brightBlack: '#4a4a4a',
brightRed: '#ff7733',
brightGreen: '#8fff00',
brightYellow: '#ffe44d',
brightBlue: '#33c7e6',
brightMagenta: '#ff4d94',
brightCyan: '#33c7e6',
brightWhite: '#ffffff',
}
// Reconnection configuration
const RECONNECT_DELAY_BASE = 1000
const RECONNECT_DELAY_MAX = 30000
export function Terminal({ projectName, isActive }: TerminalProps) {
const containerRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<XTerm | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<number | null>(null)
const reconnectAttempts = useRef(0)
const isInitializedRef = useRef(false)
const isConnectingRef = useRef(false)
const hasExitedRef = useRef(false)
// Track intentional disconnection to prevent auto-reconnect race condition
const isManualCloseRef = useRef(false)
// Store connect function in ref to avoid useEffect dependency issues
const connectRef = useRef<(() => void) | null>(null)
// Track last project to avoid duplicate connect on initial activation
const lastProjectRef = useRef<string | null>(null)
const [isConnected, setIsConnected] = useState(false)
const [hasExited, setHasExited] = useState(false)
const [exitCode, setExitCode] = useState<number | null>(null)
// Keep ref in sync with state for use in callbacks without re-creating them
useEffect(() => {
hasExitedRef.current = hasExited
}, [hasExited])
/**
* Encode string to base64
*/
const encodeBase64 = useCallback((str: string): string => {
// Handle Unicode by encoding to UTF-8 first
const encoder = new TextEncoder()
const bytes = encoder.encode(str)
let binary = ''
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}, [])
/**
* Decode base64 to string
*/
const decodeBase64 = useCallback((base64: string): string => {
try {
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
const decoder = new TextDecoder()
return decoder.decode(bytes)
} catch {
console.error('Failed to decode base64 data')
return ''
}
}, [])
/**
* Send a message through the WebSocket
*/
const sendMessage = useCallback(
(message: TerminalInputMessage | TerminalResizeMessage) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message))
}
},
[]
)
/**
* Send resize message to server
*/
const sendResize = useCallback(
(cols: number, rows: number) => {
const message: TerminalResizeMessage = {
type: 'resize',
cols,
rows,
}
sendMessage(message)
},
[sendMessage]
)
/**
* Fit terminal to container and notify server of new dimensions
*/
const fitTerminal = useCallback(() => {
if (fitAddonRef.current && terminalRef.current) {
try {
fitAddonRef.current.fit()
const { cols, rows } = terminalRef.current
sendResize(cols, rows)
} catch {
// Container may not be visible yet, ignore
}
}
}, [sendResize])
/**
* Connect to the terminal WebSocket
*/
const connect = useCallback(() => {
if (!projectName || !isActive) return
// Prevent multiple simultaneous connection attempts
if (
isConnectingRef.current ||
wsRef.current?.readyState === WebSocket.CONNECTING ||
wsRef.current?.readyState === WebSocket.OPEN
) {
return
}
isConnectingRef.current = true
// Clear any pending reconnection
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null
}
// Build WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const wsUrl = `${protocol}//${host}/api/terminal/ws/${encodeURIComponent(projectName)}`
try {
const ws = new WebSocket(wsUrl)
wsRef.current = ws
ws.onopen = () => {
isConnectingRef.current = false
setIsConnected(true)
setHasExited(false)
setExitCode(null)
reconnectAttempts.current = 0
// Send initial size after connection
if (terminalRef.current) {
const { cols, rows } = terminalRef.current
sendResize(cols, rows)
}
}
ws.onmessage = (event) => {
try {
const message: TerminalServerMessage = JSON.parse(event.data)
switch (message.type) {
case 'output': {
const decoded = decodeBase64(message.data)
if (decoded && terminalRef.current) {
terminalRef.current.write(decoded)
}
break
}
case 'exit': {
setHasExited(true)
setExitCode(message.code)
if (terminalRef.current) {
terminalRef.current.writeln('')
terminalRef.current.writeln(
`\x1b[33m[Shell exited with code ${message.code}]\x1b[0m`
)
terminalRef.current.writeln(
'\x1b[90mPress any key to reconnect...\x1b[0m'
)
}
break
}
}
} catch {
console.error('Failed to parse terminal WebSocket message')
}
}
ws.onclose = () => {
isConnectingRef.current = false
setIsConnected(false)
wsRef.current = null
// Only reconnect if still active, not intentionally exited, and not manually closed
// Use refs to avoid re-creating this callback when state changes
const shouldReconnect = isActive && !hasExitedRef.current && !isManualCloseRef.current
// Reset manual close flag after checking (so subsequent disconnects can auto-reconnect)
isManualCloseRef.current = false
if (shouldReconnect) {
// Exponential backoff reconnection
const delay = Math.min(
RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttempts.current),
RECONNECT_DELAY_MAX
)
reconnectAttempts.current++
reconnectTimeoutRef.current = window.setTimeout(() => {
connect()
}, delay)
}
}
ws.onerror = () => {
// Will trigger onclose, which handles reconnection
ws.close()
}
} catch {
isConnectingRef.current = false
// Failed to connect, attempt reconnection
const delay = Math.min(
RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttempts.current),
RECONNECT_DELAY_MAX
)
reconnectAttempts.current++
reconnectTimeoutRef.current = window.setTimeout(() => {
connect()
}, delay)
}
}, [projectName, isActive, sendResize, decodeBase64])
// Keep connect ref up to date
useEffect(() => {
connectRef.current = connect
}, [connect])
/**
* Initialize xterm.js terminal
*/
const initializeTerminal = useCallback(() => {
if (!containerRef.current || isInitializedRef.current) return
// Create terminal instance
const terminal = new XTerm({
theme: TERMINAL_THEME,
fontFamily: 'JetBrains Mono, Consolas, Monaco, monospace',
fontSize: 14,
cursorBlink: true,
cursorStyle: 'block',
allowProposedApi: true,
scrollback: 10000,
})
// Create and load FitAddon
const fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
// Open terminal in container
terminal.open(containerRef.current)
// Store references
terminalRef.current = terminal
fitAddonRef.current = fitAddon
isInitializedRef.current = true
// Initial fit
setTimeout(() => {
fitTerminal()
}, 0)
// Handle keyboard input
terminal.onData((data) => {
// If shell has exited, reconnect on any key
// Use ref to avoid re-creating this callback when hasExited changes
if (hasExitedRef.current) {
setHasExited(false)
setExitCode(null)
connectRef.current?.()
return
}
// Send input to server
const message: TerminalInputMessage = {
type: 'input',
data: encodeBase64(data),
}
sendMessage(message)
})
// Handle terminal resize
terminal.onResize(({ cols, rows }) => {
sendResize(cols, rows)
})
}, [fitTerminal, encodeBase64, sendMessage, sendResize])
/**
* Handle window resize
*/
useEffect(() => {
if (!isActive) return
const handleResize = () => {
fitTerminal()
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [isActive, fitTerminal])
/**
* Initialize terminal and WebSocket when becoming active
*/
useEffect(() => {
if (!isActive) {
// Clean up when becoming inactive
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null
}
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
return
}
// Initialize terminal if not already done
if (!isInitializedRef.current) {
initializeTerminal()
} else {
// Re-fit when becoming active again
setTimeout(() => {
fitTerminal()
}, 0)
}
// Connect WebSocket using ref to avoid dependency on connect callback
connectRef.current?.()
}, [isActive, initializeTerminal, fitTerminal])
/**
* Fit terminal when isActive becomes true
*/
useEffect(() => {
if (isActive && terminalRef.current) {
// Small delay to ensure container is visible
const timeoutId = setTimeout(() => {
fitTerminal()
terminalRef.current?.focus()
}, 100)
return () => clearTimeout(timeoutId)
}
}, [isActive, fitTerminal])
/**
* Cleanup on unmount
*/
useEffect(() => {
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
if (wsRef.current) {
wsRef.current.close()
}
if (terminalRef.current) {
terminalRef.current.dispose()
}
isInitializedRef.current = false
}
}, [])
/**
* Reconnect when project changes
*/
useEffect(() => {
if (isActive && isInitializedRef.current) {
// Only reconnect if project actually changed, not on initial activation
// This prevents duplicate connect calls when both isActive and projectName effects run
if (lastProjectRef.current === null) {
// Initial activation - just track the project, don't reconnect (the isActive effect handles initial connect)
lastProjectRef.current = projectName
return
}
if (lastProjectRef.current === projectName) {
// Project didn't change, skip
return
}
// Project changed - update tracking
lastProjectRef.current = projectName
// Clear terminal and reset cursor position
if (terminalRef.current) {
terminalRef.current.clear()
terminalRef.current.write('\x1b[H') // Move cursor to home position
}
// Set manual close flag to prevent auto-reconnect race condition
isManualCloseRef.current = true
// Close existing connection and reset connecting state
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
isConnectingRef.current = false
// Reset state
setHasExited(false)
setExitCode(null)
reconnectAttempts.current = 0
// Connect to new project using ref to avoid dependency on connect callback
connectRef.current?.()
}
}, [projectName, isActive])
return (
<div className="relative h-full w-full bg-[#1a1a1a]">
{/* Connection status indicator */}
<div className="absolute top-2 right-2 z-10 flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
isConnected ? 'bg-neo-done' : 'bg-neo-danger'
}`}
title={isConnected ? 'Connected' : 'Disconnected'}
/>
{!isConnected && !hasExited && (
<span className="text-xs font-mono text-gray-500">Connecting...</span>
)}
{hasExited && exitCode !== null && (
<span className="text-xs font-mono text-yellow-500">
Exit: {exitCode}
</span>
)}
</div>
{/* Terminal container */}
<div
ref={containerRef}
className="h-full w-full p-2"
style={{ minHeight: '100px' }}
/>
</div>
)
}