feat: add clide-web package replacing ttyd + zellij
Python web server that wraps Clide for browser access. Replaces the previous ttyd (C binary) + zellij (Rust binary) stack with a single FastAPI application using tmux for session persistence. Key features: - WebSocket ↔ PTY bridge via tmux attach - Project switching via /projects/<name> URL routing - Vendored xterm.js for offline LAN operation - Auto-respawn on Clide exit (tmux pane-died hook) - Setup wizard for first-run configuration - No scrollbar (TUI manages its own scrolling) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
.PHONY: setup run dev test lint format clean help start-server stop-server restart-server status-server install-server logs-server
|
||||
|
||||
PYTHON := python3.12
|
||||
VENV := .venv
|
||||
BIN := $(VENV)/bin
|
||||
SERVICE := clide-web
|
||||
|
||||
BLUE := \033[0;34m
|
||||
GREEN := \033[0;32m
|
||||
YELLOW := \033[0;33m
|
||||
RESET := \033[0m
|
||||
|
||||
help:
|
||||
@printf "$(BLUE)clide-web Commands$(RESET)\n\n"
|
||||
@printf "$(YELLOW)Development$(RESET)\n"
|
||||
@printf " $(GREEN)setup$(RESET) Create venv, install deps, run setup wizard\n"
|
||||
@printf " $(GREEN)run$(RESET) Run the web server (foreground)\n"
|
||||
@printf " $(GREEN)dev$(RESET) Run with auto-reload\n"
|
||||
@printf " $(GREEN)test$(RESET) Run tests\n"
|
||||
@printf " $(GREEN)lint$(RESET) Run ruff linter\n"
|
||||
@printf " $(GREEN)format$(RESET) Run ruff formatter\n"
|
||||
@printf " $(GREEN)clean$(RESET) Remove build artifacts\n"
|
||||
@printf "\n$(YELLOW)Service$(RESET)\n"
|
||||
@printf " $(GREEN)install-server$(RESET) Install systemd service (requires sudo)\n"
|
||||
@printf " $(GREEN)start-server$(RESET) Start the systemd service\n"
|
||||
@printf " $(GREEN)stop-server$(RESET) Stop the systemd service\n"
|
||||
@printf " $(GREEN)restart-server$(RESET) Restart the systemd service\n"
|
||||
@printf " $(GREEN)status-server$(RESET) Show service status\n"
|
||||
@printf " $(GREEN)logs-server$(RESET) Tail service logs\n"
|
||||
|
||||
# --- Development ---
|
||||
|
||||
setup:
|
||||
$(PYTHON) -m venv $(VENV)
|
||||
$(BIN)/pip install --upgrade pip
|
||||
$(BIN)/pip install -e ".[dev]"
|
||||
$(BIN)/clide-web-setup
|
||||
@printf "$(GREEN)Setup complete!$(RESET)\n"
|
||||
|
||||
run:
|
||||
$(BIN)/clide-web
|
||||
|
||||
dev:
|
||||
$(BIN)/uvicorn clide_web.server:app --reload --host 0.0.0.0 --port 8888
|
||||
|
||||
test:
|
||||
$(BIN)/pytest tests/
|
||||
|
||||
lint:
|
||||
$(BIN)/ruff check clide_web/
|
||||
|
||||
format:
|
||||
$(BIN)/ruff format clide_web/
|
||||
$(BIN)/ruff check --fix clide_web/
|
||||
|
||||
clean:
|
||||
rm -rf $(VENV) dist/ build/
|
||||
rm -rf .pytest_cache/ .mypy_cache/ .ruff_cache/
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# --- Service ---
|
||||
|
||||
install-server:
|
||||
@if [ "$$(id -u)" -ne 0 ]; then printf "$(YELLOW)Run with sudo: sudo make install-server$(RESET)\n"; exit 1; fi
|
||||
cp ../deploy/clide-web.service /etc/systemd/system/$(SERVICE).service
|
||||
systemctl daemon-reload
|
||||
systemctl enable $(SERVICE)
|
||||
@printf "$(GREEN)Service installed. Run 'make start-server' to start.$(RESET)\n"
|
||||
|
||||
start-server:
|
||||
sudo systemctl start $(SERVICE)
|
||||
@systemctl is-active --quiet $(SERVICE) && printf "$(GREEN)$(SERVICE) started$(RESET)\n" || printf "$(YELLOW)Failed to start. Check: make logs-server$(RESET)\n"
|
||||
|
||||
stop-server:
|
||||
sudo systemctl stop $(SERVICE)
|
||||
@printf "$(GREEN)$(SERVICE) stopped$(RESET)\n"
|
||||
|
||||
restart-server:
|
||||
sudo systemctl restart $(SERVICE)
|
||||
@systemctl is-active --quiet $(SERVICE) && printf "$(GREEN)$(SERVICE) restarted$(RESET)\n" || printf "$(YELLOW)Failed to restart. Check: make logs-server$(RESET)\n"
|
||||
|
||||
status-server:
|
||||
@systemctl status $(SERVICE) --no-pager || true
|
||||
|
||||
logs-server:
|
||||
journalctl -u $(SERVICE) -f --no-pager
|
||||
@@ -0,0 +1,3 @@
|
||||
"""clide-web: Web server for Clide TUI."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Entry point for python -m clide_web."""
|
||||
|
||||
from clide_web.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Configuration for clide-web using Pydantic Settings + DB preferences."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_PATH = Path.home() / ".clide" / "clide.db"
|
||||
|
||||
|
||||
class ClideWebSettings(BaseSettings):
|
||||
"""Web server settings.
|
||||
|
||||
Priority: env vars > .env file > DB preferences > defaults.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="CLIDE_WEB_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8888
|
||||
|
||||
# Paths
|
||||
projects_dir: Path = Path("/mnt/media/Projects")
|
||||
clide_bin: str = "clide"
|
||||
|
||||
# Database
|
||||
db_path: Path = DB_PATH
|
||||
|
||||
# Sessions
|
||||
session_timeout_seconds: int = 3600
|
||||
session_cleanup_interval_seconds: int = 60
|
||||
|
||||
# Terminal
|
||||
default_cols: int = 120
|
||||
default_rows: int = 40
|
||||
term: str = "xterm-256color"
|
||||
|
||||
# UI
|
||||
font_family: str = "JetBrains Mono, monospace"
|
||||
font_size: int = 14
|
||||
default_theme: str = "summer-night"
|
||||
|
||||
|
||||
def load_settings() -> ClideWebSettings:
|
||||
"""Load settings, overlaying DB preferences onto defaults.
|
||||
|
||||
Env vars still take highest priority (Pydantic handles that).
|
||||
DB preferences override hardcoded defaults for fields not set via env.
|
||||
"""
|
||||
import os
|
||||
|
||||
# First load from env/defaults
|
||||
settings = ClideWebSettings()
|
||||
|
||||
# Then overlay DB preferences for fields not explicitly set via env
|
||||
try:
|
||||
prefs = _load_db_preferences(settings.db_path)
|
||||
except Exception:
|
||||
logger.debug("Could not load DB preferences (DB may not exist yet)")
|
||||
return settings
|
||||
|
||||
env_prefix = "CLIDE_WEB_"
|
||||
field_map = {
|
||||
"projects_dir": ("projects_dir", Path),
|
||||
"clide_bin": ("clide_bin", str),
|
||||
"port": ("port", int),
|
||||
"font_size": ("font_size", int),
|
||||
"default_theme": ("default_theme", str),
|
||||
"font_family": ("font_family", str),
|
||||
"host": ("host", str),
|
||||
}
|
||||
|
||||
for pref_key, (field_name, field_type) in field_map.items():
|
||||
env_var = f"{env_prefix}{field_name.upper()}"
|
||||
# Only apply DB pref if env var is NOT set
|
||||
if env_var not in os.environ and pref_key in prefs:
|
||||
try:
|
||||
setattr(settings, field_name, field_type(prefs[pref_key]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
def _load_db_preferences(db_path: Path) -> dict[str, str]:
|
||||
"""Read UserPreference records from the database."""
|
||||
if not db_path.exists():
|
||||
return {}
|
||||
|
||||
from clide.models.db import UserPreference
|
||||
from clide.services.database import get_engine
|
||||
from sqlmodel import Session as DBSession
|
||||
from sqlmodel import select
|
||||
|
||||
engine = get_engine(db_path)
|
||||
with DBSession(engine) as db:
|
||||
stmt = select(UserPreference)
|
||||
return {p.key: p.value for p in db.exec(stmt).all()}
|
||||
Binary file not shown.
@@ -0,0 +1,169 @@
|
||||
"""PTY bridge: fork a PTY running tmux attach, async I/O to WebSocket clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
import signal
|
||||
import struct
|
||||
import termios
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
class PtyBridge:
|
||||
"""Manages a single PTY connection to a tmux session.
|
||||
|
||||
One PtyBridge per WebSocket connection. The PTY runs `tmux attach -t <session>`.
|
||||
When the WebSocket disconnects, the PTY is killed but the tmux session persists.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tmux_session: str,
|
||||
on_output: Callable[[bytes], None],
|
||||
on_exit: Callable[[], None],
|
||||
rows: int = 40,
|
||||
cols: int = 120,
|
||||
) -> None:
|
||||
self._tmux_session = tmux_session
|
||||
self._on_output = on_output
|
||||
self._on_exit = on_exit
|
||||
self._rows = rows
|
||||
self._cols = cols
|
||||
self._pid: int | None = None
|
||||
self._master_fd: int | None = None
|
||||
self._read_task: asyncio.Task | None = None # type: ignore[type-arg]
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
if self._pid is None:
|
||||
return False
|
||||
try:
|
||||
pid, _ = os.waitpid(self._pid, os.WNOHANG)
|
||||
return pid == 0
|
||||
except ChildProcessError:
|
||||
return False
|
||||
|
||||
def start(self) -> None:
|
||||
"""Fork a PTY and exec tmux attach."""
|
||||
pid, master_fd = pty.fork()
|
||||
|
||||
if pid == 0:
|
||||
# Child process
|
||||
os.environ["TERM"] = "xterm-256color"
|
||||
os.environ["COLORTERM"] = "truecolor"
|
||||
os.environ["COLUMNS"] = str(self._cols)
|
||||
os.environ["LINES"] = str(self._rows)
|
||||
os.execvp("tmux", ["tmux", "attach-session", "-t", self._tmux_session])
|
||||
else:
|
||||
# Parent process
|
||||
self._pid = pid
|
||||
self._master_fd = master_fd
|
||||
|
||||
# Set non-blocking I/O
|
||||
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||||
|
||||
# Set initial PTY size
|
||||
self._set_pty_size(self._rows, self._cols)
|
||||
|
||||
# Start async read loop
|
||||
self._read_task = asyncio.create_task(self._read_output())
|
||||
|
||||
async def _read_output(self) -> None:
|
||||
"""Read PTY output and forward to callback."""
|
||||
if self._master_fd is None:
|
||||
return
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
fd = self._master_fd
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Wait for data using event loop (more efficient than polling)
|
||||
await _wait_for_fd(loop, fd)
|
||||
|
||||
try:
|
||||
data = os.read(fd, 65536)
|
||||
if not data:
|
||||
break
|
||||
self._on_output(data)
|
||||
except BlockingIOError:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
self._on_exit()
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
"""Write input data to the PTY."""
|
||||
if self._master_fd is not None:
|
||||
try:
|
||||
os.write(self._master_fd, data)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def resize(self, rows: int, cols: int) -> None:
|
||||
"""Resize the PTY and notify the child process."""
|
||||
self._rows = rows
|
||||
self._cols = cols
|
||||
if self._master_fd is not None:
|
||||
self._set_pty_size(rows, cols)
|
||||
|
||||
def _set_pty_size(self, rows: int, cols: int) -> None:
|
||||
"""Set PTY window size via ioctl and send SIGWINCH."""
|
||||
if self._master_fd is None:
|
||||
return
|
||||
try:
|
||||
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize)
|
||||
if self._pid is not None:
|
||||
try:
|
||||
os.kill(self._pid, signal.SIGWINCH)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Kill the PTY process and clean up."""
|
||||
if self._read_task:
|
||||
self._read_task.cancel()
|
||||
self._read_task = None
|
||||
|
||||
if self._pid is not None:
|
||||
try:
|
||||
os.kill(self._pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.waitpid(self._pid, 0)
|
||||
except ChildProcessError:
|
||||
pass
|
||||
self._pid = None
|
||||
|
||||
if self._master_fd is not None:
|
||||
try:
|
||||
os.close(self._master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self._master_fd = None
|
||||
|
||||
|
||||
async def _wait_for_fd(loop: asyncio.AbstractEventLoop, fd: int) -> None:
|
||||
"""Wait until a file descriptor has data ready to read."""
|
||||
future: asyncio.Future[None] = loop.create_future()
|
||||
|
||||
def _ready() -> None:
|
||||
if not future.done():
|
||||
future.set_result(None)
|
||||
|
||||
loop.add_reader(fd, _ready)
|
||||
try:
|
||||
await future
|
||||
finally:
|
||||
loop.remove_reader(fd)
|
||||
@@ -0,0 +1,309 @@
|
||||
"""FastAPI application: HTTP routes + WebSocket terminal bridge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from clide.models.db import ConnectionLog
|
||||
from clide.services.database import get_db, init_db
|
||||
from fastapi import Depends, FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlmodel import Session as DBSession
|
||||
|
||||
from clide_web.config import ClideWebSettings, load_settings
|
||||
from clide_web.pty_bridge import PtyBridge
|
||||
from clide_web.sessions import TmuxSessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level settings and session manager (initialized in lifespan)
|
||||
_settings: ClideWebSettings | None = None
|
||||
_session_mgr: TmuxSessionManager | None = None
|
||||
_cleanup_task: asyncio.Task | None = None # type: ignore[type-arg]
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifespan
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI): # type: ignore[no-untyped-def]
|
||||
global _settings, _session_mgr, _cleanup_task
|
||||
|
||||
_settings = load_settings()
|
||||
_session_mgr = TmuxSessionManager(_settings)
|
||||
|
||||
# Initialize database
|
||||
init_db(_settings.db_path)
|
||||
logger.info("Database initialized at %s", _settings.db_path)
|
||||
|
||||
# Start periodic session cleanup
|
||||
_cleanup_task = asyncio.create_task(_periodic_cleanup())
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
if _cleanup_task:
|
||||
_cleanup_task.cancel()
|
||||
try:
|
||||
await _cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
async def _periodic_cleanup() -> None:
|
||||
"""Periodically sync DB session records with live tmux state."""
|
||||
assert _settings is not None
|
||||
assert _session_mgr is not None
|
||||
while True:
|
||||
await asyncio.sleep(_settings.session_cleanup_interval_seconds)
|
||||
try:
|
||||
db_gen = get_db()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
await _session_mgr.cleanup_dead_sessions(db)
|
||||
finally:
|
||||
try:
|
||||
next(db_gen)
|
||||
except StopIteration:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("Session cleanup failed")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# App
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
app = FastAPI(title="clide-web", version="1.0.0", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Routes
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index():
|
||||
"""Redirect root to project list or serve HTML."""
|
||||
return _serve_index()
|
||||
|
||||
|
||||
@app.get("/projects/{project_name}", response_class=HTMLResponse)
|
||||
@app.head("/projects/{project_name}")
|
||||
async def project_page(project_name: str): # noqa: ARG001
|
||||
"""Serve the terminal page for a specific project."""
|
||||
return _serve_index()
|
||||
|
||||
|
||||
def _serve_index() -> FileResponse | HTMLResponse:
|
||||
index_path = STATIC_DIR / "index.html"
|
||||
if index_path.exists():
|
||||
return FileResponse(index_path, media_type="text/html")
|
||||
return HTMLResponse("<h1>clide-web</h1><p>index.html not found</p>", status_code=500)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check for reverse proxy."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/projects")
|
||||
async def list_projects():
|
||||
"""List available git projects."""
|
||||
assert _session_mgr is not None
|
||||
return {"projects": _session_mgr.list_projects()}
|
||||
|
||||
|
||||
@app.get("/api/sessions")
|
||||
async def list_sessions():
|
||||
"""List active tmux sessions."""
|
||||
assert _session_mgr is not None
|
||||
sessions = await _session_mgr.list_sessions()
|
||||
return {"sessions": sessions}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# WebSocket terminal bridge
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.websocket("/projects/{project}/ws")
|
||||
async def websocket_terminal(
|
||||
ws: WebSocket,
|
||||
project: str,
|
||||
db: DBSession = Depends(get_db), # noqa: B008
|
||||
):
|
||||
"""Bridge WebSocket ↔ PTY (via tmux attach).
|
||||
|
||||
Protocol:
|
||||
Prefix "0" + data → terminal I/O
|
||||
Prefix "1" + json → control messages
|
||||
Prefix "2" + C,R → resize (cols,rows)
|
||||
"""
|
||||
assert _settings is not None
|
||||
assert _session_mgr is not None
|
||||
|
||||
await ws.accept()
|
||||
|
||||
if not project:
|
||||
await ws.send_text('1{"type":"error","message":"No project specified"}')
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
|
||||
# Validate and create/attach tmux session
|
||||
try:
|
||||
tmux_name = await _session_mgr.create_session(project, db)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
await ws.send_text(f'1{json.dumps({"type": "error", "message": str(e)})}')
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
|
||||
# Log connection
|
||||
client_ip = ws.client.host if ws.client else "unknown"
|
||||
log_entry = ConnectionLog(project_name=project, client_ip=client_ip)
|
||||
db.add(log_entry)
|
||||
db.commit()
|
||||
db.refresh(log_entry)
|
||||
|
||||
# Send session info
|
||||
await ws.send_text(
|
||||
f'1{json.dumps({"type": "session_info", "project": project, "tmux_session": tmux_name})}'
|
||||
)
|
||||
|
||||
# Set up PTY bridge to tmux session
|
||||
send_queue: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
|
||||
def on_output(data: bytes) -> None:
|
||||
send_queue.put_nowait(data)
|
||||
|
||||
def on_exit() -> None:
|
||||
send_queue.put_nowait(b"") # Sentinel for EOF
|
||||
|
||||
bridge = PtyBridge(
|
||||
tmux_session=tmux_name,
|
||||
on_output=on_output,
|
||||
on_exit=on_exit,
|
||||
rows=_settings.default_rows,
|
||||
cols=_settings.default_cols,
|
||||
)
|
||||
bridge.start()
|
||||
|
||||
# Task to forward PTY output → WebSocket
|
||||
async def _forward_output() -> None:
|
||||
while True:
|
||||
data = await send_queue.get()
|
||||
if not data:
|
||||
break
|
||||
try:
|
||||
await ws.send_bytes(b"0" + data)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
output_task = asyncio.create_task(_forward_output())
|
||||
|
||||
try:
|
||||
while True:
|
||||
message = await ws.receive()
|
||||
|
||||
if message["type"] == "websocket.disconnect":
|
||||
break
|
||||
|
||||
if "text" in message:
|
||||
text = message["text"]
|
||||
if not text:
|
||||
continue
|
||||
prefix = text[0]
|
||||
payload = text[1:]
|
||||
|
||||
if prefix == "0":
|
||||
# Terminal input
|
||||
bridge.write(payload.encode("utf-8", errors="surrogateescape"))
|
||||
elif prefix == "1":
|
||||
# Control message
|
||||
await _handle_control(ws, payload, project)
|
||||
elif prefix == "2":
|
||||
# Resize: "2cols,rows"
|
||||
try:
|
||||
cols_str, rows_str = payload.split(",", 1)
|
||||
bridge.resize(int(rows_str), int(cols_str))
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
elif "bytes" in message:
|
||||
raw = message["bytes"]
|
||||
if raw and len(raw) > 1:
|
||||
prefix = raw[0:1]
|
||||
payload_bytes = raw[1:]
|
||||
if prefix == b"0":
|
||||
bridge.write(payload_bytes)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("WebSocket error for project %s", project)
|
||||
finally:
|
||||
bridge.stop()
|
||||
output_task.cancel()
|
||||
try:
|
||||
await output_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Update connection log
|
||||
log_entry.disconnected_at = datetime.utcnow()
|
||||
db.add(log_entry)
|
||||
db.commit()
|
||||
|
||||
|
||||
async def _handle_control(ws: WebSocket, payload: str, project: str) -> None: # noqa: ARG001
|
||||
"""Handle a JSON control message from the client."""
|
||||
try:
|
||||
msg = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
|
||||
msg_type = msg.get("type", "")
|
||||
|
||||
if msg_type == "list_projects":
|
||||
assert _session_mgr is not None
|
||||
projects = _session_mgr.list_projects()
|
||||
await ws.send_text(f'1{json.dumps({"type": "projects", "projects": projects})}')
|
||||
|
||||
elif msg_type == "ping":
|
||||
await ws.send_text(f'1{json.dumps({"type": "pong"})}')
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the clide-web server."""
|
||||
settings = load_settings()
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||
)
|
||||
logger.info("Starting clide-web on %s:%d", settings.host, settings.port)
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
log_level="info",
|
||||
ws_ping_interval=20,
|
||||
ws_ping_timeout=20,
|
||||
)
|
||||
@@ -0,0 +1,240 @@
|
||||
"""tmux session manager: create, attach, list, kill sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from clide.models.db import Project, Session
|
||||
from sqlmodel import Session as DBSession
|
||||
from sqlmodel import select
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from clide_web.config import ClideWebSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TmuxSessionManager:
|
||||
"""Manages tmux sessions, one per project."""
|
||||
|
||||
def __init__(self, settings: ClideWebSettings) -> None:
|
||||
self._settings = settings
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Project discovery
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_projects(self) -> list[str]:
|
||||
"""List git repos in the projects directory."""
|
||||
projects_dir = self._settings.projects_dir
|
||||
if not projects_dir.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
d.name for d in projects_dir.iterdir() if d.is_dir() and (d / ".git").exists()
|
||||
)
|
||||
|
||||
def validate_project(self, name: str) -> Path | None:
|
||||
"""Return project path if valid, else None."""
|
||||
project_dir = self._settings.projects_dir / name
|
||||
if project_dir.is_dir() and (project_dir / ".git").exists():
|
||||
return project_dir
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# tmux operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _session_name(project: str) -> str:
|
||||
return f"clide-{project}"
|
||||
|
||||
async def session_exists(self, project: str) -> bool:
|
||||
"""Check if a tmux session exists for this project."""
|
||||
name = self._session_name(project)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
"has-session",
|
||||
"-t",
|
||||
name,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
return proc.returncode == 0
|
||||
|
||||
async def create_session(self, project: str, db: DBSession) -> str:
|
||||
"""Create a new tmux session running clide for the given project.
|
||||
|
||||
Returns the tmux session name.
|
||||
"""
|
||||
name = self._session_name(project)
|
||||
project_dir = self.validate_project(project)
|
||||
if project_dir is None:
|
||||
raise ValueError(f"Project '{project}' not found")
|
||||
|
||||
# Check if session already exists
|
||||
if await self.session_exists(project):
|
||||
logger.info("tmux session %s already exists, reusing", name)
|
||||
else:
|
||||
clide_bin = _resolve_clide_bin(self._settings.clide_bin)
|
||||
env = _build_env(self._settings.term)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
"new-session",
|
||||
"-d", # detached
|
||||
"-s",
|
||||
name, # session name
|
||||
"-c",
|
||||
str(project_dir), # working directory
|
||||
"-x",
|
||||
str(self._settings.default_cols),
|
||||
"-y",
|
||||
str(self._settings.default_rows),
|
||||
clide_bin, # command to run
|
||||
env=env,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"Failed to create tmux session: {stderr.decode().strip()}")
|
||||
|
||||
# Configure session: hide status bar, auto-respawn on exit
|
||||
# pane-died hook: kill the dead pane, clear all history, respawn clean
|
||||
respawn_cmd = (
|
||||
f"respawn-pane -k -t {name} -c {project_dir} {clide_bin} \\; "
|
||||
f"clear-history -t {name}"
|
||||
)
|
||||
for opt_args in [
|
||||
["set-option", "-t", name, "status", "off"],
|
||||
["set-option", "-t", name, "remain-on-exit", "on"],
|
||||
["set-hook", "-t", name, "pane-died", respawn_cmd],
|
||||
]:
|
||||
await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
*opt_args,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
logger.info("Created tmux session %s for project %s", name, project)
|
||||
|
||||
# Persist to DB
|
||||
_upsert_project(db, project, str(project_dir))
|
||||
_upsert_session(db, project, name)
|
||||
|
||||
return name
|
||||
|
||||
async def list_sessions(self) -> list[dict[str, str]]:
|
||||
"""List active tmux sessions matching clide-* pattern."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
"list-sessions",
|
||||
"-F",
|
||||
"#{session_name}:#{session_created}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
return []
|
||||
|
||||
sessions = []
|
||||
for line in stdout.decode().strip().splitlines():
|
||||
if not line.startswith("clide-"):
|
||||
continue
|
||||
parts = line.split(":", 1)
|
||||
name = parts[0]
|
||||
project = name.removeprefix("clide-")
|
||||
sessions.append({"name": name, "project": project})
|
||||
return sessions
|
||||
|
||||
async def kill_session(self, project: str) -> None:
|
||||
"""Kill a tmux session for a project."""
|
||||
name = self._session_name(project)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
"kill-session",
|
||||
"-t",
|
||||
name,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
logger.info("Killed tmux session %s", name)
|
||||
|
||||
async def cleanup_dead_sessions(self, db: DBSession) -> None:
|
||||
"""Sync DB session records with actual tmux state."""
|
||||
live = await self.list_sessions()
|
||||
live_names = {s["name"] for s in live}
|
||||
|
||||
stmt = select(Session).where(Session.status == "active")
|
||||
for session in db.exec(stmt).all():
|
||||
if session.tmux_session not in live_names:
|
||||
session.status = "dead"
|
||||
db.add(session)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_env(term: str) -> dict[str, str]:
|
||||
"""Build environment for tmux sessions, inheriting the current env."""
|
||||
import os
|
||||
|
||||
env = os.environ.copy()
|
||||
env["TERM"] = term
|
||||
env["COLORTERM"] = "truecolor"
|
||||
return env
|
||||
|
||||
|
||||
def _resolve_clide_bin(clide_bin: str) -> str:
|
||||
"""Resolve clide binary path to absolute if relative."""
|
||||
import shutil
|
||||
|
||||
path = Path(clide_bin)
|
||||
if path.is_absolute():
|
||||
return clide_bin
|
||||
# Relative path — resolve against cwd
|
||||
resolved = Path.cwd() / path
|
||||
if resolved.is_file():
|
||||
return str(resolved)
|
||||
# Try to find it on PATH
|
||||
found = shutil.which(clide_bin)
|
||||
if found:
|
||||
return found
|
||||
return clide_bin # Last resort: return as-is
|
||||
|
||||
|
||||
def _upsert_project(db: DBSession, name: str, path: str) -> Project:
|
||||
"""Create or update a project record."""
|
||||
stmt = select(Project).where(Project.name == name)
|
||||
project = db.exec(stmt).first()
|
||||
if project is None:
|
||||
project = Project(name=name, path=path)
|
||||
project.last_accessed = datetime.utcnow()
|
||||
db.add(project)
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
def _upsert_session(db: DBSession, project_name: str, tmux_session: str) -> Session:
|
||||
"""Create or update a session record."""
|
||||
stmt = select(Session).where(Session.tmux_session == tmux_session)
|
||||
session = db.exec(stmt).first()
|
||||
if session is None:
|
||||
session = Session(project_name=project_name, tmux_session=tmux_session)
|
||||
session.status = "active"
|
||||
session.last_activity = datetime.utcnow()
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Interactive setup wizard for clide-web configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from clide.models.db import UserPreference
|
||||
from clide.services.database import get_engine, init_db
|
||||
from sqlmodel import Session as DBSession
|
||||
from sqlmodel import select
|
||||
|
||||
|
||||
def run_setup() -> None:
|
||||
"""Run the interactive setup wizard, persisting config to the database."""
|
||||
print()
|
||||
print(" ╔═══════════════════════════════════════╗")
|
||||
print(" ║ clide-web Setup Wizard ║")
|
||||
print(" ╚═══════════════════════════════════════╝")
|
||||
print()
|
||||
|
||||
db_path = Path.home() / ".clide" / "clide.db"
|
||||
init_db(db_path)
|
||||
engine = get_engine(db_path)
|
||||
|
||||
with DBSession(engine) as db:
|
||||
# Projects directory
|
||||
current = _get_pref(db, "projects_dir")
|
||||
default = current or _guess_projects_dir()
|
||||
projects_dir = _prompt("Projects directory", default)
|
||||
path = Path(projects_dir).expanduser().resolve()
|
||||
if not path.is_dir():
|
||||
print(f" Warning: '{path}' does not exist yet")
|
||||
_set_pref(db, "projects_dir", str(path))
|
||||
|
||||
# Clide binary
|
||||
current = _get_pref(db, "clide_bin")
|
||||
default = current or _find_clide_bin()
|
||||
clide_bin = _prompt("Clide binary path", default)
|
||||
_set_pref(db, "clide_bin", clide_bin)
|
||||
|
||||
# Port
|
||||
current = _get_pref(db, "port")
|
||||
port = _prompt("Server port", current or "8888")
|
||||
_set_pref(db, "port", port)
|
||||
|
||||
# Font size
|
||||
current = _get_pref(db, "font_size")
|
||||
font_size = _prompt("Terminal font size", current or "14")
|
||||
_set_pref(db, "font_size", font_size)
|
||||
|
||||
db.commit()
|
||||
|
||||
print()
|
||||
print(" Configuration saved to ~/.clide/clide.db")
|
||||
print()
|
||||
print(" Run with: clide-web")
|
||||
print()
|
||||
|
||||
|
||||
def _prompt(label: str, default: str) -> str:
|
||||
"""Prompt the user with a default value."""
|
||||
result = input(f" {label} [{default}]: ").strip()
|
||||
return result if result else default
|
||||
|
||||
|
||||
def _get_pref(db: DBSession, key: str) -> str | None:
|
||||
"""Get a preference from the database."""
|
||||
stmt = select(UserPreference).where(UserPreference.key == key)
|
||||
pref = db.exec(stmt).first()
|
||||
return pref.value if pref else None
|
||||
|
||||
|
||||
def _set_pref(db: DBSession, key: str, value: str) -> None:
|
||||
"""Set a preference in the database."""
|
||||
stmt = select(UserPreference).where(UserPreference.key == key)
|
||||
pref = db.exec(stmt).first()
|
||||
if pref is None:
|
||||
pref = UserPreference(key=key, value=value)
|
||||
else:
|
||||
pref.value = value
|
||||
db.add(pref)
|
||||
|
||||
|
||||
def _guess_projects_dir() -> str:
|
||||
"""Try to guess a sensible default for projects directory."""
|
||||
candidates = [
|
||||
Path.home() / "Projects",
|
||||
Path.home() / "projects",
|
||||
Path.home() / "src",
|
||||
Path.home() / "code",
|
||||
Path("/mnt/media/Projects"),
|
||||
]
|
||||
for p in candidates:
|
||||
if p.is_dir():
|
||||
return str(p)
|
||||
return str(Path.home() / "Projects")
|
||||
|
||||
|
||||
def _find_clide_bin() -> str:
|
||||
"""Try to find the clide binary."""
|
||||
found = shutil.which("clide")
|
||||
if found:
|
||||
return found
|
||||
# Check common venv locations
|
||||
candidates = [
|
||||
Path.cwd() / ".venv" / "bin" / "clide",
|
||||
Path.cwd().parent / ".venv" / "bin" / "clide",
|
||||
]
|
||||
for p in candidates:
|
||||
if p.exists():
|
||||
return str(p)
|
||||
return "clide"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_setup()
|
||||
@@ -0,0 +1,388 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Clide</title>
|
||||
<link rel="stylesheet" href="/static/vendor/xterm.min.css">
|
||||
<style>
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #21262f;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
/* Kill all scrollbars */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
html::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
/* Kill xterm.js internal scrollbar — TUI handles its own scrolling */
|
||||
.xterm-viewport {
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
}
|
||||
.xterm-viewport::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
#toolbar {
|
||||
height: 36px;
|
||||
background: #292e38;
|
||||
border-bottom: 1px solid #393e48;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
gap: 12px;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
color: #e2e8f5;
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#toolbar .logo {
|
||||
font-weight: 600;
|
||||
color: #00a3d2;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
#toolbar .separator {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #393e48;
|
||||
}
|
||||
|
||||
#toolbar select {
|
||||
background: #393e48;
|
||||
color: #e2e8f5;
|
||||
border: 1px solid #525762;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
#toolbar select:hover {
|
||||
border-color: #00a3d2;
|
||||
}
|
||||
#toolbar select:focus {
|
||||
border-color: #00a3d2;
|
||||
box-shadow: 0 0 0 1px #00a3d2;
|
||||
}
|
||||
|
||||
#toolbar label {
|
||||
font-size: 11px;
|
||||
color: #898e9a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-spacer { flex: 1; }
|
||||
|
||||
#status {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
#status .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #525762;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
#status .dot.connected { background: #00ab9a; }
|
||||
#status .dot.reconnecting { background: #d08447; animation: pulse 1s infinite; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Terminal container */
|
||||
#terminal-container {
|
||||
height: calc(100% - 36px);
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 36px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* Reconnect overlay */
|
||||
#overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background: rgba(33, 38, 47, 0.92);
|
||||
z-index: 100;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
color: #e2e8f5;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
#overlay .message {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
#overlay .sub {
|
||||
font-size: 13px;
|
||||
color: #898e9a;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="toolbar">
|
||||
<span class="logo">Clide</span>
|
||||
<div class="separator"></div>
|
||||
<div class="toolbar-group">
|
||||
<label>Project</label>
|
||||
<select id="project-select"><option value="">Loading...</option></select>
|
||||
</div>
|
||||
<div class="toolbar-spacer"></div>
|
||||
<div id="status">
|
||||
<div class="dot" id="status-dot"></div>
|
||||
<span id="status-text">Connecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="terminal-container"></div>
|
||||
<div id="overlay">
|
||||
<div class="message">Reconnecting...</div>
|
||||
<div class="sub">Session is preserved</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/vendor/xterm.min.js"></script>
|
||||
<script src="/static/vendor/addon-fit.min.js"></script>
|
||||
<script src="/static/vendor/addon-web-links.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
"use strict";
|
||||
|
||||
// --- State ---
|
||||
// Extract project from path: /projects/<name> or fallback to ?project=<name>
|
||||
function getProjectFromUrl() {
|
||||
var match = location.pathname.match(/^\/projects\/([^/]+)/);
|
||||
if (match) return decodeURIComponent(match[1]);
|
||||
return new URLSearchParams(location.search).get("project") || "";
|
||||
}
|
||||
let currentProject = getProjectFromUrl();
|
||||
let ws = null;
|
||||
let reconnectTimer = null;
|
||||
let reconnectDelay = 1000;
|
||||
|
||||
// --- DOM refs ---
|
||||
const statusDot = document.getElementById("status-dot");
|
||||
const statusText = document.getElementById("status-text");
|
||||
const overlay = document.getElementById("overlay");
|
||||
const projectSelect = document.getElementById("project-select");
|
||||
|
||||
// --- Terminal ---
|
||||
const term = new Terminal({
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: 14,
|
||||
theme: {
|
||||
background: "#21262f",
|
||||
foreground: "#e2e8f5",
|
||||
cursor: "#00a3d2",
|
||||
cursorAccent: "#21262f",
|
||||
selectionBackground: "rgba(0, 163, 210, 0.3)",
|
||||
black: "#21262f",
|
||||
red: "#f06c6f",
|
||||
green: "#00ab9a",
|
||||
yellow: "#d08447",
|
||||
blue: "#00a3d2",
|
||||
magenta: "#fa5f8b",
|
||||
cyan: "#00a9b9",
|
||||
white: "#e2e8f5",
|
||||
brightBlack: "#393e48",
|
||||
brightRed: "#f06c6f",
|
||||
brightGreen: "#00ab9a",
|
||||
brightYellow: "#d08447",
|
||||
brightBlue: "#00a3d2",
|
||||
brightMagenta: "#fa5f8b",
|
||||
brightCyan: "#00a9b9",
|
||||
brightWhite: "#e2e8f5",
|
||||
},
|
||||
cursorBlink: true,
|
||||
allowProposedApi: true,
|
||||
scrollback: 0,
|
||||
});
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon.WebLinksAddon());
|
||||
term.open(document.getElementById("terminal-container"));
|
||||
fitAddon.fit();
|
||||
|
||||
// --- Status helpers ---
|
||||
function setStatus(state, text) {
|
||||
statusDot.className = "dot " + state;
|
||||
statusText.textContent = text;
|
||||
}
|
||||
|
||||
// --- WebSocket ---
|
||||
function connect() {
|
||||
if (ws) {
|
||||
ws.onclose = null;
|
||||
ws.close();
|
||||
}
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const url = proto + "//" + location.host + "/projects/" + encodeURIComponent(currentProject) + "/ws";
|
||||
ws = new WebSocket(url);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.onopen = function() {
|
||||
setStatus("connected", currentProject || "Connected");
|
||||
overlay.style.display = "none";
|
||||
reconnectDelay = 1000;
|
||||
|
||||
// Send initial terminal size (slight delay to ensure xterm is rendered)
|
||||
setTimeout(function() {
|
||||
fitAddon.fit();
|
||||
sendResize();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
ws.onmessage = function(evt) {
|
||||
let prefix, payload;
|
||||
if (evt.data instanceof ArrayBuffer) {
|
||||
const bytes = new Uint8Array(evt.data);
|
||||
if (bytes.length < 1) return;
|
||||
prefix = String.fromCharCode(bytes[0]);
|
||||
payload = bytes.slice(1);
|
||||
if (prefix === "0") {
|
||||
term.write(payload);
|
||||
} else if (prefix === "1") {
|
||||
handleControl(new TextDecoder().decode(payload));
|
||||
}
|
||||
} else {
|
||||
// Text frame
|
||||
prefix = evt.data[0];
|
||||
payload = evt.data.slice(1);
|
||||
if (prefix === "0") {
|
||||
term.write(payload);
|
||||
} else if (prefix === "1") {
|
||||
handleControl(payload);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = function() {
|
||||
setStatus("reconnecting", "Reconnecting...");
|
||||
overlay.style.display = "flex";
|
||||
reconnectTimer = setTimeout(function() {
|
||||
reconnectDelay = Math.min(reconnectDelay * 1.5, 10000);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
};
|
||||
|
||||
ws.onerror = function() {
|
||||
// onclose will fire after this
|
||||
};
|
||||
}
|
||||
|
||||
// --- Terminal input → WebSocket ---
|
||||
term.onData(function(data) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send("0" + data);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Resize ---
|
||||
function sendResize() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
const dims = fitAddon.proposeDimensions();
|
||||
if (dims) {
|
||||
ws.send("2" + dims.cols + "," + dims.rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(function() {
|
||||
fitAddon.fit();
|
||||
sendResize();
|
||||
});
|
||||
resizeObserver.observe(document.getElementById("terminal-container"));
|
||||
|
||||
// --- Control messages ---
|
||||
function handleControl(jsonStr) {
|
||||
let msg;
|
||||
try { msg = JSON.parse(jsonStr); } catch(e) { return; }
|
||||
|
||||
if (msg.type === "projects") {
|
||||
populateProjects(msg.projects);
|
||||
} else if (msg.type === "session_info") {
|
||||
setStatus("connected", msg.project);
|
||||
document.title = "Clide — " + msg.project;
|
||||
} else if (msg.type === "error") {
|
||||
term.write("\r\n\x1b[31mError: " + msg.message + "\x1b[0m\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Project management ---
|
||||
function populateProjects(projects) {
|
||||
projectSelect.innerHTML = "";
|
||||
projects.forEach(function(p) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p;
|
||||
opt.textContent = p;
|
||||
if (p === currentProject) opt.selected = true;
|
||||
projectSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
projectSelect.addEventListener("change", function() {
|
||||
currentProject = projectSelect.value;
|
||||
history.pushState(null, "", "/projects/" + encodeURIComponent(currentProject));
|
||||
document.title = "Clide — " + currentProject;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
term.clear();
|
||||
term.reset();
|
||||
connect();
|
||||
});
|
||||
|
||||
// --- Load projects and connect ---
|
||||
fetch("/api/projects")
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
populateProjects(data.projects);
|
||||
// If no project selected, pick first available
|
||||
if (!currentProject && data.projects.length > 0) {
|
||||
currentProject = data.projects[0];
|
||||
projectSelect.value = currentProject;
|
||||
history.replaceState(null, "", "/projects/" + encodeURIComponent(currentProject));
|
||||
}
|
||||
if (currentProject) {
|
||||
connect();
|
||||
} else {
|
||||
setStatus("", "No projects found");
|
||||
term.write("\r\nNo projects found in configured projects directory.\r\n");
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
setStatus("reconnecting", "Server unreachable");
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/@xterm/addon-fit@0.11.0/lib/addon-fit.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=addon-fit.js.map
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/@xterm/addon-web-links@0.12.0/lib/addon-web-links.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={490:(e,t)=>{function n(e){try{const t=new URL(e),n=t.password&&t.username?`${t.protocol}//${t.username}:${t.password}@${t.host}`:t.username?`${t.protocol}//${t.username}@${t.host}`:`${t.protocol}//${t.host}`;return e.toLocaleLowerCase().startsWith(n.toLocaleLowerCase())}catch(e){return!1}}Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,o={}){this._terminal=e,this._regex=t,this._handler=n,this._options=o}provideLinks(e,t){const n=o.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(n))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:o}=e;this._options.hover(t,n,o)}},e)))}};class o{static computeLink(e,t,r,i){const s=new RegExp(t.source,(t.flags||"")+"g"),[a,c]=o._getWindowedLineStrings(e-1,r),l=a.join("");let d;const p=[];for(;d=s.exec(l);){const e=d[0];if(!n(e))continue;const[t,s]=o._mapStrIdx(r,c,0,d.index),[a,l]=o._mapStrIdx(r,t,s,e.length);if(-1===t||-1===s||-1===a||-1===l)continue;const h={start:{x:s+1,y:t+1},end:{x:l,y:a+1}};p.push({range:h,text:e,activate:i})}return p}static _getWindowedLineStrings(e,t){let n,o=e,r=e,i=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(i=0;(n=t.buffer.active.getLine(--o))&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),i=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,o]}static _mapStrIdx(e,t,n,o){const r=e.buffer.active,i=r.getNullCell();let s=n;for(;o;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,i);const s=i.getChars();if(i.getWidth()&&(o-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,i),2===i.getWidth()&&(o+=1))}if(o<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=o}},t={};function n(o){var r=t[o];if(void 0!==r)return r.exports;var i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}var o={};return(()=>{var e=o;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(490),r=/(https?|HTTPS?):[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function i(e,t){const n=window.open();if(n){try{n.opener=null}catch{}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=i,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,o=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,o,this._handler,n))}dispose(){this._linkProvider?.dispose()}}})(),o})()));
|
||||
//# sourceMappingURL=addon-web-links.js.map
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Minified by jsDelivr using clean-css v5.3.3.
|
||||
* Original file: /npm/@xterm/xterm@5.5.0/css/xterm.css
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:0}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm .xterm-cursor-pointer,.xterm.xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:transparent}.xterm .xterm-accessibility-tree{user-select:text;white-space:pre}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative}
|
||||
/*# sourceMappingURL=/sm/97377c0c258e109358121823f5790146c714989366481f90e554c42277efb500.map */
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,66 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "clide-web"
|
||||
version = "1.0.0"
|
||||
description = "Web server for Clide TUI — replaces ttyd + zellij"
|
||||
requires-python = ">=3.12"
|
||||
license = "MIT"
|
||||
authors = [
|
||||
{ name = "Jeroen Schweitzer", email = "you@example.com" }
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Environment :: Web Environment",
|
||||
"Framework :: FastAPI",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
]
|
||||
dependencies = [
|
||||
"clide",
|
||||
"fastapi>=0.110",
|
||||
"uvicorn[standard]>=0.29",
|
||||
"pydantic-settings>=2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"httpx>=0.27.0",
|
||||
"ruff>=0.3.0",
|
||||
"mypy>=1.8.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
clide-web = "clide_web.__main__:main"
|
||||
clide-web-setup = "clide_web.setup_wizard:run_setup"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["clide_web"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
src = ["clide_web"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", "W", "F", "I", "B", "C4", "UP", "ARG", "SIM", "TCH", "PTH", "ASYNC",
|
||||
]
|
||||
ignore = [
|
||||
"E501", "ARG002", "SIM102", "SIM105", "SIM115", "PTH123", "TCH002", "TCH003",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["clide_web"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
strict = true
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
Reference in New Issue
Block a user