astroshot 0.2.1 → 0.2.2

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.
Files changed (99) hide show
  1. package/node_modules/@archastro/astroshot/README.md +4 -0
  2. package/node_modules/@archastro/astroshot/bin/astroshot.mjs +56 -1
  3. package/node_modules/@archastro/astroshot/bin/demo.mjs +72 -16
  4. package/node_modules/@archastro/astroshot/package.json +5 -4
  5. package/node_modules/@archastro/astroshot-review/LICENSE +21 -0
  6. package/node_modules/@archastro/astroshot-review/README.md +19 -0
  7. package/node_modules/@archastro/astroshot-review/bin/astroshot-review.mjs +17 -0
  8. package/node_modules/@archastro/astroshot-review/dist/cli.d.ts +12 -0
  9. package/node_modules/@archastro/astroshot-review/dist/cli.js +240 -0
  10. package/node_modules/@archastro/astroshot-review/dist/data/friction.d.ts +22 -0
  11. package/node_modules/@archastro/astroshot-review/dist/data/friction.js +278 -0
  12. package/node_modules/@archastro/astroshot-review/dist/data/hash-cache.d.ts +17 -0
  13. package/node_modules/@archastro/astroshot-review/dist/data/hash-cache.js +51 -0
  14. package/node_modules/@archastro/astroshot-review/dist/data/index-cache.d.ts +22 -0
  15. package/node_modules/@archastro/astroshot-review/dist/data/index-cache.js +64 -0
  16. package/node_modules/@archastro/astroshot-review/dist/data/manifest.d.ts +41 -0
  17. package/node_modules/@archastro/astroshot-review/dist/data/manifest.js +105 -0
  18. package/node_modules/@archastro/astroshot-review/dist/data/model.d.ts +96 -0
  19. package/node_modules/@archastro/astroshot-review/dist/data/model.js +1 -0
  20. package/node_modules/@archastro/astroshot-review/dist/data/paths.d.ts +27 -0
  21. package/node_modules/@archastro/astroshot-review/dist/data/paths.js +98 -0
  22. package/node_modules/@archastro/astroshot-review/dist/data/review-store.d.ts +67 -0
  23. package/node_modules/@archastro/astroshot-review/dist/data/review-store.js +237 -0
  24. package/node_modules/@archastro/astroshot-review/dist/data/scan.d.ts +32 -0
  25. package/node_modules/@archastro/astroshot-review/dist/data/scan.js +227 -0
  26. package/node_modules/@archastro/astroshot-review/dist/data/store.d.ts +92 -0
  27. package/node_modules/@archastro/astroshot-review/dist/data/store.js +408 -0
  28. package/node_modules/@archastro/astroshot-review/dist/data/watcher.d.ts +37 -0
  29. package/node_modules/@archastro/astroshot-review/dist/data/watcher.js +126 -0
  30. package/node_modules/@archastro/astroshot-review/dist/images/halfblocks.d.ts +10 -0
  31. package/node_modules/@archastro/astroshot-review/dist/images/halfblocks.js +21 -0
  32. package/node_modules/@archastro/astroshot-review/dist/images/png.d.ts +14 -0
  33. package/node_modules/@archastro/astroshot-review/dist/images/png.js +45 -0
  34. package/node_modules/@archastro/astroshot-review/dist/images/scale.d.ts +31 -0
  35. package/node_modules/@archastro/astroshot-review/dist/images/scale.js +102 -0
  36. package/node_modules/@archastro/astroshot-review/dist/images/service.d.ts +54 -0
  37. package/node_modules/@archastro/astroshot-review/dist/images/service.js +163 -0
  38. package/node_modules/@archastro/astroshot-review/dist/images/worker.d.ts +21 -0
  39. package/node_modules/@archastro/astroshot-review/dist/images/worker.js +30 -0
  40. package/node_modules/@archastro/astroshot-review/dist/index.d.ts +12 -0
  41. package/node_modules/@archastro/astroshot-review/dist/index.js +9 -0
  42. package/node_modules/@archastro/astroshot-review/dist/terminal/graphics-stdout.d.ts +9 -0
  43. package/node_modules/@archastro/astroshot-review/dist/terminal/graphics-stdout.js +36 -0
  44. package/node_modules/@archastro/astroshot-review/dist/terminal/herdr.d.ts +61 -0
  45. package/node_modules/@archastro/astroshot-review/dist/terminal/herdr.js +327 -0
  46. package/node_modules/@archastro/astroshot-review/dist/terminal/image-layer.d.ts +122 -0
  47. package/node_modules/@archastro/astroshot-review/dist/terminal/image-layer.js +471 -0
  48. package/node_modules/@archastro/astroshot-review/dist/terminal/kitty.d.ts +74 -0
  49. package/node_modules/@archastro/astroshot-review/dist/terminal/kitty.js +112 -0
  50. package/node_modules/@archastro/astroshot-review/dist/terminal/probe.d.ts +49 -0
  51. package/node_modules/@archastro/astroshot-review/dist/terminal/probe.js +206 -0
  52. package/node_modules/@archastro/astroshot-review/dist/ui/app.d.ts +6 -0
  53. package/node_modules/@archastro/astroshot-review/dist/ui/app.js +695 -0
  54. package/node_modules/@archastro/astroshot-review/dist/ui/chrome.d.ts +53 -0
  55. package/node_modules/@archastro/astroshot-review/dist/ui/chrome.js +69 -0
  56. package/node_modules/@archastro/astroshot-review/dist/ui/context.d.ts +16 -0
  57. package/node_modules/@archastro/astroshot-review/dist/ui/context.js +8 -0
  58. package/node_modules/@archastro/astroshot-review/dist/ui/detail.d.ts +33 -0
  59. package/node_modules/@archastro/astroshot-review/dist/ui/detail.js +39 -0
  60. package/node_modules/@archastro/astroshot-review/dist/ui/friction.d.ts +42 -0
  61. package/node_modules/@archastro/astroshot-review/dist/ui/friction.js +84 -0
  62. package/node_modules/@archastro/astroshot-review/dist/ui/help.d.ts +4 -0
  63. package/node_modules/@archastro/astroshot-review/dist/ui/help.js +61 -0
  64. package/node_modules/@archastro/astroshot-review/dist/ui/hooks.d.ts +9 -0
  65. package/node_modules/@archastro/astroshot-review/dist/ui/hooks.js +32 -0
  66. package/node_modules/@archastro/astroshot-review/dist/ui/movie-player.d.ts +29 -0
  67. package/node_modules/@archastro/astroshot-review/dist/ui/movie-player.js +119 -0
  68. package/node_modules/@archastro/astroshot-review/dist/ui/picture.d.ts +19 -0
  69. package/node_modules/@archastro/astroshot-review/dist/ui/picture.js +100 -0
  70. package/node_modules/@archastro/astroshot-review/dist/ui/selectors.d.ts +26 -0
  71. package/node_modules/@archastro/astroshot-review/dist/ui/selectors.js +69 -0
  72. package/node_modules/@archastro/astroshot-review/dist/ui/settings.d.ts +5 -0
  73. package/node_modules/@archastro/astroshot-review/dist/ui/settings.js +17 -0
  74. package/node_modules/@archastro/astroshot-review/dist/ui/stream.d.ts +38 -0
  75. package/node_modules/@archastro/astroshot-review/dist/ui/stream.js +118 -0
  76. package/node_modules/@archastro/astroshot-review/dist/ui/system.d.ts +4 -0
  77. package/node_modules/@archastro/astroshot-review/dist/ui/system.js +34 -0
  78. package/node_modules/@archastro/astroshot-review/dist/ui/takeover.d.ts +26 -0
  79. package/node_modules/@archastro/astroshot-review/dist/ui/takeover.js +29 -0
  80. package/node_modules/@archastro/astroshot-review/dist/ui/text-input.d.ts +8 -0
  81. package/node_modules/@archastro/astroshot-review/dist/ui/text-input.js +74 -0
  82. package/node_modules/@archastro/astroshot-review/dist/ui/theme.d.ts +21 -0
  83. package/node_modules/@archastro/astroshot-review/dist/ui/theme.js +63 -0
  84. package/node_modules/@archastro/astroshot-review/dist/video/ffmpeg.d.ts +54 -0
  85. package/node_modules/@archastro/astroshot-review/dist/video/ffmpeg.js +206 -0
  86. package/node_modules/@archastro/astroshot-review/package.json +71 -0
  87. package/node_modules/@archastro/movie-harness/package.json +1 -1
  88. package/node_modules/@archastro/react-shot/package.json +4 -4
  89. package/node_modules/@archastro/tui-shot/README.md +9 -0
  90. package/node_modules/@archastro/tui-shot/dist/index.d.ts +2 -0
  91. package/node_modules/@archastro/tui-shot/dist/index.js +1 -0
  92. package/node_modules/@archastro/tui-shot/dist/kitty-graphics.d.ts +45 -0
  93. package/node_modules/@archastro/tui-shot/dist/kitty-graphics.js +279 -0
  94. package/node_modules/@archastro/tui-shot/dist/pty-shot.js +25 -3
  95. package/node_modules/@archastro/tui-shot/dist/shot.d.ts +2 -0
  96. package/node_modules/@archastro/tui-shot/dist/shot.js +3 -1
  97. package/node_modules/@archastro/tui-shot/dist/types.d.ts +6 -0
  98. package/node_modules/@archastro/tui-shot/package.json +1 -1
  99. package/package.json +10 -5
