diff --git a/.gitignore b/.gitignore
index 590c496..cca23ea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@ htmlcov/
.coverage
.pytest_cache/
*.egg-info/
+coverage.xml
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..01e6805
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,366 @@
+
+
+
+
+
+ Mainline Terminal
+
+
+
+
+
+
+
+
+
+
+
+
+ Connecting...
+
+
+
+
diff --git a/engine/app.py b/engine/app.py
index ec36e11..d91ff2d 100644
--- a/engine/app.py
+++ b/engine/app.py
@@ -29,6 +29,18 @@ from engine.terminal import (
slow_print,
tw,
)
+from engine.websocket_display import WebSocketDisplay
+
+
+def _get_display():
+ """Get the appropriate display based on config."""
+ if config.WEBSOCKET:
+ ws = WebSocketDisplay(host="0.0.0.0", port=config.WEBSOCKET_PORT)
+ ws.start_server()
+ ws.start_http_server()
+ return ws
+ return None
+
TITLE = [
" ███╗ ███╗ █████╗ ██╗███╗ ██╗██╗ ██╗███╗ ██╗███████╗",
@@ -343,7 +355,10 @@ def main():
print()
time.sleep(0.4)
- stream(items, ntfy, mic)
+ display = _get_display()
+ stream(items, ntfy, mic, display)
+ if display:
+ display.cleanup()
print()
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
diff --git a/engine/config.py b/engine/config.py
index 284877c..fab1387 100644
--- a/engine/config.py
+++ b/engine/config.py
@@ -127,6 +127,9 @@ class Config:
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
+ websocket: bool = False
+ websocket_port: int = 8765
+
@classmethod
def from_args(cls, argv: list[str] | None = None) -> "Config":
"""Create Config from CLI arguments (or custom argv for testing)."""
@@ -164,6 +167,8 @@ class Config:
glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋",
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
script_fonts=_get_platform_font_paths(),
+ websocket="--websocket" in argv,
+ websocket_port=_arg_int("--websocket-port", 8765, argv),
)
@@ -223,6 +228,10 @@ GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full sweep)
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
+# ─── WEBSOCKET ─────────────────────────────────────────────
+WEBSOCKET = "--websocket" in sys.argv
+WEBSOCKET_PORT = _arg_int("--websocket-port", 8765)
+
def set_font_selection(font_path=None, font_index=None):
"""Set runtime primary font selection."""
diff --git a/engine/controller.py b/engine/controller.py
index e6e2e3d..5b07e67 100644
--- a/engine/controller.py
+++ b/engine/controller.py
@@ -8,6 +8,17 @@ from engine.events import EventType, StreamEvent
from engine.mic import MicMonitor
from engine.ntfy import NtfyPoller
from engine.scroll import stream
+from engine.websocket_display import WebSocketDisplay
+
+
+def _get_display(config: Config):
+ """Get the appropriate display based on config."""
+ if config.websocket:
+ ws = WebSocketDisplay(host="0.0.0.0", port=config.websocket_port)
+ ws.start_server()
+ ws.start_http_server()
+ return ws
+ return None
class StreamController:
@@ -51,7 +62,10 @@ class StreamController:
),
)
- stream(items, self.ntfy, self.mic)
+ display = _get_display(self.config)
+ stream(items, self.ntfy, self.mic, display)
+ if display:
+ display.cleanup()
if self.event_bus:
self.event_bus.publish(
diff --git a/engine/websocket_display.py b/engine/websocket_display.py
new file mode 100644
index 0000000..ba382c7
--- /dev/null
+++ b/engine/websocket_display.py
@@ -0,0 +1,264 @@
+"""
+WebSocket display server - broadcasts frame buffer to connected web clients.
+
+Usage:
+ ws_display = WebSocketDisplay(host="0.0.0.0", port=8765)
+ ws_display.init(80, 24)
+ ws_display.show(["line1", "line2", ...])
+ ws_display.cleanup()
+"""
+
+import asyncio
+import json
+import threading
+import time
+from typing import Protocol
+
+try:
+ import websockets
+except ImportError:
+ websockets = None
+
+
+class Display(Protocol):
+ """Protocol for display backends."""
+
+ def init(self, width: int, height: int) -> None:
+ """Initialize display with dimensions."""
+ ...
+
+ def show(self, buffer: list[str]) -> None:
+ """Show buffer on display."""
+ ...
+
+ def clear(self) -> None:
+ """Clear display."""
+ ...
+
+ def cleanup(self) -> None:
+ """Shutdown display."""
+ ...
+
+
+def get_monitor():
+ """Get the performance monitor."""
+ try:
+ from engine.effects.performance import get_monitor as _get_monitor
+
+ return _get_monitor()
+ except Exception:
+ return None
+
+
+class WebSocketDisplay:
+ """WebSocket display backend - broadcasts to HTML Canvas clients."""
+
+ def __init__(
+ self,
+ host: str = "0.0.0.0",
+ port: int = 8765,
+ http_port: int = 8766,
+ ):
+ self.host = host
+ self.port = port
+ self.http_port = http_port
+ self.width = 80
+ self.height = 24
+ self._clients: set = set()
+ self._server_running = False
+ self._http_running = False
+ self._server_thread: threading.Thread | None = None
+ self._http_thread: threading.Thread | None = None
+ self._available = True
+ self._max_clients = 10
+ self._client_connected_callback = None
+ self._client_disconnected_callback = None
+ self._frame_delay = 0.0
+
+ try:
+ import websockets as _ws
+
+ self._available = _ws is not None
+ except ImportError:
+ self._available = False
+
+ def is_available(self) -> bool:
+ """Check if WebSocket support is available."""
+ return self._available
+
+ def init(self, width: int, height: int) -> None:
+ """Initialize display with dimensions and start server."""
+ self.width = width
+ self.height = height
+ self.start_server()
+ self.start_http_server()
+
+ def show(self, buffer: list[str]) -> None:
+ """Broadcast buffer to all connected clients."""
+ t0 = time.perf_counter()
+
+ if self._clients:
+ frame_data = {
+ "type": "frame",
+ "width": self.width,
+ "height": self.height,
+ "lines": buffer,
+ }
+ message = json.dumps(frame_data)
+
+ disconnected = set()
+ for client in list(self._clients):
+ try:
+ asyncio.run(client.send(message))
+ except Exception:
+ disconnected.add(client)
+
+ for client in disconnected:
+ self._clients.discard(client)
+ if self._client_disconnected_callback:
+ self._client_disconnected_callback(client)
+
+ elapsed_ms = (time.perf_counter() - t0) * 1000
+ monitor = get_monitor()
+ if monitor:
+ chars_in = sum(len(line) for line in buffer)
+ monitor.record_effect("websocket_display", elapsed_ms, chars_in, chars_in)
+
+ def clear(self) -> None:
+ """Broadcast clear command to all clients."""
+ if self._clients:
+ clear_data = {"type": "clear"}
+ message = json.dumps(clear_data)
+ for client in list(self._clients):
+ try:
+ asyncio.run(client.send(message))
+ except Exception:
+ pass
+
+ def cleanup(self) -> None:
+ """Stop the servers."""
+ self.stop_server()
+ self.stop_http_server()
+
+ async def _websocket_handler(self, websocket):
+ """Handle WebSocket connections."""
+ if len(self._clients) >= self._max_clients:
+ await websocket.close()
+ return
+
+ self._clients.add(websocket)
+ if self._client_connected_callback:
+ self._client_connected_callback(websocket)
+
+ try:
+ async for message in websocket:
+ try:
+ data = json.loads(message)
+ if data.get("type") == "resize":
+ self.width = data.get("width", 80)
+ self.height = data.get("height", 24)
+ except json.JSONDecodeError:
+ pass
+ except Exception:
+ pass
+ finally:
+ self._clients.discard(websocket)
+ if self._client_disconnected_callback:
+ self._client_disconnected_callback(websocket)
+
+ async def _run_websocket_server(self):
+ """Run the WebSocket server."""
+ async with websockets.serve(self._websocket_handler, self.host, self.port):
+ while self._server_running:
+ await asyncio.sleep(0.1)
+
+ async def _run_http_server(self):
+ """Run simple HTTP server for the client."""
+ import os
+ from http.server import HTTPServer, SimpleHTTPRequestHandler
+
+ client_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "client")
+
+ class Handler(SimpleHTTPRequestHandler):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, directory=client_dir, **kwargs)
+
+ def log_message(self, format, *args):
+ pass
+
+ httpd = HTTPServer((self.host, self.http_port), Handler)
+ while self._http_running:
+ httpd.handle_request()
+
+ def _run_async(self, coro):
+ """Run coroutine in background."""
+ try:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ loop.run_until_complete(coro)
+ except Exception:
+ pass
+
+ def start_server(self):
+ """Start the WebSocket server in a background thread."""
+ if not self._available:
+ return
+ if self._server_thread is not None:
+ return
+
+ self._server_running = True
+ self._server_thread = threading.Thread(
+ target=self._run_async, args=(self._run_websocket_server(),), daemon=True
+ )
+ self._server_thread.start()
+
+ def stop_server(self):
+ """Stop the WebSocket server."""
+ self._server_running = False
+ self._server_thread = None
+
+ def start_http_server(self):
+ """Start the HTTP server in a background thread."""
+ if not self._available:
+ return
+ if self._http_thread is not None:
+ return
+
+ self._http_running = True
+ self._http_thread = threading.Thread(
+ target=self._run_async, args=(self._run_http_server(),), daemon=True
+ )
+ self._http_thread.start()
+
+ def stop_http_server(self):
+ """Stop the HTTP server."""
+ self._http_running = False
+ self._http_thread = None
+
+ def client_count(self) -> int:
+ """Return number of connected clients."""
+ return len(self._clients)
+
+ def get_ws_port(self) -> int:
+ """Return WebSocket port."""
+ return self.port
+
+ def get_http_port(self) -> int:
+ """Return HTTP port."""
+ return self.http_port
+
+ def set_frame_delay(self, delay: float) -> None:
+ """Set delay between frames in seconds."""
+ self._frame_delay = delay
+
+ def get_frame_delay(self) -> float:
+ """Get delay between frames."""
+ return self._frame_delay
+
+ def set_client_connected_callback(self, callback) -> None:
+ """Set callback for client connections."""
+ self._client_connected_callback = callback
+
+ def set_client_disconnected_callback(self, callback) -> None:
+ """Set callback for client disconnections."""
+ self._client_disconnected_callback = callback
diff --git a/mise.toml b/mise.toml
index 32f7c59..61e1f04 100644
--- a/mise.toml
+++ b/mise.toml
@@ -13,6 +13,9 @@ test-v = "uv run pytest -v"
test-cov = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html"
test-cov-open = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html && open htmlcov/index.html"
+test-browser-install = { run = "uv run playwright install chromium", depends = ["sync-all"] }
+test-browser = { run = "uv run pytest tests/e2e/", depends = ["test-browser-install"] }
+
lint = "uv run ruff check engine/ mainline.py"
lint-fix = "uv run ruff check --fix engine/ mainline.py"
format = "uv run ruff format engine/ mainline.py"
@@ -24,6 +27,8 @@ format = "uv run ruff format engine/ mainline.py"
run = "uv run mainline.py"
run-poetry = "uv run mainline.py --poetry"
run-firehose = "uv run mainline.py --firehose"
+run-websocket = { run = "uv run mainline.py --websocket", depends = ["sync-all"] }
+run-client = { run = "uv run mainline.py --websocket & WEBSOCKET_PID=$! && sleep 2 && case $(uname -s) in Darwin) open http://localhost:8766 ;; Linux) xdg-open http://localhost:8766 ;; CYGWIN*) cmd /c start http://localhost:8766 ;; *) echo 'Unknown platform' ;; esac && wait $WEBSOCKET_PID", depends = ["sync-all"] }
# =====================
# Environment
diff --git a/pyproject.toml b/pyproject.toml
index f52a05a..661392f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -30,6 +30,12 @@ mic = [
"sounddevice>=0.4.0",
"numpy>=1.24.0",
]
+websocket = [
+ "websockets>=12.0",
+]
+browser = [
+ "playwright>=1.40.0",
+]
dev = [
"pytest>=8.0.0",
"pytest-cov>=4.1.0",
diff --git a/tests/e2e/test_web_client.py b/tests/e2e/test_web_client.py
new file mode 100644
index 0000000..daf4efb
--- /dev/null
+++ b/tests/e2e/test_web_client.py
@@ -0,0 +1,133 @@
+"""
+End-to-end tests for web client with headless browser.
+"""
+
+import os
+import socketserver
+import threading
+from http.server import HTTPServer, SimpleHTTPRequestHandler
+from pathlib import Path
+
+import pytest
+
+CLIENT_DIR = Path(__file__).parent.parent.parent / "client"
+
+
+class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
+ """Threaded HTTP server for handling concurrent requests."""
+
+ daemon_threads = True
+
+
+@pytest.fixture(scope="module")
+def http_server():
+ """Start a local HTTP server for the client."""
+ os.chdir(CLIENT_DIR)
+
+ handler = SimpleHTTPRequestHandler
+ server = ThreadedHTTPServer(("127.0.0.1", 0), handler)
+ port = server.server_address[1]
+
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+
+ yield f"http://127.0.0.1:{port}"
+
+ server.shutdown()
+
+
+class TestWebClient:
+ """Tests for the web client using Playwright."""
+
+ @pytest.fixture(autouse=True)
+ def setup_browser(self):
+ """Set up browser for tests."""
+ pytest.importorskip("playwright")
+ from playwright.sync_api import sync_playwright
+
+ self.playwright = sync_playwright().start()
+ self.browser = self.playwright.chromium.launch(headless=True)
+ self.context = self.browser.new_context()
+ self.page = self.context.new_page()
+
+ yield
+
+ self.page.close()
+ self.context.close()
+ self.browser.close()
+ self.playwright.stop()
+
+ def test_client_loads(self, http_server):
+ """Web client loads without errors."""
+ response = self.page.goto(http_server)
+ assert response.status == 200, f"Page load failed with status {response.status}"
+
+ self.page.wait_for_load_state("domcontentloaded")
+
+ content = self.page.content()
+ assert "