Add files via upload
This commit is contained in:
417
nano-claude-code/make_demo.py
Normal file
417
nano-claude-code/make_demo.py
Normal file
@@ -0,0 +1,417 @@
|
||||
#!/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}")
|
||||
Reference in New Issue
Block a user