Spaces:
Configuration error
Configuration error
Aman Nindra commited on
Commit ·
4dcc016
1
Parent(s): d06bbb9
Enhance backend terminal session management and frontend command comparison features. Added new API endpoints for terminal session creation, input handling, resizing, and stopping. Updated frontend to support real-time command broadcasting and display runtime comparisons between jobs. Improved styling and layout for terminal panes and comparison statistics.
Browse files- backend/main.py +96 -3
- backend/terminal_manager.py +342 -0
- frontend/src/App.jsx +132 -135
- frontend/src/api/terminal.js +57 -0
- frontend/src/components/TerminalPane.jsx +147 -0
- frontend/src/hooks/useTerminalSession.js +166 -0
- frontend/src/index.css +351 -133
backend/main.py
CHANGED
|
@@ -8,10 +8,12 @@ ROOT = Path(__file__).resolve().parents[1]
|
|
| 8 |
if str(ROOT) not in sys.path:
|
| 9 |
sys.path.insert(0, str(ROOT))
|
| 10 |
|
| 11 |
-
from fastapi import FastAPI, HTTPException
|
| 12 |
from fastapi.middleware.cors import CORSMiddleware
|
| 13 |
from pydantic import BaseModel
|
| 14 |
|
|
|
|
|
|
|
| 15 |
app = FastAPI(
|
| 16 |
title="RL Autotuning Backend",
|
| 17 |
description="Backend API for the multi-family GPU autotuning benchmark",
|
|
@@ -20,13 +22,19 @@ app = FastAPI(
|
|
| 20 |
|
| 21 |
app.add_middleware(
|
| 22 |
CORSMiddleware,
|
| 23 |
-
allow_origins=[
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
allow_methods=["*"],
|
| 26 |
allow_headers=["*"],
|
| 27 |
)
|
| 28 |
|
| 29 |
env = None
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
def _get_env():
|
|
@@ -53,6 +61,21 @@ class StepRequest(BaseModel):
|
|
| 53 |
x: Optional[List[float]] = None
|
| 54 |
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
@app.get("/health")
|
| 57 |
def health() -> Dict[str, str]:
|
| 58 |
return {"status": "ok"}
|
|
@@ -78,6 +101,76 @@ def state() -> Dict[str, Any]:
|
|
| 78 |
return _get_env().state()
|
| 79 |
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
if __name__ == "__main__":
|
| 82 |
import uvicorn
|
| 83 |
|
|
|
|
| 8 |
if str(ROOT) not in sys.path:
|
| 9 |
sys.path.insert(0, str(ROOT))
|
| 10 |
|
| 11 |
+
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
| 12 |
from fastapi.middleware.cors import CORSMiddleware
|
| 13 |
from pydantic import BaseModel
|
| 14 |
|
| 15 |
+
from backend.terminal_manager import ALLOWED_JOBS, TerminalManager
|
| 16 |
+
|
| 17 |
app = FastAPI(
|
| 18 |
title="RL Autotuning Backend",
|
| 19 |
description="Backend API for the multi-family GPU autotuning benchmark",
|
|
|
|
| 22 |
|
| 23 |
app.add_middleware(
|
| 24 |
CORSMiddleware,
|
| 25 |
+
allow_origins=[
|
| 26 |
+
"http://localhost:5173",
|
| 27 |
+
"http://127.0.0.1:5173",
|
| 28 |
+
"http://localhost:4173",
|
| 29 |
+
"http://127.0.0.1:4173",
|
| 30 |
+
],
|
| 31 |
+
allow_credentials=False,
|
| 32 |
allow_methods=["*"],
|
| 33 |
allow_headers=["*"],
|
| 34 |
)
|
| 35 |
|
| 36 |
env = None
|
| 37 |
+
terminal_manager = TerminalManager()
|
| 38 |
|
| 39 |
|
| 40 |
def _get_env():
|
|
|
|
| 61 |
x: Optional[List[float]] = None
|
| 62 |
|
| 63 |
|
| 64 |
+
class SessionRequest(BaseModel):
|
| 65 |
+
job_id: str
|
| 66 |
+
restart: bool = False
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class SessionInputRequest(BaseModel):
|
| 70 |
+
data: str
|
| 71 |
+
append_newline: bool = True
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class SessionResizeRequest(BaseModel):
|
| 75 |
+
cols: int
|
| 76 |
+
rows: int
|
| 77 |
+
|
| 78 |
+
|
| 79 |
@app.get("/health")
|
| 80 |
def health() -> Dict[str, str]:
|
| 81 |
return {"status": "ok"}
|
|
|
|
| 101 |
return _get_env().state()
|
| 102 |
|
| 103 |
|
| 104 |
+
@app.get("/terminal/jobs")
|
| 105 |
+
def terminal_jobs() -> Dict[str, Any]:
|
| 106 |
+
return {"jobs": terminal_manager.list_jobs()}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@app.post("/terminal/sessions")
|
| 110 |
+
async def create_terminal_session(payload: SessionRequest) -> Dict[str, Any]:
|
| 111 |
+
if payload.job_id not in ALLOWED_JOBS:
|
| 112 |
+
raise HTTPException(status_code=404, detail=f"Unknown job_id: {payload.job_id}")
|
| 113 |
+
session = await terminal_manager.ensure_session(payload.job_id, restart=payload.restart)
|
| 114 |
+
return session.snapshot()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@app.get("/terminal/sessions/{session_id}")
|
| 118 |
+
def terminal_session_snapshot(session_id: str) -> Dict[str, Any]:
|
| 119 |
+
session = terminal_manager.get_session(session_id)
|
| 120 |
+
if session is None:
|
| 121 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 122 |
+
return session.snapshot()
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@app.post("/terminal/sessions/{session_id}/input")
|
| 126 |
+
def terminal_session_input(session_id: str, payload: SessionInputRequest) -> Dict[str, Any]:
|
| 127 |
+
session = terminal_manager.get_session(session_id)
|
| 128 |
+
if session is None:
|
| 129 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 130 |
+
if not session.is_active:
|
| 131 |
+
raise HTTPException(status_code=409, detail="Session is not running")
|
| 132 |
+
session.write(payload.data, append_newline=payload.append_newline)
|
| 133 |
+
return {"ok": True}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@app.post("/terminal/sessions/{session_id}/resize")
|
| 137 |
+
def terminal_session_resize(session_id: str, payload: SessionResizeRequest) -> Dict[str, Any]:
|
| 138 |
+
session = terminal_manager.get_session(session_id)
|
| 139 |
+
if session is None:
|
| 140 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 141 |
+
session.resize(payload.cols, payload.rows)
|
| 142 |
+
return {"ok": True}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@app.post("/terminal/sessions/{session_id}/stop")
|
| 146 |
+
def terminal_session_stop(session_id: str) -> Dict[str, Any]:
|
| 147 |
+
session = terminal_manager.get_session(session_id)
|
| 148 |
+
if session is None:
|
| 149 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 150 |
+
session.interrupt()
|
| 151 |
+
return {"ok": True}
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@app.websocket("/terminal/sessions/{session_id}/stream")
|
| 155 |
+
async def terminal_session_stream(websocket: WebSocket, session_id: str) -> None:
|
| 156 |
+
session = terminal_manager.get_session(session_id)
|
| 157 |
+
if session is None:
|
| 158 |
+
await websocket.close(code=4404)
|
| 159 |
+
return
|
| 160 |
+
|
| 161 |
+
await websocket.accept()
|
| 162 |
+
queue = await session.subscribe()
|
| 163 |
+
try:
|
| 164 |
+
await websocket.send_json(session.snapshot())
|
| 165 |
+
while True:
|
| 166 |
+
event = await queue.get()
|
| 167 |
+
await websocket.send_json(event)
|
| 168 |
+
except WebSocketDisconnect:
|
| 169 |
+
pass
|
| 170 |
+
finally:
|
| 171 |
+
session.unsubscribe(queue)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
if __name__ == "__main__":
|
| 175 |
import uvicorn
|
| 176 |
|
backend/terminal_manager.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import importlib.util
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import pty
|
| 8 |
+
import signal
|
| 9 |
+
import struct
|
| 10 |
+
import subprocess
|
| 11 |
+
import termios
|
| 12 |
+
import threading
|
| 13 |
+
import time
|
| 14 |
+
import uuid
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
| 21 |
+
BUFFER_LIMIT = 160_000
|
| 22 |
+
DEFAULT_COLS = 120
|
| 23 |
+
DEFAULT_ROWS = 36
|
| 24 |
+
PYTHON_CANDIDATES = (
|
| 25 |
+
"/usr/local/bin/python3",
|
| 26 |
+
"/opt/homebrew/bin/python3",
|
| 27 |
+
"/Users/amannindra/miniconda3/bin/python3",
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass(frozen=True)
|
| 32 |
+
class AllowedJob:
|
| 33 |
+
job_id: str
|
| 34 |
+
label: str
|
| 35 |
+
description: str
|
| 36 |
+
command: tuple[str, ...]
|
| 37 |
+
cwd: Path
|
| 38 |
+
|
| 39 |
+
def as_dict(self) -> dict[str, Any]:
|
| 40 |
+
return {
|
| 41 |
+
"job_id": self.job_id,
|
| 42 |
+
"label": self.label,
|
| 43 |
+
"description": self.description,
|
| 44 |
+
"command": list(self.command),
|
| 45 |
+
"cwd": str(self.cwd),
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
ALLOWED_JOBS: dict[str, AllowedJob] = {
|
| 50 |
+
"qwen": AllowedJob(
|
| 51 |
+
job_id="qwen",
|
| 52 |
+
label="Qwen Baseline",
|
| 53 |
+
description="Runs the exact-kernel Qwen2.5-0.5B benchmark pipeline.",
|
| 54 |
+
command=("bash", "scripts/run_qwen_05b_pipeline.sh"),
|
| 55 |
+
cwd=REPO_ROOT,
|
| 56 |
+
),
|
| 57 |
+
"rl-agent": AllowedJob(
|
| 58 |
+
job_id="rl-agent",
|
| 59 |
+
label="RL Agent",
|
| 60 |
+
description="Runs the multi-family surrogate and runtime benchmark pipeline.",
|
| 61 |
+
command=("bash", "scripts/run_full_pipeline.sh"),
|
| 62 |
+
cwd=REPO_ROOT,
|
| 63 |
+
),
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _probe_python(path: str) -> dict[str, Any] | None:
|
| 68 |
+
if not Path(path).exists():
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
script = (
|
| 72 |
+
"import importlib.util, json, sys; "
|
| 73 |
+
"print(json.dumps({"
|
| 74 |
+
"'executable': sys.executable, "
|
| 75 |
+
"'torch': bool(importlib.util.find_spec('torch')), "
|
| 76 |
+
"'triton': bool(importlib.util.find_spec('triton'))"
|
| 77 |
+
"}))"
|
| 78 |
+
)
|
| 79 |
+
try:
|
| 80 |
+
result = subprocess.run(
|
| 81 |
+
[path, "-c", script],
|
| 82 |
+
check=True,
|
| 83 |
+
capture_output=True,
|
| 84 |
+
text=True,
|
| 85 |
+
)
|
| 86 |
+
except (OSError, subprocess.CalledProcessError):
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
payload = json.loads(result.stdout.strip())
|
| 91 |
+
except json.JSONDecodeError:
|
| 92 |
+
return None
|
| 93 |
+
payload["path"] = path
|
| 94 |
+
return payload
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _best_python_runtime() -> dict[str, Any] | None:
|
| 98 |
+
explicit = os.environ.get("TERMINAL_PYTHON_BIN")
|
| 99 |
+
if explicit:
|
| 100 |
+
probe = _probe_python(explicit)
|
| 101 |
+
if probe is not None:
|
| 102 |
+
probe["score"] = int(probe["torch"]) + int(probe["triton"])
|
| 103 |
+
probe["explicit"] = True
|
| 104 |
+
return probe
|
| 105 |
+
|
| 106 |
+
best: dict[str, Any] | None = None
|
| 107 |
+
for candidate in PYTHON_CANDIDATES:
|
| 108 |
+
probe = _probe_python(candidate)
|
| 109 |
+
if probe is None:
|
| 110 |
+
continue
|
| 111 |
+
score = int(probe["torch"]) + int(probe["triton"])
|
| 112 |
+
probe["score"] = score
|
| 113 |
+
if best is None or score > best["score"]:
|
| 114 |
+
best = probe
|
| 115 |
+
return best
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class TerminalSession:
|
| 119 |
+
def __init__(self, job: AllowedJob, loop: asyncio.AbstractEventLoop) -> None:
|
| 120 |
+
self.id = uuid.uuid4().hex
|
| 121 |
+
self.job = job
|
| 122 |
+
self.loop = loop
|
| 123 |
+
self.created_at = time.time()
|
| 124 |
+
self.started_at: float | None = None
|
| 125 |
+
self.finished_at: float | None = None
|
| 126 |
+
self.exit_code: int | None = None
|
| 127 |
+
self.status = "starting"
|
| 128 |
+
self.cols = DEFAULT_COLS
|
| 129 |
+
self.rows = DEFAULT_ROWS
|
| 130 |
+
self.python_runtime = _best_python_runtime()
|
| 131 |
+
|
| 132 |
+
self._buffer = ""
|
| 133 |
+
self._buffer_lock = threading.Lock()
|
| 134 |
+
self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set()
|
| 135 |
+
self._subscriber_lock = threading.Lock()
|
| 136 |
+
|
| 137 |
+
self._master_fd, slave_fd = pty.openpty()
|
| 138 |
+
self._resize_fd(self.cols, self.rows)
|
| 139 |
+
|
| 140 |
+
env = os.environ.copy()
|
| 141 |
+
env.setdefault("TERM", "xterm-256color")
|
| 142 |
+
env.setdefault("PYTHONUNBUFFERED", "1")
|
| 143 |
+
env.setdefault("FORCE_COLOR", "1")
|
| 144 |
+
if self.python_runtime is not None:
|
| 145 |
+
python_dir = str(Path(self.python_runtime["path"]).parent)
|
| 146 |
+
env["PATH"] = f"{python_dir}:{env.get('PATH', '')}"
|
| 147 |
+
env["PYTHON_BIN"] = self.python_runtime["path"]
|
| 148 |
+
|
| 149 |
+
self._append_buffer(self._launcher_banner())
|
| 150 |
+
|
| 151 |
+
self.process = subprocess.Popen(
|
| 152 |
+
self.job.command,
|
| 153 |
+
cwd=str(self.job.cwd),
|
| 154 |
+
stdin=slave_fd,
|
| 155 |
+
stdout=slave_fd,
|
| 156 |
+
stderr=slave_fd,
|
| 157 |
+
env=env,
|
| 158 |
+
preexec_fn=os.setsid,
|
| 159 |
+
close_fds=True,
|
| 160 |
+
)
|
| 161 |
+
os.close(slave_fd)
|
| 162 |
+
|
| 163 |
+
self.started_at = time.time()
|
| 164 |
+
self.status = "running"
|
| 165 |
+
|
| 166 |
+
self._reader_thread = threading.Thread(target=self._reader_loop, daemon=True)
|
| 167 |
+
self._waiter_thread = threading.Thread(target=self._wait_loop, daemon=True)
|
| 168 |
+
self._reader_thread.start()
|
| 169 |
+
self._waiter_thread.start()
|
| 170 |
+
|
| 171 |
+
@property
|
| 172 |
+
def command_display(self) -> str:
|
| 173 |
+
return " ".join(self.job.command)
|
| 174 |
+
|
| 175 |
+
@property
|
| 176 |
+
def is_active(self) -> bool:
|
| 177 |
+
return self.process.poll() is None
|
| 178 |
+
|
| 179 |
+
def snapshot(self) -> dict[str, Any]:
|
| 180 |
+
with self._buffer_lock:
|
| 181 |
+
buffer = self._buffer
|
| 182 |
+
return {
|
| 183 |
+
"type": "snapshot",
|
| 184 |
+
"session": {
|
| 185 |
+
"id": self.id,
|
| 186 |
+
"job_id": self.job.job_id,
|
| 187 |
+
"label": self.job.label,
|
| 188 |
+
"description": self.job.description,
|
| 189 |
+
"cwd": str(self.job.cwd),
|
| 190 |
+
"command": self.command_display,
|
| 191 |
+
"status": self.status,
|
| 192 |
+
"created_at": self.created_at,
|
| 193 |
+
"started_at": self.started_at,
|
| 194 |
+
"finished_at": self.finished_at,
|
| 195 |
+
"exit_code": self.exit_code,
|
| 196 |
+
"cols": self.cols,
|
| 197 |
+
"rows": self.rows,
|
| 198 |
+
},
|
| 199 |
+
"buffer": buffer,
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
async def subscribe(self) -> asyncio.Queue[dict[str, Any]]:
|
| 203 |
+
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
| 204 |
+
with self._subscriber_lock:
|
| 205 |
+
self._subscribers.add(queue)
|
| 206 |
+
return queue
|
| 207 |
+
|
| 208 |
+
def unsubscribe(self, queue: asyncio.Queue[dict[str, Any]]) -> None:
|
| 209 |
+
with self._subscriber_lock:
|
| 210 |
+
self._subscribers.discard(queue)
|
| 211 |
+
|
| 212 |
+
def write(self, data: str, append_newline: bool = True) -> None:
|
| 213 |
+
if not data:
|
| 214 |
+
return
|
| 215 |
+
payload = data + ("\n" if append_newline else "")
|
| 216 |
+
os.write(self._master_fd, payload.encode("utf-8", errors="replace"))
|
| 217 |
+
|
| 218 |
+
def resize(self, cols: int, rows: int) -> None:
|
| 219 |
+
self.cols = max(20, cols)
|
| 220 |
+
self.rows = max(8, rows)
|
| 221 |
+
self._resize_fd(self.cols, self.rows)
|
| 222 |
+
|
| 223 |
+
def interrupt(self) -> None:
|
| 224 |
+
if self.process.poll() is None:
|
| 225 |
+
os.killpg(os.getpgid(self.process.pid), signal.SIGINT)
|
| 226 |
+
|
| 227 |
+
def terminate(self) -> None:
|
| 228 |
+
if self.process.poll() is None:
|
| 229 |
+
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
|
| 230 |
+
|
| 231 |
+
def _resize_fd(self, cols: int, rows: int) -> None:
|
| 232 |
+
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
| 233 |
+
try:
|
| 234 |
+
termios.tcsetwinsize(self._master_fd, (rows, cols))
|
| 235 |
+
except AttributeError:
|
| 236 |
+
pass
|
| 237 |
+
try:
|
| 238 |
+
import fcntl
|
| 239 |
+
|
| 240 |
+
fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize)
|
| 241 |
+
except OSError:
|
| 242 |
+
pass
|
| 243 |
+
|
| 244 |
+
def _append_buffer(self, chunk: str) -> None:
|
| 245 |
+
with self._buffer_lock:
|
| 246 |
+
self._buffer = (self._buffer + chunk)[-BUFFER_LIMIT:]
|
| 247 |
+
|
| 248 |
+
def _launcher_banner(self) -> str:
|
| 249 |
+
lines = [
|
| 250 |
+
f"[launcher] job: {self.job.label}",
|
| 251 |
+
f"[launcher] cwd: {self.job.cwd}",
|
| 252 |
+
f"[launcher] command: {self.command_display}",
|
| 253 |
+
]
|
| 254 |
+
if self.python_runtime is not None:
|
| 255 |
+
modules = []
|
| 256 |
+
modules.append(f"torch={'yes' if self.python_runtime['torch'] else 'no'}")
|
| 257 |
+
modules.append(f"triton={'yes' if self.python_runtime['triton'] else 'no'}")
|
| 258 |
+
lines.append(f"[launcher] python3: {self.python_runtime['path']} ({', '.join(modules)})")
|
| 259 |
+
if self.python_runtime.get("explicit"):
|
| 260 |
+
lines.append("[launcher] python3 source: TERMINAL_PYTHON_BIN")
|
| 261 |
+
if not self.python_runtime["triton"]:
|
| 262 |
+
lines.append("[launcher] warning: Triton is not installed in the selected Python runtime.")
|
| 263 |
+
else:
|
| 264 |
+
lines.append("[launcher] warning: no preferred Python runtime detected; falling back to PATH lookup.")
|
| 265 |
+
return "\n".join(lines) + "\n\n"
|
| 266 |
+
|
| 267 |
+
def _publish(self, event: dict[str, Any]) -> None:
|
| 268 |
+
with self._subscriber_lock:
|
| 269 |
+
subscribers = tuple(self._subscribers)
|
| 270 |
+
for queue in subscribers:
|
| 271 |
+
self.loop.call_soon_threadsafe(self._safe_put, queue, event)
|
| 272 |
+
|
| 273 |
+
@staticmethod
|
| 274 |
+
def _safe_put(queue: asyncio.Queue[dict[str, Any]], event: dict[str, Any]) -> None:
|
| 275 |
+
try:
|
| 276 |
+
queue.put_nowait(event)
|
| 277 |
+
except asyncio.QueueFull:
|
| 278 |
+
pass
|
| 279 |
+
|
| 280 |
+
def _reader_loop(self) -> None:
|
| 281 |
+
while True:
|
| 282 |
+
try:
|
| 283 |
+
data = os.read(self._master_fd, 4096)
|
| 284 |
+
except OSError:
|
| 285 |
+
break
|
| 286 |
+
if not data:
|
| 287 |
+
break
|
| 288 |
+
text = data.decode("utf-8", errors="replace")
|
| 289 |
+
self._append_buffer(text)
|
| 290 |
+
self._publish({"type": "output", "data": text})
|
| 291 |
+
|
| 292 |
+
def _wait_loop(self) -> None:
|
| 293 |
+
exit_code = self.process.wait()
|
| 294 |
+
self.exit_code = exit_code
|
| 295 |
+
self.finished_at = time.time()
|
| 296 |
+
self.status = "exited" if exit_code == 0 else "failed"
|
| 297 |
+
self._publish(
|
| 298 |
+
{
|
| 299 |
+
"type": "exit",
|
| 300 |
+
"exit_code": exit_code,
|
| 301 |
+
"status": self.status,
|
| 302 |
+
"finished_at": self.finished_at,
|
| 303 |
+
}
|
| 304 |
+
)
|
| 305 |
+
try:
|
| 306 |
+
os.close(self._master_fd)
|
| 307 |
+
except OSError:
|
| 308 |
+
pass
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
class TerminalManager:
|
| 312 |
+
def __init__(self) -> None:
|
| 313 |
+
self._sessions: dict[str, TerminalSession] = {}
|
| 314 |
+
self._latest_by_job: dict[str, str] = {}
|
| 315 |
+
self._lock = threading.Lock()
|
| 316 |
+
|
| 317 |
+
def list_jobs(self) -> list[dict[str, Any]]:
|
| 318 |
+
return [job.as_dict() for job in ALLOWED_JOBS.values()]
|
| 319 |
+
|
| 320 |
+
def get_session(self, session_id: str) -> TerminalSession | None:
|
| 321 |
+
with self._lock:
|
| 322 |
+
return self._sessions.get(session_id)
|
| 323 |
+
|
| 324 |
+
async def ensure_session(self, job_id: str, restart: bool = False) -> TerminalSession:
|
| 325 |
+
if job_id not in ALLOWED_JOBS:
|
| 326 |
+
raise KeyError(job_id)
|
| 327 |
+
|
| 328 |
+
with self._lock:
|
| 329 |
+
existing_id = self._latest_by_job.get(job_id)
|
| 330 |
+
existing = self._sessions.get(existing_id) if existing_id else None
|
| 331 |
+
|
| 332 |
+
if existing and existing.is_active and not restart:
|
| 333 |
+
return existing
|
| 334 |
+
|
| 335 |
+
if existing and restart:
|
| 336 |
+
existing.interrupt()
|
| 337 |
+
|
| 338 |
+
session = TerminalSession(ALLOWED_JOBS[job_id], asyncio.get_running_loop())
|
| 339 |
+
with self._lock:
|
| 340 |
+
self._sessions[session.id] = session
|
| 341 |
+
self._latest_by_job[job_id] = session.id
|
| 342 |
+
return session
|
frontend/src/App.jsx
CHANGED
|
@@ -1,48 +1,53 @@
|
|
| 1 |
import { useEffect, useRef, useState } from 'react'
|
|
|
|
| 2 |
|
| 3 |
const panes = [
|
| 4 |
-
{
|
| 5 |
-
|
| 6 |
-
shell: 'zsh',
|
| 7 |
-
mode: 'INSERT',
|
| 8 |
-
buffer: [
|
| 9 |
-
{ kind: 'prompt', cwd: '~/Projects/Rl_fianl/frontend', command: 'npm run dev' },
|
| 10 |
-
{ kind: 'stdout', text: '' },
|
| 11 |
-
{ kind: 'stdout', text: '> frontend@0.0.0 dev' },
|
| 12 |
-
{ kind: 'stdout', text: '> vite' },
|
| 13 |
-
{ kind: 'stdout', text: '' },
|
| 14 |
-
{ kind: 'success', text: ' VITE v7.3.1 ready in 182 ms' },
|
| 15 |
-
{ kind: 'muted', text: ' ➜ Local: http://localhost:5173/' },
|
| 16 |
-
{ kind: 'muted', text: ' ➜ press h + enter to show help' },
|
| 17 |
-
{ kind: 'stdout', text: '' },
|
| 18 |
-
{ kind: 'prompt', cwd: '~/Projects/Rl_fianl/frontend/src', command: 'npm run build' },
|
| 19 |
-
{ kind: 'success', text: '✓ built in 336ms' },
|
| 20 |
-
],
|
| 21 |
-
},
|
| 22 |
-
{
|
| 23 |
-
id: 'right',
|
| 24 |
-
shell: 'zsh',
|
| 25 |
-
mode: 'NORMAL',
|
| 26 |
-
buffer: [
|
| 27 |
-
{ kind: 'prompt', cwd: '~/Projects/Rl_fianl/backend', command: 'uvicorn app.main:app --reload' },
|
| 28 |
-
{ kind: 'stdout', text: 'INFO: Will watch for changes in these directories:' },
|
| 29 |
-
{ kind: 'stdout', text: 'INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)' },
|
| 30 |
-
{ kind: 'muted', text: 'INFO: Started reloader process [48211] using WatchFiles' },
|
| 31 |
-
{ kind: 'stdout', text: '' },
|
| 32 |
-
{ kind: 'prompt', cwd: '~/Projects/Rl_fianl/backend', command: 'pytest -q' },
|
| 33 |
-
{ kind: 'stdout', text: 'tests/test_api.py ....' },
|
| 34 |
-
{ kind: 'success', text: '4 passed in 0.84s' },
|
| 35 |
-
{ kind: 'stdout', text: '' },
|
| 36 |
-
{ kind: 'prompt', cwd: '~/Projects/Rl_fianl/backend', command: 'curl -s http://127.0.0.1:8000/health' },
|
| 37 |
-
{ kind: 'json', text: '{ "status": "ok", "env": "local", "latency_ms": 11 }' },
|
| 38 |
-
],
|
| 39 |
-
},
|
| 40 |
]
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
function App() {
|
| 43 |
-
const [split, setSplit] = useState(
|
| 44 |
const [dragging, setDragging] = useState(false)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
const workspaceRef = useRef(null)
|
|
|
|
|
|
|
| 46 |
|
| 47 |
useEffect(() => {
|
| 48 |
if (!dragging) {
|
|
@@ -73,60 +78,81 @@ function App() {
|
|
| 73 |
}
|
| 74 |
}, [dragging])
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
return (
|
| 77 |
<main className="desktop">
|
| 78 |
<div className="desktop__glow" />
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
<section ref={workspaceRef} className="workspace">
|
| 81 |
-
<
|
| 82 |
-
<
|
| 83 |
-
|
| 84 |
-
{Array.from({ length: panes[0].buffer.length + 2 }, (_, index) => (
|
| 85 |
-
<span key={index}>{index + 1}</span>
|
| 86 |
-
))}
|
| 87 |
-
</div>
|
| 88 |
-
|
| 89 |
-
<div className="pane__buffer" role="log" aria-live="polite">
|
| 90 |
-
{panes[0].buffer.map((line, index) => (
|
| 91 |
-
<div key={`${panes[0].id}-${line.kind}-${index}`} className={`line line--${line.kind}`}>
|
| 92 |
-
{line.kind === 'prompt' ? (
|
| 93 |
-
<>
|
| 94 |
-
<span className="prompt__host">amannindra</span>
|
| 95 |
-
<span className="prompt__separator">@</span>
|
| 96 |
-
<span className="prompt__cwd">{line.cwd}</span>
|
| 97 |
-
<span className="prompt__symbol">$</span>
|
| 98 |
-
<span className="prompt__command">{line.command}</span>
|
| 99 |
-
</>
|
| 100 |
-
) : (
|
| 101 |
-
<span>{line.text}</span>
|
| 102 |
-
)}
|
| 103 |
-
</div>
|
| 104 |
-
))}
|
| 105 |
-
|
| 106 |
-
<div className="line line--prompt line--active">
|
| 107 |
-
<span className="prompt__host">amannindra</span>
|
| 108 |
-
<span className="prompt__separator">@</span>
|
| 109 |
-
<span className="prompt__cwd">~/Projects/Rl_fianl/frontend</span>
|
| 110 |
-
<span className="prompt__symbol">$</span>
|
| 111 |
-
<span className="prompt__command" />
|
| 112 |
-
</div>
|
| 113 |
-
</div>
|
| 114 |
-
</div>
|
| 115 |
-
|
| 116 |
-
<footer className="pane__statusbar">
|
| 117 |
-
<div className="statusbar__left">
|
| 118 |
-
<span className="status-pill">ghostty</span>
|
| 119 |
-
<span>{panes[0].shell}</span>
|
| 120 |
-
<span>{panes[0].mode}</span>
|
| 121 |
-
</div>
|
| 122 |
-
|
| 123 |
-
<div className="statusbar__right">
|
| 124 |
-
<span>{Math.round(split)}%</span>
|
| 125 |
-
<span>UTF-8</span>
|
| 126 |
-
<span>Ligatures on</span>
|
| 127 |
-
</div>
|
| 128 |
-
</footer>
|
| 129 |
-
</article>
|
| 130 |
|
| 131 |
<button
|
| 132 |
type="button"
|
|
@@ -141,56 +167,27 @@ function App() {
|
|
| 141 |
<span />
|
| 142 |
</button>
|
| 143 |
|
| 144 |
-
<
|
| 145 |
-
<
|
| 146 |
-
|
| 147 |
-
{Array.from({ length: panes[1].buffer.length + 2 }, (_, index) => (
|
| 148 |
-
<span key={index}>{index + 1}</span>
|
| 149 |
-
))}
|
| 150 |
-
</div>
|
| 151 |
-
|
| 152 |
-
<div className="pane__buffer" role="log" aria-live="polite">
|
| 153 |
-
{panes[1].buffer.map((line, index) => (
|
| 154 |
-
<div key={`${panes[1].id}-${line.kind}-${index}`} className={`line line--${line.kind}`}>
|
| 155 |
-
{line.kind === 'prompt' ? (
|
| 156 |
-
<>
|
| 157 |
-
<span className="prompt__host">amannindra</span>
|
| 158 |
-
<span className="prompt__separator">@</span>
|
| 159 |
-
<span className="prompt__cwd">{line.cwd}</span>
|
| 160 |
-
<span className="prompt__symbol">$</span>
|
| 161 |
-
<span className="prompt__command">{line.command}</span>
|
| 162 |
-
</>
|
| 163 |
-
) : (
|
| 164 |
-
<span>{line.text}</span>
|
| 165 |
-
)}
|
| 166 |
-
</div>
|
| 167 |
-
))}
|
| 168 |
-
|
| 169 |
-
<div className="line line--prompt line--active">
|
| 170 |
-
<span className="prompt__host">amannindra</span>
|
| 171 |
-
<span className="prompt__separator">@</span>
|
| 172 |
-
<span className="prompt__cwd">~/Projects/Rl_fianl/backend</span>
|
| 173 |
-
<span className="prompt__symbol">$</span>
|
| 174 |
-
<span className="prompt__command" />
|
| 175 |
-
</div>
|
| 176 |
-
</div>
|
| 177 |
-
</div>
|
| 178 |
-
|
| 179 |
-
<footer className="pane__statusbar">
|
| 180 |
-
<div className="statusbar__left">
|
| 181 |
-
<span className="status-pill">ghostty</span>
|
| 182 |
-
<span>{panes[1].shell}</span>
|
| 183 |
-
<span>{panes[1].mode}</span>
|
| 184 |
-
</div>
|
| 185 |
-
|
| 186 |
-
<div className="statusbar__right">
|
| 187 |
-
<span>{Math.round(100 - split)}%</span>
|
| 188 |
-
<span>UTF-8</span>
|
| 189 |
-
<span>Ligatures on</span>
|
| 190 |
-
</div>
|
| 191 |
-
</footer>
|
| 192 |
-
</article>
|
| 193 |
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
</main>
|
| 195 |
)
|
| 196 |
}
|
|
|
|
| 1 |
import { useEffect, useRef, useState } from 'react'
|
| 2 |
+
import TerminalPane from './components/TerminalPane'
|
| 3 |
|
| 4 |
const panes = [
|
| 5 |
+
{ jobId: 'qwen', title: 'Qwen Baseline', tone: 'cyan' },
|
| 6 |
+
{ jobId: 'rl-agent', title: 'RL Agent', tone: 'green' },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
]
|
| 8 |
|
| 9 |
+
function formatMs(value) {
|
| 10 |
+
if (value == null) {
|
| 11 |
+
return '--'
|
| 12 |
+
}
|
| 13 |
+
if (value < 1000) {
|
| 14 |
+
return `${Math.round(value)} ms`
|
| 15 |
+
}
|
| 16 |
+
return `${(value / 1000).toFixed(2)} s`
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function buildRunStats(telemetry, issuedAt) {
|
| 20 |
+
if (!telemetry || !issuedAt) {
|
| 21 |
+
return {
|
| 22 |
+
responseMs: null,
|
| 23 |
+
completionMs: null,
|
| 24 |
+
waiting: true,
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const responseMs = telemetry.lastOutputAt && telemetry.lastOutputAt >= issuedAt ? telemetry.lastOutputAt - issuedAt : null
|
| 29 |
+
const finishedAt = telemetry.session?.finished_at ? telemetry.session.finished_at * 1000 : null
|
| 30 |
+
const completionMs = finishedAt && finishedAt >= issuedAt ? finishedAt - issuedAt : null
|
| 31 |
+
|
| 32 |
+
return {
|
| 33 |
+
responseMs,
|
| 34 |
+
completionMs,
|
| 35 |
+
waiting: responseMs == null && completionMs == null,
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
function App() {
|
| 40 |
+
const [split, setSplit] = useState(50)
|
| 41 |
const [dragging, setDragging] = useState(false)
|
| 42 |
+
const [command, setCommand] = useState('')
|
| 43 |
+
const [comparisonRun, setComparisonRun] = useState(null)
|
| 44 |
+
const [telemetry, setTelemetry] = useState({
|
| 45 |
+
qwen: null,
|
| 46 |
+
'rl-agent': null,
|
| 47 |
+
})
|
| 48 |
const workspaceRef = useRef(null)
|
| 49 |
+
const leftPaneRef = useRef(null)
|
| 50 |
+
const rightPaneRef = useRef(null)
|
| 51 |
|
| 52 |
useEffect(() => {
|
| 53 |
if (!dragging) {
|
|
|
|
| 78 |
}
|
| 79 |
}, [dragging])
|
| 80 |
|
| 81 |
+
const handleBroadcast = async (event) => {
|
| 82 |
+
event.preventDefault()
|
| 83 |
+
const value = command.trim()
|
| 84 |
+
if (!value) {
|
| 85 |
+
return
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const issuedAt = Date.now()
|
| 89 |
+
setComparisonRun({
|
| 90 |
+
command: value,
|
| 91 |
+
issuedAt,
|
| 92 |
+
})
|
| 93 |
+
|
| 94 |
+
await Promise.allSettled([
|
| 95 |
+
leftPaneRef.current?.submit(value),
|
| 96 |
+
rightPaneRef.current?.submit(value),
|
| 97 |
+
])
|
| 98 |
+
setCommand('')
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
const handleTelemetryChange = (payload) => {
|
| 102 |
+
setTelemetry((previous) => ({
|
| 103 |
+
...previous,
|
| 104 |
+
[payload.jobId]: payload,
|
| 105 |
+
}))
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
const qwenStats = buildRunStats(telemetry.qwen, comparisonRun?.issuedAt)
|
| 109 |
+
const agentStats = buildRunStats(telemetry['rl-agent'], comparisonRun?.issuedAt)
|
| 110 |
+
|
| 111 |
+
let comparisonHeadline = 'Send a shared command to compare runtime.'
|
| 112 |
+
if (comparisonRun) {
|
| 113 |
+
if (qwenStats.completionMs != null && agentStats.completionMs != null) {
|
| 114 |
+
const fasterJob = qwenStats.completionMs <= agentStats.completionMs ? panes[0].title : panes[1].title
|
| 115 |
+
const delta = Math.abs(qwenStats.completionMs - agentStats.completionMs)
|
| 116 |
+
comparisonHeadline = `${fasterJob} finished ${formatMs(delta)} faster.`
|
| 117 |
+
} else if (qwenStats.responseMs != null && agentStats.responseMs != null) {
|
| 118 |
+
const fasterJob = qwenStats.responseMs <= agentStats.responseMs ? panes[0].title : panes[1].title
|
| 119 |
+
const delta = Math.abs(qwenStats.responseMs - agentStats.responseMs)
|
| 120 |
+
comparisonHeadline = `${fasterJob} responded ${formatMs(delta)} faster.`
|
| 121 |
+
} else {
|
| 122 |
+
comparisonHeadline = `Running shared command: ${comparisonRun.command}`
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
return (
|
| 127 |
<main className="desktop">
|
| 128 |
<div className="desktop__glow" />
|
| 129 |
|
| 130 |
+
<section className="comparison-bar">
|
| 131 |
+
<div className="comparison-bar__copy">
|
| 132 |
+
<span className="comparison-bar__eyebrow">Runtime compare</span>
|
| 133 |
+
<strong>{comparisonHeadline}</strong>
|
| 134 |
+
<small>{comparisonRun ? `Command: ${comparisonRun.command}` : 'Broadcast one command to both panes.'}</small>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<div className="comparison-bar__stats">
|
| 138 |
+
<article className="comparison-card comparison-card--cyan">
|
| 139 |
+
<span>{panes[0].title}</span>
|
| 140 |
+
<strong>{formatMs(qwenStats.completionMs ?? qwenStats.responseMs)}</strong>
|
| 141 |
+
<small>{qwenStats.completionMs != null ? 'completion time' : 'first output latency'}</small>
|
| 142 |
+
</article>
|
| 143 |
+
|
| 144 |
+
<article className="comparison-card comparison-card--green">
|
| 145 |
+
<span>{panes[1].title}</span>
|
| 146 |
+
<strong>{formatMs(agentStats.completionMs ?? agentStats.responseMs)}</strong>
|
| 147 |
+
<small>{agentStats.completionMs != null ? 'completion time' : 'first output latency'}</small>
|
| 148 |
+
</article>
|
| 149 |
+
</div>
|
| 150 |
+
</section>
|
| 151 |
+
|
| 152 |
<section ref={workspaceRef} className="workspace">
|
| 153 |
+
<div className="workspace__pane" style={{ width: `${split}%` }}>
|
| 154 |
+
<TerminalPane ref={leftPaneRef} {...panes[0]} onTelemetryChange={handleTelemetryChange} />
|
| 155 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
<button
|
| 158 |
type="button"
|
|
|
|
| 167 |
<span />
|
| 168 |
</button>
|
| 169 |
|
| 170 |
+
<div className="workspace__pane" style={{ width: `${100 - split}%` }}>
|
| 171 |
+
<TerminalPane ref={rightPaneRef} {...panes[1]} onTelemetryChange={handleTelemetryChange} />
|
| 172 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
</section>
|
| 174 |
+
|
| 175 |
+
<form className="broadcast-bar" onSubmit={handleBroadcast}>
|
| 176 |
+
<label className="broadcast-bar__label" htmlFor="broadcast-input">
|
| 177 |
+
Shared input
|
| 178 |
+
</label>
|
| 179 |
+
<div className="broadcast-bar__field">
|
| 180 |
+
<span className="broadcast-bar__prompt">$</span>
|
| 181 |
+
<input
|
| 182 |
+
id="broadcast-input"
|
| 183 |
+
value={command}
|
| 184 |
+
onChange={(event) => setCommand(event.target.value)}
|
| 185 |
+
placeholder="Send the same command to both terminals"
|
| 186 |
+
spellCheck="false"
|
| 187 |
+
/>
|
| 188 |
+
<button type="submit">Send to both</button>
|
| 189 |
+
</div>
|
| 190 |
+
</form>
|
| 191 |
</main>
|
| 192 |
)
|
| 193 |
}
|
frontend/src/api/terminal.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const RAW_API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8000'
|
| 2 |
+
const API_BASE = RAW_API_BASE.replace(/\/+$/, '')
|
| 3 |
+
const WS_BASE = API_BASE.replace(/^http/, 'ws')
|
| 4 |
+
|
| 5 |
+
async function request(path, options = {}) {
|
| 6 |
+
const response = await fetch(`${API_BASE}${path}`, {
|
| 7 |
+
headers: {
|
| 8 |
+
'Content-Type': 'application/json',
|
| 9 |
+
...(options.headers || {}),
|
| 10 |
+
},
|
| 11 |
+
...options,
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
if (!response.ok) {
|
| 15 |
+
let message = `Request failed with status ${response.status}`
|
| 16 |
+
try {
|
| 17 |
+
const payload = await response.json()
|
| 18 |
+
message = payload.detail || message
|
| 19 |
+
} catch {
|
| 20 |
+
// Keep default error message when the payload is not JSON.
|
| 21 |
+
}
|
| 22 |
+
throw new Error(message)
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return response.json()
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export function createOrAttachSession(jobId, { restart = false } = {}) {
|
| 29 |
+
return request('/terminal/sessions', {
|
| 30 |
+
method: 'POST',
|
| 31 |
+
body: JSON.stringify({ job_id: jobId, restart }),
|
| 32 |
+
})
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function stopTerminalSession(sessionId) {
|
| 36 |
+
return request(`/terminal/sessions/${sessionId}/stop`, {
|
| 37 |
+
method: 'POST',
|
| 38 |
+
})
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export function sendTerminalInput(sessionId, data, appendNewline = true) {
|
| 42 |
+
return request(`/terminal/sessions/${sessionId}/input`, {
|
| 43 |
+
method: 'POST',
|
| 44 |
+
body: JSON.stringify({ data, append_newline: appendNewline }),
|
| 45 |
+
})
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export function resizeTerminalSession(sessionId, cols, rows) {
|
| 49 |
+
return request(`/terminal/sessions/${sessionId}/resize`, {
|
| 50 |
+
method: 'POST',
|
| 51 |
+
body: JSON.stringify({ cols, rows }),
|
| 52 |
+
})
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export function openTerminalSocket(sessionId) {
|
| 56 |
+
return new WebSocket(`${WS_BASE}/terminal/sessions/${sessionId}/stream`)
|
| 57 |
+
}
|
frontend/src/components/TerminalPane.jsx
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
| 2 |
+
import { useTerminalSession } from '../hooks/useTerminalSession'
|
| 3 |
+
|
| 4 |
+
function formatTime(timestamp) {
|
| 5 |
+
if (!timestamp) {
|
| 6 |
+
return 'Idle'
|
| 7 |
+
}
|
| 8 |
+
return new Date(timestamp * 1000).toLocaleTimeString([], {
|
| 9 |
+
hour: '2-digit',
|
| 10 |
+
minute: '2-digit',
|
| 11 |
+
second: '2-digit',
|
| 12 |
+
})
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function statusLabel(status) {
|
| 16 |
+
if (status === 'running') {
|
| 17 |
+
return 'Running'
|
| 18 |
+
}
|
| 19 |
+
if (status === 'failed') {
|
| 20 |
+
return 'Failed'
|
| 21 |
+
}
|
| 22 |
+
if (status === 'exited') {
|
| 23 |
+
return 'Completed'
|
| 24 |
+
}
|
| 25 |
+
return 'Starting'
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const TerminalPane = forwardRef(function TerminalPane({ jobId, title, tone, onTelemetryChange }, ref) {
|
| 29 |
+
const { session, buffer, connectionState, error, lastOutputAt, restart, resize, sendInput, start, stop } =
|
| 30 |
+
useTerminalSession(jobId)
|
| 31 |
+
const viewportRef = useRef(null)
|
| 32 |
+
const scrollRef = useRef(null)
|
| 33 |
+
|
| 34 |
+
useImperativeHandle(
|
| 35 |
+
ref,
|
| 36 |
+
() => ({
|
| 37 |
+
submit: async (value) => {
|
| 38 |
+
await sendInput(value, true)
|
| 39 |
+
},
|
| 40 |
+
}),
|
| 41 |
+
[sendInput],
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
useEffect(() => {
|
| 45 |
+
onTelemetryChange?.({
|
| 46 |
+
jobId,
|
| 47 |
+
session,
|
| 48 |
+
connectionState,
|
| 49 |
+
error,
|
| 50 |
+
lastOutputAt,
|
| 51 |
+
})
|
| 52 |
+
}, [connectionState, error, jobId, lastOutputAt, onTelemetryChange, session])
|
| 53 |
+
|
| 54 |
+
useEffect(() => {
|
| 55 |
+
const container = scrollRef.current
|
| 56 |
+
if (container) {
|
| 57 |
+
container.scrollTop = container.scrollHeight
|
| 58 |
+
}
|
| 59 |
+
}, [buffer])
|
| 60 |
+
|
| 61 |
+
useEffect(() => {
|
| 62 |
+
const element = viewportRef.current
|
| 63 |
+
if (!element) {
|
| 64 |
+
return undefined
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
let frameId = 0
|
| 68 |
+
const measure = () => {
|
| 69 |
+
cancelAnimationFrame(frameId)
|
| 70 |
+
frameId = requestAnimationFrame(() => {
|
| 71 |
+
const style = getComputedStyle(element)
|
| 72 |
+
const fontSize = parseFloat(style.fontSize) || 15
|
| 73 |
+
const lineHeight = parseFloat(style.lineHeight) || 24
|
| 74 |
+
const cols = Math.max(48, Math.floor(element.clientWidth / (fontSize * 0.61)))
|
| 75 |
+
const rows = Math.max(14, Math.floor(element.clientHeight / lineHeight))
|
| 76 |
+
resize(cols, rows)
|
| 77 |
+
})
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
measure()
|
| 81 |
+
const observer = new ResizeObserver(measure)
|
| 82 |
+
observer.observe(element)
|
| 83 |
+
|
| 84 |
+
return () => {
|
| 85 |
+
cancelAnimationFrame(frameId)
|
| 86 |
+
observer.disconnect()
|
| 87 |
+
}
|
| 88 |
+
}, [resize])
|
| 89 |
+
|
| 90 |
+
const footerMeta = useMemo(
|
| 91 |
+
() => [
|
| 92 |
+
session?.status ? statusLabel(session.status) : 'Connecting',
|
| 93 |
+
session?.started_at ? `Started ${formatTime(session.started_at)}` : null,
|
| 94 |
+
session?.exit_code != null ? `Exit ${session.exit_code}` : null,
|
| 95 |
+
connectionState === 'connected' ? 'WS live' : connectionState,
|
| 96 |
+
].filter(Boolean),
|
| 97 |
+
[connectionState, session],
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
return (
|
| 101 |
+
<article className={`terminal-pane terminal-pane--${tone}`}>
|
| 102 |
+
<header className="terminal-pane__header">
|
| 103 |
+
<div className="terminal-pane__heading">
|
| 104 |
+
<div className="terminal-pane__title-row">
|
| 105 |
+
<span className="terminal-pane__dot" />
|
| 106 |
+
<h2>{title}</h2>
|
| 107 |
+
<span className={`status-chip status-chip--${session?.status || 'starting'}`}>
|
| 108 |
+
{statusLabel(session?.status)}
|
| 109 |
+
</span>
|
| 110 |
+
</div>
|
| 111 |
+
<p>{session?.command || 'Waiting for backend session...'}</p>
|
| 112 |
+
<small>{session?.cwd || 'No working directory available yet.'}</small>
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
<div className="terminal-pane__actions">
|
| 116 |
+
<button type="button" onClick={start}>
|
| 117 |
+
Attach
|
| 118 |
+
</button>
|
| 119 |
+
<button type="button" onClick={restart}>
|
| 120 |
+
Restart
|
| 121 |
+
</button>
|
| 122 |
+
<button type="button" onClick={stop}>
|
| 123 |
+
Stop
|
| 124 |
+
</button>
|
| 125 |
+
</div>
|
| 126 |
+
</header>
|
| 127 |
+
|
| 128 |
+
<div ref={viewportRef} className="terminal-pane__viewport">
|
| 129 |
+
<div ref={scrollRef} className="terminal-pane__scroll">
|
| 130 |
+
<pre className="terminal-pane__buffer">{buffer || 'Starting session...\n'}</pre>
|
| 131 |
+
{session?.status === 'running' ? <span className="terminal-pane__cursor" aria-hidden="true" /> : null}
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
<footer className="terminal-pane__footer">
|
| 136 |
+
<div className="terminal-pane__meta">
|
| 137 |
+
{footerMeta.map((item) => (
|
| 138 |
+
<span key={item}>{item}</span>
|
| 139 |
+
))}
|
| 140 |
+
{error ? <span className="terminal-pane__error">{error}</span> : null}
|
| 141 |
+
</div>
|
| 142 |
+
</footer>
|
| 143 |
+
</article>
|
| 144 |
+
)
|
| 145 |
+
})
|
| 146 |
+
|
| 147 |
+
export default TerminalPane
|
frontend/src/hooks/useTerminalSession.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
| 2 |
+
import {
|
| 3 |
+
createOrAttachSession,
|
| 4 |
+
openTerminalSocket,
|
| 5 |
+
resizeTerminalSession,
|
| 6 |
+
sendTerminalInput,
|
| 7 |
+
stopTerminalSession,
|
| 8 |
+
} from '../api/terminal'
|
| 9 |
+
|
| 10 |
+
const BUFFER_LIMIT = 160000
|
| 11 |
+
|
| 12 |
+
function trimBuffer(text) {
|
| 13 |
+
return text.length > BUFFER_LIMIT ? text.slice(-BUFFER_LIMIT) : text
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export function useTerminalSession(jobId) {
|
| 17 |
+
const [session, setSession] = useState(null)
|
| 18 |
+
const [buffer, setBuffer] = useState('')
|
| 19 |
+
const [connectionState, setConnectionState] = useState('connecting')
|
| 20 |
+
const [error, setError] = useState('')
|
| 21 |
+
const [lastOutputAt, setLastOutputAt] = useState(null)
|
| 22 |
+
|
| 23 |
+
const socketRef = useRef(null)
|
| 24 |
+
const resizeRef = useRef({ cols: null, rows: null })
|
| 25 |
+
|
| 26 |
+
const attachSocket = useCallback((sessionId) => {
|
| 27 |
+
if (socketRef.current) {
|
| 28 |
+
socketRef.current.close()
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const socket = openTerminalSocket(sessionId)
|
| 32 |
+
socketRef.current = socket
|
| 33 |
+
setConnectionState('connecting')
|
| 34 |
+
|
| 35 |
+
socket.addEventListener('open', () => {
|
| 36 |
+
setConnectionState('connected')
|
| 37 |
+
})
|
| 38 |
+
|
| 39 |
+
socket.addEventListener('message', (event) => {
|
| 40 |
+
const payload = JSON.parse(event.data)
|
| 41 |
+
|
| 42 |
+
if (payload.type === 'snapshot') {
|
| 43 |
+
setSession(payload.session)
|
| 44 |
+
setBuffer(payload.buffer || '')
|
| 45 |
+
return
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
if (payload.type === 'output') {
|
| 49 |
+
setLastOutputAt(Date.now())
|
| 50 |
+
setBuffer((previous) => trimBuffer(previous + payload.data))
|
| 51 |
+
return
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
if (payload.type === 'exit') {
|
| 55 |
+
setSession((previous) =>
|
| 56 |
+
previous
|
| 57 |
+
? {
|
| 58 |
+
...previous,
|
| 59 |
+
status: payload.status,
|
| 60 |
+
exit_code: payload.exit_code,
|
| 61 |
+
finished_at: payload.finished_at,
|
| 62 |
+
}
|
| 63 |
+
: previous,
|
| 64 |
+
)
|
| 65 |
+
}
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
socket.addEventListener('close', () => {
|
| 69 |
+
setConnectionState('disconnected')
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
socket.addEventListener('error', () => {
|
| 73 |
+
setConnectionState('error')
|
| 74 |
+
})
|
| 75 |
+
}, [])
|
| 76 |
+
|
| 77 |
+
const bootSession = useCallback(
|
| 78 |
+
async (restart = false) => {
|
| 79 |
+
try {
|
| 80 |
+
setError('')
|
| 81 |
+
const payload = await createOrAttachSession(jobId, { restart })
|
| 82 |
+
setSession(payload.session)
|
| 83 |
+
setBuffer(payload.buffer || '')
|
| 84 |
+
attachSocket(payload.session.id)
|
| 85 |
+
} catch (caughtError) {
|
| 86 |
+
setError(caughtError.message)
|
| 87 |
+
setConnectionState('error')
|
| 88 |
+
}
|
| 89 |
+
},
|
| 90 |
+
[attachSocket, jobId],
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
useEffect(() => {
|
| 94 |
+
const timeoutId = window.setTimeout(() => {
|
| 95 |
+
void bootSession(false)
|
| 96 |
+
}, 0)
|
| 97 |
+
|
| 98 |
+
return () => {
|
| 99 |
+
window.clearTimeout(timeoutId)
|
| 100 |
+
if (socketRef.current) {
|
| 101 |
+
socketRef.current.close()
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
}, [bootSession])
|
| 105 |
+
|
| 106 |
+
const restart = useCallback(() => bootSession(true), [bootSession])
|
| 107 |
+
|
| 108 |
+
const stop = useCallback(async () => {
|
| 109 |
+
if (!session?.id) {
|
| 110 |
+
return
|
| 111 |
+
}
|
| 112 |
+
try {
|
| 113 |
+
await stopTerminalSession(session.id)
|
| 114 |
+
} catch (caughtError) {
|
| 115 |
+
setError(caughtError.message)
|
| 116 |
+
}
|
| 117 |
+
}, [session])
|
| 118 |
+
|
| 119 |
+
const sendInput = useCallback(
|
| 120 |
+
async (value, appendNewline = true) => {
|
| 121 |
+
if (!session?.id || !value.trim()) {
|
| 122 |
+
return
|
| 123 |
+
}
|
| 124 |
+
try {
|
| 125 |
+
await sendTerminalInput(session.id, value, appendNewline)
|
| 126 |
+
} catch (caughtError) {
|
| 127 |
+
setError(caughtError.message)
|
| 128 |
+
}
|
| 129 |
+
},
|
| 130 |
+
[session],
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
const resize = useCallback(
|
| 134 |
+
async (cols, rows) => {
|
| 135 |
+
if (!session?.id) {
|
| 136 |
+
return
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const previous = resizeRef.current
|
| 140 |
+
if (previous.cols === cols && previous.rows === rows) {
|
| 141 |
+
return
|
| 142 |
+
}
|
| 143 |
+
resizeRef.current = { cols, rows }
|
| 144 |
+
|
| 145 |
+
try {
|
| 146 |
+
await resizeTerminalSession(session.id, cols, rows)
|
| 147 |
+
} catch {
|
| 148 |
+
// Ignore resize errors so rendering stays responsive.
|
| 149 |
+
}
|
| 150 |
+
},
|
| 151 |
+
[session],
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
return {
|
| 155 |
+
buffer,
|
| 156 |
+
connectionState,
|
| 157 |
+
error,
|
| 158 |
+
lastOutputAt,
|
| 159 |
+
restart,
|
| 160 |
+
resize,
|
| 161 |
+
sendInput,
|
| 162 |
+
session,
|
| 163 |
+
start: () => bootSession(false),
|
| 164 |
+
stop,
|
| 165 |
+
}
|
| 166 |
+
}
|
frontend/src/index.css
CHANGED
|
@@ -1,22 +1,22 @@
|
|
| 1 |
:root {
|
| 2 |
-
color: #
|
| 3 |
background:
|
| 4 |
-
radial-gradient(circle at top, rgba(88,
|
| 5 |
-
linear-gradient(180deg, #
|
| 6 |
font-synthesis: none;
|
| 7 |
text-rendering: optimizeLegibility;
|
| 8 |
-webkit-font-smoothing: antialiased;
|
| 9 |
-moz-osx-font-smoothing: grayscale;
|
| 10 |
-
--page: #
|
| 11 |
-
--pane: rgba(14,
|
| 12 |
-
--pane-
|
| 13 |
-
--
|
| 14 |
-
--text: #
|
| 15 |
-
--muted: #
|
| 16 |
-
--green: #93e19d;
|
| 17 |
--cyan: #8fd1ff;
|
| 18 |
-
--
|
| 19 |
-
--
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
* {
|
|
@@ -34,12 +34,18 @@ body {
|
|
| 34 |
min-width: 320px;
|
| 35 |
background: var(--page);
|
| 36 |
color: var(--text);
|
|
|
|
| 37 |
}
|
| 38 |
|
| 39 |
-
button
|
|
|
|
| 40 |
font: inherit;
|
| 41 |
}
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
.desktop {
|
| 44 |
position: relative;
|
| 45 |
min-height: 100vh;
|
|
@@ -50,8 +56,8 @@ button {
|
|
| 50 |
position: absolute;
|
| 51 |
inset: 0;
|
| 52 |
background:
|
| 53 |
-
radial-gradient(circle at
|
| 54 |
-
radial-gradient(circle at 100% 0%, rgba(
|
| 55 |
pointer-events: none;
|
| 56 |
}
|
| 57 |
|
|
@@ -60,180 +66,356 @@ button {
|
|
| 60 |
z-index: 1;
|
| 61 |
display: flex;
|
| 62 |
width: 100vw;
|
| 63 |
-
height: 100vh;
|
| 64 |
-
background: rgba(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
}
|
| 66 |
|
| 67 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
display: grid;
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
min-width: 0;
|
| 71 |
height: 100%;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
background:
|
| 73 |
linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent 10%),
|
| 74 |
var(--pane);
|
| 75 |
-
box-shadow: var(--shadow);
|
| 76 |
}
|
| 77 |
|
| 78 |
-
.pane + .pane {
|
| 79 |
-
border-left: 1px solid var(--pane-
|
| 80 |
}
|
| 81 |
|
| 82 |
-
.
|
| 83 |
-
display:
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
}
|
| 87 |
|
| 88 |
-
.
|
| 89 |
-
|
| 90 |
-
align-content: start;
|
| 91 |
-
gap: 4px;
|
| 92 |
-
padding: 16px 10px 16px 14px;
|
| 93 |
-
border-right: 1px solid var(--pane-edge);
|
| 94 |
-
background: var(--gutter);
|
| 95 |
-
color: rgba(142, 150, 163, 0.42);
|
| 96 |
-
font:
|
| 97 |
-
500 0.78rem/1.65 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
| 98 |
-
user-select: none;
|
| 99 |
}
|
| 100 |
|
| 101 |
-
.
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
color: var(--text);
|
|
|
|
|
|
|
| 107 |
}
|
| 108 |
|
| 109 |
-
.
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
word-break: break-word;
|
| 113 |
}
|
| 114 |
|
| 115 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
display: flex;
|
| 117 |
-
|
| 118 |
-
align-items: baseline;
|
| 119 |
-
gap: 0.5ch;
|
| 120 |
}
|
| 121 |
|
| 122 |
-
.
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
}
|
| 125 |
|
| 126 |
-
.
|
| 127 |
-
|
| 128 |
}
|
| 129 |
|
| 130 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
color: var(--green);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
}
|
| 133 |
|
| 134 |
-
.
|
| 135 |
-
color: var(--
|
|
|
|
|
|
|
| 136 |
}
|
| 137 |
|
| 138 |
-
.
|
| 139 |
color: var(--cyan);
|
|
|
|
|
|
|
| 140 |
}
|
| 141 |
|
| 142 |
-
.
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
}
|
| 145 |
|
| 146 |
-
.
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
display: inline-block;
|
| 149 |
width: 0.62em;
|
| 150 |
-
height: 1.
|
| 151 |
-
|
| 152 |
-
background: rgba(
|
| 153 |
animation: blink 1s steps(1) infinite;
|
| 154 |
}
|
| 155 |
|
| 156 |
-
.
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
| 158 |
}
|
| 159 |
|
| 160 |
-
.
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
}
|
| 164 |
|
| 165 |
-
.
|
| 166 |
-
color:
|
| 167 |
}
|
| 168 |
|
| 169 |
-
.
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
}
|
| 172 |
|
| 173 |
-
.
|
| 174 |
-
display: flex;
|
| 175 |
-
align-items: center;
|
| 176 |
-
justify-content: space-between;
|
| 177 |
-
gap: 16px;
|
| 178 |
-
padding: 10px 14px;
|
| 179 |
-
border-top: 1px solid var(--pane-edge);
|
| 180 |
-
background: rgba(15, 17, 21, 0.98);
|
| 181 |
color: var(--muted);
|
| 182 |
font:
|
| 183 |
500 0.78rem/1 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
|
|
|
|
|
|
| 184 |
}
|
| 185 |
|
| 186 |
-
.
|
| 187 |
-
.statusbar__right {
|
| 188 |
display: flex;
|
| 189 |
align-items: center;
|
| 190 |
-
gap:
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
}
|
| 193 |
|
| 194 |
-
.
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
background: rgba(255, 255, 255, 0.04);
|
| 199 |
-
color: var(--text);
|
| 200 |
}
|
| 201 |
|
| 202 |
-
.
|
| 203 |
-
|
| 204 |
-
|
| 205 |
border: 0;
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
.workspace__divider::before {
|
| 212 |
-
content: "";
|
| 213 |
-
position: absolute;
|
| 214 |
-
inset: 0;
|
| 215 |
-
background: rgba(255, 255, 255, 0.03);
|
| 216 |
}
|
| 217 |
|
| 218 |
-
.
|
| 219 |
-
|
| 220 |
-
top: 50%;
|
| 221 |
-
left: 50%;
|
| 222 |
-
width: 3px;
|
| 223 |
-
height: 52px;
|
| 224 |
-
border-radius: 999px;
|
| 225 |
-
background: rgba(255, 255, 255, 0.22);
|
| 226 |
-
transform: translate(-50%, -50%);
|
| 227 |
}
|
| 228 |
|
| 229 |
-
.
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
| 232 |
}
|
| 233 |
|
| 234 |
-
.
|
| 235 |
-
|
| 236 |
-
box-shadow: inset 0 0 0 1px rgba(143, 209, 255, 0.5);
|
| 237 |
}
|
| 238 |
|
| 239 |
@keyframes blink {
|
|
@@ -242,27 +424,63 @@ button {
|
|
| 242 |
}
|
| 243 |
}
|
| 244 |
|
| 245 |
-
@media (max-width:
|
| 246 |
-
.
|
| 247 |
-
|
| 248 |
}
|
| 249 |
|
| 250 |
-
.
|
| 251 |
-
|
| 252 |
}
|
| 253 |
|
| 254 |
-
.
|
| 255 |
-
|
| 256 |
-
|
| 257 |
}
|
| 258 |
|
| 259 |
-
.
|
| 260 |
-
align-items: flex-start;
|
| 261 |
flex-direction: column;
|
| 262 |
}
|
| 263 |
|
| 264 |
-
.
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
}
|
| 268 |
}
|
|
|
|
| 1 |
:root {
|
| 2 |
+
color: #ebeff5;
|
| 3 |
background:
|
| 4 |
+
radial-gradient(circle at top, rgba(88, 108, 146, 0.16), transparent 24%),
|
| 5 |
+
linear-gradient(180deg, #0d1015 0%, #090b0f 100%);
|
| 6 |
font-synthesis: none;
|
| 7 |
text-rendering: optimizeLegibility;
|
| 8 |
-webkit-font-smoothing: antialiased;
|
| 9 |
-moz-osx-font-smoothing: grayscale;
|
| 10 |
+
--page: #090b0f;
|
| 11 |
+
--pane: rgba(14, 17, 22, 0.98);
|
| 12 |
+
--pane-border: rgba(255, 255, 255, 0.07);
|
| 13 |
+
--pane-soft: rgba(255, 255, 255, 0.03);
|
| 14 |
+
--text: #ebeff5;
|
| 15 |
+
--muted: #8f97a6;
|
|
|
|
| 16 |
--cyan: #8fd1ff;
|
| 17 |
+
--green: #93e09f;
|
| 18 |
+
--red: #ff7f8c;
|
| 19 |
+
--amber: #e6c171;
|
| 20 |
}
|
| 21 |
|
| 22 |
* {
|
|
|
|
| 34 |
min-width: 320px;
|
| 35 |
background: var(--page);
|
| 36 |
color: var(--text);
|
| 37 |
+
font-family: "IBM Plex Sans", "SF Pro Display", "Segoe UI", sans-serif;
|
| 38 |
}
|
| 39 |
|
| 40 |
+
button,
|
| 41 |
+
input {
|
| 42 |
font: inherit;
|
| 43 |
}
|
| 44 |
|
| 45 |
+
button {
|
| 46 |
+
cursor: pointer;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
.desktop {
|
| 50 |
position: relative;
|
| 51 |
min-height: 100vh;
|
|
|
|
| 56 |
position: absolute;
|
| 57 |
inset: 0;
|
| 58 |
background:
|
| 59 |
+
radial-gradient(circle at 30% 0%, rgba(109, 132, 180, 0.14), transparent 20%),
|
| 60 |
+
radial-gradient(circle at 100% 0%, rgba(70, 110, 168, 0.12), transparent 16%);
|
| 61 |
pointer-events: none;
|
| 62 |
}
|
| 63 |
|
|
|
|
| 66 |
z-index: 1;
|
| 67 |
display: flex;
|
| 68 |
width: 100vw;
|
| 69 |
+
height: calc(100vh - 194px);
|
| 70 |
+
background: rgba(7, 10, 13, 0.98);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.comparison-bar {
|
| 74 |
+
position: relative;
|
| 75 |
+
z-index: 1;
|
| 76 |
+
display: flex;
|
| 77 |
+
align-items: stretch;
|
| 78 |
+
justify-content: space-between;
|
| 79 |
+
gap: 18px;
|
| 80 |
+
padding: 14px 18px;
|
| 81 |
+
border-bottom: 1px solid var(--pane-border);
|
| 82 |
+
background:
|
| 83 |
+
linear-gradient(180deg, rgba(16, 19, 25, 0.98), rgba(11, 13, 18, 0.98));
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.comparison-bar__copy {
|
| 87 |
+
display: grid;
|
| 88 |
+
gap: 5px;
|
| 89 |
+
min-width: 0;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.comparison-bar__copy strong,
|
| 93 |
+
.comparison-bar__copy small {
|
| 94 |
+
overflow: hidden;
|
| 95 |
+
text-overflow: ellipsis;
|
| 96 |
+
white-space: nowrap;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.comparison-bar__eyebrow {
|
| 100 |
+
color: var(--muted);
|
| 101 |
+
font:
|
| 102 |
+
500 0.74rem/1 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
| 103 |
+
text-transform: uppercase;
|
| 104 |
+
letter-spacing: 0.14em;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.comparison-bar__copy strong {
|
| 108 |
+
font-size: 1rem;
|
| 109 |
+
font-weight: 600;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.comparison-bar__copy small {
|
| 113 |
+
color: var(--muted);
|
| 114 |
}
|
| 115 |
|
| 116 |
+
.comparison-bar__stats {
|
| 117 |
+
display: flex;
|
| 118 |
+
gap: 12px;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.comparison-card {
|
| 122 |
display: grid;
|
| 123 |
+
gap: 4px;
|
| 124 |
+
min-width: 170px;
|
| 125 |
+
padding: 12px 14px;
|
| 126 |
+
border: 1px solid var(--pane-border);
|
| 127 |
+
border-radius: 14px;
|
| 128 |
+
background: rgba(255, 255, 255, 0.03);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.comparison-card span,
|
| 132 |
+
.comparison-card small {
|
| 133 |
+
color: var(--muted);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.comparison-card strong {
|
| 137 |
+
font:
|
| 138 |
+
600 1.2rem/1.1 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
.comparison-card--cyan strong {
|
| 142 |
+
color: var(--cyan);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.comparison-card--green strong {
|
| 146 |
+
color: var(--green);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.workspace__pane {
|
| 150 |
min-width: 0;
|
| 151 |
height: 100%;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.workspace__divider {
|
| 155 |
+
position: relative;
|
| 156 |
+
flex: 0 0 12px;
|
| 157 |
+
border: 0;
|
| 158 |
+
padding: 0;
|
| 159 |
+
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.015));
|
| 160 |
+
cursor: col-resize;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.workspace__divider span {
|
| 164 |
+
position: absolute;
|
| 165 |
+
top: 50%;
|
| 166 |
+
left: 50%;
|
| 167 |
+
width: 3px;
|
| 168 |
+
height: 72px;
|
| 169 |
+
border-radius: 999px;
|
| 170 |
+
background: rgba(255, 255, 255, 0.22);
|
| 171 |
+
transform: translate(-50%, -50%);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.workspace__divider:hover span,
|
| 175 |
+
.workspace__divider.is-dragging span {
|
| 176 |
+
background: rgba(143, 209, 255, 0.8);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.workspace__divider:focus-visible {
|
| 180 |
+
outline: none;
|
| 181 |
+
box-shadow: inset 0 0 0 1px rgba(143, 209, 255, 0.5);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.terminal-pane {
|
| 185 |
+
display: grid;
|
| 186 |
+
grid-template-rows: auto minmax(0, 1fr) auto;
|
| 187 |
+
width: 100%;
|
| 188 |
+
height: 100%;
|
| 189 |
background:
|
| 190 |
linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent 10%),
|
| 191 |
var(--pane);
|
|
|
|
| 192 |
}
|
| 193 |
|
| 194 |
+
.terminal-pane + .terminal-pane {
|
| 195 |
+
border-left: 1px solid var(--pane-border);
|
| 196 |
}
|
| 197 |
|
| 198 |
+
.terminal-pane__header {
|
| 199 |
+
display: flex;
|
| 200 |
+
align-items: flex-start;
|
| 201 |
+
justify-content: space-between;
|
| 202 |
+
gap: 18px;
|
| 203 |
+
padding: 18px 18px 14px;
|
| 204 |
+
border-bottom: 1px solid var(--pane-border);
|
| 205 |
+
background: rgba(255, 255, 255, 0.02);
|
| 206 |
}
|
| 207 |
|
| 208 |
+
.terminal-pane__heading {
|
| 209 |
+
min-width: 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
}
|
| 211 |
|
| 212 |
+
.terminal-pane__title-row {
|
| 213 |
+
display: flex;
|
| 214 |
+
align-items: center;
|
| 215 |
+
gap: 10px;
|
| 216 |
+
margin-bottom: 6px;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.terminal-pane__title-row h2 {
|
| 220 |
+
margin: 0;
|
| 221 |
+
font-size: 1rem;
|
| 222 |
+
font-weight: 600;
|
| 223 |
+
letter-spacing: 0.01em;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.terminal-pane__heading p,
|
| 227 |
+
.terminal-pane__heading small {
|
| 228 |
+
display: block;
|
| 229 |
+
margin: 0;
|
| 230 |
+
overflow: hidden;
|
| 231 |
+
text-overflow: ellipsis;
|
| 232 |
+
white-space: nowrap;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.terminal-pane__heading p {
|
| 236 |
color: var(--text);
|
| 237 |
+
font:
|
| 238 |
+
500 0.84rem/1.4 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
| 239 |
}
|
| 240 |
|
| 241 |
+
.terminal-pane__heading small {
|
| 242 |
+
margin-top: 4px;
|
| 243 |
+
color: var(--muted);
|
|
|
|
| 244 |
}
|
| 245 |
|
| 246 |
+
.terminal-pane__dot {
|
| 247 |
+
width: 9px;
|
| 248 |
+
height: 9px;
|
| 249 |
+
border-radius: 999px;
|
| 250 |
+
background: var(--cyan);
|
| 251 |
+
box-shadow: 0 0 24px rgba(143, 209, 255, 0.35);
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.terminal-pane--green .terminal-pane__dot {
|
| 255 |
+
background: var(--green);
|
| 256 |
+
box-shadow: 0 0 24px rgba(147, 224, 159, 0.35);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
.terminal-pane__actions {
|
| 260 |
display: flex;
|
| 261 |
+
gap: 8px;
|
|
|
|
|
|
|
| 262 |
}
|
| 263 |
|
| 264 |
+
.terminal-pane__actions button {
|
| 265 |
+
padding: 8px 10px;
|
| 266 |
+
border: 1px solid var(--pane-border);
|
| 267 |
+
border-radius: 10px;
|
| 268 |
+
background: rgba(255, 255, 255, 0.03);
|
| 269 |
+
color: var(--text);
|
| 270 |
}
|
| 271 |
|
| 272 |
+
.terminal-pane__actions button:hover {
|
| 273 |
+
background: rgba(255, 255, 255, 0.06);
|
| 274 |
}
|
| 275 |
|
| 276 |
+
.status-chip {
|
| 277 |
+
padding: 4px 8px;
|
| 278 |
+
border-radius: 999px;
|
| 279 |
+
font-size: 0.7rem;
|
| 280 |
+
text-transform: uppercase;
|
| 281 |
+
letter-spacing: 0.12em;
|
| 282 |
+
border: 1px solid transparent;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
.status-chip--running {
|
| 286 |
color: var(--green);
|
| 287 |
+
border-color: rgba(147, 224, 159, 0.24);
|
| 288 |
+
background: rgba(147, 224, 159, 0.08);
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
.status-chip--failed {
|
| 292 |
+
color: var(--red);
|
| 293 |
+
border-color: rgba(255, 127, 140, 0.25);
|
| 294 |
+
background: rgba(255, 127, 140, 0.08);
|
| 295 |
}
|
| 296 |
|
| 297 |
+
.status-chip--exited {
|
| 298 |
+
color: var(--amber);
|
| 299 |
+
border-color: rgba(230, 193, 113, 0.24);
|
| 300 |
+
background: rgba(230, 193, 113, 0.08);
|
| 301 |
}
|
| 302 |
|
| 303 |
+
.status-chip--starting {
|
| 304 |
color: var(--cyan);
|
| 305 |
+
border-color: rgba(143, 209, 255, 0.24);
|
| 306 |
+
background: rgba(143, 209, 255, 0.08);
|
| 307 |
}
|
| 308 |
|
| 309 |
+
.terminal-pane__viewport {
|
| 310 |
+
min-height: 0;
|
| 311 |
+
overflow: hidden;
|
| 312 |
+
font:
|
| 313 |
+
500 0.95rem/1.72 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
.terminal-pane__scroll {
|
| 317 |
+
height: 100%;
|
| 318 |
+
overflow: auto;
|
| 319 |
+
padding: 18px 18px 20px;
|
| 320 |
}
|
| 321 |
|
| 322 |
+
.terminal-pane__buffer {
|
| 323 |
+
margin: 0;
|
| 324 |
+
white-space: pre-wrap;
|
| 325 |
+
word-break: break-word;
|
| 326 |
+
color: #edf2fa;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
.terminal-pane__cursor {
|
| 330 |
display: inline-block;
|
| 331 |
width: 0.62em;
|
| 332 |
+
height: 1.05em;
|
| 333 |
+
margin-top: 4px;
|
| 334 |
+
background: rgba(237, 242, 250, 0.9);
|
| 335 |
animation: blink 1s steps(1) infinite;
|
| 336 |
}
|
| 337 |
|
| 338 |
+
.terminal-pane__footer {
|
| 339 |
+
display: grid;
|
| 340 |
+
padding: 14px 18px 16px;
|
| 341 |
+
border-top: 1px solid var(--pane-border);
|
| 342 |
+
background: rgba(255, 255, 255, 0.02);
|
| 343 |
}
|
| 344 |
|
| 345 |
+
.terminal-pane__meta {
|
| 346 |
+
display: flex;
|
| 347 |
+
flex-wrap: wrap;
|
| 348 |
+
gap: 10px 14px;
|
| 349 |
+
color: var(--muted);
|
| 350 |
+
font:
|
| 351 |
+
500 0.76rem/1.3 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
| 352 |
}
|
| 353 |
|
| 354 |
+
.terminal-pane__error {
|
| 355 |
+
color: var(--red);
|
| 356 |
}
|
| 357 |
|
| 358 |
+
.broadcast-bar {
|
| 359 |
+
position: relative;
|
| 360 |
+
z-index: 1;
|
| 361 |
+
display: grid;
|
| 362 |
+
gap: 8px;
|
| 363 |
+
padding: 14px 18px 18px;
|
| 364 |
+
border-top: 1px solid var(--pane-border);
|
| 365 |
+
background:
|
| 366 |
+
linear-gradient(180deg, rgba(18, 21, 27, 0.98), rgba(10, 12, 16, 0.98));
|
| 367 |
}
|
| 368 |
|
| 369 |
+
.broadcast-bar__label {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
color: var(--muted);
|
| 371 |
font:
|
| 372 |
500 0.78rem/1 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
| 373 |
+
text-transform: uppercase;
|
| 374 |
+
letter-spacing: 0.12em;
|
| 375 |
}
|
| 376 |
|
| 377 |
+
.broadcast-bar__field {
|
|
|
|
| 378 |
display: flex;
|
| 379 |
align-items: center;
|
| 380 |
+
gap: 12px;
|
| 381 |
+
padding: 14px 16px;
|
| 382 |
+
border: 1px solid var(--pane-border);
|
| 383 |
+
border-radius: 14px;
|
| 384 |
+
background: rgba(255, 255, 255, 0.03);
|
| 385 |
+
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
|
| 386 |
}
|
| 387 |
|
| 388 |
+
.broadcast-bar__prompt {
|
| 389 |
+
color: var(--cyan);
|
| 390 |
+
font:
|
| 391 |
+
600 0.92rem/1 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
|
|
|
|
|
|
| 392 |
}
|
| 393 |
|
| 394 |
+
.broadcast-bar__field input {
|
| 395 |
+
flex: 1;
|
| 396 |
+
min-width: 0;
|
| 397 |
border: 0;
|
| 398 |
+
background: transparent;
|
| 399 |
+
color: var(--text);
|
| 400 |
+
outline: none;
|
| 401 |
+
font:
|
| 402 |
+
500 0.95rem/1.2 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
}
|
| 404 |
|
| 405 |
+
.broadcast-bar__field input::placeholder {
|
| 406 |
+
color: var(--muted);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
}
|
| 408 |
|
| 409 |
+
.broadcast-bar__field button {
|
| 410 |
+
padding: 10px 14px;
|
| 411 |
+
border: 1px solid rgba(143, 209, 255, 0.18);
|
| 412 |
+
border-radius: 10px;
|
| 413 |
+
background: rgba(143, 209, 255, 0.08);
|
| 414 |
+
color: var(--text);
|
| 415 |
}
|
| 416 |
|
| 417 |
+
.broadcast-bar__field button:hover {
|
| 418 |
+
background: rgba(143, 209, 255, 0.14);
|
|
|
|
| 419 |
}
|
| 420 |
|
| 421 |
@keyframes blink {
|
|
|
|
| 424 |
}
|
| 425 |
}
|
| 426 |
|
| 427 |
+
@media (max-width: 980px) {
|
| 428 |
+
.comparison-bar {
|
| 429 |
+
flex-direction: column;
|
| 430 |
}
|
| 431 |
|
| 432 |
+
.comparison-bar__stats {
|
| 433 |
+
width: 100%;
|
| 434 |
}
|
| 435 |
|
| 436 |
+
.comparison-card {
|
| 437 |
+
flex: 1;
|
| 438 |
+
min-width: 0;
|
| 439 |
}
|
| 440 |
|
| 441 |
+
.terminal-pane__header {
|
|
|
|
| 442 |
flex-direction: column;
|
| 443 |
}
|
| 444 |
|
| 445 |
+
.terminal-pane__actions {
|
| 446 |
+
width: 100%;
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
.terminal-pane__actions button {
|
| 450 |
+
flex: 1;
|
| 451 |
+
}
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
@media (max-width: 780px) {
|
| 455 |
+
.workspace {
|
| 456 |
+
height: calc(100vh - 244px);
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
.workspace__divider {
|
| 460 |
+
flex-basis: 10px;
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
.terminal-pane__scroll {
|
| 464 |
+
padding: 16px;
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
.terminal-pane__viewport {
|
| 468 |
+
font-size: 0.88rem;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
.terminal-pane__footer {
|
| 472 |
+
padding: 12px 14px 14px;
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
.broadcast-bar {
|
| 476 |
+
padding: 12px 14px 14px;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
.broadcast-bar__field {
|
| 480 |
+
padding: 12px 14px;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
.comparison-bar {
|
| 484 |
+
padding: 12px 14px;
|
| 485 |
}
|
| 486 |
}
|