pi-simocracy 0.5.0 → 0.5.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-simocracy",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
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)",
@@ -0,0 +1,188 @@
1
+ /**
2
+ * AnimatedImage — a pi-tui `Component` that cycles through PNG frames in
3
+ * place using the Kitty graphics protocol's image-by-ID swap.
4
+ *
5
+ * Why not just reuse pi-tui's `Image`?
6
+ *
7
+ * `Image` caches its render output and `Image.base64Data` is declared
8
+ * `private`, so we can't mutate the source bytes between renders to
9
+ * swap frames. Pi-tui's own animated component (`Loader`, the spinner)
10
+ * works because it extends `Text` and calls `setText()` to swap its
11
+ * payload — there's no equivalent setter on `Image`. Rather than reach
12
+ * into pi-tui internals or upstream a patch, we ship a tiny standalone
13
+ * `Component` that owns its frames + timer and emits the appropriate
14
+ * Kitty / iTerm2 escape on every `render()` call.
15
+ *
16
+ * Behaviour:
17
+ *
18
+ * - `render(width)` always emits the *current* frame's escape sequence
19
+ * (no across-frame cache). Same line-shape trick as pi-tui's `Image`:
20
+ * return `rows` lines, the last carrying the cursor-up + image
21
+ * transmission so the bitmap occupies all `rows` of vertical space.
22
+ * - The `setInterval` ticks `currentFrame` and calls
23
+ * `tui.requestRender()` — pi-tui re-runs the diff renderer, our
24
+ * `render()` produces a new escape, the terminal swaps the bitmap
25
+ * in place by image ID. Same pattern pi-tui's spinner uses for the
26
+ * text spinner; typing keeps working because input handling is on
27
+ * an independent code path.
28
+ * - The interval is `unref()`ed so it doesn't keep the node process
29
+ * alive on shutdown.
30
+ * - `dispose()` clears the interval. Call it when the sim unloads or
31
+ * when a new sim takes over the active-animation slot.
32
+ * - In terminals without `kitty` / `iterm2` capability the component
33
+ * emits a single line of fallback text instead — but in practice
34
+ * the message renderer never instantiates `AnimatedImage` on those
35
+ * terminals; it returns the ANSI half-block `Text` path.
36
+ */
37
+
38
+ import {
39
+ type Component,
40
+ type ImageDimensions,
41
+ type TUI,
42
+ encodeKitty,
43
+ encodeITerm2,
44
+ getCapabilities,
45
+ calculateImageRows,
46
+ getCellDimensions,
47
+ } from "@mariozechner/pi-tui";
48
+
49
+ export interface AnimatedImageOptions {
50
+ /** Pre-encoded base64 PNG bytes per frame, in playback order.
51
+ * At least 2 frames; otherwise just use a static `Image` instead. */
52
+ frames: string[];
53
+ /** Native frame dimensions in pixels — used both for aspect ratio
54
+ * during display-cell calculation and for the `c=,r=` parameters
55
+ * on the Kitty escape. Uniform across all frames. */
56
+ dimensions: ImageDimensions;
57
+ /** Display target in terminal cells. Aspect-preserved height is
58
+ * computed from `dimensions`. */
59
+ maxWidthCells: number;
60
+ /** Stable Kitty image ID — every frame transmission carries this so
61
+ * the terminal swaps the bitmap in place rather than stacking
62
+ * copies. Allocate once per AnimatedImage with `allocateImageId()`. */
63
+ imageId: number;
64
+ /** Display rate. Internally clamped to ≤ 16 fps to keep escape
65
+ * bandwidth reasonable. */
66
+ fps: number;
67
+ /** TUI handle. Required so we can call `requestRender()` after each
68
+ * frame tick. `setWidget`'s factory form is the canonical way to
69
+ * obtain this from inside an extension. */
70
+ tui: TUI;
71
+ /** Fallback text printed when `getCapabilities().images` is null
72
+ * (e.g. user forced ANSI mid-render). Defaults to "[animation]". */
73
+ fallbackText?: string;
74
+ }
75
+
76
+ export class AnimatedImage implements Component {
77
+ private readonly frames: string[];
78
+ private readonly dimensions: ImageDimensions;
79
+ private readonly maxWidthCells: number;
80
+ private readonly imageId: number;
81
+ private readonly tui: TUI;
82
+ private readonly fallbackText: string;
83
+
84
+ /** Index into `frames`. Mutates on each tick. */
85
+ private currentFrame = 0;
86
+ /** Active animation timer, or null when stopped / disposed. */
87
+ private intervalId: ReturnType<typeof setInterval> | null = null;
88
+ /** True after `dispose()` — render() then short-circuits to a single
89
+ * empty line so the diff renderer cleanly removes the image. */
90
+ private disposed = false;
91
+
92
+ constructor(opts: AnimatedImageOptions) {
93
+ if (opts.frames.length < 2) {
94
+ throw new Error(
95
+ `AnimatedImage requires at least 2 frames (got ${opts.frames.length}); use pi-tui's Image for a single frame.`,
96
+ );
97
+ }
98
+ this.frames = opts.frames;
99
+ this.dimensions = opts.dimensions;
100
+ this.maxWidthCells = opts.maxWidthCells;
101
+ this.imageId = opts.imageId;
102
+ this.tui = opts.tui;
103
+ this.fallbackText = opts.fallbackText ?? "[animation]";
104
+
105
+ // Clamp fps to ≤ 16 — each tick re-transmits the full PNG, ~30–50
106
+ // KB per codex pet frame. 16 fps = ~800 KB/s, plenty of headroom.
107
+ const fps = Math.min(Math.max(opts.fps, 1), 16);
108
+ const intervalMs = Math.round(1000 / fps);
109
+ this.intervalId = setInterval(() => {
110
+ if (this.disposed) return;
111
+ this.currentFrame = (this.currentFrame + 1) % this.frames.length;
112
+ // Pi-tui's render tick will call our `render()` again, which
113
+ // emits the new frame's escape sequence. The terminal swaps the
114
+ // bitmap in place by image ID — no flicker, no scrollback churn.
115
+ this.tui.requestRender();
116
+ }, intervalMs);
117
+ // Don't keep the node process alive solely for the animation
118
+ // timer — let SIGINT and process exit work cleanly.
119
+ this.intervalId.unref?.();
120
+ }
121
+
122
+ /** Stop the timer. Idempotent. After dispose, `render()` returns an
123
+ * empty single line so pi-tui's diff renderer cleanly clears the
124
+ * cells the animation was occupying. */
125
+ dispose(): void {
126
+ if (this.intervalId !== null) {
127
+ clearInterval(this.intervalId);
128
+ this.intervalId = null;
129
+ }
130
+ this.disposed = true;
131
+ }
132
+
133
+ /**
134
+ * Emit the current frame as a Kitty / iTerm2 inline image escape.
135
+ * Same line-shape contract pi-tui's `Image` uses: `rows` lines, the
136
+ * last one containing `\x1b[<rows-1>A` followed by the actual image
137
+ * transmission. The first `rows-1` lines are empty; pi-tui's diff
138
+ * renderer treats them as occupied vertical space.
139
+ */
140
+ render(width: number): string[] {
141
+ if (this.disposed) return [""];
142
+ const caps = getCapabilities();
143
+ if (!caps.images) {
144
+ // Should never happen in practice — the message renderer only
145
+ // builds an AnimatedImage when caps.images is truthy. Soft
146
+ // fallback so the layout doesn't collapse if caps change at
147
+ // runtime (e.g. user resizes into iTerm2 from Kitty mid-session).
148
+ return [this.fallbackText];
149
+ }
150
+ const maxWidth = Math.min(width - 2, this.maxWidthCells);
151
+ const rows = calculateImageRows(this.dimensions, maxWidth, getCellDimensions());
152
+ const base64 = this.frames[this.currentFrame] ?? this.frames[0];
153
+
154
+ let sequence: string;
155
+ if (caps.images === "kitty") {
156
+ // imageId is required for in-place swap — that's the whole point
157
+ // of AnimatedImage. encodeKitty handles the chunked transmission
158
+ // for payloads above the protocol's 4096-byte chunk limit.
159
+ sequence = encodeKitty(base64, {
160
+ columns: maxWidth,
161
+ rows,
162
+ imageId: this.imageId,
163
+ });
164
+ } else {
165
+ // iTerm2: no image-ID swap, but it draws the new image at the
166
+ // current cursor; combined with the cursor-up trick below, the
167
+ // visual effect is "in place" enough for our needs.
168
+ sequence = encodeITerm2(base64, {
169
+ width: maxWidth,
170
+ height: "auto",
171
+ preserveAspectRatio: true,
172
+ });
173
+ }
174
+
175
+ const lines: string[] = [];
176
+ for (let i = 0; i < rows - 1; i++) lines.push("");
177
+ const moveUp = rows > 1 ? `\x1b[${rows - 1}A` : "";
178
+ lines.push(moveUp + sequence);
179
+ return lines;
180
+ }
181
+
182
+ /** No internal cache to clear — `render()` always recomputes from
183
+ * `currentFrame`. Provided for `Component` interface conformance and
184
+ * in case pi-tui ever calls invalidate() on the parent tree. */
185
+ invalidate(): void {
186
+ /* intentionally empty */
187
+ }
188
+ }
package/src/index.ts CHANGED
@@ -69,6 +69,7 @@ import {
69
69
  type ImageTheme,
70
70
  type TUI,
71
71
  } from "@mariozechner/pi-tui";
