pi-simocracy · kitty-graphics-render

Animated codex pets via the Kitty graphics protocol

@daviddao

Replace pi-simocracy's ANSI half-block sprite render with true-pixel inline graphics in Kitty / Ghostty / WezTerm / iTerm2, including a 6-frame idle animation that ticks via pi-tui's existing render loop — typing during animation continues to work, the same way it does during a spinner.

Date: 2026-05-03 · Version: pi-simocracy 0.5.1 · pass


Headline result

2/2
Sims rendered (Duo, Einstein)
192×208
Source PNG, no downsample
10
Cells wide, configurable
5 fps
Idle loop, in-place swap

Result. Both Duo (Duolingo owl) and Einstein (chibi scientist) now render as native Kitty-protocol bitmaps inline in pi's chat, animated through the 6-frame idle row of their codex-pet atlas. The terminal swaps each frame in place by image ID at 5 fps, no flicker, no scrollback churn. The full ANSI half-block path (`▀` / `▄`) is preserved as a universal fallback for terminals without inline-image support; flip with SIMOCRACY_INLINE_GRAPHICS=ansi. Source PNGs travel at full native resolution — zero pre-downsampling on our side. Typing during animation works (verified) because the timer follows the same tui.requestRender() pattern pi-tui's spinner already uses.

Walkthrough

Hero render — actual Ghostty screenshot

Both sims loaded via the production message-renderer path. Each sprite is the native 192×208 idle cell from the codex-pet atlas, transmitted with Kitty's a=T action and a stable image ID so frame swaps replace in place. Display is pinned to SIMOCRACY_SPRITE_WIDTH cells wide (default 10) with aspect-ratio-preserved height; the terminal does the on-screen scaling.

Ghostty terminal showing Duo (green Duolingo owl) and Einstein (chibi scientist) rendered as crisp inline Kitty-protocol images, both showing 'frame 2/6'
01Real Ghostty screenshot. Both sprites mid-animation at frame 2/6, native pixel quality preserved through the entire pipeline.

ANSI half-block fallback

Same script invoked with SIMOCRACY_INLINE_GRAPHICS=ansi simulates a terminal that doesn't advertise Kitty / iTerm2 inline-image support. Pi-simocracy detects the lack of capability and falls back to its existing / 24-bit colour half-block render. Same source RGBA, same sims, same display target — visibly more pixelated because each cell paints two pixels via Unicode block characters instead of a real bitmap.

Same Ghostty terminal showing the same Duo and Einstein sprites rendered as ANSI half-blocks instead of Kitty graphics; visibly more pixelated
02The universal fallback path — Apple Terminal, plain SSH, tmux without passthrough, etc. Same RGBA source, half-block render.

Wire-level evidence

