termcut 0.7.3 → 1.0.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 +5 -1
- package/package.json +1 -1
- package/src/cli.ts +11 -1
- package/src/doctor.ts +150 -0
- package/src/export/frames.ts +29 -6
- package/src/export/html.ts +2 -1
- package/src/export/svg.ts +76 -30
- package/src/index.ts +4 -0
- package/src/keys.ts +20 -1
- package/src/osc.ts +48 -0
- package/src/recorder.ts +16 -6
- package/src/render.ts +45 -3
- package/src/renderer/generated/page.js +1 -1
- package/src/renderer/generated/player.js +1 -1
- package/src/renderer/page-entry.ts +15 -1
- package/src/renderer/page.ts +1 -1
- package/src/renderer/player-entry.ts +13 -2
- package/src/renderer/webview.ts +22 -1
- package/src/screen.ts +44 -0
- package/src/types.ts +14 -5
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, `
|
|
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, `t.snapshot("hero.png")` (or `.svg`) saves a still of that exact moment on every render, `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
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";
|
|
@@ -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 }). */
|
|
@@ -367,7 +370,7 @@ export default defineVideo(
|
|
|
367
370
|
await t.sleep("400ms");
|
|
368
371
|
await t.enter();
|
|
369
372
|
await t.wait(); // prompt is back
|
|
370
|
-
await t.
|
|
373
|
+
await t.snapshot("${name}-notes.png");
|
|
371
374
|
await t.sleep("1.5s");
|
|
372
375
|
},
|
|
373
376
|
);
|
|
@@ -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";
|
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/export/frames.ts
CHANGED
|
@@ -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")
|
|
142
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/export/html.ts
CHANGED
|
@@ -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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import { fitFrame } from "../loop";
|
|
2
4
|
import { barHeight, embedImage } from "../renderer/page";
|
|
3
5
|
import type { Recording, ResolvedConfig } from "../types";
|
|
@@ -54,7 +56,7 @@ export function svgGeometry(config: ResolvedConfig, cols: number, rows: number):
|
|
|
54
56
|
};
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
function windowBar(config: ResolvedConfig, g: Geometry): string {
|
|
59
|
+
function windowBar(config: ResolvedConfig, g: Geometry, title: string): string {
|
|
58
60
|
if (config.windowBar === "none") return "";
|
|
59
61
|
const rings = config.windowBar.startsWith("rings");
|
|
60
62
|
const right = config.windowBar.endsWith("Right");
|
|
@@ -64,10 +66,10 @@ function windowBar(config: ResolvedConfig, g: Geometry): string {
|
|
|
64
66
|
const dots = colors
|
|
65
67
|
.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
68
|
.join("");
|
|
67
|
-
const
|
|
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(
|
|
69
|
+
const titleText = title
|
|
70
|
+
? `<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
71
|
: "";
|
|
70
|
-
return dots +
|
|
72
|
+
return dots + titleText;
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
function styleAttrs(cell: GridCell, defaultFg: string): string {
|
|
@@ -103,22 +105,27 @@ function frameMarkup(frame: GridFrame, config: ResolvedConfig, g: Geometry): str
|
|
|
103
105
|
}
|
|
104
106
|
flushBg(x);
|
|
105
107
|
|
|
106
|
-
// Text runs with identical style
|
|
108
|
+
// Text runs with identical style (and link); OSC 8 links become real <a> elements
|
|
107
109
|
const spans: string[] = [];
|
|
108
110
|
x = 0;
|
|
109
|
-
let run: { x: number; text: string; style: string } | null = null;
|
|
111
|
+
let run: { x: number; text: string; style: string; link: string | null } | null = null;
|
|
112
|
+
const flushRun = () => {
|
|
113
|
+
if (!run || !run.text.trim()) return;
|
|
114
|
+
const span = `<tspan x="${num(run.x * g.cellW)}"${run.style}>${esc(run.text.replace(/\s+$/, ""))}</tspan>`;
|
|
115
|
+
spans.push(run.link ? `<a href="${esc(run.link)}">${span}</a>` : span);
|
|
116
|
+
};
|
|
110
117
|
for (const cell of cells) {
|
|
111
118
|
const blank = cell.text === " " && !(cell.flags & (FLAG.underline | FLAG.strike));
|
|
112
119
|
const style = blank ? "" : styleAttrs(cell, theme.foreground);
|
|
113
|
-
if (run && (run.style === style || (blank && run.text.length > 0))) {
|
|
120
|
+
if (run && ((run.style === style && run.link === cell.link) || (blank && run.text.length > 0 && run.link === null))) {
|
|
114
121
|
run.text += cell.text;
|
|
115
122
|
} else {
|
|
116
|
-
|
|
117
|
-
run = blank ? null : { x, text: cell.text, style };
|
|
123
|
+
flushRun();
|
|
124
|
+
run = blank ? null : { x, text: cell.text, style, link: cell.link };
|
|
118
125
|
}
|
|
119
126
|
x += cell.width;
|
|
120
127
|
}
|
|
121
|
-
|
|
128
|
+
flushRun();
|
|
122
129
|
if (spans.length) parts.push(`<text y="${num(y * g.cellH + baseline)}">${spans.join("")}</text>`);
|
|
123
130
|
}
|
|
124
131
|
|
|
@@ -160,11 +167,29 @@ export interface SvgResult {
|
|
|
160
167
|
duration: number;
|
|
161
168
|
}
|
|
162
169
|
|
|
170
|
+
/** The shared document: chrome (background, window, bar, watermark) around exporter-supplied style + body. */
|
|
171
|
+
async function svgDocument(config: ResolvedConfig, g: Geometry, title: string, style: string, body: string): Promise<string> {
|
|
172
|
+
const { theme, font } = config;
|
|
173
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
174
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="${g.width}" height="${g.height}" viewBox="0 0 ${g.width} ${g.height}" font-family="${esc(font.family)}" font-size="${font.size}">
|
|
175
|
+
<style>
|
|
176
|
+
${style}text{white-space:pre;dominant-baseline:auto}
|
|
177
|
+
</style>
|
|
178
|
+
${config.marginFill === "transparent" ? "" : `<rect width="100%" height="100%" fill="${config.marginFill}"/>`}
|
|
179
|
+
${shadowDefs(config)}
|
|
180
|
+
<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)"' : ""}/>
|
|
181
|
+
${windowBar(config, g, title)}
|
|
182
|
+
<clipPath id="term"><rect x="${num(g.termX)}" y="${num(g.termY)}" width="${num(g.termW)}" height="${num(g.termH)}"/></clipPath>
|
|
183
|
+
<g clip-path="url(#term)"><g transform="translate(${num(g.termX)} ${num(g.termY)})">${body}</g></g>
|
|
184
|
+
${await watermarkMarkup(config, g)}
|
|
185
|
+
</svg>
|
|
186
|
+
`;
|
|
187
|
+
}
|
|
188
|
+
|
|
163
189
|
/** Animated SVG: a horizontal strip of unique frames moved by a stepped CSS animation. No JS, no fonts embedded. */
|
|
164
190
|
export async function buildSvg(rec: Recording, config: ResolvedConfig): Promise<SvgResult> {
|
|
165
191
|
const replay = await replayFrames(rec, config);
|
|
166
192
|
const g = svgGeometry(config, replay.cols, replay.rows);
|
|
167
|
-
const { theme, font } = config;
|
|
168
193
|
const n = replay.frames.length;
|
|
169
194
|
const total = replay.duration;
|
|
170
195
|
|
|
@@ -179,25 +204,46 @@ export async function buildSvg(rec: Recording, config: ResolvedConfig): Promise<
|
|
|
179
204
|
.map((f, i) => `<g transform="translate(${num(i * g.termW)} 0)">${frameMarkup(f, config, g)}</g>`)
|
|
180
205
|
.join("\n");
|
|
181
206
|
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
207
|
+
const style = `.strip{animation:tcut ${num(total)}s steps(1,end) infinite}\n@keyframes tcut{${keyframes.join("")}}\n`;
|
|
208
|
+
const body = `<g class="strip" xml:space="preserve">\n${frames}\n</g>`;
|
|
209
|
+
const title = config.title === "auto" ? (replay.title ?? "") : config.title;
|
|
210
|
+
return { svg: await svgDocument(config, g, title, style, body), frames: n, duration: total };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface SnapshotMark {
|
|
214
|
+
file: string;
|
|
215
|
+
/** Seconds on the visible timeline. */
|
|
216
|
+
at: number;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The frame on screen at `at` seconds (frames carry their start time; the last one started before `at` wins). */
|
|
220
|
+
function frameAt(frames: GridFrame[], at: number): GridFrame | undefined {
|
|
221
|
+
let current = frames[0];
|
|
222
|
+
for (const f of frames) {
|
|
223
|
+
if (f.time <= at + 1e-9) current = f;
|
|
224
|
+
else break;
|
|
225
|
+
}
|
|
226
|
+
return current;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Static (non-animated) SVG stills for `t.snapshot("x.svg")` marks — one replay serves all of them. */
|
|
230
|
+
export async function writeSvgSnapshots(rec: Recording, config: ResolvedConfig, marks: SnapshotMark[]): Promise<string[]> {
|
|
231
|
+
const replay = await replayFrames(rec, config);
|
|
232
|
+
const g = svgGeometry(config, replay.cols, replay.rows);
|
|
233
|
+
const title = config.title === "auto" ? (replay.title ?? "") : config.title;
|
|
234
|
+
const written: string[] = [];
|
|
235
|
+
for (const mark of marks) {
|
|
236
|
+
// The raster pass applies output and marks that share a frame tick together; match that: the mark
|
|
237
|
+
// captures the first tick at or after its instant, so output recorded just before it is included.
|
|
238
|
+
const tick = Math.ceil(mark.at * config.fps - 1e-6) / config.fps;
|
|
239
|
+
const frame = frameAt(replay.frames, tick);
|
|
240
|
+
if (!frame) continue;
|
|
241
|
+
const svg = await svgDocument(config, g, title, "", `<g xml:space="preserve">${frameMarkup(frame, config, g)}</g>`);
|
|
242
|
+
await mkdir(path.dirname(path.resolve(mark.file)), { recursive: true });
|
|
243
|
+
await Bun.write(mark.file, svg);
|
|
244
|
+
written.push(mark.file);
|
|
245
|
+
}
|
|
246
|
+
return written;
|
|
201
247
|
}
|
|
202
248
|
|
|
203
249
|
export async function writeSvg(rec: Recording, config: ResolvedConfig, file: string): Promise<SvgResult> {
|
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,6 +23,9 @@ 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";
|
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
|
-
|
|
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
|
+
}
|