Skip to main content

README.md

# TTYCast

TTYCast records terminal sessions into seekable, compressed `.ttycast` files for Elixir and Erlang applications.

It is useful when you need more than a plain terminal log: fast snapshots, timestamp seeking, safe input handling, and application-specific timeline events.

## Why TTYCast?

- **Seekable replay** — recordings are split into compressed chunks with Ghostty terminal keyframes.
- **Small files** — chunks are gzip-compressed and indexed in the same container.
- **Safe by default** — input is redacted unless raw input is explicitly enabled.
- **Real terminal behavior** — command recording runs under a real PTY via Ghostty.
- **Structured events** — host apps can record semantic markers and custom streams next to terminal bytes.
- **Portable export** — export terminal I/O to asciinema v2 JSONL when needed.

## Install

```elixir
def deps do
  [
    {:ttycast, "~> 0.1.0"}
  ]
end
```

## Record a command

```sh
mix ttycast.record --output /tmp/demo.ttycast -- sh -lc 'echo hello'
```

Inspect it:

```sh
mix ttycast.info /tmp/demo.ttycast
mix ttycast.snapshot /tmp/demo.ttycast
mix ttycast.find /tmp/demo.ttycast hello
```

Record an interactive command in your current terminal:

```sh
mix ttycast.rec --output /tmp/shell.ttycast -- bash
```

Raw input is not recorded by default. To opt in for disposable/debug sessions:

```sh
mix ttycast.rec --output /tmp/shell.ttycast --input raw -- bash
```

## Use from Elixir

For most applications, use scoped writer lifecycle:

```elixir
TTYCast.write("/tmp/demo.ttycast", [width: 120, height: 40], fn writer ->
  TTYCast.Writer.write(writer, "hello\r\n")
  TTYCast.Writer.marker(writer, :checkpoint, %{label: "first screen"})
end)
```

Read and seek:

```elixir
cast = TTYCast.open!("/tmp/demo.ttycast")

TTYCast.info(cast)
TTYCast.snapshot!(cast, time_ms: 1_000)
TTYCast.stream(cast) |> Enum.to_list()
TTYCast.export(cast, :asciinema, "/tmp/demo.cast")
```

Stream existing IO into a recording:

```elixir
TTYCast.write("/tmp/log.ttycast", [width: 120, height: 40], fn writer ->
  File.stream!("app.log")
  |> Enum.into(TTYCast.into(writer))
end)
```

## Input policy

TTYCast defaults to redacted input:

```elixir
TTYCast.Writer.input(writer, "secret")
# records {:input_redacted, t_us, 6}
```

Available policies:

- `:redacted` — store byte counts only. Default.
- `:raw` — store raw input bytes.
- `:none` — drop input events entirely.

Set policy when starting a writer:

```elixir
TTYCast.start_writer(path: path, width: 80, height: 24, input_policy: :none)
```

## Recovery

Writers maintain a live sidecar index while recording. If a process crashes before the final trailer/footer is written, TTYCast can still open the file through that live index when available.

If the live index is missing but chunks are intact, rebuild the trailer/footer:

```sh
mix ttycast.reindex /tmp/demo.ttycast
```

or:

```elixir
TTYCast.reindex("/tmp/demo.ttycast")
```

## Benchmarks

Run a local benchmark comparing `.ttycast`, asciinema JSONL, and gzipped asciinema JSONL sizes plus open/seek timings:

```sh
mix ttycast.bench --events 10000
```

## Format

See [`FORMAT.md`](FORMAT.md) for the binary container layout and event schema.