@gajae-code/tui 0.9.5 → 0.10.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.
@@ -0,0 +1,478 @@
1
+ /**
2
+ * ┌─ 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.
6
+ *
7
+ * GRID RULES
8
+ * - Exactly 16 rows × 16 columns. Only PALETTE keys below are valid chars.
9
+ * - `.` = transparent. Keep the outer columns transparent so the sprite sits
10
+ * snug beside the input box (the widget reserves +1 column of slack).
11
+ *
12
+ * PALETTE (char → role) — see PALETTE for exact RGB:
13
+ * .=transparent K=dark outline R=body red r=red highlight
14
+ * V=visor screen G=visor glow(green) H=satgat straw h=satgat brim
15
+ * b=belly tan A=antenna
16
+ *
17
+ * FRAME CATALOG (GajaePixelFrameName → PIXEL_GRIDS):
18
+ * base idle rest; also the dance "drop/settle" beat
19
+ * gazeL eyes glance left ┐ idle loop (see gajae-pet-widget IDLE_LOOP)
20
+ * gazeR eyes glance right │
21
+ * flicker visor blink ┘
22
+ * flex both claws up + `^^`; dance accent + random idle flex burst
23
+ * danceL left claw up + `><` + feet step left ┐ work loop (PARA_PARA_STEPS)
24
+ * danceR right claw up + `^^` + feet step right ┘
25
+ *
26
+ * RENDERING: buildGajaePixelFrames({ protocol, cellWidthPx, cellHeightPx,
27
+ * targetRows: 2 }) scales the art to 2 terminal rows and encodes each frame
28
+ * 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.
30
+ *
31
+ * BEHAVIOR (timing, positioning, on/off) lives in
32
+ * packages/coding-agent/src/modes/components/gajae-pet-widget.ts.
33
+ *
34
+ * ADD A FRAME: draw the grid → add its name to GajaePixelFrameName → register it in
35
+ * PIXEL_GRIDS → reference it from an idle/work loop or a skin burst.
36
+ *
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.
42
+ * └────────────────────────────────────────────────────────────────────────┘
43
+ */
44
+ type Rgb = readonly [number, number, number];
45
+
46
+ export type Palette = Record<string, Rgb | null>;
47
+ export const PET_SKIN_IDS = ["red", "blue"] as const;
48
+ export type PetSkinId = (typeof PET_SKIN_IDS)[number];
49
+ /** Every pet mode: "off" plus each skin id, in menu order. */
50
+ export const PET_MODE_IDS = ["off", ...PET_SKIN_IDS] as const;
51
+ export type PetMode = (typeof PET_MODE_IDS)[number];
52
+ /** Narrow an arbitrary string to a PetMode. */
53
+ export function isPetMode(value: string): value is PetMode {
54
+ return (PET_MODE_IDS as readonly string[]).includes(value);
55
+ }
56
+
57
+ const RED_PALETTE: Palette = {
58
+ ".": null, // transparent
59
+ K: [74, 20, 8], // outline dark
60
+ R: [229, 72, 46], // body
61
+ r: [255, 122, 82], // body highlight
62
+ V: [14, 22, 14], // visor screen (dark)
63
+ G: [61, 245, 146], // visor glow green
64
+ H: [232, 180, 90], // satgat straw
65
+ h: [169, 117, 47], // satgat brim
66
+ b: [216, 154, 74], // belly tan
67
+ A: [196, 60, 30], // antenna
68
+ w: [200, 230, 255], // tear (BlueGajae sob)
69
+ };
70
+ // BlueGajae recolors the crab to match the "blue-crab" theme (crabShell body, claw
71
+ // highlight, deep-ocean outline, azure belly, foam tears); the straw hat and green
72
+ // are shared across skins.
73
+ const BLUE_PALETTE: Palette = {
74
+ ...RED_PALETTE,
75
+ K: [7, 38, 74], // deep ocean (outline)
76
+ R: [47, 155, 255], // crabShell (body)
77
+ r: [94, 200, 255], // claw (highlight)
78
+ b: [125, 211, 252], // azure (belly)
79
+ A: [37, 120, 200], // muted blue (antenna)
80
+ w: [230, 247, 255], // foam (tear)
81
+ };
82
+
83
+ // ------------------------------------------------------------------------
84
+ // Real-pixel frames (codex-pets style): the same grids encoded as terminal
85
+ // image escapes, for absolute-positioned overlay rendering. No PNG round
86
+ // trip — sixel and kitty raw-RGBA are generated straight from the grids.
87
+ // ------------------------------------------------------------------------
88
+
89
+ // 16x16 full-body grids used by the pixel pet -----------------------------
90
+ // biome-ignore format: pixel grid stays one row per line
91
+ const F0 = [
92
+ "..A.........A...",
93
+ "...A..HHHH..A...",
94
+ "....AHHHHHHA....",
95
+ ".HHHHHHHHHHHHHH.",
96
+ ".hhhhhhhhhhhhhh.",
97
+ "....KRRRRRRK....",
98
+ ".KK.KGGVVGGK.KK.",
99
+ "KRRKKVVVVVVKKRRK",
100
+ "KRrRKRRRRRRKRrRK",
101
+ ".KRRKKRbbRKKRRK.",
102
+ ".....KRbbRK.....",
103
+ ".....KRbbRK.....",
104
+ ".....KRRRRK.....",
105
+ "....KRRRRRRK....",
106
+ "...KRrK..KrRK...",
107
+ "...K......K.....",
108
+ ];
109
+
110
+ // Eye-only gaze frames: identical body, only visor row 6 changes.
111
+ const FL = F0.map((row, i) => (i === 6 ? ".KK.KGGVGGVK.KK." : row));
112
+ const FR = F0.map((row, i) => (i === 6 ? ".KK.KVGGVGGK.KK." : row));
113
+ // Visor flicker (blink).
114
+ const FF = F0.map((row, i) => {
115
+ if (i === 6) return ".KK.KVVVVVVK.KK.";
116
+ if (i === 7) return "KRRKKVGVVGVKKRRK";
117
+ return row;
118
+ });
119
+
120
+ // Both round claws raised with a three-pixel-tall ^ ^ victory face (the dance's
121
+ // "both arms up" beat). Shares base's body and feet so the sequence stays planted.
122
+ // biome-ignore format: pixel grid stays one row per line
123
+ const FX = [
124
+ "..A.........A...",
125
+ "...A..HHHH..A...",
126
+ "....AHHHHHHA....",
127
+ ".HHHHHHHHHHHHHH.",
128
+ ".hhhhhhhhhhhhhh.",
129
+ ".KK.KRRRRRRK.KK.",
130
+ "KRRKKVGVVGVKKRRK",
131
+ "KRrRKGVGGVGKRrRK",
132
+ ".KRRKVVVVVVKRRK.",
133
+ "....KKRbbRKK....",
134
+ ".....KRbbRK.....",
135
+ ".....KRbbRK.....",
136
+ ".....KRRRRK.....",
137
+ "....KRRRRRRK....",
138
+ "...KRrK..KrRK...",
139
+ "...K......K.....",
140
+ ];
141
+
142
+ // Para-para dance: pump the round claws up one at a time (left then right) while
143
+ // stepping the feet the opposite way, with cute faces (danceL ">< ", danceR "^ ^"),
144
+ // so the arms bob AND the legs shuffle side to side while working.
145
+ const DL = F0.map((row, i) => {
146
+ if (i === 5) return ".KK.KRRRRRRK....";
147
+ if (i === 6) return "KRRKKGVVVGVK.KK.";
148
+ if (i === 7) return "KRrRKVGVGVVKKRRK";
149
+ if (i === 8) return ".KRRKGVVVGVKRrRK";
150
+ if (i === 9) return "....KKRbbRKKRRK.";
151
+ if (i === 13) return "...KRRRRRRK.....";
152
+ if (i === 14) return "..KRrK..KrRK....";
153
+ if (i === 15) return "..K......K......";
154
+ return row;
155
+ });
156
+ const DR = F0.map((row, i) => {
157
+ if (i === 5) return "....KRRRRRRK.KK.";
158
+ if (i === 6) return ".KK.KVGVVGVKKRRK";
159
+ if (i === 7) return "KRRKKGVGGVGKRrRK";
160
+ if (i === 8) return "KRrRKVVVVVVKRRK.";
161
+ if (i === 9) return ".KRRKKRbbRKK....";
162
+ if (i === 13) return ".....KRRRRRRK...";
163
+ if (i === 14) return "....KRrK..KrRK..";
164
+ if (i === 15) return "....K......K....";
165
+ return row;
166
+ });
167
+
168
+ // BlueGajae's idle sob: a squeezed `>< ` visor (arms down, unlike RedGajae's flex)
169
+ // with a light tear that falls diagonally outward across the three frames.
170
+ const CRY_FACE: Record<number, string> = {
171
+ 6: ".KK.KGVVVGVK.KK.",
172
+ 7: "KRRKKVGVGVVKKRRK",
173
+ 8: "KRrRKGVVVGVKRrRK",
174
+ };
175
+ const CR1 = F0.map((row, i) => CRY_FACE[i] ?? (i === 10 ? "....wKRbbRKw...." : row));
176
+ const CR2 = F0.map((row, i) => CRY_FACE[i] ?? (i === 11 ? "...w.KRbbRK.w..." : row));
177
+ const CR3 = F0.map((row, i) => CRY_FACE[i] ?? (i === 12 ? "..w..KRRRRK..w.." : row));
178
+
179
+ /** Logical pixel-pet frame names shared by the overlay state machine. */
180
+ export type GajaePixelFrameName =
181
+ | "base"
182
+ | "gazeL"
183
+ | "gazeR"
184
+ | "flicker"
185
+ | "flex"
186
+ | "danceL"
187
+ | "danceR"
188
+ | "cry1"
189
+ | "cry2"
190
+ | "cry3";
191
+
192
+ const PIXEL_GRIDS: Record<GajaePixelFrameName, string[]> = {
193
+ base: F0,
194
+ gazeL: FL,
195
+ gazeR: FR,
196
+ flicker: FF,
197
+ flex: FX,
198
+ danceL: DL,
199
+ danceR: DR,
200
+ cry1: CR1,
201
+ cry2: CR2,
202
+ cry3: CR3,
203
+ };
204
+
205
+ /** 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]> = [
207
+ ["danceL", 300],
208
+ ["danceR", 300],
209
+ ["base", 260],
210
+ ["flex", 480],
211
+ ["base", 260],
212
+ ];
213
+
214
+ /**
215
+ * A skin's idle burst: a short intro sequence, then an optional looping tail. It drives
216
+ * BOTH the random live show-off AND the selector's preview demo, so give every skin a
217
+ * real animation (reuse PARA_PARA_STEPS for a work-in intro) rather than one held frame.
218
+ */
219
+ export interface PetBurst {
220
+ /** Frames played once, in order, at the start of the burst. */
221
+ intro: ReadonlyArray<readonly [GajaePixelFrameName, number]>;
222
+ /** Frames cycled every `stepMs` for `ms` after the intro (a held or looping finish). */
223
+ tail?: { frames: readonly GajaePixelFrameName[]; stepMs: number; ms: number };
224
+ }
225
+
226
+ /** Everything that defines a pet skin: identity, UI copy, colors and behavior. */
227
+ export interface PetSkin {
228
+ id: PetSkinId;
229
+ /** Selector/settings label, e.g. "RedGajae". */
230
+ label: string;
231
+ /** One-line selector/settings description. */
232
+ description: string;
233
+ palette: Palette;
234
+ /** Idle burst animation played between quiet idle loops. */
235
+ burst: PetBurst;
236
+ }
237
+
238
+ /** Skin registry — the single source for palettes, behavior and selector/command copy. */
239
+ export const PET_SKINS: Record<PetSkinId, PetSkin> = {
240
+ red: {
241
+ id: "red",
242
+ label: "RedGajae",
243
+ description: "The Red Crab, who likes to work-out.",
244
+ palette: RED_PALETTE,
245
+ burst: {
246
+ intro: PARA_PARA_STEPS,
247
+ tail: { frames: ["flex", "base"], stepMs: 200, ms: 1000 },
248
+ },
249
+ },
250
+ blue: {
251
+ id: "blue",
252
+ label: "BlueGajae",
253
+ description: "The Blue Crab, who wants to rest.",
254
+ palette: BLUE_PALETTE,
255
+ burst: {
256
+ intro: PARA_PARA_STEPS,
257
+ tail: { frames: ["cry1", "cry2", "cry3"], stepMs: 110, ms: 990 },
258
+ },
259
+ },
260
+ };
261
+
262
+ /** Total burst duration (intro beats plus the looping tail). */
263
+ export function petBurstDurationMs(burst: PetBurst): number {
264
+ const introMs = burst.intro.reduce((sum, [, ms]) => sum + ms, 0);
265
+ return introMs + (burst.tail?.ms ?? 0);
266
+ }
267
+
268
+ /** 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 {
270
+ let t = elapsed;
271
+ for (const [frame, ms] of burst.intro) {
272
+ if (t < ms) return frame;
273
+ t -= ms;
274
+ }
275
+ const tail = burst.tail;
276
+ if (!tail) return burst.intro[burst.intro.length - 1][0];
277
+ return tail.frames[Math.floor(now / tail.stepMs) % tail.frames.length];
278
+ }
279
+
280
+ /** Test-only access to logical art; production rendering still uses encoded frames. */
281
+ export const __gajaePetTestHooks = {
282
+ getPixelGrid(name: GajaePixelFrameName): string[] {
283
+ return [...PIXEL_GRIDS[name]];
284
+ },
285
+ };
286
+
287
+ /** Encode a grid as a transparent SIXEL image, optionally bottom-aligned by top padding. */
288
+ export function encodeGridSixel(
289
+ grid: string[],
290
+ scale: number,
291
+ topPaddingPx = 0,
292
+ palette: Palette = RED_PALETTE,
293
+ ): string {
294
+ const gw = grid[0].length;
295
+ const gh = grid.length;
296
+ const w = Math.round(gw * scale);
297
+ const h = Math.round(gh * scale) + topPaddingPx;
298
+ const colors: Rgb[] = [];
299
+ const colorIndex = new Map<string, number>();
300
+ // pixel color index per row/col, -1 = transparent
301
+ const px: number[][] = [];
302
+ for (let y = 0; y < h; y++) {
303
+ const row: number[] = [];
304
+ for (let x = 0; x < w; x++) {
305
+ const sourceY = y - topPaddingPx;
306
+ const ch =
307
+ sourceY < 0
308
+ ? "."
309
+ : grid[Math.min(gh - 1, Math.floor(sourceY / scale))][Math.min(gw - 1, Math.floor(x / scale))];
310
+ const rgb = palette[ch];
311
+ if (!rgb) {
312
+ row.push(-1);
313
+ continue;
314
+ }
315
+ const key = rgb.join(",");
316
+ let idx = colorIndex.get(key);
317
+ if (idx === undefined) {
318
+ idx = colors.length;
319
+ colors.push(rgb);
320
+ colorIndex.set(key, idx);
321
+ }
322
+ row.push(idx);
323
+ }
324
+ px.push(row);
325
+ }
326
+
327
+ // DCS is P1;P2;P3: transparency is the second parameter (P2=1).
328
+ let out = `\x1bP0;1;0q"1;1;${w};${h}`;
329
+ for (let i = 0; i < colors.length; i++) {
330
+ const [r, g, b] = colors[i];
331
+ out += `#${i};2;${Math.round((r / 255) * 100)};${Math.round((g / 255) * 100)};${Math.round((b / 255) * 100)}`;
332
+ }
333
+ for (let bandTop = 0; bandTop < h; bandTop += 6) {
334
+ for (let c = 0; c < colors.length; c++) {
335
+ let line = "";
336
+ let used = false;
337
+ for (let x = 0; x < w; x++) {
338
+ let bits = 0;
339
+ for (let dy = 0; dy < 6 && bandTop + dy < h; dy++) {
340
+ if (px[bandTop + dy][x] === c) bits |= 1 << dy;
341
+ }
342
+ if (bits) used = true;
343
+ line += String.fromCharCode(63 + bits);
344
+ }
345
+ if (used) out += `#${c}${line}$`;
346
+ }
347
+ out += "-";
348
+ }
349
+ return `${out}\x1b\\`;
350
+ }
351
+
352
+ /** Encode a bottom-aligned grid as kitty raw RGBA at `scale`. */
353
+ export function encodeGridKitty(
354
+ grid: string[],
355
+ scale: number,
356
+ imageId: number,
357
+ cols: number,
358
+ rows: number,
359
+ topPaddingPx = 0,
360
+ cellYOffsetPx = 0,
361
+ leftPaddingPx = 0,
362
+ rightPaddingPx = 0,
363
+ palette: Palette = RED_PALETTE,
364
+ ): string {
365
+ const gw = grid[0].length;
366
+ const gh = grid.length;
367
+ const spriteW = Math.round(gw * scale);
368
+ // Pad the canvas to the full cell block (cols*cellWidth) so the square sprite
369
+ // renders 1:1 within it.
370
+ const w = spriteW + leftPaddingPx + rightPaddingPx;
371
+ const h = Math.round(gh * scale) + topPaddingPx;
372
+ const rgba = new Uint8Array(w * h * 4);
373
+ for (let y = 0; y < h; y++) {
374
+ for (let x = 0; x < w; x++) {
375
+ const sourceX = x - leftPaddingPx;
376
+ const sourceY = y - topPaddingPx;
377
+ const rgb =
378
+ sourceX < 0 || sourceX >= spriteW || sourceY < 0
379
+ ? null
380
+ : palette[
381
+ grid[Math.min(gh - 1, Math.floor(sourceY / scale))][Math.min(gw - 1, Math.floor(sourceX / scale))]
382
+ ];
383
+ if (!rgb) continue;
384
+ const o = (y * w + x) * 4;
385
+ rgba[o] = rgb[0];
386
+ rgba[o + 1] = rgb[1];
387
+ rgba[o + 2] = rgb[2];
388
+ rgba[o + 3] = 255;
389
+ }
390
+ }
391
+ const data = Buffer.from(rgba).toString("base64");
392
+ const CHUNK = 4000;
393
+ // `Y=` offsets the sprite down by sub-cell pixels within the first cell — the
394
+ // kitty analogue of the sixel top-padding drop. `C=1` keeps the placement
395
+ // cursor-neutral so the overlay never nudges the composer's real cursor.
396
+ const yParam = cellYOffsetPx > 0 ? `,Y=${Math.round(cellYOffsetPx)}` : "";
397
+ let out = `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`;
398
+ for (let off = 0, first = true; off < data.length; off += CHUNK, first = false) {
399
+ const chunk = data.slice(off, off + CHUNK);
400
+ const more = off + CHUNK < data.length ? 1 : 0;
401
+ out += first
402
+ ? `\x1b_Ga=T,f=32,s=${w},v=${h},c=${cols},r=${rows},i=${imageId},q=2,C=1${yParam},m=${more};${chunk}\x1b\\`
403
+ : `\x1b_Gm=${more};${chunk}\x1b\\`;
404
+ }
405
+ return out;
406
+ }
407
+
408
+ export interface GajaePixelFrames {
409
+ /** escape payload per logical frame (drawn at the current cursor cell) */
410
+ frames: Record<GajaePixelFrameName, string>;
411
+ /** protocol the frames were encoded for */
412
+ protocol: "sixel" | "kitty";
413
+ widthPx: number;
414
+ heightPx: number;
415
+ columns: number;
416
+ rows: number;
417
+ /** terminal rows touched by the encoded raster, including pixel offset */
418
+ rasterRows: number;
419
+ }
420
+
421
+ /**
422
+ * 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.
425
+ */
426
+ export function buildGajaePixelFrames(options: {
427
+ protocol: "sixel" | "kitty";
428
+ cellWidthPx: number;
429
+ cellHeightPx: number;
430
+ targetRows?: number;
431
+ /** Transparent pixel offset above sixel art for sub-cell vertical placement. */
432
+ sixelTopPaddingPx?: number;
433
+ /** Native sub-cell `Y=` pixel offset that drops the kitty sprite within its first cell. */
434
+ kittyCellYOffsetPx?: number;
435
+ kittyImageId?: number;
436
+ /** Color skin for the sprite palette (default "red"). */
437
+ skin?: PetSkinId;
438
+ }): GajaePixelFrames {
439
+ 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);
444
+ const columns = Math.ceil(widthPx / options.cellWidthPx);
445
+ const rows = Math.ceil(visibleHeightPx / options.cellHeightPx);
446
+ const allocatedHeightPx = rows * options.cellHeightPx;
447
+ const topPaddingPx =
448
+ allocatedHeightPx - visibleHeightPx + (options.protocol === "sixel" ? (options.sixelTopPaddingPx ?? 0) : 0);
449
+ const heightPx = visibleHeightPx + topPaddingPx;
450
+ const rasterRows = Math.ceil(heightPx / options.cellHeightPx);
451
+ // Center the square sprite in its (cols * cellWidth) block, which the ceil()
452
+ // column rounding can make wider than the sprite itself.
453
+ const horizontalPaddingPx = Math.max(0, columns * options.cellWidthPx - widthPx);
454
+ const leftPaddingPx = Math.floor(horizontalPaddingPx / 2);
455
+ const rightPaddingPx = horizontalPaddingPx - leftPaddingPx;
456
+ 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[]) {
460
+ frames[name] =
461
+ 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
+ );
475
+ }
476
+
477
+ return { frames, protocol: options.protocol, widthPx, heightPx, columns, rows, rasterRows };
478
+ }
@@ -3,6 +3,8 @@ import {
3
3
  type ImageDimensions,
4
4
  ImageProtocol,
5
5
  imageFallback,
6
+ isCursorNeutralImagePermittedInFallback,
7
+ isTerminalGraphicsFallbackActive,
6
8
  kittyImageId,
7
9
  renderImage,
8
10
  TERMINAL,
@@ -40,7 +42,8 @@ export class Image implements Component {
40
42
 
41
43
  #cachedLines?: string[];
42
44
  #cachedWidth?: number;
43
- // Kitty graphics: content-derived image id + per-instance placement id.
45
+ #cachedFallbackActive?: boolean;
46
+ #cachedProtocol?: ImageProtocol | null;
44
47
  // Computed lazily so non-kitty terminals never pay the hash cost.
45
48
  #kittyImageId?: number;
46
49
  readonly #kittyPlacementId = allocatePlacementId();
@@ -62,6 +65,8 @@ export class Image implements Component {
62
65
  invalidate(): void {
63
66
  this.#cachedLines = undefined;
64
67
  this.#cachedWidth = undefined;
68
+ this.#cachedFallbackActive = undefined;
69
+ this.#cachedProtocol = undefined;
65
70
  }
66
71
 
67
72
  get retainedBase64DataForTest(): string | undefined {
@@ -81,7 +86,19 @@ export class Image implements Component {
81
86
  }
82
87
 
83
88
  render(width: number): string[] {
84
- if (this.#cachedLines && this.#cachedWidth === width) {
89
+ // Kitty placements are cursor-neutral, so an opted-in fallback scope
90
+ // (e.g. the IRC split) can still render them safely; iTerm2/SIXEL
91
+ // advance the cursor and stay suppressed.
92
+ const graphicsSuppressed =
93
+ isTerminalGraphicsFallbackActive() &&
94
+ !(TERMINAL.imageProtocol === ImageProtocol.Kitty && isCursorNeutralImagePermittedInFallback());
95
+ const protocol = TERMINAL.imageProtocol;
96
+ if (
97
+ this.#cachedLines &&
98
+ this.#cachedWidth === width &&
99
+ this.#cachedFallbackActive === graphicsSuppressed &&
100
+ this.#cachedProtocol === protocol
101
+ ) {
85
102
  return this.#cachedLines;
86
103
  }
87
104
 
@@ -90,12 +107,12 @@ export class Image implements Component {
90
107
 
91
108
  let lines: string[];
92
109
 
93
- if (TERMINAL.imageProtocol) {
110
+ if (protocol && !graphicsSuppressed) {
94
111
  const base64Data = this.#getBase64Data();
95
112
  if (!base64Data) {
96
113
  lines = this.#fallbackLines();
97
114
  } else {
98
- if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
115
+ if (protocol === ImageProtocol.Kitty) {
99
116
  this.#kittyImageId ??= kittyImageId(base64Data);
100
117
  }
101
118
  const result = renderImage(base64Data, this.#dimensions, {
@@ -139,6 +156,8 @@ export class Image implements Component {
139
156
 
140
157
  this.#cachedLines = lines;
141
158
  this.#cachedWidth = width;
159
+ this.#cachedFallbackActive = graphicsSuppressed;
160
+ this.#cachedProtocol = protocol;
142
161
 
143
162
  return lines;
144
163
  }