@gajae-code/tui 0.13.2 → 0.13.3

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,25 @@
1
+ import * as zlib from "node:zlib";
2
+
3
+ import {
4
+ OUROBOROS_HEART_STEPS,
5
+ OUROBOROS_IDLE_STEPS,
6
+ OUROBOROS_PIXEL_GRIDS,
7
+ OUROBOROS_WORK_CRY_STEPS,
8
+ OUROBOROS_WORK_ENTER_STEPS,
9
+ OUROBOROS_WORK_EXIT_STEPS,
10
+ OUROBOROS_WORK_HEART_STEPS,
11
+ OUROBOROS_WORK_STEPS,
12
+ type OuroborosFrameName,
13
+ } from "./ouroboros-pet";
14
+
1
15
  /**
2
16
  * ┌─ 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.
17
+ * Pets are square pixel sprites drawn beside the composer. Everything here is
18
+ * data: no PNGs or binary assets. Current pet frames are 16×16 and render into
19
+ * the same terminal footprint.
6
20
  *
7
21
  * GRID RULES
8
- * - Exactly 16 rows × 16 columns. Only PALETTE keys below are valid chars.
22
+ * - Every frame within a skin has the same square dimensions.
9
23
  * - `.` = transparent. Keep the outer columns transparent so the sprite sits
10
24
  * snug beside the input box (the widget reserves +1 column of slack).
11
25
  *
@@ -26,7 +40,8 @@
26
40
  * RENDERING: buildGajaePixelFrames({ protocol, cellWidthPx, cellHeightPx,
27
41
  * targetRows: 2 }) scales the art to 2 terminal rows and encodes each frame
28
42
  * 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.
43
+ * composer border; sixel uses transparent top padding; iTerm2 uses an inline PNG
44
+ * sized to the reserved cell block.
30
45
  *
31
46
  * BEHAVIOR (timing, positioning, on/off) lives in
32
47
  * packages/coding-agent/src/modes/components/gajae-pet-widget.ts.
@@ -34,17 +49,15 @@
34
49
  * ADD A FRAME: draw the grid → add its name to GajaePixelFrameName → register it in
35
50
  * PIXEL_GRIDS → reference it from an idle/work loop or a skin burst.
36
51
  *
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.
52
+ * ADD A PET (skin): append one PET_SKINS entry with its palette, frame registry,
53
+ * base/idle/work animations and burst. The id flows into PetSkinId/PetMode
54
+ * automatically; settings, `/pet`, and both selectors derive from PET_SKINS.
42
55
  * └────────────────────────────────────────────────────────────────────────┘
43
56
  */
44
57
  type Rgb = readonly [number, number, number];
45
58
 
46
59
  export type Palette = Record<string, Rgb | null>;
47
- export const PET_SKIN_IDS = ["red", "blue"] as const;
60
+ export const PET_SKIN_IDS = ["red", "blue", "ouroboros"] as const;
48
61
  export type PetSkinId = (typeof PET_SKIN_IDS)[number];
49
62
  /** Every pet mode: "off" plus each skin id, in menu order. */
50
63
  export const PET_MODE_IDS = ["off", ...PET_SKIN_IDS] as const;
@@ -54,6 +67,11 @@ export function isPetMode(value: string): value is PetMode {
54
67
  return (PET_MODE_IDS as readonly string[]).includes(value);
55
68
  }
56
69
 
