storymode-cli 1.2.2 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storymode-cli",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Play AI-animated pixel art characters in your terminal",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.mjs CHANGED
@@ -49,3 +49,26 @@ export async function fetchCharacter(galleryId) {
49
49
  const { buffer } = await request(`${BASE}/gallery/${galleryId}/character`);
50
50
  return JSON.parse(buffer.toString('utf-8'));
51
51
  }
52
+
53
+ /**
54
+ * Fetch raw PNG frames (base64) for a gallery animation.
55
+ * Returns { fps, frame_count, frames: [Buffer, ...] }
56
+ */
57
+ export async function fetchPngFrames(galleryId, { animId } = {}) {
58
+ let url = `${BASE}/gallery/${galleryId}/png-frames`;
59
+ if (animId) url += `?anim_id=${animId}`;
60
+ const { buffer, contentType } = await request(url);
61
+ let json;
62
+ if (contentType.includes('gzip') || buffer[0] === 0x1f) {
63
+ json = gunzipSync(buffer).toString('utf-8');
64
+ } else {
65
+ json = buffer.toString('utf-8');
66
+ }
67
+ const data = JSON.parse(json);
68
+ // Decode base64 frames to Buffers
69
+ return {
70
+ fps: data.fps,
71
+ frame_count: data.frame_count,
72
+ frames: data.frames.map(b64 => Buffer.from(b64, 'base64')),
73
+ };
74
+ }
package/src/cache.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { fetchFrames, fetchCharacter } from './api.mjs';
4
+ import { fetchFrames, fetchCharacter, fetchPngFrames } from './api.mjs';
5
5
 
6
6
  const STORYMODE_DIR = join(homedir(), '.storymode');
7
7
 
