feat(daemon): add daemon mode with C&C and display abstraction

- Add display abstraction with swappable backends (TerminalDisplay, NullDisplay)
- Separate C&C into RX/TX topics for serial-like communication
- Add StreamController with automatic ntfy topic warmup
- Add performance monitoring for render + display stages
- Update AGENTS.md and README.md with daemon/cmd operating procedures
- Add clean/clobber tasks to mise.toml
- Add tests for display, effects controller, and controller warmup
- Fix bug where /effects reorder command was unreachable
This commit is contained in:
2026-03-15 18:37:36 -07:00
parent 40ad935dda
commit bc2e086f2f
14 changed files with 633 additions and 82 deletions

View File

@@ -4,10 +4,16 @@ Orchestrates viewport, frame timing, and layers.
"""
import random
import sys
import time
from engine import config
from engine.display import (
Display,
TerminalDisplay,
)
from engine.display import (
get_monitor as _get_display_monitor,
)
from engine.frame import calculate_scroll_step
from engine.layers import (
apply_glitch,
@@ -16,24 +22,24 @@ from engine.layers import (
render_message_overlay,
render_ticker_zone,
)
from engine.terminal import CLR
from engine.viewport import th, tw
USE_EFFECT_CHAIN = True
def stream(items, ntfy_poller, mic_monitor):
def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
if display is None:
display = TerminalDisplay()
random.shuffle(items)
pool = list(items)
seen = set()
queued = 0
time.sleep(0.5)
sys.stdout.write(CLR)
sys.stdout.flush()
w, h = tw(), th()
display.init(w, h)
display.clear()
fh = config.FIREHOSE_H if config.FIREHOSE else 0
ticker_view_h = h - fh
GAP = 3
@@ -97,6 +103,7 @@ def stream(items, ntfy_poller, mic_monitor):
buf.extend(ticker_buf)
mic_excess = mic_monitor.excess
render_start = time.perf_counter()
if USE_EFFECT_CHAIN:
buf = process_effects(
@@ -119,12 +126,16 @@ def stream(items, ntfy_poller, mic_monitor):
if msg_overlay:
buf.extend(msg_overlay)
sys.stdout.buffer.write("".join(buf).encode())
sys.stdout.flush()
render_elapsed = (time.perf_counter() - render_start) * 1000
monitor = _get_display_monitor()
if monitor:
chars = sum(len(line) for line in buf)
monitor.record_effect("render", render_elapsed, chars, chars)
display.show(buf)
elapsed = time.monotonic() - t0
time.sleep(max(0, config.FRAME_DT - elapsed))
frame_number += 1
sys.stdout.write(CLR)
sys.stdout.flush()
display.cleanup()