termcut 0.7.2 → 0.8.0

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
@@ -51,7 +51,11 @@ defineVideo({ output: "demo.mp4", browser: { position: "overlay" } }, async (t)
51
51
  });
52
52
  ```
53
53
 
54
- Polish: `shadow: true`, `watermark: "© you"`, `marginFill: "transparent"` (real alpha in PNG/WebP/GIF/WebM/SVG), `keys: true` shows key presses, `maxPause: "800ms"` cuts dead air, `t.timelapse(fn, { speed: 8 })` fast-forwards an install, `t.zoom({ rows: [0, 5] })` magnifies output, `t.chapter("Install")` adds mp4 chapters, `preset: "x"` sizes it for X. `tcut diff a.cast b.cast` catches output changes in CI.
54
+ Polish: `shadow: true`, `watermark: "© you"`, `marginFill: "transparent"` (real alpha in PNG/WebP/GIF/WebM/SVG), `title: "auto"` follows the title the program sets, `keys: true` shows key presses, `maxPause: "800ms"` cuts dead air, `t.timelapse(fn, { speed: 8 })` fast-forwards an install, `t.zoom({ rows: [0, 5] })` magnifies output, `t.chapter("Install")` adds mp4 chapters, `preset: "x"` sizes it for X.
55
+
56
+ Faithful to the terminal: arrows switch to the form vim/less asked for, `t.paste()` uses bracketed paste (no autoindent stair-steps), synchronized-output repaints never show torn frames, and OSC 8 hyperlinks — including Markdown links in `t.print()` captions — stay clickable in SVG and HTML output.
57
+
58
+ Test and inspect: `tcut diff a.cast b.cast` catches output changes in CI, `t.expect(/…/, { scope: "scrollback" })` checks output that scrolled away (`-o demo.log` writes the whole transcript), and `tcut doctor demo.cast` tells you what a recording used — and what can't be rendered (inline images).
55
59
 
56
60
  Cut and join without re-recording — on the cast, so every format works:
57
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termcut",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
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
@@ -48,10 +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 CI runner can take ~20 s to come up. */
52
- const STARTUP_GRACE = 20_000;
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
53
  const startedAt = performance.now();
54
- const startupDeadline = (deadline: number): number => (loaded ? deadline : Math.max(deadline, startedAt + STARTUP_GRACE));
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
+ };
55
63
  // A view runs one evaluate() at a time; goto's probes, waitFor and the script's own evaluate calls take turns.
56
64
  let chain: Promise<unknown> = Promise.resolve();
57
65
  const serial = <T,>(fn: () => Promise<T>): Promise<T> => {
@@ -89,6 +97,11 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
89
97
  })();
90
98
  return sampling;
91
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
+ };
92
105
  const sampler = (async () => {
93
106
  while (running) {
94
107
  await sampleOnce();
@@ -153,7 +166,7 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
153
166
  const text = String((await evaluate("document.body ? document.body.innerText : ''", 5000, "waitFor").catch(() => "")) ?? "");
154
167
  if (regex.test(text)) {
155
168
  loaded = true; // a page that answers is a page worth sampling, even if navigate() has not settled yet
156
- 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
157
170
  return;
158
171
  }
159
172
  if (performance.now() > startupDeadline(deadline)) throw new WaitTimeoutError(`${regex} in the browser page`, timeout, text.slice(0, 2000));
@@ -178,7 +191,7 @@ export function startBrowserCapture(config: ResolvedConfig, stamp: () => number,
178
191
  if (!loaded && bcfg.url) await evaluate("document.readyState", 2000, "probe").catch(() => undefined); // marks loaded if the page answers
179
192
  running = false;
180
193
  await sampler;
181
- await sampleOnce(); // final state of the page
194
+ await sampleNow(); // final state of the page
182
195
  try {
183
196
  view.close();
184
197
  } catch {
package/src/cli.ts CHANGED
@@ -8,6 +8,7 @@ import * as api from "./index";
8
8
  import { recordLive } from "./live";
9
9
  import { ensurePublicBucket, loadPublishConfig, publicUrlFor, publishFiles, savePublishConfig, type PublishConfig, type Published } from "./publish";
10
10
  import { diffCasts, type DiffResult } from "./diff";
11
+ import { diagnoseCast, formatDoctorReport, type DoctorReport } from "./doctor";
11
12
  import { toMs } from "./duration";
12
13
  import { concatRecordings, cutRecording, flattenedConfig, rebaseBrowserFrames, recordingDuration, selectChapters } from "./edit";
13
14
  import { presetNames, type PresetName } from "./presets";
@@ -15,7 +16,7 @@ import { renderOutputs } from "./render";
15
16
  import { generateScript } from "./scriptgen";
16
17
  import { runScriptTests, type TestSummary } from "./testing";
17
18
  import { findThemes, themeNames } from "./themes";
18
- import type { BrowserConfig, ClipSelection, CoreName, ThemeName, VideoConfig, WindowBar } from "./types";
19
+ import type { BrowserConfig, ClipSelection, CoreName, Recording, ResolvedConfig, ThemeName, VideoConfig, WindowBar } from "./types";
19
20
  import { Video, attachBrowserFrames, castConfig, isVideo, renderCast } from "./video";
20
21
 
21
22
  // Let user scripts `import { defineVideo } from "tcut"` (or "termcut", the npm package name) regardless of
@@ -39,6 +40,7 @@ Usage:
39
40
  tcut render <file.cast> [options] render an existing .cast (tcut or asciinema)
40
41
  tcut test <path...> run scripts in fast mode as tests (no video)
41
42
  tcut diff <a.cast> <b.cast> compare what two recordings show on screen (exit 1 if different)
43
+ tcut doctor <file.cast> what the program used, and what tcut cannot show (images, unknown sequences)
42
44
  tcut cut <file.cast> --from 2s --to 10s [--cast out.cast] [-o …] keep part of a recording (by time or --chapters)
43
45
  tcut concat <a.cast> <b.cast…> [--gap 500ms] [--cast out.cast] [-o …] join recordings end to end
44
46
  tcut publish <files...> [--open] upload to your S3-compatible bucket and print share links
@@ -162,6 +164,7 @@ type CliReport =
162
164
  | { cast: string; cached: boolean; outputs: OutputFile[]; frames: number; durationSeconds: number }
163
165
  | { cast: string; events: number; durationSeconds: number; outputs: OutputFile[] }
164
166
  | DiffResult
167
+ | DoctorReport
165
168
  | TestSummary;
166
169
 
167
170
  /** With --json, the only thing on stdout is one JSON document (results or { error }). */
@@ -515,6 +518,13 @@ async function main(): Promise<void> {
515
518
  emit({ cast: rest[0], outputs: files, frames: result.frames, durationSeconds: result.durationSeconds });
516
519
  return;
517
520
  }
521
+ case "doctor": {
522
+ if (!rest[0]) fail("doctor needs a .cast file");
523
+ const report = await diagnoseCast(rest[0]);
524
+ if (!json) for (const line of formatDoctorReport(report)) console.log(line);
525
+ emit(report);
526
+ return;
527
+ }
518
528
  case "cut":
519
529
  case "concat": {
520
530
  const joining = first === "concat";
@@ -524,13 +534,13 @@ async function main(): Promise<void> {
524
534
  const castOut = values.cast ?? (joining ? path.join(path.dirname(rest[0]!), "concat.cast") : rest[0]!.replace(/\.cast$/, "") + "-cut.cast");
525
535
  const overrides = overridesFromFlags();
526
536
  delete overrides.cast;
527
- const parts: Array<{ rec: Awaited<ReturnType<typeof readCast>>; config: ReturnType<typeof castConfig> }> = [];
537
+ const parts: Array<{ rec: Recording; config: ResolvedConfig }> = [];
528
538
  for (const [i, file] of rest.entries()) {
529
539
  const rec = await readCast(file);
530
540
  const config = applyOverrides(castConfig(rec, file, values.output), overrides);
531
541
  parts.push({ rec: await rebaseBrowserFrames(rec, file, castOut, joining ? `${i}-` : ""), config });
532
542
  }
533
- let out: Awaited<ReturnType<typeof readCast>>;
543
+ let out: Recording;
534
544
  if (joining) {
535
545
  out = concatRecordings(parts, { gap: seconds("gap") ?? 0 });
536
546
  } 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/doctor.ts ADDED
@@ -0,0 +1,150 @@
1
+ // `tcut doctor`: replay a cast through the emulator and report what the program used — and what tcut cannot show.
2
+ import { MARKER, readCast } from "./cast";
3
+ import { unsupportedProtocols, extractTitles, type UnsupportedProtocol } from "./osc";
4
+ import { loadCore } from "./screen";
5
+ import type { Recording } from "./types";
6
+
7
+ export interface UnhandledSequenceSummary {
8
+ /** Final byte of the CSI/ESC sequence, e.g. "h". */
9
+ final: string;
10
+ count: number;
11
+ }
12
+
13
+ export interface DoctorReport {
14
+ cast: string;
15
+ cols: number;
16
+ rows: number;
17
+ durationSeconds: number;
18
+ events: number;
19
+ outputBytes: number;
20
+ /** Terminal features the program used. */
21
+ features: {
22
+ altScreen: boolean;
23
+ mouseTracking: boolean;
24
+ bracketedPaste: boolean;
25
+ appCursorKeys: boolean;
26
+ synchronizedOutput: boolean;
27
+ hyperlinks: number;
28
+ scrollbackLines: number;
29
+ titles: string[];
30
+ };
31
+ markers: { chapters: number; zoom: number; hidden: number; screenshots: number; browserFrames: number };
32
+ /** Things tcut cannot render faithfully. */
33
+ unsupported: UnsupportedProtocol[];
34
+ /** Escape sequences the lightweight core did not understand (the Ghostty core handles more but does not report). */
35
+ unhandled: UnhandledSequenceSummary[];
36
+ warnings: string[];
37
+ }
38
+
39
+ function unhandledSummary(seqs: Array<{ final: string }>): UnhandledSequenceSummary[] {
40
+ const counts = new Map<string, number>();
41
+ for (const s of seqs) counts.set(s.final, (counts.get(s.final) ?? 0) + 1);
42
+ return [...counts.entries()].map(([final, count]) => ({ final, count })).sort((a, b) => b.count - a.count);
43
+ }
44
+
45
+ export async function diagnoseRecording(rec: Recording, castPath = rec.source ?? "(memory)"): Promise<DoctorReport> {
46
+ const output = rec.events.filter((e) => e[1] === "o").map((e) => e[2]);
47
+ const outputText = output.join("");
48
+ // "Ever used" must not depend on state sampling: a mode entered and left inside one chunk still counts.
49
+ const requested = (code: string): boolean => outputText.includes(`\x1b[?${code}h`);
50
+ const links = new Set<string>();
51
+ const titles: string[] = [];
52
+ let altScreen = false;
53
+ let mouse = false;
54
+ let paste = false;
55
+ let appCursor = false;
56
+ let sync = false;
57
+
58
+ // Ghostty (the default renderer core) for feature detection, sampled after every output chunk.
59
+ const core = await loadCore("ghostty");
60
+ core.init(rec.header.width, rec.header.height);
61
+ for (const [, type, data] of rec.events) {
62
+ if (type === "r") {
63
+ const [c, r] = data.split("x").map(Number);
64
+ if (c! > 0 && r! > 0) core.resize(c!, r!);
65
+ continue;
66
+ }
67
+ if (type !== "o") continue;
68
+ core.writeString(data);
69
+ for (const t of extractTitles(data)) if (titles[titles.length - 1] !== t) titles.push(t);
70
+ altScreen ||= core.usingAltScreen() || requested("1049") || requested("47");
71
+ mouse ||= (core.mouseTracking?.() ?? 0) !== 0 || requested("1000") || requested("1002") || requested("1003");
72
+ paste ||= core.bracketedPaste() || requested("2004");
73
+ appCursor ||= core.cursorKeysApp() || requested("1");
74
+ sync ||= (core.synchronizedOutput?.() ?? false) || requested("2026");
75
+ for (let y = 0; y < core.getRows(); y++) {
76
+ for (let x = 0; x < core.getCols(); x++) {
77
+ const uri = core.getCell(y, x).linkUri;
78
+ if (uri) links.add(uri);
79
+ }
80
+ }
81
+ }
82
+ // The lightweight core reports sequences it does not implement.
83
+ const lite = await loadCore("lite");
84
+ lite.init(rec.header.width, rec.header.height);
85
+ for (const chunk of output) lite.writeString(chunk);
86
+ const unhandled = unhandledSummary(lite.getUnhandledSequences());
87
+
88
+ const markers = {
89
+ chapters: rec.events.filter((e) => e[1] === "m" && e[2].startsWith(MARKER.chapter)).length,
90
+ zoom: rec.events.filter((e) => e[1] === "m" && e[2].startsWith(MARKER.zoom)).length,
91
+ hidden: rec.events.filter((e) => e[1] === "m" && e[2] === MARKER.hide).length,
92
+ screenshots: rec.events.filter((e) => e[1] === "m" && e[2].startsWith(MARKER.screenshot)).length,
93
+ browserFrames: rec.events.filter((e) => e[1] === "b").length,
94
+ };
95
+ const unsupported = unsupportedProtocols(outputText); // shown on their own report lines; not repeated as warnings
96
+ const warnings: string[] = [];
97
+ if (altScreen && rec.events.some((e) => e[1] === "o" && e[2].includes("\x1b[?1049l"))) {
98
+ warnings.push("A full-screen program exited; text it left on the primary screen is normal — use t.hide(() => t.clear()) to tidy the video");
99
+ }
100
+ if (mouse) warnings.push("The program enabled mouse tracking: t.scrollUp()/scrollDown() work here");
101
+ if (!rec.header.bunVideo) warnings.push("No tcut config in the header (foreign asciicast): rendering uses defaults unless you pass --theme/--cols/--rows");
102
+
103
+ const duration = rec.header.duration ?? (rec.events.length ? rec.events[rec.events.length - 1]![0] : 0);
104
+ return {
105
+ cast: castPath,
106
+ cols: rec.header.width,
107
+ rows: rec.header.height,
108
+ durationSeconds: Number(duration.toFixed(3)),
109
+ events: rec.events.length,
110
+ outputBytes: new TextEncoder().encode(outputText).length,
111
+ features: {
112
+ altScreen,
113
+ mouseTracking: mouse,
114
+ bracketedPaste: paste,
115
+ appCursorKeys: appCursor,
116
+ synchronizedOutput: sync,
117
+ hyperlinks: links.size,
118
+ scrollbackLines: core.getScrollbackCount(),
119
+ titles,
120
+ },
121
+ markers,
122
+ unsupported,
123
+ unhandled,
124
+ warnings,
125
+ };
126
+ }
127
+
128
+ export async function diagnoseCast(file: string): Promise<DoctorReport> {
129
+ const rec = await readCast(file);
130
+ return diagnoseRecording(rec, file);
131
+ }
132
+
133
+ /** Human-readable report lines. */
134
+ export function formatDoctorReport(r: DoctorReport): string[] {
135
+ const yes = (b: boolean) => (b ? "yes" : "no");
136
+ const lines = [
137
+ `${r.cast}: ${r.cols}×${r.rows}, ${r.durationSeconds.toFixed(1)}s, ${r.events} events, ${(r.outputBytes / 1024).toFixed(1)} KB of output`,
138
+ `features: alt screen ${yes(r.features.altScreen)} · mouse ${yes(r.features.mouseTracking)} · bracketed paste ${yes(r.features.bracketedPaste)} · app cursor keys ${yes(r.features.appCursorKeys)} · synchronized output ${yes(r.features.synchronizedOutput)} · ${r.features.hyperlinks} hyperlink(s) · ${r.features.scrollbackLines} scrollback line(s)`,
139
+ ];
140
+ if (r.features.titles.length) lines.push(`titles: ${r.features.titles.map((t) => JSON.stringify(t)).join(" → ")}`);
141
+ const m = r.markers;
142
+ if (m.chapters || m.zoom || m.hidden || m.screenshots || m.browserFrames) {
143
+ lines.push(`markers: ${m.chapters} chapter(s) · ${m.zoom} zoom · ${m.hidden} hidden section(s) · ${m.screenshots} screenshot(s) · ${m.browserFrames} browser frame(s)`);
144
+ }
145
+ for (const u of r.unsupported) lines.push(`unsupported: ${u.name} ×${u.count} — ${u.note}`);
146
+ if (r.unhandled.length) lines.push(`unhandled by the lite core: ${r.unhandled.map((u) => `${JSON.stringify(u.final)} ×${u.count}`).join(", ")} (use core: "ghostty", the default, for these)`);
147
+ for (const w of r.warnings) lines.push(`note: ${w}`);
148
+ if (!r.unsupported.length && !r.unhandled.length) lines.push("ok: nothing in this recording that tcut cannot show");
149
+ return lines;
150
+ }
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--------------`);
@@ -1,6 +1,7 @@
1
1
  import type { CellData, TerminalCore } from "@wterm/core";
