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:
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user