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 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
- allow_credentials=True,
 
 
 
 
 
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
- id: 'left',
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(52)
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
- <article className="pane" style={{ width: `${split}%` }}>
82
- <div className="pane__viewport">
83
- <div className="pane__gutter" aria-hidden="true">
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
- <article className="pane" style={{ width: `${100 - split}%` }}>
145
- <div className="pane__viewport">
146
- <div className="pane__gutter" aria-hidden="true">
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: #e9edf2;
3
  background:
4
- radial-gradient(circle at top, rgba(88, 98, 115, 0.12), transparent 24%),
5
- linear-gradient(180deg, #0e1013 0%, #0a0c0f 100%);
6
  font-synthesis: none;
7
  text-rendering: optimizeLegibility;
8
  -webkit-font-smoothing: antialiased;
9
  -moz-osx-font-smoothing: grayscale;
10
- --page: #0a0c0f;
11
- --pane: rgba(14, 16, 20, 0.98);
12
- --pane-edge: rgba(255, 255, 255, 0.06);
13
- --gutter: rgba(255, 255, 255, 0.03);
14
- --text: #e9edf2;
15
- --muted: #8e96a3;
16
- --green: #93e19d;
17
  --cyan: #8fd1ff;
18
- --yellow: #e7c377;
19
- --shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
 
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 50% 0%, rgba(105, 115, 132, 0.14), transparent 20%),
54
- radial-gradient(circle at 100% 0%, rgba(55, 78, 114, 0.1), transparent 18%);
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(8, 10, 13, 0.96);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  }
66
 
67
- .pane {
 
 
 
 
 
68
  display: grid;
69
- grid-template-rows: minmax(0, 1fr) auto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-edge);
80
  }
81
 
82
- .pane__viewport {
83
- display: grid;
84
- grid-template-columns: 54px minmax(0, 1fr);
85
- min-height: 0;
 
 
 
 
86
  }
87
 
88
- .pane__gutter {
89
- display: grid;
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
- .pane__buffer {
102
- overflow: auto;
103
- padding: 16px 18px 20px;
104
- font:
105
- 500 0.95rem/1.72 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  color: var(--text);
 
 
107
  }
108
 
109
- .line {
110
- min-height: 1.72em;
111
- white-space: pre-wrap;
112
- word-break: break-word;
113
  }
114
 
115
- .line--prompt {
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  display: flex;
117
- flex-wrap: wrap;
118
- align-items: baseline;
119
- gap: 0.5ch;
120
  }
121
 
122
- .line--stdout {
123
- color: #d4d9e1;
 
 
 
 
124
  }
125
 
126
- .line--muted {
127
- color: var(--muted);
128
  }
129
 
130
- .line--success {
 
 
 
 
 
 
 
 
 
131
  color: var(--green);
 
 
 
 
 
 
 
 
132
  }
133
 
134
- .line--warning {
135
- color: var(--yellow);
 
 
136
  }
137
 
138
- .line--json {
139
  color: var(--cyan);
 
 
140
  }
141
 
142
- .line--active .prompt__command {
143
- position: relative;
 
 
 
 
 
 
 
 
 
144
  }
145
 
146
- .line--active .prompt__command::after {
147
- content: "";
 
 
 
 
 
 
148
  display: inline-block;
149
  width: 0.62em;
150
- height: 1.08em;
151
- vertical-align: -0.18em;
152
- background: rgba(233, 237, 242, 0.92);
153
  animation: blink 1s steps(1) infinite;
154
  }
155
 
156
- .prompt__host {
157
- color: var(--cyan);
 
 
 
158
  }
159
 
160
- .prompt__separator,
161
- .prompt__symbol {
162
- color: #9ea6b3;
 
 
 
 
163
  }
164
 
165
- .prompt__cwd {
166
- color: #ccd2dc;
167
  }
168
 
169
- .prompt__command {
170
- color: #f3f6fb;
 
 
 
 
 
 
 
171
  }
172
 
173
- .pane__statusbar {
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
- .statusbar__left,
187
- .statusbar__right {
188
  display: flex;
189
  align-items: center;
190
- gap: 14px;
191
- min-width: 0;
 
 
 
 
192
  }
193
 
194
- .status-pill {
195
- padding: 4px 7px;
196
- border: 1px solid rgba(255, 255, 255, 0.09);
197
- border-radius: 6px;
198
- background: rgba(255, 255, 255, 0.04);
199
- color: var(--text);
200
  }
201
 
202
- .workspace__divider {
203
- position: relative;
204
- flex: 0 0 11px;
205
  border: 0;
206
- padding: 0;
207
- background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.015));
208
- cursor: col-resize;
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
- .workspace__divider span {
219
- position: absolute;
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
- .workspace__divider:hover span,
230
- .workspace__divider.is-dragging span {
231
- background: rgba(143, 209, 255, 0.72);
 
 
 
232
  }
233
 
234
- .workspace__divider:focus-visible {
235
- outline: none;
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: 900px) {
246
- .pane__viewport {
247
- grid-template-columns: 1fr;
248
  }
249
 
250
- .pane__gutter {
251
- display: none;
252
  }
253
 
254
- .pane__buffer {
255
- padding: 14px 16px 18px;
256
- font-size: 0.9rem;
257
  }
258
 
259
- .pane__statusbar {
260
- align-items: flex-start;
261
  flex-direction: column;
262
  }
263
 
264
- .statusbar__left,
265
- .statusbar__right {
266
- flex-wrap: wrap;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  }