2
+ import { extractTitle } from "../osc";
2
3
  import { themeOsc } from "../renderer/page";
3
- import { loadCore } from "../screen";
4
+ import { loadCore, scrollbackLines } from "../screen";
4
5
  import { buildTimeline, withReinjection } from "../timeline";
5
6
  import type { Recording, ResolvedConfig, Theme } from "../types";
6
7
 
@@ -22,6 +23,8 @@ export interface GridCell {
22
23
  fg: string | null;
23
24
  bg: string | null;
24
25
  flags: number;
26
+ /** OSC 8 hyperlink target, if the cell is inside one. */
27
+ link: string | null;
25
28
  }
26
29
 
27
30
  export interface GridFrame {
@@ -52,6 +55,10 @@ export interface GridReplay {
52
55
  duration: number;
53
56
  cols: number;
54
57
  rows: number;
58
+ /** Last window title the program set (OSC 0/2), if any. */
59
+ title: string | null;
60
+ /** Everything shown: scrollback lines followed by the final screen. */
61
+ transcript: string[];
55
62
  }
56
63
 
57
64
  const ANSI: (keyof Theme)[] = [
@@ -84,7 +91,7 @@ function toGridCell(cell: CellData, theme: Theme): GridCell {
84
91
  [fg, bg] = [bg ?? theme.background, fg ?? theme.foreground];
85
92
  }
86
93
  const text = cell.chars ?? (cell.char === 0 ? " " : String.fromCodePoint(cell.char));
87
- return { text, width: cell.width === 2 ? 2 : 1, fg, bg, flags: cell.flags & ~FLAG.reverse };
94
+ return { text, width: cell.width === 2 ? 2 : 1, fg, bg, flags: cell.flags & ~FLAG.reverse, link: cell.linkUri ?? null };
88
95
  }
89
96
 
90
97
  interface ScreenSnapshot {
@@ -109,7 +116,7 @@ function snapshot(core: TerminalCore, theme: Theme): ScreenSnapshot {
109
116
  }
110
117
  if (meaningful) {
111
118
  rows.set(y, cells);
112
- keyParts.push(`${y}:${cells.map((c) => `${c.text}${c.fg ?? ""}${c.bg ?? ""}${c.flags}`).join("")}`);
119
+ keyParts.push(`${y}:${cells.map((c) => `${c.text}${c.fg ?? ""}${c.bg ?? ""}${c.flags}${c.link ?? ""}`).join("")}`);
113
120
  }
114
121
  }
115
122
  return { rows, key: keyParts.join("\n") };
@@ -133,17 +140,31 @@ export async function replayFrames(rec: Recording, config: ResolvedConfig): Prom
133
140
  const frames: GridFrame[] = [];
134
141
  let pointer = 0;
135
142
  let lastKey: string | null = null;
143
+ let title: string | null = null;
144
+ // Synchronized output (mode 2026): while a program is mid-update, keep showing the previous frame — bounded,
145
+ // so a block that is never closed cannot freeze the video.
146
+ const maxHeld = Math.ceil(fps / 2);
147
+ let held = 0;
136
148
 
137
149
  for (let i = 0; i < totalFrames; i++) {
138
150
  const time = i / fps;
139
151
  while (pointer < events.length && events[pointer]!.vt <= time + 1e-9) {
140
152
  const e = events[pointer++]!;
141
- if (e.type === "o") core.writeString(e.data);
142
- else if (e.type === "r") {
153
+ if (e.type === "o") {
154
+ core.writeString(e.data);
155
+ const t = extractTitle(e.data);
156
+ if (t !== null) title = t;
157
+ } else if (e.type === "r") {
143
158
  const [c, r] = e.data.split("x").map(Number);
144
159
  if (c! > 0 && r! > 0) core.resize(c!, r!);
145
160
  }
146
161
  }
162
+ if (frames.length > 0 && held < maxHeld && core.synchronizedOutput?.()) {
163
+ held += 1;
164
+ frames[frames.length - 1]!.hold += 1 / fps;
165
+ continue;
166
+ }
167
+ held = 0;
147
168
  const cursor = core.getCursor();
148
169
  const { rows, key } = snapshot(core, config.theme);
149
170
  const fullKey = `${key}|${cursor.row},${cursor.col},${cursor.visible}|${core.getCols()}x${core.getRows()}`;
@@ -155,5 +176,7 @@ export async function replayFrames(rec: Recording, config: ResolvedConfig): Prom
155
176
  frames.push({ time, hold: 1 / fps, cols: core.getCols(), rows: core.getRows(), rows_: rows, cursor });
156
177
  }
157
178
 
158
- return { frames, duration: totalFrames / fps, cols: rec.header.width, rows: rec.header.height };
179
+ const last = frames[frames.length - 1];
180
+ const transcript = [...scrollbackLines(core), ...(last ? frameText(last) : [])];
181
+ return { frames, duration: totalFrames / fps, cols: rec.header.width, rows: rec.header.height, title, transcript };
159
182
  }
@@ -20,7 +20,7 @@ function windowBar(config: ResolvedConfig): string {
20
20
  const right = config.windowBar.endsWith("Right");
21
21
  const dot = (c: string) => `<span class="dot" style="${rings ? `border:2px solid ${c}` : `background:${c}`}"></span>`;
22
22
  const dots = `<div class="dots">${dot("#ff5f57")}${dot("#febc2e")}${dot("#28c840")}</div>`;
23
- const title = `<div class="title">${escapeHtml(config.title)}</div>`;
23
+ const title = `<div class="title">${escapeHtml(config.title === "auto" ? "" : config.title)}</div>`;
24
24
  return `<div id="bar" class="${right ? "right" : ""}">${right ? title + dots : dots + title}</div>`;
25
25
  }
26
26
 
@@ -36,6 +36,7 @@ export async function buildHtml(rec: Recording, config: ResolvedConfig): Promise
36
36
  rows: rec.header.height,
37
37
  duration,
38
38
  speed: 1,
39
+ autoTitle: config.title === "auto",
39
40
  events: events.filter((e) => e.type === "o" || e.type === "r").map(({ vt, type, data }) => ({ vt, type, data })),
40
41
  };