vhs tape (rendered in xterm.js, which can't display inline images) showing the underlying mechanism: terminal capability detection, the actual Kitty graphics escape sequence emitted to stdout, and the env-var-driven switch to the ANSI fallback path.

In-tape highlights: { images: 'kitty', trueColor: true, hyperlinks: true } from detectCapabilities() in Ghostty; the on-the-wire Kitty escape \x1b_Ga=T,f=100,q=2,c=10,r=6,m=1;iVBORw0KG…; the same call with SIMOCRACY_INLINE_GRAPHICS=ansi producing 24-bit colour half-block escapes (\x1b[38;2;…m▄) instead.

Animation mechanism — headless verification

The animation is impossible to capture in vhs (xterm.js can't render the bitmaps), so we verify the *mechanism* instead. This vhs tape runs a test harness that mocks pi-tui, mounts an AnimatedImage with Duo's 6 idle frames at 5 fps, and dumps a verification matrix showing every emission carries the same Kitty image ID (= in-place bitmap swap) but a different payload hash (= frames actually cycle). The 0.5.0 release shipped a regression where this didn't fire — pi-tui's Image component caches its render output and the base64Data is private, so frame swaps couldn't propagate through. 0.5.1 ships a tiny custom AnimatedImage component that owns its frames + timer and emits a fresh escape on every render() call.

In-tape result: 7 ticks captured in 1.5 s (≈4.6 fps measured against 5 fps target) · 1 distinct image ID (4001028631) across all ticks · 6 distinct sha256 payload hashes (= 6 idle frames cycling) · ✅ PASS.

How it works

Render path picks itself per-terminal. The simocracy_sim_loaded message renderer calls pi-tui's getCapabilities(); if it returns images: "kitty" | "iterm2" we build a Box[Image, Text] and let pi-tui's existing Image component emit the protocol escape. Otherwise we return a Text wrapping the existing half-block art. Both paths consume the same RGBA buffer produced by renderSprite(), so cropping/decoding changes propagate to both for free.

Animation runs on pi-tui's render tick — via a custom component. A bespoke AnimatedImage implements pi-tui's Component interface, owns its own setInterval at fps = 5, and on every render() emits a fresh Kitty escape for the current frame (no internal cache). The timer calls tui.requestRender(), pi-tui's diff renderer re-walks the tree, our render() produces a new escape, the terminal swaps the bitmap in place via stable image ID. This is the same pattern pi-tui's Loader spinner uses; input handling is on an independent code path, so typing keeps working during animation. We had to roll our own component because pi-tui's stock Image caches its render and exposes no setter to swap the bytes — see Findings.

TUI handle captured via the widget factory. The MessageRenderer signature doesn't pass a TUI reference, but setWidget's factory form does. We use the factory form when posting the loaded-sim widget and stash tui in a module-level slot; the animation timer reads it from there. One tiny exploit of pi's API surface, no upstream changes required.

Source quality is preserved end-to-end. Codex-pet idle cells are cropped from the WebP atlas at native 192×208 (lossless), encoded as PNG (lossless), and transmitted to Kitty at native resolution. The terminal scales for display — we never touch boxDownscaleRgba on the inline-graphics path. Bandwidth: ~50 KB PNG × 5 fps ≈ 250 KB/s of escape data per active sim. Only the most recently loaded sim animates; earlier messages freeze on their idle frame.

Findings

Inline render works on every Kitty / iTerm2-capable terminal

Verified in Ghostty (screenshot). pi-tui's capability detector also recognises Kitty proper, WezTerm, Konsole, and iTerm2 — all use the same protocol code path. No additional terminal-specific glue needed.

Animation does not block input

The render loop uses pi-tui's requestRender() tick, identical to the spinner. Verified by typing in pi while a loaded sim animates — keystrokes are echoed and dispatched normally; render diffs only re-emit the cells where the image lives.

0.5.0 regression: stock Image caches its render — fixed in 0.5.1 with a custom AnimatedImage component

0.5.0 wired the timer to call tui.requestRender() and assumed pi-tui would re-call our message renderer with the new frame. It does — but pi-tui's stock Image caches its render output keyed on (base64Data, width), and base64Data is declared private with no setter. Result: the timer ticked, requestRender fired, the diff renderer walked the tree — and Image.render() returned the same cached lines, so nothing changed on screen. 0.5.1 ships src/animated-image.ts, a ~150-line Component implementation that owns its frames and emits a fresh Kitty escape via encodeKitty() on every render call. Verified end-to-end via the headless 04-animation-proof tape above.

Quality chain is end-to-end lossless

Trace verified: WebP atlas (1536×1872 RGBA) → cell crop (192×208 RGBA) → PNG encode (192×208) → Kitty payload (192×208). The boxDownscaleRgba call lives only in the ANSI half-block branch — it never touches the inline-graphics PNG. Confirmed for both Duo and Einstein.

Bandwidth is non-trivial — ~250 KB/s while animating

Each frame re-transmits the full PNG (no "display by ID" cache use). 50 KB × 5 fps = 250 KB/s of escape data per active sim. Comfortable on local terminals, mildly wasteful over slow SSH. Mitigation in place: only the most recent loaded-sim message animates; older ones freeze. A future optimisation could use Kitty's a=t (transmit only) once and a=p,i=N (display by ID) per frame, dropping per-frame bandwidth to a few hundred bytes — deferred until someone actually feels it.

Pipoya pixel-art sprites get terminal-side bilinear upscale (slight blur)

Pipoya frames are 32×32 native pixels. At 10 cells wide × ~10 px/cell that's a 3× upscale by Kitty's default scaler, which is bilinear-ish and softens the pixel-art edges. Codex pets (192×208 source) don't have this issue — they're already high-resolution. A future option: pre-upscale pipoya frames 8× with nearest-neighbour on our side so the terminal does a near-identity downscale that preserves crispness. Not implemented in 0.5.0; the half-block fallback path is already pixel-perfect for pixel art.

Reproducing

Codepi-simocracy / src/index.tsrenderSprite, startAnimationFor, message renderer Live demonpx jiti assets/demo-animated.ts (run inside pi-simocracy/ with NODE_PATH=$PWD/node_modules) Force fallbackSIMOCRACY_INLINE_GRAPHICS=ansi Override sizeSIMOCRACY_SPRITE_WIDTH=20 (default 10, range 4–120) Disable animationSIMOCRACY_ANIMATION=off Hero screenshot01-kitty-graphics-hero.png — real Ghostty window, both sims animating Fallback screenshot02-ansi-fallback.png — same script, half-block path forced Mechanism video03-mechanism.webm — vhs recording of capability detection + on-wire escape sequences vhs tape03-mechanism.tape — source for the recording Helper03-emit-once.ts — emits one frame so xxd can dump the wire