termcut 0.4.0 → 0.4.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/README.md CHANGED
@@ -57,6 +57,7 @@ tcut publish demo.gif # → https://…/3f9a1c2b7d4e/demo.gif
57
57
 
58
58
  - [Examples](https://github.com/AmanVarshney01/tcut/tree/main/packages/tcut/examples) — driving an interactive TUI, recording Claude Code / Codex
59
59
  - [Reference](https://github.com/AmanVarshney01/tcut/blob/main/packages/tcut/docs/REFERENCE.md) — every CLI flag and script option
60
+ - [llms.txt](https://tcut.amanv.dev/llms.txt) — the same, condensed for coding agents (`--json` gives machine-readable results)
60
61
  - [tcut.amanv.dev](https://tcut.amanv.dev)
61
62
 
62
63
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termcut",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Script terminal sessions in TypeScript, render them to reproducible MP4/GIF/WebM/SVG/HTML with Bun.",
5
5
  "license": "MIT",
6
6
  "author": "Aman Varshney",
package/src/cli.ts CHANGED
@@ -59,6 +59,7 @@ Options (override the script's config):
59
59
  --name <file> publish: object name (default: the file's basename)
60
60
  --endpoint --bucket --access-key --secret-key --public-url --region publish --setup values
61
61
  --template <name> for init: basic | tour | test
62
+ --json machine-readable result (or { "error" }) on stdout, nothing else
62
63
  -q, --quiet
63
64
  -h, --help
64
65
  `;
@@ -103,11 +104,17 @@ const { values, positionals } = parseArgs({
103
104
  region: { type: "string" },
104
105
  template: { type: "string" },
105
106
  quiet: { type: "boolean", short: "q" },
107
+ json: { type: "boolean" },
106
108
  help: { type: "boolean", short: "h" },
107
109
  },
108
110
  });
109
111
 
110
- const quiet = values.quiet === true;
112
+ const json = values.json === true;
113
+ const quiet = values.quiet === true || json;
114
+ /** With --json, the only thing on stdout is one JSON document (results or { error }). */
115
+ const emit = (data: unknown) => {
116
+ if (json) process.stdout.write(JSON.stringify(data, null, 2) + "\n");
117
+ };
111
118
  const useColor = process.stdout.isTTY === true && !process.env.NO_COLOR;
112
119
  const paint = (code: string) => (s: string) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
113
120
  const green = paint("32");
@@ -120,7 +127,8 @@ const log = (msg: string) => {
120
127
  };
121
128
 
122
129
  function fail(message: string): never {
123
- console.error(`${red("error:")} ${message}`);
130
+ if (json) process.stdout.write(JSON.stringify({ error: message }) + "\n");
131
+ else console.error(`${red("error:")} ${message}`);
124
132
  process.exit(1);
125
133
  }
126
134
 
@@ -211,9 +219,10 @@ async function fileSize(file: string): Promise<string> {
211
219
 
212
220
  const ok = (what: string, detail = "") => log(`${green("✔")} ${what}${detail ? ` ${dim(detail)}` : ""}`);
213
221
 
214
- async function reportOutputs(outputs: string[], screenshots: string[]): Promise<void> {
222
+ async function reportOutputs(outputs: string[], screenshots: string[]): Promise<Array<{ path: string; bytes: number }>> {
215
223
  for (const out of outputs) ok(`wrote ${out}`, await fileSize(out));
216
224
  for (const shot of screenshots) ok(`screenshot ${shot}`);
225
+ return Promise.all([...outputs, ...screenshots].map(async (p) => ({ path: p, bytes: (await Bun.file(p).exists()) ? Bun.file(p).size : 0 })));
217
226
  }
218
227
 
219
228
  const TEMPLATES: Record<string, (name: string) => string> = {
@@ -344,6 +353,7 @@ async function main(): Promise<void> {
344
353
  if (!cfg) fail("publish is not configured yet — run `tcut publish --setup` (or set TCUT_S3_ENDPOINT/BUCKET/ACCESS_KEY/SECRET_KEY)");
345
354
  const published = await publishFiles(rest, cfg, { name: values.name, log });
346
355
  for (const p of published) ok(p.url, dim(path.basename(p.file)));
356
+ emit({ published });
347
357
  if (values.open && published[0]) {
348
358
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
349
359
  Bun.spawn([opener, published[published.length - 1]!.url], { stdout: "ignore", stderr: "ignore" });
@@ -386,10 +396,14 @@ async function main(): Promise<void> {
386
396
  await Bun.write(scriptPath, generateScript(recording, { output: outputs, cleanShell: !command, command, castPath: config.cast }));
387
397
  ok(`wrote ${scriptPath}`, "editable script — tweak it, then `tcut " + scriptPath + "`");
388
398
  }
389
- if (values["record-only"]) return;
399
+ if (values["record-only"]) {
400
+ emit({ cast: config.cast, script: values["no-script"] ? null : config.cast.replace(/\.cast$/, "") + ".video.ts", events: recording.events.length, durationSeconds: recording.header.duration ?? 0 });
401
+ return;
402
+ }
390
403
  const result = await renderOutputs(recording, config, progressReporter());
391
- await reportOutputs(result.outputs, result.screenshots);
404
+ const files = await reportOutputs(result.outputs, result.screenshots);
392
405
  log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
406
+ emit({ cast: config.cast, script: values["no-script"] ? null : config.cast.replace(/\.cast$/, "") + ".video.ts", outputs: files, frames: result.frames, durationSeconds: result.durationSeconds });
393
407
  return;
394
408
  }
395
409
  case "record": {
@@ -397,18 +411,21 @@ async function main(): Promise<void> {
397
411
  const video = await loadVideo(rest[0]);
398
412
  const rec = await video.record({ log, force: values.force });
399
413
  ok(`${rec.cached ? "reused" : "wrote"} ${video.config.cast}`, `${rec.events.length} events, ${(rec.header.duration ?? 0).toFixed(1)}s, ${elapsed()}`);
414
+ emit({ cast: video.config.cast, cached: rec.cached === true, events: rec.events.length, durationSeconds: rec.header.duration ?? 0 });
400
415
  return;
401
416
  }
402
417
  case "render": {
403
418
  if (!rest[0]) fail("render needs a .cast file");
404
419
  const result = await renderCast(rest[0], overridesFromFlags(), progressReporter());
405
- await reportOutputs(result.outputs, result.screenshots);
420
+ const files = await reportOutputs(result.outputs, result.screenshots);
406
421
  log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
422
+ emit({ cast: rest[0], outputs: files, frames: result.frames, durationSeconds: result.durationSeconds });
407
423
  return;
408
424
  }
409
425
  case "test": {
410
426
  if (rest.length === 0) fail("test needs at least one script file or directory");
411
- const summary = await runScriptTests(rest, (line) => console.log(line));
427
+ const summary = await runScriptTests(rest, json ? () => {} : (line) => console.log(line));
428
+ emit(summary);
412
429
  process.exit(summary.failed > 0 ? 1 : 0);
413
430
  }
414
431
  // eslint-disable-next-line no-fallthrough -- process.exit above never returns
@@ -416,13 +433,16 @@ async function main(): Promise<void> {
416
433
  const video = await loadVideo(first!);
417
434
  const result = await video.run({ log, force: values.force, recordOnly: values["record-only"], onProgress: progressReporter() });
418
435
  ok(`${result.cached ? "reused" : "wrote"} ${result.cast}`);
419
- await reportOutputs(result.outputs, result.screenshots);
436
+ const files = await reportOutputs(result.outputs, result.screenshots);
420
437
  if (!values["record-only"]) log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
438
+ emit({ cast: result.cast, cached: result.cached, outputs: files, frames: result.frames, durationSeconds: result.durationSeconds });
421
439
  }
422
440
  }
423
441
  }
424
442
 
425
443
  main().catch((err: unknown) => {
426
- console.error(`\nerror: ${err instanceof Error ? err.message : String(err)}`);
444
+ const message = err instanceof Error ? err.message : String(err);
445
+ if (json) process.stdout.write(JSON.stringify({ error: message, type: err instanceof Error ? err.name : "Error" }) + "\n");
446
+ else console.error(`\n${red("error:")} ${message}`);
427
447
  process.exit(1);
428
448
  });
package/src/recorder.ts CHANGED
@@ -230,18 +230,24 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
230
230
  }
231
231
  };
232
232
 
233
+ // The prompt is whatever sits left of the cursor; text to the right may be residue from a TUI that exited.
234
+ const promptVisible = (): boolean => promptPattern.test(screen.lineToCursor());
235
+
233
236
  const wait = async (pattern?: RegExp | string, waitOpts: WaitOptions = {}): Promise<void> => {
234
- const regex = pattern === undefined ? promptPattern : toRegExp(pattern);
237
+ const timeout = toMs(waitOpts.timeout, config.waitTimeout);
238
+ if (pattern === undefined) {
239
+ await waitFor(`prompt ${promptPattern}`, promptVisible, timeout);
240
+ return;
241
+ }
242
+ const regex = toRegExp(pattern);
235
243
  const scope = waitOpts.scope ?? "line";
236
- await waitFor(`${regex} on ${scope}`, () => matches(regex, scope), toMs(waitOpts.timeout, config.waitTimeout));
244
+ await waitFor(`${regex} on ${scope}`, () => matches(regex, scope), timeout);
237
245
  };
238
246
 
239
247
  const waitForPrompt = async (afterLine: number, echoLine: string, timeoutMs: number): Promise<void> => {
240
248
  await waitFor(
241
249
  `prompt ${promptPattern}`,
242
- () =>
243
- promptPattern.test(screen.line()) &&
244
- (screen.absoluteCursorLine() !== afterLine || screen.line() !== echoLine),
250
+ () => promptVisible() && (screen.absoluteCursorLine() !== afterLine || screen.lineToCursor() !== echoLine),
245
251
  timeoutMs,
246
252
  );
247
253
  };
@@ -250,7 +256,7 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
250
256
  await type(command, runOpts);
251
257
  await screen.settle();
252
258
  const beforeLine = screen.absoluteCursorLine();
253
- const echoLine = screen.line();
259
+ const echoLine = screen.lineToCursor();
254
260
  await raw("\r");
255
261
  const timeout = toMs(runOpts.timeout, config.waitTimeout);
256
262
  if (runOpts.wait === false) return;
@@ -345,7 +351,7 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
345
351
  log(`starting ${Array.isArray(config.shell) ? config.shell.join(" ") : config.shell}`);
346
352
  // Named shells get a known prompt; for an arbitrary command there is nothing to wait for — start at once.
347
353
  if (!Array.isArray(config.shell)) {
348
- await waitFor(`initial prompt ${promptPattern}`, () => promptPattern.test(screen.line()), config.waitTimeout);
354
+ await waitFor(`initial prompt ${promptPattern}`, promptVisible, config.waitTimeout);
349
355
  }
350
356
  startedAt = performance.now();
351
357
  log("recording");
package/src/screen.ts CHANGED
@@ -114,6 +114,21 @@ export class Screen {
114
114
  return this.rowText(this.core.getCursor().row);
115
115
  }
116
116
 
117
+ /**
118
+ * The cursor line up to the cursor column. Prompt detection uses this rather than the whole line: after a
119
+ * full-screen program exits, the primary screen may still hold stale text to the right of the cursor.
120
+ */
121
+ lineToCursor(): string {
122
+ const { row, col } = this.core.getCursor();
123
+ let text = "";
124
+ for (let x = 0; x < col; x++) {
125
+ const cell = this.core.getCell(row, x);
126
+ if (cell.width === 0) continue;
127
+ text += cell.chars ?? (cell.char === 0 ? " " : String.fromCodePoint(cell.char));
128
+ }
129
+ return text;
130
+ }
131
+
117
132
  screen(): string {
118
133
  const rows: string[] = [];
119
134
  for (let y = 0; y < this.core.getRows(); y++) rows.push(this.rowText(y));