termcut 0.7.1 → 0.7.3

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
@@ -10,7 +10,7 @@ Turn a terminal session into a video. Record it live or script it in TypeScript;
10
10
  bun add -g termcut # Bun ≥ 1.4 · installs the `tcut` command
11
11
  ```
12
12
 
13
- Standalone binaries for macOS, Linux and Windows: [Releases](https://github.com/AmanVarshney01/tcut/releases). MP4/GIF need `ffmpeg`; SVG/HTML don't. Linux renders pixels through Chrome/Chromium on the PATH.
13
+ Standalone binaries for macOS, Linux and Windows (all tested in CI): [Releases](https://github.com/AmanVarshney01/tcut/releases). MP4/GIF need `ffmpeg`; SVG/HTML don't. Linux and Windows render pixels through Chrome/Chromium.
14
14
 
15
15
  ## Use
16
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termcut",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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",
@@ -0,0 +1,39 @@
1
+ // Diagnostic: how does Bun.WebView behave on this machine? Prints backend, navigation, evaluate and screenshot
2
+ // timings for a local page. `bun scripts/probe-webview.ts` (TCUT_DEBUG_CHROME=1 shows Chrome's stderr).
3
+ import { createWebView, webViewBackend } from "../src/renderer/view";
4
+
5
+ const t0 = performance.now();
6
+ const at = () => `${((performance.now() - t0) / 1000).toFixed(3)}s`;
7
+ const log = (m: string) => console.log(`${at()} ${m}`);
8
+ const timed = async <T>(label: string, p: Promise<T>, ms = 8000): Promise<T | undefined> => {
9
+ try {
10
+ const v = await Promise.race([p, Bun.sleep(ms).then(() => Promise.reject(new Error(`timeout ${ms}ms`)))]);
11
+ log(`${label}: ok ${JSON.stringify(v)?.slice(0, 80) ?? ""}`);
12
+ return v;
13
+ } catch (cause) {
14
+ log(`${label}: FAILED ${cause instanceof Error ? cause.message : String(cause)}`);
15
+ return undefined;
16
+ }
17
+ };
18
+
19
+ const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("<html><body><h1 id=t>first</h1><button onclick=\"t.textContent='clicked'\">go</button></body></html>", { headers: { "content-type": "text/html" } }) });
20
+ const url = `http://127.0.0.1:${server.port}/`;
21
+ log(`platform ${process.platform} backend ${JSON.stringify(webViewBackend())}`);
22
+ const view = createWebView({ width: 320, height: 240 });
23
+ log(`view created; url=${JSON.stringify(view.url)} loading=${view.loading}`);
24
+ const nav = view.navigate(url).then(() => "ok", (cause: unknown) => `rejected: ${cause instanceof Error ? cause.message : String(cause)}`);
25
+ for (let i = 0; i < 6; i++) {
26
+ await Bun.sleep(250);
27
+ log(`tick ${i}: url=${JSON.stringify(view.url)} loading=${view.loading}`);
28
+ }
29
+ await timed("navigate", nav, 5000);
30
+ await timed("evaluate readyState", view.evaluate("document.readyState"));
31
+ await timed("evaluate innerText", view.evaluate("document.body ? document.body.innerText : ''"));
32
+ const shot = await timed("screenshot", view.screenshot({ encoding: "buffer" }));
33
+ log(`screenshot bytes: ${shot ? (shot as Uint8Array).length : "none"}`);
34
+ await timed("click", view.click("button"), 5000);
35
+ await timed("evaluate after click", view.evaluate("document.body.innerText"));
36
+ await timed("screenshot 2", view.screenshot({ encoding: "buffer" }));
37
+ log(`final url=${JSON.stringify(view.url)} loading=${view.loading}`);
38
+ view.close();
39
+ server.stop(true);
package/src/browser.ts CHANGED
@@ -48,6 +48,18 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
48
48
  // Sampling starts once a page has loaded: headless Chrome never resolves a screenshot of the initial blank
49
49
  // view, and a hung screenshot blocks every later command on that view.
50
50
  let loaded = false;
51
+ /** Extra time allowed while the browser has never answered: a cold Chrome on a fresh Windows runner can take 20–40 s. */
52
+ const STARTUP_GRACE = 45_000;
53
+ const startedAt = performance.now();
54
+ let startupNoted = false;
55
+ const startupDeadline = (deadline: number): number => {
56
+ if (loaded) return deadline;
57
+ if (!startupNoted && performance.now() > startedAt + 5000) {
58
+ startupNoted = true;
59
+ log("waiting for the browser to start…");
60
+ }
61
+ return Math.max(deadline, startedAt + STARTUP_GRACE);
62
+ };
51
63
  // A view runs one evaluate() at a time; goto's probes, waitFor and the script's own evaluate calls take turns.
