feat: Improve dashboard performance and Claude terminal context

- Add trial limiting (300 max) and reduce polling to 15s for large studies
- Make dashboard layout wider with col-span adjustments
- Claude terminal now runs from Atomizer root for CLAUDE.md/skills access
- Add study context display in terminal on connect
- Add KaTeX math rendering styles for study reports
- Add surrogate tuner module for hyperparameter optimization
- Fix backend proxy to port 8001

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Antoine
2025-12-04 17:36:00 -05:00
parent 9eed4d81eb
commit f8b90156b3
13 changed files with 1481 additions and 141 deletions

View File

@@ -73,7 +73,8 @@ async def list_studies():
# Protocol 10: Read from Optuna SQLite database
if study_db.exists():
try:
conn = sqlite3.connect(str(study_db))
# Use timeout to avoid blocking on locked databases
conn = sqlite3.connect(str(study_db), timeout=2.0)
cursor = conn.cursor()
# Get trial count and status
@@ -130,6 +131,29 @@ async def list_studies():
config.get('trials', {}).get('n_trials', 50)
)
# Get creation date from directory or config modification time
created_at = None
try:
# First try to get from database (most accurate)
if study_db.exists():
created_at = datetime.fromtimestamp(study_db.stat().st_mtime).isoformat()
elif config_file.exists():
created_at = datetime.fromtimestamp(config_file.stat().st_mtime).isoformat()
else:
created_at = datetime.fromtimestamp(study_dir.stat().st_ctime).isoformat()
except:
created_at = None
# Get last modified time
last_modified = None
try:
if study_db.exists():
last_modified = datetime.fromtimestamp(study_db.stat().st_mtime).isoformat()
elif history_file.exists():
last_modified = datetime.fromtimestamp(history_file.stat().st_mtime).isoformat()
except:
last_modified = None
studies.append({
"id": study_dir.name,
"name": study_dir.name.replace("_", " ").title(),
@@ -140,7 +164,9 @@ async def list_studies():
},
"best_value": best_value,
"target": config.get('target', {}).get('value'),
"path": str(study_dir)
"path": str(study_dir),
"created_at": created_at,
"last_modified": last_modified
})
return {"studies": studies}

View File