72
+ import { AnimatedImage } from "./animated-image.ts";
72
73
  import { Type } from "typebox";
73
74
 
74
75
  import {
@@ -144,9 +145,10 @@ let justUnloaded: string | null = null;
144
145
  let capturedTui: TUI | null = null;
145
146
 
146
147
  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. */
148
+ /** Identifies which loaded-sim message owns this animation. The
149
+ * renderer compares against `details.animationKey` to decide
150
+ * whether to mount the live `AnimatedImage` for this message or a
151
+ * static idle frame. */
150
152
  key: string;
151
153
  /** Stable Kitty image ID so frame transmissions replace the previous one
152
154
  * instead of stacking. Allocated once per sim load. */
@@ -156,10 +158,17 @@ interface ActiveAnimation {
156
158
  /** Frame width / height in pixels (uniform across frames). */
157
159
  widthPx: number;
158
160
  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;
161
+ /** Display rate. */
162
+ fps: number;
163
+ /**
164
+ * The live animated component. Created lazily inside the message
165
+ * renderer the first time it's asked for this `key` — we need a
166
+ * TUI handle to construct one, and the renderer is the natural
167
+ * place that has access (via `capturedTui`). Disposed when a new
168
+ * sim takes over the active-animation slot or when the sim is
169
+ * unloaded.
170
+ */
171
+ component: AnimatedImage | null;
163
172
  }
164
173
  let currentAnimation: ActiveAnimation | null = null;
165
174
 
@@ -187,15 +196,18 @@ const spriteWidthCells = (() => {
187
196
  })();
188
197
 
189
198
  function stopCurrentAnimation(): void {
190
- if (currentAnimation?.intervalId !== null && currentAnimation?.intervalId !== undefined) {
191
- clearInterval(currentAnimation.intervalId);
199
+ if (currentAnimation?.component) {
200
+ currentAnimation.component.dispose();
192
201
  }
193
202
  currentAnimation = null;
194
203
  }
195
204
 
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.
205
+ function startAnimationFor(
206
+ key: string,
207
+ frames: { pngBase64: string[]; fps: number; widthPx: number; heightPx: number },
208
+ ): void {
209
+ // Always replace any prior animation — only the most recent
210
+ // loaded-sim message animates.
199
211
  stopCurrentAnimation();
200
212
  if (!animationEnabled || frames.pngBase64.length < 2) return;
201
213
  currentAnimation = {
@@ -204,16 +216,12 @@ function startAnimationFor(key: string, frames: { pngBase64: string[]; fps: numb
204
216
  frames: frames.pngBase64,
205
217
  widthPx: frames.widthPx,
206
218
  heightPx: frames.heightPx,
207
- currentFrame: 0,
208
- intervalId: null,
219
+ fps: frames.fps,
220
+ // Lazily instantiated by the message renderer the first time it
221
+ // sees this `key` — we don't have a TUI handle here, only inside
222
+ // the setWidget factory.
223
+ component: null,
209
224
  };
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
225
  }
218
226
 
219
227
  // ---------------------------------------------------------------------------
@@ -668,47 +676,57 @@ export default async function simocracy(pi: ExtensionAPI) {
668
676
  const imageTheme: ImageTheme = {
669
677
  fallbackColor: theme.fg("dim", "") ? (s: string) => theme.fg("dim", s) : (s: string) => s,
670
678
  };
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 (
679
+ const box = new Box(0, 0);
680
+
681
+ // If this message owns the active animation slot, mount a live
682
+ // `AnimatedImage` (cycles frames, owns its own setInterval). We
683
+ // cache the component on `currentAnimation.component` so we
684
+ // don't spawn a fresh timer on every re-render of the message
685
+ // (pi-tui calls the renderer again on expand/collapse, theme
686
+ // change, etc.).
687
+ //
688
+ // For every other case older loaded-sim messages whose key
689
+ // doesn't match, sims without animation frames, or animation
690
+ // disabled via env — we mount a static `Image` of the idle
691
+ // frame, which freezes gracefully.
692
+ const isActiveAnimation =
688
693
  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 };
694
+ capturedTui !== null &&
695
+ details.animationKey !== undefined &&
696
+ currentAnimation.key === details.animationKey;
697
+ if (isActiveAnimation) {
698
+ if (!currentAnimation!.component) {
699
+ currentAnimation!.component = new AnimatedImage({
700
+ frames: currentAnimation!.frames,
701
+ dimensions: {
702
+ widthPx: currentAnimation!.widthPx,
703
+ heightPx: currentAnimation!.heightPx,
704
+ },
705
+ maxWidthCells: spriteWidthCells,
706
+ imageId: currentAnimation!.imageId,
707
+ fps: currentAnimation!.fps,
708
+ tui: capturedTui!,
709
+ });
710
+ }
711
+ box.addChild(currentAnimation!.component);
712
+ } else {
713
+ // Source PNG is at full native resolution (192×208 for codex
714
+ // pets, 32×32 for pipoya). The terminal scales it down to
715
+ // `spriteWidthCells` cells wide on display — we never
716
+ // pre-downsample on our side, so quality is preserved.
717
+ box.addChild(
718
+ new Image(
719
+ details.spritePngBase64,
720
+ "image/png",
721
+ imageTheme,
722
+ { maxWidthCells: spriteWidthCells },
723
+ {
724
+ widthPx: details.spritePngWidth ?? 0,
725
+ heightPx: details.spritePngHeight ?? 0,
726
+ },
727
+ ),
728
+ );
698
729
  }
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
730
  box.addChild(new Text(details.bioText, 0, 0));
713
731
  return box;
714
732
  }