Skip to content

Feature: terminal recorder consumer (output snapshot/replay) #7

Description

@pi0x

Summary

Add a first-class scrollback / serialize consumer (a "terminal recorder") that captures recent PTY output so it can be persisted and replayed later — e.g. to restore a session across a process restart, or to hand a reconnecting client the recent screen state.

A PTY is just a byte stream with no notion of scrollback, so today every consumer that wants this has to hand-roll a rolling buffer in onData. The IPtyConsumer interface already anticipates this use case in its own doc:

A sink that consumes PTY output… Anything with a feed(data) method conforms — including OSCInspector, a terminal recorder, a logger, etc.

This proposes shipping that recorder as a sibling of zigpty/osc and zigpty/idle, riding the same pty.attach(consumer) seam.

Motivation

The "keep the last N of output around" pattern recurs anywhere a PTY outlives a single viewer:

  • Restore a terminal's screen after the owning process restarts.
  • Replay recent output to a client that (re)attaches to an already-running PTY.
  • Persist a lightweight session log.

Each consumer reimplements the same rolling buffer, and — importantly — most reimplement it incorrectly (see below).

Design

Two levels, and I think the interesting question is which one belongs in the library.

Level 1 — raw byte ring (what people write today)

import { spawn } from "zigpty";
import { Recorder } from "zigpty/recorder";

const pty = spawn(shell, [], opts);
const rec = new Recorder({ maxBytes: 1_000_000 }); // rolling window of raw output
pty.attach(rec);

// later:
const snapshot = rec.buffer;          // raw bytes, ANSI escapes and all
// on a new PTY, replay `snapshot` into the emulator/client to redraw

Simple, and it matches the common hand-rolled approach. But a raw last-N-bytes window is subtly wrong: the cut can land in the middle of an escape sequence, or start inside alt-screen / a cursor-positioning run. Replaying such a slice produces a garbled first line, wrong colors, or a misplaced cursor. It's "good enough" for rough scrollback continuity but not correct, and every hand-rolled copy inherits the bug.

Level 2 — VT-state serialize (the version worth centralizing)

A consumer that maintains the actual terminal grid + scrollback (a headless VT parser) and emits a coherent snapshot, the way xterm.js's SerializeAddon does:

const rec = new Recorder({ cols, rows, scrollback: 1000 });
pty.attach(rec);            // also implements onResize → keeps the grid in sync

const snapshot = rec.serialize();  // escape sequences that reconstruct the current screen cleanly

Advantages over the byte ring:

  • No mid-sequence / alt-screen replay glitches — the snapshot always parses cleanly.
  • Memory bounded by rows × cols × scrollback instead of an arbitrary byte count.
  • Naturally tracks resizes via the existing onResize hook on IPtyConsumer.

Given zigpty is Zig-first and cross-platform, a small VT state machine could live on the Zig side and be shared, but even a JS-side consumer would be valuable as a correct, reusable default.

Questions

  • Is a recorder in-scope for the core package, or better as a separate companion (mirroring how osc/idle are subpath exports today)?
  • If in-scope: ship the raw ring, the VT-serialize version, or both (ring as the trivial case, serialize as the correct one)?
  • Any appetite for the VT parser living in Zig vs. JS?

Happy to prototype a JS-side SerializeAddon-style consumer against the current IPtyConsumer API to de-risk the shape if that's useful.


Disclaimer: this idea was drafted with the help of an AI assistant (Claude). The proposal and framing were reviewed by a human before filing, but the wording is largely AI-generated.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions