pi-simocracy 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -120,17 +120,36 @@ The same actions are exposed to pi as tools, so the model can drive them itself:
120
120
  - `org.simocracy.agents` — short description + full constitution
121
121
  - `org.simocracy.style` — speaking style / mannerisms
122
122
  4. **Render.** Fetch the sprite blob via `com.atproto.sync.getBlob`,
123
- decode it, crop the front-facing idle frame, and emit as 24-bit ANSI
124
- using the upper-half-block character `▀` so each terminal cell paints
125
- two pixels. Two render paths depending on the sim's `spriteKind`:
123
+ decode it, crop the front-facing idle frame. Two render paths
124
+ depending on the sim's `spriteKind`:
126
125
  - **`pipoya`** (legacy + default): 128×128 PNG, 4×4 of 32×32 walking
127
126
  frames; decode with `pngjs`, take row 0 col 0 at native size.
128
127
  - **`codexPet`** (OpenAI hatch-pet output): 1536×1872 atlas, 8×9 of
129
128
  192×208 cells. PNG sheets decode through `pngjs`; WebP sheets
130
129
  decode through `@jsquash/webp` (wasm, lazy-init). The idle cell
131
- (row 0 col 0) is box-downscaled to ~32 wide so the inline render
132
- stays comparable in height to a pipoya sprite.
133
- Transparent regions show pi's background through.
130
+ (row 0 col 0) is the source for both render paths below.
131
+
132
+ Then emit through one of two terminal output paths, picked
133
+ automatically per-terminal:
134
+ - **Inline graphics** (Kitty graphics protocol or iTerm2 inline
135
+ images). The cropped RGBA cell is re-encoded to PNG via `pngjs`
136
+ and handed to pi-tui's `Image` component, which transmits it as a
137
+ true-color bitmap. Used in Kitty, Ghostty, WezTerm, Konsole, and
138
+ iTerm2 — the terminal does its own scaling, so pixel-art sprites
139
+ stay crisp and codex pets render at full fidelity.
140
+ - **24-bit ANSI half-blocks** (universal fallback). Emits the
141
+ upper/lower half-block characters `▀`/`▄` with `\x1b[38;2;…m`
142
+ true-color escapes so each terminal cell paints two pixels. Used
143
+ in Apple Terminal, plain SSH, tmux without passthrough, and
144
+ anywhere that doesn't advertise inline-image support. Transparent
145
+ regions show pi's background through.
146
+
147
+ To force the half-block path even on a graphics-capable terminal
148
+ (handy for screenshots and demo recordings), set
149
+ `SIMOCRACY_INLINE_GRAPHICS=ansi`. The default is `auto`.
150
+
151
+ Both paths use the same upstream RGBA buffer — swapping between
152
+ them changes only the final encoding step, never the source pixels.
134
153
  5. **Inject.** A `before_agent_start` event handler appends the sim's
135
154
  identity + constitution + speaking style to pi's system prompt **every
136
155
  turn**. After `/sim unload`, a one-shot override fires on the next
@@ -151,6 +170,7 @@ src/
151
170
  ├── simocracy.ts # indexer + PDS client (read-only fetchers)
152
171
  ├── writes.ts # PDS writers + ownership / sign-in preconditions
153
172
  ├── png-to-ansi.ts # RGBA half-block ANSI renderer + downscalers
173
+ ├── png-encode.ts # RGBA → PNG encoder for inline-graphics protocols
154
174
  ├── webp-to-rgba.ts # @jsquash/webp wrapper for codex pet WebP sheets
155
175
  ├── openrouter.ts # minimal OpenRouter client (only used by simocracy_chat)
156
176
  └── auth/ # ATProto OAuth loopback flow + session storage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-simocracy",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Pi extension: load a Simocracy sim into your chat — see its pixel-art sprite render inline in the terminal and roleplay with it.",
5
5
  "type": "module",
6
6
  "author": "David Dao <david@gainforest.earth> (https://github.com/daviddao)",
package/src/index.ts CHANGED
@@ -60,7 +60,15 @@ import type {
60
60
  ExtensionContext,
61
61
  ExtensionCommandContext,
62
62
  } from "@mariozechner/pi-coding-agent";
63
- import { Text } from "@mariozechner/pi-tui";
63
+ import {
64
+ Box,
65
+ Image,
66
+ Text,
67
+ allocateImageId,
68
+ getCapabilities,
69
+ type ImageTheme,
70
+ type TUI,
71
+ } from "@mariozechner/pi-tui";
64
72
  import { Type } from "typebox";
65
73
 
