phux
guides

Recording a session

How to record a phux pane or a whole attached session and export it as an asciinema cast, an animated GIF, or an APNG, and how to play a cast back as a live...

evolving document
Full source summary

How to record a phux pane or a whole attached session and export it as an asciinema cast, an animated GIF, or an APNG, and how to play a cast back as a live pane. Two capture surfaces share one set of rules: a headless observer that never touches the pane it watches, and a tee on the interactive client's own composited output. No external tools are involved.

A phux recording, recorded with phux


1. The two surfaces

phux rec [TARGET] -o PATH records one pane, headlessly, from outside. It subscribes as a pure observer: it does not attach the session and never resizes the pane, so it is safe to run against a session a human is using right now. This is the one to reach for when you want a clean artifact of a single pane, when you want to script a recording, or when you are not the person sitting in the session. It talks to a local server over the UDS (--socket overrides the path); it does not dial QUIC or WebSocket.

phux rec -o demo.gif                       # the focused pane, until Ctrl-C
phux rec work:1.0 -o demo.cast --duration 30
phux rec @7 -o demo.png --fps 20           # .png means APNG

phux --rec PATH records the session you are attached to, as you use it. It tees the client’s own composited output stream, so the recording contains exactly what your glass received: panes tiled per the layout, dividers, the status bar, the sidebar, overlays, and the cursor. The flag applies to phux and phux attach only; on any other verb it is an error that points you at phux rec.

phux --rec demo.gif                        # attach and record the whole session
phux attach work --rec demo.cast
phux --rec demo.out --rec-format gif       # explicit format wins; path as typed

Ctrl-C during a headless capture is a success: you asked the recording to stop, and what was captured up to that point is written and exported. The interactive surface finishes when the session detaches, and prints its one-liner only after the alt screen is down.

2. What a recording is not

Worth knowing before you record something long, rather than after.

  • Timing is the server’s paint cadence, not your keystrokes. The server coalesces PTY bytes at its output pacing rate (default 60 Hz) before it emits them, and the wire carries no per-byte timestamp. Sub-frame cadence is therefore not recoverable: a fast flood replays chunkier than it looked live. The interactive surface has the same limit one layer up, because the client coalesces bursts of frames into one paint.
  • A recording opens on the viewport, not the history above it. The observer’s priming snapshot is requested without scrollback, so a recording started mid-session begins with the screen as it stands, not with what scrolled past before you started.
  • Glyph coverage is narrow. The animation is drawn with a 1-bit bitmap face: Latin, Greek, Cyrillic, Braille, box drawing, and Powerline. CJK and other wide glyphs and color emoji render as tofu boxes. This is a deliberate and permanent consequence of the encoder design (ADR-0060) — it is what keeps GIF quantization lossless — not a gap waiting to be filled. A .cast has no such limit; only the rendered animation does.

Two more worth knowing: input is never recorded (no keystroke events, on either surface, with no opt-in flag — passwords do not belong in a recording), and kitty-graphics images do not survive a re-render, because the replayer draws cells and an image is not one.

3. Formats

The output extension picks the format, case-insensitively:

ExtensionFormatNotes
.castasciinema castThe archival artifact. Text, diffable, small.
.gifanimated GIFShareable and embeddable with no player.
.png, .apnganimated PNGTruecolor, no palette, 1 ms timing.
(none)animated GIF.gif is appended to the path you gave.

--format (or --rec-format on the interactive surface) overrides the extension outright and leaves the path exactly as you typed it. An extension phux does not recognize is an error naming the three it does — it will not silently write a demo.mp4 that is not an MP4.

The .cast is the source of truth and the animation is a derivative. Every export goes through a cast, even when you asked for a GIF; for a GIF or an APNG it is an intermediate in the temp directory, deleted once the artifact lands. If the render fails, that intermediate is deliberately kept and its path printed, so a forty-minute capture is never lost to an encoder bug. Keep the cast when the recording matters and re-render it later:

phux rec --from demo.cast -o demo.gif --fps 20 --idle-limit 1.5
phux rec --from demo.cast -o smaller.gif --max-bytes 2000000

--from never touches the server. It is a pure offline re-render, and it also transcodes: --from v2.cast -o v3.cast --cast-version 3.

asciicast version

The default is v2. asciicast v3 is not backward compatible with v2 — the header schema changed and event times became relative intervals — so a v2-only reader that tolerates a v3 header plays a four-minute recording in a fraction of a second. v2 is read by asciinema CLI 2.x and 3.x, player 2.6 and later, and server builds back to 2017; v3 needs CLI 3.0, player 3.10.0, and a 2025 server. There is no consumer that reads v3 but not v2. Pass --cast-version 3 when you know your reader is new enough and you want it.

4. Tuning the capture and the render

FlagDefaultWhat it does
--fps N10Sample rate, snapped to the nearest of 5, 10, 20, 25, 50. Those are the rates whose period divides 1000 ms exactly, which is what keeps timing drift-free and GIF delays exact in centiseconds. --fps 30 therefore records at 25.
--idle-limit SECS2.0Collapse any pause longer than SECS down to SECS. 0 disables.
--max-bytes N8388608Stop encoding at this size, close the container cleanly, and report the artifact as truncated.
--duration SECS(none)Stop the capture after SECS. Without it, recording runs until Ctrl-C or the pane exits.

