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 +1 -1
- package/src/animated-image.ts +188 -0
- package/src/index.ts +78 -60
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-simocracy",
|
|
3
|
-
"version": "0.5.
|
|
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
|
|
148
|
-
* compares against `details.animationKey` to decide
|
|
149
|
-
*
|
|
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
|
-
/**
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
|
|
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?.
|
|
191
|
-
|
|
199
|
+
if (currentAnimation?.component) {
|
|
200
|
+
currentAnimation.component.dispose();
|
|
192
201
|
}
|
|
193
202
|
currentAnimation = null;
|
|
194
203
|
}
|
|
195
204
|
|
|
196
|
-
function startAnimationFor(
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
}
|