PtyZZZ
ptyZZZ
A terminal as a unix pipe: keystrokes go in as JSONL, and the screen comes out as HTML. You can then pipe that directly to a web page.
Install
·
Protocol
·
Discord
https://github.com/user-attachments/assets/b0d48f3b-2adc-4dd4-99e3-cf04bf8e5265
ptyZZZ runs a shell in a pty and emulates the terminal on the server, with wezterm-term. Keystrokes go in as JSONL on stdin. The screen, scrollback included, comes out as JSONL frames of rendered HTML.
JSONL commands ──> ptyZZZ ──> JSONL screen frames (stdin) pty + grid (stdout)
The browser gets finished HTML, so it needs no xterm.js or ghostty-web and holds no terminal state. One terminal can feed any number of viewers.
#Try it
eget cablehead/ptyZZZ # or see Install below printf '{"t":"input","b":"ls\\n"}\n' | ptyZZZ run -- nu
That types ls into a fresh nu and prints screen frames until stdin closes.
The browser view needs http-nu and a
nu on PATH. serve.nu spawns target/release/ptyZZZ if you have a source
build, otherwise ptyZZZ on PATH:
http-nu --dev --datastar --services --store ./store 127.0.0.1:5111 serve.nu
Open http://127.0.0.1:5111 and type into the page.
The bigger demo is examples/cube: six live terminals on a spinning CSS cube, all on one SSE connection. The front face is an interactive shell. examples/panes is a niri-style multiplexer: open, split, and close panes at runtime. examples/through is one full-screen pane that yaws to show the headless wezterm behind it, projecting HTML onto the page.
#Why the emulator lives on the server
The usual web terminal puts the emulator in the browser (xterm.js, now ghostty-web) and proxies raw pty bytes to it. That works until the tab closes. To survive disconnects, the server needs a session of its own, and the session has two hard jobs:
- Answer terminal queries (where is the cursor?) while no browser is attached. Programs block until something replies.
- Give a reconnecting browser the current screen. Replaying saved bytes fails: a ring buffer can cut in mid-escape-sequence, and the screen comes up scrambled.
Both jobs take a terminal emulator. tmux settled this long ago: its server parses everything into a grid and repaints the grid on attach. Clients never see raw shell output.
So the proxy design runs two full emulators, and wherever their parsers disagree the screen corrupts. ptyZZZ keeps one, next to the pty, and the browser just renders HTML.
stacks2099 has run this shape for a while; corrupted sessions stopped when the client emulator went away. Its journey.md covers the road here. ptyZZZ is the same design as a standalone process, with row-level damage tracking and diffs added to the renderer.
#Install
#eget
eget cablehead/ptyZZZ
#Homebrew (macOS)
# Homebrew now asks you to trust a third-party tap before installing from it brew trust --formula cablehead/tap/ptyzzz # or if you use a few of cablehead's projects, and trust me, the whole tap # brew trust cablehead/tap brew install cablehead/tap/ptyzzz
#From source
cargo build --release # binary lands at target/release/ptyZZZ
Prebuilt binaries (macos-arm64, linux-arm64, linux-amd64) are on the releases page, built by the shared cablehead/pipelines workflow.
#The protocol
Both directions are newline-delimited JSON, one object per line. Commands in:
{"t":"input","b":"ls\n"} raw bytes for the pty
{"t":"resize","cols":80,"rows":24}
{"t":"screen"} emit a keyframe now
Screen out:
{"t":"screen","seqno":N,"cols":C,"rows":R,"html":"<div id=\"grid\"...>"}
{"t":"diff","seqno":N,"target":"grid","patch":"...","append":"...","trim":["grid-r-0"]}
{"t":"exit","code":N}
screen is a keyframe: the visible grid plus scrollback (--scrollback,
default 3000 lines), one row div per line, with a cursor overlay. diff
carries only what changed: patched rows, rows newly scrolled into history, and
ids of rows that fell off the top.
An idle shell emits nothing: damage is tracked per row and unchanged output is
suppressed. Bursts coalesce over a 16ms window (--coalesce), so cat big.txt becomes one frame. PROTOCOL.md has the full wire
format.
ptyZZZ knows nothing about HTTP or cross.stream; it is a plain stdin/stdout program. The rest of this README wires it to cross.stream, which puts the screen on a log many readers can follow. The adapter is a few lines of Nushell.
#Why on a stream
stacks2099 renders a pty server-side already, but each terminal opens its own SSE connection. A handful of terminals plus keystroke POSTs hits the browser's ~6-connection limit on HTTP/1.1, and input stalls. HTTP/2 avoids the limit but needs TLS.
A log removes the limit a different way: each terminal is a topic, and one connection carries every topic you subscribe to.
#As a cross.stream service
cross.stream services run a Nushell closure as a
long-lived process. With duplex: true, frames appended to <name>.send feed
the closure's stdin. This adapter is the only cross.stream-aware code in the
project:
{
run: {||
^ptyZZZ run -- nu
| lines | each {|l|
let e = try { $l | from json } catch { null }
if $e == null { return }
match $e.t {
'screen' => ( $e.html | .append 'pty.screen' --ttl last:1 )
'diff' => ( null | .append 'pty.diff' --ttl ephemeral --meta {body: $l} )
'exit' => ( {code: $e.code} | to json | .append 'pty.exit' --ttl last:1 )
_ => null
}
} | ignore
}
duplex: true
}
Each line is matched on t and appended to a topic. Keyframes go to
pty.screen with ttl last:1; that stored frame is where a new subscriber
starts. Diffs go to pty.diff as ephemeral frames: live followers see them,
nothing is stored.
Diffs ride in frame meta rather than the CAS. A CAS write for a frame that is
never stored would be disk I/O nobody reads, and skipping it cuts the append
from ~30us to under 1us. The closure ends with | ignore so cross.stream
doesn't copy raw output onto a default .recv topic.
One pitfall: the external command must be the head of the pipeline.
$in | ^ptyZZZ run -- nu deadlocks, because $in collects its whole input
first and a duplex input stream never ends.
The web tier is a reader. The page opens one /sse and follows both topics: a
keyframe is one morph of #grid; a diff patches changed rows by id, appends
new rows, and removes expired ones. Keystrokes POST to /input, which appends
a pty.send frame.
sequenceDiagram
autonumber
participant Browser
participant DS as Datastar
participant HTTP as http-nu
participant XS as cross.stream
participant Svc as service
participant Pty as ptyZZZ
participant Sh as nu
Note over Svc,Sh: service spawns ptyZZZ once, wezterm owns the grid
Note over Browser,HTTP: client attaches
Browser->>DS: data-init @get /sse
DS->>HTTP: GET /sse
HTTP->>XS: follow pty.screen + pty.diff
XS-->>HTTP: replay last keyframe
HTTP-->>DS: datastar-patch-elements
DS->>Browser: morph #grid
Note over Browser,Sh: keystroke
Browser->>HTTP: POST /input, plain fetch
HTTP->>XS: append pty.send
XS->>Svc: frame to closure stdin
Svc->>Pty: JSONL line to ptyZZZ stdin
Pty->>Sh: bytes via pty master
HTTP-->>Browser: 204
Note over Sh,Browser: output
Sh->>Pty: bytes via pty master
Pty->>Pty: grid mutates, 16ms coalesce
Pty->>Svc: screen or diff frame on stdout
Svc->>XS: append pty.screen / pty.diff
XS-->>HTTP: follow yields frame
HTTP-->>DS: datastar-patch-elements
DS->>Browser: patch #grid
#What goes on the log
A screen can go on the log as full grids, as diffs, or as keyframes with diffs between them.
Full grids bloat the log on every keystroke. Diffs alone can't survive a cold
replay: they refer to wezterm's in-memory row ids, which never reach the log.
Keyframes plus diffs work: the stored keyframe (ttl last:1) is where a
subscriber starts, and diffs are ephemeral. While diffs flow, a fresh keyframe
goes out every --keyframe-interval seconds (default 5), so a missed diff
heals within one interval.
The stored keyframe can be that stale too, and a joiner would otherwise wait
out the interval to catch up. So the /sse handler sends {"t":"screen"} to
each pane once the replay is done and the follow is live (xs marks that point
with an xs.threshold frame), and a fresh keyframe lands within one coalesce
window. Diffs are ephemeral, so the request must not go out any earlier.
The 16ms window caps output near 62 frames per second per terminal. A full repaint (htop, a vim redraw) is the worst case for diffs and the best case for a keyframe, so ptyZZZ picks per burst: start, resize, alt-screen flips, and repaints that touch more than half the rows ship a keyframe. Everything else ships a diff.
#HTML, not JSON
The frame body is rendered HTML, not a list of cells. One writer serves many
readers, so the render happens once, at the writer, and every reader just
forwards bytes. Storing cells would mean each /sse connection rebuilds the
HTML itself, in Nushell, on every frame. JSON is smaller on disk, but Brotli
closes most of that gap on the wire, and the render cost would still repeat
per connection.
#Key by the session, not the clip
The topics here are fixed names. A multi-terminal app wants topics keyed by the pty's session: a closed pty's screen stays replayable, and respawning a pane is just the web tier switching topics. ptyZZZ handles one pty; sessions belong a layer above it.
#Driving it over HTTP
Anything that can make an HTTP request can type into the terminal.
POST /input forwards its body to the pty verbatim, so a command and the
enter that submits it are two writes:
curl -X POST 127.0.0.1:5111/input --data-binary 'ls -la' curl -X POST 127.0.0.1:5111/input --data-binary $'\r'
Control characters work the same way. Ctrl-C is \x03, Escape is \x1b:
curl -X POST 127.0.0.1:5111/input --data-binary $'\x03' # interrupt
Read the current screen once, or follow the live stream:
# latest frame, tags stripped to plain text curl -s 127.0.0.1:5111/snap | sed 's/<[^>]*>/ /g' # the SSE stream the browser uses curl -sN 127.0.0.1:5111/sse
The browser page uses the same path: keystrokes POST to /input, and frames
morph into #grid.