52
64
  let chain: Promise<unknown> = Promise.resolve();
53
65
  const serial = <T,>(fn: () => Promise<T>): Promise<T> => {
@@ -58,7 +70,13 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
58
70
  );
59
71
  return next;
60
72
  };
61
- const evaluate = (js: string, ms: number, label: string): Promise<unknown> => serial(() => within(view.evaluate(js), ms, label));
73
+ // A page that answers an evaluate is a page worth sampling (view.loading is not reliable on every backend).
74
+ const evaluate = (js: string, ms: number, label: string): Promise<unknown> =>
75
+ serial(async () => {
76
+ const result = await within(view.evaluate(js), ms, label);
77
+ loaded = true;
78
+ return result;
79
+ });
62
80
  // One screenshot at a time: the periodic sampler and the event-driven samples (after waitFor, at stop) share it.
63
81
  let sampling: Promise<void> | null = null;
64
82
  const sampleOnce = (): Promise<void> => {
@@ -79,6 +97,11 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
79
97
  })();
80
98
  return sampling;
81
99
  };
100
+ /** A sample taken after this moment: waits out any in-flight screenshot (seconds on a cold Chrome), then takes a fresh one. */
101
+ const sampleNow = async (): Promise<void> => {
102
+ if (sampling) await sampling;
103
+ await sampleOnce();
104
+ };
82
105
  const sampler = (async () => {
83
106
  while (running) {
84
107
  await sampleOnce();
@@ -114,16 +137,16 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
114
137
  }
115
138
  if (outcome === "failed") navigation = null;
116
139
  if (!running) return;
117
- const state = await evaluate("document.readyState", 3000, "goto").catch(() => "");
118
- // Some backends leave view.url empty; then a complete document is the best signal we have.
140
+ // No evaluate() here: the view's own loading flag and URL say whether the page arrived (some backends keep
141
+ // the navigate() promise pending long after the page is up, and may leave view.url empty).
119
142
  const href = (view.url ?? "").replace(/\/$/, "");
120
- if (state === "complete" && (href === "" || href.startsWith(target))) {
143
+ if (outcome === "tick" && (href.startsWith(target) || (!view.loading && href === ""))) {
121
144
  currentUrl = url;
122
145
  loaded = true;
123
146
  return;
124
147
  }
125
- if (performance.now() > deadline) {
126
- throw new WaitTimeoutError(`browser.goto(${url})`, config.waitTimeout, `current url: ${view.url ?? "(none)"}, readyState: ${state || "unknown"}`);
148
+ if (performance.now() > startupDeadline(deadline)) {
149
+ throw new WaitTimeoutError(`browser.goto(${url})`, config.waitTimeout, `current url: ${view.url ?? "(none)"}, loading: ${view.loading}`);
127
150
  }
128
151
  await Bun.sleep(250);
129
152
  }
@@ -143,10 +166,10 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
143
166
  const text = String((await evaluate("document.body ? document.body.innerText : ''", 5000, "waitFor").catch(() => "")) ?? "");
144
167
  if (regex.test(text)) {
145
168
  loaded = true; // a page that answers is a page worth sampling, even if navigate() has not settled yet
146
- await sampleOnce(); // the frame the script waited for, captured the moment it appeared
169
+ await sampleNow(); // the frame the script waited for, captured the moment it appeared
147
170
  return;
148
171
  }
149
- if (performance.now() > deadline) throw new WaitTimeoutError(`${regex} in the browser page`, timeout, text.slice(0, 2000));
172
+ if (performance.now() > startupDeadline(deadline)) throw new WaitTimeoutError(`${regex} in the browser page`, timeout, text.slice(0, 2000));
150
173
  await Bun.sleep(150);
151
174
  }
152
175
  },
@@ -163,9 +186,12 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
163
186
  reload: () => within(view.reload(), 30000, "reload").catch((err) => (/pending/i.test(String(err)) ? undefined : Promise.reject(err))),
164
187
  evaluate: (js) => evaluate(js, 10000, "evaluate"),
165
188
  async stop() {
189
+ // A session shorter than the browser's start-up (cold Chrome on CI) should still show the page once.
190
+ if (frames.length === 0 && initialLoad) await Promise.race([initialLoad, Bun.sleep(Math.max(0, startupDeadline(0) - performance.now()))]);
191
+ if (!loaded && bcfg.url) await evaluate("document.readyState", 2000, "probe").catch(() => undefined); // marks loaded if the page answers
166
192
  running = false;
167
193
  await sampler;
168
- await sampleOnce(); // final state of the page
194
+ await sampleNow(); // final state of the page
169
195
  try {
170
196
  view.close();
171
197
  } catch {
@@ -174,6 +200,6 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
174
200
  },
175
201
  };
176
202
 
177
- if (bcfg.url) void goto(bcfg.url).catch((err) => log(String(err)));
203
+ const initialLoad = bcfg.url ? goto(bcfg.url).catch((err) => log(String(err))) : null;
178
204
  return capture;
179
205
  }