@@ -19,7 +19,7 @@ export function readCached(galleryId, size) {
19
19
  const map = new Map();
20
20
  if (!existsSync(dir)) return map;
21
21
  for (const file of readdirSync(dir)) {
22
- if (!file.endsWith('.json') || file === 'character.json') continue;
22
+ if (!file.endsWith('.json') || file === 'character.json' || file === 'personality.json') continue;
23
23
  try {
24
24
  const data = JSON.parse(readFileSync(join(dir, file), 'utf-8'));
25
25
  const name = file.replace(/\.json$/, '').replace(/-/g, ' ');
@@ -89,6 +89,41 @@ export function readLocalPersonality(galleryId) {
89
89
  }
90
90
  }
91
91
 
92
+ // --- PNG frame cache ---
93
+
94
+ function getPngCacheDir(galleryId) {
95
+ return join(STORYMODE_DIR, 'cache', String(galleryId), 'png');
96
+ }
97
+
98
+ /** Read cached PNG frames for an animation. Returns Buffer[] or null. */
99
+ export function readCachedPng(galleryId, animName) {
100
+ const dir = getPngCacheDir(galleryId);
101
+ const filename = animName.replace(/ /g, '-') + '.json';
102
+ const file = join(dir, filename);
103
+ if (!existsSync(file)) return null;
104
+ try {
105
+ const data = JSON.parse(readFileSync(file, 'utf-8'));
106
+ return {
107
+ fps: data.fps,
108
+ frames: data.frames.map(b64 => Buffer.from(b64, 'base64')),
109
+ };
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /** Write PNG frames to cache (stored as base64 JSON). */
116
+ export function writeCachePng(galleryId, animName, fps, frames) {
117
+ const dir = getPngCacheDir(galleryId);
118
+ mkdirSync(dir, { recursive: true });
119
+ const filename = animName.replace(/ /g, '-') + '.json';
120
+ const data = {
121
+ fps,
122
+ frames: frames.map(buf => buf.toString('base64')),
123
+ };
124
+ writeFileSync(join(dir, filename), JSON.stringify(data));
125
+ }
126
+
92
127
  /** Read local animation overrides. Returns Map<name, {fps, frames}> */
93
128
  export function readLocalOverrides(galleryId) {
94
129
  const dir = getLocalDir(galleryId);
@@ -109,14 +144,18 @@ export function readLocalOverrides(galleryId) {
109
144
 
110
145
  /**
111
146
  * Load all animations for a gallery: local overrides > cache > server fetch.
112
- * Returns { animMap: Map<name, {fps, frames}>, character, personality }
147
+ * Returns { animMap: Map<name, {fps, frames, pngFrames?}>, character, personality }
148
+ *
149
+ * When opts.hd is true, PNG frames are loaded alongside ANSI frames:
150
+ * - Idle animation: loaded blocking (needed immediately)
151
+ * - Other animations: loaded in background (falls back to ANSI until ready)
113
152
  *
114
153
  * Personality loading priority:
115
154
  * 1. ~/.storymode/characters/{id}/personality.json (user-authored)
116
155
  * 2. Cached personality.json (from server, if it provides one)
117
156
  * 3. null (no personality — instant + fallback sleep only)
118
157
  */
119
- export async function loadAnimations(galleryId, { size = 'compact', mode } = {}) {
158
+ export async function loadAnimations(galleryId, { size = 'compact', mode, hd = false } = {}) {
120
159
  const localOverrides = readLocalOverrides(galleryId);
121
160
  const cached = readCached(galleryId, size);
122
161
  let character = readCachedCharacter(galleryId, size);
@@ -161,6 +200,50 @@ export async function loadAnimations(galleryId, { size = 'compact', mode } = {})
161
200
  animMap.set(name, data);
162
201
  }
163
202
 
203
+ // --- HD: load PNG frames ---
204
+ if (hd && character) {
205
+ const anims = character.animations || [];
206
+ const idleAnim = anims.find(a => a.name === 'idle breathing');
207
+
208
+ // Helper: load PNG for one animation (cache or fetch)
209
+ async function loadPngForAnim(anim) {
210
+ const cachedPng = readCachedPng(galleryId, anim.name);
211
+ if (cachedPng) return cachedPng;
212
+ try {
213
+ const data = await fetchPngFrames(galleryId, { animId: anim.id });
214
+ writeCachePng(galleryId, anim.name, data.fps, data.frames);
215
+ return data;
216
+ } catch {
217
+ return null; // PNG not available — ANSI fallback
218
+ }
219
+ }
220
+
221
+ // Idle: load blocking (needed immediately for first frame)
222
+ if (idleAnim) {
223
+ process.stderr.write(' Loading HD frames (idle)...\r');
224
+ const pngData = await loadPngForAnim(idleAnim);
225
+ if (pngData && animMap.has(idleAnim.name)) {
226
+ animMap.get(idleAnim.name).pngFrames = pngData.frames;
227
+ }
228
+ process.stderr.write(' Loading HD frames (idle)... done.\n');
229
+ }
230
+
231
+ // Rest: load in background (companion starts immediately with ANSI fallback)
232
+ const otherAnims = anims.filter(a => a.name !== 'idle breathing');
233
+ if (otherAnims.length > 0) {
234
+ Promise.all(otherAnims.map(async (anim) => {
235
+ const pngData = await loadPngForAnim(anim);
236
+ if (pngData && animMap.has(anim.name)) {
237
+ animMap.get(anim.name).pngFrames = pngData.frames;
238
+ }
239
+ })).then(() => {
240
+ process.stderr.write(' HD frames loaded for all animations.\n');
241
+ }).catch(() => {
242
+ // Silently fall back to ANSI for any that failed
243
+ });
244
+ }
245
+ }
246
+
164
247
  // Load personality: local file > cached > null
165
248
  const personality = readLocalPersonality(galleryId)
166
249
  || readCachedPersonality(galleryId, size)
package/src/cli.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { execSync } from 'node:child_process';
2
2
  import { createInterface } from 'node:readline';
3
- import { fetchFrames, fetchCharacter } from './api.mjs';
4
- import { playAnimation, playCompanion, playReactiveCompanion, showFrame } from './player.mjs';
3
+ import { fetchFrames, fetchCharacter, fetchPngFrames } from './api.mjs';
4
+ import { playAnimation, playCompanion, playReactiveCompanion, showFrame, playHdAnimation, detectGraphicsProtocol } from './player.mjs';
5
5
  import { browse } from './browse.mjs';
6
6
  import { startMcpServer } from './mcp.mjs';
7
7
  import { loadAnimations, clearCache } from './cache.mjs';
@@ -87,6 +87,7 @@ const HELP = `
87
87
  --no-ai Disable AI personality (preset speech only)
88
88
  --model=ID Anthropic model for AI personality
89
89
  --classic Full-screen animation viewer (non-reactive)
90
+ --force Kill existing session and restart
90
91
  --detach Return control immediately
91
92
 
92
93
  Controls:
@@ -122,7 +123,27 @@ export async function run(args) {
122
123
  case 'play':
123
124
  case 'companion': {
124
125
 
125
- // --classic: old full-screen animation viewer
126
+ // --classic --hd: pixel-perfect full-screen viewer (non-reactive)
127
+ if (flags.classic && flags.hd) {
128
+ const renderMode = detectGraphicsProtocol();
129
+ if (!renderMode.protocol) {
130
+ console.error(' --hd requires a terminal with graphics protocol support');
131
+ console.error(' (iTerm2, Kitty, Ghostty, or WezTerm)');
132
+ process.exit(1);
133
+ }
134
+ try {
135
+ process.stderr.write(' Loading HD frames...\n');
136
+ const pngData = await fetchPngFrames(id);
137
+ process.stderr.write(` ${pngData.frame_count} frames @ ${pngData.fps}fps (${renderMode.protocol} protocol)\n`);
138
+ await playHdAnimation(pngData.frames, { fps: pngData.fps, renderMode });
139
+ } catch (err) {
140
+ console.error(`Error: ${err.message}`);
141
+ process.exit(1);
142
+ }
143
+ break;
144
+ }
145
+
146
+ // --classic: old full-screen ANSI animation viewer
126
147
  if (flags.classic) {
127
148
  try {
128
149
  process.stderr.write(' Loading animation...\n');
@@ -144,13 +165,17 @@ export async function run(args) {
144
165
  const model = flags.model || '';
145
166
  // Reactive is always on unless --classic
146
167
  const reactive = !flags['no-reactive'];
147
- const paneWidth = size === 'full' ? 62 : size === 'tiny' ? 14 : 22;
168
+
169
+ // Auto-detect graphics protocol for HD rendering
170
+ const renderMode = detectGraphicsProtocol();
171
+ const useHd = !!renderMode.protocol; // auto-detect only
172
+ const paneWidth = useHd && size === 'full' ? 80 : size === 'full' ? 62 : size === 'tiny' ? 14 : 22;
148
173
 
149
174
  // If we're already the companion pane (spawned by tmux split), play inline
150
175
  if (process.env.STORYMODE_COMPANION === '1') {
151
176
  try {
152
177
  if (reactive) {
153
- const fetchOpts = { size };
178
+ const fetchOpts = { size, hd: useHd };
154
179
  if (flags.sextant) fetchOpts.mode = 'sextant';
155
180
  const { animMap, character, personality } = await loadAnimations(id, fetchOpts);
156
181
  await playReactiveCompanion(animMap, {
@@ -160,6 +185,7 @@ export async function run(args) {
160
185
  fullscreen: size === 'full',
161
186
  noAi,
162
187
  model: model || undefined,
188
+ renderMode: useHd ? renderMode : undefined,
163
189
  });
164
190
  } else {
165
191
  const fetchOpts = { size };
@@ -186,7 +212,9 @@ export async function run(args) {
186
212
  const reactiveFlag = reactive ? ' --reactive' : '';
187
213
  const noAiFlag = noAi ? ' --no-ai' : '';
188
214
  const modelFlag = model ? ` --model=${model}` : '';
189
- const companionCmd = `STORYMODE_COMPANION=1 npx storymode-cli play ${id} --size=${size}${sextantFlag}${reactiveFlag}${noAiFlag}${modelFlag}`;
215
+ // Enable tmux passthrough for graphics protocols when HD is active
216
+ const passthroughPrefix = useHd ? 'tmux set allow-passthrough on; ' : '';
217
+ const companionCmd = `${passthroughPrefix}STORYMODE_COMPANION=1 npx storymode-cli play ${id} --size=${size}${sextantFlag}${reactiveFlag}${noAiFlag}${modelFlag}`;
190
218
 
191
219
  try {
192
220
  if (inTmux) {
@@ -195,10 +223,24 @@ export async function run(args) {
195
223
  { stdio: 'ignore' }
196
224
  );
197
225
  if (!detach) {
198
- process.stderr.write(` Companion opened in side pane (${size}).\n`);
226
+ const hdNote = useHd ? ` HD/${renderMode.protocol}` : '';
227
+ process.stderr.write(` Companion opened in side pane (${size}${hdNote}).\n`);
199
228
  process.stderr.write(' Close the pane or press q in it to stop.\n');
200
229
  }
201
230
  } else {
231
+ // Check for existing session
232
+ try {
233
+ execSync('tmux has-session -t storymode', { stdio: 'ignore' });
234
+ if (flags.force) {
235
+ execSync('tmux kill-session -t storymode', { stdio: 'ignore' });
236
+ } else {
237
+ console.error(' A storymode tmux session already exists.');
238
+ console.error(' Attach to it: tmux attach -t storymode');
239
+ console.error(' Or kill it: tmux kill-session -t storymode');
240
+ console.error(' Or restart: npx storymode-cli play --force');
241
+ process.exit(1);
242
+ }
243
+ } catch { /* no existing session — good */ }
202
244
  process.stderr.write(' Starting tmux session with companion pane...\n');
203
245
  execSync(
204
246
  `tmux new-session -d -s storymode -x 100 -y 30 \\; ` +
package/src/player.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createServer } from 'node:net';
2
2
  import { unlinkSync, existsSync } from 'node:fs';
3
+ import { execSync } from 'node:child_process';
3
4
  import { mapEventToAnimation, pickSpeech, ONE_SHOT_ANIMS, IDLE_ANIM, SLEEP_ANIM, SLEEP_TIMEOUT_S, narrateEvent, SessionContext, StateEngine } from './reactive.mjs';
4
5
  import { createAgent } from './agent.mjs';
5
6
  import { getApiKey, hasSeenKeyPrompt, promptForApiKey } from './config.mjs';
@@ -210,7 +211,7 @@ export function playCompanion(framesData) {
210
211
  /**
211
212
  * Reactive companion player — responds to Claude Code events via Unix socket.
212
213
  *
213
- * @param {Map<string, {fps: number, frames: string[][]}>} animMap - All animations keyed by name
214
+ * @param {Map<string, {fps: number, frames: string[][], pngFrames?: Buffer[]}>} animMap - All animations keyed by name
214
215
  * @param {object} [opts]
215
216
  * @param {string} [opts.characterName] - Character name for status display
216
217
  * @param {object} [opts.character] - Character data {name, backstory, ...} for LLM personality
@@ -218,12 +219,16 @@ export function playCompanion(framesData) {
218
219
  * @param {boolean} [opts.fullscreen] - Use alt screen (full-screen mode)
219
220
  * @param {boolean} [opts.noAi] - Disable LLM personality layer
220
221
  * @param {string} [opts.model] - Anthropic model ID for personality layer
222
+ * @param {object} [opts.renderMode] - { protocol: 'kitty'|'iterm2'|null, inTmux: boolean }
221
223
  */
222
224
  export async function playReactiveCompanion(animMap, opts = {}) {
223
225
  const characterName = opts.characterName || 'companion';
224
226
  const personality = opts.personality || null;
225
227
  let character = opts.noAi ? null : (personality || opts.character || null);
226
228
  const fullscreen = !!opts.fullscreen;
229
+ const renderMode = opts.renderMode || { protocol: null, inTmux: false };
230
+ const hdProtocol = renderMode.protocol;
231
+ const hdInTmux = renderMode.inTmux;
227
232
 
228
233
  // --- API key prompt (before terminal setup) ---
229
234
  if (character && !opts.noAi && !getApiKey()) {
@@ -243,11 +248,30 @@ export async function playReactiveCompanion(animMap, opts = {}) {
243
248
  return Promise.resolve();
244
249
  }
245
250
 
251
+ // HD mode: compute image rows from PNG dimensions + pane width
252
+ let imageRows = 0;
253
+ if (hdProtocol && idleData.pngFrames && idleData.pngFrames.length > 0) {
254
+ const dim = pngDimensions(idleData.pngFrames[0]);
255
+ // Terminal cells are roughly 2:1 (height:width in pixels).
256
+ // Estimate: pane columns → pixel width, then derive rows from aspect ratio.
257
+ const paneCols = process.stdout.columns || 80;
258
+ const cellPixelW = 8; // approximate pixel width per cell
259
+ const cellPixelH = 16; // approximate pixel height per cell
260
+ const panePixelW = paneCols * cellPixelW;
261
+ const scale = panePixelW / dim.width;
262
+ imageRows = Math.ceil((dim.height * scale) / cellPixelH);
263
+ }
264
+
246
265
  // Calculate maxLines across ALL animations for stable layout
247
266
  let maxLines = 0;
248
- for (const [, data] of animMap) {
249
- for (const frame of data.frames) {
250
- if (frame.length > maxLines) maxLines = frame.length;
267
+ if (hdProtocol && imageRows > 0) {
268
+ // In HD mode, all images render at the same row height
269
+ maxLines = imageRows;
270
+ } else {
271
+ for (const [, data] of animMap) {
272
+ for (const frame of data.frames) {
273
+ if (frame.length > maxLines) maxLines = frame.length;
274
+ }
251
275
  }
252
276
  }
253
277
 
@@ -262,6 +286,7 @@ export async function playReactiveCompanion(animMap, opts = {}) {
262
286
  // Animation state
263
287
  let currentAnimName = IDLE_ANIM;
264
288
  let currentFrames = idleData.frames;
289
+ let currentPngFrames = idleData.pngFrames || null;
265
290
  let currentFps = idleData.fps || 16;
266
291
  let frameIdx = 0;
267
292
  let loopCount = 0;
@@ -343,6 +368,7 @@ export async function playReactiveCompanion(animMap, opts = {}) {
343
368
  const data = animMap.get(animName);
344
369
  currentAnimName = animName;
345
370
  currentFrames = data.frames;
371
+ currentPngFrames = data.pngFrames || null;
346
372
  currentFps = data.fps || 16;
347
373
  frameIdx = 0;
348
374
  loopCount = 0;
@@ -392,8 +418,11 @@ export async function playReactiveCompanion(animMap, opts = {}) {
392
418
  return lines;
393
419
  }
394
420
 
395
- // Estimate sprite width from first frame of idle animation
421
+ // Estimate sprite width from first frame of idle animation (for bubble sizing)
396
422
  const spriteWidth = (() => {
423
+ if (hdProtocol && imageRows > 0) {
424
+ return process.stdout.columns || 80;
425
+ }
397
426
  const firstLine = idleData.frames[0]?.[0] || '';
398
427
  // Strip ANSI escape sequences to get visible character count
399
428
  return firstLine.replace(/\x1b\[[0-9;]*m/g, '').length;
@@ -401,30 +430,60 @@ export async function playReactiveCompanion(animMap, opts = {}) {
401
430
  const bubbleWidth = Math.max(20, Math.min(spriteWidth, 60));
402
431
 
403
432
  function drawFrame(idx) {
404
- let out = fullscreen ? '\x1b[H' : '\x1b8'; // cursor home vs restore
405
- const frameLines = currentFrames[idx] || [];
406
- for (let i = 0; i < maxLines; i++) {
407
- if (i < frameLines.length) {
408
- out += frameLines[i];
433
+ const useHd = hdProtocol && currentPngFrames && currentPngFrames[idx];
434
+
435
+ if (useHd) {
436
+ // --- HD path: render PNG via graphics protocol, then text below ---
437
+ if (hdProtocol === 'kitty') {
438
+ stdout.write(fullscreen ? '\x1b[H' : '\x1b8');
439
+ const delSeq = `\x1b_Ga=d,d=a\x1b\\`;
440
+ stdout.write(hdInTmux ? wrapForTmux(delSeq) : delSeq);
441
+ writeKitty(currentPngFrames[idx], null, imageRows || undefined, hdInTmux);
442
+ } else {
443
+ // iTerm2: clear to release previous images from memory
444
+ stdout.write(fullscreen ? '\x1b[2J\x1b[H' : '\x1b8');
445
+ if (!fullscreen) {
446
+ // Clear the image area to release old images
447
+ for (let i = 0; i < maxLines; i++) stdout.write('\x1b[K\n');
448
+ stdout.write(fullscreen ? '\x1b[H' : '\x1b8');
449
+ }
450
+ writeIterm2(currentPngFrames[idx], null, imageRows || undefined, hdInTmux);
409
451
  }
410
- out += '\x1b[K\n';
452
+ // Move cursor below the image area for text rendering
453
+ if (imageRows > 0) {
454
+ stdout.write(`\x1b[${imageRows}B`);
455
+ }
456
+ } else {
457
+ // --- ANSI fallback path ---
458
+ let out = fullscreen ? '\x1b[H' : '\x1b8'; // cursor home vs restore
459
+ const frameLines = currentFrames[idx] || [];
460
+ for (let i = 0; i < maxLines; i++) {
461
+ if (i < frameLines.length) {
462
+ out += frameLines[i];
463
+ }
464
+ out += '\x1b[K\n';
465
+ }
466
+ stdout.write(out);
411
467
  }
412
468
 
469
+ // --- Text below image (shared between HD and ANSI) ---
470
+
413
471
  // Auto-clear speech after timeout
414
472
  if (speechText && (Date.now() - speechSetAt > SPEECH_CLEAR_MS)) {
415
473
  speechText = null;
416
474
  }
417
475
 
418
476
  // Speech bubble or empty space
477
+ let textOut = '';
419
478
  if (speechText) {
420
479
  const bubble = renderBubble(speechText, bubbleWidth);
421
480
  for (const bl of bubble) {
422
- out += bl + '\x1b[K\n';
481
+ textOut += bl + '\x1b[K\n';
423
482
  }
424
483
  } else {
425
484
  // Empty lines to keep layout stable
426
485
  for (let i = 0; i < bubbleLines - 1; i++) {
427
- out += '\x1b[K\n';
486
+ textOut += '\x1b[K\n';
428
487
  }
429
488
  }
430
489
 
@@ -433,8 +492,9 @@ export async function playReactiveCompanion(animMap, opts = {}) {
433
492
  ? ' ' + Object.entries(stateEngine.toJSON()).map(([k, v]) => `${k.slice(0,3)}:${v}`).join(' ')
434
493
  : '';
435
494
  const keyHints = !agent && character && !opts.noAi ? '[a]=AI key [q]=quit' : '[q]=quit';
436
- out += `\x1b[0m\x1b[K ${characterName} [${currentAnimName}]${stateStr} ${keyHints}\n`;
437
- stdout.write(out);
495
+ const hdTag = useHd ? ' HD' : '';
496
+ textOut += `\x1b[0m\x1b[K ${characterName} [${currentAnimName}]${hdTag}${stateStr} ${keyHints}\n`;
497
+ stdout.write(textOut);
438
498
  }
439
499
 
440
500
  // --- Socket server ---
@@ -620,6 +680,9 @@ export async function playReactiveCompanion(animMap, opts = {}) {
620
680
 
621
681
  // Print socket path so hooks can find it
622
682
  process.stderr.write(` Socket: ${sockPath}\n`);
683
+ if (hdProtocol) {
684
+ process.stderr.write(` Render: HD (${hdProtocol}${hdInTmux ? ' via tmux' : ''})\n`);
685
+ }
623
686
  if (agent) {
624
687
  const modelName = opts.model || 'claude-haiku-4-5';
625
688
  process.stderr.write(` AI personality: ON (${modelName})\n`);
@@ -661,3 +724,166 @@ export function showFrame(framesData, info) {
661
724
  }
662
725
  console.log('');
663
726
  }
727
+
728
+ // --- Graphics Protocol Rendering (HD mode) ---
729
+
730
+ const CHUNK_SIZE = 4096;
731
+
732
+ /**
733
+ * Detect terminal graphics protocol support.
734
+ * Returns { protocol: 'kitty'|'iterm2'|null, inTmux: boolean }
735
+ */
736
+ export function detectGraphicsProtocol() {
737
+ const inTmux = !!process.env.TMUX;
738
+
739
+ // Inside tmux: check the outer terminal, not tmux's own TERM
740
+ const termProgram = process.env.TERM_PROGRAM || '';
741
+ const term = process.env.TERM || '';
742
+ const kittyId = process.env.KITTY_WINDOW_ID;
743
+ const ghosttyDir = process.env.GHOSTTY_RESOURCES_DIR;
744
+
745
+ if (inTmux) {
746
+ // tmux overwrites env vars. Try to detect the parent terminal.
747
+ try {
748
+ const clientTerm = execSync('tmux display-message -p "#{client_termname}"', {
749
+ encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'],
750
+ }).trim();
751
+ // Map client_termname back to protocol
752
+ if (clientTerm === 'xterm-ghostty') return { protocol: 'kitty', inTmux };
753
+ if (clientTerm.includes('kitty')) return { protocol: 'kitty', inTmux };
754
+ // For iTerm2, client_termname is usually "xterm-256color" — check LC_TERMINAL
755
+ const lcTerminal = process.env.LC_TERMINAL || '';
756
+ if (lcTerminal === 'iTerm2') return { protocol: 'iterm2', inTmux };
757
+ } catch { /* fall through to env-based detection */ }
758
+ }
759
+
760
+ if (kittyId) return { protocol: 'kitty', inTmux };
761
+ if (term === 'xterm-ghostty' || ghosttyDir) return { protocol: 'kitty', inTmux };
762
+ if (termProgram === 'WezTerm') return { protocol: 'kitty', inTmux };
763
+ if (termProgram === 'iTerm.app') return { protocol: 'iterm2', inTmux };
764
+ return { protocol: null, inTmux };
765
+ }
766
+
767
+ /**
768
+ * Wrap an escape sequence for tmux DCS passthrough.
769
+ * Doubles any ESC characters inside the payload.
770
+ */
771
+ function wrapForTmux(sequence) {
772
+ // Double all ESC bytes inside the payload
773
+ const escaped = sequence.replace(/\x1b/g, '\x1b\x1b');
774
+ return `\x1bPtmux;${escaped}\x1b\\`;
775
+ }
776
+
777
+ /**
778
+ * Parse PNG IHDR chunk to get width and height in pixels.
779
+ * @param {Buffer} buf - PNG file buffer
780
+ * @returns {{ width: number, height: number }}
781
+ */
782
+ export function pngDimensions(buf) {
783
+ // PNG signature (8 bytes) + IHDR length (4 bytes) + "IHDR" (4 bytes) = offset 16
784
+ // Width at offset 16 (4 bytes BE), Height at offset 20 (4 bytes BE)
785
+ return {
786
+ width: buf.readUInt32BE(16),
787
+ height: buf.readUInt32BE(20),
788
+ };
789
+ }
790
+
791
+ /** Display PNG via iTerm2 inline images protocol */
792
+ function writeIterm2(pngBuf, cols, rows, inTmux = false) {
793
+ const b64 = pngBuf.toString('base64');
794
+ let params = `inline=1;size=${pngBuf.length}`;
795
+ if (cols) params += `;width=${cols}`;
796
+ if (rows) params += `;height=${rows}`;
797
+ params += `;preserveAspectRatio=1`;
798
+ const seq = `\x1b]1337;File=${params}:${b64}\x07`;
799
+ stdout.write(inTmux ? wrapForTmux(seq) : seq);
800
+ }
801
+
802
+ /** Display PNG via Kitty graphics protocol */
803
+ function writeKitty(pngBuf, cols, rows, inTmux = false) {
804
+ const b64 = pngBuf.toString('base64');
805
+ const write = (s) => stdout.write(inTmux ? wrapForTmux(s) : s);
806
+ if (b64.length <= CHUNK_SIZE) {
807
+ let params = `a=T,f=100`;
808
+ if (cols) params += `,c=${cols}`;
809
+ if (rows) params += `,r=${rows}`;
810
+ write(`\x1b_G${params};${b64}\x1b\\`);
811
+ return;
812
+ }
813
+ for (let i = 0; i < b64.length; i += CHUNK_SIZE) {
814
+ const chunk = b64.slice(i, i + CHUNK_SIZE);
815
+ const isFirst = i === 0;
816
+ const isLast = i + CHUNK_SIZE >= b64.length;
817
+ let params = `m=${isLast ? 0 : 1}`;
818
+ if (isFirst) {
819
+ params = `a=T,f=100`;
820
+ if (cols) params += `,c=${cols}`;
821
+ if (rows) params += `,r=${rows}`;
822
+ params += `,m=${isLast ? 0 : 1}`;
823
+ }
824
+ write(`\x1b_G${params};${chunk}\x1b\\`);
825
+ }
826
+ }
827
+
828
+ /**
829
+ * Play PNG frames using native graphics protocol.
830
+ * @param {Buffer[]} frames - Array of PNG buffers
831
+ * @param {object} opts - { fps, cols, rows, renderMode: {protocol, inTmux} }
832
+ */
833
+ export function playHdAnimation(frames, opts = {}) {
834
+ const { fps = 16, cols, rows } = opts;
835
+ const interval = 1000 / fps;
836
+ const renderMode = opts.renderMode || { protocol: null, inTmux: false };
837
+ const protocol = renderMode.protocol;
838
+ const inTmux = renderMode.inTmux;
839
+
840
+ if (!protocol) {
841
+ console.error(' No graphics protocol supported. Use without --hd.');
842
+ process.exit(1);
843
+ }
844
+
845
+ let frameIdx = 0;
846
+ let quit = false;
847
+
848
+ // Alt screen, hide cursor, clear
849
+ stdout.write('\x1b[?1049h\x1b[?25l\x1b[2J');
850
+
851
+ function drawFrame() {
852
+ if (protocol === 'kitty') {
853
+ stdout.write('\x1b[H');
854
+ const delSeq = `\x1b_Ga=d,d=a\x1b\\`;
855
+ stdout.write(inTmux ? wrapForTmux(delSeq) : delSeq);
856
+ writeKitty(frames[frameIdx], cols, rows, inTmux);
857
+ } else {
858
+ // iTerm2: clear screen before each frame to release previous inline images
859
+ // from memory. Without this, iTerm2 accumulates every image indefinitely.
860
+ stdout.write('\x1b[2J\x1b[H');
861
+ writeIterm2(frames[frameIdx], cols, rows, inTmux);
862
+ }
863
+ }
864
+
865
+ if (stdin.isTTY) {
866
+ stdin.setRawMode(true);
867
+ stdin.resume();
868
+ stdin.on('data', (data) => {
869
+ const key = data.toString();
870
+ if (key === 'q' || key === '\x03') quit = true;
871
+ });
872
+ }
873
+
874
+ return new Promise((resolve) => {
875
+ function tick() {
876
+ if (quit) {
877
+ stdout.write('\x1b[?1049l\x1b[?25h');
878
+ if (stdin.isTTY) stdin.setRawMode(false);
879
+ stdin.pause();
880
+ resolve();
881
+ return;
882
+ }
883
+ drawFrame();
884
+ frameIdx = (frameIdx + 1) % frames.length;
885
+ setTimeout(tick, interval);
886
+ }
887
+ tick();
888
+ });
889
+ }