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.
- package/README.md +49 -201
- package/docs/SIM_AUTHORED_COMMENTS.md +197 -0
- package/package.json +2 -1
- package/src/animated-image.ts +188 -0
- package/src/index.ts +623 -60
- package/src/lookup.ts +537 -0
- package/src/writes.ts +114 -0
|
@@ -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
|
+
}
|