| # Copyright 2026 The Fuchsia Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| import fcntl |
| import os |
| import re |
| import select |
| import shutil |
| import subprocess |
| import sys |
| import tempfile |
| import time |
| |
| from PIL import Image, ImageDraw, ImageFont |
| |
| |
| class Terminal: |
| def __init__( |
| self, |
| width=800, |
| height=450, |
| font_path="/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", |
| font_size=14, |
| ): |
| self.width = width |
| self.height = height |
| self.font = ImageFont.truetype(font_path, font_size) |
| bold_font_path = font_path.replace( |
| "DejaVuSansMono.ttf", "DejaVuSansMono-Bold.ttf" |
| ) |
| self.font_bold = ImageFont.truetype(bold_font_path, font_size) |
| self.lines = [""] |
| self.line_height = font_size + 6 |
| self.padding = 15 |
| self.max_lines = max(1, (height - 2 * self.padding) // self.line_height) |
| self.bg_color = (30, 30, 30) # #1e1e1e |
| self.fg_color = (212, 212, 212) # #d4d4d4 |
| self.colors = { |
| "g": (78, 201, 176), # Green/Teal |
| "b": (86, 156, 214), # Blue |
| "y": (206, 145, 120), # Yellow/Orange |
| "m": (197, 134, 192), # Magenta |
| "c": (156, 220, 254), # Cyan |
| "r": (244, 71, 71), # Red |
| } |
| self.prompt = "\x1b[32m❯\x1b[0m " |
| self.in_escape = False |
| self.escape_buf = "" |
| |
| def add_line(self, line): |
| self.lines.append(line) |
| if len(self.lines) > self.max_lines: |
| self.lines.pop(0) |
| |
| def write(self, text): |
| for char in text: |
| if self.in_escape: |
| self.escape_buf += char |
| if char == "m": |
| self.in_escape = False |
| if self.lines: |
| self.lines[-1] += self.escape_buf |
| self.escape_buf = "" |
| elif len(self.escape_buf) > 20: # Safety fallback |
| self.in_escape = False |
| if self.lines: |
| self.lines[-1] += self.escape_buf |
| self.escape_buf = "" |
| elif char == "\x1b": |
| self.in_escape = True |
| self.escape_buf = "\x1b" |
| elif char == "\n": |
| self.add_line("") |
| elif char == "\r": |
| if self.lines: |
| self.lines[-1] = "" |
| else: |
| if not self.lines: |
| self.lines.append("") |
| self.lines[-1] += char |
| |
| def strip_tags(self, line): |
| return re.sub(r"\x1b\[[0-9;]*m", "", line) |
| |
| def draw(self, show_cursor=True): |
| img = Image.new("RGB", (self.width, self.height), color=self.bg_color) |
| draw = ImageDraw.Draw(img) |
| |
| for i, line in enumerate(self.lines): |
| y = self.padding + i * self.line_height |
| self.draw_line(draw, line, self.padding, y) |
| |
| # Draw cursor at the end of the last line |
| if show_cursor and self.lines: |
| last_line = self.lines[-1] |
| raw_text = self.strip_tags(last_line) |
| x = self.padding + draw.textlength(raw_text, font=self.font) |
| y = self.padding + (len(self.lines) - 1) * self.line_height |
| draw.rectangle( |
| [x + 2, y + 4, x + 10, y + self.line_height - 2], |
| fill=self.fg_color, |
| ) |
| |
| return img |
| |
| def draw_line(self, draw, line, x, y): |
| parts = re.split(r"(\x1b\[[0-9;]*m)", line) |
| current_color = self.fg_color |
| current_font = self.font |
| current_x = x |
| for part in parts: |
| if not part: |
| continue |
| if part.startswith("\x1b["): |
| code = part[2:-1] |
| if code == "0": |
| current_color = self.fg_color |
| current_font = self.font |
| elif code == "1": |
| current_font = self.font_bold |
| elif code in ("32", "92"): |
| current_color = self.colors.get("g", self.fg_color) |
| elif code in ("33", "93"): |
| current_color = self.colors.get("y", self.fg_color) |
| elif code in ("34", "94"): |
| current_color = self.colors.get("b", self.fg_color) |
| elif code in ("35", "95"): |
| current_color = self.colors.get("m", self.fg_color) |
| elif code in ("36", "96"): |
| current_color = self.colors.get("c", self.fg_color) |
| elif code in ("31", "91"): |
| current_color = self.colors.get("r", self.fg_color) |
| else: |
| draw.text( |
| (current_x, y), part, font=current_font, fill=current_color |
| ) |
| current_x += draw.textlength(part, font=current_font) |
| |
| |
| class MultiplexedTerminal: |
| def __init__( |
| self, |
| width=850, |
| height=450, |
| font_path="/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", |
| font_size=12, |
| ): |
| self.width = width |
| self.height = height |
| self.font_path = font_path |
| self.font_size = font_size |
| self.split = False |
| self.active_pane = 1 |
| |
| self.bg_color = (30, 30, 30) |
| self.border_inactive_color = (60, 60, 60) |
| self.border_active_color = (0, 122, 204) |
| |
| self.term_full = Terminal( |
| width=width, height=height, font_path=font_path, font_size=font_size |
| ) |
| |
| self.border_width = 2 |
| self.pane_height = height // 2 - 3 |
| self.inner_width = width - 2 * self.border_width |
| self.inner_height = self.pane_height - 2 * self.border_width |
| |
| self.term_left = Terminal( |
| width=self.inner_width, |
| height=self.inner_height, |
| font_path=font_path, |
| font_size=font_size, |
| ) |
| self.term_right = Terminal( |
| width=self.inner_width, |
| height=self.inner_height, |
| font_path=font_path, |
| font_size=font_size, |
| ) |
| |
| def perform_split(self): |
| self.split = True |
| self.term_left.lines = list(self.term_full.lines) |
| if len(self.term_left.lines) > self.term_left.max_lines: |
| self.term_left.lines = self.term_left.lines[ |
| -self.term_left.max_lines : |
| ] |
| self.term_right.lines = [""] |
| |
| def perform_merge(self): |
| self.split = False |
| self.term_full.lines = list(self.term_left.lines) |
| if len(self.term_full.lines) > self.term_full.max_lines: |
| self.term_full.lines = self.term_full.lines[ |
| -self.term_full.max_lines : |
| ] |
| |
| def draw(self, show_cursor=True): |
| if not self.split: |
| return self.term_full.draw(show_cursor=show_cursor) |
| |
| img = Image.new("RGB", (self.width, self.height), color=self.bg_color) |
| draw = ImageDraw.Draw(img) |
| |
| img_left = self.term_left.draw( |
| show_cursor=(show_cursor and self.active_pane == 1) |
| ) |
| img.paste(img_left, (self.border_width, self.border_width)) |
| |
| img_right = self.term_right.draw( |
| show_cursor=(show_cursor and self.active_pane == 2) |
| ) |
| img.paste( |
| img_right, |
| (self.border_width, self.height // 2 + 1 + self.border_width), |
| ) |
| |
| top_border_color = ( |
| self.border_active_color |
| if self.active_pane == 1 |
| else self.border_inactive_color |
| ) |
| draw.rectangle( |
| [0, 0, self.width - 1, self.pane_height], |
| outline=top_border_color, |
| width=self.border_width, |
| ) |
| |
| bottom_border_color = ( |
| self.border_active_color |
| if self.active_pane == 2 |
| else self.border_inactive_color |
| ) |
| draw.rectangle( |
| [0, self.height // 2 + 1, self.width - 1, self.height - 1], |
| outline=bottom_border_color, |
| width=self.border_width, |
| ) |
| |
| return img |
| |
| |
| def setup_mock_workspace(): |
| test_dir = tempfile.mkdtemp(prefix="fx-worktree-demo-") |
| fuchsia_dir = os.path.join(test_dir, "fuchsia") |
| os.makedirs(fuchsia_dir) |
| |
| subprocess.run( |
| ["git", "init", "-q", "--initial-branch=main"], |
| cwd=fuchsia_dir, |
| check=True, |
| ) |
| subprocess.run( |
| ["git", "config", "user.name", "Test"], cwd=fuchsia_dir, check=True |
| ) |
| subprocess.run( |
| ["git", "config", "user.email", "test@test.com"], |
| cwd=fuchsia_dir, |
| check=True, |
| ) |
| |
| with open(os.path.join(fuchsia_dir, "dummy"), "w") as f: |
| f.write("hello") |
| subprocess.run(["git", "add", "dummy"], cwd=fuchsia_dir, check=True) |
| subprocess.run( |
| ["git", "commit", "-m", "initial commit", "-q"], |
| cwd=fuchsia_dir, |
| check=True, |
| ) |
| subprocess.run( |
| ["git", "tag", "JIRI_HEAD", "HEAD"], cwd=fuchsia_dir, check=True |
| ) |
| |
| jiri_root = os.path.join(fuchsia_dir, ".jiri_root") |
| os.makedirs(jiri_root) |
| os.makedirs(os.path.join(jiri_root, "worktrees")) |
| with open(os.path.join(jiri_root, "worktrees_registry"), "w") as f: |
| pass |
| |
| mock_jiri_dir = os.path.join(jiri_root, "bin") |
| os.makedirs(mock_jiri_dir) |
| mock_jiri_path = os.path.join(mock_jiri_dir, "jiri") |
| |
| jiri_script = """#!/bin/bash |
| base_dir="$(cd "$(dirname "$0")/../.." && pwd)" |
| if [ "$1" = "worktree" ]; then |
| if [ "$2" = "add" ]; then |
| target_path="$3" |
| git -C "$base_dir" worktree add -q -f --detach "$target_path" main |
| echo "$(realpath "$target_path")" >> "$base_dir/.jiri_root/worktrees_registry" |
| exit 0 |
| elif [ "$2" = "remove" ]; then |
| target_path="$3" |
| if [ "$target_path" = "-f" ] || [ "$target_path" = "-force" ]; then |
| target_path="$4" |
| fi |
| resolved_path="$(realpath "$target_path")" |
| if [ -f "$base_dir/.jiri_root/worktrees_registry" ]; then |
| grep -v "^$resolved_path$" "$base_dir/.jiri_root/worktrees_registry" > "$base_dir/.jiri_root/worktrees_registry.tmp" || true |
| mv "$base_dir/.jiri_root/worktrees_registry.tmp" "$base_dir/.jiri_root/worktrees_registry" |
| fi |
| git -C "$base_dir" worktree remove -f "$target_path" 2>/dev/null || rm -rf "$target_path" |
| exit 0 |
| elif [ "$2" = "clean" ]; then |
| exit 0 |
| fi |
| fi |
| exit 0 |
| """ |
| with open(mock_jiri_path, "w") as f: |
| f.write(jiri_script) |
| os.chmod(mock_jiri_path, 0o755) |
| |
| os.makedirs(os.path.join(fuchsia_dir, "scripts")) |
| mock_fx_path = os.path.join(fuchsia_dir, "scripts", "fx") |
| fx_script = """#!/bin/bash |
| if [ "$1" = "set" ]; then |
| cfg="$2" |
| product="${cfg%.*}" |
| board="${cfg#*.}" |
| if [ "$product" = "$board" ]; then |
| board="x64" |
| fi |
| outdir="out/${cfg}-balanced" |
| mkdir -p "$outdir" |
| echo "build_info_product = \\"$product\\"" > "$outdir/args.gn" |
| echo "build_info_board = \\"$board\\"" >> "$outdir/args.gn" |
| echo "$outdir" > ".fx-build-dir" |
| echo "✔ Configure outdir for $cfg" |
| exit 0 |
| fi |
| |
| if [ "$1" = "use" ]; then |
| cfg="$2" |
| echo "$cfg" > ".fx-build-dir" |
| echo "Using build directory $cfg" |
| exit 0 |
| fi |
| |
| if [ "$1" = "build" ]; then |
| build_dir=$(cat .fx-build-dir 2>/dev/null || echo "out/default") |
| cfg=$(basename "$build_dir" | sed 's/-balanced//') |
| echo "Building $cfg..." |
| for i in {1..10}; do |
| printf "[%d/100] CXX obj/src/file_%d.o\\r" $((i*10)) $i |
| sleep 0.05 |
| done |
| echo -e "\\nDone" |
| exit 0 |
| fi |
| exit 0 |
| """ |
| with open(mock_fx_path, "w") as f: |
| f.write(fx_script) |
| os.chmod(mock_fx_path, 0o755) |
| |
| subprocess.run(["git", "add", "scripts/fx"], cwd=fuchsia_dir, check=True) |
| subprocess.run( |
| ["git", "commit", "-m", "add mock fx", "-q"], |
| cwd=fuchsia_dir, |
| check=True, |
| ) |
| |
| return test_dir, fuchsia_dir |
| |
| |
| def make_replacer(fuchsia_dir): |
| def replace(text): |
| if not text: |
| return text |
| return text.replace(fuchsia_dir, "/home/user/fuchsia") |
| |
| return replace |
| |
| |
| def filter_list_output( |
| text, |
| allowed_names=["wt-alpha", "wt-beta", "agent-refactor", "agent-bugfix"], |
| ): |
| def strip_ansi(s): |
| return re.sub(r"\x1b\[[0-9;]*m", "", s) |
| |
| lines = text.splitlines() |
| entries = [] |
| current_entry = None |
| |
| for line in lines: |
| clean_line = strip_ansi(line).strip() |
| if not clean_line: |
| continue |
| if clean_line.startswith("No worktrees found."): |
| entries.append((None, [line])) |
| continue |
| |
| if not clean_line.startswith(" ") and not any( |
| clean_line.startswith(m) for m in ["└", "├", "│"] |
| ): |
| parts = clean_line.split() |
| if parts: |
| wt_name = parts[0] |
| current_entry = (wt_name, [line]) |
| entries.append(current_entry) |
| else: |
| if current_entry: |
| current_entry[1].append(line) |
| |
| allowed_entries = [wt_lines for name, wt_lines in entries] |
| |
| if not allowed_entries: |
| return "No worktrees found.\n" |
| |
| result = "" |
| for i, wt_lines in enumerate(allowed_entries): |
| result += "\n".join(wt_lines) + "\n" |
| if i < len(allowed_entries) - 1: |
| result += "\n" |
| return result |
| |
| |
| def compress_frames(frames, max_static_duration_ms=1000): |
| if not frames: |
| return [] |
| |
| compressed = [] |
| prev_img, prev_duration = frames[0] |
| merged_any = False |
| |
| def images_equal(img1, img2): |
| if img1 == img2: |
| return True |
| return img1.tobytes() == img2.tobytes() |
| |
| for img, duration in frames[1:]: |
| if images_equal(img, prev_img): |
| prev_duration += duration |
| merged_any = True |
| else: |
| if merged_any: |
| prev_duration = min(prev_duration, max_static_duration_ms) |
| compressed.append((prev_img, prev_duration)) |
| prev_img = img |
| prev_duration = duration |
| merged_any = False |
| |
| if merged_any: |
| prev_duration = min(prev_duration, max_static_duration_ms) |
| compressed.append((prev_img, prev_duration)) |
| |
| return compressed |
| |
| |
| def make_non_blocking(fd): |
| fl = fcntl.fcntl(fd, fcntl.F_GETFL) |
| fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) |
| |
| |
| def make_blocking(fd): |
| fl = fcntl.fcntl(fd, fcntl.F_GETFL) |
| fcntl.fcntl(fd, fcntl.F_SETFL, fl & ~os.O_NONBLOCK) |
| |
| |
| def type_command(multiplexed_term, pane_id, cmd, frames): |
| term = ( |
| multiplexed_term.term_full |
| if not multiplexed_term.split |
| else ( |
| multiplexed_term.term_left |
| if pane_id == 1 |
| else multiplexed_term.term_right |
| ) |
| ) |
| multiplexed_term.active_pane = pane_id |
| |
| current_prompt = getattr(term, "prompt", "\x1b[32m❯\x1b[0m ") |
| term.write(current_prompt) |
| frames.append((multiplexed_term.draw(), 300)) |
| |
| for i in range(len(cmd)): |
| term.write(cmd[i]) |
| frames.append((multiplexed_term.draw(), 60)) |
| |
| term.write("\n") |
| frames.append((multiplexed_term.draw(), 200)) |
| |
| |
| def run_cmd_anim( |
| multiplexed_term, |
| pane_id, |
| cmd, |
| cwd, |
| env, |
| frames, |
| replacer=None, |
| filter_fn=None, |
| fps=10, |
| ): |
| term = ( |
| multiplexed_term.term_full |
| if not multiplexed_term.split |
| else ( |
| multiplexed_term.term_left |
| if pane_id == 1 |
| else multiplexed_term.term_right |
| ) |
| ) |
| multiplexed_term.active_pane = pane_id |
| |
| proc = subprocess.Popen( |
| cmd, |
| shell=True, |
| cwd=cwd, |
| env=env, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.STDOUT, |
| text=True, |
| ) |
| |
| make_non_blocking(proc.stdout.fileno()) |
| |
| last_frame_time = time.time() |
| frame_interval = 1.0 / fps |
| buffer = "" |
| |
| while proc.poll() is None: |
| r, _, _ = select.select([proc.stdout], [], [], 0.05) |
| if proc.stdout in r: |
| try: |
| data = proc.stdout.read(4096) |
| if data: |
| if filter_fn: |
| buffer += data |
| else: |
| if replacer: |
| data = replacer(data) |
| term.write(data) |
| except BlockingIOError: |
| pass |
| |
| if not filter_fn: |
| current_time = time.time() |
| if current_time - last_frame_time >= frame_interval: |
| frames.append( |
| (multiplexed_term.draw(), int(frame_interval * 1000)) |
| ) |
| last_frame_time = current_time |
| |
| make_blocking(proc.stdout.fileno()) |
| try: |
| data = proc.stdout.read() |
| if data: |
| if filter_fn: |
| buffer += data |
| else: |
| if replacer: |
| data = replacer(data) |
| term.write(data) |
| except Exception as e: |
| print(f"Error reading remaining in run_cmd_anim: {e}") |
| |
| if filter_fn: |
| filtered_data = filter_fn(buffer) |
| if replacer: |
| filtered_data = replacer(filtered_data) |
| term.write(filtered_data) |
| |
| frames.append((multiplexed_term.draw(), 500)) |
| |
| |
| def run_parallel_cmds_anim( |
| multiplexed_term, |
| cmd1, |
| cwd1, |
| env1, |
| cmd2, |
| cwd2, |
| env2, |
| frames, |
| replacer=None, |
| fps=5, |
| ): |
| proc1 = subprocess.Popen( |
| cmd1, |
| shell=True, |
| cwd=cwd1, |
| env=env1, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.STDOUT, |
| text=True, |
| ) |
| proc2 = subprocess.Popen( |
| cmd2, |
| shell=True, |
| cwd=cwd2, |
| env=env2, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.STDOUT, |
| text=True, |
| ) |
| |
| make_non_blocking(proc1.stdout.fileno()) |
| make_non_blocking(proc2.stdout.fileno()) |
| |
| last_frame_time = time.time() |
| frame_interval = 1.0 / fps |
| |
| while proc1.poll() is None or proc2.poll() is None: |
| readable = [] |
| if proc1.poll() is None: |
| readable.append(proc1.stdout) |
| if proc2.poll() is None: |
| readable.append(proc2.stdout) |
| |
| if readable: |
| r, _, _ = select.select(readable, [], [], 0.05) |
| for pipe in r: |
| if pipe == proc1.stdout: |
| try: |
| data = proc1.stdout.read(4096) |
| if data: |
| if replacer: |
| data = replacer(data) |
| multiplexed_term.term_left.write(data) |
| except BlockingIOError: |
| pass |
| elif pipe == proc2.stdout: |
| try: |
| data = proc2.stdout.read(4096) |
| if data: |
| if replacer: |
| data = replacer(data) |
| multiplexed_term.term_right.write(data) |
| except BlockingIOError: |
| pass |
| |
| current_time = time.time() |
| if current_time - last_frame_time >= frame_interval: |
| frames.append((multiplexed_term.draw(), int(frame_interval * 1000))) |
| last_frame_time = current_time |
| |
| for proc, term in ( |
| (proc1, multiplexed_term.term_left), |
| (proc2, multiplexed_term.term_right), |
| ): |
| make_blocking(proc.stdout.fileno()) |
| try: |
| data = proc.stdout.read() |
| if data: |
| if replacer: |
| data = replacer(data) |
| term.write(data) |
| except Exception as e: |
| print(f"Error reading remaining in run_parallel_cmds_anim: {e}") |
| |
| frames.append((multiplexed_term.draw(), 1000)) |
| |
| |
| def generate_frames(main_py, fuchsia_dir): |
| term = MultiplexedTerminal() |
| frames = [] |
| |
| env = os.environ.copy() |
| env["FUCHSIA_DIR"] = fuchsia_dir |
| env["FORCE_COLOR"] = "1" |
| |
| replacer = make_replacer(fuchsia_dir) |
| |
| cwd_left = fuchsia_dir |
| cwd_right = fuchsia_dir |
| |
| # Helper to run python3 main.py <subcommand> |
| cmd_prefix = f"python3 {main_py}" |
| |
| # === Phase 0: Silent Setup (Not in frames) === |
| # Pre-provision the pool so we have checkouts ready with build configs |
| subprocess.run( |
| f"python3 {main_py} pool add --set fuchsia_internal.arm64 --set fuchsia_internal.x64", |
| shell=True, |
| cwd=fuchsia_dir, |
| env=env, |
| check=True, |
| ) |
| subprocess.run( |
| f"python3 {main_py} pool add --set fuchsia_internal.arm64 --set fuchsia_internal.x64", |
| shell=True, |
| cwd=fuchsia_dir, |
| env=env, |
| check=True, |
| ) |
| |
| # === Phase 1: Setup (Single Pane) === |
| type_command(term, 1, "fx worktree list", frames) |
| run_cmd_anim( |
| term, |
| 1, |
| f"{cmd_prefix} list", |
| fuchsia_dir, |
| env, |
| frames, |
| replacer=replacer, |
| filter_fn=filter_list_output, |
| ) |
| |
| type_command(term, 1, "fx worktree add agent-refactor", frames) |
| run_cmd_anim( |
| term, |
| 1, |
| f"{cmd_prefix} add agent-refactor", |
| fuchsia_dir, |
| env, |
| frames, |
| replacer=replacer, |
| ) |
| |
| # === Phase 2: Split & Parallel (Split Pane) === |
| term.perform_split() |
| frames.append((term.draw(), 500)) |
| |
| type_command(term, 2, "fx worktree add agent-bugfix", frames) |
| run_cmd_anim( |
| term, |
| 2, |
| f"{cmd_prefix} add agent-bugfix", |
| fuchsia_dir, |
| env, |
| frames, |
| replacer=replacer, |
| ) |
| |
| type_command(term, 2, "cd $(fx worktree locate agent-bugfix)", frames) |
| term.term_right.write("\n") |
| term.term_right.prompt = "[agent-bugfix] ❯ " |
| cwd_right = os.path.join( |
| fuchsia_dir, ".jiri_root", "worktrees", "agent-bugfix" |
| ) |
| frames.append((term.draw(), 500)) |
| |
| type_command(term, 2, "fx use out/fuchsia_internal.x64-balanced", frames) |
| run_cmd_anim( |
| term, |
| 2, |
| "scripts/fx use out/fuchsia_internal.x64-balanced", |
| cwd_right, |
| env, |
| frames, |
| replacer=replacer, |
| ) |
| |
| type_command(term, 1, "cd $(fx worktree locate agent-refactor)", frames) |
| term.term_left.write("\n") |
| term.term_left.prompt = "[agent-refactor] ❯ " |
| cwd_left = os.path.join( |
| fuchsia_dir, ".jiri_root", "worktrees", "agent-refactor" |
| ) |
| frames.append((term.draw(), 500)) |
| |
| type_command(term, 1, "fx use out/fuchsia_internal.arm64-balanced", frames) |
| run_cmd_anim( |
| term, |
| 1, |
| "scripts/fx use out/fuchsia_internal.arm64-balanced", |
| cwd_left, |
| env, |
| frames, |
| replacer=replacer, |
| ) |
| |
| type_command(term, 1, "fx build build/info:version", frames) |
| type_command(term, 2, "fx build build/info:version", frames) |
| |
| build_target = "build/info:version" |
| run_parallel_cmds_anim( |
| term, |
| f"scripts/fx build {build_target}", |
| cwd_left, |
| env, |
| f"scripts/fx build {build_target}", |
| cwd_right, |
| env, |
| frames, |
| replacer=replacer, |
| fps=10, |
| ) |
| |
| # === Phase 3: Cleanup (Split Pane) === |
| type_command(term, 1, "fx worktree remove agent-refactor", frames) |
| run_cmd_anim( |
| term, |
| 1, |
| f"{cmd_prefix} remove agent-refactor", |
| fuchsia_dir, |
| env, |
| frames, |
| replacer=replacer, |
| ) |
| term.term_left.prompt = "❯ " |
| cwd_left = fuchsia_dir |
| |
| type_command(term, 2, "fx worktree remove agent-bugfix", frames) |
| run_cmd_anim( |
| term, |
| 2, |
| f"{cmd_prefix} remove agent-bugfix", |
| fuchsia_dir, |
| env, |
| frames, |
| replacer=replacer, |
| ) |
| term.term_right.prompt = "❯ " |
| cwd_right = fuchsia_dir |
| |
| # === Phase 4: Teardown (Single Pane) === |
| term.perform_merge() |
| frames.append((term.draw(), 500)) |
| |
| type_command(term, 1, "fx worktree list", frames) |
| run_cmd_anim( |
| term, |
| 1, |
| f"{cmd_prefix} list", |
| fuchsia_dir, |
| env, |
| frames, |
| replacer=replacer, |
| filter_fn=filter_list_output, |
| ) |
| |
| for _ in range(2): |
| term.term_full.write("\n") |
| frames.append((term.draw(show_cursor=True), 250)) |
| term.term_full.lines[-1] = "" |
| frames.append((term.draw(show_cursor=False), 250)) |
| |
| return frames |
| |
| |
| if __name__ == "__main__": |
| if len(sys.argv) < 2: |
| print("Usage: python3 generate_demo.py <output_path>") |
| sys.exit(1) |
| output_path = sys.argv[1] |
| |
| print("Using MOCK workspace...") |
| test_dir, fuchsia_dir = setup_mock_workspace() |
| |
| # Find real tools/devshell/worktree/main.py |
| repo_root = os.path.abspath( |
| os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") |
| ) |
| main_py = os.path.join( |
| repo_root, "tools", "devshell", "worktree", "main.py" |
| ) |
| if not os.path.exists(main_py): |
| print(f"Error: Cannot find main.py at {main_py}") |
| sys.exit(1) |
| |
| try: |
| os.makedirs( |
| os.path.dirname(os.path.abspath(output_path)), exist_ok=True |
| ) |
| |
| print("Generating frames...") |
| frames_data = generate_frames(main_py, fuchsia_dir) |
| |
| print("Compressing static frames...") |
| frames_data = compress_frames(frames_data) |
| |
| images = [f[0] for f in frames_data] |
| durations = [f[1] for f in frames_data] |
| |
| print(f"Saving GIF to {output_path}...") |
| images[0].save( |
| output_path, |
| save_all=True, |
| append_images=images[1:], |
| duration=durations, |
| loop=0, |
| ) |
| print("Done!") |
| finally: |
| if test_dir: |
| shutil.rmtree(test_dir) |