pi-simocracy 0.4.1 → 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/README.md +26 -6
- package/package.json +1 -1
- package/src/animated-image.ts +188 -0
- package/src/index.ts +433 -49
- package/src/persona.ts +37 -0
- package/src/png-encode.ts +29 -0
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
|
|
124
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
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.
|
|
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
|
@@ -60,7 +60,16 @@ import type {
|
|
|
60
60
|
ExtensionContext,
|
|
61
61
|
ExtensionCommandContext,
|
|
62
62
|
} from "@mariozechner/pi-coding-agent";
|
|
63
|
-
import {
|
|
63
|
+
import {
|
|
64
|
+
Box,
|
|
65
|
+
Image,
|
|
66
|
+
Text,
|
|
67
|
+
allocateImageId,
|
|
68
|
+
getCapabilities,
|
|
69
|
+
type ImageTheme,
|
|
70
|
+
type TUI,
|
|
71
|
+
} from "@mariozechner/pi-tui";
|
|
72
|
+
import { AnimatedImage } from "./animated-image.ts";
|
|
64
73
|
import { Type } from "typebox";
|
|
65
74
|
|
|
66
75
|
import {
|
|
@@ -83,6 +92,7 @@ import {
|
|
|
83
92
|
downscaleRgbaNearest,
|
|
84
93
|
boxDownscaleRgba,
|
|
85
94
|
} from "./png-to-ansi.ts";
|
|
95
|
+
import { encodeRgbaToPng } from "./png-encode.ts";
|
|
86
96
|
import { decodeWebp } from "./webp-to-rgba.ts";
|
|
87
97
|
import { openRouterComplete, type ChatMessage } from "./openrouter.ts";
|
|
88
98
|
import { buildSimPrompt, type LoadedSim } from "./persona.ts";
|
|
@@ -113,6 +123,107 @@ let loadedSim: LoadedSim | null = null;
|
|
|
113
123
|
*/
|
|
114
124
|
let justUnloaded: string | null = null;
|
|
115
125
|
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Animation state
|
|
128
|
+
//
|
|
129
|
+
// We animate the most recently loaded codex pet's idle row inline in chat,
|
|
130
|
+
// using the same pattern pi-tui's spinner uses: a setInterval ticks a frame
|
|
131
|
+
// counter and calls `tui.requestRender()`, which makes pi-tui re-run the
|
|
132
|
+
// message renderer. Each render returns a new `Image` keyed by a stable
|
|
133
|
+
// Kitty image ID, so the terminal swaps the displayed frame in place.
|
|
134
|
+
//
|
|
135
|
+
// Typing keeps working during animation — input handling and rendering are
|
|
136
|
+
// independent code paths in pi-tui (verified by inspection of `Loader`).
|
|
137
|
+
//
|
|
138
|
+
// Only ONE sim animates at a time. When a new sim is loaded, the previous
|
|
139
|
+
// timer stops and the previous message freezes on its idle frame.
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
/** Captured pi-tui handle. Set by the `simocracy` widget factory the first
|
|
143
|
+
* time a sim is loaded; reused for every subsequent animation tick. Lazy
|
|
144
|
+
* because the TUI doesn't exist when the extension first imports. */
|
|
145
|
+
let capturedTui: TUI | null = null;
|
|
146
|
+
|
|
147
|
+
interface ActiveAnimation {
|
|
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. */
|
|
152
|
+
key: string;
|
|
153
|
+
/** Stable Kitty image ID so frame transmissions replace the previous one
|
|
154
|
+
* instead of stacking. Allocated once per sim load. */
|
|
155
|
+
imageId: number;
|
|
156
|
+
/** Pre-encoded base64 PNGs in playback order. */
|
|
157
|
+
frames: string[];
|
|
158
|
+
/** Frame width / height in pixels (uniform across frames). */
|
|
159
|
+
widthPx: number;
|
|
160
|
+
heightPx: number;
|
|
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;
|
|
172
|
+
}
|
|
173
|
+
let currentAnimation: ActiveAnimation | null = null;
|
|
174
|
+
|
|
175
|
+
/** Default-on; set `SIMOCRACY_ANIMATION=off` to freeze on idle frame 0. */
|
|
176
|
+
const animationEnabled =
|
|
177
|
+
(process.env.SIMOCRACY_ANIMATION ?? "on").toLowerCase() !== "off";
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Width of the inline sprite render in terminal cells. The source PNG
|
|
181
|
+
* is always transmitted at native resolution (192×208 for codex pets,
|
|
182
|
+
* 32×32 for pipoya — see `renderSprite`); this number controls only
|
|
183
|
+
* how many cells the terminal uses to display it. Aspect ratio is
|
|
184
|
+
* preserved by pi-tui's `calculateImageRows`. Override with the
|
|
185
|
+
* `SIMOCRACY_SPRITE_WIDTH` env var.
|
|
186
|
+
*
|
|
187
|
+
* 10 cells wide gives a compact ≈6-row inline render that sits
|
|
188
|
+
* comfortably alongside one or two paragraphs of bio text without
|
|
189
|
+
* dominating the chat. Bump it to 20 or 32 if you want the sprite
|
|
190
|
+
* to read at a glance.
|
|
191
|
+
*/
|
|
192
|
+
const spriteWidthCells = (() => {
|
|
193
|
+
const raw = process.env.SIMOCRACY_SPRITE_WIDTH;
|
|
194
|
+
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
|
195
|
+
return Number.isFinite(parsed) && parsed >= 4 && parsed <= 120 ? parsed : 10;
|
|
196
|
+
})();
|
|
197
|
+
|
|
198
|
+
function stopCurrentAnimation(): void {
|
|
199
|
+
if (currentAnimation?.component) {
|
|
200
|
+
currentAnimation.component.dispose();
|
|
201
|
+
}
|
|
202
|
+
currentAnimation = null;
|
|
203
|
+
}
|
|
204
|
+
|
|
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.
|
|
211
|
+
stopCurrentAnimation();
|
|
212
|
+
if (!animationEnabled || frames.pngBase64.length < 2) return;
|
|
213
|
+
currentAnimation = {
|
|
214
|
+
key,
|
|
215
|
+
imageId: allocateImageId(),
|
|
216
|
+
frames: frames.pngBase64,
|
|
217
|
+
widthPx: frames.widthPx,
|
|
218
|
+
heightPx: frames.heightPx,
|
|
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,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
116
227
|
// ---------------------------------------------------------------------------
|
|
117
228
|
// Helpers
|
|
118
229
|
// ---------------------------------------------------------------------------
|
|
@@ -153,14 +264,75 @@ const NON_PIXEL_ART_TARGET_LONG_EDGE = 40;
|
|
|
153
264
|
* box-downscaled to a comparable terminal size.
|
|
154
265
|
*
|
|
155
266
|
* absent — treated as legacy 'pipoya' for back-compat.
|
|
267
|
+
*
|
|
268
|
+
* Returns BOTH the ANSI half-block render (always — it's our universal
|
|
269
|
+
* fallback) AND, when possible, a PNG of the same RGBA cell for inline
|
|
270
|
+
* terminal-graphics protocols (Kitty / iTerm2). The renderer chooses
|
|
271
|
+
* which to display based on the host terminal's capabilities.
|
|
272
|
+
*
|
|
273
|
+
* The PNG is encoded at the *native* resolution we cropped to (32×32
|
|
274
|
+
* for pipoya, 192×208 for codexPet, the post-downscale size for the
|
|
275
|
+
* image fallback). Kitty / iTerm2 do their own scaling to the target
|
|
276
|
+
* cell box, so passing the native pixels gives the terminal the most
|
|
277
|
+
* information to work with — pixel art scales up crisply with
|
|
278
|
+
* nearest-neighbour, codex pet thumbnails scale down cleanly.
|
|
279
|
+
*/
|
|
280
|
+
export interface SpriteRender {
|
|
281
|
+
ansi: string;
|
|
282
|
+
png?: { data: Buffer; widthPx: number; heightPx: number };
|
|
283
|
+
/**
|
|
284
|
+
* Optional animation frames. Each entry in `pngs` is a PNG of one
|
|
285
|
+
* cell. Set for codex pets (idle row of their atlas); absent for
|
|
286
|
+
* everything else (pipoya sprites and image fallbacks render as a
|
|
287
|
+
* single static frame). The renderer plays these in a loop using
|
|
288
|
+
* the Kitty in-place image-swap protocol when animation is enabled.
|
|
289
|
+
*/
|
|
290
|
+
frames?: { pngs: Buffer[]; widthPx: number; heightPx: number; fps: number };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Codex pet atlas constants — keep in sync with the simocracy-v2 hatch-pet
|
|
295
|
+
* skill that produces these sheets. The atlas is 8 cols × 9 rows of
|
|
296
|
+
* 192×208 cells; the idle animation lives on row 0, frames 0–5 (the same
|
|
297
|
+
* default tui-pets ships when a pet.json doesn't override it).
|
|
156
298
|
*/
|
|
157
|
-
|
|
299
|
+
const CODEX_PET_CELL_W = 192;
|
|
300
|
+
const CODEX_PET_CELL_H = 208;
|
|
301
|
+
const CODEX_PET_COLS = 8;
|
|
302
|
+
const CODEX_PET_IDLE_FRAMES = [0, 1, 2, 3, 4, 5];
|
|
303
|
+
const CODEX_PET_IDLE_FPS = 5;
|
|
304
|
+
|
|
305
|
+
async function renderSprite(sim: SimMatch): Promise<SpriteRender | null> {
|
|
158
306
|
const spriteKind = sim.sim.spriteKind ?? "pipoya";
|
|
159
307
|
const spriteLink = blobLink(sim.sim.sprite?.ref);
|
|
160
308
|
const petSheetLink = blobLink(sim.sim.petSheet?.ref);
|
|
161
309
|
const petSheetMime = sim.sim.petSheet?.mimeType;
|
|
162
310
|
const imageLink = blobLink(sim.sim.image?.ref);
|
|
163
311
|
|
|
312
|
+
/** Render an RGBA region to ANSI half-blocks (shared options). */
|
|
313
|
+
const toAnsi = (data: Buffer, width: number, height: number) =>
|
|
314
|
+
renderRgbaToAnsi(data, width, height, {
|
|
315
|
+
cropToContent: true,
|
|
316
|
+
cropPad: 1,
|
|
317
|
+
indent: 2,
|
|
318
|
+
alphaThreshold: 16,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
/** Bundle ANSI + PNG of the same RGBA region. PNG-encode is a tiny
|
|
322
|
+
* cost and is wrapped in try/catch — if encoding ever fails we
|
|
323
|
+
* still return the ANSI render (lossless fallback). */
|
|
324
|
+
const bundle = (data: Buffer, width: number, height: number): SpriteRender => {
|
|
325
|
+
const ansi = toAnsi(data, width, height);
|
|
326
|
+
let png: SpriteRender["png"];
|
|
327
|
+
try {
|
|
328
|
+
const pngBytes = encodeRgbaToPng(data, width, height);
|
|
329
|
+
png = { data: pngBytes, widthPx: width, heightPx: height };
|
|
330
|
+
} catch {
|
|
331
|
+
png = undefined;
|
|
332
|
+
}
|
|
333
|
+
return { ansi, png };
|
|
334
|
+
};
|
|
335
|
+
|
|
164
336
|
// Pipoya 4×4 walk sheet — the legacy/default path.
|
|
165
337
|
if (spriteKind !== "codexPet" && spriteLink) {
|
|
166
338
|
try {
|
|
@@ -170,19 +342,9 @@ async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
|
|
|
170
342
|
if (width >= FRAME && height >= FRAME) {
|
|
171
343
|
// Sheets are 4×4 of 32×32 frames — row 0 col 0 = front-facing walk1.
|
|
172
344
|
const frame = cropRgba(data, width, height, 0, 0, FRAME, FRAME);
|
|
173
|
-
return
|
|
174
|
-
cropToContent: true,
|
|
175
|
-
cropPad: 1,
|
|
176
|
-
indent: 2,
|
|
177
|
-
alphaThreshold: 16,
|
|
178
|
-
});
|
|
345
|
+
return bundle(frame, FRAME, FRAME);
|
|
179
346
|
}
|
|
180
|
-
return
|
|
181
|
-
cropToContent: true,
|
|
182
|
-
cropPad: 1,
|
|
183
|
-
indent: 2,
|
|
184
|
-
alphaThreshold: 16,
|
|
185
|
-
});
|
|
347
|
+
return bundle(data, width, height);
|
|
186
348
|
} catch {
|
|
187
349
|
/* fall through to image fallback */
|
|
188
350
|
}
|
|
@@ -193,9 +355,12 @@ async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
|
|
|
193
355
|
// is the idle frame. Both PNG and WebP are valid in the lexicon (the
|
|
194
356
|
// hatch-pet skill emits WebP, the dropzone preserves PNG when the
|
|
195
357
|
// user drops a PNG sheet) so we pick the right decoder by mimeType.
|
|
196
|
-
// We crop the idle cell first thing
|
|
197
|
-
// so the inline render is similar in
|
|
198
|
-
// (~17 lines).
|
|
358
|
+
// We crop the idle cell first thing. For the ANSI render we
|
|
359
|
+
// box-downscale to ~32 wide so the inline render is similar in
|
|
360
|
+
// height to a pipoya sprite (~17 lines). For the inline-graphics
|
|
361
|
+
// PNG we keep the native 192×208 resolution — Kitty / iTerm2 will
|
|
362
|
+
// scale it down at display time, which preserves more detail than
|
|
363
|
+
// pre-downscaling here.
|
|
199
364
|
if (spriteKind === "codexPet" && petSheetLink) {
|
|
200
365
|
try {
|
|
201
366
|
const buf = await fetchBlob(sim.did, petSheetLink);
|
|
@@ -208,12 +373,46 @@ async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
|
|
|
208
373
|
const targetW = 32;
|
|
209
374
|
const targetH = Math.round((CELL_H / CELL_W) * targetW); // ~35
|
|
210
375
|
const scaled = boxDownscaleRgba(cell, CELL_W, CELL_H, targetW, targetH);
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
376
|
+
const ansi = toAnsi(scaled.data, scaled.width, scaled.height);
|
|
377
|
+
let png: SpriteRender["png"];
|
|
378
|
+
try {
|
|
379
|
+
const pngBytes = encodeRgbaToPng(cell, CELL_W, CELL_H);
|
|
380
|
+
png = { data: pngBytes, widthPx: CELL_W, heightPx: CELL_H };
|
|
381
|
+
} catch {
|
|
382
|
+
png = undefined;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Idle animation frames — same atlas, different cell offsets.
|
|
386
|
+
// Encoded eagerly here so the message renderer can flip
|
|
387
|
+
// between them at ~5 FPS without re-decoding the WebP. Wrapped
|
|
388
|
+
// in its own try/catch — a frame-encoding failure shouldn't
|
|
389
|
+
// disable the static render.
|
|
390
|
+
let frames: SpriteRender["frames"];
|
|
391
|
+
try {
|
|
392
|
+
const framePngs: Buffer[] = [];
|
|
393
|
+
for (const idx of CODEX_PET_IDLE_FRAMES) {
|
|
394
|
+
const cx = (idx % CODEX_PET_COLS) * CELL_W;
|
|
395
|
+
const cy = Math.floor(idx / CODEX_PET_COLS) * CELL_H;
|
|
396
|
+
// Skip frames that fall outside the actual atlas extent.
|
|
397
|
+
if (cx + CELL_W > width || cy + CELL_H > height) continue;
|
|
398
|
+
const frameCell = cropRgba(data, width, height, cx, cy, CELL_W, CELL_H);
|
|
399
|
+
framePngs.push(encodeRgbaToPng(frameCell, CELL_W, CELL_H));
|
|
400
|
+
}
|
|
401
|
+
// Single-frame "animations" are pointless — leave `frames`
|
|
402
|
+
// unset so the renderer takes the static path. Two or more
|
|
403
|
+
// = a real loop.
|
|
404
|
+
if (framePngs.length >= 2) {
|
|
405
|
+
frames = {
|
|
406
|
+
pngs: framePngs,
|
|
407
|
+
widthPx: CELL_W,
|
|
408
|
+
heightPx: CELL_H,
|
|
409
|
+
fps: CODEX_PET_IDLE_FPS,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
} catch {
|
|
413
|
+
frames = undefined;
|
|
414
|
+
}
|
|
415
|
+
return { ansi, png, frames };
|
|
217
416
|
}
|
|
218
417
|
} catch {
|
|
219
418
|
/* fall through to image fallback */
|
|
@@ -252,12 +451,7 @@ async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
|
|
|
252
451
|
Math.max(1, Math.round(native.height * k)),
|
|
253
452
|
);
|
|
254
453
|
}
|
|
255
|
-
return
|
|
256
|
-
cropToContent: true,
|
|
257
|
-
cropPad: 1,
|
|
258
|
-
indent: 2,
|
|
259
|
-
alphaThreshold: 16,
|
|
260
|
-
});
|
|
454
|
+
return bundle(native.data, native.width, native.height);
|
|
261
455
|
} catch {
|
|
262
456
|
/* fall through */
|
|
263
457
|
}
|
|
@@ -284,10 +478,10 @@ async function loadSimByName(query: string): Promise<{
|
|
|
284
478
|
|
|
285
479
|
async function hydrateLoadedSim(match: SimMatch): Promise<LoadedSim> {
|
|
286
480
|
// Fetch agents (constitution), style, sprite ANSI + handle in parallel.
|
|
287
|
-
const [agents, style,
|
|
481
|
+
const [agents, style, sprite, handle] = await Promise.all([
|
|
288
482
|
fetchAgentsForSim(match.uri).catch(() => null) as Promise<AgentsRecord | null>,
|
|
289
483
|
fetchStyleForSim(match.uri).catch(() => null) as Promise<StyleRecord | null>,
|
|
290
|
-
|
|
484
|
+
renderSprite(match).catch(() => null),
|
|
291
485
|
resolveHandle(match.did).catch(() => null),
|
|
292
486
|
]);
|
|
293
487
|
|
|
@@ -297,14 +491,36 @@ async function hydrateLoadedSim(match: SimMatch): Promise<LoadedSim> {
|
|
|
297
491
|
rkey: match.rkey,
|
|
298
492
|
name: match.sim.name,
|
|
299
493
|
handle,
|
|
300
|
-
spriteAnsi:
|
|
494
|
+
spriteAnsi: sprite?.ansi,
|
|
495
|
+
spritePng: sprite?.png
|
|
496
|
+
? {
|
|
497
|
+
base64: sprite.png.data.toString("base64"),
|
|
498
|
+
widthPx: sprite.png.widthPx,
|
|
499
|
+
heightPx: sprite.png.heightPx,
|
|
500
|
+
}
|
|
501
|
+
: undefined,
|
|
502
|
+
spriteFrames: sprite?.frames
|
|
503
|
+
? {
|
|
504
|
+
pngBase64: sprite.frames.pngs.map((b) => b.toString("base64")),
|
|
505
|
+
fps: sprite.frames.fps,
|
|
506
|
+
widthPx: sprite.frames.widthPx,
|
|
507
|
+
heightPx: sprite.frames.heightPx,
|
|
508
|
+
}
|
|
509
|
+
: undefined,
|
|
301
510
|
shortDescription: agents?.shortDescription,
|
|
302
511
|
description: agents?.description,
|
|
303
512
|
style: style?.description,
|
|
304
513
|
};
|
|
305
514
|
}
|
|
306
515
|
|
|
307
|
-
|
|
516
|
+
/**
|
|
517
|
+
* Build the bio text block that appears alongside (or below) the
|
|
518
|
+
* sprite in the loaded-sim message: name + handle + AT-URI +
|
|
519
|
+
* shortDescription. Indented two spaces so it lines up with the ANSI
|
|
520
|
+
* sprite render. Used both as a standalone block (Image+Text
|
|
521
|
+
* variant) and as the trailing portion of `formatSimSummary`.
|
|
522
|
+
*/
|
|
523
|
+
function formatSimBio(
|
|
308
524
|
sim: LoadedSim,
|
|
309
525
|
theme?: ExtensionContext["ui"]["theme"],
|
|
310
526
|
): string {
|
|
@@ -313,10 +529,6 @@ function formatSimSummary(
|
|
|
313
529
|
? (s: string) => theme.fg("accent", s)
|
|
314
530
|
: (s: string) => s;
|
|
315
531
|
const lines: string[] = [];
|
|
316
|
-
if (sim.spriteAnsi) {
|
|
317
|
-
lines.push(sim.spriteAnsi);
|
|
318
|
-
lines.push("");
|
|
319
|
-
}
|
|
320
532
|
lines.push(` 🐾 ${accent(sim.name)}${sim.handle ? dim(` @${sim.handle}`) : ""} loaded—pi is now in character.`);
|
|
321
533
|
lines.push(dim(` ${sim.uri}`));
|
|
322
534
|
if (sim.shortDescription) {
|
|
@@ -326,6 +538,19 @@ function formatSimSummary(
|
|
|
326
538
|
return lines.join("\n");
|
|
327
539
|
}
|
|
328
540
|
|
|
541
|
+
function formatSimSummary(
|
|
542
|
+
sim: LoadedSim,
|
|
543
|
+
theme?: ExtensionContext["ui"]["theme"],
|
|
544
|
+
): string {
|
|
545
|
+
const lines: string[] = [];
|
|
546
|
+
if (sim.spriteAnsi) {
|
|
547
|
+
lines.push(sim.spriteAnsi);
|
|
548
|
+
lines.push("");
|
|
549
|
+
}
|
|
550
|
+
lines.push(formatSimBio(sim, theme));
|
|
551
|
+
return lines.join("\n");
|
|
552
|
+
}
|
|
553
|
+
|
|
329
554
|
// The OpenTUI standalone animated viewer used to live here. It now ships
|
|
330
555
|
// alongside this file as `viewer.ts` for anyone who wants the full-window
|
|
331
556
|
// experience — run it manually with:
|
|
@@ -411,11 +636,100 @@ export default async function simocracy(pi: ExtensionAPI) {
|
|
|
411
636
|
|
|
412
637
|
// -------------------------------------------------------------------------
|
|
413
638
|
// Custom message renderer — shows the sprite + bio inline in the chat.
|
|
639
|
+
//
|
|
640
|
+
// Two render paths, picked per-call:
|
|
641
|
+
//
|
|
642
|
+
// 1. Inline graphics (preferred when supported). Terminals that
|
|
643
|
+
// advertise the Kitty graphics protocol (Kitty, Ghostty, WezTerm,
|
|
644
|
+
// Konsole) or iTerm2's inline-image protocol get a real PNG of
|
|
645
|
+
// the sprite via pi-tui's `Image` component, stacked above the
|
|
646
|
+
// bio text in a `Box`. Pixels are crisp, scaling is the
|
|
647
|
+
// terminal's job.
|
|
648
|
+
//
|
|
649
|
+
// 2. ANSI half-blocks (universal fallback). Everything else —
|
|
650
|
+
// Apple Terminal, tmux without passthrough, plain SSH, dumb
|
|
651
|
+
// pipes — falls back to the existing `▀`/`▄` half-block art.
|
|
652
|
+
//
|
|
653
|
+
// Override with `SIMOCRACY_INLINE_GRAPHICS=ansi` to force the
|
|
654
|
+
// half-block path even when the terminal supports inline graphics
|
|
655
|
+
// (handy for screenshots, demo recordings, or terminals that
|
|
656
|
+
// *advertise* support but render glitchily).
|
|
414
657
|
// -------------------------------------------------------------------------
|
|
415
|
-
|
|
658
|
+
const inlineGraphicsMode =
|
|
659
|
+
(process.env.SIMOCRACY_INLINE_GRAPHICS ?? "auto").toLowerCase();
|
|
660
|
+
pi.registerMessageRenderer<SimLoadedDetails>("simocracy_sim_loaded", (message, _opts, theme) => {
|
|
661
|
+
const details = (message.details as SimLoadedDetails | undefined) ?? {};
|
|
416
662
|
const body =
|
|
417
|
-
(message.
|
|
418
|
-
|
|
663
|
+
details.body ?? (typeof message.content === "string" ? message.content : "");
|
|
664
|
+
|
|
665
|
+
// Decide whether to use the inline-graphics path. Auto-detect by
|
|
666
|
+
// default; honour the env-var override either direction.
|
|
667
|
+
const caps = getCapabilities();
|
|
668
|
+
const wantGraphics =
|
|
669
|
+
inlineGraphicsMode === "auto"
|
|
670
|
+
? caps.images !== null
|
|
671
|
+
: inlineGraphicsMode === "kitty" || inlineGraphicsMode === "iterm2";
|
|
672
|
+
if (wantGraphics && details.spritePngBase64 && details.bioText) {
|
|
673
|
+
// Pi-tui's Image needs a fallback colour for terminals that
|
|
674
|
+
// claim image support but later fail to render — we use the
|
|
675
|
+
// theme's `dim` so the placeholder text is unobtrusive.
|
|
676
|
+
const imageTheme: ImageTheme = {
|
|
677
|
+
fallbackColor: theme.fg("dim", "") ? (s: string) => theme.fg("dim", s) : (s: string) => s,
|
|
678
|
+
};
|
|
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 =
|
|
693
|
+
currentAnimation &&
|
|
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
|
+
);
|
|
729
|
+
}
|
|
730
|
+
box.addChild(new Text(details.bioText, 0, 0));
|
|
731
|
+
return box;
|
|
732
|
+
}
|
|
419
733
|
return new Text(body, 0, 0);
|
|
420
734
|
});
|
|
421
735
|
|
|
@@ -488,6 +802,7 @@ export default async function simocracy(pi: ExtensionAPI) {
|
|
|
488
802
|
const name = loadedSim.name;
|
|
489
803
|
loadedSim = null;
|
|
490
804
|
justUnloaded = name;
|
|
805
|
+
stopCurrentAnimation();
|
|
491
806
|
ctx.ui.setStatus("simocracy", undefined);
|
|
492
807
|
ctx.ui.setWidget("simocracy", undefined);
|
|
493
808
|
ctx.ui.notify(`Unloaded ${name}. Pi will break character on the next reply.`, "info");
|
|
@@ -577,6 +892,7 @@ export default async function simocracy(pi: ExtensionAPI) {
|
|
|
577
892
|
const name = loadedSim.name;
|
|
578
893
|
loadedSim = null;
|
|
579
894
|
justUnloaded = name;
|
|
895
|
+
stopCurrentAnimation();
|
|
580
896
|
if (ctx.hasUI) {
|
|
581
897
|
ctx.ui.setStatus("simocracy", undefined);
|
|
582
898
|
ctx.ui.setWidget("simocracy", undefined);
|
|
@@ -1053,6 +1369,41 @@ async function tryLoadFromQuery(query: string): Promise<LoadedSim | null> {
|
|
|
1053
1369
|
return await hydrateLoadedSim(result.matches[0]);
|
|
1054
1370
|
}
|
|
1055
1371
|
|
|
1372
|
+
/**
|
|
1373
|
+
* Shape of `details` on the `simocracy_sim_loaded` custom message.
|
|
1374
|
+
* The renderer reads this to choose between the inline-graphics and
|
|
1375
|
+
* ANSI-half-block render paths; both fields are best-effort — the
|
|
1376
|
+
* renderer falls back to the combined `body` string if anything is
|
|
1377
|
+
* missing.
|
|
1378
|
+
*/
|
|
1379
|
+
interface SimLoadedDetails {
|
|
1380
|
+
uri?: string;
|
|
1381
|
+
did?: string;
|
|
1382
|
+
rkey?: string;
|
|
1383
|
+
name?: string;
|
|
1384
|
+
/** Combined ANSI sprite + bio, used by the half-block fallback path
|
|
1385
|
+
* and as the textual log content. */
|
|
1386
|
+
body?: string;
|
|
1387
|
+
/** Bio text only (no sprite), used alongside the Image component
|
|
1388
|
+
* on the inline-graphics path. */
|
|
1389
|
+
bioText?: string;
|
|
1390
|
+
/** base64-encoded PNG of the sprite cell. Triggers the inline-graphics
|
|
1391
|
+
* path when set + the terminal advertises image support. */
|
|
1392
|
+
spritePngBase64?: string;
|
|
1393
|
+
/** Native PNG width in pixels (aspect ratio for Image scaling). */
|
|
1394
|
+
spritePngWidth?: number;
|
|
1395
|
+
/** Native PNG height in pixels. */
|
|
1396
|
+
spritePngHeight?: number;
|
|
1397
|
+
/**
|
|
1398
|
+
* Identity tag for the active animation, if any. The message renderer
|
|
1399
|
+
* compares against `currentAnimation.key` to decide whether to swap
|
|
1400
|
+
* in the current animation frame or freeze on the static idle PNG.
|
|
1401
|
+
* Stable across re-renders of the same message; differs across
|
|
1402
|
+
* separate sim loads.
|
|
1403
|
+
*/
|
|
1404
|
+
animationKey?: string;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1056
1407
|
async function postSimToChat(
|
|
1057
1408
|
pi: ExtensionAPI,
|
|
1058
1409
|
ctx: ExtensionContext,
|
|
@@ -1060,21 +1411,54 @@ async function postSimToChat(
|
|
|
1060
1411
|
_reload: boolean,
|
|
1061
1412
|
) {
|
|
1062
1413
|
ctx.ui.setStatus("simocracy", `🐾 ${sim.name}`);
|
|
1063
|
-
|
|
1064
|
-
|
|
1414
|
+
// Use the factory form of setWidget so we can capture pi-tui's TUI
|
|
1415
|
+
// handle. We need it to call `requestRender()` from the animation
|
|
1416
|
+
// setInterval (the message renderer doesn't get a TUI reference).
|
|
1417
|
+
// The factory itself returns a tiny static Text widget — the TUI
|
|
1418
|
+
// capture is the actual purpose.
|
|
1419
|
+
const headerText = `Simocracy: ${sim.name}${sim.handle ? ` (@${sim.handle})` : ""}`;
|
|
1420
|
+
ctx.ui.setWidget(
|
|
1421
|
+
"simocracy",
|
|
1422
|
+
(tui) => {
|
|
1423
|
+
capturedTui = tui;
|
|
1424
|
+
return new Text(headerText, 0, 0);
|
|
1425
|
+
},
|
|
1426
|
+
{ placement: "aboveEditor" },
|
|
1427
|
+
);
|
|
1065
1428
|
const body = formatSimSummary(sim, ctx.ui.theme);
|
|
1429
|
+
const bioText = formatSimBio(sim, ctx.ui.theme);
|
|
1430
|
+
// Unique key per load so the renderer can tell which message owns
|
|
1431
|
+
// the active animation. AT-URI + timestamp protects against
|
|
1432
|
+
// re-loading the same sim twice (each load starts its own loop).
|
|
1433
|
+
const animationKey = `${sim.uri}#${Date.now()}`;
|
|
1434
|
+
const details: SimLoadedDetails = {
|
|
1435
|
+
uri: sim.uri,
|
|
1436
|
+
did: sim.did,
|
|
1437
|
+
rkey: sim.rkey,
|
|
1438
|
+
name: sim.name,
|
|
1439
|
+
body,
|
|
1440
|
+
bioText,
|
|
1441
|
+
animationKey,
|
|
1442
|
+
};
|
|
1443
|
+
if (sim.spritePng) {
|
|
1444
|
+
details.spritePngBase64 = sim.spritePng.base64;
|
|
1445
|
+
details.spritePngWidth = sim.spritePng.widthPx;
|
|
1446
|
+
details.spritePngHeight = sim.spritePng.heightPx;
|
|
1447
|
+
}
|
|
1066
1448
|
pi.sendMessage({
|
|
1067
1449
|
customType: "simocracy_sim_loaded",
|
|
1068
1450
|
content: stripAnsiForLog(body),
|
|
1069
1451
|
display: true,
|
|
1070
|
-
details
|
|
1071
|
-
uri: sim.uri,
|
|
1072
|
-
did: sim.did,
|
|
1073
|
-
rkey: sim.rkey,
|
|
1074
|
-
name: sim.name,
|
|
1075
|
-
body,
|
|
1076
|
-
},
|
|
1452
|
+
details,
|
|
1077
1453
|
});
|
|
1454
|
+
// Kick off (or replace) the animation loop. Only one loop runs at a
|
|
1455
|
+
// time — the last loaded sim animates, earlier messages freeze on
|
|
1456
|
+
// their idle frame.
|
|
1457
|
+
if (sim.spriteFrames) {
|
|
1458
|
+
startAnimationFor(animationKey, sim.spriteFrames);
|
|
1459
|
+
} else {
|
|
1460
|
+
stopCurrentAnimation();
|
|
1461
|
+
}
|
|
1078
1462
|
}
|
|
1079
1463
|
|
|
1080
1464
|
/** 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
|
+
}
|