@@ -2,6 +2,7 @@
Terminal WebSocket for Claude Code CLI
Provides a PTY-based terminal that runs Claude Code in the dashboard.
Uses pywinpty on Windows for proper interactive terminal support.
"""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
@@ -18,6 +19,13 @@ router = APIRouter()
# Store active terminal sessions
_terminal_sessions: dict = {}
# Check if winpty is available (for Windows)
try:
from winpty import PtyProcess
HAS_WINPTY = True
except ImportError:
HAS_WINPTY = False
class TerminalSession:
"""Manages a Claude Code terminal session."""
@@ -25,10 +33,11 @@ class TerminalSession:
def __init__(self, session_id: str, working_dir: str):
self.session_id = session_id
self.working_dir = working_dir
self.process: Optional[subprocess.Popen] = None
self.process = None
self.websocket: Optional[WebSocket] = None
self._read_task: Optional[asyncio.Task] = None
self._running = False
self._use_winpty = sys.platform == "win32" and HAS_WINPTY
async def start(self, websocket: WebSocket):
"""Start the Claude Code process."""
@@ -36,18 +45,34 @@ class TerminalSession:
self._running = True
# Determine the claude command
# On Windows, claude is typically installed via npm and available in PATH
claude_cmd = "claude"
# Check if we're on Windows
is_windows = sys.platform == "win32"
try:
if is_windows:
# On Windows, use subprocess with pipes
# We need to use cmd.exe to get proper terminal behavior
if self._use_winpty:
# Use winpty for proper PTY on Windows
# Spawn claude directly - winpty handles the interactive terminal
import shutil
claude_path = shutil.which("claude") or "claude"
# Ensure HOME and USERPROFILE are properly set for Claude CLI auth
env = {**os.environ}
env["FORCE_COLOR"] = "1"
env["TERM"] = "xterm-256color"
# Claude CLI looks for credentials in HOME/.claude or USERPROFILE/.claude
# Ensure these are set correctly
if "USERPROFILE" in env and "HOME" not in env:
env["HOME"] = env["USERPROFILE"]
self.process = PtyProcess.spawn(
claude_path,
cwd=self.working_dir,
env=env
)
elif sys.platform == "win32":
# Fallback: Windows without winpty - use subprocess
# Run claude with --dangerously-skip-permissions for non-interactive mode
self.process = subprocess.Popen(
["cmd.exe", "/c", claude_cmd],
["cmd.exe", "/k", claude_cmd],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
@@ -57,7 +82,7 @@ class TerminalSession:
env={**os.environ, "FORCE_COLOR": "1", "TERM": "xterm-256color"}
)
else:
# On Unix, we can use pty
# On Unix, use pty
import pty
master_fd, slave_fd = pty.openpty()
self.process = subprocess.Popen(
@@ -94,34 +119,71 @@ class TerminalSession:
async def _read_output(self):
"""Read output from the process and send to WebSocket."""
is_windows = sys.platform == "win32"
try:
while self._running and self.process and self.process.poll() is None:
if is_windows:
# Read from stdout pipe
if self.process.stdout:
# Use asyncio to read without blocking
while self._running:
if self._use_winpty:
# Read from winpty
if self.process and self.process.isalive():
loop = asyncio.get_event_loop()
try:
data = await loop.run_in_executor(
None,
lambda: self.process.stdout.read(1024)
lambda: self.process.read(4096)
)
if data:
await self.websocket.send_json({
"type": "output",
"data": data.decode("utf-8", errors="replace")
"data": data
})
except Exception:
break
else:
break
elif sys.platform == "win32":
# Windows subprocess pipe mode
if self.process and self.process.poll() is None:
if self.process.stdout:
loop = asyncio.get_event_loop()
try:
# Use non-blocking read with a timeout
import msvcrt
import ctypes
# Read available data
data = await loop.run_in_executor(
None,
lambda: self.process.stdout.read(1)
)
if data:
# Read more if available
more_data = b""
try:
# Try to read more without blocking
while True:
extra = self.process.stdout.read(1)
if extra:
more_data += extra
else:
break
except:
pass
full_data = data + more_data
await self.websocket.send_json({
"type": "output",
"data": full_data.decode("utf-8", errors="replace")
})
except Exception:
break
else:
break
else:
# Read from PTY master
# Unix PTY
loop = asyncio.get_event_loop()
try:
data = await loop.run_in_executor(
None,
lambda: os.read(self._master_fd, 1024)
lambda: os.read(self._master_fd, 4096)
)
if data:
await self.websocket.send_json({
@@ -135,7 +197,12 @@ class TerminalSession:
# Process ended
if self.websocket:
exit_code = self.process.poll() if self.process else -1
exit_code = -1
if self._use_winpty:
exit_code = self.process.exitstatus if self.process else -1
elif self.process:
exit_code = self.process.poll() if self.process.poll() is not None else -1
await self.websocket.send_json({
"type": "exit",
"code": exit_code
@@ -156,10 +223,10 @@ class TerminalSession:
if not self.process or not self._running:
return
is_windows = sys.platform == "win32"
try:
if is_windows:
if self._use_winpty:
self.process.write(data)
elif sys.platform == "win32":
if self.process.stdin:
self.process.stdin.write(data.encode())
self.process.stdin.flush()
@@ -173,8 +240,13 @@ class TerminalSession:
})
async def resize(self, cols: int, rows: int):
"""Resize the terminal (Unix only)."""
if sys.platform != "win32" and hasattr(self, '_master_fd'):
"""Resize the terminal."""
if self._use_winpty and self.process:
try:
self.process.setwinsize(rows, cols)
except:
pass
elif sys.platform != "win32" and hasattr(self, '_master_fd'):
import struct
import fcntl
import termios
@@ -194,14 +266,17 @@ class TerminalSession:
if self.process:
try:
if sys.platform == "win32":
if self._use_winpty:
self.process.terminate()
elif sys.platform == "win32":
self.process.terminate()
else:
os.kill(self.process.pid, signal.SIGTERM)
self.process.wait(timeout=2)
self.process.wait(timeout=2)
except:
try:
self.process.kill()
if hasattr(self.process, 'kill'):
self.process.kill()
except:
pass
@@ -213,16 +288,18 @@ class TerminalSession:
@router.websocket("/claude")
async def claude_terminal(websocket: WebSocket, working_dir: str = None):
async def claude_terminal(websocket: WebSocket, working_dir: str = None, study_id: str = None):
"""
WebSocket endpoint for Claude Code terminal.
Query params:
working_dir: Directory to start Claude Code in (defaults to Atomizer root)
study_id: Optional study ID to set context for Claude
Client -> Server messages:
{"type": "input", "data": "user input text"}
{"type": "resize", "cols": 80, "rows": 24}
{"type": "stop"}
Server -> Client messages:
{"type": "started", "message": "..."}
@@ -247,6 +324,11 @@ async def claude_terminal(websocket: WebSocket, working_dir: str = None):
# Start Claude Code
await session.start(websocket)
# Note: Claude is started in Atomizer root directory so it has access to:
# - CLAUDE.md (system instructions)
# - .claude/skills/ (skill definitions)
# The study_id is available for the user to reference in their prompts
# Handle incoming messages
while session._running:
try:
@@ -285,5 +367,6 @@ async def terminal_status():
return {
"available": claude_path is not None,
"path": claude_path,
"winpty_available": HAS_WINPTY,
"message": "Claude Code CLI is available" if claude_path else "Claude Code CLI not found. Install with: npm install -g @anthropic-ai/claude-code"
}