@@ -13,6 +13,10 @@ npx astroshot demo
13
13
  # Check Node, watched folders, app install/run state, Chromium, and macOS
14
14
  # Screen Recording. Each failure prints the exact fix command. Read-only.
15
15
  npx astroshot doctor
16
+
17
+ # Review the stream in your terminal (Ghostty, kitty, or WezTerm draw the
18
+ # pictures; ffmpeg plays movies). Same review.json as the macOS app.
19
+ npx astroshot review
16
20
  ```
17
21
 
18
22
  `demo` accepts `--feature <name>`, `--root <dir>`, and `--json`. `doctor`
@@ -8,12 +8,14 @@ import path from "node:path";
8
8
  import { writeFixtureTemplate } from "./templates.mjs";
9
9
  import { demoHelp, runDemo } from "./demo.mjs";
10
10
  import { doctorHelp, runDoctor } from "./doctor.mjs";
11
+ import { readWatchConfiguration } from "./mac-preferences.mjs";
11
12
 
12
13
  function help() {
13
14
  console.log(`astroshot — one CLI for React, Ink, PTY stills, and movies
14
15
 
15
16
  Usage:
16
- astroshot demo [--feature <name>] [--root <dir>]
17
+ astroshot review [<dir>...] [--root <dir>] [--no-graphics]
18
+ astroshot demo [--feature <name>] [--root <dir>] [--dry-run] [--clean]
17
19
  astroshot doctor [--root <dir>] [--json]
