termcut 0.6.4 → 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.
@@ -0,0 +1,189 @@
1
+ // A minimal PNG codec for the renderer. Bun.WebView screenshots come in as opaque PNGs and Bun.Image has no raw
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";
5
+
6
+ /** Straight (non-premultiplied) RGBA pixels, row-major. */
7
+ export interface RgbaImage {
8
+ width: number;
9
+ height: number;
10
+ data: Uint8Array;
11
+ }
12
+
13
+ export type Rgb = [number, number, number];
14
+
15
+ const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
16
+
17
+ const readU32 = (b: Uint8Array, o: number): number => ((b[o]! << 24) | (b[o + 1]! << 16) | (b[o + 2]! << 8) | b[o + 3]!) >>> 0;
18
+
19
+ function concat(parts: Uint8Array[]): Uint8Array {
20
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
21
+ let o = 0;
22
+ for (const p of parts) {
23
+ out.set(p, o);
24
+ o += p.length;
25
+ }
26
+ return out;
27
+ }
28
+
29
+ const paeth = (a: number, b: number, c: number): number => {
30
+ const p = a + b - c;
31
+ const pa = Math.abs(p - a);
32
+ const pb = Math.abs(p - b);
33
+ const pc = Math.abs(p - c);
34
+ return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
35
+ };
36
+
37
+ /** Undo a PNG row filter in place; `prev` is the previous (already unfiltered) row. */
38
+ function unfilter(filter: number, row: Uint8Array, prev: Uint8Array, bpp: number): void {
39
+ switch (filter) {
40
+ case 0:
41
+ return;
42
+ case 1:
43
+ for (let i = bpp; i < row.length; i++) row[i] = (row[i]! + row[i - bpp]!) & 0xff;
44
+ return;
45
+ case 2:
46
+ for (let i = 0; i < row.length; i++) row[i] = (row[i]! + prev[i]!) & 0xff;
47
+ return;
48
+ case 3:
49
+ for (let i = 0; i < row.length; i++) row[i] = (row[i]! + (((i >= bpp ? row[i - bpp]! : 0) + prev[i]!) >> 1)) & 0xff;
50
+ return;
51
+ case 4:
52
+ for (let i = 0; i < row.length; i++) row[i] = (row[i]! + paeth(i >= bpp ? row[i - bpp]! : 0, prev[i]!, i >= bpp ? prev[i - bpp]! : 0)) & 0xff;
53
+ return;
54
+ default:
55
+ throw new Error(`Unsupported PNG filter ${filter}`);
56
+ }
57
+ }
58
+
59
+ export function decodePng(png: Uint8Array): RgbaImage {
60
+ for (let i = 0; i < SIGNATURE.length; i++) if (png[i] !== SIGNATURE[i]) throw new Error("Not a PNG file");
61
+ let width = 0;
62
+ let height = 0;
63
+ let depth = 0;
64
+ let colorType = 0;
65
+ let interlace = 0;
66
+ const idat: Uint8Array[] = [];
67
+ for (let pos = 8; pos + 8 <= png.length; ) {
68
+ const length = readU32(png, pos);
69
+ const type = String.fromCharCode(png[pos + 4]!, png[pos + 5]!, png[pos + 6]!, png[pos + 7]!);
70
+ const data = png.subarray(pos + 8, pos + 8 + length);
71
+ if (type === "IHDR") {
72
+ width = readU32(data, 0);
73
+ height = readU32(data, 4);
74
+ depth = data[8]!;
75
+ colorType = data[9]!;
76
+ interlace = data[12]!;
77
+ } else if (type === "IDAT") idat.push(data);
78
+ else if (type === "IEND") break;
79
+ pos += 12 + length;
80
+ }
81
+ if (depth !== 8 || (colorType !== 6 && colorType !== 2) || interlace !== 0) {
82
+ throw new Error(`Unsupported PNG layout (bit depth ${depth}, colour type ${colorType}, interlace ${interlace})`);
83
+ }
84
+ const bpp = colorType === 6 ? 4 : 3;
85
+ const stride = width * bpp;
86
+ const raw = inflateSync(concat(idat));
87
+ const out = new Uint8Array(width * height * 4);
88
+ let prev = new Uint8Array(stride);
89
+ let row = new Uint8Array(stride);
90
+ let p = 0;
91
+ for (let y = 0; y < height; y++) {
92
+ const filter = raw[p++]!;
93
+ row.set(raw.subarray(p, p + stride));
94
+ p += stride;
95
+ unfilter(filter, row, prev, bpp);
96
+ const o = y * width * 4;
97
+ if (bpp === 4) out.set(row, o);
98
+ else {
99
+ for (let x = 0; x < width; x++) {
100
+ out[o + x * 4] = row[x * 3]!;
101
+ out[o + x * 4 + 1] = row[x * 3 + 1]!;
102
+ out[o + x * 4 + 2] = row[x * 3 + 2]!;
103
+ out[o + x * 4 + 3] = 255;
104
+ }
105
+ }
106
+ [prev, row] = [row, prev];
107
+ }
108
+ return { width, height, data: out };
109
+ }
110
+
111
+ const CRC_TABLE = new Uint32Array(256).map((_, n) => {
112
+ let c = n;
113
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
114
+ return c >>> 0;
115
+ });
116
+
117
+ function crc32(bytes: Uint8Array): number {
118
+ let c = 0xffffffff;
119
+ for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8);
120
+ return (c ^ 0xffffffff) >>> 0;
121
+ }
122
+
123
+ function chunk(type: string, data: Uint8Array): Uint8Array {
124
+ const out = new Uint8Array(12 + data.length);
125
+ const view = new DataView(out.buffer);
126
+ view.setUint32(0, data.length);
127
+ for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
128
+ out.set(data, 8);
129
+ view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
130
+ return out;
131
+ }
132
+
133
+ /** Encode straight RGBA as an 8-bit PNG (filter 0; speed matters more than size — ffmpeg re-encodes anyway). */
134
+ export function encodePng(img: RgbaImage): Uint8Array {
135
+ const stride = img.width * 4;
136
+ const raw = new Uint8Array((stride + 1) * img.height);
137
+ for (let y = 0; y < img.height; y++) {
138
+ raw[y * (stride + 1)] = 0;
139
+ raw.set(img.data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
140
+ }
141
+ const ihdr = new Uint8Array(13);
142
+ const v = new DataView(ihdr.buffer);
143
+ v.setUint32(0, img.width);
144
+ v.setUint32(4, img.height);
145
+ ihdr[8] = 8; // bit depth
146
+ ihdr[9] = 6; // RGBA
147
+ return concat([new Uint8Array(SIGNATURE), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw, { level: 1 })), chunk("IEND", new Uint8Array(0))]);
148
+ }
149
+
150
+ export function parseHex(color: string): Rgb {
151
+ const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(color.trim());
152
+ if (!m) throw new Error(`Expected a #RRGGBB colour, got "${color}"`);
153
+ return [parseInt(m[1]!, 16), parseInt(m[2]!, 16), parseInt(m[3]!, 16)];
154
+ }
155
+
156
+ /** Relative luminance (0–1) of an #RRGGBB colour — picks a contrasting second matte background. */
157
+ export function luminance(color: string): number {
158
+ const [r, g, b] = parseHex(color);
159
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
160
+ }
161
+
162
+ /**
163
+ * Two-background matting: the same frame rendered over backgrounds `a` and `b` gives, per pixel,
164
+ * alpha = 1 − (Pb − Pa) / (b − a) and colour = (Pa − (1 − alpha)·a) / alpha. Exact for anti-aliased edges and
165
+ * soft shadows alike, which is why the renderer prefers this over a colour key.
166
+ */
167
+ export function matte(onA: RgbaImage, onB: RgbaImage, a: Rgb, b: Rgb): RgbaImage {
168
+ if (onA.width !== onB.width || onA.height !== onB.height) throw new Error("matte: frame sizes differ");
169
+ const n = onA.width * onA.height;
170
+ const out = new Uint8Array(n * 4);
171
+ const usable = [0, 1, 2].filter((c) => a[c] !== b[c]);
172
+ if (usable.length === 0) throw new Error("matte: the two backgrounds must differ");
173
+ for (let i = 0; i < n; i++) {
174
+ const o = i * 4;
175
+ let alpha = 0;
176
+ for (const c of usable) alpha += 1 - (onB.data[o + c]! - onA.data[o + c]!) / (b[c]! - a[c]!);
177
+ alpha = Math.min(1, Math.max(0, alpha / usable.length));
178
+ if (alpha <= 0.002) {
179
+ out[o + 3] = 0;
180
+ continue;
181
+ }
182
+ for (let c = 0; c < 3; c++) {
183
+ const v = (onA.data[o + c]! - (1 - alpha) * a[c]!) / alpha;
184
+ out[o + c] = Math.min(255, Math.max(0, Math.round(v)));
185
+ }
186
+ out[o + 3] = Math.round(alpha * 255);
187
+ }
188
+ return { width: onA.width, height: onA.height, data: out };
189
+ }
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import type { CellSize } from "../config";
1
2
  import { mkdir } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { MARKER } from "../cast";
