@gajae-code/tui 0.13.2 → 0.14.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.
@@ -1,11 +1,26 @@
1
+ import * as zlib from "node:zlib";
2
+
3
+ import { encodeITerm2Multipart, wrapITerm2RecordsForTmux } from "../terminal-capabilities";
4
+ import {
5
+ OUROBOROS_HEART_STEPS,
6
+ OUROBOROS_IDLE_STEPS,
7
+ OUROBOROS_PIXEL_GRIDS,
8
+ OUROBOROS_WORK_CRY_STEPS,
9
+ OUROBOROS_WORK_ENTER_STEPS,
10
+ OUROBOROS_WORK_EXIT_STEPS,
11
+ OUROBOROS_WORK_HEART_STEPS,
12
+ OUROBOROS_WORK_STEPS,
13
+ type OuroborosFrameName,
14
+ } from "./ouroboros-pet";
15
+
1
16
  /**
2
17
  * ┌─ GAJAE PET SPRITE SPEC ────────────────────────────────────────────────┐
3
- * The pet is a 16×16 pixel sprite drawn beside the composer. Everything here is
4
- * data: no PNGs, no assets — each frame is 16 strings of 16 chars, encoded to a
5
- * sixel or kitty escape at runtime. Author a new frame by drawing a grid.
18
+ * Pets are square pixel sprites drawn beside the composer. Everything here is
19
+ * data: no PNGs or binary assets. Current pet frames are 16×16 and render into
20
+ * the same terminal footprint.
6
21
  *
7
22
  * GRID RULES
8
- * - Exactly 16 rows × 16 columns. Only PALETTE keys below are valid chars.
23
+ * - Every frame within a skin has the same square dimensions.
9
24
  * - `.` = transparent. Keep the outer columns transparent so the sprite sits
10
25
  * snug beside the input box (the widget reserves +1 column of slack).
11
26
  *
@@ -26,7 +41,8 @@
26
41
  * RENDERING: buildGajaePixelFrames({ protocol, cellWidthPx, cellHeightPx,
27
42
  * targetRows: 2 }) scales the art to 2 terminal rows and encodes each frame
28
43
  * once. Kitty uses a native `Y=` sub-cell drop (set by the widget) to sit on the
29
- * composer border; sixel uses transparent top padding.
44
+ * composer border; sixel uses transparent top padding; iTerm2 uses an inline PNG
45
+ * sized to the reserved cell block.
30
46
  *
31
47
  * BEHAVIOR (timing, positioning, on/off) lives in
32
48
  * packages/coding-agent/src/modes/components/gajae-pet-widget.ts.
@@ -34,17 +50,15 @@
34
50
  * ADD A FRAME: draw the grid → add its name to GajaePixelFrameName → register it in
35
51
  * PIXEL_GRIDS → reference it from an idle/work loop or a skin burst.
36
52
  *
37
- * ADD A PET (skin): append one entry to PET_SKINS below — { id, label, description,
38
- * palette, burst }. The id flows into PetSkinId/PetMode automatically, the settings
39
- * enum, `/pet` command and both selectors derive their options from PET_SKINS, and the
40
- * widget reads `burst` to animate — no other file needs editing. Recolor with a palette
41
- * spread (see BLUE_PALETTE); add frames only for poses the catalog lacks.
53
+ * ADD A PET (skin): append one PET_SKINS entry with its palette, frame registry,
54
+ * base/idle/work animations and burst. The id flows into PetSkinId/PetMode
55
+ * automatically; settings, `/pet`, and both selectors derive from PET_SKINS.
42
56
  * └────────────────────────────────────────────────────────────────────────┘
43
57
  */
44
58
  type Rgb = readonly [number, number, number];
45
59
 
46
60
  export type Palette = Record<string, Rgb | null>;
47
- export const PET_SKIN_IDS = ["red", "blue"] as const;
61
+ export const PET_SKIN_IDS = ["red", "blue", "ouroboros"] as const;
48
62
  export type PetSkinId = (typeof PET_SKIN_IDS)[number];
49
63
  /** Every pet mode: "off" plus each skin id, in menu order. */
50
64
  export const PET_MODE_IDS = ["off", ...PET_SKIN_IDS] as const;
@@ -54,6 +68,11 @@ export function isPetMode(value: string): value is PetMode {
54
68
  return (PET_MODE_IDS as readonly string[]).includes(value);
55
69
  }
56
70
 