41
42
  // "</script>" inside the JSON would terminate the data block; escape it.
package/src/export/svg.ts CHANGED
@@ -54,7 +54,7 @@ export function svgGeometry(config: ResolvedConfig, cols: number, rows: number):
54
54
  };
55
55
  }
56
56
 
57
- function windowBar(config: ResolvedConfig, g: Geometry): string {
57
+ function windowBar(config: ResolvedConfig, g: Geometry, title: string): string {
58
58
  if (config.windowBar === "none") return "";
59
59
  const rings = config.windowBar.startsWith("rings");
60
60
  const right = config.windowBar.endsWith("Right");
@@ -64,10 +64,10 @@ function windowBar(config: ResolvedConfig, g: Geometry): string {
64
64
  const dots = colors
65
65
  .map((c, i) => `<circle cx="${num(startX + i * 20)}" cy="${num(y)}" r="6" ${rings ? `fill="none" stroke="${c}" stroke-width="2"` : `fill="${c}"`}/>`)
66
66
  .join("");
67
- const title = config.title
68
- ? `<text x="${num(g.frameX + g.frameW / 2)}" y="${num(y + 4)}" text-anchor="middle" font-family="-apple-system, Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="${config.theme.foreground}" opacity="0.7">${esc(config.title)}</text>`
67
+ const titleText = title
68
+ ? `<text x="${num(g.frameX + g.frameW / 2)}" y="${num(y + 4)}" text-anchor="middle" font-family="-apple-system, Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="${config.theme.foreground}" opacity="0.7">${esc(title)}</text>`
69
69
  : "";
70
- return dots + title;
70
+ return dots + titleText;
71
71
  }
72
72
 
73
73
  function styleAttrs(cell: GridCell, defaultFg: string): string {
@@ -103,22 +103,27 @@ function frameMarkup(frame: GridFrame, config: ResolvedConfig, g: Geometry): str
103
103
  }
104
104
  flushBg(x);
105
105
 
106
- // Text runs with identical style
106
+ // Text runs with identical style (and link); OSC 8 links become real <a> elements
107
107
  const spans: string[] = [];
108
108
  x = 0;
109
- let run: { x: number; text: string; style: string } | null = null;
109
+ let run: { x: number; text: string; style: string; link: string | null } | null = null;
110
+ const flushRun = () => {
111
+ if (!run || !run.text.trim()) return;
112
+ const span = `<tspan x="${num(run.x * g.cellW)}"${run.style}>${esc(run.text.replace(/\s+$/, ""))}</tspan>`;
113
+ spans.push(run.link ? `<a href="${esc(run.link)}">${span}</a>` : span);
114
+ };
110
115
  for (const cell of cells) {
111
116
  const blank = cell.text === " " && !(cell.flags & (FLAG.underline | FLAG.strike));
112
117
  const style = blank ? "" : styleAttrs(cell, theme.foreground);
113
- if (run && (run.style === style || (blank && run.text.length > 0))) {
118
+ if (run && ((run.style === style && run.link === cell.link) || (blank && run.text.length > 0 && run.link === null))) {
114
119
  run.text += cell.text;
115
120
  } else {
116
- if (run && run.text.trim()) spans.push(`<tspan x="${num(run.x * g.cellW)}"${run.style}>${esc(run.text.replace(/\s+$/, ""))}</tspan>`);
117
- run = blank ? null : { x, text: cell.text, style };
121
+ flushRun();
122
+ run = blank ? null : { x, text: cell.text, style, link: cell.link };
118
123
  }
119
124
  x += cell.width;
120
125
  }
121
- if (run && run.text.trim()) spans.push(`<tspan x="${num(run.x * g.cellW)}"${run.style}>${esc(run.text.replace(/\s+$/, ""))}</tspan>`);
126
+ flushRun();
122
127
  if (spans.length) parts.push(`<text y="${num(y * g.cellH + baseline)}">${spans.join("")}</text>`);
123
128
  }
124
129
 
@@ -189,7 +194,7 @@ text{white-space:pre;dominant-baseline:auto}
189
194
  ${config.marginFill === "transparent" ? "" : `<rect width="100%" height="100%" fill="${config.marginFill}"/>`}
190
195
  ${shadowDefs(config)}
191
196
  <rect x="${num(g.frameX)}" y="${num(g.frameY)}" width="${num(g.frameW)}" height="${num(g.frameH)}" rx="${config.borderRadius}" fill="${theme.background}"${config.shadow ? ' filter="url(#shadow)"' : ""}/>
192
- ${windowBar(config, g)}
197
+ ${windowBar(config, g, config.title === "auto" ? (replay.title ?? "") : config.title)}
193
198
  <clipPath id="term"><rect x="${num(g.termX)}" y="${num(g.termY)}" width="${num(g.termW)}" height="${num(g.termH)}"/></clipPath>
194
199
  <g clip-path="url(#term)"><g transform="translate(${num(g.termX)} ${num(g.termY)})"><g class="strip" xml:space="preserve">
195
200
  ${frames}
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export { buildSvg } from "./export/svg";
11
11
  export { buildHtml } from "./export/html";
12
12
  export { replayFrames } from "./export/frames";
13
13
  export type { GridFrame, GridCell, GridReplay } from "./export/frames";
14
+ export { writeLog } from "./render";
14
15
  export { runScriptTests, discoverScripts } from "./testing";
15
16
  export type { TestResult, TestSummary } from "./testing";
16
17
  export { themes, themeNames, resolveTheme, findThemes, themeSlug, builtinThemes } from "./themes";
@@ -22,9 +23,12 @@ export { readCast, writeCast, parseCast, serializeCast } from "./cast";
22
23
  export { buildTimeline } from "./timeline";
23
24
  export { presets, presetNames, applyPreset } from "./presets";
24
25
  export { diffCasts } from "./diff";
26
+ export { diagnoseCast, diagnoseRecording, formatDoctorReport } from "./doctor";
27
+ export type { DoctorReport } from "./doctor";
28
+ export { extractTitle, hyperlink, linkifyMarkdown, unsupportedProtocols } from "./osc";
25
29
  export type { DiffOptions, DiffResult } from "./diff";
26
30
  export { keyLabels, keyChips } from "./keylabels";
27
31
  export { startBrowserCapture } from "./browser";
28
32
  export { resolveConfig } from "./config";
29
- export { WaitTimeoutError, ExpectationError } from "./recorder";
33
+ export { WaitTimeoutError, ExpectationError, MissingRequirementError } from "./recorder";
30
34
  export type * from "./types";
package/src/keys.ts CHANGED
@@ -32,7 +32,26 @@ const keySequences = {
32
32
  f12: `${ESC}[24~`,
33
33
  } satisfies Record<KeyName, string>;
34
34
 
35
- export function keySequence(name: KeyName): string {
35
+ /** In application cursor mode (DECCKM, used by vim/less/fzf) cursor keys send SS3 instead of CSI. */
36
+ const appCursorSequences = new Map<string, string>([
37
+ ["up", `${ESC}OA`],
38
+ ["down", `${ESC}OB`],
39
+ ["right", `${ESC}OC`],
40
+ ["left", `${ESC}OD`],
41
+ ["home", `${ESC}OH`],
42
+ ["end", `${ESC}OF`],
43
+ ]);
44
+
45
+ export interface KeySequenceOptions {
46
+ /** The program switched on application cursor mode; arrows/home/end use the SS3 form. */
47
+ appCursor?: boolean;
48
+ }
49
+
50
+ export function keySequence(name: KeyName, opts: KeySequenceOptions = {}): string {
51
+ if (opts.appCursor) {
52
+ const app = appCursorSequences.get(name);
53
+ if (app) return app;
54
+ }
36
55
  const seq = keySequences[name];
37
56
  if (seq === undefined) {
38
57
  throw new Error(`Unknown key "${name}". Known keys: ${Object.keys(keySequences).join(", ")}`);
package/src/osc.ts ADDED
@@ -0,0 +1,48 @@
1
+ // Small helpers for OSC sequences tcut reads or writes itself, independent of the emulator core.
2
+ // Patterns never contain control characters: ESC and BEL are mapped to private-use code points first.
3
+ const ESC = "\x1b";
4
+ const BEL = "\x07";
5
+ const ESC_MARK = "";
6
+ const BEL_MARK = "";
7
+
8
+ const marked = (chunk: string): string => chunk.replaceAll(ESC, ESC_MARK).replaceAll(BEL, BEL_MARK);
9
+
10
+ /** Every window title set in `chunk` via OSC 0/2 (`ESC ] 0 ; title BEL`), in order. */
11
+ export function extractTitles(chunk: string): string[] {
12
+ return [...marked(chunk).matchAll(/\](?:0|2);([^]*)(?:|\\)/g)].map((m) => m[1] ?? "");
13
+ }
14
+
15
+ /** The last window title set in `chunk`, or null — what a live window bar should show after the chunk. */
16
+ export function extractTitle(chunk: string): string | null {
17
+ const titles = extractTitles(chunk);
18
+ return titles.length ? titles[titles.length - 1]! : null;
19
+ }
20
+
21
+ /** Wrap `text` in an OSC 8 hyperlink (terminals, the HTML player and the SVG export make it clickable). */
22
+ export function hyperlink(text: string, url: string): string {
23
+ return `${ESC}]8;;${url}${ESC}\\${text}${ESC}]8;;${ESC}\\`;
24
+ }
25
+
26
+ /** Markdown `[text](url)` → underlined text carrying an OSC 8 link, so captions get real links instead of a printed URL. */
27
+ export function linkifyMarkdown(markdown: string): string {
28
+ return markdown.replace(/\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g, (_, text: string, url: string) => hyperlink(`${ESC}[4m${text}${ESC}[24m`, url));
29
+ }
30
+
31
+ export interface UnsupportedProtocol {
32
+ name: string;
33
+ count: number;
34
+ note: string;
35
+ }
36
+
37
+ const PROTOCOLS: Array<{ name: string; pattern: RegExp; note: string }> = [
38
+ { name: "kitty-graphics", pattern: /_G/g, note: "Kitty graphics protocol (inline images) is not rendered" },
39
+ { name: "sixel", pattern: /P[0-9;]*q/g, note: "Sixel images are not rendered" },
40
+ { name: "iterm2-image", pattern: /\]1337;File=/g, note: "iTerm2 inline images are not rendered" },
41
+ { name: "tmux-passthrough", pattern: /Ptmux;/g, note: "tmux passthrough sequences are ignored" },
42
+ ];
43
+
44
+ /** Image/graphics protocols tcut cannot draw, counted across a chunk of output. */
45
+ export function unsupportedProtocols(output: string): UnsupportedProtocol[] {
46
+ const safe = marked(output);
47
+ return PROTOCOLS.map((p) => ({ name: p.name, count: (safe.match(p.pattern) ?? []).length, note: p.note })).filter((p) => p.count > 0);
48
+ }