storymode-cli 1.0.0 → 1.1.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.0.0",
3
+ "version": "1.1.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
@@ -2,7 +2,7 @@ import https from 'node:https';
2
2
  import http from 'node:http';
3
3
  import { gunzipSync } from 'node:zlib';
4
4
 
5
- const BASE = 'https://storymode.fixmy.codes';
5
+ const BASE = process.env.STORYMODE_BASE_URL || 'https://pixterm-server.proudisland-84e92871.westus2.azurecontainerapps.io';
6
6
 
7
7
  function request(url) {
8
8
  return new Promise((resolve, reject) => {
@@ -28,8 +28,13 @@ 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, mode } = {}) {
32
+ let url = `${BASE}/gallery/${galleryId}/frames`;
33
+ const params = [];
34
+ if (size && size !== 'full') params.push(`size=${size}`);
35
+ if (mode) params.push(`mode=${mode}`);
36
+ if (params.length) url += '?' + params.join('&');
37
+ const { buffer, contentType } = await request(url);
33
38
  let json;
34
39
  if (contentType.includes('gzip') || buffer[0] === 0x1f) {
35
40
  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,17 @@ 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
+ Options:
18
+ --sextant Use sextant rendering (2x3 sub-pixels, sharper but needs
19
+ a modern terminal). Works with play, companion, show.
20
+ --size=compact|tiny Size for companion mode (default: compact).
21
+ --detach Return control immediately (companion mode).
14
22
 
15
23
  Controls (during playback):
16
24
  space pause / resume
@@ -21,9 +29,24 @@ const HELP = `
21
29
  https://storymode.fixmy.codes
22
30
  `;
23
31
 
32
+ function parseFlags(args) {
33
+ const flags = {};
34
+ const positional = [];
35
+ for (const arg of args) {
36
+ if (arg.startsWith('--')) {
37
+ const [key, val] = arg.slice(2).split('=');
38
+ flags[key] = val ?? true;
39
+ } else {
40
+ positional.push(arg);
41
+ }
42
+ }
43
+ return { flags, positional };
44
+ }
45
+
24
46
  export async function run(args) {
25
47
  const cmd = args[0];
26
- const id = args[1];
48
+ const { flags, positional } = parseFlags(args.slice(1));
49
+ const id = positional[0];
27
50
 
28
51
  switch (cmd) {
29
52
  case 'play': {
@@ -33,7 +56,9 @@ export async function run(args) {
33
56
  }
34
57
  try {
35
58
  process.stderr.write(' Loading animation...\n');
36
- const framesData = await fetchFrames(id);
59
+ const fetchOpts = {};
60
+ if (flags.sextant) { fetchOpts.size = 'compact'; fetchOpts.mode = 'sextant'; }
61
+ const framesData = await fetchFrames(id, fetchOpts);
37
62
  await playAnimation(framesData);
38
63
  } catch (err) {
39
64
  console.error(`Error: ${err.message}`);
@@ -42,6 +67,70 @@ export async function run(args) {
42
67
  break;
43
68
  }
44
69
 
70
+ case 'companion': {
71
+ if (!id) {
72
+ console.error('Usage: storymode companion <gallery_id> [--detach] [--size=compact|tiny]');
73
+ process.exit(1);
74
+ }
75
+ const size = flags.size || 'compact';
76
+ const detach = !!flags.detach;
77
+ const paneWidth = size === 'tiny' ? 14 : 22;
78
+
79
+ // If we're already the companion pane (spawned by tmux split), play inline
80
+ if (process.env.STORYMODE_COMPANION === '1') {
81
+ try {
82
+ const fetchOpts = { size };
83
+ if (flags.sextant) fetchOpts.mode = 'sextant';
84
+ const framesData = await fetchFrames(id, fetchOpts);
85
+ await playCompanion(framesData);
86
+ } catch (err) {
87
+ console.error(`Error: ${err.message}`);
88
+ process.exit(1);
89
+ }
90
+ break;
91
+ }
92
+
93
+ // Otherwise, open a tmux side pane
94
+ const inTmux = !!process.env.TMUX;
95
+ const sextantFlag = flags.sextant ? ' --sextant' : '';
96
+ const companionCmd = `STORYMODE_COMPANION=1 npx storymode-cli companion ${id} --size=${size}${sextantFlag}`;
97
+
98
+ try {
99
+ if (inTmux) {
100
+ // Split current window with a side pane
101
+ execSync(
102
+ `tmux split-window -h -l ${paneWidth} '${companionCmd}'`,
103
+ { stdio: 'ignore' }
104
+ );
105
+ if (!detach) {
106
+ process.stderr.write(` Companion sprite opened in side pane (${size}).\n`);
107
+ process.stderr.write(' Close the pane or press q in it to stop.\n');
108
+ }
109
+ } else {
110
+ // Not in tmux — start a new tmux session with the split
111
+ process.stderr.write(' Starting tmux session with companion pane...\n');
112
+ execSync(
113
+ `tmux new-session -d -s storymode -x 100 -y 30 \\; ` +
114
+ `split-window -h -l ${paneWidth} '${companionCmd}' \\; ` +
115
+ `select-pane -L`,
116
+ { stdio: 'ignore' }
117
+ );
118
+ if (detach) {
119
+ process.stderr.write(' Companion running in tmux session "storymode".\n');
120
+ process.stderr.write(' Attach with: tmux attach -t storymode\n');
121
+ } else {
122
+ // Attach to the session so the user sees it
123
+ execSync('tmux attach -t storymode', { stdio: 'inherit' });
124
+ }
125
+ }
126
+ } catch (err) {
127
+ console.error(`Error: ${err.message}`);
128
+ console.error(' Make sure tmux is installed: brew install tmux');
129
+ process.exit(1);
130
+ }
131
+ break;
132
+ }
133
+
45
134
  case 'show': {
46
135
  if (!id) {
47
136
  console.error('Usage: storymode show <gallery_id>');
@@ -49,8 +138,10 @@ export async function run(args) {
49
138
  }
50
139
  try {
51
140
  process.stderr.write(' Loading...\n');
141
+ const fetchOpts = {};
142
+ if (flags.sextant) { fetchOpts.size = 'compact'; fetchOpts.mode = 'sextant'; }
52
143
  const [framesData, character] = await Promise.all([
53
- fetchFrames(id),
144
+ fetchFrames(id, fetchOpts),
54
145
  fetchCharacter(id).catch(() => null),
55
146
  ]);
56
147
  const info = character
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) {