71
+ /** Resolve a persisted mode after a skin has been removed. Explicit "off" remains off. */
72
+ export function resolvePetMode(value: string): PetMode {
73
+ return isPetMode(value) ? value : "red";
74
+ }
75
+
57
76
  const RED_PALETTE: Palette = {
58
77
  ".": null, // transparent
59
78
  K: [74, 20, 8], // outline dark
@@ -79,6 +98,15 @@ const BLUE_PALETTE: Palette = {
79
98
  A: [37, 120, 200], // muted blue (antenna)
80
99
  w: [230, 247, 255], // foam (tear)
81
100
  };
101
+ const OUROBOROS_PALETTE: Palette = {
102
+ ".": null,
103
+ D: [20, 100, 48], // closed / crying eye
104
+ R: [174, 232, 14], // vivid lime body
105
+ r: [112, 146, 190], // cool blue underside (#7092BE)
106
+ G: [255, 231, 134], // pale-yellow eye
107
+ A: [255, 137, 180], // pink tongue and heart accent
108
+ w: [190, 231, 255], // tear
109
+ };
82
110
 
83
111
  // ------------------------------------------------------------------------
84
112
  // Real-pixel frames (codex-pets style): the same grids encoded as terminal
@@ -188,6 +216,7 @@ export type GajaePixelFrameName =
188
216
  | "cry1"
189
217
  | "cry2"
190
218
  | "cry3";
219
+ export type PetFrameName = GajaePixelFrameName | OuroborosFrameName;
191
220
 
192
221
  const PIXEL_GRIDS: Record<GajaePixelFrameName, string[]> = {
193
222
  base: F0,
@@ -203,13 +232,22 @@ const PIXEL_GRIDS: Record<GajaePixelFrameName, string[]> = {
203
232
  };
204
233
 
205
234
  /** Para-para work dance beats: the working loop and each skin's burst "work-in" intro. */
206
- export const PARA_PARA_STEPS: ReadonlyArray<readonly [GajaePixelFrameName, number]> = [
235
+ export type GajaeGifFrameTuple = readonly [GajaePixelFrameName, number];
236
+ export const PARA_PARA_STEPS: readonly GajaeGifFrameTuple[] = [
207
237
  ["danceL", 300],
208
238
  ["danceR", 300],
209
239
  ["base", 260],
210
240
  ["flex", 480],
211
241
  ["base", 260],
212
242
  ];
243
+ export const GAJAE_IDLE_STEPS: ReadonlyArray<readonly [GajaePixelFrameName, number]> = [
244
+ ["base", 1100],
245
+ ["gazeL", 350],
246
+ ["base", 500],
247
+ ["gazeR", 350],
248
+ ["base", 800],
249
+ ["flicker", 150],
250
+ ];
213
251
 
214
252
  /**
215
253
  * A skin's idle burst: a short intro sequence, then an optional looping tail. It drives
@@ -218,9 +256,9 @@ export const PARA_PARA_STEPS: ReadonlyArray<readonly [GajaePixelFrameName, numbe
218
256
  */
219
257
  export interface PetBurst {
220
258
  /** Frames played once, in order, at the start of the burst. */
221
- intro: ReadonlyArray<readonly [GajaePixelFrameName, number]>;
259
+ intro: ReadonlyArray<readonly [PetFrameName, number]>;
222
260
  /** Frames cycled every `stepMs` for `ms` after the intro (a held or looping finish). */
223
- tail?: { frames: readonly GajaePixelFrameName[]; stepMs: number; ms: number };
261
+ tail?: { frames: readonly PetFrameName[]; stepMs: number; ms: number };
224
262
  }
225
263
 
226
264
  /** Everything that defines a pet skin: identity, UI copy, colors and behavior. */
@@ -231,8 +269,16 @@ export interface PetSkin {
231
269
  /** One-line selector/settings description. */
232
270
  description: string;
233
271
  palette: Palette;
272
+ frames: Readonly<Record<string, string[]>>;
273
+ baseFrame: PetFrameName;
274
+ idle: ReadonlyArray<readonly [PetFrameName, number]>;
275
+ workEnter?: ReadonlyArray<readonly [PetFrameName, number]>;
276
+ work: ReadonlyArray<readonly [PetFrameName, number]>;
277
+ workExit?: ReadonlyArray<readonly [PetFrameName, number]>;
234
278
  /** Idle burst animation played between quiet idle loops. */
235
279
  burst: PetBurst;
280
+ /** Optional variants that interrupt and then resume the work loop. */
281
+ workBursts?: readonly PetBurst[];
236
282
  }
237
283
 
238
284
  /** Skin registry — the single source for palettes, behavior and selector/command copy. */
@@ -242,6 +288,10 @@ export const PET_SKINS: Record<PetSkinId, PetSkin> = {
242
288
  label: "RedGajae",
243
289
  description: "The Red Crab, who likes to work-out.",
244
290
  palette: RED_PALETTE,
291
+ frames: PIXEL_GRIDS,
292
+ baseFrame: "base",
293
+ idle: GAJAE_IDLE_STEPS,
294
+ work: PARA_PARA_STEPS,
245
295
  burst: {
246
296
  intro: PARA_PARA_STEPS,
247
297
  tail: { frames: ["flex", "base"], stepMs: 200, ms: 1000 },
@@ -252,38 +302,355 @@ export const PET_SKINS: Record<PetSkinId, PetSkin> = {
252
302
  label: "BlueGajae",
253
303
  description: "The Blue Crab, who wants to rest.",
254
304
  palette: BLUE_PALETTE,
305
+ frames: PIXEL_GRIDS,
306
+ baseFrame: "base",
307
+ idle: GAJAE_IDLE_STEPS,
308
+ work: PARA_PARA_STEPS,
255
309
  burst: {
256
310
  intro: PARA_PARA_STEPS,
257
311
  tail: { frames: ["cry1", "cry2", "cry3"], stepMs: 110, ms: 990 },
258
312
  },
259
313
  },
314
+ ouroboros: {
315
+ id: "ouroboros",
316
+ label: "Ouroboros",
317
+ description: "The little snake who keeps going.",
318
+ palette: OUROBOROS_PALETTE,
319
+ frames: OUROBOROS_PIXEL_GRIDS,
320
+ baseFrame: "idle",
321
+ idle: OUROBOROS_IDLE_STEPS,
322
+ workEnter: OUROBOROS_WORK_ENTER_STEPS,
323
+ work: OUROBOROS_WORK_STEPS,
324
+ workExit: OUROBOROS_WORK_EXIT_STEPS,
325
+ burst: {
326
+ intro: OUROBOROS_HEART_STEPS,
327
+ },
328
+ workBursts: [{ intro: OUROBOROS_WORK_HEART_STEPS }, { intro: OUROBOROS_WORK_CRY_STEPS }],
329
+ },
260
330
  };
261
331
 
262
332
  /** Total burst duration (intro beats plus the looping tail). */
263
333
  export function petBurstDurationMs(burst: PetBurst): number {
264
- const introMs = burst.intro.reduce((sum, [, ms]) => sum + ms, 0);
334
+ const introMs = burst.intro.reduce((sum, [, delayMs]) => sum + delayMs, 0);
265
335
  return introMs + (burst.tail?.ms ?? 0);
266
336
  }
267
337
 
268
338
  /** The frame to show `elapsed` ms into a burst (`now` cycles the looping tail). */
269
- export function petBurstFrame(burst: PetBurst, elapsed: number, now: number): GajaePixelFrameName {
339
+ export function petBurstFrame(burst: PetBurst, elapsed: number, now: number): PetFrameName {
270
340
  let t = elapsed;
271
- for (const [frame, ms] of burst.intro) {
272
- if (t < ms) return frame;
273
- t -= ms;
341
+ for (const [name, delayMs] of burst.intro) {
342
+ if (t < delayMs) return name;
343
+ t -= delayMs;
274
344
  }
275
345
  const tail = burst.tail;
276
- if (!tail) return burst.intro[burst.intro.length - 1][0];
346
+ if (!tail || tail.frames.length === 0) return burst.intro[burst.intro.length - 1]?.[0] ?? "base";
277
347
  return tail.frames[Math.floor(now / tail.stepMs) % tail.frames.length];
278
348
  }
279
349
 
280
350
  /** Test-only access to logical art; production rendering still uses encoded frames. */
281
351
  export const __gajaePetTestHooks = {
282
- getPixelGrid(name: GajaePixelFrameName): string[] {
283
- return [...PIXEL_GRIDS[name]];
352
+ getPixelGrid(name: PetFrameName, skin: PetSkinId = "red"): string[] {
353
+ const grid = PET_SKINS[skin].frames[name];
354
+ if (!grid) throw new Error(`Unknown ${skin} pet frame: ${name}`);
355
+ return [...grid];
284
356
  },
285
357
  };
286
358
 
359
+ export interface GajaeGifFrame {
360
+ readonly name: PetFrameName;
361
+ readonly delayMs: number;
362
+ }
363
+ export type GajaeGifTimeline = readonly GajaeGifFrame[];
364
+ export interface GajaeGifRectangle {
365
+ readonly width?: number;
366
+ readonly height?: number;
367
+ }
368
+ export interface GajaeGifDisplaySize {
369
+ /** iTerm2 display width: bare numbers are terminal cells; strings may use px or auto. */
370
+ readonly width: number | string;
371
+ /** iTerm2 display height: bare numbers are terminal cells; strings may use px or auto. */
372
+ readonly height: number | string;
373
+ }
374
+ export interface GajaeGifContentInset {
375
+ /** Transparent top padding in source pixels. */
376
+ readonly topPx?: number;
377
+ /** Transparent bottom padding in source pixels. */
378
+ readonly bottomPx?: number;
379
+ }
380
+ export interface GajaePetGifArtifact {
381
+ readonly bytes: Uint8Array;
382
+ readonly base64: string;
383
+ readonly width: number;
384
+ readonly height: number;
385
+ readonly frames: readonly GajaeGifFrame[];
386
+ readonly skin: PetSkinId;
387
+ readonly multipart: readonly string[];
388
+ readonly tmuxDcs: readonly string[];
389
+ }
390
+ export interface GajaePetGifOptions {
391
+ readonly skin?: PetSkinId;
392
+ readonly timeline?: GajaeGifTimeline;
393
+ readonly cellWidthPx?: number;
394
+ readonly cellHeightPx?: number;
395
+ readonly targetRows?: number;
396
+ readonly rectangle?: GajaeGifRectangle;
397
+ readonly displaySize?: GajaeGifDisplaySize;
398
+ readonly contentInset?: GajaeGifContentInset;
399
+ }
400
+ const GIF_CLEAR = 256,
401
+ GIF_END = 257;
402
+ function gifLzw(pixels: number[], minCodeSize = 8): Uint8Array {
403
+ const out: number[] = [],
404
+ codes = pixels.flatMap(p => [GIF_CLEAR, p]).concat(GIF_END);
405
+ let bits = 0,
406
+ value = 0;
407
+ for (const code of codes) {
408
+ value |= code << bits;
409
+ bits += minCodeSize + 1;
410
+ while (bits >= 8) {
411
+ out.push(value & 255);
412
+ value >>>= 8;
413
+ bits -= 8;
414
+ }
415
+ }
416
+ if (bits) out.push(value & 255);
417
+ const blocks: number[] = [minCodeSize];
418
+ for (let i = 0; i < out.length; i += 255) {
419
+ const part = out.slice(i, i + 255);
420
+ blocks.push(part.length, ...part);
421
+ }
422
+ blocks.push(0);
423
+ return Uint8Array.from(blocks);
424
+ }
425
+ export const idleTimeline = (): GajaeGifTimeline => [
426
+ { name: "base", delayMs: 700 },
427
+ { name: "gazeL", delayMs: 180 },
428
+ { name: "base", delayMs: 700 },
429
+ { name: "gazeR", delayMs: 180 },
430
+ { name: "flicker", delayMs: 120 },
431
+ ];
432
+ export const workingTimeline = (): GajaeGifTimeline => PARA_PARA_STEPS.map(([name, delayMs]) => ({ name, delayMs }));
433
+ export const burstTimeline = (skin: PetSkinId = "red"): GajaeGifTimeline => {
434
+ const burst = PET_SKINS[skin].burst;
435
+ const frames: GajaeGifFrame[] = burst.intro.map(([name, delayMs]) => ({ name, delayMs }));
436
+ const tail = burst.tail;
437
+ if (!tail || tail.frames.length === 0) return frames;
438
+ for (let elapsed = 0; elapsed < tail.ms; elapsed += tail.stepMs) {
439
+ frames.push({
440
+ name: tail.frames[Math.floor(elapsed / tail.stepMs) % tail.frames.length],
441
+ delayMs: Math.min(tail.stepMs, tail.ms - elapsed),
442
+ });
443
+ }
444
+ return frames;
445
+ };
446
+ export const previewTimeline = (skin: PetSkinId = "red"): GajaeGifTimeline => burstTimeline(skin);
447
+ function isGifTimeline(input: GajaePetGifOptions | GajaeGifTimeline): input is GajaeGifTimeline {
448
+ return Array.isArray(input);
449
+ }
450
+ function gifOptions(input: GajaePetGifOptions | GajaeGifTimeline): Required<
451
+ Pick<GajaePetGifOptions, "skin" | "timeline" | "cellWidthPx" | "cellHeightPx" | "targetRows">
452
+ > & {
453
+ rectangle?: GajaeGifRectangle;
454
+ displaySize?: GajaeGifDisplaySize;
455
+ contentInset?: GajaeGifContentInset;
456
+ } {
457
+ if (isGifTimeline(input)) {
458
+ return { skin: "red", timeline: input, cellWidthPx: 1, cellHeightPx: 1, targetRows: 16 };
459
+ }
460
+ return {
461
+ skin: input.skin ?? "red",
462
+ timeline: input.timeline ?? idleTimeline(),
463
+ cellWidthPx: input.cellWidthPx ?? 1,
464
+ cellHeightPx: input.cellHeightPx ?? 1,
465
+ targetRows: input.targetRows ?? 16,
466
+ rectangle: input.rectangle,
467
+ displaySize: input.displaySize,
468
+ contentInset: input.contentInset,
469
+ };
470
+ }
471
+ export function encodeGajaePetGif(input: GajaePetGifOptions | GajaeGifTimeline = {}): GajaePetGifArtifact {
472
+ const o = gifOptions(input),
473
+ rect = o.rectangle ?? {};
474
+ if (o.timeline.length === 0) throw new Error("GIF timeline must not be empty");
475
+ for (const frame of o.timeline) {
476
+ if (!Number.isFinite(frame.delayMs) || frame.delayMs < 0) throw new Error("Invalid GIF frame delay");
477
+ }
478
+ const width = rect.width ?? rect.height ?? Math.round(Math.max(1, o.targetRows * o.cellHeightPx));
479
+ const height = rect.height ?? Math.round(Math.max(1, o.targetRows * o.cellHeightPx));
480
+ const valid = (n: number, label: string): number => {
481
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0 || n > 0xffff) throw new Error(`Invalid GIF ${label}`);
482
+ return n;
483
+ };
484
+ valid(width, "width");
485
+ valid(height, "height");
486
+ const inset = o.contentInset ?? {};
487
+ const topInset = inset.topPx ?? 0;
488
+ const bottomInset = inset.bottomPx ?? 0;
489
+ if (
490
+ ![topInset, bottomInset].every(value => Number.isFinite(value) && Number.isInteger(value) && value >= 0) ||
491
+ topInset + bottomInset >= height
492
+ )
493
+ throw new Error("Invalid GIF content inset");
494
+ const contentHeight = height - topInset - bottomInset;
495
+ if (width * height * o.timeline.length > 64 * 1024 * 1024) throw new Error("GIF allocation exceeds safety budget");
496
+ const paletteKeys = Object.keys(PET_SKINS[o.skin].palette).filter(k => k !== ".");
497
+ const palette = [[0, 0, 0], ...paletteKeys.map(k => PET_SKINS[o.skin].palette[k]!)];
498
+ const chunks: number[] = [
499
+ ...Buffer.from("GIF89a"),
500
+ width & 255,
501
+ width >> 8,
502
+ height & 255,
503
+ height >> 8,
504
+ 0xf7,
505
+ 0,
506
+ 0,
507
+ ...palette.flat(),
508
+ ...Array((256 - palette.length) * 3).fill(0),
509
+ 33,
510
+ 255,
511
+ 11,
512
+ ...Buffer.from("NETSCAPE2.0"),
513
+ 3,
514
+ 1,
515
+ 0,
516
+ 0,
517
+ 0,
518
+ ];
519
+ for (const frame of o.timeline) {
520
+ const pixels: number[] = [],
521
+ grid = PET_SKINS[o.skin].frames[frame.name];
522
+ if (!grid) throw new Error(`Unknown ${o.skin} GIF frame: ${frame.name}`);
523
+ for (let y = 0; y < height; y++)
524
+ for (let x = 0; x < width; x++) {
525
+ const contentY = y - topInset;
526
+ if (contentY < 0 || contentY >= contentHeight) {
527
+ pixels.push(0);
528
+ continue;
529
+ }
530
+ const sx = Math.min(15, Math.floor((x * 16) / width));
531
+ const sy = Math.min(15, Math.floor((contentY * 16) / contentHeight));
532
+ const ch = grid[sy][sx];
533
+ pixels.push(ch === "." ? 0 : Math.max(1, paletteKeys.indexOf(ch) + 1));
534
+ }
535
+ const delay = Math.round(frame.delayMs / 10);
536
+ chunks.push(
537
+ 33,
538
+ 249,
539
+ 4,
540
+ 0x09,
541
+ delay & 255,
542
+ delay >> 8,
543
+ 0,
544
+ 0,
545
+ 44,
546
+ 0,
547
+ 0,
548
+ 0,
549
+ 0,
550
+ width & 255,
551
+ width >> 8,
552
+ height & 255,
553
+ height >> 8,
554
+ 0,
555
+ ...gifLzw(pixels),
556
+ );
557
+ }
558
+ chunks.push(59);
559
+ const bytes = Uint8Array.from(chunks);
560
+ const base64 = Buffer.from(bytes).toString("base64");
561
+ const multipart = encodeITerm2Multipart(base64, {
562
+ width: o.displaySize?.width ?? `${width}px`,
563
+ height: o.displaySize?.height ?? `${height}px`,
564
+ });
565
+ const tmuxDcs = wrapITerm2RecordsForTmux(multipart);
566
+ return {
567
+ bytes,
568
+ base64,
569
+ width,
570
+ height,
571
+ frames: [...o.timeline],
572
+ skin: o.skin,
573
+ multipart,
574
+ tmuxDcs,
575
+ };
576
+ }
577
+ const gifCache = new Map<string, GajaePetGifArtifact>();
578
+ let gifCacheBytes = 0;
579
+ let gifCacheBase64Bytes = 0;
580
+ let gifCacheMultipartBytes = 0;
581
+ let gifCacheTmuxDcsBytes = 0;
582
+ let gifCacheEvictions = 0;
583
+ const GIF_CACHE_MAX_ENTRIES = 32;
584
+ const GIF_CACHE_MAX_BYTES = 8 * 1024 * 1024;
585
+ const byteLength = (value: string): number => Buffer.byteLength(value, "utf8");
586
+
587
+ export function getGajaePetGifCached(input: GajaePetGifOptions | GajaeGifTimeline = {}): GajaePetGifArtifact {
588
+ const o = gifOptions(input),
589
+ key = JSON.stringify([
590
+ o.skin,
591
+ o.timeline,
592
+ o.cellWidthPx,
593
+ o.cellHeightPx,
594
+ o.targetRows,
595
+ o.rectangle,
596
+ o.displaySize,
597
+ o.contentInset,
598
+ ]),
599
+ hit = gifCache.get(key);
600
+ if (hit) {
601
+ gifCache.delete(key);
602
+ gifCache.set(key, hit);
603
+ return hit;
604
+ }
605
+ const value = encodeGajaePetGif(o);
606
+ gifCache.set(key, value);
607
+ gifCacheBytes += value.bytes.byteLength;
608
+ gifCacheBase64Bytes += byteLength(value.base64);
609
+ gifCacheMultipartBytes += value.multipart.reduce((sum, record) => sum + byteLength(record), 0);
610
+ gifCacheTmuxDcsBytes += value.tmuxDcs.reduce((sum, record) => sum + byteLength(record), 0);
611
+ while (
612
+ gifCache.size > GIF_CACHE_MAX_ENTRIES ||
613
+ gifCacheBytes + gifCacheBase64Bytes + gifCacheMultipartBytes + gifCacheTmuxDcsBytes > GIF_CACHE_MAX_BYTES
614
+ ) {
615
+ const k = gifCache.keys().next().value as string,
616
+ old = gifCache.get(k)!;
617
+ gifCache.delete(k);
618
+ gifCacheBytes -= old.bytes.byteLength;
619
+ gifCacheBase64Bytes -= byteLength(old.base64);
620
+ gifCacheMultipartBytes -= old.multipart.reduce((sum, record) => sum + byteLength(record), 0);
621
+ gifCacheTmuxDcsBytes -= old.tmuxDcs.reduce((sum, record) => sum + byteLength(record), 0);
622
+ gifCacheEvictions++;
623
+ }
624
+ return value;
625
+ }
626
+ export function getGajaePetGifCacheStats(): {
627
+ size: number;
628
+ bytes: number;
629
+ gifBytes: number;
630
+ base64Bytes: number;
631
+ multipartBytes: number;
632
+ tmuxDcsBytes: number;
633
+ evictions: number;
634
+ } {
635
+ return {
636
+ size: gifCache.size,
637
+ bytes: gifCacheBytes + gifCacheBase64Bytes + gifCacheMultipartBytes + gifCacheTmuxDcsBytes,
638
+ gifBytes: gifCacheBytes,
639
+ base64Bytes: gifCacheBase64Bytes,
640
+ multipartBytes: gifCacheMultipartBytes,
641
+ tmuxDcsBytes: gifCacheTmuxDcsBytes,
642
+ evictions: gifCacheEvictions,
643
+ };
644
+ }
645
+ export function resetGajaePetGifCache(): void {
646
+ gifCache.clear();
647
+ gifCacheBytes = 0;
648
+ gifCacheBase64Bytes = 0;
649
+ gifCacheMultipartBytes = 0;
650
+ gifCacheTmuxDcsBytes = 0;
651
+ gifCacheEvictions = 0;
652
+ }
653
+ export const clearGajaePetGifCache = resetGajaePetGifCache;
287
654
  /** Encode a grid as a transparent SIXEL image, optionally bottom-aligned by top padding. */
288
655
  export function encodeGridSixel(
289
656
  grid: string[],
@@ -349,6 +716,162 @@ export function encodeGridSixel(
349
716
  return `${out}\x1b\\`;
350
717
  }
351
718
 
719
+ const MAX_PET_PNG_DIMENSION = 16_384;
720
+ const MAX_PET_PNG_RAW_BYTES = 64 * 1024 * 1024;
721
+ const MAX_PET_FRAME_DIMENSION = 4_096;
722
+ const MAX_PET_FRAME_RGBA_BYTES = 16 * 1024 * 1024;
723
+
724
+ function pngChunk(type: string, data: Uint8Array): Uint8Array {
725
+ const typeBytes = Buffer.from(type, "ascii");
726
+ const payload = Buffer.concat([typeBytes, Buffer.from(data)]);
727
+ let crc = 0xffffffff;
728
+ for (const byte of payload) {
729
+ crc ^= byte;
730
+ for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
731
+ }
732
+ crc = (crc ^ 0xffffffff) >>> 0;
733
+ const out = Buffer.allocUnsafe(12 + data.length);
734
+ out.writeUInt32BE(data.length, 0);
735
+ Buffer.from(payload).copy(out, 4);
736
+ out.writeUInt32BE(crc, 8 + data.length);
737
+ return out;
738
+ }
739
+
740
+ function validatePngGrid(
741
+ grid: string[],
742
+ scale: number,
743
+ topPaddingPx: number,
744
+ bottomPaddingPx: number,
745
+ leftPaddingPx: number,
746
+ rightPaddingPx: number,
747
+ ): {
748
+ gridWidth: number;
749
+ gridHeight: number;
750
+ spriteWidth: number;
751
+ width: number;
752
+ spriteHeight: number;
753
+ height: number;
754
+ } {
755
+ const gridHeight = grid.length;
756
+ const gridWidth = grid[0]?.length ?? 0;
757
+ if (gridHeight === 0 || gridWidth === 0 || grid.some(row => row.length !== gridWidth)) {
758
+ throw new Error("iTerm2 pet grid must be non-empty and rectangular");
759
+ }
760
+ if (!Number.isFinite(scale) || scale <= 0) throw new Error("iTerm2 pet scale must be finite and positive");
761
+ for (const [name, value] of [
762
+ ["top padding", topPaddingPx],
763
+ ["bottom padding", bottomPaddingPx],
764
+ ["left padding", leftPaddingPx],
765
+ ["right padding", rightPaddingPx],
766
+ ] as const) {
767
+ if (!Number.isSafeInteger(value) || value < 0)
768
+ throw new Error(`iTerm2 pet ${name} must be a non-negative integer`);
769
+ }
770
+ const spriteWidth = Math.round(gridWidth * scale);
771
+ const spriteHeight = Math.round(gridHeight * scale);
772
+ const width = spriteWidth + leftPaddingPx + rightPaddingPx;
773
+ const height = spriteHeight + topPaddingPx + bottomPaddingPx;
774
+ if (
775
+ !Number.isSafeInteger(spriteWidth) ||
776
+ !Number.isSafeInteger(spriteHeight) ||
777
+ !Number.isSafeInteger(width) ||
778
+ !Number.isSafeInteger(height) ||
779
+ spriteWidth <= 0 ||
780
+ spriteHeight <= 0 ||
781
+ width <= 0 ||
782
+ height <= 0 ||
783
+ width > MAX_PET_PNG_DIMENSION ||
784
+ height > MAX_PET_PNG_DIMENSION
785
+ ) {
786
+ throw new Error("iTerm2 pet PNG dimensions are out of bounds");
787
+ }
788
+ const stride = width * 4 + 1;
789
+ const rawBytes = stride * height;
790
+ if (!Number.isSafeInteger(rawBytes) || rawBytes > MAX_PET_PNG_RAW_BYTES) {
791
+ throw new Error("iTerm2 pet PNG allocation is out of bounds");
792
+ }
793
+ return { gridWidth, gridHeight, spriteWidth, spriteHeight, width, height };
794
+ }
795
+
796
+ /**
797
+ * Encode a grid as an iTerm2 inline PNG spanning a terminal cell block.
798
+ *
799
+ * The escape's `width`/`height` are the reserved cell-block footprint in
800
+ * character cells (unitless numbers per the iTerm2 inline-images protocol).
801
+ * iTerm2 resolves cells against its own live font metrics, so the sprite
802
+ * scales with the real terminal geometry — including Retina, where iTerm2
803
+ * divides `Npx` values by the backing-scale factor and would render a fixed
804
+ * pixel box at half size.
805
+ */
806
+ export function encodeGridIterm2(
807
+ grid: string[],
808
+ scale: number,
809
+ columns: number,
810
+ rows: number,
811
+ topPaddingPx = 0,
812
+ bottomPaddingPx = 0,
813
+ leftPaddingPx = 0,
814
+ rightPaddingPx = 0,
815
+ palette: Palette = RED_PALETTE,
816
+ ): string {
817
+ for (const [name, value] of [
818
+ ["column count", columns],
819
+ ["row count", rows],
820
+ ] as const) {
821
+ if (!Number.isSafeInteger(value) || value <= 0) {
822
+ throw new Error(`iTerm2 pet ${name} must be a positive integer`);
823
+ }
824
+ }
825
+ const { gridWidth, gridHeight, spriteWidth, spriteHeight, width, height } = validatePngGrid(
826
+ grid,
827
+ scale,
828
+ topPaddingPx,
829
+ bottomPaddingPx,
830
+ leftPaddingPx,
831
+ rightPaddingPx,
832
+ );
833
+ const raw = Buffer.alloc((width * 4 + 1) * height);
834
+ for (let y = 0; y < height; y++) {
835
+ for (let x = 0; x < width; x++) {
836
+ const sourceX = x - leftPaddingPx;
837
+ const sourceY = y - topPaddingPx;
838
+ const rgb =
839
+ sourceX < 0 || sourceX >= spriteWidth || sourceY < 0 || sourceY >= spriteHeight
840
+ ? null
841
+ : palette[
842
+ grid[Math.min(gridHeight - 1, Math.floor(sourceY / scale))][
843
+ Math.min(gridWidth - 1, Math.floor(sourceX / scale))
844
+ ]
845
+ ];
846
+ const offset = y * (width * 4 + 1) + 1 + x * 4;
847
+ if (!rgb) continue;
848
+ raw[offset] = rgb[0];
849
+ raw[offset + 1] = rgb[1];
850
+ raw[offset + 2] = rgb[2];
851
+ raw[offset + 3] = 255;
852
+ }
853
+ }
854
+ const compressed = zlib.deflateSync(raw);
855
+ const header = Buffer.alloc(13);
856
+ header.writeUInt32BE(width, 0);
857
+ header.writeUInt32BE(height, 4);
858
+ header[8] = 8;
859
+ header[9] = 6;
860
+ const png = Buffer.concat([
861
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
862
+ pngChunk("IHDR", header),
863
+ pngChunk("IDAT", compressed),
864
+ pngChunk("IEND", new Uint8Array()),
865
+ ]);
866
+ // Size in character cells, not pixels: iTerm2 applies its own cell metrics
867
+ // (and divides px values by the Retina backing scale), so cells keep the
868
+ // padded canvas 1:1 with the reserved block at every geometry.
869
+ // iTerm uses the supplied filename when a user drags this inline image out.
870
+ // The unique name lets the composer discard only this pet's automatic path paste.
871
+ const params = `name=Z2FqYWUtcGV0LnBuZw==;width=${columns};height=${rows};preserveAspectRatio=0;inline=1`;
872
+ return `\x1b]1337;File=${params}:${png.toString("base64")}\x1b\\`;
873
+ }
874
+
352
875
  /** Encode a bottom-aligned grid as kitty raw RGBA at `scale`. */
353
876
  export function encodeGridKitty(
354
877
  grid: string[],
@@ -407,10 +930,12 @@ export function encodeGridKitty(
407
930
 
408
931
  export interface GajaePixelFrames {
409
932
  /** escape payload per logical frame (drawn at the current cursor cell) */
410
- frames: Record<GajaePixelFrameName, string>;
933
+ frames: Record<string, string>;
411
934
  /** protocol the frames were encoded for */
412
- protocol: "sixel" | "kitty";
935
+ protocol: "sixel" | "kitty" | "iterm2";
936
+ /** Scaled sprite width before transparent cell-block padding. */
413
937
  widthPx: number;
938
+ /** Encoded raster height, including protocol-specific transparent padding. */
414
939
  heightPx: number;
415
940
  columns: number;
416
941
  rows: number;
@@ -420,11 +945,22 @@ export interface GajaePixelFrames {
420
945
 
421
946
  /**
422
947
  * Build overlay pixel frames exactly `targetRows` terminal rows tall when the
423
- * terminal cells permit it. Nearest-neighbor sampling preserves the 16x16 art
424
- * while allowing fractional scale factors such as 36px / 16px.
948
+ * terminal cells permit it. Each skin owns its source resolution so future
949
+ * additions can opt into denser art without changing the terminal footprint.
950
+ *
951
+ * Geometry contract:
952
+ * - `scale = max(1, targetRows * cellHeightPx / gridHeight)`
953
+ * - `columns = ceil(scaledSpriteWidthPx / cellWidthPx)`
954
+ * - `rows = ceil(scaledSpriteHeightPx / cellHeightPx)`
955
+ * - the square sprite is centered in a `columns * cellWidthPx` PNG canvas
956
+ *
957
+ * iTerm2 receives unitless `width=columns;height=rasterRows`, so it resolves the
958
+ * padded block with its live cell metrics. The PNG has that block's pixel aspect
959
+ * ratio, allowing `preserveAspectRatio=0` without stretching the authored square
960
+ * sprite. Kitty and Sixel retain their protocol-specific paths.
425
961
  */
426
962
  export function buildGajaePixelFrames(options: {
427
- protocol: "sixel" | "kitty";
963
+ protocol: "sixel" | "kitty" | "iterm2";
428
964
  cellWidthPx: number;
429
965
  cellHeightPx: number;
430
966
  targetRows?: number;
@@ -433,46 +969,106 @@ export function buildGajaePixelFrames(options: {
433
969
  /** Native sub-cell `Y=` pixel offset that drops the kitty sprite within its first cell. */
434
970
  kittyCellYOffsetPx?: number;
435
971
  kittyImageId?: number;
972
+ /** Additional transparent iTerm2-only top padding for sub-cell vertical alignment. */
973
+ iterm2TopPaddingPx?: number;
974
+ /** Transparent iTerm2-only bottom padding inside the canvas. */
975
+ iterm2BottomPaddingPx?: number;
436
976
  /** Color skin for the sprite palette (default "red"). */
437
977
  skin?: PetSkinId;
438
978
  }): GajaePixelFrames {
439
979
  const targetRows = options.targetRows ?? 2;
440
- const gridSize = 16;
441
- const scale = Math.max(1, (targetRows * options.cellHeightPx) / gridSize);
442
- const widthPx = Math.round(gridSize * scale);
443
- const visibleHeightPx = Math.round(gridSize * scale);
980
+ if (!Number.isFinite(options.cellWidthPx) || options.cellWidthPx <= 0) {
981
+ throw new Error("Pet cell width must be finite and positive");
982
+ }
983
+ if (!Number.isFinite(options.cellHeightPx) || options.cellHeightPx <= 0) {
984
+ throw new Error("Pet cell height must be finite and positive");
985
+ }
986
+ if (!Number.isFinite(targetRows) || targetRows <= 0) throw new Error("Pet target rows must be finite and positive");
987
+ const skin = PET_SKINS[options.skin ?? "red"];
988
+ const grids = Object.entries(skin.frames);
989
+ const firstGrid = grids[0]?.[1];
990
+ if (!firstGrid?.[0]) throw new Error(`Pet skin ${skin.id} has no pixel frames`);
991
+ const gridHeight = firstGrid.length;
992
+ const gridWidth = firstGrid[0].length;
993
+ for (const [name, grid] of grids) {
994
+ if (grid.length !== gridHeight || grid.some(row => row.length !== gridWidth)) {
995
+ throw new Error(`Pet frame ${skin.id}/${name} does not match ${gridWidth}x${gridHeight}`);
996
+ }
997
+ }
998
+ const scale = Math.max(1, (targetRows * options.cellHeightPx) / gridHeight);
999
+ const widthPx = Math.round(gridWidth * scale);
1000
+ const visibleHeightPx = Math.round(gridHeight * scale);
444
1001
  const columns = Math.ceil(widthPx / options.cellWidthPx);
445
1002
  const rows = Math.ceil(visibleHeightPx / options.cellHeightPx);
446
1003
  const allocatedHeightPx = rows * options.cellHeightPx;
447
1004
  const topPaddingPx =
448
1005
  allocatedHeightPx - visibleHeightPx + (options.protocol === "sixel" ? (options.sixelTopPaddingPx ?? 0) : 0);
449
1006
  const heightPx = visibleHeightPx + topPaddingPx;
450
- const rasterRows = Math.ceil(heightPx / options.cellHeightPx);
1007
+ const kittyYOffsetPx = options.protocol === "kitty" ? Math.max(0, Math.round(options.kittyCellYOffsetPx ?? 0)) : 0;
451
1008
  // Center the square sprite in its (cols * cellWidth) block, which the ceil()
452
1009
  // column rounding can make wider than the sprite itself.
453
1010
  const horizontalPaddingPx = Math.max(0, columns * options.cellWidthPx - widthPx);
454
1011
  const leftPaddingPx = Math.floor(horizontalPaddingPx / 2);
455
1012
  const rightPaddingPx = horizontalPaddingPx - leftPaddingPx;
1013
+ const canvasWidthPx = widthPx + leftPaddingPx + rightPaddingPx;
1014
+ // When minimum 1x art is taller than targetRows (only possible with tiny
1015
+ // cells), top-pad iTerm2 to the full reserved row block. Its unitless OSC
1016
+ // height then maps the PNG 1:1 without vertically stretching the authored square sprite.
1017
+ const iterm2TopPaddingPx =
1018
+ allocatedHeightPx - visibleHeightPx + (options.protocol === "iterm2" ? (options.iterm2TopPaddingPx ?? 0) : 0);
1019
+ const protocolHeightPx =
1020
+ options.protocol === "iterm2"
1021
+ ? visibleHeightPx + iterm2TopPaddingPx + (options.iterm2BottomPaddingPx ?? 0)
1022
+ : heightPx;
1023
+ const rasterRows = Math.ceil((protocolHeightPx + kittyYOffsetPx) / options.cellHeightPx);
1024
+ if (
1025
+ widthPx > MAX_PET_FRAME_DIMENSION ||
1026
+ heightPx > MAX_PET_FRAME_DIMENSION ||
1027
+ canvasWidthPx > MAX_PET_FRAME_DIMENSION ||
1028
+ !Number.isSafeInteger(canvasWidthPx * heightPx * 4) ||
1029
+ canvasWidthPx * heightPx * 4 > MAX_PET_FRAME_RGBA_BYTES
1030
+ ) {
1031
+ throw new Error("Pet frame dimensions are out of bounds");
1032
+ }
456
1033
  const imageId = options.kittyImageId ?? 0xc0de;
457
- const palette = PET_SKINS[options.skin ?? "red"].palette;
458
- const frames = {} as Record<GajaePixelFrameName, string>;
459
- for (const name of Object.keys(PIXEL_GRIDS) as GajaePixelFrameName[]) {
1034
+ const frames: Record<string, string> = {};
1035
+ for (const [name, grid] of grids) {
460
1036
  frames[name] =
461
1037
  options.protocol === "sixel"
462
- ? encodeGridSixel(PIXEL_GRIDS[name], scale, topPaddingPx, palette)
463
- : encodeGridKitty(
464
- PIXEL_GRIDS[name],
465
- scale,
466
- imageId,
467
- columns,
468
- rows,
469
- topPaddingPx,
470
- options.kittyCellYOffsetPx ?? 0,
471
- leftPaddingPx,
472
- rightPaddingPx,
473
- palette,
474
- );
1038
+ ? encodeGridSixel(grid, scale, topPaddingPx, skin.palette)
1039
+ : options.protocol === "iterm2"
1040
+ ? encodeGridIterm2(
1041
+ grid,
1042
+ scale,
1043
+ columns,
1044
+ rasterRows,
1045
+ iterm2TopPaddingPx,
1046
+ options.iterm2BottomPaddingPx ?? 0,
1047
+ leftPaddingPx,
1048
+ rightPaddingPx,
1049
+ skin.palette,
1050
+ )
1051
+ : encodeGridKitty(
1052
+ grid,
1053
+ scale,
1054
+ imageId,
1055
+ columns,
1056
+ rows,
1057
+ topPaddingPx,
1058
+ options.kittyCellYOffsetPx ?? 0,
1059
+ leftPaddingPx,
1060
+ rightPaddingPx,
1061
+ skin.palette,
1062
+ );
475
1063
  }
476
1064
 
477
- return { frames, protocol: options.protocol, widthPx, heightPx, columns, rows, rasterRows };
1065
+ return {
1066
+ frames,
1067
+ protocol: options.protocol,
1068
+ widthPx,
1069
+ heightPx: protocolHeightPx,
1070
+ columns,
1071
+ rows,
1072
+ rasterRows,
1073
+ };
478
1074
  }