termcut 0.7.0 → 0.7.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
@@ -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: [Releases](https://github.com/AmanVarshney01/tcut/releases). MP4/GIF need `ffmpeg`; SVG/HTML don't.
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.
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.0",
3
+ "version": "0.7.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/browser.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { WINDOW_BAR_HEIGHT, estimateCell } from "./config";
2
+ import { createWebView } from "./renderer/view";
2
3
  import { toMs } from "./duration";
3
4
  import { WaitTimeoutError } from "./errors";
4
5
  import type { BrowserFrame, BrowserSession, ResolvedConfig } from "./types";
@@ -25,7 +26,7 @@ const toRegExp = (pattern: RegExp | string): RegExp =>
25
26
  */
26
27
  export function startBrowserCapture(config: ResolvedConfig, stamp: () => number, log: (m: string) => void = () => {}): BrowserCapture {
27
28
  if (!config.browser) throw new Error("startBrowserCapture needs config.browser");
28
- if (!Bun.WebView) throw new Error("The browser pane needs Bun.WebView (Bun >= 1.4).");
29
+
29
30
  const bcfg = config.browser;
30
31
 
31
32
  // Default pane size: match the terminal window (estimated from the font metrics) unless given.
@@ -35,7 +36,7 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
35
36
  const stacked = bcfg.position === "top" || bcfg.position === "bottom";
36
37
  const paneW = stacked ? termFrameW : bcfg.width;
37
38
  const paneH = bcfg.height || (stacked || bcfg.position === "overlay" ? 480 : termFrameH);
38
- const view = new Bun.WebView({ width: paneW, height: paneH });
39
+ const view = createWebView({ width: paneW, height: paneH });
39
40
 
40
41
  const frames: BrowserFrame[] = [];
41
42
  const within = <T,>(promise: Promise<T>, ms: number, label: string): Promise<T> =>
@@ -44,8 +45,25 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
44
45
  let currentUrl = bcfg.url ?? "about:blank";
45
46
  let lastHash = "";
46
47
  let running = true;
47
- const sampler = (async () => {
48
- while (running) {
48
+ // Sampling starts once a page has loaded: headless Chrome never resolves a screenshot of the initial blank
49
+ // view, and a hung screenshot blocks every later command on that view.
50
+ let loaded = false;
51
+ // A view runs one evaluate() at a time; goto's probes, waitFor and the script's own evaluate calls take turns.
52
+ let chain: Promise<unknown> = Promise.resolve();
53
+ const serial = <T,>(fn: () => Promise<T>): Promise<T> => {
54
+ const next = chain.then(fn, fn);
55
+ chain = next.then(
56
+ () => undefined,
57
+ () => undefined,
58
+ );
59
+ return next;
60
+ };
61
+ const evaluate = (js: string, ms: number, label: string): Promise<unknown> => serial(() => within(view.evaluate(js), ms, label));
62
+ // One screenshot at a time: the periodic sampler and the event-driven samples (after waitFor, at stop) share it.
63
+ let sampling: Promise<void> | null = null;
64
+ const sampleOnce = (): Promise<void> => {
65
+ if (!loaded) return Promise.resolve();
66
+ sampling ??= (async () => {
49
67
  try {
50
68
  const png = (await within(view.screenshot({ encoding: "buffer" }), 5000, "screenshot")) as Uint8Array;
51
69
  const hash = Bun.hash(png).toString(16);
@@ -55,8 +73,16 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
55
73
  }
56
74
  } catch {
57
75
  /* view busy or closed */
76
+ } finally {
77
+ sampling = null;
58
78
  }
59
- await Bun.sleep(1000 / bcfg.fps);
79
+ })();
80
+ return sampling;
81
+ };
82
+ const sampler = (async () => {
83
+ while (running) {
84
+ await sampleOnce();
85
+ await Bun.sleep(loaded ? 1000 / bcfg.fps : 50);
60
86
  }
61
87
  })();
62
88
 
@@ -83,13 +109,17 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
83
109
  const outcome = await Promise.race([navigation, Bun.sleep(750).then(() => "tick" as const)]);
84
110
  if (outcome === "ok") {
85
111
  currentUrl = url;
112
+ loaded = true;
86
113
  return;
87
114
  }
88
115
  if (outcome === "failed") navigation = null;
89
116
  if (!running) return;
90
- const state = await within(view.evaluate("document.readyState"), 3000, "goto").catch(() => "");
91
- if (state === "complete" && (view.url ?? "").replace(/\/$/, "").startsWith(target)) {
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.
119
+ const href = (view.url ?? "").replace(/\/$/, "");
120
+ if (state === "complete" && (href === "" || href.startsWith(target))) {
92
121
  currentUrl = url;
122
+ loaded = true;
93
123
  return;
94
124
  }
95
125
  if (performance.now() > deadline) {
@@ -110,18 +140,32 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
110
140
  const timeout = toMs(waitOpts.timeout, config.waitTimeout);
111
141
  const deadline = performance.now() + timeout;
112
142
  for (;;) {
113
- const text = String((await within(view.evaluate("document.body ? document.body.innerText : ''"), 5000, "waitFor").catch(() => "")) ?? "");
114
- if (regex.test(text)) return;
143
+ const text = String((await evaluate("document.body ? document.body.innerText : ''", 5000, "waitFor").catch(() => "")) ?? "");
144
+ if (regex.test(text)) {
145
+ 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
147
+ return;
148
+ }
115
149
  if (performance.now() > deadline) throw new WaitTimeoutError(`${regex} in the browser page`, timeout, text.slice(0, 2000));
116
150
  await Bun.sleep(150);
117
151
  }
118
152
  },
119
- click: (selector) => within(view.click(selector), 10000, "click"),
153
+ /** Real input emulation first; if the browser's actionability checks stall, a DOM click still drives the page. */
154
+ click: async (selector) => {
155
+ loaded = true;
156
+ try {
157
+ await within(view.click(selector), 3000, "click");
158
+ } catch {
159
+ const hit = await evaluate(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.click(); return true; })()`, 5000, "click");
160
+ if (hit !== true) throw new Error(`browser.click: no element matches ${selector}`);
161
+ }
162
+ },
120
163
  reload: () => within(view.reload(), 30000, "reload").catch((err) => (/pending/i.test(String(err)) ? undefined : Promise.reject(err))),
121
- evaluate: (js) => within(view.evaluate(js), 10000, "evaluate"),
164
+ evaluate: (js) => evaluate(js, 10000, "evaluate"),
122
165
  async stop() {
123
166
  running = false;
124
167
  await sampler;
168
+ await sampleOnce(); // final state of the page
125
169
  try {
126
170
  view.close();
127
171
  } catch {
package/src/recorder.ts CHANGED
@@ -78,6 +78,28 @@ export function shellSetup(config: ResolvedConfig): ShellSetup {
78
78
  }
79
79
  }
80
80
 
81
+ let cachedLang: string | null = null;
82
+
83
+ /**
84
+ * A UTF-8 locale the shell can actually switch to. A LANG naming a locale that is not installed (en_US.UTF-8 on a
85
+ * minimal Debian, say) silently drops bash into the C locale, where readline mangles multi-byte input such as emoji.
86
+ */
87
+ export function defaultLang(): string {
88
+ if (cachedLang !== null) return cachedLang;
89
+ const current = process.env.LANG ?? "";
90
+ const available = new Set<string>();
91
+ try {
92
+ const proc = Bun.spawnSync(["locale", "-a"], { stdout: "pipe", stderr: "ignore" });
93
+ for (const line of proc.stdout.toString().split("\n")) available.add(line.trim().toLowerCase().replace("utf8", "utf-8"));
94
+ } catch {
95
+ /* no `locale` binary: keep the first candidate */
96
+ }
97
+ const has = (name: string) => available.size === 0 || available.has(name.toLowerCase());
98
+ const candidates = [current, "C.UTF-8", "en_US.UTF-8"].filter((c) => /utf-?8/i.test(c));
99
+ cachedLang = candidates.find(has) ?? candidates[candidates.length - 1] ?? "C.UTF-8";
100
+ return cachedLang;
101
+ }
102
+
81
103
  /** Drives a Bun.Terminal PTY according to a script and produces an asciicast recording. */
82
104
  export async function record(config: ResolvedConfig, script: Script, opts: RecordOptions = {}): Promise<Recording> {
83
105
  const log = opts.log ?? (() => {});
@@ -111,7 +133,7 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
111
133
  ...inheritedEnv,
112
134
  TERM: "xterm-256color",
113
135
  COLORTERM: "truecolor",
114
- LANG: process.env.LANG ?? "en_US.UTF-8",
136
+ LANG: defaultLang(),
115
137
  ...setup.env,
116
138
  ...config.env,
117
139
  };
@@ -0,0 +1,41 @@
1
+ // One place that knows how to open a headless Bun.WebView on every platform.
2
+ // macOS uses the system WebKit; everywhere else Bun drives Chrome/Chromium over the DevTools protocol.
3
+
4
+ export interface ViewSize {
5
+ width: number;
6
+ height: number;
7
+ }
8
+
9
+ /** Chrome flags that make headless rendering work in containers and CI without changing what is drawn. */
10
+ export function chromeArgs(): string[] {
11
+ const args = ["--force-device-scale-factor=1", "--hide-scrollbars", "--disable-dev-shm-usage"];
12
+ // Chrome refuses to start its sandbox as root (Docker, most CI runners); unprivileged users keep it.
13
+ if (process.getuid?.() === 0) args.push("--no-sandbox");
14
+ return args;
15
+ }
16
+
17
+ /** Which backend to use: WebKit on macOS unless TCUT_WEBVIEW=chrome, Chrome elsewhere. */
18
+ export function webViewBackend(): Bun.WebView.Backend {
19
+ const forced = process.env.TCUT_WEBVIEW;
20
+ if (process.platform === "darwin" && forced !== "chrome") return "webkit";
21
+ return {
22
+ type: "chrome",
23
+ url: false, // always spawn our own headless Chrome; never attach to a user's running browser
24
+ argv: chromeArgs(),
25
+ stderr: process.env.TCUT_DEBUG_CHROME ? "inherit" : "ignore",
26
+ ...(process.env.BUN_CHROME_PATH && { path: process.env.BUN_CHROME_PATH }),
27
+ };
28
+ }
29
+
30
+ export function createWebView(size: ViewSize): Bun.WebView {
31
+ if (!Bun.WebView) throw new Error("Bun.WebView is not available in this Bun version. tcut needs Bun >= 1.4.");
32
+ try {
33
+ return new Bun.WebView({ ...size, backend: webViewBackend() });
34
+ } catch (cause) {
35
+ const hint =
36
+ process.platform === "darwin"
37
+ ? ""
38
+ : " Rendering pixels on Linux/Windows needs Chrome or Chromium on the PATH (or BUN_CHROME_PATH=/path/to/chrome); SVG, HTML and TXT output need no browser.";
39
+ throw new Error(`Could not start the headless browser: ${cause instanceof Error ? cause.message : String(cause)}.${hint}`, { cause });
40
+ }
41
+ }
@@ -10,6 +10,7 @@ import { createSinks, type Chapter } from "./encoder";
10
10
  import { chipBuilder } from "../keylabels";
11
11
  import { BROWSER_GAP, barHeight, opaqueFill, renderHtml, themeOsc } from "./page";
12
12
  import { decodePng, encodePng, luminance, matte, parseHex } from "./png";
13
+ import { createWebView } from "./view";
13
14
 
14
15
  export interface RenderResult {
15
16
  outputs: string[];
@@ -60,10 +61,6 @@ export async function render(
60
61
  config: ResolvedConfig,
61
62
  onProgress?: (p: RenderProgress) => void,
62
63
  ): Promise<RenderResult> {
63
- if (!Bun.WebView) {
64
- throw new Error("Bun.WebView is not available in this Bun version. tcut needs Bun >= 1.4.");
65
- }
66
-
67
64
  const assets = await pageAssets();
68
65
  const html = renderHtml(config);
69
66
  const osc = themeOsc(config.theme);
@@ -121,7 +118,7 @@ export async function render(
121
118
  const rgbA = parseHex(fillA);
122
119
  const rgbB = parseHex(fillB);
123
120
 
124
- const view = new Bun.WebView({ width: 800, height: 600 });
121
+ const view = createWebView({ width: 800, height: 600 });
125
122
  try {
126
123
  await view.navigate(`http://127.0.0.1:${server.port}/`);
127
124
  for (let i = 0; i < 100; i++) {