418 lines
16 KiB
Python
418 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Generate animated GIF demo of nano claude code using PIL.
|
||
Simulates a realistic terminal session with tool calls.
|
||
"""
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
import os, textwrap
|
||
|
||
# ── Catppuccin Mocha palette ─────────────────────────────────────────────
|
||
BG = (30, 30, 46) # base
|
||
SURFACE = (49, 50, 68) # surface0
|
||
TEXT = (205, 214, 244) # text
|
||
SUBTEXT = (108, 112, 134) # overlay0 (dim)
|
||
CYAN = (137, 220, 235) # sky
|
||
GREEN = (166, 227, 161) # green
|
||
YELLOW = (249, 226, 175) # yellow
|
||
RED = (243, 139, 168) # red
|
||
MAUVE = (203, 166, 247) # mauve (user prompt)
|
||
BLUE = (137, 180, 250) # blue
|
||
PEACH = (250, 179, 135) # peach
|
||
|
||
W, H = 960, 720
|
||
FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
|
||
FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf"
|
||
FONT_SIZE = 14
|
||
LINE_H = 20
|
||
PAD_X = 18
|
||
PAD_Y = 16
|
||
|
||
|
||
def make_font(size=FONT_SIZE, bold=False):
|
||
path = FONT_BOLD if bold else FONT_PATH
|
||
try:
|
||
return ImageFont.truetype(path, size)
|
||
except:
|
||
return ImageFont.load_default()
|
||
|
||
|
||
FONT = make_font()
|
||
FONT_B = make_font(bold=True)
|
||
FONT_SM = make_font(FONT_SIZE - 1)
|
||
|
||
|
||
# ── Segment: (text, color, bold?) ────────────────────────────────────────
|
||
Seg = tuple # (str, rgb_tuple, bool)
|
||
|
||
|
||
def seg(t, c=TEXT, b=False): return (t, c, b)
|
||
def segs(*args): return list(args)
|
||
|
||
|
||
def render_line(draw, y, segments, x_start=PAD_X):
|
||
x = x_start
|
||
for text, color, bold in segments:
|
||
font = FONT_B if bold else FONT
|
||
draw.text((x, y), text, font=font, fill=color)
|
||
x += font.getlength(text)
|
||
return y + LINE_H
|
||
|
||
|
||
def blank_frame():
|
||
img = Image.new("RGB", (W, H), BG)
|
||
return img
|
||
|
||
|
||
def draw_frame(lines_segments):
|
||
"""
|
||
lines_segments: list of either
|
||
- list[Seg] → rendered as a line
|
||
- None → blank line
|
||
Returns PIL Image.
|
||
"""
|
||
img = blank_frame()
|
||
d = ImageDraw.Draw(img)
|
||
y = PAD_Y
|
||
for item in lines_segments:
|
||
if item is None:
|
||
y += LINE_H
|
||
elif isinstance(item, list):
|
||
y = render_line(d, y, item)
|
||
else:
|
||
y = render_line(d, y, [item])
|
||
return img
|
||
|
||
|
||
# ── Pre-defined screen content blocks ───────────────────────────────────
|
||
|
||
BANNER = [
|
||
[seg("╭─ Nano Claude Code ──────────────────────────────────────────╮", SUBTEXT)],
|
||
[seg("│ ", SUBTEXT), seg("Model: ", SUBTEXT), seg("claude-opus-4-6", CYAN, True)],
|
||
[seg("│ ", SUBTEXT), seg("Permissions: ", SUBTEXT), seg("auto", YELLOW)],
|
||
[seg("│ Type /help for commands, Ctrl+C to cancel │", SUBTEXT)],
|
||
[seg("╰────────────────────────────────────────────────────────────╯", SUBTEXT)],
|
||
None,
|
||
]
|
||
|
||
def prompt_line(text="", cursor=False):
|
||
cur = "█" if cursor else ""
|
||
return [
|
||
seg("[nano_claude_code] ", SUBTEXT),
|
||
seg("❯ ", CYAN, True),
|
||
seg(text + cur, TEXT),
|
||
]
|
||
|
||
def claude_header():
|
||
return [
|
||
seg("╭─ Claude ", SUBTEXT),
|
||
seg("●", GREEN),
|
||
seg(" ─────────────────────────────────────────────", SUBTEXT),
|
||
]
|
||
|
||
def claude_sep():
|
||
return [seg("╰──────────────────────────────────────────────────────────", SUBTEXT)]
|
||
|
||
def tool_line(icon, name, arg, color=CYAN):
|
||
return [
|
||
seg(f" {icon} ", SUBTEXT),
|
||
seg(name, color),
|
||
seg("(", SUBTEXT),
|
||
seg(arg, TEXT),
|
||
seg(")", SUBTEXT),
|
||
]
|
||
|
||
def tool_ok(msg):
|
||
return [seg(f" ✓ ", GREEN), seg(msg, SUBTEXT)]
|
||
|
||
def tool_err(msg):
|
||
return [seg(f" ✗ ", RED), seg(msg, SUBTEXT)]
|
||
|
||
def text_line(t, indent=2):
|
||
return [seg(" " * indent + t, TEXT)]
|
||
|
||
def dim_line(t, indent=4):
|
||
return [seg(" " * indent + t, SUBTEXT)]
|
||
|
||
|
||
# ── Scene builder ─────────────────────────────────────────────────────────
|
||
|
||
def build_scenes():
|
||
"""Return list of (frame_content, duration_ms)."""
|
||
scenes = []
|
||
def add(lines, ms=120):
|
||
scenes.append((lines, ms))
|
||
|
||
# ── Scene 0: Empty terminal with banner ──────────────────────────────
|
||
add(BANNER + [prompt_line(cursor=True)], 800)
|
||
|
||
# ── Scene 1: User types query 1 ──────────────────────────────────────
|
||
msg1 = "List Python files in this project and show me their line counts"
|
||
for i in range(0, len(msg1) + 1, 3):
|
||
add(BANNER + [prompt_line(msg1[:i], cursor=(i < len(msg1)))], 60)
|
||
add(BANNER + [prompt_line(msg1, cursor=False)], 400)
|
||
|
||
# ── Scene 2: Claude header appears ──────────────────────────────────
|
||
pre = BANNER + [prompt_line(msg1)]
|
||
add(pre + [None, claude_header(), [seg("│ ", SUBTEXT)]], 300)
|
||
|
||
# ── Scene 3: Tool call - Glob ────────────────────────────────────────
|
||
base = pre + [None, claude_header()]
|
||
add(base + [
|
||
tool_line("⚙", "Glob", "**/*.py"),
|
||
], 500)
|
||
add(base + [
|
||
tool_line("⚙", "Glob", "**/*.py"),
|
||
tool_ok("5 files matched"),
|
||
], 600)
|
||
|
||
# ── Scene 4: Tool call - Bash (wc -l) ────────────────────────────────
|
||
add(base + [
|
||
tool_line("⚙", "Glob", "**/*.py"),
|
||
tool_ok("5 files matched"),
|
||
None,
|
||
tool_line("⚙", "Bash", "wc -l *.py | sort -n"),
|
||
], 500)
|
||
add(base + [
|
||
tool_line("⚙", "Glob", "**/*.py"),
|
||
tool_ok("5 files matched"),
|
||
None,
|
||
tool_line("⚙", "Bash", "wc -l *.py | sort -n"),
|
||
tool_ok("→ 6 lines (120 chars)"),
|
||
], 700)
|
||
|
||
# ── Scene 5: Claude streams response ────────────────────────────────
|
||
response_lines = [
|
||
"Here are the Python files in this project with their line counts:",
|
||
"",
|
||
" 76 config.py — Configuration management and cost calculation",
|
||
" 100 context.py — System prompt builder, CLAUDE.md + git injection",
|
||
" 173 agent.py — Core agent loop with streaming API calls",
|
||
" 359 tools.py — 8 built-in tools (Read/Write/Edit/Bash/Glob/Grep/Web)",
|
||
" 553 nano_claude.py — REPL entry point, slash commands, rich rendering",
|
||
"────────────────────────────────────────────────────",
|
||
"1261 total",
|
||
"",
|
||
"The largest file is `nano_claude.py` containing the interactive REPL,",
|
||
"14 slash commands, permission handling, and markdown rendering.",
|
||
]
|
||
tool_section = [
|
||
tool_line("⚙", "Glob", "**/*.py"),
|
||
tool_ok("5 files matched"),
|
||
None,
|
||
tool_line("⚙", "Bash", "wc -l *.py | sort -n"),
|
||
tool_ok("→ 6 lines (120 chars)"),
|
||
None,
|
||
[seg("│ ", SUBTEXT)],
|
||
]
|
||
streamed = []
|
||
for i, rline in enumerate(response_lines):
|
||
streamed.append(text_line(rline, 2))
|
||
content = base + tool_section + streamed
|
||
add(content, 80 if rline else 30)
|
||
|
||
add(base + tool_section + [text_line(l, 2) for l in response_lines] + [claude_sep()], 1200)
|
||
|
||
# ── Scene 6: New prompt appears ──────────────────────────────────────
|
||
full1 = (pre + [None, claude_header()] +
|
||
tool_section +
|
||
[text_line(l, 2) for l in response_lines] +
|
||
[claude_sep(), None])
|
||
add(full1 + [prompt_line(cursor=True)], 800)
|
||
|
||
# ── Scene 7: User types query 2 ──────────────────────────────────────
|
||
msg2 = "Write a hello_world.py that prints 'Hello from Nano Claude!'"
|
||
for i in range(0, len(msg2) + 1, 4):
|
||
add(full1 + [prompt_line(msg2[:i], cursor=(i < len(msg2)))], 55)
|
||
add(full1 + [prompt_line(msg2)], 400)
|
||
|
||
# ── Scene 8: Write tool call ─────────────────────────────────────────
|
||
base2 = full1 + [prompt_line(msg2), None, claude_header()]
|
||
add(base2 + [
|
||
tool_line("⚙", "Write", "/tmp/hello_world.py", MAUVE),
|
||
], 600)
|
||
add(base2 + [
|
||
tool_line("⚙", "Write", "/tmp/hello_world.py", MAUVE),
|
||
tool_ok("Wrote 3 lines to /tmp/hello_world.py"),
|
||
None,
|
||
tool_line("⚙", "Bash", "python3 /tmp/hello_world.py"),
|
||
], 500)
|
||
add(base2 + [
|
||
tool_line("⚙", "Write", "/tmp/hello_world.py", MAUVE),
|
||
tool_ok("Wrote 3 lines to /tmp/hello_world.py"),
|
||
None,
|
||
tool_line("⚙", "Bash", "python3 /tmp/hello_world.py"),
|
||
tool_ok("→ Hello from Nano Claude!"),
|
||
], 800)
|
||
|
||
# ── Scene 9: Final response ──────────────────────────────────────────
|
||
resp2 = [
|
||
"Done! Created `/tmp/hello_world.py` and ran it successfully.",
|
||
"",
|
||
" print('Hello from Nano Claude!')",
|
||
"",
|
||
"Output: Hello from Nano Claude!",
|
||
]
|
||
tool2 = [
|
||
tool_line("⚙", "Write", "/tmp/hello_world.py", MAUVE),
|
||
tool_ok("Wrote 3 lines to /tmp/hello_world.py"),
|
||
None,
|
||
tool_line("⚙", "Bash", "python3 /tmp/hello_world.py"),
|
||
tool_ok("→ Hello from Nano Claude!"),
|
||
None,
|
||
[seg("│ ", SUBTEXT)],
|
||
]
|
||
streamed2 = []
|
||
for rline in resp2:
|
||
streamed2.append(text_line(rline, 2))
|
||
add(base2 + tool2 + streamed2, 90)
|
||
|
||
add(base2 + tool2 + [text_line(l, 2) for l in resp2] + [claude_sep()], 1500)
|
||
|
||
# ── Scene 10: Slash command demo ─────────────────────────────────────
|
||
final_state = (full1 + [prompt_line(msg2), None, claude_header()] +
|
||
tool2 + [text_line(l, 2) for l in resp2] + [claude_sep(), None])
|
||
add(final_state + [prompt_line(cursor=True)], 600)
|
||
|
||
slash = "/cost"
|
||
for i in range(len(slash) + 1):
|
||
add(final_state + [prompt_line(slash[:i], cursor=(i < len(slash)))], 80)
|
||
add(final_state + [prompt_line(slash)], 400)
|
||
|
||
# cost output
|
||
cost_lines = [
|
||
[seg("Input tokens: ", CYAN), seg("1,842", TEXT, True)],
|
||
[seg("Output tokens: ", CYAN), seg("312", TEXT, True)],
|
||
[seg("Est. cost: ", CYAN), seg("$0.0318 USD", GREEN, True)],
|
||
]
|
||
add(final_state + [prompt_line(slash), None] + cost_lines + [None, prompt_line(cursor=True)], 2000)
|
||
|
||
return scenes
|
||
|
||
|
||
# ── Render ────────────────────────────────────────────────────────────────
|
||
|
||
def _build_explicit_palette():
|
||
"""
|
||
Build a 256-entry palette from our exact theme colors.
|
||
Returns flat list of 768 ints (R,G,B, R,G,B, ...) suitable for putpalette().
|
||
"""
|
||
# All distinct colors used in the renderer
|
||
theme = [
|
||
BG, SURFACE, TEXT, SUBTEXT,
|
||
CYAN, GREEN, YELLOW, RED, MAUVE, BLUE, PEACH,
|
||
(255, 255, 255), (0, 0, 0),
|
||
# Extra intermediate shades that PIL might snap to
|
||
(50, 55, 80), # surface variant
|
||
(90, 95, 120), # dim text variant
|
||
(160, 166, 200),
|
||
]
|
||
flat = []
|
||
for c in theme:
|
||
flat.extend(c)
|
||
# Pad to 256 entries with black
|
||
while len(flat) < 256 * 3:
|
||
flat.extend((0, 0, 0))
|
||
return flat
|
||
|
||
|
||
def render_gif(output_path="demo.gif"):
|
||
print("Building scenes...")
|
||
scenes = build_scenes()
|
||
print(f" {len(scenes)} scenes")
|
||
|
||
palette_data = _build_explicit_palette()
|
||
|
||
# Create a palette-mode reference image for quantize()
|
||
pal_ref = Image.new("P", (1, 1))
|
||
pal_ref.putpalette(palette_data)
|
||
|
||
print(" Rendering frames...")
|
||
rgb_frames = []
|
||
durations = []
|
||
for i, (lines, ms) in enumerate(scenes):
|
||
img = draw_frame(lines)
|
||
rgb_frames.append(img)
|
||
durations.append(ms)
|
||
if i % 20 == 0:
|
||
print(f" {i}/{len(scenes)}...")
|
||
|
||
# Quantize all frames to the same explicit palette (no dither → exact snap)
|
||
print(" Quantizing to global palette...")
|
||
p_frames = [f.quantize(palette=pal_ref, dither=0) for f in rgb_frames]
|
||
|
||
print(f"Saving GIF → {output_path} ({len(p_frames)} frames)...")
|
||
p_frames[0].save(
|
||
output_path,
|
||
save_all=True,
|
||
append_images=p_frames[1:],
|
||
duration=durations,
|
||
loop=0,
|
||
optimize=False,
|
||
)
|
||
size_kb = os.path.getsize(output_path) // 1024
|
||
print(f"Done! {size_kb} KB")
|
||
|
||
|
||
# ── Static screenshot ─────────────────────────────────────────────────────
|
||
|
||
def render_screenshot(output_path="screenshot.png"):
|
||
"""Single high-quality screenshot showing a complete session."""
|
||
lines = (
|
||
BANNER +
|
||
[prompt_line("List Python files and their line counts")] +
|
||
[None, claude_header()] +
|
||
[
|
||
tool_line("⚙", "Glob", "**/*.py"),
|
||
tool_ok("5 files matched"),
|
||
None,
|
||
tool_line("⚙", "Bash", "wc -l *.py | sort -n"),
|
||
tool_ok("→ 6 lines (120 chars)"),
|
||
None,
|
||
[seg("│ ", SUBTEXT)],
|
||
text_line("Here are the Python files with their line counts:", 2),
|
||
None,
|
||
text_line(" 76 config.py — Configuration management", 2),
|
||
text_line(" 100 context.py — System prompt + git injection", 2),
|
||
text_line(" 173 agent.py — Core agent loop", 2),
|
||
text_line(" 359 tools.py — 8 built-in tools", 2),
|
||
text_line(" 553 nano_claude.py — REPL + slash commands", 2),
|
||
text_line("────────────────────────────────", 2),
|
||
text_line("1261 total", 2),
|
||
None,
|
||
text_line("The main entry point `nano_claude.py` contains the REPL,", 2),
|
||
text_line("14 slash commands, permission handling, and rich rendering.", 2),
|
||
claude_sep(),
|
||
None,
|
||
prompt_line("/cost"),
|
||
None,
|
||
[seg("Input tokens: ", CYAN), seg("1,842", TEXT, True)],
|
||
[seg("Output tokens: ", CYAN), seg("312", TEXT, True)],
|
||
[seg("Est. cost: ", CYAN), seg("$0.0318 USD", GREEN, True)],
|
||
None,
|
||
prompt_line(cursor=True),
|
||
]
|
||
)
|
||
img = draw_frame(lines)
|
||
|
||
# Add subtle rounded border effect
|
||
d = ImageDraw.Draw(img)
|
||
d.rectangle([0, 0, W-1, H-1], outline=SURFACE, width=2)
|
||
|
||
img.save(output_path, format="PNG", optimize=True)
|
||
size_kb = os.path.getsize(output_path) // 1024
|
||
print(f"Screenshot saved: {output_path} ({size_kb} KB)")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
out_dir = os.path.dirname(os.path.abspath(__file__))
|
||
|
||
gif_path = os.path.join(out_dir, "demo.gif")
|
||
png_path = os.path.join(out_dir, "screenshot.png")
|
||
|
||
render_screenshot(png_path)
|
||
render_gif(gif_path)
|
||
print("\nFiles created:")
|
||
print(f" {png_path}")
|
||
print(f" {gif_path}")
|