66
74
  import {
@@ -83,6 +91,7 @@ import {
83
91
  downscaleRgbaNearest,
84
92
  boxDownscaleRgba,
85
93
  } from "./png-to-ansi.ts";
94
+ import { encodeRgbaToPng } from "./png-encode.ts";
86
95
  import { decodeWebp } from "./webp-to-rgba.ts";
87
96
  import { openRouterComplete, type ChatMessage } from "./openrouter.ts";
88
97
  import { buildSimPrompt, type LoadedSim } from "./persona.ts";
@@ -113,6 +122,100 @@ let loadedSim: LoadedSim | null = null;
113
122
  */
114
123
  let justUnloaded: string | null = null;
115
124
 
125
+ // ---------------------------------------------------------------------------
126
+ // Animation state
127
+ //
128
+ // We animate the most recently loaded codex pet's idle row inline in chat,
129
+ // using the same pattern pi-tui's spinner uses: a setInterval ticks a frame
130
+ // counter and calls `tui.requestRender()`, which makes pi-tui re-run the
131
+ // message renderer. Each render returns a new `Image` keyed by a stable
132
+ // Kitty image ID, so the terminal swaps the displayed frame in place.
133
+ //
134
+ // Typing keeps working during animation — input handling and rendering are
135
+ // independent code paths in pi-tui (verified by inspection of `Loader`).
136
+ //
137
+ // Only ONE sim animates at a time. When a new sim is loaded, the previous
138
+ // timer stops and the previous message freezes on its idle frame.
139
+ // ---------------------------------------------------------------------------
140
+
141
+ /** Captured pi-tui handle. Set by the `simocracy` widget factory the first
142
+ * time a sim is loaded; reused for every subsequent animation tick. Lazy
143
+ * because the TUI doesn't exist when the extension first imports. */
144
+ let capturedTui: TUI | null = null;
145
+
146
+ interface ActiveAnimation {
147
+ /** Identifies which loaded-sim message owns this animation. The renderer
148
+ * compares against `details.animationKey` to decide whether to use the
149
+ * current frame or the static idle PNG. */
150
+ key: string;
151
+ /** Stable Kitty image ID so frame transmissions replace the previous one
152
+ * instead of stacking. Allocated once per sim load. */
153
+ imageId: number;
154
+ /** Pre-encoded base64 PNGs in playback order. */
155
+ frames: string[];
156
+ /** Frame width / height in pixels (uniform across frames). */
157
+ widthPx: number;
158
+ heightPx: number;
159
+ /** Currently displayed frame index. */
160
+ currentFrame: number;
161
+ /** Active setInterval handle so we can clear it on unload / reload. */
162
+ intervalId: ReturnType<typeof setInterval> | null;
163
+ }
164
+ let currentAnimation: ActiveAnimation | null = null;
165
+
166
+ /** Default-on; set `SIMOCRACY_ANIMATION=off` to freeze on idle frame 0. */
167
+ const animationEnabled =
168
+ (process.env.SIMOCRACY_ANIMATION ?? "on").toLowerCase() !== "off";
169
+
170
+ /**
171
+ * Width of the inline sprite render in terminal cells. The source PNG
172
+ * is always transmitted at native resolution (192×208 for codex pets,
173
+ * 32×32 for pipoya — see `renderSprite`); this number controls only
174
+ * how many cells the terminal uses to display it. Aspect ratio is
175
+ * preserved by pi-tui's `calculateImageRows`. Override with the
176
+ * `SIMOCRACY_SPRITE_WIDTH` env var.
177
+ *
178
+ * 10 cells wide gives a compact ≈6-row inline render that sits
179
+ * comfortably alongside one or two paragraphs of bio text without
180
+ * dominating the chat. Bump it to 20 or 32 if you want the sprite
181
+ * to read at a glance.
182
+ */
183
+ const spriteWidthCells = (() => {
184
+ const raw = process.env.SIMOCRACY_SPRITE_WIDTH;
185
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
186
+ return Number.isFinite(parsed) && parsed >= 4 && parsed <= 120 ? parsed : 10;
187
+ })();
188
+
189
+ function stopCurrentAnimation(): void {
190
+ if (currentAnimation?.intervalId !== null && currentAnimation?.intervalId !== undefined) {
191
+ clearInterval(currentAnimation.intervalId);
192
+ }
193
+ currentAnimation = null;
194
+ }
195
+
196
+ function startAnimationFor(key: string, frames: { pngBase64: string[]; fps: number; widthPx: number; heightPx: number }): void {
197
+ // Always replace any prior animation — only the most recent loaded-sim
198
+ // message animates.
199
+ stopCurrentAnimation();
200
+ if (!animationEnabled || frames.pngBase64.length < 2) return;
201
+ currentAnimation = {
202
+ key,
203
+ imageId: allocateImageId(),
204
+ frames: frames.pngBase64,
205
+ widthPx: frames.widthPx,
206
+ heightPx: frames.heightPx,
207
+ currentFrame: 0,
208
+ intervalId: null,
209
+ };
210
+ const intervalMs = Math.max(1000 / frames.fps, 60); // clamp to ≆16 fps max
211
+ currentAnimation.intervalId = setInterval(() => {
212
+ if (!currentAnimation) return;
213
+ currentAnimation.currentFrame =
214
+ (currentAnimation.currentFrame + 1) % currentAnimation.frames.length;
215
+ capturedTui?.requestRender();
216
+ }, intervalMs);
217
+ }
218
+
116
219
  // ---------------------------------------------------------------------------
117
220
  // Helpers
118
221
  // ---------------------------------------------------------------------------
@@ -153,14 +256,75 @@ const NON_PIXEL_ART_TARGET_LONG_EDGE = 40;
153
256
  * box-downscaled to a comparable terminal size.
154
257
  *
155
258
  * absent — treated as legacy 'pipoya' for back-compat.
259
+ *
260
+ * Returns BOTH the ANSI half-block render (always — it's our universal
261
+ * fallback) AND, when possible, a PNG of the same RGBA cell for inline
262
+ * terminal-graphics protocols (Kitty / iTerm2). The renderer chooses
263
+ * which to display based on the host terminal's capabilities.
264
+ *
265
+ * The PNG is encoded at the *native* resolution we cropped to (32×32
266
+ * for pipoya, 192×208 for codexPet, the post-downscale size for the
267
+ * image fallback). Kitty / iTerm2 do their own scaling to the target
268
+ * cell box, so passing the native pixels gives the terminal the most
269
+ * information to work with — pixel art scales up crisply with
270
+ * nearest-neighbour, codex pet thumbnails scale down cleanly.
271
+ */
272
+ export interface SpriteRender {
273
+ ansi: string;
274
+ png?: { data: Buffer; widthPx: number; heightPx: number };
275
+ /**
276
+ * Optional animation frames. Each entry in `pngs` is a PNG of one
277
+ * cell. Set for codex pets (idle row of their atlas); absent for
278
+ * everything else (pipoya sprites and image fallbacks render as a
279
+ * single static frame). The renderer plays these in a loop using
280
+ * the Kitty in-place image-swap protocol when animation is enabled.
281
+ */
282
+ frames?: { pngs: Buffer[]; widthPx: number; heightPx: number; fps: number };
283
+ }
284
+
285
+ /**
286
+ * Codex pet atlas constants — keep in sync with the simocracy-v2 hatch-pet
287
+ * skill that produces these sheets. The atlas is 8 cols × 9 rows of
288
+ * 192×208 cells; the idle animation lives on row 0, frames 0–5 (the same
289
+ * default tui-pets ships when a pet.json doesn't override it).
156
290
  */
157
- async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
291
+ const CODEX_PET_CELL_W = 192;
292
+ const CODEX_PET_CELL_H = 208;
293
+ const CODEX_PET_COLS = 8;
294
+ const CODEX_PET_IDLE_FRAMES = [0, 1, 2, 3, 4, 5];
295
+ const CODEX_PET_IDLE_FPS = 5;
296
+
297
+ async function renderSprite(sim: SimMatch): Promise<SpriteRender | null> {
158
298
  const spriteKind = sim.sim.spriteKind ?? "pipoya";
159
299
  const spriteLink = blobLink(sim.sim.sprite?.ref);
160
300
  const petSheetLink = blobLink(sim.sim.petSheet?.ref);
161
301
  const petSheetMime = sim.sim.petSheet?.mimeType;
162
302
  const imageLink = blobLink(sim.sim.image?.ref);
163
303
 
304
+ /** Render an RGBA region to ANSI half-blocks (shared options). */
305
+ const toAnsi = (data: Buffer, width: number, height: number) =>
306
+ renderRgbaToAnsi(data, width, height, {
307
+ cropToContent: true,
308
+ cropPad: 1,
309
+ indent: 2,
310
+ alphaThreshold: 16,
311
+ });
312
+
313
+ /** Bundle ANSI + PNG of the same RGBA region. PNG-encode is a tiny
314
+ * cost and is wrapped in try/catch — if encoding ever fails we
315
+ * still return the ANSI render (lossless fallback). */
316
+ const bundle = (data: Buffer, width: number, height: number): SpriteRender => {
317
+ const ansi = toAnsi(data, width, height);
318
+ let png: SpriteRender["png"];
319
+ try {
320
+ const pngBytes = encodeRgbaToPng(data, width, height);
321
+ png = { data: pngBytes, widthPx: width, heightPx: height };
322
+ } catch {
323
+ png = undefined;
324
+ }
325
+ return { ansi, png };
326
+ };
327
+
164
328
  // Pipoya 4×4 walk sheet — the legacy/default path.
165
329
  if (spriteKind !== "codexPet" && spriteLink) {
166
330
  try {
@@ -170,19 +334,9 @@ async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
170
334
  if (width >= FRAME && height >= FRAME) {
171
335
  // Sheets are 4×4 of 32×32 frames — row 0 col 0 = front-facing walk1.
172
336
  const frame = cropRgba(data, width, height, 0, 0, FRAME, FRAME);
173
- return renderRgbaToAnsi(frame, FRAME, FRAME, {
174
- cropToContent: true,
175
- cropPad: 1,
176
- indent: 2,
177
- alphaThreshold: 16,
178
- });
337
+ return bundle(frame, FRAME, FRAME);
179
338
  }
180
- return renderRgbaToAnsi(data, width, height, {
181
- cropToContent: true,
182
- cropPad: 1,
183
- indent: 2,
184
- alphaThreshold: 16,
185
- });
339
+ return bundle(data, width, height);
186
340
  } catch {
187
341
  /* fall through to image fallback */
188
342
  }
@@ -193,9 +347,12 @@ async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
193
347
  // is the idle frame. Both PNG and WebP are valid in the lexicon (the
194
348
  // hatch-pet skill emits WebP, the dropzone preserves PNG when the
195
349
  // user drops a PNG sheet) so we pick the right decoder by mimeType.
196
- // We crop the idle cell first thing and box-downscale to ~32 wide,
197
- // so the inline render is similar in height to a pipoya sprite
198
- // (~17 lines).
350
+ // We crop the idle cell first thing. For the ANSI render we
351
+ // box-downscale to ~32 wide so the inline render is similar in
352
+ // height to a pipoya sprite (~17 lines). For the inline-graphics
353
+ // PNG we keep the native 192×208 resolution — Kitty / iTerm2 will
354
+ // scale it down at display time, which preserves more detail than
355
+ // pre-downscaling here.
199
356
  if (spriteKind === "codexPet" && petSheetLink) {
200
357
  try {
201
358
  const buf = await fetchBlob(sim.did, petSheetLink);
@@ -208,12 +365,46 @@ async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
208
365
  const targetW = 32;
209
366
  const targetH = Math.round((CELL_H / CELL_W) * targetW); // ~35
210
367
  const scaled = boxDownscaleRgba(cell, CELL_W, CELL_H, targetW, targetH);
211
- return renderRgbaToAnsi(scaled.data, scaled.width, scaled.height, {
212
- cropToContent: true,
213
- cropPad: 1,
214
- indent: 2,
215
- alphaThreshold: 16,
216
- });
368
+ const ansi = toAnsi(scaled.data, scaled.width, scaled.height);
369
+ let png: SpriteRender["png"];
370
+ try {
371
+ const pngBytes = encodeRgbaToPng(cell, CELL_W, CELL_H);
372
+ png = { data: pngBytes, widthPx: CELL_W, heightPx: CELL_H };
373
+ } catch {
374
+ png = undefined;
375
+ }
376
+
377
+ // Idle animation frames — same atlas, different cell offsets.
378
+ // Encoded eagerly here so the message renderer can flip
379
+ // between them at ~5 FPS without re-decoding the WebP. Wrapped
380
+ // in its own try/catch — a frame-encoding failure shouldn't
381
+ // disable the static render.
382
+ let frames: SpriteRender["frames"];
383
+ try {
384
+ const framePngs: Buffer[] = [];
385
+ for (const idx of CODEX_PET_IDLE_FRAMES) {
386
+ const cx = (idx % CODEX_PET_COLS) * CELL_W;
387
+ const cy = Math.floor(idx / CODEX_PET_COLS) * CELL_H;
388
+ // Skip frames that fall outside the actual atlas extent.
389
+ if (cx + CELL_W > width || cy + CELL_H > height) continue;
390
+ const frameCell = cropRgba(data, width, height, cx, cy, CELL_W, CELL_H);
391
+ framePngs.push(encodeRgbaToPng(frameCell, CELL_W, CELL_H));
392
+ }
393
+ // Single-frame "animations" are pointless — leave `frames`
394
+ // unset so the renderer takes the static path. Two or more
395
+ // = a real loop.
396
+ if (framePngs.length >= 2) {
397
+ frames = {
398
+ pngs: framePngs,
399
+ widthPx: CELL_W,
400
+ heightPx: CELL_H,
401
+ fps: CODEX_PET_IDLE_FPS,
402
+ };
403
+ }
404
+ } catch {
405
+ frames = undefined;
406
+ }
407
+ return { ansi, png, frames };
217
408
  }
218
409
  } catch {
219
410
  /* fall through to image fallback */
@@ -252,12 +443,7 @@ async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
252
443
  Math.max(1, Math.round(native.height * k)),
253
444
  );
254
445
  }
255
- return renderRgbaToAnsi(native.data, native.width, native.height, {
256
- cropToContent: true,
257
- cropPad: 1,
258
- indent: 2,
259
- alphaThreshold: 16,
260
- });
446
+ return bundle(native.data, native.width, native.height);
261
447
  } catch {
262
448
  /* fall through */
263
449
  }
@@ -284,10 +470,10 @@ async function loadSimByName(query: string): Promise<{
284
470
 
285
471
  async function hydrateLoadedSim(match: SimMatch): Promise<LoadedSim> {
286
472
  // Fetch agents (constitution), style, sprite ANSI + handle in parallel.
287
- const [agents, style, spriteAnsi, handle] = await Promise.all([
473
+ const [agents, style, sprite, handle] = await Promise.all([
288
474
  fetchAgentsForSim(match.uri).catch(() => null) as Promise<AgentsRecord | null>,
289
475
  fetchStyleForSim(match.uri).catch(() => null) as Promise<StyleRecord | null>,
290
- renderSpriteAnsi(match).catch(() => null),
476
+ renderSprite(match).catch(() => null),
291
477
  resolveHandle(match.did).catch(() => null),
292
478
  ]);
293
479
 
@@ -297,14 +483,36 @@ async function hydrateLoadedSim(match: SimMatch): Promise<LoadedSim> {
297
483
  rkey: match.rkey,
298
484
  name: match.sim.name,
299
485
  handle,
300
- spriteAnsi: spriteAnsi ?? undefined,
486
+ spriteAnsi: sprite?.ansi,
487
+ spritePng: sprite?.png
488
+ ? {
489
+ base64: sprite.png.data.toString("base64"),
490
+ widthPx: sprite.png.widthPx,
491
+ heightPx: sprite.png.heightPx,
492
+ }
493
+ : undefined,
494
+ spriteFrames: sprite?.frames
495
+ ? {
496
+ pngBase64: sprite.frames.pngs.map((b) => b.toString("base64")),
497
+ fps: sprite.frames.fps,
498
+ widthPx: sprite.frames.widthPx,
499
+ heightPx: sprite.frames.heightPx,
500
+ }
501
+ : undefined,
301
502
  shortDescription: agents?.shortDescription,
302
503
  description: agents?.description,
303
504
  style: style?.description,
304
505
  };
305
506
  }
306
507
 
307
- function formatSimSummary(
508
+ /**
509
+ * Build the bio text block that appears alongside (or below) the
510
+ * sprite in the loaded-sim message: name + handle + AT-URI +
511
+ * shortDescription. Indented two spaces so it lines up with the ANSI
512
+ * sprite render. Used both as a standalone block (Image+Text
513
+ * variant) and as the trailing portion of `formatSimSummary`.
514
+ */
515
+ function formatSimBio(
308
516
  sim: LoadedSim,
309
517
  theme?: ExtensionContext["ui"]["theme"],
310
518
  ): string {
@@ -313,10 +521,6 @@ function formatSimSummary(
313
521
  ? (s: string) => theme.fg("accent", s)
314
522
  : (s: string) => s;
315
523
  const lines: string[] = [];
316
- if (sim.spriteAnsi) {
317
- lines.push(sim.spriteAnsi);
318
- lines.push("");
319
- }
320
524
  lines.push(` 🐾 ${accent(sim.name)}${sim.handle ? dim(` @${sim.handle}`) : ""} loaded—pi is now in character.`);
321
525
  lines.push(dim(` ${sim.uri}`));
322
526
  if (sim.shortDescription) {
@@ -326,6 +530,19 @@ function formatSimSummary(
326
530
  return lines.join("\n");
327
531
  }
328
532
 
533
+ function formatSimSummary(
534
+ sim: LoadedSim,
535
+ theme?: ExtensionContext["ui"]["theme"],
536
+ ): string {
537
+ const lines: string[] = [];
538
+ if (sim.spriteAnsi) {
539
+ lines.push(sim.spriteAnsi);
540
+ lines.push("");
541
+ }
542
+ lines.push(formatSimBio(sim, theme));
543
+ return lines.join("\n");
544
+ }
545
+
329
546
  // The OpenTUI standalone animated viewer used to live here. It now ships
330
547
  // alongside this file as `viewer.ts` for anyone who wants the full-window
331
548
  // experience — run it manually with:
@@ -411,11 +628,90 @@ export default async function simocracy(pi: ExtensionAPI) {
411
628
 
412
629
  // -------------------------------------------------------------------------
413
630
  // Custom message renderer — shows the sprite + bio inline in the chat.
631
+ //
632
+ // Two render paths, picked per-call:
633
+ //
634
+ // 1. Inline graphics (preferred when supported). Terminals that
635
+ // advertise the Kitty graphics protocol (Kitty, Ghostty, WezTerm,
636
+ // Konsole) or iTerm2's inline-image protocol get a real PNG of
637
+ // the sprite via pi-tui's `Image` component, stacked above the
638
+ // bio text in a `Box`. Pixels are crisp, scaling is the
639
+ // terminal's job.
640
+ //
641
+ // 2. ANSI half-blocks (universal fallback). Everything else —
642
+ // Apple Terminal, tmux without passthrough, plain SSH, dumb
643
+ // pipes — falls back to the existing `▀`/`▄` half-block art.
644
+ //
645
+ // Override with `SIMOCRACY_INLINE_GRAPHICS=ansi` to force the
646
+ // half-block path even when the terminal supports inline graphics
647
+ // (handy for screenshots, demo recordings, or terminals that
648
+ // *advertise* support but render glitchily).
414
649
  // -------------------------------------------------------------------------
415
- pi.registerMessageRenderer<{ body: string }>("simocracy_sim_loaded", (message) => {
650
+ const inlineGraphicsMode =
651
+ (process.env.SIMOCRACY_INLINE_GRAPHICS ?? "auto").toLowerCase();
652
+ pi.registerMessageRenderer<SimLoadedDetails>("simocracy_sim_loaded", (message, _opts, theme) => {
653
+ const details = (message.details as SimLoadedDetails | undefined) ?? {};
416
654
  const body =
417
- (message.details as { body?: string } | undefined)?.body ??
418
- (typeof message.content === "string" ? message.content : "");
655
+ details.body ?? (typeof message.content === "string" ? message.content : "");
656
+
657
+ // Decide whether to use the inline-graphics path. Auto-detect by
658
+ // default; honour the env-var override either direction.
659
+ const caps = getCapabilities();
660
+ const wantGraphics =
661
+ inlineGraphicsMode === "auto"
662
+ ? caps.images !== null
663
+ : inlineGraphicsMode === "kitty" || inlineGraphicsMode === "iterm2";
664
+ if (wantGraphics && details.spritePngBase64 && details.bioText) {
665
+ // Pi-tui's Image needs a fallback colour for terminals that
666
+ // claim image support but later fail to render — we use the
667
+ // theme's `dim` so the placeholder text is unobtrusive.
668
+ const imageTheme: ImageTheme = {
669
+ fallbackColor: theme.fg("dim", "") ? (s: string) => theme.fg("dim", s) : (s: string) => s,
670
+ };
671
+ // Animation handoff: if there's an active animation AND it's
672
+ // for this exact message (matching animationKey), pull the
673
+ // current frame's PNG + the stable Kitty image ID. The image
674
+ // ID makes successive frame transmissions replace each other
675
+ // in place rather than stacking. For everything else (older
676
+ // loaded-sim messages, sims without animation frames) we use
677
+ // the static idle PNG with no image ID, so they freeze
678
+ // gracefully on the first frame.
679
+ let pngBase64 = details.spritePngBase64;
680
+ let imageOptions: { maxWidthCells: number; imageId?: number } = {
681
+ maxWidthCells: spriteWidthCells,
682
+ };
683
+ let imageDims = {
684
+ widthPx: details.spritePngWidth ?? 0,
685
+ heightPx: details.spritePngHeight ?? 0,
686
+ };
687
+ if (
688
+ currentAnimation &&
689
+ details.animationKey &&
690
+ currentAnimation.key === details.animationKey
691
+ ) {
692
+ pngBase64 = currentAnimation.frames[currentAnimation.currentFrame] ?? pngBase64;
693
+ imageOptions = {
694
+ maxWidthCells: spriteWidthCells,
695
+ imageId: currentAnimation.imageId,
696
+ };
697
+ imageDims = { widthPx: currentAnimation.widthPx, heightPx: currentAnimation.heightPx };
698
+ }
699
+ // Source PNG is at full native resolution (192×208 for codex
700
+ // pets, 32×32 for pipoya). The terminal scales it down to
701
+ // `spriteWidthCells` cells wide on display — we never
702
+ // pre-downsample on our side, so quality is preserved.
703
+ const image = new Image(
704
+ pngBase64,
705
+ "image/png",
706
+ imageTheme,
707
+ imageOptions,
708
+ imageDims,
709
+ );
710
+ const box = new Box(0, 0);
711
+ box.addChild(image);
712
+ box.addChild(new Text(details.bioText, 0, 0));
713
+ return box;
714
+ }
419
715
  return new Text(body, 0, 0);
420
716
  });
421
717
 
@@ -488,6 +784,7 @@ export default async function simocracy(pi: ExtensionAPI) {
488
784
  const name = loadedSim.name;
489
785
  loadedSim = null;
490
786
  justUnloaded = name;
787
+ stopCurrentAnimation();
491
788
  ctx.ui.setStatus("simocracy", undefined);
492
789
  ctx.ui.setWidget("simocracy", undefined);
493
790
  ctx.ui.notify(`Unloaded ${name}. Pi will break character on the next reply.`, "info");
@@ -577,6 +874,7 @@ export default async function simocracy(pi: ExtensionAPI) {
577
874
  const name = loadedSim.name;
578
875
  loadedSim = null;
579
876
  justUnloaded = name;
877
+ stopCurrentAnimation();
580
878
  if (ctx.hasUI) {
581
879
  ctx.ui.setStatus("simocracy", undefined);
582
880
  ctx.ui.setWidget("simocracy", undefined);
@@ -1053,6 +1351,41 @@ async function tryLoadFromQuery(query: string): Promise<LoadedSim | null> {
1053
1351
  return await hydrateLoadedSim(result.matches[0]);
1054
1352
  }
1055
1353
 
1354
+ /**
1355
+ * Shape of `details` on the `simocracy_sim_loaded` custom message.
1356
+ * The renderer reads this to choose between the inline-graphics and
1357
+ * ANSI-half-block render paths; both fields are best-effort — the
1358
+ * renderer falls back to the combined `body` string if anything is
1359
+ * missing.
1360
+ */
1361
+ interface SimLoadedDetails {
1362
+ uri?: string;
1363
+ did?: string;
1364
+ rkey?: string;
1365
+ name?: string;
1366
+ /** Combined ANSI sprite + bio, used by the half-block fallback path
1367
+ * and as the textual log content. */
1368
+ body?: string;
1369
+ /** Bio text only (no sprite), used alongside the Image component
1370
+ * on the inline-graphics path. */
1371
+ bioText?: string;
1372
+ /** base64-encoded PNG of the sprite cell. Triggers the inline-graphics
1373
+ * path when set + the terminal advertises image support. */
1374
+ spritePngBase64?: string;
1375
+ /** Native PNG width in pixels (aspect ratio for Image scaling). */
1376
+ spritePngWidth?: number;
1377
+ /** Native PNG height in pixels. */
1378
+ spritePngHeight?: number;
1379
+ /**
1380
+ * Identity tag for the active animation, if any. The message renderer
1381
+ * compares against `currentAnimation.key` to decide whether to swap
1382
+ * in the current animation frame or freeze on the static idle PNG.
1383
+ * Stable across re-renders of the same message; differs across
1384
+ * separate sim loads.
1385
+ */
1386
+ animationKey?: string;
1387
+ }
1388
+
1056
1389
  async function postSimToChat(
1057
1390
  pi: ExtensionAPI,
1058
1391
  ctx: ExtensionContext,
@@ -1060,21 +1393,54 @@ async function postSimToChat(
1060
1393
  _reload: boolean,
1061
1394
  ) {
1062
1395
  ctx.ui.setStatus("simocracy", `🐾 ${sim.name}`);
1063
- const headerLines = [`Simocracy: ${sim.name}${sim.handle ? ` (@${sim.handle})` : ""}`];
1064
- ctx.ui.setWidget("simocracy", headerLines, { placement: "aboveEditor" });
1396
+ // Use the factory form of setWidget so we can capture pi-tui's TUI
1397
+ // handle. We need it to call `requestRender()` from the animation
1398
+ // setInterval (the message renderer doesn't get a TUI reference).
1399
+ // The factory itself returns a tiny static Text widget — the TUI
1400
+ // capture is the actual purpose.
1401
+ const headerText = `Simocracy: ${sim.name}${sim.handle ? ` (@${sim.handle})` : ""}`;
1402
+ ctx.ui.setWidget(
1403
+ "simocracy",
1404
+ (tui) => {
1405
+ capturedTui = tui;
1406
+ return new Text(headerText, 0, 0);
1407
+ },
1408
+ { placement: "aboveEditor" },
1409
+ );
1065
1410
  const body = formatSimSummary(sim, ctx.ui.theme);
1411
+ const bioText = formatSimBio(sim, ctx.ui.theme);
1412
+ // Unique key per load so the renderer can tell which message owns
1413
+ // the active animation. AT-URI + timestamp protects against
1414
+ // re-loading the same sim twice (each load starts its own loop).
1415
+ const animationKey = `${sim.uri}#${Date.now()}`;
1416
+ const details: SimLoadedDetails = {
1417
+ uri: sim.uri,
1418
+ did: sim.did,
1419
+ rkey: sim.rkey,
1420
+ name: sim.name,
1421
+ body,
1422
+ bioText,
1423
+ animationKey,
1424
+ };
1425
+ if (sim.spritePng) {
1426
+ details.spritePngBase64 = sim.spritePng.base64;
1427
+ details.spritePngWidth = sim.spritePng.widthPx;
1428
+ details.spritePngHeight = sim.spritePng.heightPx;
1429
+ }
1066
1430
  pi.sendMessage({
1067
1431
  customType: "simocracy_sim_loaded",
1068
1432
  content: stripAnsiForLog(body),
1069
1433
  display: true,
1070
- details: {
1071
- uri: sim.uri,
1072
- did: sim.did,
1073
- rkey: sim.rkey,
1074
- name: sim.name,
1075
- body,
1076
- },
1434
+ details,
1077
1435
  });
1436
+ // Kick off (or replace) the animation loop. Only one loop runs at a
1437
+ // time — the last loaded sim animates, earlier messages freeze on
1438
+ // their idle frame.
1439
+ if (sim.spriteFrames) {
1440
+ startAnimationFor(animationKey, sim.spriteFrames);
1441
+ } else {
1442
+ stopCurrentAnimation();
1443
+ }
1078
1444
  }
1079
1445
 
1080
1446
  /** Strip ANSI escapes for the textual log copy (the renderer uses details.body). */
package/src/persona.ts CHANGED
@@ -18,6 +18,43 @@ export interface LoadedSim {
18
18
  style?: string;
19
19
  /** Pre-rendered colored ANSI art of the sim's sprite. */
20
20
  spriteAnsi?: string;
21
+ /**
22
+ * Pre-encoded PNG of the sim's sprite (idle frame), for inline
23
+ * terminal graphics protocols (Kitty, iTerm2). Present when sprite
24
+ * rendering succeeded and the source could be encoded; the renderer
25
+ * uses this when the host terminal advertises image support and
26
+ * falls back to `spriteAnsi` otherwise.
27
+ */
28
+ spritePng?: {
29
+ /** base64-encoded PNG bytes (no `data:` prefix). */
30
+ base64: string;
31
+ /** Native PNG width in pixels (used for aspect ratio). */
32
+ widthPx: number;
33
+ /** Native PNG height in pixels. */
34
+ heightPx: number;
35
+ };
36
+ /**
37
+ * Pre-encoded PNG frames of the sim's idle animation, for terminals
38
+ * that support inline graphics. When present **and** the renderer's
39
+ * animation timer is active for this sim, the message renderer
40
+ * cycles through these instead of repeating `spritePng` — producing
41
+ * a gentle in-chat idle loop using the same Kitty-protocol
42
+ * in-place image swap that pi-tui's spinner uses to animate.
43
+ *
44
+ * Currently populated only for `codexPet` sims (their atlas defines
45
+ * an explicit 6-frame idle row); pipoya and image-fallback sims
46
+ * stay static.
47
+ */
48
+ spriteFrames?: {
49
+ /** base64-encoded PNG bytes per frame, in display order. */
50
+ pngBase64: string[];
51
+ /** Display rate in frames-per-second. */
52
+ fps: number;
53
+ /** Native PNG width in pixels (uniform across all frames). */
54
+ widthPx: number;
55
+ /** Native PNG height in pixels. */
56
+ heightPx: number;
57
+ };
21
58
  }
22
59
 
23
60
  /**
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Encode an RGBA pixel buffer to a PNG (`Buffer`) for inline terminal
3
+ * graphics protocols (Kitty, iTerm2). Both protocols accept base64-PNG
4
+ * payloads — Kitty supports it via `f=100`, iTerm2 via `inline=1`.
5
+ *
6
+ * Sized to be tiny: the only operation here is wrapping `pngjs`'s
7
+ * synchronous packer in a typed helper that matches the
8
+ * decoder/cropper interfaces in `png-to-ansi.ts`. We never need
9
+ * streaming or async — the largest image we encode is a 192×208 codex
10
+ * pet idle cell (~30 KB PNG).
11
+ */
12
+
13
+ import { PNG } from "pngjs";
14
+
15
+ /**
16
+ * Encode a flat RGBA8 buffer to PNG. `data.length` must equal
17
+ * `width * height * 4`. Returns a fresh `Buffer` containing the PNG
18
+ * bytes (signature `89 50 4e 47 …`).
19
+ */
20
+ export function encodeRgbaToPng(data: Buffer, width: number, height: number): Buffer {
21
+ if (data.length !== width * height * 4) {
22
+ throw new Error(
23
+ `encodeRgbaToPng: expected ${width * height * 4} bytes for ${width}×${height}, got ${data.length}`,
24
+ );
25
+ }
26
+ const png = new PNG({ width, height });
27
+ data.copy(png.data);
28
+ return PNG.sync.write(png);
29
+ }