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 +1 -1
- package/src/api.mjs +3 -1
- package/src/browse.mjs +2 -1
- package/src/cache.mjs +87 -4
- package/src/cli.mjs +80 -27
- package/src/mcp.mjs +2 -1
- package/src/player.mjs +151 -32
package/package.json
CHANGED
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
|
-
|
|
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
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 {
|
|
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
|
-
|
|
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:
|
|
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
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
// --
|
|
142
|
-
if (flags.
|
|
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
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
437
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
if (
|
|
677
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
740
|
-
|
|
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
|
-
|
|
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
|
|