Skip to content

Commit c153164

Browse files
max-sixtyclaude
andauthored
fix(demos): add OCR validation for TUI demos, fix permission dialog (#897)
* Add OCR-based validation for TUI demos TUI demos (Zellij, interactive UIs) can't be validated via text snapshots because VHS only captures the outer terminal, not content inside multiplexers. Instead, extract key frames from GIFs and use OCR (tesseract) to verify expected patterns. Add `docs/demos/shared/validation.py` with checkpoint definitions and validation logic. Update build script to validate TUI demos with defined checkpoints during snapshot mode, skipping those without checkpoints. Document validation approach in CLAUDE.md with requirements and checkpoint definitions for wt-zellij-omnibus. * fix(demos): pre-populate Zellij permissions cache to avoid dialog The zellij-tab-name plugin shows a permission dialog on first run, which was appearing in recorded demos. Fix by pre-populating the permissions.kdl cache file before Zellij starts. Also simplifies the tape by removing the "y" keypress and clear that were working around the permission dialog. Co-Authored-By: Claude <noreply@anthropic.com> * fix(demos): re-enable OCR validation checkpoints, handle permission dialog race The permission dialog timing is non-deterministic (depends on macOS cache state). Handle both cases: - If dialog appears: "y" dismisses it - If no dialog (cache hit): "y" + Enter + clear cleans up the shell Re-enables validation checkpoints with calibrated frame numbers: - Frame 100: wt list output with branch table - Frame 500: Claude UI with Opus indicator - Frame 2000: Final wt list --full output Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 771ad8e commit c153164

File tree

9 files changed

+316
-54
lines changed

9 files changed

+316
-54
lines changed

docs/demos/CLAUDE.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,27 @@ Snapshots capture command output (not terminal rendering) and are committed to `
6666
- Output format changes you didn't intend
6767
- New lines or missing output
6868

69+
**TUI demo validation:**
70+
71+
TUI demos (Zellij, Claude UI) can't use text snapshots because VHS only captures the outer terminal, not content inside terminal multiplexers. Instead, they use OCR-based validation:
72+
73+
1. After recording, specific frames are extracted from the GIF using ffmpeg
74+
2. Tesseract OCR extracts text from those frames
75+
3. The text is validated for expected/forbidden patterns
76+
4. Validation runs automatically when building TUI demos with defined checkpoints
77+
78+
Checkpoints are defined in `docs/demos/shared/validation.py`. To add validation to a TUI demo:
79+
1. Identify key frame numbers by examining the GIF (30fps, so frame 90 = 3 seconds)
80+
2. Define checkpoint patterns in `validation.py` with frame numbers, expected patterns, and forbidden patterns
81+
82+
Currently `wt-zellij-omnibus` has checkpoints; other TUI demos are skipped until checkpoints are added.
83+
84+
**Prerequisites for TUI validation:** `ffmpeg` and `tesseract` must be installed.
85+
6986
**Limitations:**
7087
- Tab completion sequences are not replayed; only `Type "command"` + `Enter` patterns are extracted
71-
- TUI demos (wt-select, wt-switch, wt-statusline, wt-zellij-omnibus) are skipped since they require interactive input
88+
- TUI demos without defined checkpoints are skipped
89+
- OCR accuracy depends on font rendering quality
7290

7391
## Prerequisites
7492

docs/demos/build

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ from shared import ( # noqa: E402
4747
build_tape_replacements,
4848
ensure_vhs_binary,
4949
)
50+
from shared.validation import ( # noqa: E402
51+
TUI_CHECKPOINTS,
52+
validate_tui_demo_verbose,
53+
)
5054

5155
REPO_ROOT = SCRIPT_DIR.parent.parent
5256
OUT_DIR = SCRIPT_DIR.parent / "static" / "assets"
@@ -385,7 +389,23 @@ def record_demo(
385389
vhs_binary=vhs_binary,
386390
size=demo_size,
387391
)
388-
print(f"✓ {output_name}: GIFs saved: {', '.join(themes)}")
392+
393+
# Validate TUI demos with OCR after recording
394+
if output_name in TUI_CHECKPOINTS:
395+
# Use the light theme GIF for validation
396+
gif_path = output_gifs.get("light", list(output_gifs.values())[0])
397+
success, output = validate_tui_demo_verbose(output_name, gif_path)
398+
if success:
399+
print(f"✓ {output_name}: GIFs saved + validation passed")
400+
else:
401+
print(f"✓ {output_name}: GIFs saved")
402+
print(f"✗ {output_name}: Validation failed")
403+
for line in output.split("\n"):
404+
if line.startswith(" "):
405+
print(line)
406+
raise RuntimeError(f"TUI validation failed for {output_name}")
407+
else:
408+
print(f"✓ {output_name}: GIFs saved: {', '.join(themes)}")
389409

390410

391411
# =============================================================================
@@ -498,13 +518,13 @@ Examples:
498518
print(f"Available for {args.target}: {', '.join(all_names)}")
499519
return
500520

501-
# Filter out TUI demos in snapshot mode
521+
# In snapshot mode, skip TUI demos (they validate via checkpoints during GIF recording)
502522
if args.snapshot:
503523
original_count = len(demos)
504524
demos = [(t, n, s) for t, n, s in demos if n not in TUI_DEMOS]
505525
skipped = original_count - len(demos)
506526
if skipped:
507-
print(f"Skipping {skipped} TUI demo(s) (not snapshotable)")
527+
print(f"Skipping {skipped} TUI demo(s) (validate during GIF recording instead)")
508528
if not demos:
509529
print("No snapshotable demos found")
510530
return

docs/demos/shared/lib.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,22 @@ def setup_zellij_config(env: DemoEnv, default_cwd: str = None) -> None:
504504

505505
default_cwd_line = f'default_cwd "{default_cwd}"' if default_cwd else ""
506506

507+
# Pre-populate Zellij permissions cache to avoid permission dialog
508+
# On macOS, cache is at $HOME/Library/Caches/org.Zellij-Contributors.Zellij/
509+
# On Linux, it would be at $HOME/.cache/zellij/
510+
plugin_dest = zellij_plugins_dir / "zellij-tab-name.wasm"
511+
if platform.system() == "Darwin":
512+
zellij_cache_dir = env.home / "Library" / "Caches" / "org.Zellij-Contributors.Zellij"
513+
else:
514+
zellij_cache_dir = env.home / ".cache" / "zellij"
515+
zellij_cache_dir.mkdir(parents=True, exist_ok=True)
516+
permissions_file = zellij_cache_dir / "permissions.kdl"
517+
permissions_file.write_text(f'''"{plugin_dest}" {{
518+
ReadApplicationState
519+
ChangeApplicationState
520+
}}
521+
''')
522+
507523
zellij_config = zellij_config_dir / "config.kdl"
508524
zellij_config.write_text(f"""// Demo Zellij config
509525
default_shell "fish"
@@ -513,6 +529,11 @@ def setup_zellij_config(env: DemoEnv, default_cwd: str = None) -> None:
513529
show_release_notes false
514530
theme "warm-gold"
515531
532+
// Load the tab-name plugin
533+
load_plugins {{
534+
"file:{zellij_plugins_dir}/zellij-tab-name.wasm"
535+
}}
536+
516537
// Warm gold theme to match the demo aesthetic
517538
themes {{
518539
warm-gold {{
@@ -530,10 +551,6 @@ def setup_zellij_config(env: DemoEnv, default_cwd: str = None) -> None:
530551
}}
531552
}}
532553
533-
load_plugins {{
534-
"file:{zellij_plugins_dir}/zellij-tab-name.wasm"
535-
}}
536-
537554
keybinds clear-defaults=true {{
538555
normal {{
539556
bind "Ctrl Space" {{ SwitchToMode "tmux"; }}

docs/demos/shared/validation.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""OCR-based validation for TUI demos.
2+
3+
TUI demos (Zellij, interactive UIs) can't be validated via text output because
4+
VHS only captures the outer terminal, not content rendered inside terminal
5+
multiplexers. Instead, we extract key frames from the GIF and use OCR to verify
6+
expected content appears.
7+
8+
Usage:
9+
from shared.validation import validate_tui_demo, TUI_CHECKPOINTS
10+
11+
# Validate after building
12+
errors = validate_tui_demo("wt-zellij-omnibus", gif_path)
13+
if errors:
14+
print("Validation failed:", errors)
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import subprocess
20+
import tempfile
21+
from pathlib import Path
22+
23+
# Checkpoint definitions per TUI demo
24+
# Format: {demo_name: [(frame_number, expected_patterns, forbidden_patterns), ...]}
25+
#
26+
# Frame numbers are calibrated from actual GIF content at 30fps.
27+
# Expected patterns must ALL be present (case-insensitive).
28+
# Forbidden patterns must ALL be absent (case-insensitive).
29+
30+
TUI_CHECKPOINTS: dict[str, list[tuple[int, list[str], list[str]]]] = {
31+
# Frame numbers calibrated with permissions cache fix (no permission dialog).
32+
# At 30fps: frame 100 = ~3.3s, frame 500 = ~16.7s, frame 2000 = ~66.7s
33+
"wt-zellij-omnibus": [
34+
# Frame 100: After wt list - should see branch table, no permission dialog
35+
(100, ["Branch", "Status", "main", "hooks"], ["Allow?", "permission"]),
36+
# Frame 500: Claude UI visible - shows Opus model indicator and worktree
37+
(500, ["Opus", "acme"], ["command not found", "Unknown command"]),
38+
# Frame 2000: Near end - wt list --full showing all worktrees
39+
(2000, ["Branch", "main", "feature", "billing"], ["CONFLICT", "error:", "failed"]),
40+
],
41+
}
42+
43+
44+
def check_dependencies() -> list[str]:
45+
"""Check that required tools are available. Returns list of missing tools."""
46+
missing = []
47+
for cmd in ["ffmpeg", "tesseract"]:
48+
result = subprocess.run(
49+
["which", cmd], capture_output=True, text=True
50+
)
51+
if result.returncode != 0:
52+
missing.append(cmd)
53+
return missing
54+
55+
56+
def extract_frame(gif_path: Path, frame_number: int, output_path: Path) -> bool:
57+
"""Extract a single frame from a GIF. Returns True on success."""
58+
result = subprocess.run(
59+
[
60+
"ffmpeg",
61+
"-loglevel", "error",
62+
"-i", str(gif_path),
63+
"-vf", f"select=eq(n\\,{frame_number})",
64+
"-vframes", "1",
65+
"-update", "1",
66+
str(output_path),
67+
],
68+
capture_output=True,
69+
)
70+
return result.returncode == 0 and output_path.exists()
71+
72+
73+
def ocr_image(image_path: Path) -> str:
74+
"""Run OCR on an image and return the extracted text."""
75+
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f:
76+
output_base = f.name[:-4] # Remove .txt suffix for tesseract
77+
78+
result = subprocess.run(
79+
["tesseract", str(image_path), output_base, "-l", "eng"],
80+
capture_output=True,
81+
)
82+
83+
output_path = Path(f"{output_base}.txt")
84+
if result.returncode == 0 and output_path.exists():
85+
text = output_path.read_text()
86+
output_path.unlink()
87+
return text
88+
return ""
89+
90+
91+
def validate_checkpoint(
92+
gif_path: Path,
93+
frame_number: int,
94+
expected: list[str],
95+
forbidden: list[str],
96+
work_dir: Path,
97+
) -> list[str]:
98+
"""Validate a single checkpoint. Returns list of error messages."""
99+
errors = []
100+
101+
# Extract frame
102+
frame_path = work_dir / f"frame_{frame_number}.png"
103+
if not extract_frame(gif_path, frame_number, frame_path):
104+
return [f"Failed to extract frame {frame_number}"]
105+
106+
# OCR the frame
107+
text = ocr_image(frame_path)
108+
if not text:
109+
return [f"OCR failed for frame {frame_number}"]
110+
111+
text_lower = text.lower()
112+
113+
# Check expected patterns
114+
for pattern in expected:
115+
if pattern.lower() not in text_lower:
116+
errors.append(f"Expected pattern not found: '{pattern}'")
117+
118+
# Check forbidden patterns
119+
for pattern in forbidden:
120+
if pattern.lower() in text_lower:
121+
errors.append(f"Forbidden pattern found: '{pattern}'")
122+
123+
return errors
124+
125+
126+
def validate_tui_demo(demo_name: str, gif_path: Path) -> list[str]:
127+
"""Validate a TUI demo GIF against its checkpoints.
128+
129+
Args:
130+
demo_name: Name of the demo (e.g., "wt-zellij-omnibus")
131+
gif_path: Path to the GIF file to validate
132+
133+
Returns:
134+
List of error messages. Empty list means validation passed.
135+
"""
136+
if demo_name not in TUI_CHECKPOINTS:
137+
return [f"No checkpoints defined for demo: {demo_name}"]
138+
139+
if not gif_path.exists():
140+
return [f"GIF not found: {gif_path}"]
141+
142+
# Check dependencies
143+
missing = check_dependencies()
144+
if missing:
145+
return [f"Missing required tools: {', '.join(missing)}"]
146+
147+
checkpoints = TUI_CHECKPOINTS[demo_name]
148+
all_errors = []
149+
150+
with tempfile.TemporaryDirectory(prefix="wt-validate-") as work_dir:
151+
work_path = Path(work_dir)
152+
153+
for frame_number, expected, forbidden in checkpoints:
154+
errors = validate_checkpoint(
155+
gif_path, frame_number, expected, forbidden, work_path
156+
)
157+
if errors:
158+
all_errors.append(f"Frame {frame_number}: {'; '.join(errors)}")
159+
160+
return all_errors
161+
162+
163+
def validate_tui_demo_verbose(demo_name: str, gif_path: Path) -> tuple[bool, str]:
164+
"""Validate a TUI demo with verbose output.
165+
166+
Returns:
167+
(success, output_message)
168+
"""
169+
lines = [f"Validating {demo_name}: {gif_path}"]
170+
171+
if demo_name not in TUI_CHECKPOINTS:
172+
return False, f"No checkpoints defined for demo: {demo_name}"
173+
174+
if not gif_path.exists():
175+
return False, f"GIF not found: {gif_path}"
176+
177+
missing = check_dependencies()
178+
if missing:
179+
return False, f"Missing required tools: {', '.join(missing)}"
180+
181+
checkpoints = TUI_CHECKPOINTS[demo_name]
182+
all_passed = True
183+
184+
with tempfile.TemporaryDirectory(prefix="wt-validate-") as work_dir:
185+
work_path = Path(work_dir)
186+
187+
for frame_number, expected, forbidden in checkpoints:
188+
errors = validate_checkpoint(
189+
gif_path, frame_number, expected, forbidden, work_path
190+
)
191+
if errors:
192+
lines.append(f" ✗ Frame {frame_number}")
193+
for error in errors:
194+
lines.append(f" - {error}")
195+
all_passed = False
196+
else:
197+
lines.append(f" ✓ Frame {frame_number}")
198+
199+
if all_passed:
200+
lines.append("✓ All checkpoints passed")
201+
else:
202+
lines.append("✗ Some checkpoints failed")
203+
204+
return all_passed, "\n".join(lines)

docs/demos/snapshots/wt-commit.snap

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ $ wt step commit
1212

1313
Add validation module with is_positive and is_non_empty helpers
1414
for validating user input. Includes comprehensive test coverage.
15-
✓ Committed changes @ 200f6f2
15+
✓ Committed changes @ 08d6aa2
1616

1717
$ git log -1
18-
commit 200f6f2d9731f93626892c1a410f942217189339
18+
commit 08d6aa281ae15980b1dc485df79834735f9afaeb
1919
Author: Worktrunk Demo <demo@example.com>
20-
Date: Mon Jan 26 13:27:07 2026 -0800
20+
Date: Tue Jan 27 19:36:26 2026 -0800
2121

2222
feat(validation): add input validation utilities
2323

docs/demos/snapshots/wt-core.snap

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
$ wt list
22
Branch Status HEAD± main↕ Remote⇅ Commit Age Message
3-
@ main [2m^[22m[2m|[22m [2m|[0m [2m9b344c49[0m [2m1d[0m [2mdocs: add developm…[0m
4-
+ hooks [36m+[39m[36m![39m [2m↑[22m [32m+2[0m [32m1[0m [2m0967fab5[0m [2m2h[0m [2mfeat: add math ope…[0m
5-
+ [2mbilling[0m [2m_[22m[2m|[22m [2m|[0m [2m9b344c49[0m [2m1d[0m [2mdocs: add developm…[0m
6-
+ alpha [36m![39m[36m?[39m [2m↑[22m[2m⇡[22m [32m+105[0m [31m-14[0m [32m5[0m [32m1[0m [2mf31634d7[0m [2m3d[0m [2mdocs: add FAQ sect…[0m
7-
+ beta [36m+[39m [2m↓[22m[2m|[22m [32m+2[0m [2m[31m1[0m [2m|[0m [2mb10e3edd[0m [2m5d[0m [2mAdd project hooks[0m
3+
@ main [2m^[22m[2m|[22m [2m|[0m [2m8dbd1794[0m [2m1d[0m [2mdocs: add developm…[0m
4+
+ hooks [36m+[39m[36m![39m [2m↑[22m [32m+2[0m [32m1[0m [2m27ef5da7[0m [2m2h[0m [2mfeat: add math ope…[0m
5+
+ [2mbilling[0m [2m_[22m[2m|[22m [2m|[0m [2m8dbd1794[0m [2m1d[0m [2mdocs: add developm…[0m
6+
+ alpha [36m![39m[36m?[39m [2m↑[22m[2m⇡[22m [32m+105[0m [31m-14[0m [32m5[0m [32m1[0m [2mc53676dd[0m [2m3d[0m [2mdocs: add FAQ sect…[0m
7+
+ beta [36m+[39m [2m↓[22m[2m|[22m [32m+2[0m [2m[31m1[0m [2m|[0m [2m6ed05ea1[0m [2m5d[0m [2mAdd project hooks[0m
88

99
○ Showing 5 worktrees, 3 with changes, 2 ahead, 1 column hidden
1010

@@ -17,12 +17,12 @@ $ wt switch --create api
1717

1818
$ wt list --full
1919
Branch Status HEAD± main↕ main…± Remote⇅ CI Commit Age
20-
@ [2mapi[0m [2m_[22m [2m9b344c49[0m [2m1d[0m
21-
^ main [2m^[22m[2m|[22m [2m|[0m [2m9b344c49[0m [2m1d[0m
22-
+ hooks [36m+[39m[36m![39m [2m↑[22m [32m+2[0m [32m↑1[0m [32m+12[0m [31m-7[0m [2m0967fab5[0m [2m2h[0m
23-
+ [2mbilling[0m [2m_[22m[2m|[22m [2m|[0m [2m9b344c49[0m [2m1d[0m
24-
+ alpha [36m![39m[36m?[39m [2m↑[22m[2m⇡[22m [32m+105[0m [31m-14[0m [32m↑5[0m [32m+264[0m [31m-3[0m [32m⇡1[0m [2mf31634d7[0m [2m3d[0m
25-
+ beta [36m+[39m [2m↓[22m[2m|[22m [32m+2[0m [2m[31m↓1[0m [2m|[0m [2mb10e3edd[0m [2m5d[0m
20+
@ [2mapi[0m [2m_[22m [2m8dbd1794[0m [2m1d[0m
21+
^ main [2m^[22m[2m|[22m [2m|[0m [2m8dbd1794[0m [2m1d[0m
22+
+ hooks [36m+[39m[36m![39m [2m↑[22m [32m+2[0m [32m↑1[0m [32m+12[0m [31m-7[0m [2m27ef5da7[0m [2m2h[0m
23+
+ [2mbilling[0m [2m_[22m[2m|[22m [2m|[0m [2m8dbd1794[0m [2m1d[0m
24+
+ alpha [36m![39m[36m?[39m [2m↑[22m[2m⇡[22m [32m+105[0m [31m-14[0m [32m↑5[0m [32m+264[0m [31m-3[0m [32m⇡1[0m [2mc53676dd[0m [2m3d[0m
25+
+ beta [36m+[39m [2m↓[22m[2m|[22m [32m+2[0m [2m[31m↓1[0m [2m|[0m [2m6ed05ea1[0m [2m5d[0m
2626

2727
○ Showing 6 worktrees, 3 with changes, 2 ahead, 2 columns hidden
2828


0 commit comments

Comments
 (0)