---
url: /guide/tracing-visual-bugs.md
description: >-
  Capture every render-relevant frame of a terminal session and scrub them to
  find exactly where a TUI rendering bug appears.
---

# Tracing Visual Bugs

A static screenshot captures one moment. The **frames** projection of a
[Recording](../concepts/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](../concepts/recording#the-three-tracks): 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.ttyz -- 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 [`.ttyz`](../reference/formats/tty)
directory (or a bare frame-trace directory) — `index.jsonl` plus `NNNNN.png`.
See the [`.tty`/`.ttyz` format reference](../reference/formats/tty)
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 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: 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](../reference/mcp) for the full tool list.

## See Also

* [Recording](../concepts/recording) -- the frames projection in context.
* [Recording Sessions](./recording-sessions) -- record / play / view / compare how-to.
* [.tty/.ttyz format](../reference/formats/tty) -- the on-disk frames layout.
