pi-simocracy 0.5.0 → 0.6.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.
@@ -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
+ }