diff --git a/docs/assets/tui.gif b/docs/assets/tui.gif index 0fb8b15..7a54510 100644 Binary files a/docs/assets/tui.gif and b/docs/assets/tui.gif differ diff --git a/scripts/gen_tui_gif.py b/scripts/gen_tui_gif.py index 23efb1d..a4a2f1d 100644 --- a/scripts/gen_tui_gif.py +++ b/scripts/gen_tui_gif.py @@ -2,8 +2,10 @@ Drives the Textual app headlessly (Pilot), exports one SVG frame per screen, rasterises the frames with headless Chrome, and assembles an -animated GIF with Pillow. No terminal emulator or recording involved, -so the output is deterministic. +animated GIF with Pillow. No terminal emulator or recording involved; +each frame is captured only after the command's worker has finished, so +rendering is deterministic (frame *content* still reflects whatever live +data sits in ~/.opspilot on this machine). Usage (from repo root, venv active, Google Chrome installed): @@ -27,13 +29,13 @@ TERM_SIZE = (110, 30) # cols, rows FRAME_MS = 2200 # per-frame duration -# (slash command to type, frame label, settle seconds) -TOUR: list[tuple[str, str, float]] = [ - ("/help", "help", 0.5), - ("/sessions", "sessions", 1.0), - ("/kb list", "kb-list", 1.0), - ("/wiki list", "wiki-list", 1.0), - ("/providers", "providers", 1.0), +# (slash command to type, frame label) +TOUR: list[tuple[str, str]] = [ + ("/help", "help"), + ("/sessions", "sessions"), + ("/kb list", "kb-list"), + ("/wiki list", "wiki-list"), + ("/providers", "providers"), ] @@ -43,12 +45,19 @@ async def capture_frames(svg_dir: Path) -> list[Path]: frames: list[Path] = [] async with OpsPilotApp().run_test(size=TERM_SIZE) as pilot: + # Headless, the app's Rich console defaults to 80 cols and RichLog + # measures content against it, wrapping lines that would fit the + # terminal. Real ttys don't hit this; align the console explicitly. + pilot.app.console.width = TERM_SIZE[0] await pilot.pause(0.5) - for i, (command, label, settle) in enumerate(TOUR): + for i, (command, label) in enumerate(TOUR): for ch in command: await pilot.press("space" if ch == " " else ch) await pilot.press("enter") - await pilot.pause(settle) + # Commands run as thread workers; wait for them instead of + # guessing a settle time, then let the queued writes render. + await pilot.app.workers.wait_for_complete() + await pilot.pause(0.2) svg = pilot.app.export_screenshot(title=f"OpsPilot TUI — {command}") path = svg_dir / f"{i:02d}-{label}.svg" path.write_text(svg, encoding="utf-8") @@ -75,7 +84,7 @@ def rasterise(svg_path: Path, png_path: Path) -> None: f'{svg_path.read_text(encoding="utf-8")}', encoding="utf-8", ) - subprocess.run( + proc = subprocess.run( [ CHROME, "--headless", @@ -85,9 +94,11 @@ def rasterise(svg_path: Path, png_path: Path) -> None: "--hide-scrollbars", f"file://{html}", ], - check=True, capture_output=True, ) + if proc.returncode != 0: + stderr = proc.stderr.decode(errors="replace").strip() + raise RuntimeError(f"chrome failed on {svg_path.name} (exit {proc.returncode}): {stderr}") def assemble_gif(pngs: list[Path]) -> None: @@ -105,6 +116,8 @@ def assemble_gif(pngs: list[Path]) -> None: def main() -> None: + if not Path(CHROME).exists(): + sys.exit(f"Google Chrome not found at {CHROME} — install it (or edit CHROME) first.") with tempfile.TemporaryDirectory(prefix="tui-frames-") as tmp: svg_dir = Path(tmp) frames = asyncio.run(capture_frames(svg_dir))