@@ -7,7 +8,9 @@ import { buildTimeline, withReinjection, type TimedEvent } from "../timeline";
7
8
  import { pageAssets } from "./bundle";
8
9
  import { createSinks, type Chapter } from "./encoder";
9
10
  import { chipBuilder } from "../keylabels";
10
- import { BROWSER_GAP, barHeight, renderHtml, themeOsc } from "./page";
11
+ import { BROWSER_GAP, barHeight, opaqueFill, renderHtml, themeOsc } from "./page";
12
+ import { decodePng, encodePng, luminance, matte, parseHex } from "./png";
13
+ import { createWebView } from "./view";
11
14
 
12
15
  export interface RenderResult {
13
16
  outputs: string[];
@@ -15,6 +18,8 @@ export interface RenderResult {
15
18
  screenshots: string[];
16
19
  durationSeconds: number;
17
20
  chapters?: Chapter[];
21
+ /** Things worth telling the user (e.g. a format that cannot carry alpha). */
22
+ notes?: string[];
18
23
  }
19
24
 
20
25
  interface ZoomRect {
@@ -25,7 +30,7 @@ interface ZoomRect {
25
30
  }
26
31
 
27
32
  /** Grid region (inclusive rows/cols, padded by `padding` cells) → px rect relative to the terminal grid. */
28
- function zoomRect(spec: { rows?: [number, number]; cols?: [number, number]; padding?: number }, cols: number, rows: number, cell: { w: number; h: number }): ZoomRect {
33
+ function zoomRect(spec: { rows?: [number, number]; cols?: [number, number]; padding?: number }, cols: number, rows: number, cell: CellSize): ZoomRect {
29
34
  const pad = spec.padding ?? 1;
30
35
  const r0 = Math.max(0, (spec.rows?.[0] ?? 0) - pad);
31
36
  const r1 = Math.min(rows - 1, (spec.rows?.[1] ?? rows - 1) + pad);
@@ -56,10 +61,6 @@ export async function render(
56
61
  config: ResolvedConfig,
57
62
  onProgress?: (p: RenderProgress) => void,
58
63
  ): Promise<RenderResult> {
59
- if (typeof Bun.WebView !== "function") {
60
- throw new Error("Bun.WebView is not available in this Bun version. tcut needs Bun >= 1.4.");
61
- }
62
-
63
64
  const assets = await pageAssets();
64
65
  const html = renderHtml(config);
65
66
  const osc = themeOsc(config.theme);
@@ -77,6 +78,7 @@ export async function render(
77
78
  if (pathname === "/wterm.css") return new Response(assets.css, { headers: { "content-type": "text/css" } });
78
79
  if (pathname === "/ghostty-vt.wasm") return new Response(Bun.file(assets.wasmPath), { headers: { "content-type": "application/wasm" } });
79
80
  if (pathname === "/theme") return new Response(osc, { headers: { "content-type": "text/plain; charset=utf-8" } });
81
+ if (pathname === "/watermark" && config.watermark?.image) return new Response(Bun.file(path.resolve(config.watermark.image)));
80
82
  if (pathname.startsWith("/bframe/")) {
81
83
  const rel = decodeURIComponent(pathname.slice("/bframe/".length));
82
84
  if (rel.includes("..")) return new Response("forbidden", { status: 403 });
@@ -107,8 +109,16 @@ export async function render(
107
109
  const totalFrames = Math.max(1, Math.ceil(timeline.duration * fps) + 1);
108
110
  const blinkPeriod = config.cursor.period / 1000;
109
111
  const screenshots: string[] = [];
112
+ const notes: string[] = [];
113
+ // Transparent output: every dirty frame is shot twice, over the theme background and over a contrasting one,
114
+ // and the pair is matted into real RGBA (the WebView itself always composites onto an opaque page).
115
+ const transparent = config.marginFill === "transparent";
116
+ const fillA = opaqueFill(config);
117
+ const fillB = luminance(fillA) > 0.5 ? "#000000" : "#ffffff";
118
+ const rgbA = parseHex(fillA);
119
+ const rgbB = parseHex(fillB);
110
120
 
111
- const view = new Bun.WebView({ width: 800, height: 600 });
121
+ const view = createWebView({ width: 800, height: 600 });
112
122
  try {
113
123
  await view.navigate(`http://127.0.0.1:${server.port}/`);
114
124
  for (let i = 0; i < 100; i++) {
@@ -126,7 +136,7 @@ export async function render(
126
136
  await view.evaluate(`window.__vt.boot(${JSON.stringify(boot)})`);
127
137
  if (!lite) await view.evaluate("window.__vt.writeUrl('/theme')");
128
138
 
129
- const cell = (await view.evaluate("window.__vt.measure()")) as { w: number; h: number };
139
+ const cell = (await view.evaluate("window.__vt.measure()")) as CellSize;
130
140
  if (!cell || !(cell.w > 0) || !(cell.h > 0)) throw new Error("Could not measure terminal cell size");
131
141
 
132
142
  const termW = Math.ceil(rec.header.width * cell.w);
@@ -167,7 +177,11 @@ export async function render(
167
177
  await view.resize(width, height);
168
178
  await view.evaluate("new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))");
169
179
 
170
- const sinks = await createSinks(config.output, fps, { chapters, durationSeconds: timeline.duration });
180
+ const sinks = await createSinks(config.output, fps, { chapters, durationSeconds: timeline.duration, alpha: transparent });
181
+ if (transparent) {
182
+ const opaque = sinks.filter((s) => !s.alpha).map((s) => path.basename(s.target));
183
+ if (opaque.length) notes.push(`${opaque.join(", ")}: this format has no alpha channel, so the theme background was used instead of transparency`);
184
+ }
171
185
  const cellPx = cell;
172
186
  const fullRect: ZoomRect = { x: 0, y: 0, w: termW, h: termH };
173
187
  let zoomFrom: ZoomRect | null = null;
@@ -179,10 +193,12 @@ export async function render(
179
193
  // loopOffset rotates the frame order for looping outputs; those frames are buffered and flushed at the end.
180
194
  const loopSinks = config.loopOffset ? sinks.filter((s) => s.loops) : [];
181
195
  const streamSinks = sinks.filter((s) => !loopSinks.includes(s));
182
- const buffered: Uint8Array[] = [];
196
+ const buffered: Array<{ opaque: Uint8Array; alpha: Uint8Array }> = [];
183
197
  let pointer = 0;
184
198
  let lastPng: Uint8Array | null = null;
199
+ let lastAlphaPng: Uint8Array | null = null;
185
200
  let lastBlink: boolean | null = null;
201
+ const setFill = (color: string) => view.evaluate(`window.__vt.background(${JSON.stringify(color)})`);
186
202
 
187
203
  for (let frame = 0; frame < totalFrames; frame++) {
188
204
  const time = frame / fps;
@@ -253,26 +269,33 @@ export async function render(
253
269
  if (zoomChanged || chipsChanged) dirty = true;
254
270
  if (dirty) {
255
271
  lastPng = (await view.screenshot({ encoding: "buffer" })) as Uint8Array;
272
+ if (transparent) {
273
+ await setFill(fillB);
274
+ const onB = (await view.screenshot({ encoding: "buffer" })) as Uint8Array;
275
+ await setFill(fillA);
276
+ lastAlphaPng = encodePng(matte(decodePng(lastPng), decodePng(onB), rgbA, rgbB));
277
+ }
256
278
  }
279
+ const frameFor = (sink: { alpha?: boolean }): Uint8Array => (sink.alpha && lastAlphaPng ? lastAlphaPng : lastPng!);
257
280
 
258
281
  for (const shot of shots) {
259
282
  const file = shot.data.slice(MARKER.screenshot.length);
260
283
  await mkdir(path.dirname(path.resolve(file)), { recursive: true });
261
- await Bun.write(file, lastPng!);
284
+ await Bun.write(file, lastAlphaPng ?? lastPng!);
262
285
  screenshots.push(file);
263
286
  }
264
287
 
265
- for (const sink of streamSinks) await sink.frame(lastPng!);
266
- if (loopSinks.length) buffered.push(lastPng!);
288
+ for (const sink of streamSinks) await sink.frame(frameFor(sink));
289
+ if (loopSinks.length) buffered.push({ opaque: lastPng!, alpha: lastAlphaPng ?? lastPng! });
267
290
  onProgress?.({ frame: frame + 1, total: totalFrames });
268
291
  }
269
292
 
270
293
  if (loopSinks.length) {
271
294
  const rotated = rotateFrames(buffered, loopOffsetFrames(buffered.length, config.loopOffset));
272
- for (const png of rotated) for (const sink of loopSinks) await sink.frame(png);
295
+ for (const pair of rotated) for (const sink of loopSinks) await sink.frame(sink.alpha ? pair.alpha : pair.opaque);
273
296
  }
274
297
  for (const sink of sinks) await sink.finish();
275
- return { outputs: sinks.map((s) => s.target), frames: totalFrames, screenshots, durationSeconds: timeline.duration, chapters };
298
+ return { outputs: sinks.map((s) => s.target), frames: totalFrames, screenshots, durationSeconds: timeline.duration, chapters, ...(notes.length && { notes }) };
276
299
  } finally {
277
300
  view.close();
278
301
  server.stop(true);
package/src/screen.ts CHANGED
@@ -32,6 +32,12 @@ export async function loadCore(core: CoreName = "ghostty"): Promise<TerminalCore
32
32
  * Headless terminal model backed by libghostty (WASM). The recorder feeds every PTY chunk through it so
33
33
  * `wait()` / `expect()` / `run()` look at the actual screen instead of a raw byte stream.
34
34
  */
35
+ /** Zero-based cursor cell. */
36
+ export interface CursorPosition {
37
+ x: number;
38
+ y: number;
39
+ }
40
+
35
41
  export class Screen {
36
42
  private listeners = new Set<() => void>();
37
43
  onResponse: ((data: string) => void) | undefined;
@@ -47,8 +53,8 @@ export class Screen {
47
53
  }
48
54
 
49
55
  write(data: string | Uint8Array): void {
50
- if (typeof data === "string") this.core.writeString(data);
51
- else this.core.writeRaw(data);
56
+ if (data instanceof Uint8Array) this.core.writeRaw(data);
57
+ else this.core.writeString(data);
52
58
  this.drainResponses();
53
59
  for (const listener of this.listeners) listener();
54
60
  }
@@ -87,7 +93,7 @@ export class Screen {
87
93
  return this.core.getRows();
88
94
  }
89
95
 
90
- cursor(): { x: number; y: number } {
96
+ cursor(): CursorPosition {
91
97
  const c = this.core.getCursor();
92
98
  return { x: c.col, y: c.row };
93
99
  }
package/src/scriptgen.ts CHANGED
@@ -23,28 +23,28 @@ type Op =
23
23
  | { kind: "raw"; data: string }
24
24
  | { kind: "sleep"; ms: number };
25
25
 
26
- const NAMED: Record<string, string> = {
27
- "\r": "enter",
28
- "\n": "enter",
29
- "\t": "tab",
30
- "\x7f": "backspace",
31
- "\x1b": "escape",
32
- "\x1b[A": "up",
33
- "\x1b[B": "down",
34
- "\x1b[C": "right",
35
- "\x1b[D": "left",
36
- "\x1bOA": "up",
37
- "\x1bOB": "down",
38
- "\x1bOC": "right",
39
- "\x1bOD": "left",
40
- "\x1b[H": "home",
41
- "\x1b[F": "end",
42
- "\x1b[1~": "home",
43
- "\x1b[4~": "end",
44
- "\x1b[3~": "delete",
45
- "\x1b[5~": "pageUp",
46
- "\x1b[6~": "pageDown",
47
- };
26
+ const NAMED = new Map<string, string>([
27
+ ["\r", "enter"],
28
+ ["\n", "enter"],
29
+ ["\t", "tab"],
30
+ ["\x7f", "backspace"],
31
+ ["\x1b", "escape"],
32
+ ["\x1b[A", "up"],
33
+ ["\x1b[B", "down"],
34
+ ["\x1b[C", "right"],
35
+ ["\x1b[D", "left"],
36
+ ["\x1bOA", "up"],
37
+ ["\x1bOB", "down"],
38
+ ["\x1bOC", "right"],
39
+ ["\x1bOD", "left"],
40
+ ["\x1b[H", "home"],
41
+ ["\x1b[F", "end"],
42
+ ["\x1b[1~", "home"],
43
+ ["\x1b[4~", "end"],
44
+ ["\x1b[3~", "delete"],
45
+ ["\x1b[5~", "pageUp"],
46
+ ["\x1b[6~", "pageDown"],
47
+ ]);
48
48
 
49
49
  /** Split a raw input chunk into individual key tokens (escape sequences, control chars, printable runs). */
50
50
  export function tokenize(input: string): string[] {
@@ -53,15 +53,16 @@ export function tokenize(input: string): string[] {
53
53
  while (i < input.length) {
54
54
  const ch = input[i]!;
55
55
  if (ch === "\x1b") {
56
- // CSI: ESC [ params final | SS3: ESC O x | Alt+key: ESC x
57
- const csi = /^\x1b\[[0-9;?]*[A-Za-z~]/.exec(input.slice(i));
58
- const ss3 = /^\x1bO[A-Za-z]/.exec(input.slice(i));
56
+ // CSI: ESC [ params final | SS3: ESC O x | Alt+key: ESC x (matched after the ESC we already hold)
57
+ const rest = input.slice(i + 1);
58
+ const csi = /^\[[0-9;?]*[A-Za-z~]/.exec(rest);
59
+ const ss3 = /^O[A-Za-z]/.exec(rest);
59
60
  if (csi) {
60
- tokens.push(csi[0]);
61
- i += csi[0].length;
61
+ tokens.push(ch + csi[0]);
62
+ i += csi[0].length + 1;
62
63
  } else if (ss3) {
63
- tokens.push(ss3[0]);
64
- i += ss3[0].length;
64
+ tokens.push(ch + ss3[0]);
65
+ i += ss3[0].length + 1;
65
66
  } else if (i + 1 < input.length) {
66
67
  tokens.push(input.slice(i, i + 2));
67
68
  i += 2;
@@ -139,7 +140,7 @@ export function eventsToOps(rec: Recording, opts: ScriptGenOptions): Op[] {
139
140
  pendingText += token;
140
141
  continue;
141
142
  }
142
- const named = NAMED[token];
143
+ const named = NAMED.get(token);
143
144
  if (named === "enter") {
144
145
  if (opts.cleanShell && pendingText.trim()) {
145
146
  const command = pendingText;
package/src/themes.ts CHANGED
@@ -134,7 +134,7 @@ export type BuiltinThemeName = keyof typeof builtinThemes;
134
134
  const generated = generatedJson as Record<string, Theme>;
135
135
 
136
136
  /** All themes by slug: the generated bundle, with the built-ins overriding colliding names. */
137
- export const themes: Record<string, Theme> = { ...generated, ...builtinThemes };
137
+ export const themes = Object.assign({}, generated, builtinThemes);
138
138
 
139
139
  export const themeNames: string[] = Object.keys(themes).sort();
140
140
 
@@ -153,15 +153,13 @@ export function findThemes(query: string): string[] {
153
153
 
154
154
  export function resolveTheme(theme: ThemeName | Theme | undefined): Theme {
155
155
  if (!theme) return builtinThemes["catppuccin-mocha"];
156
- if (typeof theme === "string") {
157
- const found = themes[themeSlug(theme)];
158
- if (!found) {
159
- const near = findThemes(theme.split(/[\s-]+/)[0] ?? theme).slice(0, 5);
160
- throw new Error(
161
- `Unknown theme "${theme}". ${near.length ? `Did you mean: ${near.join(", ")}?` : ""} Run \`tcut themes [query]\` to list all ${themeNames.length}.`.trim(),
162
- );
163
- }
164
- return found;
156
+ if (theme instanceof Object) return theme; // an inline Theme object
157
+ const found = themes[themeSlug(theme)];
158
+ if (!found) {
159
+ const near = findThemes(theme.split(/[\s-]+/)[0] ?? theme).slice(0, 5);
160
+ throw new Error(
161
+ `Unknown theme "${theme}". ${near.length ? `Did you mean: ${near.join(", ")}?` : ""} Run \`tcut themes [query]\` to list all ${themeNames.length}.`.trim(),
162
+ );
165
163
  }
166
- return theme;
164
+ return found;
167
165
  }
package/src/timeline.ts CHANGED
@@ -21,14 +21,25 @@ export interface TimelineOptions {
21
21
  }
22
22
 
23
23
  /**
24
- * Collapse hidden intervals, apply playback speed, optionally cap idle gaps. Hidden events keep their relative
25
- * order but all land on the instant the hide started, so the first visible frame after `show` reflects their
26
- * combined effect. Input (`i`) events are dropped unless `keepInput`: the PTY already echoed them.
24
+ * Collapse hidden intervals, apply playback speed (global and per `speed:` segment), optionally cap idle gaps.
25
+ * Hidden events keep their relative order but all land on the instant the hide started, so the first visible
26
+ * frame after `show` reflects their combined effect. Input (`i`) events are dropped unless `keepInput`: the PTY
27
+ * already echoed them.
27
28
  */
28
29
  export function buildTimeline(events: CastEvent[], playbackSpeed: number, opts: TimelineOptions = {}): Timeline {
29
30
  const out: TimedEvent[] = [];
30
31
  let hiddenSince: number | null = null;
31
32
  let removed = 0;
33
+ // Visible time accumulates per segment: (collapsed time since the last event) / (global × segment speed).
34
+ let segmentSpeed = 1;
35
+ let lastCollapsed = 0;
36
+ let vt = 0;
37
+ const advance = (t: number): number => {
38
+ const collapsed = hiddenSince === null ? t - removed : hiddenSince - removed;
39
+ vt += Math.max(0, collapsed - lastCollapsed) / (playbackSpeed * segmentSpeed);
40
+ lastCollapsed = collapsed;
41
+ return vt;
42
+ };
32
43
 
33
44
  for (const [t, type, data] of events) {
34
45
  if (type === "m" && data === MARKER.hide) {
@@ -42,9 +53,14 @@ export function buildTimeline(events: CastEvent[], playbackSpeed: number, opts:
42
53
  }
43
54
  continue;
44
55
  }
56
+ if (type === "m" && data.startsWith(MARKER.speed)) {
57
+ advance(t);
58
+ const speed = Number(data.slice(MARKER.speed.length));
59
+ segmentSpeed = Number.isFinite(speed) && speed > 0 ? speed : 1;
60
+ continue;
61
+ }
45
62
  if (type === "i" && !opts.keepInput) continue;
46
- const visible = hiddenSince === null ? t - removed : hiddenSince - removed;
47
- out.push({ vt: visible / playbackSpeed, type, data });
63
+ out.push({ vt: advance(t), type, data });
48
64
  }
49
65
 
50
66
  if (opts.maxPause !== undefined && opts.maxPause >= 0) {