storymode-cli 1.0.0 → 1.1.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.0.0",
3
+ "version": "1.1.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
@@ -28,8 +28,12 @@ export async function fetchGallery() {
28
28
  return JSON.parse(buffer.toString('utf-8'));
29
29
  }
30
30
 
31
- export async function fetchFrames(galleryId) {
32
- const { buffer, contentType } = await request(`${BASE}/gallery/${galleryId}/frames`);
31
+ export async function fetchFrames(galleryId, { size } = {}) {
32
+ let url = `${BASE}/gallery/${galleryId}/frames`;
33
+ if (size && size !== 'full') {
34
+ url += `?size=${size}`;
35
+ }
36
+ const { buffer, contentType } = await request(url);
33
37
  let json;
34
38
  if (contentType.includes('gzip') || buffer[0] === 0x1f) {
35
39
  json = gunzipSync(buffer).toString('utf-8');
package/src/cli.mjs CHANGED
@@ -1,5 +1,6 @@
1
+ import { execSync } from 'node:child_process';
1
2
  import { fetchFrames, fetchCharacter } from './api.mjs';
2
- import { playAnimation, showFrame } from './player.mjs';
3
+ import { playAnimation, playCompanion, showFrame } from './player.mjs';
3
4
  import { browse } from './browse.mjs';
4
5
  import { startMcpServer } from './mcp.mjs';
5
6
 
@@ -7,10 +8,16 @@ const HELP = `
7
8
  storymode-cli — play AI-animated pixel art in your terminal
8
9
 
9
10
  Usage:
10
- storymode play <gallery_id> Play an animation
11
- storymode show <gallery_id> Show first frame as static portrait
12
- storymode browse Browse the gallery interactively
13
- storymode mcp Start MCP server (for Claude Code)
11
+ storymode play <gallery_id> Play an animation (full screen)
12
+ storymode companion <gallery_id> Play compact sprite alongside your session
13
+ storymode show <gallery_id> Show first frame as static portrait
14
+ storymode browse Browse the gallery interactively
15
+ storymode mcp Start MCP server (for Claude Code)
16
+
17
+ Companion mode:
18
+ Opens a tmux side pane with a small looping sprite.
19
+ Use --detach to return control immediately.
20
+ Use --size=compact (default) or --size=tiny for smaller sprites.
14
21
 
15
22
  Controls (during playback):
16
23
  space pause / resume
@@ -21,9 +28,24 @@ const HELP = `
21
28
  https://storymode.fixmy.codes
22
29
  `;
23
30
 
31
+ function parseFlags(args) {
32
+ const flags = {};
33
+ const positional = [];
34
+ for (const arg of args) {
35
+ if (arg.startsWith('--')) {
36
+ const [key, val] = arg.slice(2).split('=');
37
+ flags[key] = val ?? true;
38
+ } else {
39
+ positional.push(arg);
40
+ }
41
+ }
42
+ return { flags, positional };
43
+ }
44
+
24
45
  export async function run(args) {
25
46
  const cmd = args[0];
26
- const id = args[1];
47
+ const { flags, positional } = parseFlags(args.slice(1));
48
+ const id = positional[0];
27
49
 
28
50
  switch (cmd) {
29
51
  case 'play': {
@@ -42,6 +64,67 @@ export async function run(args) {
42
64
  break;
43
65
  }
44
66
 
67
+ case 'companion': {
68
+ if (!id) {
69
+ console.error('Usage: storymode companion <gallery_id> [--detach] [--size=compact|tiny]');
70
+ process.exit(1);
71
+ }
72
+ const size = flags.size || 'compact';
73
+ const detach = !!flags.detach;
74
+ const paneWidth = size === 'tiny' ? 14 : 22;
75
+
76
+ // If we're already the companion pane (spawned by tmux split), play inline
77
+ if (process.env.STORYMODE_COMPANION === '1') {
78
+ try {
79
+ const framesData = await fetchFrames(id, { size });
80
+ await playCompanion(framesData);
81
+ } catch (err) {
82
+ console.error(`Error: ${err.message}`);
83
+ process.exit(1);
84
+ }
85
+ break;
86
+ }
87
+
88
+ // Otherwise, open a tmux side pane
89
+ const inTmux = !!process.env.TMUX;
90
+ const companionCmd = `STORYMODE_COMPANION=1 npx storymode-cli companion ${id} --size=${size}`;
91
+
92
+ try {
93
+ if (inTmux) {
94
+ // Split current window with a side pane
95
+ execSync(
96
+ `tmux split-window -h -l ${paneWidth} '${companionCmd}'`,
97
+ { stdio: 'ignore' }
98
+ );
99
+ if (!detach) {
100
+ process.stderr.write(` Companion sprite opened in side pane (${size}).\n`);
101
+ process.stderr.write(' Close the pane or press q in it to stop.\n');
102
+ }
103
+ } else {
104
+ // Not in tmux — start a new tmux session with the split
105
+ process.stderr.write(' Starting tmux session with companion pane...\n');
106
+ execSync(
107
+ `tmux new-session -d -s storymode -x 100 -y 30 \\; ` +
108
+ `split-window -h -l ${paneWidth} '${companionCmd}' \\; ` +
109
+ `select-pane -L`,
110
+ { stdio: 'ignore' }
111
+ );
112
+ if (detach) {
113
+ process.stderr.write(' Companion running in tmux session "storymode".\n');
114
+ process.stderr.write(' Attach with: tmux attach -t storymode\n');
115
+ } else {
116
+ // Attach to the session so the user sees it
117
+ execSync('tmux attach -t storymode', { stdio: 'inherit' });
118
+ }
119
+ }
120
+ } catch (err) {
121
+ console.error(`Error: ${err.message}`);
122
+ console.error(' Make sure tmux is installed: brew install tmux');
123
+ process.exit(1);
124
+ }
125
+ break;
126
+ }
127
+
45
128
  case 'show': {
46
129
  if (!id) {
47
130
  console.error('Usage: storymode show <gallery_id>');
package/src/mcp.mjs CHANGED
@@ -40,7 +40,7 @@ async function handleToolCall(name, args) {
40
40
  const id = args.gallery_id;
41
41
  const [character, framesData] = await Promise.all([
42
42
  fetchCharacter(id).catch(() => null),
43
- fetchFrames(id),
43
+ fetchFrames(id, { size: 'tiny' }),
44
44
  ]);
45
45
  let text = '';
46
46
  if (character) {
@@ -60,12 +60,13 @@ async function handleToolCall(name, args) {
60
60
  }
61
61
  case 'play_animation': {
62
62
  const id = args.gallery_id;
63
- const framesData = await fetchFrames(id);
63
+ const framesData = await fetchFrames(id, { size: 'tiny' });
64
64
  let text = '';
65
65
  if (framesData.frames?.length > 0) {
66
66
  text += framesData.frames[0].join('\n');
67
67
  text += `\n\n${framesData.frames.length} frames @ ${framesData.fps || 16} fps`;
68
68
  text += `\n\nTo see the full animation, run:\n npx storymode-cli play ${id}`;
69
+ text += `\n\nTo play as a companion sprite alongside your session:\n npx storymode-cli companion ${id}`;
69
70
  } else {
70
71
  text = 'No frames available.';
71
72
  }
package/src/player.mjs CHANGED
@@ -120,6 +120,87 @@ export function playAnimation(framesData) {
120
120
  });
121
121
  }
122
122
 
123
+ export function playCompanion(framesData) {
124
+ const frames = framesData.frames;
125
+ const baseFps = framesData.fps || 16;
126
+ const nFrames = frames.length;
127
+
128
+ if (nFrames === 0) {
129
+ console.log('No frames to play.');
130
+ return Promise.resolve();
131
+ }
132
+
133
+ const maxLines = Math.max(...frames.map(f => f.length));
134
+ let frameIdx = 0;
135
+ let currentFps = baseFps;
136
+ let interval = 1000 / currentFps;
137
+ let quit = false;
138
+
139
+ // No alt screen — render inline. Hide cursor only.
140
+ stdout.write('\x1b[?25l');
141
+
142
+ // Reserve space by printing blank lines, then move cursor back up
143
+ for (let i = 0; i < maxLines + 1; i++) {
144
+ stdout.write('\n');
145
+ }
146
+ stdout.write(`\x1b[${maxLines + 1}A`);
147
+
148
+ // Save cursor position (we'll return here each frame)
149
+ stdout.write('\x1b7');
150
+
151
+ function drawFrame(idx) {
152
+ // Restore saved cursor position
153
+ let out = '\x1b8';
154
+ const frameLines = frames[idx];
155
+ for (let i = 0; i < maxLines; i++) {
156
+ if (i < frameLines.length) {
157
+ out += frameLines[i];
158
+ }
159
+ out += '\x1b[K\n';
160
+ }
161
+ out += `\x1b[0m\x1b[K [q]=quit\n`;
162
+ stdout.write(out);
163
+ }
164
+
165
+ function cleanup() {
166
+ if (stdin.isTTY) stdin.setRawMode(false);
167
+ stdin.removeAllListeners('data');
168
+ stdin.pause();
169
+ stdout.write('\x1b[?25h');
170
+ }
171
+
172
+ function handleKey(data) {
173
+ const key = data.toString();
174
+ if (key === 'q' || key === '\x03') {
175
+ quit = true;
176
+ }
177
+ }
178
+
179
+ if (stdin.isTTY) {
180
+ stdin.setRawMode(true);
181
+ stdin.resume();
182
+ stdin.on('data', handleKey);
183
+ }
184
+
185
+ return new Promise((resolve) => {
186
+ function tick() {
187
+ if (quit) {
188
+ cleanup();
189
+ resolve();
190
+ return;
191
+ }
192
+ drawFrame(frameIdx);
193
+ frameIdx = (frameIdx + 1) % nFrames;
194
+ setTimeout(tick, interval);
195
+ }
196
+ tick();
197
+
198
+ process.on('SIGINT', () => {
199
+ quit = true;
200
+ });
201
+ });
202
+ }
203
+
123
204
  export function showFrame(framesData, info) {
124
205
  const frames = framesData.frames;
125
206
  if (!frames || frames.length === 0) {