Skip to content

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

bash
$ termless record --frames -o trace.rec bun km view ~/Vault

Via a .tape

tape
Set Frames "/tmp/my-trace/"
Set FrameDebounceMs 16
Set Width 140
Set Height 40

Type "echo hello"
Enter
Sleep 500ms

When the tape runs, the executor wires up frame capture automatically and exposes the summary on result.frameTrace.

Programmatically

ts
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:

bash
$ termless view ./my-trace

The 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.

text
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 / End jump to the ends.
  • Find — text search across the frames' ANSI-input previews.
  • Filter — a free-text predicate over each row's JSON.
  • Diff modepress 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-celldebounceMs: 16 produces 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:

ts
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 summary

See the MCP reference for the full tool list.

See Also