storymode-cli 1.2.3 → 1.3.1

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.3",
3
+ "version": "1.3.1",
4
4
  "description": "Play AI-animated pixel art characters in your terminal",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.mjs CHANGED
@@ -25,7 +25,9 @@ function request(url) {
25
25
 
26
26
  export async function fetchGallery() {
27
27
  const { buffer } = await request(`${BASE}/gallery`);
28
- return JSON.parse(buffer.toString('utf-8'));
28
+ const data = JSON.parse(buffer.toString('utf-8'));
29
+ // Handle both new {featured_id, curated_ids, items} and legacy bare array
30
+ return Array.isArray(data) ? { featured_id: null, curated_ids: null, items: data } : data;
29
31
  }
30
32
 
31
33
  export async function fetchFrames(galleryId, { size, mode, animId } = {}) {
package/src/browse.mjs CHANGED
@@ -19,7 +19,8 @@ export async function browse() {
19
19
  const spinner = startSpinner('Fetching gallery...');
20
20
  let items;
21
21
  try {
22
- items = await fetchGallery();
22
+ const gallery = await fetchGallery();
23
+ items = gallery.items;
23
24
  } finally {
24
25
  stopSpinner(spinner);
25
26
  }
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,6 +1,9 @@
1
1
  import { execSync } from 'node:child_process';
2
2
  import { createInterface } from 'node:readline';
3
- import { fetchFrames, fetchCharacter, fetchPngFrames } from './api.mjs';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { fetchFrames, fetchCharacter, fetchPngFrames, fetchGallery } from './api.mjs';
4
7
  import { playAnimation, playCompanion, playReactiveCompanion, showFrame, playHdAnimation, detectGraphicsProtocol } from './player.mjs';
5
8
  import { browse } from './browse.mjs';
6
9
  import { startMcpServer } from './mcp.mjs';
@@ -86,8 +89,8 @@ const HELP = `
86
89
  --tiny Tiny companion pane
87
90
  --no-ai Disable AI personality (preset speech only)
88
91
  --model=ID Anthropic model for AI personality
89
- --hd Pixel-perfect rendering (iTerm2/Kitty/Ghostty)
90
92
  --classic Full-screen animation viewer (non-reactive)
93
+ --force Kill existing session and restart
91
94
  --detach Return control immediately
92
95
 
93
96
  Controls:
@@ -111,26 +114,60 @@ function parseFlags(args) {
111
114
  return { flags, positional };
112
115
  }
113
116
 
114
- const DEFAULT_CHARACTER = 3; // Zephyr
117
+ const DEFAULT_CHARACTER = 3; // Zephyr (fallback if server has no featured_id)
118
+ const CONFIG_CACHE_PATH = join(homedir(), '.storymode', 'cache', 'gallery-config.json');
119
+
120
+ /** Get the featured character ID: positional arg > cached config > fetch from server > fallback */
121
+ async function resolveFeaturedId() {
122
+ // Try cached config first
123
+ try {
124
+ if (existsSync(CONFIG_CACHE_PATH)) {
125
+ const cached = JSON.parse(readFileSync(CONFIG_CACHE_PATH, 'utf-8'));
126
+ if (cached.featured_id != null) return cached.featured_id;
127
+ }
128
+ } catch { /* ignore corrupt cache */ }
129
+
130
+ // Fetch from server and cache
131
+ try {
132
+ const gallery = await fetchGallery();
133
+ const cacheDir = join(homedir(), '.storymode', 'cache');
134
+ mkdirSync(cacheDir, { recursive: true });
135
+ writeFileSync(CONFIG_CACHE_PATH, JSON.stringify({
136
+ featured_id: gallery.featured_id,
137
+ curated_ids: gallery.curated_ids,
138
+ }));
139
+ if (gallery.featured_id != null) return gallery.featured_id;
140
+ } catch { /* network failure — fall through */ }
141
+
142
+ return DEFAULT_CHARACTER;
143
+ }
115
144
 
116
145
  export async function run(args) {
117
146
  const cmd = args[0];
118
147
  const { flags, positional } = parseFlags(args.slice(1));
119
- const id = positional[0] || (cmd === 'play' || cmd === 'companion' ? DEFAULT_CHARACTER : undefined);
148
+ let id = positional[0];
149
+ if (!id && (cmd === 'play' || cmd === 'companion')) {
150
+ id = await resolveFeaturedId();
151
+ }
120
152
 
121
153
  switch (cmd) {
122
154
  // --- Main command: reactive companion ---
123
155
  case 'play':
124
156
  case 'companion': {
125
157
 
126
- // --classic: old full-screen animation viewer
127
- if (flags.classic) {
158
+ // --classic --hd: pixel-perfect full-screen viewer (non-reactive)
159
+ if (flags.classic && flags.hd) {
160
+ const renderMode = detectGraphicsProtocol();
161
+ if (!renderMode.protocol) {
162
+ console.error(' --hd requires a terminal with graphics protocol support');
163
+ console.error(' (iTerm2, Kitty, Ghostty, or WezTerm)');
164
+ process.exit(1);
165
+ }
128
166
  try {
129
- process.stderr.write(' Loading animation...\n');
130
- const fetchOpts = {};
131
- if (flags.sextant) { fetchOpts.size = 'compact'; fetchOpts.mode = 'sextant'; }
132
- const framesData = await fetchFrames(id, fetchOpts);
133
- await playAnimation(framesData);
167
+ process.stderr.write(' Loading HD frames...\n');
168
+ const pngData = await fetchPngFrames(id);
169
+ process.stderr.write(` ${pngData.frame_count} frames @ ${pngData.fps}fps (${renderMode.protocol} protocol)\n`);
170
+ await playHdAnimation(pngData.frames, { fps: pngData.fps, renderMode });
134
171
  } catch (err) {
135
172
  console.error(`Error: ${err.message}`);
136
173
  process.exit(1);
@@ -138,19 +175,14 @@ export async function run(args) {
138
175
  break;
139
176
  }
140
177
 
141
- // --hd: pixel-perfect rendering via graphics protocol (iTerm2/Kitty/Ghostty)
142
- if (flags.hd) {
143
- const protocol = detectGraphicsProtocol();
144
- if (!protocol) {
145
- console.error(' --hd requires a terminal with graphics protocol support');
146
- console.error(' (iTerm2, Kitty, Ghostty, or WezTerm)');
147
- process.exit(1);
148
- }
178
+ // --classic: old full-screen ANSI animation viewer
179
+ if (flags.classic) {
149
180
  try {
150
- process.stderr.write(' Loading HD frames...\n');
151
- const pngData = await fetchPngFrames(id);
152
- process.stderr.write(` ${pngData.frame_count} frames @ ${pngData.fps}fps (${protocol} protocol)\n`);
153
- await playHdAnimation(pngData.frames, { fps: pngData.fps });
181
+ process.stderr.write(' Loading animation...\n');
182
+ const fetchOpts = {};
183
+ if (flags.sextant) { fetchOpts.size = 'compact'; fetchOpts.mode = 'sextant'; }
184
+ const framesData = await fetchFrames(id, fetchOpts);
185
+ await playAnimation(framesData);
154
186
  } catch (err) {
155
187
  console.error(`Error: ${err.message}`);
156
188
  process.exit(1);
@@ -165,13 +197,17 @@ export async function run(args) {
165
197
  const model = flags.model || '';
166
198
  // Reactive is always on unless --classic
167
199
  const reactive = !flags['no-reactive'];
168
- const paneWidth = size === 'full' ? 62 : size === 'tiny' ? 14 : 22;
200
+
201
+ // Auto-detect graphics protocol for HD rendering
202
+ const renderMode = detectGraphicsProtocol();
203
+ const useHd = !!renderMode.protocol; // auto-detect only
204
+ const paneWidth = useHd && size === 'full' ? 80 : size === 'full' ? 62 : size === 'tiny' ? 14 : 22;
169
205
 
170
206
  // If we're already the companion pane (spawned by tmux split), play inline
171
207
  if (process.env.STORYMODE_COMPANION === '1') {
172
208
  try {
173
209
  if (reactive) {
174
- const fetchOpts = { size };
210
+ const fetchOpts = { size, hd: useHd };
175
211
  if (flags.sextant) fetchOpts.mode = 'sextant';
176
212
  const { animMap, character, personality } = await loadAnimations(id, fetchOpts);
177
213
  await playReactiveCompanion(animMap, {
@@ -181,6 +217,7 @@ export async function run(args) {
181
217
  fullscreen: size === 'full',
182
218
  noAi,
183
219
  model: model || undefined,
220
+ renderMode: useHd ? renderMode : undefined,
184
221
  });
185
222
  } else {
186
223
  const fetchOpts = { size };
@@ -207,7 +244,9 @@ export async function run(args) {
207
244
  const reactiveFlag = reactive ? ' --reactive' : '';
208
245
  const noAiFlag = noAi ? ' --no-ai' : '';
209
246
  const modelFlag = model ? ` --model=${model}` : '';
210
- const companionCmd = `STORYMODE_COMPANION=1 npx storymode-cli play ${id} --size=${size}${sextantFlag}${reactiveFlag}${noAiFlag}${modelFlag}`;
247
+ // Enable tmux passthrough for graphics protocols when HD is active
248
+ const passthroughPrefix = useHd ? 'tmux set allow-passthrough on; ' : '';
249
+ const companionCmd = `${passthroughPrefix}STORYMODE_COMPANION=1 npx storymode-cli play ${id} --size=${size}${sextantFlag}${reactiveFlag}${noAiFlag}${modelFlag}`;
211
250
 
212
251
  try {
213
252
  if (inTmux) {
@@ -216,10 +255,24 @@ export async function run(args) {
216
255
  { stdio: 'ignore' }
217
256
  );
218
257
  if (!detach) {
219
- process.stderr.write(` Companion opened in side pane (${size}).\n`);
258
+ const hdNote = useHd ? ` HD/${renderMode.protocol}` : '';
259
+ process.stderr.write(` Companion opened in side pane (${size}${hdNote}).\n`);
220
260
  process.stderr.write(' Close the pane or press q in it to stop.\n');
221
261
  }
222
262
  } else {
263
+ // Check for existing session
264
+ try {
265
+ execSync('tmux has-session -t storymode', { stdio: 'ignore' });
266
+ if (flags.force) {
267
+ execSync('tmux kill-session -t storymode', { stdio: 'ignore' });
268
+ } else {
269
+ console.error(' A storymode tmux session already exists.');
270
+ console.error(' Attach to it: tmux attach -t storymode');
271
+ console.error(' Or kill it: tmux kill-session -t storymode');
272
+ console.error(' Or restart: npx storymode-cli play --force');
273
+ process.exit(1);
274
+ }
275
+ } catch { /* no existing session — good */ }
223
276
  process.stderr.write(' Starting tmux session with companion pane...\n');
224
277
  execSync(
225
278
  `tmux new-session -d -s storymode -x 100 -y 30 \\; ` +
package/src/mcp.mjs CHANGED
@@ -47,7 +47,8 @@ function findAnimation(animations, query) {
47
47
  async function handleToolCall(name, args) {
48
48
  switch (name) {
49
49
  case 'list_characters': {
50
- const items = await fetchGallery();
50
+ const gallery = await fetchGallery();
51
+ const items = gallery.items;
51
52
  if (!items.length) return 'Gallery is empty.';
52
53
  const chars = await Promise.all(
53
54
  items.map(item => fetchCharacter(item.id).catch(() => null))
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`);
@@ -666,35 +729,85 @@ export function showFrame(framesData, info) {
666
729
 
667
730
  const CHUNK_SIZE = 4096;
668
731
 
669
- /** Detect terminal graphics protocol support */
732
+ /**
733
+ * Detect terminal graphics protocol support.
734
+ * Returns { protocol: 'kitty'|'iterm2'|null, inTmux: boolean }
735
+ */
670
736
  export function detectGraphicsProtocol() {
737
+ const inTmux = !!process.env.TMUX;
738
+
739
+ // Inside tmux: check the outer terminal, not tmux's own TERM
671
740
  const termProgram = process.env.TERM_PROGRAM || '';
672
741
  const term = process.env.TERM || '';
673
- if (process.env.KITTY_WINDOW_ID) return 'kitty';
674
- if (term === 'xterm-ghostty' || process.env.GHOSTTY_RESOURCES_DIR) return 'kitty';
675
- if (termProgram === 'WezTerm') return 'kitty';
676
- if (termProgram === 'iTerm.app') return 'iterm2';
677
- return null;
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
+ } catch { /* tmux command failed — fall through */ }
755
+ // LC_TERMINAL survives tmux env overwriting (iTerm2 sets it)
756
+ const lcTerminal = process.env.LC_TERMINAL || '';
757
+ if (lcTerminal === 'iTerm2') return { protocol: 'iterm2', inTmux };
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
+ };
678
789
  }
679
790
 
680
791
  /** Display PNG via iTerm2 inline images protocol */
681
- function writeIterm2(pngBuf, cols, rows) {
792
+ function writeIterm2(pngBuf, cols, rows, inTmux = false) {
682
793
  const b64 = pngBuf.toString('base64');
683
794
  let params = `inline=1;size=${pngBuf.length}`;
684
795
  if (cols) params += `;width=${cols}`;
685
796
  if (rows) params += `;height=${rows}`;
686
797
  params += `;preserveAspectRatio=1`;
687
- stdout.write(`\x1b]1337;File=${params}:${b64}\x07`);
798
+ const seq = `\x1b]1337;File=${params}:${b64}\x07`;
799
+ stdout.write(inTmux ? wrapForTmux(seq) : seq);
688
800
  }
689
801
 
690
802
  /** Display PNG via Kitty graphics protocol */
691
- function writeKitty(pngBuf, cols, rows) {
803
+ function writeKitty(pngBuf, cols, rows, inTmux = false) {
692
804
  const b64 = pngBuf.toString('base64');
805
+ const write = (s) => stdout.write(inTmux ? wrapForTmux(s) : s);
693
806
  if (b64.length <= CHUNK_SIZE) {
694
807
  let params = `a=T,f=100`;
695
808
  if (cols) params += `,c=${cols}`;
696
809
  if (rows) params += `,r=${rows}`;
697
- stdout.write(`\x1b_G${params};${b64}\x1b\\`);
810
+ write(`\x1b_G${params};${b64}\x1b\\`);
698
811
  return;
699
812
  }
700
813
  for (let i = 0; i < b64.length; i += CHUNK_SIZE) {
@@ -708,19 +821,21 @@ function writeKitty(pngBuf, cols, rows) {
708
821
  if (rows) params += `,r=${rows}`;
709
822
  params += `,m=${isLast ? 0 : 1}`;
710
823
  }
711
- stdout.write(`\x1b_G${params};${chunk}\x1b\\`);
824
+ write(`\x1b_G${params};${chunk}\x1b\\`);
712
825
  }
713
826
  }
714
827
 
715
828
  /**
716
829
  * Play PNG frames using native graphics protocol.
717
830
  * @param {Buffer[]} frames - Array of PNG buffers
718
- * @param {object} opts - { fps, cols, rows }
831
+ * @param {object} opts - { fps, cols, rows, renderMode: {protocol, inTmux} }
719
832
  */
720
833
  export function playHdAnimation(frames, opts = {}) {
721
834
  const { fps = 16, cols, rows } = opts;
722
835
  const interval = 1000 / fps;
723
- const protocol = detectGraphicsProtocol();
836
+ const renderMode = opts.renderMode || { protocol: null, inTmux: false };
837
+ const protocol = renderMode.protocol;
838
+ const inTmux = renderMode.inTmux;
724
839
 
725
840
  if (!protocol) {
726
841
  console.error(' No graphics protocol supported. Use without --hd.');
@@ -734,12 +849,16 @@ export function playHdAnimation(frames, opts = {}) {
734
849
  stdout.write('\x1b[?1049h\x1b[?25l\x1b[2J');
735
850
 
736
851
  function drawFrame() {
737
- stdout.write('\x1b[H');
738
852
  if (protocol === 'kitty') {
739
- stdout.write(`\x1b_Ga=d,d=a\x1b\\`);
740
- writeKitty(frames[frameIdx], cols, rows);
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);
741
857
  } else {
742
- writeIterm2(frames[frameIdx], cols, rows);
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);
743
862
  }
744
863
  }
745
864