18
20
  astroshot init react [fixture.tsx] [--force]
19
21
  astroshot init ink [fixture.tsx] [--force]
@@ -28,6 +30,7 @@ Usage:
28
30
  astroshot install-browser [--with-deps]
29
31
 
30
32
  Start here:
33
+ review Review the .astroshot/ stream in your terminal (Kitty graphics)
31
34
  demo Write a complete .astroshot/ example set (no prerequisites)
32
35
  doctor Check Node, watched folders, app, Chromium, permissions
33
36
 
@@ -104,6 +107,11 @@ Options:
104
107
  }
105
108
 
106
109
  function engineBin(mode) {
110
+ if (mode === "review") {
111
+ const entry = fileURLToPath(import.meta.resolve("@archastro/astroshot-review"));
112
+ const packageRoot = path.dirname(path.dirname(entry));
113
+ return path.join(packageRoot, "bin", "astroshot-review.mjs");
114
+ }
107
115
  if (mode === "movie") {
108
116
  const entry = fileURLToPath(import.meta.resolve("@archastro/movie-harness"));
109
117
  // package exports "." → dist/index.js → package root is two levels up from dist
@@ -189,6 +197,49 @@ function runInit(arguments_) {
189
197
  console.log(`Created ${result.label} fixture: ${result.absolutePath}`);
190
198
  }
191
199
 
200
+ /**
201
+ * `astroshot review`: the terminal tray. Without explicit roots it watches
202
+ * the same folders as the Astroshots app so both surfaces show one stream.
203
+ */
204
+ function runReview(arguments_) {
205
+ // `astroshot review help` mirrors the other modes; anything else is
206
+ // forwarded verbatim so a folder literally named "help" still works.
207
+ const wantsHelp =
208
+ arguments_[0] === "help" ||
209
+ arguments_.includes("-h") ||
210
+ arguments_.includes("--help");
211
+ const forwarded = wantsHelp ? ["--help"] : [...arguments_];
212
+ const hasRoots =
213
+ forwarded.includes("--root") ||
214
+ forwarded.some((value) => !value.startsWith("-"));
215
+ if (!hasRoots && !wantsHelp) {
216
+ const configuration = readWatchConfiguration();
217
+ if (configuration.available && configuration.roots.length > 0) {
218
+ for (const root of configuration.roots) forwarded.push("--root", root);
219
+ forwarded.push("--roots-source", "app");
220
+ }
221
+ }
222
+ let engine;
223
+ try {
224
+ engine = engineBin("review");
225
+ } catch (error) {
226
+ console.error(
227
+ `astroshot could not find the review tray engine (@archastro/astroshot-review): ${error instanceof Error ? error.message : error}`,
228
+ );
229
+ console.error("Reinstall @archastro/astroshot (or the unscoped astroshot package) and retry.");
230
+ process.exit(1);
231
+ }
232
+ const result = spawnSync(process.execPath, [engine, ...forwarded], {
233
+ stdio: "inherit",
234
+ });
235
+ if (result.error) {
236
+ console.error(`astroshot could not start the review tray: ${result.error.message}`);
237
+ process.exit(1);
238
+ }
239
+ if (result.signal) process.kill(process.pid, result.signal);
240
+ process.exit(result.status ?? 1);
241
+ }
242
+
192
243
  const [command, ...arguments_] = process.argv.slice(2);
193
244
 
194
245
  if (command === "-v" || command === "--version") {
@@ -254,6 +305,10 @@ if (command === "react" || command === "ink" || command === "tui" || command ===
254
305
  runEngine(canonicalMode, arguments_);
255
306
  }
256
307
 
308
+ if (command === "review" || command === "tray") {
309
+ runReview(arguments_);
310
+ }
311
+
257
312
  if (command === "movie") {
258
313
  // Always forward to movie-harness (including --help / which-source).
259
314
  const result = spawnSync(
@@ -30,6 +30,8 @@ Options:
30
30
  --feature <name> Feature directory under .astroshot/ (default: ${DEFAULT_DEMO_FEATURE})
31
31
  --root <dir> Worktree root (default: git root, else cwd)
32
32
  --json Print the written paths as JSON
33
+ --dry-run Print planned paths without writing
34
+ --clean Accepted; does not delete yet
33
35
  -h, --help Show this help
34
36
 
35
37
  Writes two stills, one movie poster + video pair, and manifest.json using
@@ -112,14 +114,24 @@ export function loadDemoFixtures(fixturesDirectory = FIXTURES_DIR) {
112
114
  return index;
113
115
  }
114
116
 
117
+ function publicDemoResult(plan) {
118
+ return {
119
+ root: plan.root,
120
+ feature: plan.feature,
121
+ featureDirectory: plan.featureDirectory,
122
+ manifestPath: plan.manifestPath,
123
+ runId: plan.runId,
124
+ shots: plan.shots,
125
+ files: plan.files,
126
+ };
127
+ }
128
+
115
129
  /**
116
- * Write the demo set and return the paths, without printing anything.
117
- *
118
- * Each invocation is its own run: a new `run_id` with a fresh shot list, while
119
- * earlier numbered frames stay on disk as prior-run evidence. That matches the
120
- * documented lifecycle used by astroshot-capture and the movie harness.
130
+ * Read-only plan of the next demo write: same root/feature resolve, kebab-case
131
+ * check, fixtures, next sequence, and file names (including manifest.json).
132
+ * Does not mkdir or write.
121
133
  */
122
- export function writeDemo({
134
+ export function planDemo({
123
135
  root,
124
136
  feature = DEFAULT_DEMO_FEATURE,
125
137
  fixturesDirectory = FIXTURES_DIR,
@@ -130,7 +142,6 @@ export function writeDemo({
130
142
  const featureDirectory = path.join(resolvedRoot, ".astroshot", feature);
131
143
  const index = loadDemoFixtures(fixturesDirectory);
132
144
 
133
- fs.mkdirSync(featureDirectory, { recursive: true });
134
145
  const startSequence = nextSequence(featureDirectory);
135
146
  const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
136
147
  // The first sequence of this run keeps the id unique when two runs land in
@@ -140,11 +151,16 @@ export function writeDemo({
140
151
 
141
152
  const shots = [];
142
153
  const files = [];
154
+ const copies = [];
143
155
  index.shots.forEach((fixture, offset) => {
144
156
  const sequence = String(startSequence + offset).padStart(4, "0");
145
157
  const posterName = `${sequence}-${fixture.slug}.png`;
146
- copyAtomic(path.join(fixturesDirectory, fixture.asset), path.join(featureDirectory, posterName));
147
- files.push(path.join(featureDirectory, posterName));
158
+ const posterPath = path.join(featureDirectory, posterName);
159
+ copies.push({
160
+ source: path.join(fixturesDirectory, fixture.asset),
161
+ destination: posterPath,
162
+ });
163
+ files.push(posterPath);
148
164
 
149
165
  const shot = {
150
166
  id: sequence,
@@ -159,8 +175,12 @@ export function writeDemo({
159
175
  if (fixture.video) {
160
176
  const videoExtension = path.extname(fixture.video) || ".webm";
161
177
  const videoName = `${sequence}-${fixture.slug}${videoExtension}`;
162
- copyAtomic(path.join(fixturesDirectory, fixture.video), path.join(featureDirectory, videoName));
163
- files.push(path.join(featureDirectory, videoName));
178
+ const videoPath = path.join(featureDirectory, videoName);
179
+ copies.push({
180
+ source: path.join(fixturesDirectory, fixture.video),
181
+ destination: videoPath,
182
+ });
183
+ files.push(videoPath);
164
184
  shot.kind = "movie";
165
185
  shot.video = videoName;
166
186
  shot.duration_ms = fixture.duration_ms;
@@ -181,7 +201,6 @@ export function writeDemo({
181
201
  "Synthetic proof set written by `astroshot demo` — stills plus one journey movie.",
182
202
  shots,
183
203
  };
184
- writeAtomic(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
185
204
  files.push(manifestPath);
186
205
 
187
206
  return {
@@ -189,12 +208,31 @@ export function writeDemo({
189
208
  feature,
190
209
  featureDirectory,
191
210
  manifestPath,
211
+ manifest,
192
212
  runId,
193
213
  shots,
194
214
  files,
215
+ copies,
195
216
  };
196
217
  }
197
218
 
219
+ /**
220
+ * Write the demo set and return the paths, without printing anything.
221
+ *
222
+ * Each invocation is its own run: a new `run_id` with a fresh shot list, while
223
+ * earlier numbered frames stay on disk as prior-run evidence. That matches the
224
+ * documented lifecycle used by astroshot-capture and the movie harness.
225
+ */
226
+ export function writeDemo(options = {}) {
227
+ const plan = planDemo(options);
228
+ fs.mkdirSync(plan.featureDirectory, { recursive: true });
229
+ for (const { source, destination } of plan.copies) {
230
+ copyAtomic(source, destination);
231
+ }
232
+ writeAtomic(plan.manifestPath, `${JSON.stringify(plan.manifest, null, 2)}\n`);
233
+ return publicDemoResult(plan);
234
+ }
235
+
198
236
  function coverageAdvice(root) {
199
237
  const configuration = readWatchConfiguration();
200
238
  const coverage = evaluateWatchCoverage(root, configuration);
@@ -231,7 +269,13 @@ function coverageAdvice(root) {
231
269
  }
232
270
 
233
271
  export function runDemo(argv, { log = console.log } = {}) {
234
- const options = { feature: DEFAULT_DEMO_FEATURE, root: undefined, json: false };
272
+ const options = {
273
+ feature: DEFAULT_DEMO_FEATURE,
274
+ root: undefined,
275
+ json: false,
276
+ dryRun: false,
277
+ clean: false,
278
+ };
235
279
  for (let index = 0; index < argv.length; index += 1) {
236
280
  const token = argv[index];
237
281
  if (token === "-h" || token === "--help" || token === "help") {
@@ -242,6 +286,14 @@ export function runDemo(argv, { log = console.log } = {}) {
242
286
  options.json = true;
243
287
  continue;
244
288
  }
289
+ if (token === "--dry-run") {
290
+ options.dryRun = true;
291
+ continue;
292
+ }
293
+ if (token === "--clean") {
294
+ options.clean = true;
295
+ continue;
296
+ }
245
297
  if (token === "--feature" || token === "--root") {
246
298
  const value = argv[index + 1];
247
299
  if (!value || value.startsWith("-")) {
@@ -254,18 +306,22 @@ export function runDemo(argv, { log = console.log } = {}) {
254
306
  throw new Error(`Unknown demo argument: ${token}`);
255
307
  }
256
308
 
257
- const result = writeDemo(options);
309
+ const result = options.dryRun
310
+ ? publicDemoResult(planDemo(options))
311
+ : writeDemo(options);
258
312
  if (options.json) {
259
313
  log(JSON.stringify({ ...result, advice: coverageAdvice(result.root) }, null, 2));
260
314
  return 0;
261
315
  }
262
316
 
263
- const movies = result.shots.filter((shot) => shot.kind === "movie").length;
264
- const stills = result.shots.length - movies;
265
317
  log(`astroshot demo → ${result.featureDirectory}`);
266
318
  for (const file of result.files) {
267
319
  log(` ${path.relative(result.root, file)}`);
268
320
  }
321
+ if (options.dryRun) return 0;
322
+
323
+ const movies = result.shots.filter((shot) => shot.kind === "movie").length;
324
+ const stills = result.shots.length - movies;
269
325
  log("");
270
326
  log(
271
327
  `Wrote ${stills} still${stills === 1 ? "" : "s"}, ${movies} movie${movies === 1 ? "" : "s"} (poster + video), and manifest.json.`,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@archastro/astroshot",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "One CLI for deterministic React, terminal, and movie UI captures",
5
5
  "keywords": [
6
6
  "react",
@@ -72,8 +72,9 @@
72
72
  "registry": "https://registry.npmjs.org/"
73
73
  },
74
74
  "dependencies": {
75
- "@archastro/movie-harness": "0.2.1",
76
- "@archastro/react-shot": "0.2.1",
77
- "@archastro/tui-shot": "0.2.1"
75
+ "@archastro/astroshot-review": "0.2.2",
76
+ "@archastro/movie-harness": "0.2.2",
77
+ "@archastro/react-shot": "0.2.2",
78
+ "@archastro/tui-shot": "0.2.2"
78
79
  }
79
80
  }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ArchAstro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,19 @@
1
+ # @archastro/astroshot-review
2
+
3
+ The Astroshots review tray in your terminal. Streams `.astroshot/` captures
4
+ across worktrees newest-first, draws them with the Kitty graphics protocol,
5
+ plays movies through ffmpeg, and writes the same `review.json` Seen state and
6
+ feedback as the macOS app.
7
+
8
+ ```bash
9
+ npx astroshot review # via the unified CLI (recommended)
10
+ npx astroshot-review ~/projects # this package's own bin
11
+ ```
12
+
13
+ - Terminals: Ghostty, kitty, WezTerm (anything with the Kitty graphics protocol).
14
+ Others get labeled placeholders.
15
+ - Movies need `ffmpeg` on `PATH`.
16
+ - Keys: `↑↓` move · `⏎` open · `f` full screen · `s` seen · `c` feedback ·
17
+ `u` history · `m` movies · `1`/`2` tabs · `,` settings · `?` help · `q` quit.
18
+
19
+ Full guide: [docs/review-tui.md](https://github.com/ArchAstro/astroshots/blob/main/docs/review-tui.md).
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../dist/cli.js";
3
+
4
+ main(process.argv.slice(2)).then(
5
+ (code) => {
6
+ process.exitCode = code;
7
+ // main() has torn the tray down, so the event loop should drain and the
8
+ // process exit on its own. If a stray handle is still referenced (a
9
+ // socket, a pipe), don't sit invisibly in the shell forever: exit. The
10
+ // timer is unref'd, so it never keeps the loop alive itself.
11
+ setTimeout(() => process.exit(code), 1000).unref();
12
+ },
13
+ (error) => {
14
+ console.error(error instanceof Error ? error.message : error);
15
+ process.exitCode = 1;
16
+ },
17
+ );
@@ -0,0 +1,12 @@
1
+ export declare function reviewHelp(): string;
2
+ export interface ParsedArgs {
3
+ roots: string[];
4
+ graphics: boolean;
5
+ watch: boolean;
6
+ index: boolean;
7
+ help: boolean;
8
+ version: boolean;
9
+ rootsSource: "app" | "cli" | "cwd";
10
+ }
11
+ export declare function parseArgs(argv: string[]): ParsedArgs;
12
+ export declare function main(argv: string[]): Promise<number>;
@@ -0,0 +1,240 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { render } from "ink";
4
+ import { createElement } from "react";
5
+ import { ReviewStore } from "./data/store.js";
6
+ import { ImageService } from "./images/service.js";
7
+ import { createGraphicsStdout } from "./terminal/graphics-stdout.js";
8
+ import { HerdrSink, discoverHerdr, herdrAddress, probeHerdrSet } from "./terminal/herdr.js";
9
+ import { ImageLayer } from "./terminal/image-layer.js";
10
+ import { probeTerminal } from "./terminal/probe.js";
11
+ import { App } from "./ui/app.js";
12
+ import { ServicesContext } from "./ui/context.js";
13
+ import { detectFfmpeg } from "./video/ffmpeg.js";
14
+ export function reviewHelp() {
15
+ return `astroshot review — the Astroshots tray in your terminal
16
+
17
+ Usage:
18
+ astroshot review [<dir>...] [options]
19
+
20
+ Options:
21
+ --root <dir> Folder to watch for .astroshot/ trees (repeatable)
22
+ --no-graphics Skip terminal pictures (text only)
23
+ --no-watch Do not follow filesystem changes
24
+ --no-index Ignore the on-disk index (full scan every start)
25
+ -h, --help Show this help
26
+
27
+ Without roots, \`astroshot review\` uses the folders the Astroshots app
28
+ watches (macOS) and otherwise the current directory. Images render pixel-
29
+ perfect in Kitty-protocol terminals (Ghostty, kitty, WezTerm) and inside
30
+ herdr (enable [experimental] kitty_graphics and reattach the client once);
31
+ elsewhere — mosh, tmux, plain terminals — they render as truecolor
32
+ half-block text. Movie playback needs ffmpeg on PATH.
33
+
34
+ Keys: ↑↓ move · ⏎ open · f full screen · s seen · c feedback · u history ·
35
+ m movies · 1/2 tabs · , settings · ? help · q quit`;
36
+ }
37
+ export function parseArgs(argv) {
38
+ const parsed = { roots: [], graphics: true, watch: true, index: true, help: false, version: false, rootsSource: "cli" };
39
+ for (let index = 0; index < argv.length; index += 1) {
40
+ const argument = argv[index];
41
+ switch (argument) {
42
+ case "-h":
43
+ case "--help":
44
+ parsed.help = true;
45
+ break;
46
+ case "-v":
47
+ case "--version":
48
+ parsed.version = true;
49
+ break;
50
+ case "--no-graphics":
51
+ parsed.graphics = false;
52
+ break;
53
+ case "--no-watch":
54
+ parsed.watch = false;
55
+ break;
56
+ case "--no-index":
57
+ parsed.index = false;
58
+ break;
59
+ case "--root": {
60
+ const value = argv[index + 1];
61
+ if (!value)
62
+ throw new Error("--root requires a directory");
63
+ parsed.roots.push(value);
64
+ index += 1;
65
+ break;
66
+ }
67
+ case "--roots-source": {
68
+ const value = argv[index + 1];
69
+ if (value !== "app" && value !== "cli" && value !== "cwd")
70
+ throw new Error("--roots-source must be app, cli, or cwd");
71
+ parsed.rootsSource = value;
72
+ index += 1;
73
+ break;
74
+ }
75
+ default:
76
+ if (argument.startsWith("-"))
77
+ throw new Error(`Unknown option: ${argument}`);
78
+ parsed.roots.push(argument);
79
+ }
80
+ }
81
+ if (parsed.roots.length === 0) {
82
+ parsed.roots = [process.cwd()];
83
+ parsed.rootsSource = "cwd";
84
+ }
85
+ return parsed;
86
+ }
87
+ function readVersion() {
88
+ try {
89
+ const packageJson = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
90
+ return packageJson.version ?? "0.0.0";
91
+ }
92
+ catch {
93
+ return "0.0.0";
94
+ }
95
+ }
96
+ export async function main(argv) {
97
+ let args;
98
+ try {
99
+ args = parseArgs(argv);
100
+ }
101
+ catch (error) {
102
+ console.error(error instanceof Error ? error.message : String(error));
103
+ console.error("");
104
+ console.error(reviewHelp());
105
+ return 1;
106
+ }
107
+ if (args.help) {
108
+ console.log(reviewHelp());
109
+ return 0;
110
+ }
111
+ if (args.version) {
112
+ console.log(readVersion());
113
+ return 0;
114
+ }
115
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
116
+ console.error("astroshot review needs an interactive terminal (stdin and stdout must be a TTY).");
117
+ return 1;
118
+ }
119
+ const roots = args.roots.map((root) => path.resolve(root)).filter((root) => {
120
+ try {
121
+ return fs.statSync(root).isDirectory();
122
+ }
123
+ catch {
124
+ console.error(`Ignoring missing folder: ${root}`);
125
+ return false;
126
+ }
127
+ });
128
+ const debugLog = process.env.ASTROSHOT_REVIEW_DEBUG
129
+ ? (message) => fs.appendFileSync("astroshot-review.log", `${new Date().toISOString()} ${message}\n`)
130
+ : null;
131
+ let capabilities = await probeTerminal({ stdin: process.stdin, stdout: process.stdout }, { env: args.graphics ? process.env : { ...process.env, ASTROSHOT_REVIEW_GRAPHICS: "none" } });
132
+ // Inside herdr, raw Kitty escapes are dropped; render through its socket
133
+ // graphics API instead (pixel-perfect), falling back to half-blocks with an
134
+ // actionable reason when herdr can't yet report the host cell size.
135
+ let herdrSink;
136
+ const herdr = args.graphics ? herdrAddress() : null;
137
+ if (herdr) {
138
+ const discovery = await discoverHerdr(herdr, {
139
+ onWaiting: () => debugLog?.("herdr: waiting for host cell size"),
140
+ });
141
+ if (discovery.ok && discovery.cellWidth && discovery.cellHeight) {
142
+ const setSupport = await probeHerdrSet(herdr);
143
+ if (setSupport.ok) {
144
+ herdrSink = new HerdrSink(herdr, (error) => debugLog?.(`herdr: ${error.message}`));
145
+ capabilities = {
146
+ ...capabilities,
147
+ graphics: "herdr",
148
+ cellWidth: discovery.cellWidth,
149
+ cellHeight: discovery.cellHeight,
150
+ cellSource: "query",
151
+ reason: undefined,
152
+ };
153
+ }
154
+ else {
155
+ // herdr can size images but not place per-image layers; half-blocks it is.
156
+ capabilities = { ...capabilities, reason: setSupport.reason ?? "herdr pane.graphics.set unavailable" };
157
+ }
158
+ }
159
+ else {
160
+ capabilities = { ...capabilities, reason: discovery.reason ?? capabilities.reason };
161
+ }
162
+ }
163
+ debugLog?.(`capabilities ${JSON.stringify(capabilities)}`);
164
+ const ffmpeg = detectFfmpeg();
165
+ const service = new ImageService();
166
+ const layer = new ImageLayer({
167
+ capabilities,
168
+ service,
169
+ herdr: herdrSink,
170
+ write: (data) => {
171
+ process.stdout.write(data);
172
+ },
173
+ onError: (src, error) => debugLog?.(`image ${src}: ${error.message}`),
174
+ onDebug: debugLog ? (message) => debugLog(`layer ${message}`) : undefined,
175
+ });
176
+ const store = new ReviewStore({
177
+ roots,
178
+ watch: args.watch,
179
+ useIndex: args.index,
180
+ onLog: (message) => debugLog?.(`store ${message}`),
181
+ });
182
+ void store.start();
183
+ const stdout = createGraphicsStdout(process.stdout, layer);
184
+ const services = { store, layer, service, capabilities, ffmpeg, rootsSource: args.rootsSource, version: readVersion() };
185
+ let cleared = false;
186
+ const clearPictures = () => {
187
+ if (cleared)
188
+ return;
189
+ cleared = true;
190
+ const output = layer.clear();
191
+ if (output)
192
+ process.stdout.write(output);
193
+ herdrSink?.dispose();
194
+ };
195
+ const instance = render(createElement(ServicesContext.Provider, { value: services }, createElement(App, { onQuit: clearPictures })), {
196
+ stdout,
197
+ stdin: process.stdin,
198
+ alternateScreen: true,
199
+ exitOnCtrlC: true,
200
+ patchConsole: true,
201
+ // Ink treats CI=true as "not interactive": no alternate screen, no key
202
+ // handling, frames appended instead of redrawn. The tray already refuses
203
+ // to run without a TTY on both ends, so a TTY is the only signal that
204
+ // matters — CI-flavoured shells and CI pty harnesses get the real UI.
205
+ interactive: true,
206
+ });
207
+ // `kill <pid>` or a closing terminal must not leave pictures or the
208
+ // alternate screen behind; Ink only handles Ctrl+C itself.
209
+ const onSignal = (signal) => {
210
+ clearPictures();
211
+ try {
212
+ process.stdout.write("\x1b[?1049l\x1b[?25h");
213
+ if (process.stdin.isTTY)
214
+ process.stdin.setRawMode(false);
215
+ }
216
+ catch {
217
+ // The terminal may already be gone.
218
+ }
219
+ instance.unmount();
220
+ process.exit(signal === "SIGHUP" ? 129 : 143);
221
+ };
222
+ process.once("SIGTERM", onSignal);
223
+ process.once("SIGHUP", onSignal);
224
+ process.once("SIGQUIT", onSignal);
225
+ process.once("SIGINT", onSignal);
226
+ // Last-ditch: closing the herdr sockets makes herdr drop the layers even if
227
+ // we never got to run a signal handler.
228
+ process.once("exit", () => herdrSink?.dispose());
229
+ try {
230
+ await instance.waitUntilExit();
231
+ }
232
+ finally {
233
+ process.off("SIGTERM", onSignal);
234
+ process.off("SIGHUP", onSignal);
235
+ clearPictures();
236
+ await store.dispose();
237
+ await service.dispose();
238
+ }
239
+ return 0;
240
+ }
@@ -0,0 +1,22 @@
1
+ import type { HashCache } from "./hash-cache.js";
2
+ import type { FrictionLog, FrictionRun, FrictionStep } from "./model.js";
3
+ export declare const PROMPT_FILE = "prompt.md";
4
+ export declare const META_FILE = "meta.json";
5
+ export declare const RUNS_DIR = "runs";
6
+ export declare const LOG_FILE = "log.jsonl";
7
+ /** Parse JSONL text into ordered steps; screenshots are resolved against `runDir`. */
8
+ export declare function parseJsonl(text: string, runDir: string): Promise<FrictionStep[]>;
9
+ export declare function loadRun(runDir: string, runId: string, hashes: HashCache): Promise<FrictionRun | null>;
10
+ export declare function loadRuns(logDir: string, hashes: HashCache): Promise<FrictionRun[]>;
11
+ export declare function loadFrictionLog(logDir: string, context: {
12
+ worktreePath: string;
13
+ worktree: string;
14
+ }, hashes: HashCache): Promise<FrictionLog | null>;
15
+ export declare function loadFrictionLogs(frictionDir: string, context: {
16
+ worktreePath: string;
17
+ worktree: string;
18
+ }, hashes: HashCache): Promise<FrictionLog[]>;
19
+ /** `MMM d · HH:mm` in local time for `yyyyMMddTHHmmssZ(-N)` run ids. */
20
+ export declare function runDisplayTitle(runId: string): string;
21
+ export declare function stepCountLabel(count: number): string;
22
+ export declare function frictionStatusLabel(status: string | null): string | null;