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

1
.gitignore vendored
View File

@@ -136,3 +136,4 @@ Pipfile.lock
*.temp *.temp
.tmp/ .tmp/
.temp/ .temp/
tmpclaude-*-cwd

View File

@@ -7,6 +7,7 @@ websockets>=13.0
python-multipart>=0.0.17 python-multipart>=0.0.17
psutil>=6.0.0 psutil>=6.0.0
aiofiles>=24.0.0 aiofiles>=24.0.0
pywinpty>=2.0.0; sys_platform == "win32"
# Dev dependencies # Dev dependencies
ruff>=0.8.0 ruff>=0.8.0

View File

@@ -34,17 +34,24 @@ from fastapi.staticfiles import StaticFiles
from .routers import ( from .routers import (
agent_router, agent_router,
assistant_chat_router, assistant_chat_router,
devserver_router,
expand_project_router, expand_project_router,
features_router, features_router,
filesystem_router, filesystem_router,
projects_router, projects_router,
settings_router, settings_router,
spec_creation_router, spec_creation_router,
terminal_router,
) )
from .schemas import SetupStatus from .schemas import SetupStatus
from .services.assistant_chat_session import cleanup_all_sessions as cleanup_assistant_sessions from .services.assistant_chat_session import cleanup_all_sessions as cleanup_assistant_sessions
from .services.dev_server_manager import (
cleanup_all_devservers,
cleanup_orphaned_devserver_locks,
)
from .services.expand_chat_session import cleanup_all_expand_sessions from .services.expand_chat_session import cleanup_all_expand_sessions
from .services.process_manager import cleanup_all_managers, cleanup_orphaned_locks from .services.process_manager import cleanup_all_managers, cleanup_orphaned_locks
from .services.terminal_manager import cleanup_all_terminals
from .websocket import project_websocket from .websocket import project_websocket
# Paths # Paths
@@ -57,11 +64,14 @@ async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown.""" """Lifespan context manager for startup and shutdown."""
# Startup - clean up orphaned lock files from previous runs # Startup - clean up orphaned lock files from previous runs
cleanup_orphaned_locks() cleanup_orphaned_locks()
cleanup_orphaned_devserver_locks()
yield yield
# Shutdown - cleanup all running agents and sessions # Shutdown - cleanup all running agents, sessions, terminals, and dev servers
await cleanup_all_managers() await cleanup_all_managers()
await cleanup_assistant_sessions() await cleanup_assistant_sessions()
await cleanup_all_expand_sessions() await cleanup_all_expand_sessions()
await cleanup_all_terminals()
await cleanup_all_devservers()
# Create FastAPI app # Create FastAPI app
@@ -110,11 +120,13 @@ async def require_localhost(request: Request, call_next):
app.include_router(projects_router) app.include_router(projects_router)
app.include_router(features_router) app.include_router(features_router)
app.include_router(agent_router) app.include_router(agent_router)
app.include_router(devserver_router)
app.include_router(spec_creation_router) app.include_router(spec_creation_router)
app.include_router(expand_project_router) app.include_router(expand_project_router)
app.include_router(filesystem_router) app.include_router(filesystem_router)
app.include_router(assistant_chat_router) app.include_router(assistant_chat_router)
app.include_router(settings_router) app.include_router(settings_router)
app.include_router(terminal_router)
# ============================================================================ # ============================================================================

View File

@@ -7,20 +7,24 @@ FastAPI routers for different API endpoints.
from .agent import router as agent_router from .agent import router as agent_router
from .assistant_chat import router as assistant_chat_router from .assistant_chat import router as assistant_chat_router
from .devserver import router as devserver_router
from .expand_project import router as expand_project_router from .expand_project import router as expand_project_router
from .features import router as features_router from .features import router as features_router
from .filesystem import router as filesystem_router from .filesystem import router as filesystem_router
from .projects import router as projects_router from .projects import router as projects_router
from .settings import router as settings_router from .settings import router as settings_router
from .spec_creation import router as spec_creation_router from .spec_creation import router as spec_creation_router
from .terminal import router as terminal_router
__all__ = [ __all__ = [
"projects_router", "projects_router",
"features_router", "features_router",
"agent_router", "agent_router",
"devserver_router",
"spec_creation_router", "spec_creation_router",
"expand_project_router", "expand_project_router",
"filesystem_router", "filesystem_router",
"assistant_chat_router", "assistant_chat_router",
"settings_router", "settings_router",
"terminal_router",
] ]

280
server/routers/devserver.py Normal file
View File

@@ -0,0 +1,280 @@
"""
Dev Server Router
=================
API endpoints for dev server control (start/stop) and configuration.
Uses project registry for path lookups and project_config for command detection.
"""
import re
import sys
from pathlib import Path
from fastapi import APIRouter, HTTPException
from ..schemas import (
DevServerActionResponse,
DevServerConfigResponse,
DevServerConfigUpdate,
DevServerStartRequest,
DevServerStatus,
)
from ..services.dev_server_manager import get_devserver_manager
from ..services.project_config import (
clear_dev_command,
get_dev_command,
get_project_config,
set_dev_command,
)
# Add root to path for registry import
_root = Path(__file__).parent.parent.parent
if str(_root) not in sys.path:
sys.path.insert(0, str(_root))
from registry import get_project_path as registry_get_project_path
def _get_project_path(project_name: str) -> Path | None:
"""Get project path from registry."""
return registry_get_project_path(project_name)
router = APIRouter(prefix="/api/projects/{project_name}/devserver", tags=["devserver"])
# ============================================================================
# Helper Functions
# ============================================================================
def validate_project_name(name: str) -> str:
"""Validate and sanitize project name to prevent path traversal."""
if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
raise HTTPException(
status_code=400,
detail="Invalid project name"
)
return name
def get_project_dir(project_name: str) -> Path:
"""
Get the validated project directory for a project name.
Args:
project_name: Name of the project
Returns:
Path to the project directory
Raises:
HTTPException: If project is not found or directory does not exist
"""
project_name = validate_project_name(project_name)
project_dir = _get_project_path(project_name)
if not project_dir:
raise HTTPException(
status_code=404,
detail=f"Project '{project_name}' not found in registry"
)
if not project_dir.exists():
raise HTTPException(
status_code=404,
detail=f"Project directory not found: {project_dir}"
)
return project_dir
def get_project_devserver_manager(project_name: str):
"""
Get the dev server process manager for a project.
Args:
project_name: Name of the project
Returns:
DevServerProcessManager instance for the project
Raises:
HTTPException: If project is not found or directory does not exist
"""
project_dir = get_project_dir(project_name)
return get_devserver_manager(project_name, project_dir)
# ============================================================================
# Endpoints
# ============================================================================
@router.get("/status", response_model=DevServerStatus)
async def get_devserver_status(project_name: str) -> DevServerStatus:
"""
Get the current status of the dev server for a project.
Returns information about whether the dev server is running,
its process ID, detected URL, and the command used to start it.
"""
manager = get_project_devserver_manager(project_name)
# Run healthcheck to detect crashed processes
await manager.healthcheck()
return DevServerStatus(
status=manager.status,
pid=manager.pid,
url=manager.detected_url,
command=manager._command,
started_at=manager.started_at,
)
@router.post("/start", response_model=DevServerActionResponse)
async def start_devserver(
project_name: str,
request: DevServerStartRequest = DevServerStartRequest(),
) -> DevServerActionResponse:
"""
Start the dev server for a project.
If a custom command is provided in the request, it will be used.
Otherwise, the effective command from the project configuration is used.
Args:
project_name: Name of the project
request: Optional start request with custom command
Returns:
Response indicating success/failure and current status
"""
manager = get_project_devserver_manager(project_name)
project_dir = get_project_dir(project_name)
# Determine which command to use
command: str | None
if request.command:
command = request.command
else:
command = get_dev_command(project_dir)
if not command:
raise HTTPException(
status_code=400,
detail="No dev command available. Configure a custom command or ensure project type can be detected."
)
# Now command is definitely str
success, message = await manager.start(command)
return DevServerActionResponse(
success=success,
status=manager.status,
message=message,
)
@router.post("/stop", response_model=DevServerActionResponse)
async def stop_devserver(project_name: str) -> DevServerActionResponse:
"""
Stop the dev server for a project.
Gracefully terminates the dev server process and all its child processes.
Args:
project_name: Name of the project
Returns:
Response indicating success/failure and current status
"""
manager = get_project_devserver_manager(project_name)
success, message = await manager.stop()
return DevServerActionResponse(
success=success,
status=manager.status,
message=message,
)
@router.get("/config", response_model=DevServerConfigResponse)
async def get_devserver_config(project_name: str) -> DevServerConfigResponse:
"""
Get the dev server configuration for a project.
Returns information about:
- detected_type: The auto-detected project type (nodejs-vite, python-django, etc.)
- detected_command: The default command for the detected type
- custom_command: Any user-configured custom command
- effective_command: The command that will actually be used (custom or detected)
Args:
project_name: Name of the project
Returns:
Configuration details for the project's dev server
"""
project_dir = get_project_dir(project_name)
config = get_project_config(project_dir)
return DevServerConfigResponse(
detected_type=config["detected_type"],
detected_command=config["detected_command"],
custom_command=config["custom_command"],
effective_command=config["effective_command"],
)
@router.patch("/config", response_model=DevServerConfigResponse)
async def update_devserver_config(
project_name: str,
update: DevServerConfigUpdate,
) -> DevServerConfigResponse:
"""
Update the dev server configuration for a project.
Set custom_command to a string to override the auto-detected command.
Set custom_command to null/None to clear the custom command and revert
to using the auto-detected command.
Args:
project_name: Name of the project
update: Configuration update containing the new custom_command
Returns:
Updated configuration details for the project's dev server
"""
project_dir = get_project_dir(project_name)
# Update the custom command
if update.custom_command is None:
# Clear the custom command
try:
clear_dev_command(project_dir)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
else:
# Set the custom command
try:
set_dev_command(project_dir, update.custom_command)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"Failed to save configuration: {e}"
)
# Return updated config
config = get_project_config(project_dir)
return DevServerConfigResponse(
detected_type=config["detected_type"],
detected_command=config["detected_command"],
custom_command=config["custom_command"],
effective_command=config["effective_command"],
)

273
server/routers/terminal.py Normal file
View File

@@ -0,0 +1,273 @@
"""
Terminal Router
===============
WebSocket endpoint for interactive terminal I/O with PTY support.
Provides real-time bidirectional communication with terminal sessions.
"""
import asyncio
import base64
import json
import logging
import re
import sys
from pathlib import Path
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from ..services.terminal_manager import get_terminal_session
# Add project root to path for registry import
_root = Path(__file__).parent.parent.parent
if str(_root) not in sys.path:
sys.path.insert(0, str(_root))
from registry import get_project_path as registry_get_project_path
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/terminal", tags=["terminal"])
class TerminalCloseCode:
"""WebSocket close codes for terminal endpoint."""
INVALID_PROJECT_NAME = 4000
PROJECT_NOT_FOUND = 4004
FAILED_TO_START = 4500
def _get_project_path(project_name: str) -> Path | None:
"""Get project path from registry."""
return registry_get_project_path(project_name)
def validate_project_name(name: str) -> bool:
"""
Validate project name to prevent path traversal attacks.
Allows only alphanumeric characters, underscores, and hyphens.
Maximum length of 50 characters.
Args:
name: The project name to validate
Returns:
True if valid, False otherwise
"""
return bool(re.match(r"^[a-zA-Z0-9_-]{1,50}$", name))
@router.websocket("/ws/{project_name}")
async def terminal_websocket(websocket: WebSocket, project_name: str) -> None:
"""
WebSocket endpoint for interactive terminal I/O.
Message protocol:
Client -> Server:
- {"type": "input", "data": "<base64-encoded-bytes>"} - Keyboard input
- {"type": "resize", "cols": 80, "rows": 24} - Terminal resize
- {"type": "ping"} - Keep-alive ping
Server -> Client:
- {"type": "output", "data": "<base64-encoded-bytes>"} - PTY output
- {"type": "exit", "code": 0} - Shell process exited
- {"type": "pong"} - Keep-alive response
- {"type": "error", "message": "..."} - Error message
"""
# Validate project name
if not validate_project_name(project_name):
await websocket.close(
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid project name"
)
return
# Look up project directory from registry
project_dir = _get_project_path(project_name)
if not project_dir:
await websocket.close(
code=TerminalCloseCode.PROJECT_NOT_FOUND,
reason="Project not found in registry",
)
return
if not project_dir.exists():
await websocket.close(
code=TerminalCloseCode.PROJECT_NOT_FOUND,
reason="Project directory not found",
)
return
await websocket.accept()
# Get or create terminal session for this project
session = get_terminal_session(project_name, project_dir)
# Queue for output data to send to client
output_queue: asyncio.Queue[bytes] = asyncio.Queue()
# Callback to receive terminal output and queue it for sending
def on_output(data: bytes) -> None:
"""Queue terminal output for async sending to WebSocket."""
try:
output_queue.put_nowait(data)
except asyncio.QueueFull:
logger.warning(f"Output queue full for {project_name}, dropping data")
# Register the output callback
session.add_output_callback(on_output)
# Start the terminal session if not already active
if not session.is_active:
started = await session.start()
if not started:
session.remove_output_callback(on_output)
try:
await websocket.send_json(
{"type": "error", "message": "Failed to start terminal session"}
)
except Exception:
pass
await websocket.close(
code=TerminalCloseCode.FAILED_TO_START, reason="Failed to start terminal"
)
return
# Task to send queued output to WebSocket
async def send_output_task() -> None:
"""Continuously send queued output to the WebSocket client."""
try:
while True:
# Wait for output data
data = await output_queue.get()
# Encode as base64 and send
encoded = base64.b64encode(data).decode("ascii")
await websocket.send_json({"type": "output", "data": encoded})
except asyncio.CancelledError:
raise
except WebSocketDisconnect:
raise
except Exception as e:
logger.warning(f"Error sending output for {project_name}: {e}")
raise
# Task to monitor if the terminal session exits
async def monitor_exit_task() -> None:
"""Monitor the terminal session and notify client on exit."""
try:
while session.is_active:
await asyncio.sleep(0.5)
# Session ended - send exit message
# Note: We don't have access to actual exit code from PTY
await websocket.send_json({"type": "exit", "code": 0})
except asyncio.CancelledError:
raise
except WebSocketDisconnect:
raise
except Exception as e:
logger.warning(f"Error in exit monitor for {project_name}: {e}")
# Start background tasks
output_task = asyncio.create_task(send_output_task())
exit_task = asyncio.create_task(monitor_exit_task())
try:
while True:
try:
# Receive message from client
data = await websocket.receive_text()
message = json.loads(data)
msg_type = message.get("type")
if msg_type == "ping":
await websocket.send_json({"type": "pong"})
elif msg_type == "input":
# Decode base64 input and write to PTY
encoded_data = message.get("data", "")
# Add size limit to prevent DoS
if len(encoded_data) > 65536: # 64KB limit for base64 encoded data
await websocket.send_json({"type": "error", "message": "Input too large"})
continue
if encoded_data:
try:
decoded = base64.b64decode(encoded_data)
except (ValueError, TypeError) as e:
logger.warning(f"Failed to decode base64 input: {e}")
await websocket.send_json(
{"type": "error", "message": "Invalid base64 data"}
)
continue
try:
session.write(decoded)
except Exception as e:
logger.warning(f"Failed to write to terminal: {e}")
await websocket.send_json(
{"type": "error", "message": "Failed to write to terminal"}
)
elif msg_type == "resize":
# Resize the terminal
cols = message.get("cols", 80)
rows = message.get("rows", 24)
# Validate dimensions
if isinstance(cols, int) and isinstance(rows, int):
cols = max(10, min(500, cols))
rows = max(5, min(200, rows))
session.resize(cols, rows)
else:
await websocket.send_json({"type": "error", "message": "Invalid resize dimensions"})
else:
await websocket.send_json({"type": "error", "message": f"Unknown message type: {msg_type}"})
except json.JSONDecodeError:
await websocket.send_json({"type": "error", "message": "Invalid JSON"})
except WebSocketDisconnect:
logger.info(f"Terminal WebSocket disconnected for {project_name}")
except Exception as e:
logger.exception(f"Terminal WebSocket error for {project_name}")
try:
await websocket.send_json({"type": "error", "message": f"Server error: {str(e)}"})
except Exception:
pass
finally:
# Cancel background tasks
output_task.cancel()
exit_task.cancel()
try:
await output_task
except asyncio.CancelledError:
pass
try:
await exit_task
except asyncio.CancelledError:
pass
# Remove the output callback
session.remove_output_callback(on_output)
# Only stop session if no other clients are connected
with session._callbacks_lock:
remaining_callbacks = len(session._output_callbacks)
if remaining_callbacks == 0:
await session.stop()
logger.info(f"Terminal session stopped for {project_name} (last client disconnected)")
else:
logger.info(
f"Client disconnected from {project_name}, {remaining_callbacks} clients remaining"
)

View File

@@ -308,3 +308,61 @@ class SettingsUpdate(BaseModel):
if v is not None and v not in VALID_MODELS: if v is not None and v not in VALID_MODELS:
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}") raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
return v return v
# ============================================================================
# Dev Server Schemas
# ============================================================================
class DevServerStartRequest(BaseModel):
"""Request schema for starting the dev server."""
command: str | None = None # If None, uses effective command from config
class DevServerStatus(BaseModel):
"""Current dev server status."""
status: Literal["stopped", "running", "crashed"]
pid: int | None = None
url: str | None = None
command: str | None = None
started_at: datetime | None = None
class DevServerActionResponse(BaseModel):
"""Response for dev server control actions."""
success: bool
status: Literal["stopped", "running", "crashed"]
message: str = ""
class DevServerConfigResponse(BaseModel):
"""Response for dev server configuration."""
detected_type: str | None = None
detected_command: str | None = None
custom_command: str | None = None
effective_command: str | None = None
class DevServerConfigUpdate(BaseModel):
"""Request schema for updating dev server configuration."""
custom_command: str | None = None # None clears the custom command
# ============================================================================
# Dev Server WebSocket Message Schemas
# ============================================================================
class WSDevLogMessage(BaseModel):
"""WebSocket message for dev server log output."""
type: Literal["dev_log"] = "dev_log"
line: str
timestamp: datetime
class WSDevServerStatusMessage(BaseModel):
"""WebSocket message for dev server status changes."""
type: Literal["dev_server_status"] = "dev_server_status"
status: Literal["stopped", "running", "crashed"]
url: str | None = None

View File

@@ -6,5 +6,31 @@ Business logic and process management services.
""" """
from .process_manager import AgentProcessManager from .process_manager import AgentProcessManager
from .project_config import (
clear_dev_command,
detect_project_type,
get_default_dev_command,
get_dev_command,
get_project_config,
set_dev_command,
)
from .terminal_manager import (
TerminalSession,
cleanup_all_terminals,
get_terminal_session,
remove_terminal_session,
)
__all__ = ["AgentProcessManager"] __all__ = [
"AgentProcessManager",
"TerminalSession",
"cleanup_all_terminals",
"clear_dev_command",
"detect_project_type",
"get_default_dev_command",
"get_dev_command",
"get_project_config",
"get_terminal_session",
"remove_terminal_session",
"set_dev_command",
]

View File

@@ -0,0 +1,556 @@
"""
Dev Server Process Manager
==========================
Manages the lifecycle of dev server subprocesses per project.
Provides start/stop functionality with cross-platform support via psutil.
This is a simplified version of AgentProcessManager, tailored for dev servers:
- No pause/resume (not needed for dev servers)
- URL detection from output (regex for http://localhost:XXXX patterns)
- Simpler status states: stopped, running, crashed
"""
import asyncio
import logging
import re
import subprocess
import sys
import threading
from datetime import datetime
from pathlib import Path
from typing import Awaitable, Callable, Literal, Set
import psutil
from registry import list_registered_projects
logger = logging.getLogger(__name__)
# Patterns for sensitive data that should be redacted from output
SENSITIVE_PATTERNS = [
r'sk-[a-zA-Z0-9]{20,}', # Anthropic API keys
r'ANTHROPIC_API_KEY=[^\s]+',
r'api[_-]?key[=:][^\s]+',
r'token[=:][^\s]+',
r'password[=:][^\s]+',
r'secret[=:][^\s]+',
r'ghp_[a-zA-Z0-9]{36,}', # GitHub personal access tokens
r'gho_[a-zA-Z0-9]{36,}', # GitHub OAuth tokens
r'ghs_[a-zA-Z0-9]{36,}', # GitHub server tokens
r'ghr_[a-zA-Z0-9]{36,}', # GitHub refresh tokens
r'aws[_-]?access[_-]?key[=:][^\s]+', # AWS keys
r'aws[_-]?secret[=:][^\s]+',
]
# Patterns to detect URLs in dev server output
# Matches common patterns like:
# - http://localhost:3000
# - http://127.0.0.1:5173
# - https://localhost:8080/
# - Local: http://localhost:3000
# - http://localhost:3000/api/docs
URL_PATTERNS = [
r'https?://(?:localhost|127\.0\.0\.1):\d+(?:/[^\s]*)?',
r'https?://\[::1\]:\d+(?:/[^\s]*)?', # IPv6 localhost
r'https?://0\.0\.0\.0:\d+(?:/[^\s]*)?', # Bound to all interfaces
]
def sanitize_output(line: str) -> str:
"""Remove sensitive information from output lines."""
for pattern in SENSITIVE_PATTERNS:
line = re.sub(pattern, '[REDACTED]', line, flags=re.IGNORECASE)
return line
def extract_url(line: str) -> str | None:
"""
Extract a localhost URL from an output line if present.
Returns the first URL found, or None if no URL is detected.
"""
for pattern in URL_PATTERNS:
match = re.search(pattern, line)
if match:
return match.group(0)
return None
class DevServerProcessManager:
"""
Manages dev server subprocess lifecycle for a single project.
Provides start/stop with cross-platform support via psutil.
Supports multiple output callbacks for WebSocket clients.
Detects and tracks the server URL from output.
"""
def __init__(
self,
project_name: str,
project_dir: Path,
):
"""
Initialize the dev server process manager.
Args:
project_name: Name of the project
project_dir: Absolute path to the project directory
"""
self.project_name = project_name
self.project_dir = project_dir
self.process: subprocess.Popen | None = None
self._status: Literal["stopped", "running", "crashed"] = "stopped"
self.started_at: datetime | None = None
self._output_task: asyncio.Task | None = None
self._detected_url: str | None = None
self._command: str | None = None # Store the command used to start
# Support multiple callbacks (for multiple WebSocket clients)
self._output_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
self._status_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
self._callbacks_lock = threading.Lock()
# Lock file to prevent multiple instances (stored in project directory)
self.lock_file = self.project_dir / ".devserver.lock"
@property
def status(self) -> Literal["stopped", "running", "crashed"]:
"""Current status of the dev server."""
return self._status
@status.setter
def status(self, value: Literal["stopped", "running", "crashed"]):
old_status = self._status
self._status = value
if old_status != value:
self._notify_status_change(value)
@property
def detected_url(self) -> str | None:
"""The URL detected from server output, if any."""
return self._detected_url
@property
def pid(self) -> int | None:
"""Process ID of the running dev server, or None if not running."""
return self.process.pid if self.process else None
def _notify_status_change(self, status: str) -> None:
"""Notify all registered callbacks of status change."""
with self._callbacks_lock:
callbacks = list(self._status_callbacks)
for callback in callbacks:
try:
# Schedule the callback in the event loop
loop = asyncio.get_running_loop()
loop.create_task(self._safe_callback(callback, status))
except RuntimeError:
# No running event loop
pass
async def _safe_callback(self, callback: Callable, *args) -> None:
"""Safely execute a callback, catching and logging any errors."""
try:
await callback(*args)
except Exception as e:
logger.warning(f"Callback error: {e}")
def add_output_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
"""Add a callback for output lines."""
with self._callbacks_lock:
self._output_callbacks.add(callback)
def remove_output_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
"""Remove an output callback."""
with self._callbacks_lock:
self._output_callbacks.discard(callback)
def add_status_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
"""Add a callback for status changes."""
with self._callbacks_lock:
self._status_callbacks.add(callback)
def remove_status_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
"""Remove a status callback."""
with self._callbacks_lock:
self._status_callbacks.discard(callback)
def _check_lock(self) -> bool:
"""
Check if another dev server is already running for this project.
Validates that the PID in the lock file belongs to a process running
in the same project directory to avoid false positives from PID recycling.
Returns:
True if we can proceed (no other server running), False otherwise.
"""
if not self.lock_file.exists():
return True
try:
pid = int(self.lock_file.read_text().strip())
if psutil.pid_exists(pid):
try:
proc = psutil.Process(pid)
if proc.is_running():
try:
# Verify the process is running in our project directory
# to avoid false positives from PID recycling
proc_cwd = Path(proc.cwd()).resolve()
if sys.platform == "win32":
# Windows paths are case-insensitive
if proc_cwd.as_posix().lower() == self.project_dir.resolve().as_posix().lower():
return False # Likely our dev server
else:
if proc_cwd == self.project_dir.resolve():
return False # Likely our dev server
except (psutil.AccessDenied, OSError):
# Cannot verify cwd, assume it's our process to be safe
return False
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
# Stale lock file - process no longer exists or is in different directory
self.lock_file.unlink(missing_ok=True)
return True
except (ValueError, OSError):
# Invalid lock file content - remove it
self.lock_file.unlink(missing_ok=True)
return True
def _create_lock(self) -> None:
"""Create lock file with current process PID."""
self.lock_file.parent.mkdir(parents=True, exist_ok=True)
if self.process:
self.lock_file.write_text(str(self.process.pid))
def _remove_lock(self) -> None:
"""Remove lock file."""
self.lock_file.unlink(missing_ok=True)
async def _broadcast_output(self, line: str) -> None:
"""Broadcast output line to all registered callbacks."""
with self._callbacks_lock:
callbacks = list(self._output_callbacks)
for callback in callbacks:
await self._safe_callback(callback, line)
async def _stream_output(self) -> None:
"""Stream process output to callbacks and detect URL."""
if not self.process or not self.process.stdout:
return
try:
loop = asyncio.get_running_loop()
while True:
# Use run_in_executor for blocking readline
line = await loop.run_in_executor(
None, self.process.stdout.readline
)
if not line:
break
decoded = line.decode("utf-8", errors="replace").rstrip()
sanitized = sanitize_output(decoded)
# Try to detect URL from output (only if not already detected)
if not self._detected_url:
url = extract_url(decoded)
if url:
self._detected_url = url
logger.info(
"Dev server URL detected for %s: %s",
self.project_name, url
)
await self._broadcast_output(sanitized)
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(f"Output streaming error: {e}")
finally:
# Check if process ended
if self.process and self.process.poll() is not None:
exit_code = self.process.returncode
if exit_code != 0 and self.status == "running":
self.status = "crashed"
elif self.status == "running":
self.status = "stopped"
self._remove_lock()
async def start(self, command: str) -> tuple[bool, str]:
"""
Start the dev server as a subprocess.
Args:
command: The shell command to run (e.g., "npm run dev")
Returns:
Tuple of (success, message)
"""
if self.status == "running":
return False, "Dev server is already running"
if not self._check_lock():
return False, "Another dev server instance is already running for this project"
# Validate that project directory exists
if not self.project_dir.exists():
return False, f"Project directory does not exist: {self.project_dir}"
self._command = command
self._detected_url = None # Reset URL detection
try:
# Determine shell based on platform
if sys.platform == "win32":
# On Windows, use cmd.exe
shell_cmd = ["cmd", "/c", command]
else:
# On Unix-like systems, use sh
shell_cmd = ["sh", "-c", command]
# Start subprocess with piped stdout/stderr
# stdin=DEVNULL prevents interactive dev servers from blocking on stdin
# On Windows, use CREATE_NO_WINDOW to prevent console window from flashing
if sys.platform == "win32":
self.process = subprocess.Popen(
shell_cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=str(self.project_dir),
creationflags=subprocess.CREATE_NO_WINDOW,
)
else:
self.process = subprocess.Popen(
shell_cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=str(self.project_dir),
)
self._create_lock()
self.started_at = datetime.now()
self.status = "running"
# Start output streaming task
self._output_task = asyncio.create_task(self._stream_output())
return True, f"Dev server started with PID {self.process.pid}"
except Exception as e:
logger.exception("Failed to start dev server")
return False, f"Failed to start dev server: {e}"
async def stop(self) -> tuple[bool, str]:
"""
Stop the dev server (SIGTERM then SIGKILL if needed).
Uses psutil to terminate the entire process tree, ensuring
child processes (like Node.js) are also terminated.
Returns:
Tuple of (success, message)
"""
if not self.process or self.status == "stopped":
return False, "Dev server is not running"
try:
# Cancel output streaming
if self._output_task:
self._output_task.cancel()
try:
await self._output_task
except asyncio.CancelledError:
pass
# Use psutil to terminate the entire process tree
# This is important for dev servers that spawn child processes
try:
parent = psutil.Process(self.process.pid)
children = parent.children(recursive=True)
# Terminate children first
for child in children:
try:
child.terminate()
except psutil.NoSuchProcess:
pass
# Terminate parent
parent.terminate()
# Wait for graceful shutdown
_, still_alive = psutil.wait_procs(
[parent] + children, timeout=5
)
# Force kill any remaining processes
for proc in still_alive:
try:
proc.kill()
except psutil.NoSuchProcess:
pass
except psutil.NoSuchProcess:
# Process already gone
pass
self._remove_lock()
self.status = "stopped"
self.process = None
self.started_at = None
self._detected_url = None
self._command = None
return True, "Dev server stopped"
except Exception as e:
logger.exception("Failed to stop dev server")
return False, f"Failed to stop dev server: {e}"
async def healthcheck(self) -> bool:
"""
Check if the dev server process is still alive.
Updates status to 'crashed' if process has died unexpectedly.
Returns:
True if healthy, False otherwise
"""
if not self.process:
return self.status == "stopped"
poll = self.process.poll()
if poll is not None:
# Process has terminated
if self.status == "running":
self.status = "crashed"
self._remove_lock()
return False
return True
def get_status_dict(self) -> dict:
"""Get current status as a dictionary."""
return {
"status": self.status,
"pid": self.pid,
"started_at": self.started_at.isoformat() if self.started_at else None,
"detected_url": self._detected_url,
"command": self._command,
}
# Global registry of dev server managers per project with thread safety
_managers: dict[str, DevServerProcessManager] = {}
_managers_lock = threading.Lock()
def get_devserver_manager(project_name: str, project_dir: Path) -> DevServerProcessManager:
"""
Get or create a dev server process manager for a project (thread-safe).
Args:
project_name: Name of the project
project_dir: Absolute path to the project directory
Returns:
DevServerProcessManager instance for the project
"""
with _managers_lock:
if project_name in _managers:
manager = _managers[project_name]
# Update project_dir in case project was moved
if manager.project_dir.resolve() != project_dir.resolve():
logger.info(
f"Project {project_name} path updated: {manager.project_dir} -> {project_dir}"
)
manager.project_dir = project_dir
manager.lock_file = project_dir / ".devserver.lock"
return manager
_managers[project_name] = DevServerProcessManager(project_name, project_dir)
return _managers[project_name]
async def cleanup_all_devservers() -> None:
"""Stop all running dev servers. Called on server shutdown."""
with _managers_lock:
managers = list(_managers.values())
for manager in managers:
try:
if manager.status != "stopped":
await manager.stop()
except Exception as e:
logger.warning(f"Error stopping dev server for {manager.project_name}: {e}")
with _managers_lock:
_managers.clear()
def cleanup_orphaned_devserver_locks() -> int:
"""
Clean up orphaned dev server lock files from previous server runs.
Scans all registered projects for .devserver.lock files and removes them
if the referenced process is no longer running.
Returns:
Number of orphaned lock files cleaned up
"""
cleaned = 0
try:
projects = list_registered_projects()
for name, info in projects.items():
project_path = Path(info.get("path", ""))
if not project_path.exists():
continue
lock_file = project_path / ".devserver.lock"
if not lock_file.exists():
continue
try:
pid_str = lock_file.read_text().strip()
pid = int(pid_str)
# Check if process is still running
if psutil.pid_exists(pid):
try:
proc = psutil.Process(pid)
if proc.is_running():
# Process is still running, don't remove
logger.info(
"Found running dev server for project '%s' (PID %d)",
name, pid
)
continue
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
# Process not running - remove stale lock
lock_file.unlink(missing_ok=True)
cleaned += 1
logger.info("Removed orphaned dev server lock file for project '%s'", name)
except (ValueError, OSError) as e:
# Invalid lock file content - remove it
logger.warning(
"Removing invalid dev server lock file for project '%s': %s", name, e
)
lock_file.unlink(missing_ok=True)
cleaned += 1
except Exception as e:
logger.error("Error during dev server orphan cleanup: %s", e)
if cleaned:
logger.info("Cleaned up %d orphaned dev server lock file(s)", cleaned)
return cleaned

View File

@@ -0,0 +1,466 @@
"""
Project Configuration Service
=============================
Handles project type detection and dev command configuration.
Detects project types by scanning for configuration files and provides
default or custom dev commands for each project.
Configuration is stored in {project_dir}/.autocoder/config.json.
"""
import json
import logging
from pathlib import Path
from typing import TypedDict
# Python 3.11+ has tomllib in the standard library
try:
import tomllib
except ImportError:
tomllib = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
# =============================================================================
# Path Validation
# =============================================================================
def _validate_project_dir(project_dir: Path) -> Path:
"""
Validate and resolve the project directory.
Args:
project_dir: Path to the project directory.
Returns:
Resolved Path object.
Raises:
ValueError: If project_dir is not a valid directory.
"""
resolved = Path(project_dir).resolve()
if not resolved.exists():
raise ValueError(f"Project directory does not exist: {resolved}")
if not resolved.is_dir():
raise ValueError(f"Path is not a directory: {resolved}")
return resolved
# =============================================================================
# Type Definitions
# =============================================================================
class ProjectConfig(TypedDict):
"""Full project configuration response."""
detected_type: str | None
detected_command: str | None
custom_command: str | None
effective_command: str | None
# =============================================================================
# Project Type Definitions
# =============================================================================
# Mapping of project types to their default dev commands
PROJECT_TYPE_COMMANDS: dict[str, str] = {
"nodejs-vite": "npm run dev",
"nodejs-cra": "npm start",
"python-poetry": "poetry run python -m uvicorn main:app --reload",
"python-django": "python manage.py runserver",
"python-fastapi": "python -m uvicorn main:app --reload",
"rust": "cargo run",
"go": "go run .",
}
# =============================================================================
# Configuration File Handling
# =============================================================================
def _get_config_path(project_dir: Path) -> Path:
"""
Get the path to the project config file.
Args:
project_dir: Path to the project directory.
Returns:
Path to the .autocoder/config.json file.
"""
return project_dir / ".autocoder" / "config.json"
def _load_config(project_dir: Path) -> dict:
"""
Load the project configuration from disk.
Args:
project_dir: Path to the project directory.
Returns:
Configuration dictionary, or empty dict if file doesn't exist or is invalid.
"""
config_path = _get_config_path(project_dir)
if not config_path.exists():
return {}
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
if not isinstance(config, dict):
logger.warning(
"Invalid config format in %s: expected dict, got %s",
config_path, type(config).__name__
)
return {}
return config
except json.JSONDecodeError as e:
logger.warning("Failed to parse config at %s: %s", config_path, e)
return {}
except OSError as e:
logger.warning("Failed to read config at %s: %s", config_path, e)
return {}
def _save_config(project_dir: Path, config: dict) -> None:
"""
Save the project configuration to disk.
Creates the .autocoder directory if it doesn't exist.
Args:
project_dir: Path to the project directory.
config: Configuration dictionary to save.
Raises:
OSError: If the file cannot be written.
"""
config_path = _get_config_path(project_dir)
# Ensure the .autocoder directory exists
config_path.parent.mkdir(parents=True, exist_ok=True)
try:
with open(config_path, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
logger.debug("Saved config to %s", config_path)
except OSError as e:
logger.error("Failed to save config to %s: %s", config_path, e)
raise
# =============================================================================
# Project Type Detection
# =============================================================================
def _parse_package_json(project_dir: Path) -> dict | None:
"""
Parse package.json if it exists.
Args:
project_dir: Path to the project directory.
Returns:
Parsed package.json as dict, or None if not found or invalid.
"""
package_json_path = project_dir / "package.json"
if not package_json_path.exists():
return None
try:
with open(package_json_path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return data
return None
except (json.JSONDecodeError, OSError) as e:
logger.debug("Failed to parse package.json in %s: %s", project_dir, e)
return None
def _is_poetry_project(project_dir: Path) -> bool:
"""
Check if pyproject.toml indicates a Poetry project.
Parses pyproject.toml to look for [tool.poetry] section.
Falls back to simple file existence check if tomllib is not available.
Args:
project_dir: Path to the project directory.
Returns:
True if pyproject.toml exists and contains Poetry configuration.
"""
pyproject_path = project_dir / "pyproject.toml"
if not pyproject_path.exists():
return False
# If tomllib is available (Python 3.11+), parse and check for [tool.poetry]
if tomllib is not None:
try:
with open(pyproject_path, "rb") as f:
data = tomllib.load(f)
return "poetry" in data.get("tool", {})
except Exception:
# If parsing fails, fall back to False
return False
# Fallback for older Python: simple file existence check
# This is less accurate but provides backward compatibility
return True
def detect_project_type(project_dir: Path) -> str | None:
"""
Detect the project type by scanning for configuration files.
Detection priority (first match wins):
1. package.json with scripts.dev -> nodejs-vite
2. package.json with scripts.start -> nodejs-cra
3. pyproject.toml with [tool.poetry] -> python-poetry
4. manage.py -> python-django
5. requirements.txt + (main.py or app.py) -> python-fastapi
6. Cargo.toml -> rust
7. go.mod -> go
Args:
project_dir: Path to the project directory.
Returns:
Project type string (e.g., "nodejs-vite", "python-django"),
or None if no known project type is detected.
"""
project_dir = Path(project_dir).resolve()
if not project_dir.exists() or not project_dir.is_dir():
logger.debug("Project directory does not exist: %s", project_dir)
return None
# Check for Node.js projects (package.json)
package_json = _parse_package_json(project_dir)
if package_json is not None:
scripts = package_json.get("scripts", {})
if isinstance(scripts, dict):
# Check for 'dev' script first (typical for Vite, Next.js, etc.)
if "dev" in scripts:
logger.debug("Detected nodejs-vite project in %s", project_dir)
return "nodejs-vite"
# Fall back to 'start' script (typical for CRA)
if "start" in scripts:
logger.debug("Detected nodejs-cra project in %s", project_dir)
return "nodejs-cra"
# Check for Python Poetry project (must have [tool.poetry] in pyproject.toml)
if _is_poetry_project(project_dir):
logger.debug("Detected python-poetry project in %s", project_dir)
return "python-poetry"
# Check for Django project
if (project_dir / "manage.py").exists():
logger.debug("Detected python-django project in %s", project_dir)
return "python-django"
# Check for Python FastAPI project (requirements.txt + main.py or app.py)
if (project_dir / "requirements.txt").exists():
has_main = (project_dir / "main.py").exists()
has_app = (project_dir / "app.py").exists()
if has_main or has_app:
logger.debug("Detected python-fastapi project in %s", project_dir)
return "python-fastapi"
# Check for Rust project
if (project_dir / "Cargo.toml").exists():
logger.debug("Detected rust project in %s", project_dir)
return "rust"
# Check for Go project
if (project_dir / "go.mod").exists():
logger.debug("Detected go project in %s", project_dir)
return "go"
logger.debug("No known project type detected in %s", project_dir)
return None
# =============================================================================
# Dev Command Functions
# =============================================================================
def get_default_dev_command(project_dir: Path) -> str | None:
"""
Get the auto-detected dev command for a project.
This returns the default command based on detected project type,
ignoring any custom command that may be configured.
Args:
project_dir: Path to the project directory.
Returns:
Default dev command string for the detected project type,
or None if no project type is detected.
"""
project_type = detect_project_type(project_dir)
if project_type is None:
return None
return PROJECT_TYPE_COMMANDS.get(project_type)
def get_dev_command(project_dir: Path) -> str | None:
"""
Get the effective dev command for a project.
Returns the custom command if one is configured,
otherwise returns the auto-detected default command.
Args:
project_dir: Path to the project directory.
Returns:
The effective dev command (custom if set, else detected),
or None if neither is available.
"""
project_dir = Path(project_dir).resolve()
# Check for custom command first
config = _load_config(project_dir)
custom_command = config.get("dev_command")
if custom_command and isinstance(custom_command, str):
# Type is narrowed to str by isinstance check
result: str = custom_command
return result
# Fall back to auto-detected command
return get_default_dev_command(project_dir)
def set_dev_command(project_dir: Path, command: str) -> None:
"""
Save a custom dev command for a project.
Args:
project_dir: Path to the project directory.
command: The custom dev command to save.
Raises:
ValueError: If command is empty or not a string, or if project_dir is invalid.
OSError: If the config file cannot be written.
"""
if not command or not isinstance(command, str):
raise ValueError("Command must be a non-empty string")
project_dir = _validate_project_dir(project_dir)
# Load existing config and update
config = _load_config(project_dir)
config["dev_command"] = command
_save_config(project_dir, config)
logger.info("Set custom dev command for %s: %s", project_dir.name, command)
def clear_dev_command(project_dir: Path) -> None:
"""
Remove the custom dev command, reverting to auto-detection.
If no config file exists or no custom command is set,
this function does nothing (no error is raised).
Args:
project_dir: Path to the project directory.
Raises:
ValueError: If project_dir is not a valid directory.
"""
project_dir = _validate_project_dir(project_dir)
config_path = _get_config_path(project_dir)
if not config_path.exists():
return
config = _load_config(project_dir)
if "dev_command" not in config:
return
del config["dev_command"]
# If config is now empty, delete the file
if not config:
try:
config_path.unlink(missing_ok=True)
logger.info("Removed empty config file for %s", project_dir.name)
# Also remove .autocoder directory if empty
autocoder_dir = config_path.parent
if autocoder_dir.exists() and not any(autocoder_dir.iterdir()):
autocoder_dir.rmdir()
logger.debug("Removed empty .autocoder directory for %s", project_dir.name)
except OSError as e:
logger.warning("Failed to clean up config for %s: %s", project_dir.name, e)
else:
_save_config(project_dir, config)
logger.info("Cleared custom dev command for %s", project_dir.name)
def get_project_config(project_dir: Path) -> ProjectConfig:
"""
Get the full project configuration including detection results.
This provides all relevant configuration information in a single call,
useful for displaying in a UI or debugging.
Args:
project_dir: Path to the project directory.
Returns:
ProjectConfig dict with:
- detected_type: The auto-detected project type (or None)
- detected_command: The default command for detected type (or None)
- custom_command: The user-configured custom command (or None)
- effective_command: The command that would actually be used (or None)
Raises:
ValueError: If project_dir is not a valid directory.
"""
project_dir = _validate_project_dir(project_dir)
# Detect project type and get default command
detected_type = detect_project_type(project_dir)
detected_command = PROJECT_TYPE_COMMANDS.get(detected_type) if detected_type else None
# Load custom command from config
config = _load_config(project_dir)
custom_command = config.get("dev_command")
# Validate custom_command is a string
if not isinstance(custom_command, str):
custom_command = None
# Determine effective command
effective_command = custom_command if custom_command else detected_command
return ProjectConfig(
detected_type=detected_type,
detected_command=detected_command,
custom_command=custom_command,
effective_command=effective_command,
)

View File

@@ -0,0 +1,563 @@
"""
Terminal Manager
================
Manages PTY terminal sessions per project with cross-platform support.
Uses winpty (ConPTY) on Windows and built-in pty module on Unix.
"""
import asyncio
import logging
import os
import platform
import shutil
import threading
from pathlib import Path
from typing import Callable, Set
logger = logging.getLogger(__name__)
# Platform detection
IS_WINDOWS = platform.system() == "Windows"
# Conditional imports for PTY support
# Note: Type checking is disabled for cross-platform PTY modules since mypy
# cannot properly handle conditional imports for platform-specific APIs.
if IS_WINDOWS:
try:
from winpty import PtyProcess as WinPtyProcess
WINPTY_AVAILABLE = True
except ImportError:
WinPtyProcess = None
WINPTY_AVAILABLE = False
logger.warning(
"winpty package not installed. Terminal sessions will not be available on Windows. "
"Install with: pip install pywinpty"
)
else:
# Unix systems use built-in pty module
import fcntl
import pty
import select
import signal
import struct
import termios
WINPTY_AVAILABLE = False # Not applicable on Unix
def _get_shell() -> str:
"""
Get the appropriate shell for the current platform.
Returns:
Path to shell executable
"""
if IS_WINDOWS:
# Prefer PowerShell, fall back to cmd.exe
powershell = shutil.which("powershell.exe")
if powershell:
return powershell
cmd = shutil.which("cmd.exe")
if cmd:
return cmd
# Last resort fallback
return "cmd.exe"
else:
# Unix: Use $SHELL environment variable or fall back to /bin/bash
shell = os.environ.get("SHELL")
if shell and shutil.which(shell):
return shell
# Fall back to common shells
for fallback in ["/bin/bash", "/bin/sh"]:
if os.path.exists(fallback):
return fallback
return "/bin/sh"
class TerminalSession:
"""
Manages a single PTY terminal session for a project.
Provides cross-platform PTY support with async output streaming
and multiple output callbacks for WebSocket clients.
"""
def __init__(self, project_name: str, project_dir: Path):
"""
Initialize the terminal session.
Args:
project_name: Name of the project
project_dir: Absolute path to the project directory (used as cwd)
"""
self.project_name = project_name
self.project_dir = project_dir
# PTY process references (platform-specific)
self._pty_process: "WinPtyProcess | None" = None # Windows winpty
self._master_fd: int | None = None # Unix master file descriptor
self._child_pid: int | None = None # Unix child process PID
# State tracking
self._is_active = False
self._output_task: asyncio.Task | None = None
# Output callbacks with thread-safe access
self._output_callbacks: Set[Callable[[bytes], None]] = set()
self._callbacks_lock = threading.Lock()
@property
def is_active(self) -> bool:
"""Check if the terminal session is currently active."""
return self._is_active
@property
def pid(self) -> int | None:
"""Get the PID of the PTY child process."""
if IS_WINDOWS:
if self._pty_process is not None:
try:
pid = self._pty_process.pid
return int(pid) if pid is not None else None
except Exception:
return None
return None
else:
return self._child_pid
def add_output_callback(self, callback: Callable[[bytes], None]) -> None:
"""
Add a callback to receive terminal output.
Args:
callback: Function that receives raw bytes from the PTY
"""
with self._callbacks_lock:
self._output_callbacks.add(callback)
def remove_output_callback(self, callback: Callable[[bytes], None]) -> None:
"""
Remove an output callback.
Args:
callback: The callback to remove
"""
with self._callbacks_lock:
self._output_callbacks.discard(callback)
def _broadcast_output(self, data: bytes) -> None:
"""Broadcast output data to all registered callbacks."""
with self._callbacks_lock:
callbacks = list(self._output_callbacks)
for callback in callbacks:
try:
callback(data)
except Exception as e:
logger.warning(f"Output callback error: {e}")
async def start(self, cols: int = 80, rows: int = 24) -> bool:
"""
Start the PTY terminal session.
Args:
cols: Terminal width in columns
rows: Terminal height in rows
Returns:
True if started successfully, False otherwise
"""
if self._is_active:
logger.warning(f"Terminal session already active for {self.project_name}")
return False
# Validate project directory
if not self.project_dir.exists():
logger.error(f"Project directory does not exist: {self.project_dir}")
return False
if not self.project_dir.is_dir():
logger.error(f"Project path is not a directory: {self.project_dir}")
return False
shell = _get_shell()
cwd = str(self.project_dir.resolve())
try:
if IS_WINDOWS:
return await self._start_windows(shell, cwd, cols, rows)
else:
return await self._start_unix(shell, cwd, cols, rows)
except Exception as e:
logger.exception(f"Failed to start terminal for {self.project_name}: {e}")
return False
async def _start_windows(self, shell: str, cwd: str, cols: int, rows: int) -> bool:
"""Start PTY on Windows using winpty."""
if not WINPTY_AVAILABLE:
logger.error("Cannot start terminal: winpty package not available")
# This error will be caught and sent to the client
raise RuntimeError(
"Terminal requires pywinpty on Windows. Install with: pip install pywinpty"
)
try:
# WinPtyProcess.spawn expects the shell command
self._pty_process = WinPtyProcess.spawn(
shell,
cwd=cwd,
dimensions=(rows, cols),
)
self._is_active = True
# Start output reading task
self._output_task = asyncio.create_task(self._read_output_windows())
logger.info(f"Terminal started for {self.project_name} (PID: {self.pid}, shell: {shell})")
return True
except Exception as e:
logger.exception(f"Failed to start Windows PTY: {e}")
self._pty_process = None
return False
async def _start_unix(self, shell: str, cwd: str, cols: int, rows: int) -> bool:
"""Start PTY on Unix using built-in pty module."""
# Note: This entire method uses Unix-specific APIs that don't exist on Windows.
# Type checking is disabled for these platform-specific calls.
try:
# Fork a new pseudo-terminal
pid, master_fd = pty.fork() # type: ignore[attr-defined]
if pid == 0:
# Child process - exec the shell
os.chdir(cwd)
# Set terminal size (Unix-specific modules imported at top-level)
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(0, termios.TIOCSWINSZ, winsize) # type: ignore[attr-defined]
# Execute the shell
os.execvp(shell, [shell])
os._exit(1) # Fallback if execvp returns (shouldn't happen)
else:
# Parent process
self._master_fd = master_fd
self._child_pid = pid
self._is_active = True
# Set terminal size on master (Unix-specific modules imported at top-level)
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(master_fd, termios.TIOCSWINSZ, winsize) # type: ignore[attr-defined]
# Start output reading task
self._output_task = asyncio.create_task(self._read_output_unix())
logger.info(f"Terminal started for {self.project_name} (PID: {pid}, shell: {shell})")
return True
except Exception as e:
logger.exception(f"Failed to start Unix PTY: {e}")
self._master_fd = None
self._child_pid = None
return False
async def _read_output_windows(self) -> None:
"""Read output from Windows PTY and broadcast to callbacks."""
pty = self._pty_process
if pty is None:
return
loop = asyncio.get_running_loop()
def read_data():
"""Read data from PTY, capturing pty reference to avoid race condition."""
try:
if pty.isalive():
return pty.read(4096)
except Exception:
pass
return b""
try:
while self._is_active and self._pty_process is not None:
try:
# Use run_in_executor for non-blocking read
# winpty read() is blocking, so we need to run it in executor
data = await loop.run_in_executor(None, read_data)
if data:
# winpty may return string, convert to bytes if needed
if isinstance(data, str):
data = data.encode("utf-8", errors="replace")
self._broadcast_output(data)
else:
# Check if process is still alive
if self._pty_process is None or not self._pty_process.isalive():
break
# Small delay to prevent busy loop
await asyncio.sleep(0.01)
except asyncio.CancelledError:
raise
except Exception as e:
if self._is_active:
logger.warning(f"Windows PTY read error: {e}")
break
except asyncio.CancelledError:
pass
finally:
if self._is_active:
self._is_active = False
logger.info(f"Terminal output stream ended for {self.project_name}")
async def _read_output_unix(self) -> None:
"""Read output from Unix PTY and broadcast to callbacks."""
if self._master_fd is None:
return
loop = asyncio.get_running_loop()
try:
while self._is_active and self._master_fd is not None:
try:
# Use run_in_executor with select for non-blocking read
def read_with_select():
if self._master_fd is None:
return b""
try:
# Wait up to 100ms for data
readable, _, _ = select.select([self._master_fd], [], [], 0.1)
if readable:
return os.read(self._master_fd, 4096)
return b""
except (OSError, ValueError):
return b""
data = await loop.run_in_executor(None, read_with_select)
if data:
self._broadcast_output(data)
elif not self._check_child_alive():
break
except asyncio.CancelledError:
raise
except Exception as e:
if self._is_active:
logger.warning(f"Unix PTY read error: {e}")
break
except asyncio.CancelledError:
pass
finally:
if self._is_active:
self._is_active = False
logger.info(f"Terminal output stream ended for {self.project_name}")
# Reap zombie if not already reaped
if self._child_pid is not None:
try:
os.waitpid(self._child_pid, os.WNOHANG)
except ChildProcessError:
pass
except Exception:
pass
def _check_child_alive(self) -> bool:
"""Check if the Unix child process is still alive."""
if self._child_pid is None:
return False
try:
# Use signal 0 to check if process exists without reaping it.
# This avoids race conditions with os.waitpid which can reap the process.
os.kill(self._child_pid, 0)
return True
except OSError:
return False
def write(self, data: bytes) -> None:
"""
Write input data to the PTY.
Args:
data: Raw bytes to write to the terminal
"""
if not self._is_active:
logger.warning(f"Cannot write to inactive terminal for {self.project_name}")
return
try:
if IS_WINDOWS:
if self._pty_process is not None:
# winpty expects string input
text = data.decode("utf-8", errors="replace")
self._pty_process.write(text)
else:
if self._master_fd is not None:
os.write(self._master_fd, data)
except Exception as e:
logger.warning(f"Failed to write to PTY: {e}")
def resize(self, cols: int, rows: int) -> None:
"""
Resize the terminal.
Args:
cols: New terminal width in columns
rows: New terminal height in rows
"""
if not self._is_active:
return
try:
if IS_WINDOWS:
if self._pty_process is not None:
self._pty_process.setwinsize(rows, cols)
else:
if self._master_fd is not None:
# Unix-specific modules imported at top-level
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize) # type: ignore[attr-defined]
logger.debug(f"Terminal resized for {self.project_name}: {cols}x{rows}")
except Exception as e:
logger.warning(f"Failed to resize terminal: {e}")
async def stop(self) -> None:
"""Stop the terminal session and clean up resources."""
if not self._is_active:
return
self._is_active = False
# Cancel output reading task
if self._output_task is not None:
self._output_task.cancel()
try:
await self._output_task
except asyncio.CancelledError:
pass
self._output_task = None
try:
if IS_WINDOWS:
await self._stop_windows()
else:
await self._stop_unix()
except Exception as e:
logger.warning(f"Error stopping terminal: {e}")
logger.info(f"Terminal stopped for {self.project_name}")
async def _stop_windows(self) -> None:
"""Stop Windows PTY process."""
if self._pty_process is None:
return
try:
if self._pty_process.isalive():
self._pty_process.terminate()
# Give it a moment to terminate
await asyncio.sleep(0.1)
if self._pty_process.isalive():
self._pty_process.kill()
except Exception as e:
logger.warning(f"Error terminating Windows PTY: {e}")
finally:
self._pty_process = None
async def _stop_unix(self) -> None:
"""Stop Unix PTY process."""
# Note: This method uses Unix-specific signal handling (signal imported at top-level)
# Close master file descriptor
if self._master_fd is not None:
try:
os.close(self._master_fd)
except OSError:
pass
self._master_fd = None
# Terminate child process
if self._child_pid is not None:
try:
os.kill(self._child_pid, signal.SIGTERM)
# Wait briefly for graceful shutdown
await asyncio.sleep(0.1)
# Check if still running and force kill if needed
try:
os.kill(self._child_pid, 0) # Check if process exists
# SIGKILL is Unix-specific (Windows would use SIGTERM)
os.kill(self._child_pid, signal.SIGKILL) # type: ignore[attr-defined]
except ProcessLookupError:
pass # Already terminated
# Reap the child process to prevent zombie
try:
os.waitpid(self._child_pid, 0)
except ChildProcessError:
pass
except ProcessLookupError:
pass # Already terminated
except Exception as e:
logger.warning(f"Error terminating Unix PTY child: {e}")
finally:
self._child_pid = None
# Global registry of terminal sessions per project with thread safety
_sessions: dict[str, TerminalSession] = {}
_sessions_lock = threading.Lock()
def get_terminal_session(project_name: str, project_dir: Path) -> TerminalSession:
"""
Get or create a terminal session for a project (thread-safe).
Args:
project_name: Name of the project
project_dir: Absolute path to the project directory
Returns:
TerminalSession instance for the project
"""
with _sessions_lock:
if project_name not in _sessions:
_sessions[project_name] = TerminalSession(project_name, project_dir)
return _sessions[project_name]
def remove_terminal_session(project_name: str) -> TerminalSession | None:
"""
Remove a terminal session from the registry.
Args:
project_name: Name of the project
Returns:
The removed session, or None if not found
"""
with _sessions_lock:
return _sessions.pop(project_name, None)
async def cleanup_all_terminals() -> None:
"""
Stop all active terminal sessions.
Called on server shutdown to ensure all PTY processes are terminated.
"""
with _sessions_lock:
sessions = list(_sessions.values())
for session in sessions:
try:
if session.is_active:
await session.stop()
except Exception as e:
logger.warning(f"Error stopping terminal for {session.project_name}: {e}")
with _sessions_lock:
_sessions.clear()
logger.info("All terminal sessions cleaned up")

View File

@@ -2,7 +2,7 @@
WebSocket Handlers WebSocket Handlers
================== ==================
Real-time updates for project progress and agent output. Real-time updates for project progress, agent output, and dev server output.
""" """
import asyncio import asyncio
@@ -15,6 +15,7 @@ from typing import Set
from fastapi import WebSocket, WebSocketDisconnect from fastapi import WebSocket, WebSocketDisconnect
from .services.dev_server_manager import get_devserver_manager
from .services.process_manager import get_manager from .services.process_manager import get_manager
# Lazy imports # Lazy imports
@@ -195,16 +196,52 @@ async def project_websocket(websocket: WebSocket, project_name: str):
agent_manager.add_output_callback(on_output) agent_manager.add_output_callback(on_output)
agent_manager.add_status_callback(on_status_change) agent_manager.add_status_callback(on_status_change)
# Get dev server manager and register callbacks
devserver_manager = get_devserver_manager(project_name, project_dir)
async def on_dev_output(line: str):
"""Handle dev server output - broadcast to this WebSocket."""
try:
await websocket.send_json({
"type": "dev_log",
"line": line,
"timestamp": datetime.now().isoformat(),
})
except Exception:
pass # Connection may be closed
async def on_dev_status_change(status: str):
"""Handle dev server status change - broadcast to this WebSocket."""
try:
await websocket.send_json({
"type": "dev_server_status",
"status": status,
"url": devserver_manager.detected_url,
})
except Exception:
pass # Connection may be closed
# Register dev server callbacks
devserver_manager.add_output_callback(on_dev_output)
devserver_manager.add_status_callback(on_dev_status_change)
# Start progress polling task # Start progress polling task
poll_task = asyncio.create_task(poll_progress(websocket, project_name, project_dir)) poll_task = asyncio.create_task(poll_progress(websocket, project_name, project_dir))
try: try:
# Send initial status # Send initial agent status
await websocket.send_json({ await websocket.send_json({
"type": "agent_status", "type": "agent_status",
"status": agent_manager.status, "status": agent_manager.status,
}) })
# Send initial dev server status
await websocket.send_json({
"type": "dev_server_status",
"status": devserver_manager.status,
"url": devserver_manager.detected_url,
})
# Send initial progress # Send initial progress
count_passing_tests = _get_count_passing_tests() count_passing_tests = _get_count_passing_tests()
passing, in_progress, total = count_passing_tests(project_dir) passing, in_progress, total = count_passing_tests(project_dir)
@@ -244,9 +281,13 @@ async def project_websocket(websocket: WebSocket, project_name: str):
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
# Unregister callbacks # Unregister agent callbacks
agent_manager.remove_output_callback(on_output) agent_manager.remove_output_callback(on_output)
agent_manager.remove_status_callback(on_status_change) agent_manager.remove_status_callback(on_status_change)
# Unregister dev server callbacks
devserver_manager.remove_output_callback(on_dev_output)
devserver_manager.remove_status_callback(on_dev_status_change)
# Disconnect from manager # Disconnect from manager
await manager.disconnect(websocket, project_name) await manager.disconnect(websocket, project_name)

28
ui/package-lock.json generated
View File

@@ -1,17 +1,20 @@
{ {
"name": "autonomous-coding-ui", "name": "autocoder",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "autonomous-coding-ui", "name": "autocoder",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-query": "^5.60.0", "@tanstack/react-query": "^5.60.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
@@ -2628,6 +2631,27 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-web-links": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",

View File

@@ -14,6 +14,9 @@
"@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-query": "^5.60.0", "@tanstack/react-query": "^5.60.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",

View File

@@ -13,12 +13,13 @@ import { ProgressDashboard } from './components/ProgressDashboard'
import { SetupWizard } from './components/SetupWizard' import { SetupWizard } from './components/SetupWizard'
import { AddFeatureForm } from './components/AddFeatureForm' import { AddFeatureForm } from './components/AddFeatureForm'
import { FeatureModal } from './components/FeatureModal' import { FeatureModal } from './components/FeatureModal'
import { DebugLogViewer } from './components/DebugLogViewer' import { DebugLogViewer, type TabType } from './components/DebugLogViewer'
import { AgentThought } from './components/AgentThought' import { AgentThought } from './components/AgentThought'
import { AssistantFAB } from './components/AssistantFAB' import { AssistantFAB } from './components/AssistantFAB'
import { AssistantPanel } from './components/AssistantPanel' import { AssistantPanel } from './components/AssistantPanel'
import { ExpandProjectModal } from './components/ExpandProjectModal' import { ExpandProjectModal } from './components/ExpandProjectModal'
import { SettingsModal } from './components/SettingsModal' import { SettingsModal } from './components/SettingsModal'
import { DevServerControl } from './components/DevServerControl'
import { Loader2, Settings } from 'lucide-react' import { Loader2, Settings } from 'lucide-react'
import type { Feature } from './lib/types' import type { Feature } from './lib/types'
@@ -37,6 +38,7 @@ function App() {
const [setupComplete, setSetupComplete] = useState(true) // Start optimistic const [setupComplete, setSetupComplete] = useState(true) // Start optimistic
const [debugOpen, setDebugOpen] = useState(false) const [debugOpen, setDebugOpen] = useState(false)
const [debugPanelHeight, setDebugPanelHeight] = useState(288) // Default height const [debugPanelHeight, setDebugPanelHeight] = useState(288) // Default height
const [debugActiveTab, setDebugActiveTab] = useState<TabType>('agent')
const [assistantOpen, setAssistantOpen] = useState(false) const [assistantOpen, setAssistantOpen] = useState(false)
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
const [isSpecCreating, setIsSpecCreating] = useState(false) const [isSpecCreating, setIsSpecCreating] = useState(false)
@@ -88,6 +90,22 @@ function App() {
setDebugOpen(prev => !prev) setDebugOpen(prev => !prev)
} }
// T : Toggle terminal tab in debug panel
if (e.key === 't' || e.key === 'T') {
e.preventDefault()
if (!debugOpen) {
// If panel is closed, open it and switch to terminal tab
setDebugOpen(true)
setDebugActiveTab('terminal')
} else if (debugActiveTab === 'terminal') {
// If already on terminal tab, close the panel
setDebugOpen(false)
} else {
// If open but on different tab, switch to terminal
setDebugActiveTab('terminal')
}
}
// N : Add new feature (when project selected) // N : Add new feature (when project selected)
if ((e.key === 'n' || e.key === 'N') && selectedProject) { if ((e.key === 'n' || e.key === 'N') && selectedProject) {
e.preventDefault() e.preventDefault()
@@ -133,7 +151,7 @@ function App() {
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, assistantOpen, features, showSettings, isSpecCreating]) }, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, isSpecCreating])
// Combine WebSocket progress with feature data // Combine WebSocket progress with feature data
const progress = wsState.progress.total > 0 ? wsState.progress : { const progress = wsState.progress.total > 0 ? wsState.progress : {
@@ -178,6 +196,12 @@ function App() {
status={wsState.agentStatus} status={wsState.agentStatus}
/> />
<DevServerControl
projectName={selectedProject}
status={wsState.devServerStatus}
url={wsState.devServerUrl}
/>
<button <button
onClick={() => setShowSettings(true)} onClick={() => setShowSettings(true)}
className="neo-btn text-sm py-2 px-3" className="neo-btn text-sm py-2 px-3"
@@ -285,10 +309,15 @@ function App() {
{selectedProject && ( {selectedProject && (
<DebugLogViewer <DebugLogViewer
logs={wsState.logs} logs={wsState.logs}
devLogs={wsState.devLogs}
isOpen={debugOpen} isOpen={debugOpen}
onToggle={() => setDebugOpen(!debugOpen)} onToggle={() => setDebugOpen(!debugOpen)}
onClear={wsState.clearLogs} onClear={wsState.clearLogs}
onClearDevLogs={wsState.clearDevLogs}
onHeightChange={setDebugPanelHeight} onHeightChange={setDebugPanelHeight}
projectName={selectedProject}
activeTab={debugActiveTab}
onTabChange={setDebugActiveTab}
/> />
)} )}

View File

@@ -3,49 +3,85 @@
* *
* Collapsible panel at the bottom of the screen showing real-time * Collapsible panel at the bottom of the screen showing real-time
* agent output (tool calls, results, steps). Similar to browser DevTools. * agent output (tool calls, results, steps). Similar to browser DevTools.
* Features a resizable height via drag handle. * Features a resizable height via drag handle and tabs for different log sources.
*/ */
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import { ChevronUp, ChevronDown, Trash2, Terminal, GripHorizontal } from 'lucide-react' import { ChevronUp, ChevronDown, Trash2, Terminal as TerminalIcon, GripHorizontal, Cpu, Server } from 'lucide-react'
import { Terminal } from './Terminal'
const MIN_HEIGHT = 150 const MIN_HEIGHT = 150
const MAX_HEIGHT = 600 const MAX_HEIGHT = 600
const DEFAULT_HEIGHT = 288 const DEFAULT_HEIGHT = 288
const STORAGE_KEY = 'debug-panel-height' const STORAGE_KEY = 'debug-panel-height'
const TAB_STORAGE_KEY = 'debug-panel-tab'
type TabType = 'agent' | 'devserver' | 'terminal'
interface DebugLogViewerProps { interface DebugLogViewerProps {
logs: Array<{ line: string; timestamp: string }> logs: Array<{ line: string; timestamp: string }>
devLogs: Array<{ line: string; timestamp: string }>
isOpen: boolean isOpen: boolean
onToggle: () => void onToggle: () => void
onClear: () => void onClear: () => void
onClearDevLogs: () => void
onHeightChange?: (height: number) => void onHeightChange?: (height: number) => void
projectName: string
activeTab?: TabType
onTabChange?: (tab: TabType) => void
} }
type LogLevel = 'error' | 'warn' | 'debug' | 'info' type LogLevel = 'error' | 'warn' | 'debug' | 'info'
export function DebugLogViewer({ export function DebugLogViewer({
logs, logs,
devLogs,
isOpen, isOpen,
onToggle, onToggle,
onClear, onClear,
onClearDevLogs,
onHeightChange, onHeightChange,
projectName,
activeTab: controlledActiveTab,
onTabChange,
}: DebugLogViewerProps) { }: DebugLogViewerProps) {
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const devScrollRef = useRef<HTMLDivElement>(null)
const [autoScroll, setAutoScroll] = useState(true) const [autoScroll, setAutoScroll] = useState(true)
const [devAutoScroll, setDevAutoScroll] = useState(true)
const [isResizing, setIsResizing] = useState(false) const [isResizing, setIsResizing] = useState(false)
const [panelHeight, setPanelHeight] = useState(() => { const [panelHeight, setPanelHeight] = useState(() => {
// Load saved height from localStorage // Load saved height from localStorage
const saved = localStorage.getItem(STORAGE_KEY) const saved = localStorage.getItem(STORAGE_KEY)
return saved ? Math.min(Math.max(parseInt(saved, 10), MIN_HEIGHT), MAX_HEIGHT) : DEFAULT_HEIGHT return saved ? Math.min(Math.max(parseInt(saved, 10), MIN_HEIGHT), MAX_HEIGHT) : DEFAULT_HEIGHT
}) })
const [internalActiveTab, setInternalActiveTab] = useState<TabType>(() => {
// Load saved tab from localStorage
const saved = localStorage.getItem(TAB_STORAGE_KEY)
return (saved as TabType) || 'agent'
})
// Auto-scroll to bottom when new logs arrive (if user hasn't scrolled up) // Use controlled tab if provided, otherwise use internal state
const activeTab = controlledActiveTab ?? internalActiveTab
const setActiveTab = (tab: TabType) => {
setInternalActiveTab(tab)
localStorage.setItem(TAB_STORAGE_KEY, tab)
onTabChange?.(tab)
}
// Auto-scroll to bottom when new agent logs arrive (if user hasn't scrolled up)
useEffect(() => { useEffect(() => {
if (autoScroll && scrollRef.current && isOpen) { if (autoScroll && scrollRef.current && isOpen && activeTab === 'agent') {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight scrollRef.current.scrollTop = scrollRef.current.scrollHeight
} }
}, [logs, autoScroll, isOpen]) }, [logs, autoScroll, isOpen, activeTab])
// Auto-scroll to bottom when new dev logs arrive (if user hasn't scrolled up)
useEffect(() => {
if (devAutoScroll && devScrollRef.current && isOpen && activeTab === 'devserver') {
devScrollRef.current.scrollTop = devScrollRef.current.scrollHeight
}
}, [devLogs, devAutoScroll, isOpen, activeTab])
// Notify parent of height changes // Notify parent of height changes
useEffect(() => { useEffect(() => {
@@ -91,13 +127,44 @@ export function DebugLogViewer({
setIsResizing(true) setIsResizing(true)
} }
// Detect if user scrolled up // Detect if user scrolled up (agent logs)
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => { const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget const el = e.currentTarget
const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 50 const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 50
setAutoScroll(isAtBottom) setAutoScroll(isAtBottom)
} }
// Detect if user scrolled up (dev logs)
const handleDevScroll = (e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget
const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 50
setDevAutoScroll(isAtBottom)
}
// Handle clear button based on active tab
const handleClear = () => {
if (activeTab === 'agent') {
onClear()
} else if (activeTab === 'devserver') {
onClearDevLogs()
}
// Terminal has no clear button (it's managed internally)
}
// Get the current log count based on active tab
const getCurrentLogCount = () => {
if (activeTab === 'agent') return logs.length
if (activeTab === 'devserver') return devLogs.length
return 0
}
// Check if current tab has auto-scroll paused
const isAutoScrollPaused = () => {
if (activeTab === 'agent') return !autoScroll
if (activeTab === 'devserver') return !devAutoScroll
return false
}
// Parse log level from line content // Parse log level from line content
const getLogLevel = (line: string): LogLevel => { const getLogLevel = (line: string): LogLevel => {
const lowerLine = line.toLowerCase() const lowerLine = line.toLowerCase()
@@ -164,35 +231,108 @@ export function DebugLogViewer({
{/* Header bar */} {/* Header bar */}
<div <div
className="flex items-center justify-between h-10 px-4 bg-[#1a1a1a] border-t-3 border-black cursor-pointer" className="flex items-center justify-between h-10 px-4 bg-[#1a1a1a] border-t-3 border-black"
onClick={onToggle}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Terminal size={16} className="text-green-400" /> {/* Collapse/expand toggle */}
<span className="font-mono text-sm text-white font-bold"> <button
Debug onClick={onToggle}
</span> className="flex items-center gap-2 hover:bg-[#333] px-2 py-1 rounded transition-colors cursor-pointer"
<span className="px-1.5 py-0.5 text-xs font-mono bg-[#333] text-gray-500 rounded" title="Toggle debug panel"> >
D <TerminalIcon size={16} className="text-green-400" />
</span> <span className="font-mono text-sm text-white font-bold">
{logs.length > 0 && ( Debug
<span className="px-2 py-0.5 text-xs font-mono bg-[#333] text-gray-300 rounded">
{logs.length}
</span> </span>
<span className="px-1.5 py-0.5 text-xs font-mono bg-[#333] text-gray-500 rounded" title="Toggle debug panel">
D
</span>
</button>
{/* Tabs - only visible when open */}
{isOpen && (
<div className="flex items-center gap-1 ml-4">
<button
onClick={(e) => {
e.stopPropagation()
setActiveTab('agent')
}}
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-mono rounded transition-colors ${
activeTab === 'agent'
? 'bg-[#333] text-white'
: 'text-gray-400 hover:text-white hover:bg-[#2a2a2a]'
}`}
>
<Cpu size={12} />
Agent
{logs.length > 0 && (
<span className="px-1.5 py-0.5 text-[10px] bg-[#444] rounded">
{logs.length}
</span>
)}
</button>
<button
onClick={(e) => {
e.stopPropagation()
setActiveTab('devserver')
}}
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-mono rounded transition-colors ${
activeTab === 'devserver'
? 'bg-[#333] text-white'
: 'text-gray-400 hover:text-white hover:bg-[#2a2a2a]'
}`}
>
<Server size={12} />
Dev Server
{devLogs.length > 0 && (
<span className="px-1.5 py-0.5 text-[10px] bg-[#444] rounded">
{devLogs.length}
</span>
)}
</button>
<button
onClick={(e) => {
e.stopPropagation()
setActiveTab('terminal')
}}
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-mono rounded transition-colors ${
activeTab === 'terminal'
? 'bg-[#333] text-white'
: 'text-gray-400 hover:text-white hover:bg-[#2a2a2a]'
}`}
>
<TerminalIcon size={12} />
Terminal
<span className="px-1.5 py-0.5 text-[10px] bg-[#444] text-gray-500 rounded" title="Toggle terminal">
T
</span>
</button>
</div>
)} )}
{!autoScroll && isOpen && (
<span className="px-2 py-0.5 text-xs font-mono bg-yellow-600 text-white rounded"> {/* Log count and status - only for log tabs */}
Paused {isOpen && activeTab !== 'terminal' && (
</span> <>
{getCurrentLogCount() > 0 && (
<span className="px-2 py-0.5 text-xs font-mono bg-[#333] text-gray-300 rounded ml-2">
{getCurrentLogCount()}
</span>
)}
{isAutoScrollPaused() && (
<span className="px-2 py-0.5 text-xs font-mono bg-yellow-600 text-white rounded">
Paused
</span>
)}
</>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isOpen && ( {/* Clear button - only for log tabs */}
{isOpen && activeTab !== 'terminal' && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onClear() handleClear()
}} }}
className="p-1.5 hover:bg-[#333] rounded transition-colors" className="p-1.5 hover:bg-[#333] rounded transition-colors"
title="Clear logs" title="Clear logs"
@@ -210,42 +350,95 @@ export function DebugLogViewer({
</div> </div>
</div> </div>
{/* Log content area */} {/* Content area */}
{isOpen && ( {isOpen && (
<div <div className="h-[calc(100%-2.5rem)] bg-[#1a1a1a]">
ref={scrollRef} {/* Agent Logs Tab */}
onScroll={handleScroll} {activeTab === 'agent' && (
className="h-[calc(100%-2.5rem)] overflow-y-auto bg-[#1a1a1a] p-2 font-mono text-sm" <div
> ref={scrollRef}
{logs.length === 0 ? ( onScroll={handleScroll}
<div className="flex items-center justify-center h-full text-gray-500"> className="h-full overflow-y-auto p-2 font-mono text-sm"
No logs yet. Start the agent to see output. >
</div> {logs.length === 0 ? (
) : ( <div className="flex items-center justify-center h-full text-gray-500">
<div className="space-y-0.5"> No logs yet. Start the agent to see output.
{logs.map((log, index) => { </div>
const level = getLogLevel(log.line) ) : (
const colorClass = getLogColor(level) <div className="space-y-0.5">
const timestamp = formatTimestamp(log.timestamp) {logs.map((log, index) => {
const level = getLogLevel(log.line)
const colorClass = getLogColor(level)
const timestamp = formatTimestamp(log.timestamp)
return ( return (
<div <div
key={`${log.timestamp}-${index}`} key={`${log.timestamp}-${index}`}
className="flex gap-2 hover:bg-[#2a2a2a] px-1 py-0.5 rounded" className="flex gap-2 hover:bg-[#2a2a2a] px-1 py-0.5 rounded"
> >
<span className="text-gray-500 select-none shrink-0"> <span className="text-gray-500 select-none shrink-0">
{timestamp} {timestamp}
</span> </span>
<span className={`${colorClass} whitespace-pre-wrap break-all`}> <span className={`${colorClass} whitespace-pre-wrap break-all`}>
{log.line} {log.line}
</span> </span>
</div> </div>
) )
})} })}
</div>
)}
</div> </div>
)} )}
{/* Dev Server Logs Tab */}
{activeTab === 'devserver' && (
<div
ref={devScrollRef}
onScroll={handleDevScroll}
className="h-full overflow-y-auto p-2 font-mono text-sm"
>
{devLogs.length === 0 ? (
<div className="flex items-center justify-center h-full text-gray-500">
No dev server logs yet.
</div>
) : (
<div className="space-y-0.5">
{devLogs.map((log, index) => {
const level = getLogLevel(log.line)
const colorClass = getLogColor(level)
const timestamp = formatTimestamp(log.timestamp)
return (
<div
key={`${log.timestamp}-${index}`}
className="flex gap-2 hover:bg-[#2a2a2a] px-1 py-0.5 rounded"
>
<span className="text-gray-500 select-none shrink-0">
{timestamp}
</span>
<span className={`${colorClass} whitespace-pre-wrap break-all`}>
{log.line}
</span>
</div>
)
})}
</div>
)}
</div>
)}
{/* Terminal Tab */}
{activeTab === 'terminal' && (
<Terminal
projectName={projectName}
isActive={activeTab === 'terminal'}
/>
)}
</div> </div>
)} )}
</div> </div>
) )
} }
// Export the TabType for use in parent components
export type { TabType }

View File

@@ -0,0 +1,155 @@
import { Globe, Square, Loader2, ExternalLink, AlertTriangle } from 'lucide-react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { DevServerStatus } from '../lib/types'
import { startDevServer, stopDevServer } from '../lib/api'
// Re-export DevServerStatus from lib/types for consumers that import from here
export type { DevServerStatus }
// ============================================================================
// React Query Hooks (Internal)
// ============================================================================
/**
* Internal hook to start the dev server for a project.
* Invalidates the dev-server-status query on success.
*/
function useStartDevServer(projectName: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => startDevServer(projectName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dev-server-status', projectName] })
},
})
}
/**
* Internal hook to stop the dev server for a project.
* Invalidates the dev-server-status query on success.
*/
function useStopDevServer(projectName: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => stopDevServer(projectName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dev-server-status', projectName] })
},
})
}
// ============================================================================
// Component
// ============================================================================
interface DevServerControlProps {
projectName: string
status: DevServerStatus
url: string | null
}
/**
* DevServerControl provides start/stop controls for a project's development server.
*
* Features:
* - Toggle button to start/stop the dev server
* - Shows loading state during operations
* - Displays clickable URL when server is running
* - Uses neobrutalism design with cyan accent when running
*/
export function DevServerControl({ projectName, status, url }: DevServerControlProps) {
const startDevServerMutation = useStartDevServer(projectName)
const stopDevServerMutation = useStopDevServer(projectName)
const isLoading = startDevServerMutation.isPending || stopDevServerMutation.isPending
const handleStart = () => {
// Clear any previous errors before starting
stopDevServerMutation.reset()
startDevServerMutation.mutate()
}
const handleStop = () => {
// Clear any previous errors before stopping
startDevServerMutation.reset()
stopDevServerMutation.mutate()
}
// Server is stopped when status is 'stopped' or 'crashed' (can restart)
const isStopped = status === 'stopped' || status === 'crashed'
// Server is in a running state
const isRunning = status === 'running'
// Server has crashed
const isCrashed = status === 'crashed'
return (
<div className="flex items-center gap-2">
{isStopped ? (
<button
onClick={handleStart}
disabled={isLoading}
className="neo-btn text-sm py-2 px-3"
style={isCrashed ? {
backgroundColor: 'var(--color-neo-danger)',
color: '#ffffff',
} : undefined}
title={isCrashed ? "Dev Server Crashed - Click to Restart" : "Start Dev Server"}
aria-label={isCrashed ? "Restart Dev Server (crashed)" : "Start Dev Server"}
>
{isLoading ? (
<Loader2 size={18} className="animate-spin" />
) : isCrashed ? (
<AlertTriangle size={18} />
) : (
<Globe size={18} />
)}
</button>
) : (
<button
onClick={handleStop}
disabled={isLoading}
className="neo-btn text-sm py-2 px-3"
style={{
backgroundColor: 'var(--color-neo-progress)',
color: '#ffffff',
}}
title="Stop Dev Server"
aria-label="Stop Dev Server"
>
{isLoading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Square size={18} />
)}
</button>
)}
{/* Show URL as clickable link when server is running */}
{isRunning && url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="neo-btn text-sm py-2 px-3 gap-1"
style={{
backgroundColor: 'var(--color-neo-progress)',
color: '#ffffff',
textDecoration: 'none',
}}
title={`Open ${url} in new tab`}
>
<span className="font-mono text-xs">{url}</span>
<ExternalLink size={14} />
</a>
)}
{/* Error display */}
{(startDevServerMutation.error || stopDevServerMutation.error) && (
<span className="text-xs font-mono text-[var(--color-neo-danger)] ml-2">
{String((startDevServerMutation.error || stopDevServerMutation.error)?.message || 'Operation failed')}
</span>
)}
</div>
)
}

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>
)
}

View File

@@ -3,7 +3,7 @@
*/ */
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import type { WSMessage, AgentStatus } from '../lib/types' import type { WSMessage, AgentStatus, DevServerStatus } from '../lib/types'
interface WebSocketState { interface WebSocketState {
progress: { progress: {
@@ -15,6 +15,9 @@ interface WebSocketState {
agentStatus: AgentStatus agentStatus: AgentStatus
logs: Array<{ line: string; timestamp: string }> logs: Array<{ line: string; timestamp: string }>
isConnected: boolean isConnected: boolean
devServerStatus: DevServerStatus
devServerUrl: string | null
devLogs: Array<{ line: string; timestamp: string }>
} }
const MAX_LOGS = 100 // Keep last 100 log lines const MAX_LOGS = 100 // Keep last 100 log lines
@@ -25,6 +28,9 @@ export function useProjectWebSocket(projectName: string | null) {
agentStatus: 'stopped', agentStatus: 'stopped',
logs: [], logs: [],
isConnected: false, isConnected: false,
devServerStatus: 'stopped',
devServerUrl: null,
devLogs: [],
}) })
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null)
@@ -86,6 +92,24 @@ export function useProjectWebSocket(projectName: string | null) {
// Feature updates will trigger a refetch via React Query // Feature updates will trigger a refetch via React Query
break break
case 'dev_log':
setState(prev => ({
...prev,
devLogs: [
...prev.devLogs.slice(-MAX_LOGS + 1),
{ line: message.line, timestamp: message.timestamp },
],
}))
break
case 'dev_server_status':
setState(prev => ({
...prev,
devServerStatus: message.status,
devServerUrl: message.url,
}))
break
case 'pong': case 'pong':
// Heartbeat response // Heartbeat response
break break
@@ -131,6 +155,9 @@ export function useProjectWebSocket(projectName: string | null) {
agentStatus: 'stopped', agentStatus: 'stopped',
logs: [], logs: [],
isConnected: false, isConnected: false,
devServerStatus: 'stopped',
devServerUrl: null,
devLogs: [],
}) })
if (!projectName) { if (!projectName) {
@@ -164,8 +191,14 @@ export function useProjectWebSocket(projectName: string | null) {
setState(prev => ({ ...prev, logs: [] })) setState(prev => ({ ...prev, logs: [] }))
}, []) }, [])
// Clear dev logs function
const clearDevLogs = useCallback(() => {
setState(prev => ({ ...prev, devLogs: [] }))
}, [])
return { return {
...state, ...state,
clearLogs, clearLogs,
clearDevLogs,
} }
} }

View File

@@ -21,6 +21,8 @@ import type {
Settings, Settings,
SettingsUpdate, SettingsUpdate,
ModelsResponse, ModelsResponse,
DevServerStatusResponse,
DevServerConfig,
} from './types' } from './types'
const API_BASE = '/api' const API_BASE = '/api'
@@ -301,3 +303,33 @@ export async function updateSettings(settings: SettingsUpdate): Promise<Settings
body: JSON.stringify(settings), body: JSON.stringify(settings),
}) })
} }
// ============================================================================
// Dev Server API
// ============================================================================
export async function getDevServerStatus(projectName: string): Promise<DevServerStatusResponse> {
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/status`)
}
export async function startDevServer(
projectName: string,
command?: string
): Promise<{ success: boolean; message: string }> {
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/start`, {
method: 'POST',
body: JSON.stringify({ command }),
})
}
export async function stopDevServer(
projectName: string
): Promise<{ success: boolean; message: string }> {
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/stop`, {
method: 'POST',
})
}
export async function getDevServerConfig(projectName: string): Promise<DevServerConfig> {
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/config`)
}

View File

@@ -107,8 +107,26 @@ export interface SetupStatus {
npm: boolean npm: boolean
} }
// Dev Server types
export type DevServerStatus = 'stopped' | 'running' | 'crashed'
export interface DevServerStatusResponse {
status: DevServerStatus
pid: number | null
url: string | null
command: string | null
started_at: string | null
}
export interface DevServerConfig {
detected_type: string | null
detected_command: string | null
custom_command: string | null
effective_command: string | null
}
// WebSocket message types // WebSocket message types
export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' | 'dev_log' | 'dev_server_status'
export interface WSProgressMessage { export interface WSProgressMessage {
type: 'progress' type: 'progress'
@@ -139,12 +157,26 @@ export interface WSPongMessage {
type: 'pong' type: 'pong'
} }
export interface WSDevLogMessage {
type: 'dev_log'
line: string
timestamp: string
}
export interface WSDevServerStatusMessage {
type: 'dev_server_status'
status: DevServerStatus
url: string | null
}
export type WSMessage = export type WSMessage =
| WSProgressMessage | WSProgressMessage
| WSFeatureUpdateMessage | WSFeatureUpdateMessage
| WSLogMessage | WSLogMessage
| WSAgentStatusMessage | WSAgentStatusMessage
| WSPongMessage | WSPongMessage
| WSDevLogMessage
| WSDevServerStatusMessage
// ============================================================================ // ============================================================================
// Spec Chat Types // Spec Chat Types

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/assistantchat.tsx","./src/components/assistantfab.tsx","./src/components/assistantpanel.tsx","./src/components/chatmessage.tsx","./src/components/confirmdialog.tsx","./src/components/debuglogviewer.tsx","./src/components/expandprojectchat.tsx","./src/components/expandprojectmodal.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/settingsmodal.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.ts","./src/hooks/useexpandchat.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"} {"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/assistantchat.tsx","./src/components/assistantfab.tsx","./src/components/assistantpanel.tsx","./src/components/chatmessage.tsx","./src/components/confirmdialog.tsx","./src/components/debuglogviewer.tsx","./src/components/devservercontrol.tsx","./src/components/expandprojectchat.tsx","./src/components/expandprojectmodal.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/settingsmodal.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/terminal.tsx","./src/components/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.ts","./src/hooks/useexpandchat.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"}