Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d0c8eef
chore: bump overrides to clear high-severity audit advisories
ThomasK33 Aug 21, 2026
b525854
feat: add browserless deterministic SVG export to record export
ThomasK33 Aug 21, 2026
3e37952
test: cover svg record export across unit, integration, and schema su…
ThomasK33 Aug 21, 2026
8501e4d
chore: relock aube after upstream repo transfer
ThomasK33 Aug 21, 2026
e9a5b49
fix: preserve columns across zero-style gap padding cells in svg runs
ThomasK33 Aug 21, 2026
4ed3a7b
Merge remote-tracking branch 'origin/chore/fix-audit-advisories' into…
ThomasK33 Aug 21, 2026
c215cdd
fix: address round-2 review findings in svg export
ThomasK33 Aug 21, 2026
b725d76
fix: address round-3 review findings in svg export
ThomasK33 Aug 21, 2026
0edf33d
fix: export blank svg for sessions with empty event logs
ThomasK33 Aug 21, 2026
c4f53a7
docs: document the bundled-font fallback boundary for svg export
ThomasK33 Aug 21, 2026
d30294e
fix: split svg text runs at wide-glyph boundaries
ThomasK33 Aug 21, 2026
c624435
fix: give animated svg exports a distinct default filename
ThomasK33 Aug 21, 2026
1ab6654
fix: keep animated svg keyTimes strictly increasing for tiny holds
ThomasK33 Aug 21, 2026
2727b55
fix: break svg text runs at styled empty cells
ThomasK33 Aug 21, 2026
b55a1c5
fix: include the render profile in default svg filenames
ThomasK33 Aug 21, 2026
7e84625
fix: split svg text runs at font-face boundaries
ThomasK33 Aug 21, 2026
e5a7863
fix: avoid argument-spreading per-frame arrays in svg rendering
ThomasK33 Aug 21, 2026
f5e8e3c
fix: bound retained grid cells during animated svg capture
ThomasK33 Aug 21, 2026
29ac24b
fix: report svg canvas dimensions matching the rendered viewBox
ThomasK33 Aug 21, 2026
e9cce6c
fix: split svg runs between symbols-face and system-fallback glyphs
ThomasK33 Aug 21, 2026
39cc137
fix: position system-fallback glyphs as singleton svg runs
ThomasK33 Aug 21, 2026
c117cba
Merge remote-tracking branch 'origin/main' into svg-export-fixes
ThomasK33 Sep 24, 2026
1f7ec56
fix: show the final animated svg frame in viewers without SMIL
ThomasK33 Sep 24, 2026
68078f2
Merge remote-tracking branch 'origin/main' into svg-export-fixes
ThomasK33 Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ agent-tty --home <path> snapshot <session-id> --format text --json
agent-tty --home <path> screenshot <session-id> --json
agent-tty --home <path> record export <session-id> --format asciicast --json
agent-tty --home <path> record export <session-id> --format webm --json
agent-tty --home <path> record export <session-id> --format svg --json
```

## `run`
Expand Down Expand Up @@ -220,10 +221,24 @@ agent-tty screenshot <session-id> --profile reference-dark --json
agent-tty screenshot <session-id> --show-cursor --json
agent-tty record export <session-id> --format asciicast --out ./session.cast --json
agent-tty record export <session-id> --format webm --timing accelerated --out ./session.webm --json
agent-tty record export <session-id> --format svg --json
agent-tty record export <session-id> --format svg --animate --out ./session.svg --json
```

WebM export replays with recorded wall-clock timing by default. Pass `--timing accelerated` (idle gaps clamped to 400ms) or `--timing max-speed` for a time-compressed video.

SVG export renders styled grid frames from the event log through the native `libghostty-vt` backend with no browser and no ffmpeg, so it requires the optional `@coder/libghostty-vt-node` package (there is no `ghostty-web` fallback). The output is deterministic: exporting the same session twice produces byte-identical, diffable SVG. `--format svg` writes a still image of the final screen; add `--animate` for an animated SVG of de-duplicated frames replayed with recorded event-log timing (`--timing` is not supported with SVG).

Animated SVG capture retains every distinct frame's styled grid in memory, so it is bounded by a total-cell budget (20M cells, roughly 10,000 distinct 80x24 frames, adapting to terminal size); recordings that exceed it fail with a clear error suggesting a still SVG or WebM export instead.

SVG exports always embed the pinned JetBrains Mono latin subset and additionally embed the Symbols Nerd Font Mono face when the rendered content needs it. Glyphs outside both faces (notably CJK and most emoji) render via the viewer's monospace fallback, mirroring the reference renderer's own system-font fallback for the same glyphs. Text content and layout metrics stay deterministic even when fallback glyph shapes vary: every text run is pinned to the terminal grid via `textLength`, so columns never shift.

