Files
Atomizer/atomizer-dashboard/backend/api/routes/terminal.py
Anto01 7c700c4606 feat: Dashboard improvements and configuration updates
Dashboard:
- Enhanced terminal components (ClaudeTerminal, GlobalClaudeTerminal)
- Improved MarkdownRenderer for better documentation display
- Updated convergence plots (ConvergencePlot, PlotlyConvergencePlot)
- Refined Home, Analysis, Dashboard, Setup, Results pages
- Added StudyContext improvements
- Updated vite.config for better dev experience

Configuration:
- Updated CLAUDE.md with latest instructions
- Enhanced launch_dashboard.py
- Updated config.py settings

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 13:47:05 -05:00

537 lines
20 KiB
Python

"""
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
from typing import Optional
import asyncio
import subprocess
import sys
import os
import signal
import json
router = APIRouter()
# Store active terminal sessions
_terminal_sessions: dict = {}
# Path to Atomizer root (for loading prompts)
# Go up 5 levels: terminal.py -> routes -> api -> backend -> atomizer-dashboard -> Atomizer
_file_path = os.path.abspath(__file__)
ATOMIZER_ROOT = os.path.normpath(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(_file_path))
))))
STUDIES_DIR = os.path.join(ATOMIZER_ROOT, "studies")
# Debug: print the resolved path at module load
print(f"[Terminal] ATOMIZER_ROOT resolved to: {ATOMIZER_ROOT}")
def resolve_study_path(study_id: str) -> str:
"""Find study folder by scanning topic directories.
Returns relative path from ATOMIZER_ROOT (e.g., 'studies/M1_Mirror/m1_mirror_adaptive_V14').
"""
# First check direct path (flat structure)
direct_path = os.path.join(STUDIES_DIR, study_id)
if os.path.isdir(direct_path):
setup_dir = os.path.join(direct_path, "1_setup")
config_file = os.path.join(direct_path, "optimization_config.json")
if os.path.exists(setup_dir) or os.path.exists(config_file):
return f"studies/{study_id}"
# Scan topic folders for nested structure
if os.path.isdir(STUDIES_DIR):
for topic_name in os.listdir(STUDIES_DIR):
topic_path = os.path.join(STUDIES_DIR, topic_name)
if os.path.isdir(topic_path) and not topic_name.startswith('.'):
study_path = os.path.join(topic_path, study_id)
if os.path.isdir(study_path):
setup_dir = os.path.join(study_path, "1_setup")
config_file = os.path.join(study_path, "optimization_config.json")
if os.path.exists(setup_dir) or os.path.exists(config_file):
return f"studies/{topic_name}/{study_id}"
# Fallback to flat path
return f"studies/{study_id}"
def get_session_prompt(study_name: str = None) -> str:
"""
Generate the initial prompt for a Claude session.
This injects the Protocol Operating System context and study-specific info.
"""
prompt_lines = [
"# Atomizer Session Context",
"",
"You are assisting with **Atomizer** - an LLM-first FEA optimization framework.",
"",
"## Bootstrap (READ FIRST)",
"",
"Read these files to understand how to help:",
"- `.claude/skills/00_BOOTSTRAP.md` - Task classification and routing",
"- `.claude/skills/01_CHEATSHEET.md` - Quick reference (I want X → Use Y)",
"- `.claude/skills/02_CONTEXT_LOADER.md` - What to load per task",
"",
"## Protocol System",
"",
"| Layer | Location | Purpose |",
"|-------|----------|---------|",
"| Operations | `docs/protocols/operations/OP_*.md` | How-to guides |",
"| System | `docs/protocols/system/SYS_*.md` | Core specs |",
"| Extensions | `docs/protocols/extensions/EXT_*.md` | Adding features |",
"",
]
if study_name:
# Resolve actual study path (handles nested folder structure)
study_path = resolve_study_path(study_name)
prompt_lines.extend([
f"## Current Study: `{study_name}`",
"",
f"**Directory**: `{study_path}/`",
"",
"Key files:",
f"- `{study_path}/1_setup/optimization_config.json` - Configuration",
f"- `{study_path}/3_results/study.db` - Optuna database",
f"- `{study_path}/README.md` - Study documentation",
"",
"Quick status check:",
"```bash",
f"python -c \"import optuna; s=optuna.load_study('{study_name}', 'sqlite:///{study_path}/3_results/study.db'); print(f'Trials: {{len(s.trials)}}, Best: {{s.best_value}}')\"",
"```",
"",
])
else:
prompt_lines.extend([
"## No Study Selected",
"",
"No specific study context. You can:",
"- List studies: `ls studies/`",
"- Create new study: Ask user what they want to optimize",
"- Load context: Read `.claude/skills/core/study-creation-core.md`",
"",
])
prompt_lines.extend([
"## Key Principles",
"",
"1. **Read bootstrap first** - Follow task routing from 00_BOOTSTRAP.md",
"2. **Use centralized extractors** - Check `optimization_engine/extractors/`",
"3. **Never modify master models** - Work on copies",
"4. **Python env**: Always use `conda activate atomizer`",
"",
"---",
"*Session launched from Atomizer Dashboard*",
])
return "\n".join(prompt_lines)
# Check if winpty is available (for Windows)
try:
from winpty import PtyProcess
HAS_WINPTY = True
print("[Terminal] winpty is available")
except ImportError as e:
HAS_WINPTY = False
print(f"[Terminal] winpty not available: {e}")
class TerminalSession:
"""Manages a Claude Code terminal session."""
def __init__(self, session_id: str, working_dir: str):
self.session_id = session_id
self.working_dir = working_dir
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."""
self.websocket = websocket
self._running = True
# Validate working directory exists
if not os.path.isdir(self.working_dir):
await self.websocket.send_json({
"type": "error",
"message": f"Working directory does not exist: {self.working_dir}"
})
self._running = False
return
try:
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
import shutil
claude_cmd = shutil.which("claude") or "claude"
self.process = subprocess.Popen(
["cmd.exe", "/k", claude_cmd],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=self.working_dir,
bufsize=0,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
env={**os.environ, "FORCE_COLOR": "1", "TERM": "xterm-256color"}
)
else:
# On Unix, use pty
import pty
import shutil
claude_cmd = shutil.which("claude") or "claude"
master_fd, slave_fd = pty.openpty()
self.process = subprocess.Popen(
[claude_cmd],
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
cwd=self.working_dir,
env={**os.environ, "TERM": "xterm-256color"}
)
os.close(slave_fd)
self._master_fd = master_fd
# Start reading output
self._read_task = asyncio.create_task(self._read_output())
await self.websocket.send_json({
"type": "started",
"message": f"Claude Code started in {self.working_dir}"
})
except FileNotFoundError:
await self.websocket.send_json({
"type": "error",
"message": "Claude Code CLI not found. Please install it with: npm install -g @anthropic-ai/claude-code"
})
self._running = False
except Exception as e:
await self.websocket.send_json({
"type": "error",
"message": f"Failed to start Claude Code: {str(e)}"
})
self._running = False
async def _read_output(self):
"""Read output from the process and send to WebSocket."""
try:
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.read(4096)
)
if data:
await self.websocket.send_json({
"type": "output",
"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:
# Unix PTY
loop = asyncio.get_event_loop()
try:
data = await loop.run_in_executor(
None,
lambda: os.read(self._master_fd, 4096)
)
if data:
await self.websocket.send_json({
"type": "output",
"data": data.decode("utf-8", errors="replace")
})
except OSError:
break
await asyncio.sleep(0.01)
# Process ended
if self.websocket:
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
})
except Exception as e:
if self.websocket:
try:
await self.websocket.send_json({
"type": "error",
"message": str(e)
})
except:
pass
async def write(self, data: str):
"""Write input to the process."""
if not self.process or not self._running:
return
try:
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()
else:
os.write(self._master_fd, data.encode())
except Exception as e:
if self.websocket:
await self.websocket.send_json({
"type": "error",
"message": f"Write error: {str(e)}"
})
async def resize(self, cols: int, rows: int):
"""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
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize)
async def stop(self):
"""Stop the terminal session."""
self._running = False
if self._read_task:
self._read_task.cancel()
try:
await self._read_task
except asyncio.CancelledError:
pass
if self.process:
try:
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)
except:
try:
if hasattr(self.process, 'kill'):
self.process.kill()
except:
pass
if sys.platform != "win32" and hasattr(self, '_master_fd'):
try:
os.close(self._master_fd)
except:
pass
@router.websocket("/claude")
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": "..."}
{"type": "output", "data": "terminal output"}
{"type": "exit", "code": 0}
{"type": "error", "message": "..."}
{"type": "context", "prompt": "..."} # Initial context prompt
"""
await websocket.accept()
# Default to Atomizer root directory
if not working_dir:
working_dir = ATOMIZER_ROOT
# Create session
session_id = f"claude-{id(websocket)}"
session = TerminalSession(session_id, working_dir)
_terminal_sessions[session_id] = session
try:
# Send context prompt to frontend (for display/reference)
context_prompt = get_session_prompt(study_id)
await websocket.send_json({
"type": "context",
"prompt": context_prompt,
"study_id": study_id
})
# Start Claude Code
await session.start(websocket)
# If study_id provided, send initial context to Claude after startup
if study_id:
# Wait a moment for Claude to initialize
await asyncio.sleep(1.0)
# Send the context as the first message
initial_message = f"I'm working with the Atomizer study '{study_id}'. Please read .claude/skills/00_BOOTSTRAP.md first to understand the Protocol Operating System, then help me with this study.\n"
await session.write(initial_message)
# Handle incoming messages
while session._running:
try:
message = await websocket.receive_json()
if message.get("type") == "input":
await session.write(message.get("data", ""))
elif message.get("type") == "resize":
await session.resize(
message.get("cols", 80),
message.get("rows", 24)
)
elif message.get("type") == "stop":
break
except WebSocketDisconnect:
break
except Exception as e:
await websocket.send_json({
"type": "error",
"message": str(e)
})
finally:
await session.stop()
_terminal_sessions.pop(session_id, None)
@router.get("/status")
async def terminal_status():
"""Check if Claude Code CLI is available."""
import shutil
claude_path = shutil.which("claude")
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"
}
@router.get("/context")
async def get_context(study_id: str = None):
"""
Get the context prompt for a Claude session without starting a terminal.
Useful for displaying context in the UI or preparing prompts.
Query params:
study_id: Optional study ID to include study-specific context
"""
prompt = get_session_prompt(study_id)
# Resolve study path for nested folder structure
study_path = resolve_study_path(study_id) if study_id else None
return {
"study_id": study_id,
"prompt": prompt,
"bootstrap_files": [
".claude/skills/00_BOOTSTRAP.md",
".claude/skills/01_CHEATSHEET.md",
".claude/skills/02_CONTEXT_LOADER.md",
],
"study_files": [
f"{study_path}/1_setup/optimization_config.json",
f"{study_path}/3_results/study.db",
f"{study_path}/README.md",
] if study_path else []
}