From d7b044ceae871c23c3e585b10613210909fd4894 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Sun, 15 Mar 2026 21:17:16 -0700 Subject: [PATCH] feat(display): add configurable multi-backend display system --- AGENTS.md | 32 +++++++++++++++++++++++++------- engine/app.py | 20 ++++++++++++++++---- engine/config.py | 3 +++ engine/display.py | 27 +++++++++++++++++++++++++++ engine/websocket_display.py | 10 +++++----- mise.toml | 3 ++- 6 files changed, 78 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 21d7e2c..6430826 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,13 +22,23 @@ uv sync ### Available Commands ```bash -mise run test # Run tests -mise run test-v # Run tests verbose -mise run test-cov # Run tests with coverage report -mise run lint # Run ruff linter -mise run lint-fix # Run ruff with auto-fix -mise run format # Run ruff formatter -mise run ci # Full CI pipeline (sync + test + coverage) +mise run test # Run tests +mise run test-v # Run tests verbose +mise run test-cov # Run tests with coverage report +mise run test-browser # Run e2e browser tests (requires playwright) +mise run lint # Run ruff linter +mise run lint-fix # Run ruff with auto-fix +mise run format # Run ruff formatter +mise run ci # Full CI pipeline (sync + test + coverage) +``` + +### Runtime Commands + +```bash +mise run run # Run mainline (terminal) +mise run run-websocket # Run with WebSocket display +mise run run-both # Run with both terminal and WebSocket +mise run run-client # Run both + open browser ``` ## Git Hooks @@ -108,3 +118,11 @@ The project uses pytest with strict marker enforcement. Test configuration is in - **eventbus.py** provides thread-safe event publishing for decoupled communication - **controller.py** coordinates ntfy/mic monitoring - The render pipeline: fetch → render → effects → scroll → terminal output +- **Display abstraction** (`engine/display.py`): swap display backends via the Display protocol + - `TerminalDisplay` - ANSI terminal output + - `WebSocketDisplay` - broadcasts to web clients via WebSocket + - `MultiDisplay` - forwards to multiple displays simultaneously +- **WebSocket display** (`engine/websocket_display.py`): real-time frame broadcasting to web browsers + - WebSocket server on port 8765 + - HTTP server on port 8766 (serves HTML client) + - Client at `client/index.html` with ANSI color parsing and fullscreen support diff --git a/engine/app.py b/engine/app.py index d91ff2d..3bd8952 100644 --- a/engine/app.py +++ b/engine/app.py @@ -33,13 +33,25 @@ from engine.websocket_display import WebSocketDisplay def _get_display(): - """Get the appropriate display based on config.""" - if config.WEBSOCKET: + """Get the appropriate display(s) based on config.""" + from engine.display import MultiDisplay, TerminalDisplay + + displays = [] + + if config.DISPLAY in ("terminal", "both"): + displays.append(TerminalDisplay()) + + if config.DISPLAY in ("websocket", "both") or config.WEBSOCKET: ws = WebSocketDisplay(host="0.0.0.0", port=config.WEBSOCKET_PORT) ws.start_server() ws.start_http_server() - return ws - return None + displays.append(ws) + + if not displays: + return None + if len(displays) == 1: + return displays[0] + return MultiDisplay(displays) TITLE = [ diff --git a/engine/config.py b/engine/config.py index fab1387..795367a 100644 --- a/engine/config.py +++ b/engine/config.py @@ -127,6 +127,7 @@ class Config: script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths) + display: str = "terminal" websocket: bool = False websocket_port: int = 8765 @@ -167,6 +168,7 @@ class Config: glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋", kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ", script_fonts=_get_platform_font_paths(), + display=_arg_value("--display", argv) or "terminal", websocket="--websocket" in argv, websocket_port=_arg_int("--websocket-port", 8765, argv), ) @@ -229,6 +231,7 @@ GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋" KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ" # ─── WEBSOCKET ───────────────────────────────────────────── +DISPLAY = _arg_value("--display", sys.argv) or "terminal" WEBSOCKET = "--websocket" in sys.argv WEBSOCKET_PORT = _arg_int("--websocket-port", 8765) diff --git a/engine/display.py b/engine/display.py index 32eb09e..78d25ad 100644 --- a/engine/display.py +++ b/engine/display.py @@ -100,3 +100,30 @@ class NullDisplay: def cleanup(self) -> None: pass + + +class MultiDisplay: + """Display that forwards to multiple displays.""" + + def __init__(self, displays: list[Display]): + self.displays = displays + self.width = 80 + self.height = 24 + + def init(self, width: int, height: int) -> None: + self.width = width + self.height = height + for d in self.displays: + d.init(width, height) + + def show(self, buffer: list[str]) -> None: + for d in self.displays: + d.show(buffer) + + def clear(self) -> None: + for d in self.displays: + d.clear() + + def cleanup(self) -> None: + for d in self.displays: + d.cleanup() diff --git a/engine/websocket_display.py b/engine/websocket_display.py index ba382c7..6ff7d36 100644 --- a/engine/websocket_display.py +++ b/engine/websocket_display.py @@ -193,11 +193,9 @@ class WebSocketDisplay: 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 + asyncio.run(coro) + except Exception as e: + print(f"WebSocket async error: {e}") def start_server(self): """Start the WebSocket server in a background thread.""" @@ -224,6 +222,8 @@ class WebSocketDisplay: if self._http_thread is not None: return + self._http_running = True + self._http_running = True self._http_thread = threading.Thread( target=self._run_async, args=(self._run_http_server(),), daemon=True diff --git a/mise.toml b/mise.toml index 61e1f04..ea0f6ed 100644 --- a/mise.toml +++ b/mise.toml @@ -28,7 +28,8 @@ 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"] } +run-both = { run = "uv run mainline.py --display both", depends = ["sync-all"] } +run-client = { run = "uv run mainline.py --display both & 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