70
+ /** Resolve a persisted mode after a skin has been removed. Explicit "off" remains off. */
71
+ export function resolvePetMode(value: string): PetMode {
72
+ return isPetMode(value) ? value : "red";
73
+ }
74
+
57
75
  const RED_PALETTE: Palette = {
58
76
  ".": null, // transparent
59
77
  K: [74, 20, 8], // outline dark
@@ -79,6 +97,15 @@ const BLUE_PALETTE: Palette = {
79
97
  A: [37, 120, 200], // muted blue (antenna)
80
98
  w: [230, 247, 255], // foam (tear)
81
99
  };
100
+ const OUROBOROS_PALETTE: Palette = {
101
+ ".": null,
102
+ D: [20, 100, 48], // closed / crying eye
103
+ R: [174, 232, 14], // vivid lime body
104
+ r: [112, 146, 190], // cool blue underside (#7092BE)
105
+ G: [255, 231, 134], // pale-yellow eye
106
+ A: [255, 137, 180], // pink tongue and heart accent
107
+ w: [190, 231, 255], // tear
108
+ };
82
109
 
83
110
  // ------------------------------------------------------------------------
84
111
  // Real-pixel frames (codex-pets style): the same grids encoded as terminal
@@ -188,6 +215,7 @@ export type GajaePixelFrameName =
188
215
  | "cry1"
189
216
  | "cry2"
190
217
  | "cry3";
218
+ export type PetFrameName = GajaePixelFrameName | OuroborosFrameName;
191
219
 
192
220
  const PIXEL_GRIDS: Record<GajaePixelFrameName, string[]> = {
193
221
  base: F0,
@@ -210,6 +238,14 @@ export const PARA_PARA_STEPS: ReadonlyArray<readonly [GajaePixelFrameName, numbe
210
238
  ["flex", 480],
211
239
  ["base", 260],
212
240
  ];
241
+ export const GAJAE_IDLE_STEPS: ReadonlyArray<readonly [GajaePixelFrameName, number]> = [
242
+ ["base", 1100],
243
+ ["gazeL", 350],
244
+ ["base", 500],
245
+ ["gazeR", 350],
246
+ ["base", 800],
247
+ ["flicker", 150],
248
+ ];
213
249
 
214
250
  /**
215
251
  * A skin's idle burst: a short intro sequence, then an optional looping tail. It drives
@@ -218,9 +254,9 @@ export const PARA_PARA_STEPS: ReadonlyArray<readonly [GajaePixelFrameName, numbe
218
254
  */
219
255
  export interface PetBurst {
220
256
  /** Frames played once, in order, at the start of the burst. */
221
- intro: ReadonlyArray<readonly [GajaePixelFrameName, number]>;
257
+ intro: ReadonlyArray<readonly [PetFrameName, number]>;
222
258
  /** Frames cycled every `stepMs` for `ms` after the intro (a held or looping finish). */
223
- tail?: { frames: readonly GajaePixelFrameName[]; stepMs: number; ms: number };
259
+ tail?: { frames: readonly PetFrameName[]; stepMs: number; ms: number };
224
260
  }
225
261
 
226
262
  /** Everything that defines a pet skin: identity, UI copy, colors and behavior. */
@@ -231,8 +267,16 @@ export interface PetSkin {
231
267
  /** One-line selector/settings description. */
232
268
  description: string;
233
269
  palette: Palette;
270
+ frames: Readonly<Record<string, string[]>>;
271
+ baseFrame: PetFrameName;
272
+ idle: ReadonlyArray<readonly [PetFrameName, number]>;
273
+ workEnter?: ReadonlyArray<readonly [PetFrameName, number]>;
274
+ work: ReadonlyArray<readonly [PetFrameName, number]>;
275
+ workExit?: ReadonlyArray<readonly [PetFrameName, number]>;
234
276
  /** Idle burst animation played between quiet idle loops. */
235
277
  burst: PetBurst;
278
+ /** Optional variants that interrupt and then resume the work loop. */
279
+ workBursts?: readonly PetBurst[];
236
280
  }
237
281
 
238
282
  /** Skin registry — the single source for palettes, behavior and selector/command copy. */
@@ -242,6 +286,10 @@ export const PET_SKINS: Record<PetSkinId, PetSkin> = {
242
286
  label: "RedGajae",
243
287
  description: "The Red Crab, who likes to work-out.",
244
288
  palette: RED_PALETTE,
289
+ frames: PIXEL_GRIDS,
290
+ baseFrame: "base",
291
+ idle: GAJAE_IDLE_STEPS,
292
+ work: PARA_PARA_STEPS,
245
293
  burst: {
246
294
  intro: PARA_PARA_STEPS,
247
295
  tail: { frames: ["flex", "base"], stepMs: 200, ms: 1000 },
@@ -252,11 +300,31 @@ export const PET_SKINS: Record<PetSkinId, PetSkin> = {
252
300
  label: "BlueGajae",
253
301
  description: "The Blue Crab, who wants to rest.",
254
302
  palette: BLUE_PALETTE,
303
+ frames: PIXEL_GRIDS,
304
+ baseFrame: "base",
305
+ idle: GAJAE_IDLE_STEPS,
306
+ work: PARA_PARA_STEPS,
255
307
  burst: {
256
308
  intro: PARA_PARA_STEPS,
257
309
  tail: { frames: ["cry1", "cry2", "cry3"], stepMs: 110, ms: 990 },
258
310
  },
259
311
  },
312
+ ouroboros: {
313
+ id: "ouroboros",
314
+ label: "Ouroboros",
315
+ description: "The little snake who keeps going.",
316
+ palette: OUROBOROS_PALETTE,
317
+ frames: OUROBOROS_PIXEL_GRIDS,
318
+ baseFrame: "idle",
319
+ idle: OUROBOROS_IDLE_STEPS,
320
+ workEnter: OUROBOROS_WORK_ENTER_STEPS,
321
+ work: OUROBOROS_WORK_STEPS,
322
+ workExit: OUROBOROS_WORK_EXIT_STEPS,
323
+ burst: {
324
+ intro: OUROBOROS_HEART_STEPS,
325
+ },
326
+ workBursts: [{ intro: OUROBOROS_WORK_HEART_STEPS }, { intro: OUROBOROS_WORK_CRY_STEPS }],
327
+ },
260
328
  };
261
329
 
262
330
  /** Total burst duration (intro beats plus the looping tail). */
@@ -266,7 +334,7 @@ export function petBurstDurationMs(burst: PetBurst): number {
266
334
  }
267
335
 
268
336
  /** 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 {
337
+ export function petBurstFrame(burst: PetBurst, elapsed: number, now: number): PetFrameName {
270
338
  let t = elapsed;
271
339
  for (const [frame, ms] of burst.intro) {
272
340
  if (t < ms) return frame;
@@ -279,8 +347,10 @@ export function petBurstFrame(burst: PetBurst, elapsed: number, now: number): Ga
279
347
 
280
348
  /** Test-only access to logical art; production rendering still uses encoded frames. */
281
349
  export const __gajaePetTestHooks = {
282
- getPixelGrid(name: GajaePixelFrameName): string[] {
283
- return [...PIXEL_GRIDS[name]];
350
+ getPixelGrid(name: PetFrameName, skin: PetSkinId = "red"): string[] {
351
+ const grid = PET_SKINS[skin].frames[name];
352
+ if (!grid) throw new Error(`Unknown ${skin} pet frame: ${name}`);
353
+ return [...grid];
284
354
  },
285
355
  };
286
356
 
@@ -349,6 +419,128 @@ export function encodeGridSixel(
349
419
  return `${out}\x1b\\`;
350
420
  }
351
421
 
422
+ const MAX_PET_PNG_DIMENSION = 16_384;
423
+ const MAX_PET_PNG_RAW_BYTES = 64 * 1024 * 1024;
424
+ const MAX_PET_FRAME_DIMENSION = 4_096;
425
+ const MAX_PET_FRAME_RGBA_BYTES = 16 * 1024 * 1024;
426
+
427
+ function pngChunk(type: string, data: Uint8Array): Uint8Array {
428
+ const typeBytes = Buffer.from(type, "ascii");
429
+ const payload = Buffer.concat([typeBytes, Buffer.from(data)]);
430
+ let crc = 0xffffffff;
431
+ for (const byte of payload) {
432
+ crc ^= byte;
433
+ for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
434
+ }
435
+ crc = (crc ^ 0xffffffff) >>> 0;
436
+ const out = Buffer.allocUnsafe(12 + data.length);
437
+ out.writeUInt32BE(data.length, 0);
438
+ Buffer.from(payload).copy(out, 4);
439
+ out.writeUInt32BE(crc, 8 + data.length);
440
+ return out;
441
+ }
442
+
443
+ function validatePngGrid(
444
+ grid: string[],
445
+ scale: number,
446
+ topPaddingPx: number,
447
+ bottomPaddingPx: number,
448
+ ): {
449
+ gridWidth: number;
450
+ gridHeight: number;
451
+ width: number;
452
+ spriteHeight: number;
453
+ height: number;
454
+ } {
455
+ const gridHeight = grid.length;
456
+ const gridWidth = grid[0]?.length ?? 0;
457
+ if (gridHeight === 0 || gridWidth === 0 || grid.some(row => row.length !== gridWidth)) {
458
+ throw new Error("iTerm2 pet grid must be non-empty and rectangular");
459
+ }
460
+ if (!Number.isFinite(scale) || scale <= 0) throw new Error("iTerm2 pet scale must be finite and positive");
461
+ for (const [name, value] of [
462
+ ["top padding", topPaddingPx],
463
+ ["bottom padding", bottomPaddingPx],
464
+ ] as const) {
465
+ if (!Number.isSafeInteger(value) || value < 0)
466
+ throw new Error(`iTerm2 pet ${name} must be a non-negative integer`);
467
+ }
468
+ const width = Math.round(gridWidth * scale);
469
+ const spriteHeight = Math.round(gridHeight * scale);
470
+ const height = spriteHeight + topPaddingPx + bottomPaddingPx;
471
+ if (
472
+ !Number.isSafeInteger(width) ||
473
+ !Number.isSafeInteger(spriteHeight) ||
474
+ !Number.isSafeInteger(height) ||
475
+ width <= 0 ||
476
+ spriteHeight <= 0 ||
477
+ height <= 0 ||
478
+ width > MAX_PET_PNG_DIMENSION ||
479
+ height > MAX_PET_PNG_DIMENSION
480
+ ) {
481
+ throw new Error("iTerm2 pet PNG dimensions are out of bounds");
482
+ }
483
+ const stride = width * 4 + 1;
484
+ const rawBytes = stride * height;
485
+ if (!Number.isSafeInteger(rawBytes) || rawBytes > MAX_PET_PNG_RAW_BYTES) {
486
+ throw new Error("iTerm2 pet PNG allocation is out of bounds");
487
+ }
488
+ return { gridWidth, gridHeight, width, spriteHeight, height };
489
+ }
490
+
491
+ /** Encode a grid as an iTerm2 inline PNG. */
492
+ export function encodeGridIterm2(
493
+ grid: string[],
494
+ scale: number,
495
+ topPaddingPx = 0,
496
+ bottomPaddingPx = 0,
497
+ palette: Palette = RED_PALETTE,
498
+ ): string {
499
+ const { gridWidth, gridHeight, width, spriteHeight, height } = validatePngGrid(
500
+ grid,
501
+ scale,
502
+ topPaddingPx,
503
+ bottomPaddingPx,
504
+ );
505
+ const raw = Buffer.alloc((width * 4 + 1) * height);
506
+ for (let y = 0; y < height; y++) {
507
+ for (let x = 0; x < width; x++) {
508
+ const sourceY = y - topPaddingPx;
509
+ const rgb =
510
+ sourceY < 0 || sourceY >= spriteHeight
511
+ ? null
512
+ : palette[
513
+ grid[Math.min(gridHeight - 1, Math.floor(sourceY / scale))][
514
+ Math.min(gridWidth - 1, Math.floor(x / scale))
515
+ ]
516
+ ];
517
+ const offset = y * (width * 4 + 1) + 1 + x * 4;
518
+ if (!rgb) continue;
519
+ raw[offset] = rgb[0];
520
+ raw[offset + 1] = rgb[1];
521
+ raw[offset + 2] = rgb[2];
522
+ raw[offset + 3] = 255;
523
+ }
524
+ }
525
+ const compressed = zlib.deflateSync(raw);
526
+ const header = Buffer.alloc(13);
527
+ header.writeUInt32BE(width, 0);
528
+ header.writeUInt32BE(height, 4);
529
+ header[8] = 8;
530
+ header[9] = 6;
531
+ const png = Buffer.concat([
532
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
533
+ pngChunk("IHDR", header),
534
+ pngChunk("IDAT", compressed),
535
+ pngChunk("IEND", new Uint8Array()),
536
+ ]);
537
+ // Use explicit pixel dimensions. Cell dimensions are only a layout hint for
538
+ // the surrounding TUI; asking iTerm2 to fit a small PNG into a cell box makes
539
+ // it stretch or shrink differently from Kitty/Ghostty.
540
+ const params = `width=${width}px;height=${height}px;preserveAspectRatio=0;inline=1`;
541
+ return `\x1b]1337;File=${params}:${png.toString("base64")}\x1b\\`;
542
+ }
543
+
352
544
  /** Encode a bottom-aligned grid as kitty raw RGBA at `scale`. */
353
545
  export function encodeGridKitty(
354
546
  grid: string[],
@@ -407,9 +599,9 @@ export function encodeGridKitty(
407
599
 
408
600
  export interface GajaePixelFrames {
409
601
  /** escape payload per logical frame (drawn at the current cursor cell) */
410
- frames: Record<GajaePixelFrameName, string>;
602
+ frames: Record<string, string>;
411
603
  /** protocol the frames were encoded for */
412
- protocol: "sixel" | "kitty";
604
+ protocol: "sixel" | "kitty" | "iterm2";
413
605
  widthPx: number;
414
606
  heightPx: number;
415
607
  columns: number;
@@ -420,11 +612,11 @@ export interface GajaePixelFrames {
420
612
 
421
613
  /**
422
614
  * 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.
615
+ * terminal cells permit it. Each skin owns its source resolution so future
616
+ * additions can opt into denser art without changing the terminal footprint.
425
617
  */
426
618
  export function buildGajaePixelFrames(options: {
427
- protocol: "sixel" | "kitty";
619
+ protocol: "sixel" | "kitty" | "iterm2";
428
620
  cellWidthPx: number;
429
621
  cellHeightPx: number;
430
622
  targetRows?: number;
@@ -433,46 +625,95 @@ export function buildGajaePixelFrames(options: {
433
625
  /** Native sub-cell `Y=` pixel offset that drops the kitty sprite within its first cell. */
434
626
  kittyCellYOffsetPx?: number;
435
627
  kittyImageId?: number;
628
+ /** Transparent iTerm2-only top padding for half-cell vertical alignment. */
629
+ iterm2TopPaddingPx?: number;
630
+ /** Transparent iTerm2-only bottom padding inside the two-row canvas. */
631
+ iterm2BottomPaddingPx?: number;
436
632
  /** Color skin for the sprite palette (default "red"). */
437
633
  skin?: PetSkinId;
438
634
  }): GajaePixelFrames {
439
635
  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);
636
+ if (!Number.isFinite(options.cellWidthPx) || options.cellWidthPx <= 0) {
637
+ throw new Error("Pet cell width must be finite and positive");
638
+ }
639
+ if (!Number.isFinite(options.cellHeightPx) || options.cellHeightPx <= 0) {
640
+ throw new Error("Pet cell height must be finite and positive");
641
+ }
642
+ if (!Number.isFinite(targetRows) || targetRows <= 0) throw new Error("Pet target rows must be finite and positive");
643
+ const skin = PET_SKINS[options.skin ?? "red"];
644
+ const grids = Object.entries(skin.frames);
645
+ const firstGrid = grids[0]?.[1];
646
+ if (!firstGrid?.[0]) throw new Error(`Pet skin ${skin.id} has no pixel frames`);
647
+ const gridHeight = firstGrid.length;
648
+ const gridWidth = firstGrid[0].length;
649
+ for (const [name, grid] of grids) {
650
+ if (grid.length !== gridHeight || grid.some(row => row.length !== gridWidth)) {
651
+ throw new Error(`Pet frame ${skin.id}/${name} does not match ${gridWidth}x${gridHeight}`);
652
+ }
653
+ }
654
+ const scale = Math.max(1, (targetRows * options.cellHeightPx) / gridHeight);
655
+ const widthPx = Math.round(gridWidth * scale);
656
+ const visibleHeightPx = Math.round(gridHeight * scale);
444
657
  const columns = Math.ceil(widthPx / options.cellWidthPx);
445
658
  const rows = Math.ceil(visibleHeightPx / options.cellHeightPx);
446
659
  const allocatedHeightPx = rows * options.cellHeightPx;
447
660
  const topPaddingPx =
448
661
  allocatedHeightPx - visibleHeightPx + (options.protocol === "sixel" ? (options.sixelTopPaddingPx ?? 0) : 0);
449
662
  const heightPx = visibleHeightPx + topPaddingPx;
450
- const rasterRows = Math.ceil(heightPx / options.cellHeightPx);
451
663
  // Center the square sprite in its (cols * cellWidth) block, which the ceil()
452
664
  // column rounding can make wider than the sprite itself.
453
665
  const horizontalPaddingPx = Math.max(0, columns * options.cellWidthPx - widthPx);
454
666
  const leftPaddingPx = Math.floor(horizontalPaddingPx / 2);
455
667
  const rightPaddingPx = horizontalPaddingPx - leftPaddingPx;
668
+ const canvasWidthPx = widthPx + leftPaddingPx + rightPaddingPx;
669
+ if (
670
+ widthPx > MAX_PET_FRAME_DIMENSION ||
671
+ heightPx > MAX_PET_FRAME_DIMENSION ||
672
+ canvasWidthPx > MAX_PET_FRAME_DIMENSION ||
673
+ !Number.isSafeInteger(canvasWidthPx * heightPx * 4) ||
674
+ canvasWidthPx * heightPx * 4 > MAX_PET_FRAME_RGBA_BYTES
675
+ ) {
676
+ throw new Error("Pet frame dimensions are out of bounds");
677
+ }
456
678
  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[]) {
679
+ const frames: Record<string, string> = {};
680
+ for (const [name, grid] of grids) {
460
681
  frames[name] =
461
682
  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
- );
683
+ ? encodeGridSixel(grid, scale, topPaddingPx, skin.palette)
684
+ : options.protocol === "iterm2"
685
+ ? encodeGridIterm2(
686
+ grid,
687
+ scale,
688
+ options.iterm2TopPaddingPx ?? 0,
689
+ options.iterm2BottomPaddingPx ?? 0,
690
+ skin.palette,
691
+ )
692
+ : encodeGridKitty(
693
+ grid,
694
+ scale,
695
+ imageId,
696
+ columns,
697
+ rows,
698
+ topPaddingPx,
699
+ options.kittyCellYOffsetPx ?? 0,
700
+ leftPaddingPx,
701
+ rightPaddingPx,
702
+ skin.palette,
703
+ );
475
704
  }
476
705
 
477
- return { frames, protocol: options.protocol, widthPx, heightPx, columns, rows, rasterRows };
706
+ const protocolHeightPx =
707
+ options.protocol === "iterm2"
708
+ ? visibleHeightPx + (options.iterm2TopPaddingPx ?? 0) + (options.iterm2BottomPaddingPx ?? 0)
709
+ : heightPx;
710
+ return {
711
+ frames,
712
+ protocol: options.protocol,
713
+ widthPx,
714
+ heightPx: protocolHeightPx,
715
+ columns,
716
+ rows,
717
+ rasterRows: Math.ceil(protocolHeightPx / options.cellHeightPx),
718
+ };
478
719
  }