The idle clamp is applied once, to the shared event list, before both the cast write and the render — so the .cast and the GIF derived from it can never disagree about how long a pause was.

--fps is the main size lever. An idle terminal costs no frames at all (a sampled frame with nothing dirty extends the previous frame’s delay instead of emitting a new one), so the cost of a higher rate is paid only by the parts of the recording that are actually moving.

The interactive --rec surface has none of these knobs: it renders with the defaults. If you want a different rate, keep the .cast and re-render with --from.

5. Output

On success, one line on stdout:

phux: wrote demo.gif (184.3 KiB, 211 frames, 42.1s)

With --json, one object on stdout and nothing else. The shape is documented in agents.md §4.14. Progress (recording... 12s (340 events)) goes to stderr and only on the headless surface, and is suppressed under --json; the interactive surface says nothing at all while the session is up, because it owns the alt screen.

6. Playing a recording back

phux play FILE.cast [TARGET] creates a pane whose PTY is fed from the recording, and prints its Terminal id. It does not paint the cast onto the terminal you are typing in — for that, asciinema play FILE.cast is the right tool and needs no phux server at all.

phux play demo.cast                        # a pane beside the focused one
phux play demo.cast work:1.0 --speed 2     # twice as fast, beside that pane
phux play demo.cast --loop --idle-limit 0.5
phux play demo.cast --json                 # {"schema_version": 1, "terminal_id": 7, ...}

The point is what the result is: an ordinary pane. Attach to it, read its grid with phux snapshot @7, resize it with phux resize @7 100x30, watch it from an agent over the same observer subscription phux rec uses, share it with a second client, re-record it, or end it with phux kill @7. Everything already aimed at panes works, because it is not a special object.

TARGET says where the pane goes, never what gets overwritten. The playback pane is created beside TARGET, splitting its window exactly as phux spawn --target does; TARGET itself is untouched. The default is ., the focused pane, so a playback appears next to whatever you are looking at. There is no flag that plays into a pane that already has a shell in it — a pane’s process is its identity, and the only way to give one to a recording would be to kill the shell first, which is phux kill spelled confusingly.

FlagDefaultWhat it does
--speed N1Divides wall-clock time: 2 is twice as fast, 0.5 half. Between 0.01 and 100. No events are dropped, merged, or resampled at any speed.
--idle-limit SECS(the recording’s own)Collapse pauses longer than SECS. Defaults to the idle_time_limit the cast declares, so playback agrees with the recorder that wrote it; 0 plays the raw timeline.
--loop [N](one pass)Bare --loop repeats until the pane is killed; --loop N plays it N times. Between passes the screen is soft-reset and cleared, never fully reset — a full reset would drop the grid size and make pass two wrap differently from pass one.
--no-fit(off)Leave the pane’s grid alone. See below.
--close(off)Close the pane when playback ends instead of holding the final frame.
--split, --ratiohorizontal, 0.5Placement of the new pane, as on phux spawn.

The pane is fitted to the recording. Before the first byte, the pane is resized to the cast header’s grid, and each r event in the recording is applied the same way. A cast is bytes that were correct at one geometry: played into a narrower pane, wrapped lines wrap in the wrong places and absolute cursor addresses land somewhere else, and the result is not approximate but unreadable. When the resize does not hold — an attached client’s viewport owns a pane’s size under every defaults.window-size policy but manual, see tui.md §4.2 — playback says so in one line and plays anyway. --no-fit suppresses the header fit and the recorded resizes alike.

When the recording ends, the pane holds its final frame until you kill it. That is deliberate: the painted screen is the artifact, and a pane that erased itself on the last byte would make phux snapshot a race. --close ends the pane instead, and Ctrl-C in an attached playback pane stops it.

Only o (output) and r (resize) events drive a pane. Recorded input (i), markers (m), and the recorded exit status (x) are read and ignored — replaying input would type a recording’s keystrokes into a live PTY, which is not what anyone means by “play”.

There is no pause, no seek, and no scrubbing. Adding them would make this the shell-level player ADR-0064 deliberately does not build.

7. Where this fits

Recording adds nothing to the wire. It rides the ATTACH_TERMINAL observer subscription that ../spec/L1.md §5.1 already specifies — snapshot, then deltas, no session attach, no resize — and the GIF and APNG encoders are in-process, so phux rec works on a machine with no agg, no vhs, and no ffmpeg. The reasoning, and the design spaces it closes, are in ADR-0060.

Playback adds nothing to the wire either. SPAWN_TERMINAL already carries a command, the server already tells a spawned pane its own id and socket, and TERMINAL_RESIZE already exists — so the pane’s “process” is simply the phux binary re-invoked in a mode that writes a cast to its own stdout. ADR-0064 has the reasoning, including why the shell-level player stays unbuilt.

View exact source