blob: e7a0a88d9febec3f177da6d15f5820dc23a8265b [file]
# 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)