Tracing Visual Bugs
A static screenshot captures one moment. The frames projection of a Recording captures every render-relevant moment — so a visual bug becomes investigable in one pass instead of a five-round-trip "does this look right?" interaction.
This is the answer to questions like "the border disappeared after frame 47 — what ANSI input caused it?"
When to use it
- Diagnosing layout flicker, intermittent border drops, mis-aligned glyphs.
- Reproducing TUI race conditions tied to a specific render sequence.
- Generating regression baselines for visual diffing.
The frames projection
When you record --frames (or set Frames in a .tape), termless populates the Recording's frames track: every render-relevant buffer change is captured as a debounced PNG plus a JSONL row carrying a timestamp, content hash, cursor position, and bytes-in delta.
Frames is a projection — a derived view of io × Renderer. The visual part can be regenerated; the capture metadata (dirty regions, ANSI preview) is recorded alongside. Identical buffer states (by xxHash64) skip the PNG and record duplicate_of instead, to save disk.
Capturing frames
Via the CLI
$ termless record --frames -o trace.rec bun km view ~/VaultVia a .tape
Set Frames "/tmp/my-trace/"
Set FrameDebounceMs 16
Set Width 140
Set Height 40
Type "echo hello"
Enter
Sleep 500msWhen the tape runs, the executor wires up frame capture automatically and exposes the summary on result.frameTrace.
Programmatically
import { createTerminal, createFrameTracer } from "@termless/core"
import { createXtermBackend } from "@termless/xtermjs"
let tracer: ReturnType<typeof createFrameTracer> | null = null
const terminal = createTerminal({
backend: createXtermBackend({ cols: 140, rows: 40 }),
cols: 140,
rows: 40,
onAfterWrite: (data) => tracer?.onWrite(data),
})
tracer = createFrameTracer(terminal, {
dir: "/tmp/my-trace/",
debounceMs: 16, // one frame interval — default
maxFrames: 10_000, // safety cap — default
dedupe: true, // skip PNG for identical hashes — default
canvas: { cols: 140, rows: 40 },
})
await terminal.spawn(["bash", "-lc", "your-app"])
// Poll mid-run:
const frames = tracer.framesSinceSeq(0)
// Drain and stop:
const summary = await tracer.stop()
// { count, uniqueCount, duplicateRatio, indexFile, firstTs, lastTs, totalBytes, truncated }The frames land in the frames/ subtree of a .rec directory (or a bare frame-trace directory) — index.jsonl plus NNNNN.png. See the .rec format reference for the row schema.
Scrubbing frames — termless view
termless view opens a recording's frames in a self-contained, scrubbable viewer.html — no server, no install:
$ termless view ./my-traceThe viewer writes viewer.html alongside the recording and inlines everything (JSONL rows as a JSON blob, every PNG as a base64 data URI) so it works from file:// with no cross-origin fetch.
my-trace/
index.jsonl
00001.png
00002.png
viewer.html ← generated by `termless view`The viewer offers:
- Horizontal timeline — one tick per frame; deduped frames dimmed, idle gaps shown as wider gray bars.
- Click to select — a frame's PNG, ANSI-input preview, silvery state (when present), and bytes-in delta vs. the previous frame.
- Keyboard scrub — arrow keys plus vim-style
j/k;Home/Endjump to the ends. - Find — text search across the frames' ANSI-input previews.
- Filter — a free-text predicate over each row's JSON.
- Diff mode — press
d, pick two frames, pixel-diff them in-browser with a red overlay on changed pixels.
A ~1000-frame trace scrubs smoothly because all images are inlined up front.
Design notes
- Dedupe via xxHash64 — Bun's built-in
Bun.hash.xxHash64, with an FNV-1a fallback for Node. Collision rate is fine for a 10k-frame trace. - Debounce, not per-cell —
debounceMs: 16produces frame-per-render-pass, not frame-per-cell-write. A 100ms output burst that ends stable yields one frame. - maxFrames cap — the session continues but no new frames are written past the cap; the summary reports
truncated: true. - Streaming-readable index — append-only JSONL means a partial trace from a crashed session is readable up to the last fully-flushed row.
MCP surface
The termless MCP server exposes frame capture to AI agents — start a session with a trace directory, read frames with the trace tool, finalize with stop:
mcp__tty__start({
command: ["bun", "km", "view", "~/Vault"],
cols: 140,
rows: 40,
trace: { dir: "/tmp/trace-15290/", debounceMs: 16 },
})
mcp__tty__trace({ sessionId, since: 0 }) // poll live frames
mcp__tty__stop({ sessionId }) // returns the recording summarySee the MCP reference for the full tool list.
See Also
- Recording -- the frames projection in context.
- Recording Sessions -- record / play / view / compare how-to.
- .rec format -- the on-disk frames layout.