SVG export has known fidelity limits, because the native backend's snapshot cells do not yet carry every attribute ([coder/libghostty-vt-node#15](https://github.com/coder/libghostty-vt-node/issues/15)):

- **Only bold, italic, underline, and foreground/background colors are rendered.** Reverse video (SGR 7), strikethrough (SGR 9), dim (SGR 2), and hidden text (SGR 8) render as plain text, so hidden text stays visible. Reverse video is the one most often visible in TUIs (selection bars, status lines, fuzzy-finder highlights). Use a PNG screenshot or WebM export when those attributes matter.
- **The cursor is always drawn.** SVG frames show a block cursor even after the application hides it with `ESC[?25l`. PNG screenshots hide the cursor unless you pass `--show-cursor`.
- **Viewers without SMIL show only the final frame.** Animated SVGs play in browsers. Viewers that ignore SMIL animation (librsvg-based previews, Inkscape) show the final frame as a still image.

Use `--renderer ghostty-web`, `AGENT_TTY_RENDERER=ghostty-web`, or Home `config.json` `{ "defaultRenderer": "ghostty-web" }` to force legacy all-browser rendering. Use `--renderer libghostty-vt` only when you intentionally want semantic and screenshot requests routed through the native backend; WebM requests still record `ghostty-web` as the actual video producer.

`ghostty-web` provides reference visual truth for reviewable artifacts; it does not promise exact pixel parity with native terminals.
Expand Down
124 changes: 119 additions & 5 deletions src/cli/commands/record-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import type { CommandContext } from '../context.js';