package/src/cli.ts CHANGED
@@ -15,7 +15,7 @@ import { renderOutputs } from "./render";
15
15
  import { generateScript } from "./scriptgen";
16
16
  import { runScriptTests, type TestSummary } from "./testing";
17
17
  import { findThemes, themeNames } from "./themes";
18
- import type { BrowserConfig, ClipSelection, CoreName, ThemeName, VideoConfig, WindowBar } from "./types";
18
+ import type { BrowserConfig, ClipSelection, CoreName, Recording, ResolvedConfig, ThemeName, VideoConfig, WindowBar } from "./types";
19
19
  import { Video, attachBrowserFrames, castConfig, isVideo, renderCast } from "./video";
20
20
 
21
21
  // Let user scripts `import { defineVideo } from "tcut"` (or "termcut", the npm package name) regardless of
@@ -524,13 +524,13 @@ async function main(): Promise<void> {
524
524
  const castOut = values.cast ?? (joining ? path.join(path.dirname(rest[0]!), "concat.cast") : rest[0]!.replace(/\.cast$/, "") + "-cut.cast");
525
525
  const overrides = overridesFromFlags();
526
526
  delete overrides.cast;
527
- const parts: Array<{ rec: Awaited<ReturnType<typeof readCast>>; config: ReturnType<typeof castConfig> }> = [];
527
+ const parts: Array<{ rec: Recording; config: ResolvedConfig }> = [];
528
528
  for (const [i, file] of rest.entries()) {
529
529
  const rec = await readCast(file);
530
530
  const config = applyOverrides(castConfig(rec, file, values.output), overrides);
531
531
  parts.push({ rec: await rebaseBrowserFrames(rec, file, castOut, joining ? `${i}-` : ""), config });
532
532
  }
533
- let out: Awaited<ReturnType<typeof readCast>>;
533
+ let out: Recording;
534
534
  if (joining) {
535
535
  out = concatRecordings(parts, { gap: seconds("gap") ?? 0 });
536
536
  } else {
package/src/config.ts CHANGED
@@ -114,6 +114,7 @@ export function resolveConfig(input: VideoConfig): ResolvedConfig {
114
114
  quantize: config.quantize ?? false,
115
115
  core: config.core ?? "ghostty",
116
116
  cache: config.cache ?? true,
117
+ requires: config.requires ?? [],
117
118
  font,
118
119
  theme,
119
120
  cursor: {
package/src/errors.ts CHANGED
@@ -7,6 +7,16 @@ export class WaitTimeoutError extends Error {
7
7
  }
8
8
  }
9
9
 
10
+ /** A program named in `requires` is not on the PATH — raised before the shell is even started. */
11
+ export class MissingRequirementError extends Error {
12
+ constructor(readonly missing: string[]) {
13
+ super(
14
+ `${missing.length === 1 ? `\`${missing[0]}\` is` : `${missing.map((m) => `\`${m}\``).join(", ")} are`} not on the PATH. Install ${missing.length === 1 ? "it" : "them"}, or remove ${missing.length === 1 ? "it" : "them"} from \`requires\`.`,
15
+ );
16
+ this.name = "MissingRequirementError";
17
+ }
18
+ }
19
+
10
20
  export class ExpectationError extends Error {
11
21
  constructor(what: string, screen: string) {
12
22
  super(`Expected ${what} to match.\n\n--- screen ---\n${screen}\n--------------`);
package/src/index.ts CHANGED
@@ -26,5 +26,5 @@ export type { DiffOptions, DiffResult } from "./diff";
26
26
  export { keyLabels, keyChips } from "./keylabels";
27
27
  export { startBrowserCapture } from "./browser";
28
28
  export { resolveConfig } from "./config";
29
- export { WaitTimeoutError, ExpectationError } from "./recorder";
29
+ export { WaitTimeoutError, ExpectationError, MissingRequirementError } from "./recorder";
30
30
  export type * from "./types";
package/src/recorder.ts CHANGED
@@ -2,7 +2,7 @@ import { MarkdownRenderer } from "@wterm/markdown";
2
2
  import { startBrowserCapture, type BrowserCapture } from "./browser";
3
3
  import { MARKER } from "./cast";
4
4
  import { toMs } from "./duration";
5
- import { ExpectationError, WaitTimeoutError } from "./errors";
5
+ import { ExpectationError, MissingRequirementError, WaitTimeoutError } from "./errors";
6
6
  import { altSequence, ctrlSequence, keySequence, shiftSequence, wheelSequence } from "./keys";
7
7
  import { Screen } from "./screen";
8
8
  import type {
@@ -20,7 +20,7 @@ import type {
20
20
  WaitOptions,
21
21
  } from "./types";
22
22
 
23
- export { WaitTimeoutError, ExpectationError } from "./errors";
23
+ export { WaitTimeoutError, ExpectationError, MissingRequirementError } from "./errors";
24
24
 
25
25
  /** Deterministic PRNG (mulberry32) so typing jitter is reproducible. */
26
26
  function mulberry32(seed: number): () => number {
@@ -103,6 +103,8 @@ export function defaultLang(): string {
103
103
  /** Drives a Bun.Terminal PTY according to a script and produces an asciicast recording. */
104
104
  export async function record(config: ResolvedConfig, script: Script, opts: RecordOptions = {}): Promise<Recording> {
105
105
  const log = opts.log ?? (() => {});
106
+ const missing = config.requires.filter((name) => Bun.which(name) === null);
107
+ if (missing.length > 0) throw new MissingRequirementError(missing);
106
108
  const events: CastEvent[] = [];
107
109
  const screen = await Screen.create(config.cols, config.rows, { core: config.core });
108
110
  const fast = opts.fast === true;
@@ -43,8 +43,8 @@ async function embeddedAssets(): Promise<PageAssets | null> {
43
43
  }
44
44
  }
45
45
 
46
- /** True inside a `bun build --compile` binary, where sources live on the virtual /$bunfs filesystem. */
47
- const isCompiled = import.meta.dir.startsWith("/$bunfs");
46
+ /** True inside a `bun build --compile` binary (sources then live on a virtual filesystem: /$bunfs, or B:\~BUN on Windows). */
47
+ const isCompiled = Bun.isStandaloneExecutable;
48
48
 
49
49
  /**
50
50
  * Prebuilt assets in the compiled binary; otherwise built at runtime with Bun.build so edits to the page entries
@@ -1,7 +1,7 @@
1
1
  // A minimal PNG codec for the renderer. Bun.WebView screenshots come in as opaque PNGs and Bun.Image has no raw
2
2
  // pixel access, so transparent output needs its own decode → matte → encode step. 8-bit RGB/RGBA, non-interlaced.
3
- // node:zlib rather than Bun.inflateSync/deflateSync: PNG needs zlib-framed streams and Bun's defaults are raw deflate.
4
- import { deflateSync, inflateSync } from "node:zlib";
3
+ // PNG streams are zlib-framed; Bun's inflateSync reads that with windowBits 15, and deflateSync's raw output gets the
4
+ // two-byte header and Adler-32 trailer added here (Bun only — no node:zlib).
5
5
 
6
6
  /** Straight (non-premultiplied) RGBA pixels, row-major. */
7
7
  export interface RgbaImage {
@@ -16,8 +16,31 @@ const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
16
16
 
17
17
  const readU32 = (b: Uint8Array, o: number): number => ((b[o]! << 24) | (b[o + 1]! << 16) | (b[o + 2]! << 8) | b[o + 3]!) >>> 0;
18
18
 
19
- function concat(parts: Uint8Array[]): Uint8Array {
20
- const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
19
+ function adler32(bytes: Uint8Array): number {
20
+ let a = 1;
21
+ let b = 0;
22
+ for (let i = 0; i < bytes.length; i++) {
23
+ a = (a + bytes[i]!) % 65521;
24
+ b = (b + a) % 65521;
25
+ }
26
+ return ((b << 16) | a) >>> 0;
27
+ }
28
+
29
+ /** Raw DEFLATE from Bun.deflateSync wrapped as a zlib stream (RFC 1950): CMF/FLG header + data + Adler-32. */
30
+ function zlibDeflate(raw: Uint8Array<ArrayBuffer>, level: 1 | 6 = 1): Uint8Array {
31
+ const body = Bun.deflateSync(raw, { level });
32
+ const out = new Uint8Array(2 + body.length + 4);
33
+ out[0] = 0x78;
34
+ out[1] = level === 1 ? 0x01 : 0x9c;
35
+ out.set(body, 2);
36
+ new DataView(out.buffer).setUint32(2 + body.length, adler32(raw));
37
+ return out;
38
+ }
39
+
40
+ const zlibInflate = (data: Uint8Array<ArrayBuffer>): Uint8Array => Bun.inflateSync(data, { windowBits: 15 });
41
+
42
+ function concat(parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
43
+ const out = new Uint8Array(new ArrayBuffer(parts.reduce((n, p) => n + p.length, 0)));
21
44
  let o = 0;
22
45
  for (const p of parts) {
23
46
  out.set(p, o);
@@ -83,7 +106,7 @@ export function decodePng(png: Uint8Array): RgbaImage {
83
106
  }
84
107
  const bpp = colorType === 6 ? 4 : 3;
85
108
  const stride = width * bpp;
86
- const raw = inflateSync(concat(idat));
109
+ const raw = zlibInflate(concat(idat));
87
110
  const out = new Uint8Array(width * height * 4);
88
111
  let prev = new Uint8Array(stride);
89
112
  let row = new Uint8Array(stride);
@@ -133,7 +156,7 @@ function chunk(type: string, data: Uint8Array): Uint8Array {
133
156
  /** Encode straight RGBA as an 8-bit PNG (filter 0; speed matters more than size — ffmpeg re-encodes anyway). */
134
157
  export function encodePng(img: RgbaImage): Uint8Array {
135
158
  const stride = img.width * 4;
136
- const raw = new Uint8Array((stride + 1) * img.height);
159
+ const raw = new Uint8Array(new ArrayBuffer((stride + 1) * img.height));
137
160
  for (let y = 0; y < img.height; y++) {
138
161
  raw[y * (stride + 1)] = 0;
139
162
  raw.set(img.data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
@@ -144,7 +167,7 @@ export function encodePng(img: RgbaImage): Uint8Array {
144
167
  v.setUint32(4, img.height);
145
168
  ihdr[8] = 8; // bit depth
146
169
  ihdr[9] = 6; // RGBA
147
- return concat([new Uint8Array(SIGNATURE), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw, { level: 1 })), chunk("IEND", new Uint8Array(0))]);
170
+ return concat([new Uint8Array(SIGNATURE), chunk("IHDR", ihdr), chunk("IDAT", zlibDeflate(raw)), chunk("IEND", new Uint8Array(0))]);
148
171
  }
149
172
 
150
173
  export function parseHex(color: string): Rgb {
package/src/scriptgen.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { keySequence } from "./keys";
2
3
  import type { Recording } from "./types";
3
4
 
4
5
  export interface ScriptGenOptions {
@@ -20,7 +21,9 @@ type Op =
20
21
  | { kind: "key"; name: string; times: number }
21
22
  | { kind: "ctrl"; letter: string; times: number }
22
23
  | { kind: "alt"; key: string; times: number }
23
- | { kind: "raw"; data: string }
24
+ | { kind: "fkey"; name: string; times: number }
25
+ | { kind: "shift"; key: string; times: number }
26
+ | { kind: "raw"; data: string; comment?: string }
24
27
  | { kind: "sleep"; ms: number };
25
28
 
26
29
  const NAMED = new Map<string, string>([
@@ -46,6 +49,35 @@ const NAMED = new Map<string, string>([
46
49
  ["\x1b[6~", "pageDown"],
47
50
  ]);
48
51
 
52
+ /** F-keys by the bytes a terminal sends (`ESC O P` … `ESC [24~`), so they replay as `t.key("f5")`: one write, never ESC + key. */
53
+ const FKEYS = new Map(Array.from({ length: 12 }, (_, i) => [keySequence(`f${i + 1}` as `f${1}`), `f${i + 1}`] as const));
54
+
55
+ const MODIFIER_NAMES = new Map([
56
+ ["2", "shift"],
57
+ ["3", "alt"],
58
+ ["5", "ctrl"],
59
+ ["6", "ctrl+shift"],
60
+ ["7", "ctrl+alt"],
61
+ ]);
62
+ const MODIFIED_KEYS = new Map([
63
+ ["A", "up"],
64
+ ["B", "down"],
65
+ ["C", "right"],
66
+ ["D", "left"],
67
+ ["H", "home"],
68
+ ["F", "end"],
69
+ ]);
70
+
71
+ /** `ESC [1;3D` → { modifier: "alt", key: "left" } (xterm modifier encoding), or null. */
72
+ function modifiedKey(token: string): { modifier: string; key: string } | null {
73
+ if (!token.startsWith("\x1b[1;")) return null;
74
+ const m = /^(\d)([A-DHF])$/.exec(token.slice(4));
75
+ if (!m) return null;
76
+ const modifier = MODIFIER_NAMES.get(m[1]!);
77
+ const key = MODIFIED_KEYS.get(m[2]!);
78
+ return modifier && key ? { modifier, key } : null;
79
+ }
80
+
49
81
  /** Split a raw input chunk into individual key tokens (escape sequences, control chars, printable runs). */
50
82
  export function tokenize(input: string): string[] {
51
83
  const tokens: string[] = [];
@@ -160,8 +192,12 @@ export function eventsToOps(rec: Recording, opts: ScriptGenOptions): Op[] {
160
192
  pushKey({ kind: "ctrl", letter, times: 1 });
161
193
  } else if (token.length === 2 && token[0] === "\x1b") {
162
194
  pushKey({ kind: "alt", key: token[1]!, times: 1 });
195
+ } else if (FKEYS.has(token)) {
196
+ pushKey({ kind: "fkey", name: FKEYS.get(token)!, times: 1 });
163
197
  } else {
164
- ops.push({ kind: "raw", data: token });
198
+ const mod = modifiedKey(token);
199
+ if (mod?.modifier === "shift") pushKey({ kind: "shift", key: mod.key, times: 1 });
200
+ else ops.push({ kind: "raw", data: token, ...(mod && { comment: `${mod.modifier}+${mod.key}` }) });
165
201
  }
166
202
  }
167
203
  }
@@ -186,8 +222,12 @@ function opToLine(op: Op): string {
186
222
  return op.times > 1 ? `await t.ctrl(${q(op.letter)}, ${op.times});` : `await t.ctrl(${q(op.letter)});`;
187
223
  case "alt":
188
224
  return op.times > 1 ? `await t.alt(${q(op.key)}, ${op.times});` : `await t.alt(${q(op.key)});`;
225
+ case "fkey":
226
+ return op.times > 1 ? `await t.key(${q(op.name)}, ${op.times});` : `await t.key(${q(op.name)});`;
227
+ case "shift":
228
+ return op.times > 1 ? `await t.shift(${q(op.key)}, ${op.times});` : `await t.shift(${q(op.key)});`;
189
229
  case "raw":
190
- return `await t.raw(${q(op.data)});`;
230
+ return `await t.raw(${q(op.data)});${op.comment ? ` // ${op.comment}` : ""}`;
191
231
  case "sleep":
192
232
  return `await t.sleep(${formatMs(op.ms)});`;
193
233
  }
package/src/testing.ts CHANGED
@@ -45,7 +45,7 @@ export async function runScriptTests(inputs: string[], log: (line: string) => vo
45
45
  const results: TestResult[] = [];
46
46
  log(`TAP version 14\n1..${files.length}`);
47
47
  for (const [index, file] of files.entries()) {
48
- const rel = path.relative(process.cwd(), file);
48
+ const rel = path.relative(process.cwd(), file).split(path.sep).join("/"); // stable TAP output on every platform
49
49
  const started = performance.now();
50
50
  let error: string | undefined;
51
51
  try {
package/src/types.ts CHANGED
@@ -205,6 +205,8 @@ export interface VideoConfig {
205
205
  core?: CoreName;
206
206
  /** Reuse the existing cast when the script and record config are unchanged. Default true. */
207
207
  cache?: boolean;
208
+ /** Programs the script needs on the PATH (`["bun", "eza"]`). Checked before the shell starts; missing ones fail fast with a clear message. */
209
+ requires?: string[];
208
210
 
209
211
  font?: FontConfig;
210
212
  theme?: ThemeName | Theme;
@@ -257,6 +259,7 @@ export interface ResolvedConfig {
257
259
  quantize: boolean;
258
260
  core: CoreName;
259
261
  cache: boolean;
262
+ requires: string[];
260
263
  font: Required<FontConfig>;
261
264
  theme: Theme;
262
265
  cursor: Required<CursorConfig>;