Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified docs/assets/tui.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
39 changes: 26 additions & 13 deletions scripts/gen_tui_gif.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand All @@ -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"),
]


Expand All @@ -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")
Expand All @@ -75,7 +84,7 @@ def rasterise(svg_path: Path, png_path: Path) -> None:
f'<!doctype html><body style="margin:0">{svg_path.read_text(encoding="utf-8")}</body>',
encoding="utf-8",
)
subprocess.run(
proc = subprocess.run(
[
CHROME,
"--headless",
Expand All @@ -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:
Expand All @@ -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))
Expand Down
Loading