import { emitSuccess } from '../output.js';
import { generateAsciicast } from '../../export/asciicast.js';
import { renderGridFramesToSvg } from '../../export/svg.js';
import {
generateWebmExport,
type WebmExportResult,
} from '../../export/webm.js';
import { captureGridFrames } from '../../replay/gridFrames.js';
import { readEventLogRecords } from '../../storage/eventLogCodec.js';
import { hashProfile, resolveProfile } from '../../renderer/profiles.js';
import { CliError } from '../errors.js';
Expand Down Expand Up @@ -48,7 +50,7 @@ import {
import { invariant } from '../../util/assert.js';
import { loadPackageMetadata } from '../../util/packageMetadata.js';

const RecordExportFormatSchema = z.enum(['asciicast', 'webm']);
const RecordExportFormatSchema = z.enum(['asciicast', 'webm', 'svg']);

type RecordExportFormat = z.infer<typeof RecordExportFormatSchema>;

Expand All @@ -62,6 +64,7 @@ interface CommandOptions {
out?: string;
profile?: string;
timing?: string;
animate?: boolean;
}

function resolveRecordExportFormat(
Expand All @@ -71,7 +74,7 @@ function resolveRecordExportFormat(

if (!formatResult.success) {
throw makeCliError(ERROR_CODES.INVALID_INPUT, {
message: 'Record export format must be one of: asciicast, webm.',
message: 'Record export format must be one of: asciicast, webm, svg.',
details: {
format,
},
Expand Down Expand Up @@ -105,7 +108,7 @@ function resolveReplayTimingMode(
return timingResult.data;
}

function resolveWebmProfileName(
function resolveRenderProfileName(
commandProfile: string | undefined,
contextProfileDefault: string | undefined,
): string | undefined {
Expand Down Expand Up @@ -137,12 +140,13 @@ async function resolveOutputPath(
capturedAtSeq: number,
format: RecordExportFormat,
outputPath: string | undefined,
filenameVariant?: string,
): Promise<string> {
if (outputPath === undefined) {
await ensureArtifactsDir(sessionDirectory);
return artifactPath(
sessionDirectory,
recordingFilename(capturedAtSeq, format),
recordingFilename(capturedAtSeq, format, filenameVariant),
);
}

Expand Down Expand Up @@ -201,6 +205,29 @@ export async function runRecordExportCommand(
options: CommandOptions,
): Promise<void> {
const format = resolveRecordExportFormat(options.format);

if (options.animate === true && format !== 'svg') {
throw makeCliError(ERROR_CODES.INVALID_INPUT, {
message: '--animate is only supported with --format svg.',
details: {
format,
},
});
}

// Animated SVG always replays with recorded event-log timing; there is no
// timing mode to choose.
if (format === 'svg' && options.timing !== undefined) {
throw makeCliError(ERROR_CODES.INVALID_INPUT, {
message:
'--timing is not supported with --format svg; animated SVG always uses recorded timing.',
details: {
format,
timing: options.timing,
},
});
}

const timingMode = resolveReplayTimingMode(options.timing);
const home = options.context.home;
let sessionDirectory: string;
Expand Down Expand Up @@ -234,11 +261,28 @@ export async function runRecordExportCommand(
const eventsFile = eventLogPath(sessionDirectory);
const events = await readEventLogRecords(eventsFile);
const defaultCapturedAtSeq = resolveCapturedAtSeq(events);
// SVG exports at the same seq produce different content per render
// profile and animation mode, so those must be part of the default
// filename to keep exports from overwriting each other.
const svgProfileName =
format === 'svg'
? (resolveRenderProfileName(
options.profile,
options.context.profileDefault,
) ?? 'reference-dark')
: undefined;
const filenameVariant =
svgProfileName === undefined
? undefined
: options.animate === true
? `${svgProfileName}-animated`
: svgProfileName;
const artifactOutputPath = await resolveOutputPath(
sessionDirectory,
defaultCapturedAtSeq,
format,
options.out,
filenameVariant,
);

invariant(
Expand Down Expand Up @@ -305,8 +349,78 @@ export async function runRecordExportCommand(
bytes = contentsBuffer.byteLength;
invariant(bytes > 0, 'asciicast export artifact must not be empty');
sha256 = createHash('sha256').update(contentsBuffer).digest('hex');
} else if (format === 'svg') {
invariant(
svgProfileName !== undefined,
'svg profile name must be resolved before the svg export branch',
);
const resolvedProfile = resolveProfile(svgProfileName);
const renderProfileHash = hashProfile(resolvedProfile);
const animate = options.animate === true;
Comment thread
ThomasK33 marked this conversation as resolved.

// An empty event log (running-but-silent session) is valid: it exports
// the manifest-defined blank initial grid.
const capture = await captureGridFrames({
sessionId: options.sessionId,
manifest,
events,
profile: resolvedProfile,
mode: animate ? 'timeline' : 'final',
});
const svgContents = renderGridFramesToSvg({
profile: resolvedProfile,
frames: capture.frames,
animate,
});
const contentsBuffer = Buffer.from(svgContents, 'utf8');

capturedAtSeq = capture.capturedAtSeq;
durationMs = animate ? capture.timelineDurationMs : 0;
artifactKind = 'recording';
artifactMetadata = {
format,
outputPath: artifactOutputPath,
width: capture.cols,
height: capture.rows,
profileName: svgProfileName,
renderProfileHash,
rendererBackend: capture.rendererBackend,
animated: animate,
frameCount: capture.frames.length,
outputEventCount: capture.outputEventCount,
resizeEventCount: capture.resizeEventCount,
};
resultMetadata = {
width: capture.cols,
height: capture.rows,
profileName: svgProfileName,
renderProfileHash,
rendererBackend: capture.rendererBackend,
animated: animate,
frameCount: capture.frames.length,
outputEventCount: capture.outputEventCount,
resizeEventCount: capture.resizeEventCount,
};

if (options.out === undefined) {
invariant(
capturedAtSeq === defaultCapturedAtSeq,
'default svg artifact path seq must match exported seq',
);
}

await writeTextFileAtomic({
path: artifactOutputPath,
pathLabel: 'record export path',
contents: svgContents,
writeErrorMessage: `Failed to write record export artifact at ${artifactOutputPath}.`,
});

bytes = contentsBuffer.byteLength;
invariant(bytes > 0, 'svg export artifact must not be empty');
sha256 = createHash('sha256').update(contentsBuffer).digest('hex');
} else {
const webmProfileName = resolveWebmProfileName(
const webmProfileName = resolveRenderProfileName(
options.profile,
options.context.profileDefault,
);
Expand Down
12 changes: 11 additions & 1 deletion src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -855,13 +855,21 @@ async function main(): Promise<void> {
recordCommand
.command('export <session-id>')
.description('Export a recorded session artifact')
.requiredOption('--format <format>', "Export format: 'asciicast' or 'webm'")
.requiredOption(
'--format <format>',
"Export format: 'asciicast', 'webm', or 'svg'",
)
.option('--out <path>', 'Explicit output path')
.option('--profile <name>', 'Render profile name')
.option(
'--timing <mode>',
'Replay timing mode for WebM: recorded (default), accelerated, max-speed',
)
.option(
'--animate',
'Animate the SVG export with recorded timing (only with --format svg)',
false,
)
.option('--json', 'Emit a JSON command envelope', false)
.action(
wrapAction(
Expand All @@ -873,6 +881,7 @@ async function main(): Promise<void> {
out?: string;
profile?: string;
timing?: string;
animate: boolean;
json: boolean;
},
context: CommandContext,
Expand All @@ -882,6 +891,7 @@ async function main(): Promise<void> {
json: options.json,
sessionId,
format: options.format,
animate: options.animate,
...(options.out !== undefined ? { out: options.out } : {}),
...(options.profile !== undefined
? { profile: options.profile }
Expand Down
Loading
Loading