A terminal program for serial ports, with a real terminal emulator, scrollback and built-in XMODEM, YMODEM, ZMODEM and Kermit transfers.
  • Zig 96.2%
  • Nix 3.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie cc8707989f
All checks were successful
test / test (push) Successful in 9m21s
test / development (push) Successful in 12m5s
test / release (push) Successful in 0s
Say why the ghostty fork needs nothing more than --fork=
The zon2nix README adds -fno-sys for simdutf and highway, and a real copy
in place of symlinks for simdutf's headers. Neither applies with SIMD
off, which builds neither library; and Zig refusing a fork that matches
nothing is what keeps the list honest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MfnPVdTHsWUXhKnDGxsSAE
2026-10-20 18:39:01 -05:00
.forgejo/workflows Move to Zig 0.17.0 2026-10-20 17:21:51 -05:00
examples Log the wire, and snapshot the screen beside it 2026-09-22 22:47:43 -05:00
LICENSES Port picocom to Zig 0.16 2026-09-02 11:47:15 -05:00
src Move to Zig 0.17.0 2026-10-20 17:21:51 -05:00
.gitignore Package with Nix, fetching Zig dependencies through zon2nix 2026-09-08 14:24:37 -05:00
build.zig Move to Zig 0.17.0 2026-10-20 17:21:51 -05:00
build.zig.zon Move to Zig 0.17.0 2026-10-20 17:21:51 -05:00
build.zig.zon.nix Move to Zig 0.17.0 2026-10-20 17:21:51 -05:00
flake.lock Move to Zig 0.17.0 2026-10-20 17:21:51 -05:00
flake.nix Move to Zig 0.17.0 2026-10-20 17:21:51 -05:00
package.nix Say why the ghostty fork needs nothing more than --fork= 2026-10-20 18:39:01 -05:00
README.md Move to Zig 0.17.0 2026-10-20 17:21:51 -05:00
REUSE.toml Move the repository home to git.jcollie.dev 2026-09-08 14:27:29 -05:00

zettacom

A terminal program for serial ports.

Bytes from the port go into a real terminal emulator rather than straight at your screen. That is what buys a scrollback you can search, a status line that port output cannot scribble over, and keystrokes spelled for the modes the far end has actually set. File transfers are built in — XMODEM, YMODEM, ZMODEM and Kermit — so nothing is forked, the port never changes hands, and a download can start by itself.

Zig 0.17, and four kinds of system: Linux, Windows, macOS and the BSDs. Commands are reached with Alt-a and a letter; see Commands. What each build can and cannot be said to do is set out under Windows and macOS and the BSDs.

The interface

A program that hands port output to your terminal never knows what is on the screen, because it never interpreted any of it — which is why such a program can have no scrollback of its own, no search, and nowhere to put a status line.

zettacom interprets it. Bytes from the port go into a real terminal emulator (libghostty-vt) with a screen, a scrollback and styles, and what that emulator holds is drawn with libvaxis under a status bar. Two terminals are in play: the emulated one, which is the far end's idea of a screen, and yours, which zettacom draws on.

The status bar

One line at the bottom that port output cannot reach, saying what the port is and what it is doing:

 /dev/ttyUSB0  115200 8N1  none  DTR RTS CTS  TX RX  LOG  ZMODEM  [Alt-a] for commands

The modem lines name only what is up, so a change is visible rather than having to be read. TX and RX light red when bytes are moving — every byte of them, transfers included, since a transfer is when the line is busiest. A light stays on for a quarter of a second after the last byte, which is both what makes it long enough to see and what keeps it cheap: the bar is redrawn when a light changes, not while it is on, so a chatty line costs a handful of redraws a second rather than one per frame. --activity-color changes the color; activity_color in the configuration file says the same.

LOG appears only when something is being recorded, and says which of the three things it is doing — LOG, LOG-PAUSED or LOG-FAILED. A log nobody started says nothing, which is the usual case. See Logging the traffic.

The bar turns a color of its own for the two states where what you type is not reaching the far end — the command prefix and the scrollback — and a transfer takes it over entirely while one runs. See Transfers.

Yours is /dev/tty, not stdin and stdout. libvaxis opens the terminal this process is attached to, puts it in raw mode, and puts it back on the way out; the keyboard is read from the same place. So redirecting output does not stop zettacom from drawing and does not send a screenful of escape sequences down the pipe.

It says nothing on your terminal. No banner, no settings dump, no "Terminal ready", nothing on the way out — the shell prompt you started from is where you come back to. Everything zettacom has to say goes on the status bar or into the scrollback, where it is next to the port output it is about and can be searched later. Only a failure it cannot recover from is written to your terminal, because by then there is nowhere else.

What it does need is a controlling terminal to open. Without one there is nothing to draw on and it says so rather than starting. --exit is the exception, since it draws nothing: it applies the settings, puts the init string on the wire and leaves, which is the one thing zettacom usefully does from a script.

Alt-a o walks back through what the far end has already said, and so does rolling the wheel back. The emulator keeps ten megabytes of it, which is hours of a chatty line.

While the scrollback is up it owns the keyboard, and nothing typed reaches the port — which is why leaving is bound to q and to escape, and why the status bar turns cyan and lists the keys:

Key
wheel Three lines at a time; one click past the end leaves
j k, ↓ ↑ A line at a time
space, b, PgDn, PgUp A screen at a time
g, G, Home, End The very top, or back to the end
/ Search backwards for a string
n, N The next match, or the previous one
q, escape Leave, returning to the end

--scrollback-color changes that color, or none leaves the bar the shade it always is; scrollback_color in the configuration file says the same. It is a different color from the one the command prefix turns the bar, because the two last very differently — the prefix is gone in about a second, and this stays until you leave it — but both mean the same thing about the keyboard, which is why both are colored at all.

A scrollbar down the right edge says where in the scrollback you are, since "1200 lines back" on the status bar is a number and a bar is a position. Its thumb moves by eighths of a row, so stepping back a line at a time moves it a little each time rather than a whole row every eighth line. It is drawn only while the scrollback is up, over the rightmost column — giving it a column of its own would mean narrowing the far end's terminal, and a terminal that reflows every time you scroll back is one nobody wants. The column is back the moment you leave.

The thumb and the track are two shades of the terminal's own palette rather than a bright glyph on a dim one, because a row the thumb only half covers has to be both at once — one shade above the edge and the other below it — and there is no way to say "dim" about half a cell. --scrollbar-color sets the thumb's shade and --scrollbar-track-color the track's; out of the box they are the terminal's white and its dim grey, so a theme change moves both.

Alt-a k opens the command palette.

A search scrolls to its match and marks every match on screen in reverse video. Output carries on arriving and carries on being emulated meanwhile; the viewport simply stops following it, as it does in any terminal you scroll back in, and leaving returns to the end so nothing said in between is missed.

Rolling forward off the end leaves the scrollback, which is the other half of rolling back to enter it — so the whole visit can be one gesture, and the way out is the way you were already going. Reaching the bottom is not enough by itself: that is where you arrive by catching up with the port, and being put out for it would be a mode that ends when you stop wanting it rather than when you say so. It takes one more click past the end, where there is nothing below to look at and the gesture can only mean "done".

The wheel is a wheel, not four arrow keys. A terminal on its alternate screen turns a scroll gesture into arrow keys unless the program asks for mouse reports, and those are indistinguishable from pressed ones — so rolling the wheel used to walk through the far end's shell history instead of back through the screen. zettacom asks for the reports, which is why the wheel can do the thing it was meant to do. Rolling it forward at the end of the scrollback does nothing, there being nothing below the end to look at, and the wheel is left alone while a transfer is running, since the keyboard is watched by a much smaller state machine then and a mode entered there could not be left.

The command palette

Alt-a k lists the commands, in a box drawn over the screen that says what each one is set to:

╭──────────────────────────────────────────────────────────────╮
│> par_                                                        │
│ [Alt-a y]  Change parity mode                            none │
╰──────────────────────────────────────────────────────────────╯

Type to narrow the list — on the description or on the value, so 9600 finds the baud rate — then ↑ ↓ (or Ctrl-P and Ctrl-N) to choose and enter to run it. Escape closes without running anything.

The same widget is the protocol picker below, and every other list zettacom asks you to choose from: what a row means is carried by the row, not by the code that draws it.

It is drawn over the emulated screen rather than beside it: giving it a region of its own would mean resizing the far end's terminal to make room, and a terminal that reflows every time a menu opens is not one anybody wants. What is underneath is intact and back the moment the palette closes.

Nothing about the command keys changes. The palette runs the command the same way the key does, so it is a way of finding a command rather than a second implementation of every one.

Picking a file

Typing a path is fine when the path is known and tedious when it is not, so Alt-a s shows what is actually there:

╭──────────────────────────────────────────────────────────────╮
│ …/scratchpad/picker/files  > _                               │
│  ..                                                          │
│  sub                                                       / │
│  beta.bin                                                    │
│  hello.txt                                                   │
╰──────────────────────────────────────────────────────────────╯

Directories first, then files, each sorted without regard to case — ASCII would file Makefile above bin, which is a rule nobody reading a list has in mind. Enter walks into a directory or sends a file; the same typing, arrows and escape as every other list. It reopens where it was left, so sending three files from one directory means walking there once.

Alt-a r shows the same thing for the other direction, listing only directories and offering [receive here] at the top: the sender names the file, so the question worth asking at this end is where it should land. What is chosen becomes the receive directory from then on.

XMODEM carries no name on the wire, so it asks for one afterwards — see Answering a question below.

Changing the baud rate

Alt-a b lists the rates the driver has a constant for, opening on the one in force and marking it with a dot:

╭──────────────────────────────────────────────────────────────╮
│ baud rate  > _                                               │
│   2400                                                       │
│   4800                                                       │
│ ● 9600                                                       │
│   19200                                                      │
│   38400                                                      │
╰──────────────────────────────────────────────────────────────╯

The list is not the whole story, because any rate the driver accepts can be asked for — 31250 for MIDI, say. So the first row is type a rate, and it stays put however the list is narrowed: typing 31250 filters every rate away and leaves that row, which is exactly when it is wanted. Choosing it opens a box with 31250 already in it.

Alt-a u and Alt-a d step up and down the same list without opening it.

Answering a question

Two things have to be typed rather than chosen, and both are typed into the same box:

╭──────────────────────────────────────────────────────────────╮
│ baud rate (now 9600)                                         │
│ > 31250_                                                     │
╰──────────────────────────────────────────────────────────────╯
Alt-a w Bytes to write, in hex — de ad be ef, or deadbeef
after Alt-a r What to call an XMODEM download, which carries no name on the wire

— and a third from the baud picker's type a rate row, where 0x1c200 works as well as 115200.

Enter answers, escape abandons, Ctrl-U clears the line. An answer that will not do is not thrown away with a message scrolled past — the box asks again with what was wrong with it in the question, keeping what was typed so a typo can be corrected rather than retyped: baud rate (now 9600) -- that was not a number.

The question carries the current value where there is one. The XMODEM name is asked for as a name, not a path — the directory was settled a moment earlier by the picker — so / is not accepted there, which is the same thing zettacom refuses when a peer sends it.

Changing the protocol

Alt-a z changes which protocol sending and receiving use, without restarting. It is the same kind of picker as the baud rates, with a dot on the one in use. An incoming ZMODEM or Kermit transfer is still recognized whatever this says — the setting only decides what this end starts.

Transfers

A transfer takes the status bar, as a gauge, with the filled part in color:

 recv disk.img  [████▌             ] 25%  50.0MiB/200.0MiB  1.0MiB/s  eta 2m30s

It fills by eighths of a cell rather than whole ones, using the block elements from ▏ up to █, so eighteen cells give it a hundred and forty-four steps instead of eighteen. A gauge that can only move a whole cell sits still for five per cent at this width, which on a slow line is long enough to wonder whether anything is happening.

--gauge-color says which color — a name (green, bright-cyan), a palette number from 0 to 255, a #rrggbb, or none to leave it the shade the rest of the bar is. The brackets take it too, since they are the ends of the gauge rather than part of the sentence around it. gauge_color in the configuration file says the same thing. The default is green.

The terminal is told as well. While a transfer runs, zettacom sends the OSC 9;4 progress reports that ConEmu introduced and Windows Terminal, WezTerm, Ghostty and foot have since picked up, so the terminal can show how far along it is somewhere that is visible when zettacom is not — a tab, a taskbar icon, a window badge. A ten-minute download is exactly the sort of thing to start and then go and look at something else.

One report per percentage point, not one per frame, and the last one clears it — including on the way out, so a progress bar is never left behind on a taskbar by a program that has exited. A protocol that carries no length says "running, length unknown" instead of a percentage. --no-terminal-progress, or terminal_progress = false in the configuration file, turns the whole thing off for a terminal that does something unwelcome with it; a terminal that does not know the sequence discards it.

And it says so when the transfer is done. A finished file raises a desktop notification through OSC 9, which is the other half of the same idea: the gauge and the scrollback both want somebody to be looking, and a transfer is the one thing here that takes long enough to walk away from.

received disk.img (190.7MiB in 2m53s)
did not finish receiving disk.img (12.4MiB in 9s)

--no-notify, or notify = false, turns it off. So does --quiet, which silences this along with everything else — a flag by that name that still raised notifications would be a surprising one.

A transfer can be cancelled and does not deafen the program. ^C aborts one in flight, and the exit command aborts it and leaves; anything else typed meanwhile is queued for the port and goes out when the line is free. zig-modem has no way to cancel a session from outside — Filter.stop would take the whole port down — so cancelling works by refusing to go on reading the file being sent, or writing the one being received, which the library turns into the protocol's own abort sequence. The far end is told in its own words rather than left talking to a line that went quiet.

It is the only thing on that bar that changes by itself and the only thing with an end to wait for; the port's settings are still what you set them to and are back the moment the transfer finishes. What stays behind is the record — the *** receiving ... *** line, a final summary with the total and the average rate, and *** received: complete *** — all of it in the scrollback, where a search can find it later.

Commands, and the key that starts them

A command is Alt-a followed by a plain letter: Alt-a x to leave, Alt-a o for the scrollback, Alt-a k for the palette, Alt-a l to pause the log. --escape moves it, so --escape b gives Alt-b. While it is waiting for the letter the whole status bar turns orange and says so:

 Alt-a  --  press a command key, or wait to cancel

That is worth noticing before typing, because the next key will not reach the far end — and it is a state that ends by itself in about a second, so it has to be seen at a glance or not at all. --prefix-color changes the color, or none leaves the bar the shade it always is; prefix_color in the configuration file says the same. If nothing follows within a second it gives up and the next key is ordinary input again. Alt-a twice sends Alt-a to the port.

--key-wait <msec> is how long that is; the default is 1000. 0 waits indefinitely, which is the right answer on a terminal that speaks the Kitty keyboard protocol, because there the wait buys nothing:

Under the Kitty keyboard protocol Alt-a arrives as a key with a modifier on it, so it is simply not the escape key and there is nothing to disambiguate. Without that protocol it reaches a program as escape followed by a — byte for byte what pressing escape and then typing a looks like, which is how anybody leaves insert mode in vi and appends — and vaxis's parser separates them by whether anything followed in the same read. Either way a lone escape reaches the far end without being held back.

Escape also leaves the scrollback, and the search prompt within it, without needing a second key to prove it was pressed.

Keystrokes are read, not forwarded. libvaxis's parser says which key was pressed, and libghostty-vt's encoder says what that key should be on the wire for the modes the far end has actually set.

That is more faithful than passing the bytes through. A host that has asked for application cursor keys wants ESC O A from the up arrow; a terminal configured for normal mode sends ESC [ A, and a program forwarding bytes cannot tell the difference. Pressing the same key before and after such a host sets the mode puts ESC [ A and then ESC O A on the wire.

It is also what makes the Kitty keyboard protocol usable where the terminal speaks it. zettacom still owns its own descriptors and its own raw mode: vaxis's Tty and Loop are not used, only its parser.

Building

nix develop          # Zig 0.17, plus zon2nix and reuse for the license lint
zig build            # -> zig-out/bin/zettacom
zig build test       # unit tests
zig build check      # type-check without emitting a binary
zig build run -- /dev/ttyUSB0

Or as a Nix package, which builds and runs the tests in the sandbox:

nix build            # -> result/bin/zettacom

Linux and Windows link no libc: raw syscalls in src/sys/linux.zig, Win32 in src/sys/windows.zig. On Linux that means a static executable with no shared-library dependencies at all; on Windows it means the system DLLs every process already has, and nothing else — no runtime to install.

macOS and the BSDs do link one, and not by preference: Darwin's system call ABI is private, libSystem is the supported interface, and Zig's standard library reaches the BSDs only through std.c. src/sys/bsd.zig is that backend, and build.zig links libc for those targets alone.

Cross-compiling needs no more than naming the target:

zig build -Dtarget=x86_64-windows -Doptimize=ReleaseSafe   # -> zig-out/bin/zettacom.exe
zig build -Dtarget=aarch64-macos -Doptimize=ReleaseSafe
zig build -Dtarget=x86_64-freebsd -Doptimize=ReleaseSafe

Development builds

If you would rather not build it, every push to main publishes an x86_64-linux binary and an x86_64-windows one, both built ReleaseSafe, as the development release. It replaces the one before it, so there is one such build and it is whatever main is at the moment you fetch it. Neither needs anything installed alongside it. Both keep their debug info, so a crash prints a stack trace worth sending — on Windows that information is in zettacom.pdb, which is worth downloading into the same directory as the .exe for the trace to name anything. And they say which commit they are:

$ zettacom --version
zettacom v0.2.0-dev+e5c50aa3

Nothing is published from a commit whose tests failed: the test workflow calls the release one when it is done, so a red build leaves the previous binary in place. It is still a development build in the plain sense of the word — the tests passing is a lower bar than a release.

Build options

Option Default Meaning
-Dversion=<string> 0.2.0-dev Version reported by --version
-Dtty-queue-max=<bytes> 0 (unlimited) Cap on the output queue
-Duucp-lock-dir=<path> unset Use UUCP LCK.. lock files instead of flock(2)

Locking defaults to flock(2). Set -Duucp-lock-dir=/var/lock only if you need to interoperate with programs that use UUCP lock files (minicom, cu, pppd); enabling both conventions at once is normally a mistake.

Dependencies

Four everywhere, plus one on Windows, all pinned in build.zig.zon: ziggy for the configuration file, zig-modem for the file transfer protocols, and libghostty-vt and libvaxis for the interface.

The fifth is zigwin32, the Win32 declarations, which src/sys/windows.zig and src/term/windows.zig are written against. It is marked lazy in the manifest: sixty-four megabytes of generated bindings that a Linux build has no use for, and never fetches.

There is no line-editing library. Everything zettacom asks for is typed into a box it draws itself; see Answering a question.

libghostty-vt comes from a fork of ghostty, pinned to its zig-0.17-jeff branch. What zettacom needs from ghostty's build is that it be usable alongside libvaxis: both libraries read Unicode properties out of uucode and each would otherwise instantiate its own, from the same source file, which Zig refuses. ghostty exports its instance so vaxis can be handed it through -Dexternal_uucode, and makes the uucode tables vaxis reads available to it. Both changes began in the fork and have since been merged into ghostty itself.

Depending on ghostty means depending on its whole manifest: build.zig.zon.nix lists 39 packages, most of which — harfbuzz, fontconfig, GTK bindings, fonts — are fetched to build the link farm and never compiled. Building the Nix package takes correspondingly long.

A Nix build has no network, so the Zig package manager cannot fetch these itself. zon2nix reads build.zig.zon, follows every transitive dependency, and writes build.zig.zon.nix — a farm laid out the way Zig's global cache is, which package.nix hands to zig build --system. That file is generated and committed, so adding, removing or updating a dependency is a matter of editing build.zig.zon and regenerating it:

nix develop -c zon2nix --17 --nix=build.zig.zon.nix build.zig.zon

ghostty is copied out of that farm and passed as --fork= first, because Zig cannot build a fetched package with .path dependencies of its own under --system: 0.17.0 rejects them as absolute paths, where 0.16.0 spun forever rather than failing. package.nix says why at length; which packages need it is zon2nix's pathDependencyPackages rather than a name written here.

Windows

Ports are named the way Windows names them:

> zettacom COM3
> zettacom --baud 115200 COM12

COM10 and beyond have no DOS device alias and can only be opened through the \\.\ namespace, so every port name is given that prefix before it is opened and the awkward case stops being one. A path that already names a device is used as it stands.

The configuration file lives at %APPDATA%\zettacom\config.ziggy, which is what --help reports. Everything in it means what it means on Linux, except that uucp-lock-dir is a build option with no Windows counterpart.

The console has to be a real one, and a recent one. zettacom reads the keyboard as a byte stream, which is what ENABLE_VIRTUAL_TERMINAL_INPUT makes a console deliver — Windows 10 1809 and later. Windows Terminal is the obvious place to run it; conhost works. Output goes through the console's own virtual terminal processing, so the emulated screen is drawn the same way it is everywhere else. A redirected stdin or stdout is refused at startup rather than half-drawn, the same as having no controlling terminal on Linux.

What genuinely differs, beyond the spelling:

  • DTR and RTS are remembered, not read back. GetCommModemStatus reports the four input lines — CTS, DSR, DCD, RI — and says nothing about the two outputs, so the status bar shows what zettacom last set them to. A port another program has been driving will be described as this one left it.
  • Every baud rate is available. A DCB carries the rate as a plain number, so there is no equivalent of the BOTHER machinery Linux needs and NO_CUSTOM_BAUD has nothing to disable. The rates Alt-a b steps through are the CBR_ list, which differs a little from Linux's: it starts at 110 rather than 50, and includes 14400.
  • Exclusion comes from the open. A port is opened with no sharing at all, so a second program is refused by the system rather than by a lock this one takes out; there are no UUCP lock files and nothing to clean up after a crash.
  • A break is a quarter of a second. Win32 has no timed break — one call starts it and another ends it — so the duration is chosen here.
  • --hangup is implicit. Windows drops DTR when the port is closed and offers no way to ask otherwise.

What has been tested, and what has not

The Windows build compiles from a clean checkout, and its unit tests — the same ones the Linux build runs, with the two that assert POSIX path syntax skipping themselves — pass under Wine:

zig build test -Dtarget=x86_64-windows -fwine   # needs wine on PATH

That covers the program's own startup, argument parsing, configuration loading, port opening through Wine's COM mapping, screen drawing and exit paths as well; typed characters reach the far end of a serial line and output from it reaches the screen. Continuous integration builds for Windows on every push but does not run this, since Wine is a large thing to install on a runner for the purpose.

It has not been run against real serial hardware on a real Windows machine, and two things in particular could not be exercised under Wine: the Alt-a command prefix, because Wine's console swallows the escape byte that carries the modifier, and the exact wait that Windows performs on a console handle, because Wine never signals one — which is why the wait proves itself before relying on it, and slices its waiting until it has. Reports from a real machine are welcome.

macOS and the BSDs

One backend covers macOS, FreeBSD, NetBSD, OpenBSD and DragonFly, because at the level zettacom works at they are the same system. Ports are named the way each system names them:

$ zettacom /dev/cu.usbserial-A9007UX1     # macOS
$ zettacom /dev/cuau0                     # FreeBSD
$ zettacom /dev/dty00                     # OpenBSD

Everything reads as it does on Linux — the same options, the same configuration file at $XDG_CONFIG_HOME/zettacom/config.ziggy, the same Alt-a — with three real differences:

  • Mark and space parity are refused, because there is no CMSPAR to ask for them with. picocom defines the bit as zero on these systems, which means asking for mark quietly gets you odd; --parity mark here fails and says so.
  • Any baud rate works, and more simply than on Linux. A BSD speed_t is the rate, so 31250 goes in the same field 9600 does. macOS is the exception: rates it does not know go in through IOSSIOSPEED after the attributes are applied rather than as part of them, which is why term/bsd.zig carries such a rate separately until it can be applied in the right order.
  • libc is linked, where the Linux and Windows builds link none. Darwin's syscall ABI is private and the BSDs are wrapped only through std.c; see Building.

Flow control is spelled two ways across these systems — CCTS_OFLOW with CRTS_IFLOW on macOS, FreeBSD and DragonFly, the single CRTSCTS on NetBSD and OpenBSD — and term/bsd.zig is where that difference begins and ends.

What has been tested, and what has not

FreeBSD is tested properly. The whole suite — 206 tests, the same ones Linux runs plus the backend's own — passes on FreeBSD 14.3 in a virtual machine, and zettacom itself was run there against a real serial port: it opened /dev/cuau1 at 9600 8N1, reported the modem lines the driver gave it, drew text arriving from the far end onto the emulated screen with the RX light coming on, put typed characters on the wire byte for byte, and exited cleanly.

macOS, NetBSD, OpenBSD and DragonFly are compiled and not run. They share every line of the code FreeBSD exercised, and what is not shared is small and named above — the flow-control bits and, on macOS, the extra ioctl for an unusual baud rate. That is a reasonable position to be in and it is not the same as tested: nobody has watched a Mac talk to a serial port with this. If you do, reports are welcome.

File transfers

zettacom speaks XMODEM, YMODEM, ZMODEM and Kermit itself, through zig-modem. Nothing is forked, no lrzsz has to be installed, and the port never changes hands. Which protocol to use is named rather than spelled as a command line:

Flag Effect
--protocol zmodem | xmodem | xmodem-1k | ymodem | ymodem-1k | kermit Protocol for Alt-a s and Alt-a r. Default zmodem
--receive-dir <directory> Where received files are written. Default .

Downloads start by themselves. Port traffic runs through a filter that copies bytes through unchanged until a ZMODEM frame header or a Kermit S packet appears in it, at which point it stops forwarding and receives the file. So typing sz report.txt on the far end downloads it here, with nothing typed on this end.

XMODEM and YMODEM cannot work that way, because their senders say nothing at all until a receiver prompts them; there is nothing in the stream to notice. What the far end says while being waited for does reach the screen, though — the echo of the command that started the receiver, the question it asked, or the message it printed instead of prompting. On a console line that is usually the only explanation of why a transfer is not starting.

Those downloads are asked for with Alt-a r, which for XMODEM also asks what to call the file, since XMODEM carries no name.

xmodem-1k and ymodem-1k are separate protocols, not a setting. They send 1024 byte blocks where the other two send 128. Whether the far end takes the larger block is the one thing about XMODEM and YMODEM that cannot be negotiated — a receiver that predates the extension refuses every block without saying why, which looks from this end like a transfer failing at nought per cent after a file name that crossed perfectly well. Making it part of the name means the choice is visible in the log, in the status bar and in the picker, rather than hidden in a defaulted field. Both spellings work, so xmodem-1k, xmodem_1k and xmodem1k all name the same thing.

Only the sender is affected: a receiver takes whichever block sizes it is sent, so ymodem and ymodem-1k receive identically. The 1K senders fall back to 128 byte blocks if the first two large ones are refused.

A file received under a name the sender chose is written to its last path component only, so a peer cannot ask for ../../etc/passwd.

A transfer says how it is getting on. The moving part is the gauge on the status bar; what is left behind in the scrollback is the two *** ... *** lines that announce a file and its outcome, with a summary of what the transfer did between them:

*** receiving disk.img (200000000 bytes) ***
receiving disk.img  100%  190.7MiB/190.7MiB  1.1MiB/s  in 2m53s
*** received: complete ***

The percentage needs a length, so it is absent for XMODEM, which carries none, and for a sender that declined to give one; the byte count and the rate are always there. The rate is measured over the last minute once there has been a minute to measure, and over the file so far before that. --quiet suppresses both the gauge and the line, but not what they count: a transfer is the busiest the port ever is even though not a byte of it reaches the screen, so --exit-after will not time out in the middle of one.

The summary is skipped rather than waited for if something else holds the console when it is written. It comes from inside the protocol engine, with a sender at the other end of the line waiting on it, and that sender will not wait for the console.

Logging the traffic

--logfile <file> records the wire: every byte the port delivered and every byte handed to it, raw, in the order they crossed. Nothing else is in the file — no framing, no direction marks, no timestamps — because anything added is something a reader has to strip before the file is the bytes again.

zettacom --logfile port.log -b 115200 /dev/ttyUSB0

Logging is off unless that flag names a file, or Alt-a n opens one later. logfile in the configuration file says the same thing as the flag.

The tap is either side of the transfer filter, which is what makes it the wire and not the screen: a ZMODEM download is in the log, and so is everything sent during one. picocom's -g, which zettacom's flag is spelled after, logged only the received bytes that reached the screen.

What you typed appears twice. A far end that echoes sends your keystrokes back, so ls typed at a shell is in the file once going out and once coming back:

l l s s \r \r\n total 4\r\n drwxr-xr-x ...
^ ^
| +-- echoed back by the far end
+---- what you typed

That is what "all the traffic" means, and it is the thing to know before reading one. If what you want is a transcript of the session rather than a recording of the line, the emulator already keeps one — Alt-a o walks it, and a snapshot preserves it.

A log is opened for appending, so pointing at a name twice adds to it rather than destroying what was there. Both it and its sidecar are created 0600: a recording of the wire holds what was typed as well as what was shown, which is to say passwords.

Command Effect
Alt-a l Pause the log, or resume it
Alt-a n Start writing to a new file
Alt-a m Take a snapshot now

Pausing drops bytes rather than buffering them. A log paused to keep a password out of it is not one that should be holding the password in memory until you resume.

Alt-a n opens a box with a name made out of the current UTC time already in it — zettacom-20260922-181500Z.log — so enter accepts it and typing replaces it. It is also how logging is started when --logfile named nothing. A name that cannot be opened leaves the log running where it was, rather than stopping it.

The sidecar

A file of raw bytes cannot be replayed into a terminal unless the reader knows what state that terminal was in when the recording started — where the cursor was, which modes were set, what was already on the screen. Across a pause it cannot be replayed at all, because the bytes that would have moved the cursor are exactly the ones that were dropped.

So beside port.log there is port.log.snapshot, holding a libghostty-vt snapshot: the far end's screen as zettacom understands it, its alternate screen, its scrollback, its styles and modes, and the bytes of whatever escape sequence the parser was part way through. One is written when logging starts, one when it resumes, and one every time Alt-a m asks for one.

Each is preceded by a 32-byte header saying which of those it was, when, and how many bytes of the log had been written by then — which is what ties a snapshot to a position in the byte stream beside it. The snapshot's own framing ends itself, at its FINISH record, so entries simply follow one another:

+----------------+----------------------------+
| entry header   | snapshot, through FINISH   |
+----------------+----------------------------+
| entry header   | snapshot, through FINISH   |
+----------------+----------------------------+
Offset Size Field
0 8 ZCSNAP01
8 1 0 start, 1 resumed, 2 taken by hand
9 7 Reserved, zero
16 8 Bytes of the log written when it was taken, little-endian
24 8 Unix nanoseconds, little-endian

The offset is read as the snapshot is encoded, under the same lock that holds the emulator still, so it is approximate by whatever the filter has read from the port and not yet fed to the emulator — at most one read. It is a place to start replaying from, not a byte-exact cut.

Alt-a m with nothing being logged still writes a snapshot, to a file of its own named for the moment it was taken. There is no log to point into then, so its offset is zero. It is the scrollback, in a form something else can read.

Configuration

zettacom reads a configuration file, in Ziggy, from $XDG_CONFIG_HOME/zettacom/config.ziggy (or ~/.config/zettacom/config.ziggy when XDG_CONFIG_HOME is unset). Having no file is not an error.

.defaults = .{
    .baud = 115200,
},
.profiles = {
    "usb-serial": .{
        .match = "/dev/ttyUSB*",
        .baud = 9600,
        .flow = .rtscts,
    },
},

Settings resolve in this order, each overriding the one before:

built-in defaults  ->  defaults  ->  matching profile  ->  command line

A profile applies when its match glob accepts the port path, the first match in file order winning; a profile with no match is reachable only by name. Matching uses the port path exactly as typed, so a pattern written for /dev/serial/by-id/... will not fire when the same device is opened as /dev/ttyUSB0.

Flag Effect
--config <file> Read this file instead of the default location; a missing file is an error
--no-config Read no file at all
--profile <name> Use this profile and skip match entirely

examples/config.ziggy is a commented example covering every setting.

Errors point at the line: an unknown setting reports config.ziggy: 3:5: unknown setting, and a bad value says what was wrong with it. There is no .ziggy-schema file — the Zig types in src/config.zig are the schema, and ziggy validates against them as it deserializes, which is what produces those messages. A schema file would add editor tooling support and could be written later.

Layout

There are two loops, because the filter has to own both directions of the port to notice a transfer going past. modem.Filter reads the port and drains the output queue on tasks of its own, under a std.Io.Threaded; the keyboard loop reads the terminal and runs the command-prefix state machine. A console lock keeps zettacom's own messages and a burst of port output off each other's toes.

Everything that waits goes through std.Io. The port, the timers and the keyboard alike. The keyboard loop awaits an Io.Batch of two reads — the terminal, and a pipe that a deadly signal or the port's tasks finishing write a byte to — with a deadline only when the command prefix or --exit-after has one. So an idle zettacom is asleep rather than waking on a timer to look at flags, and which mechanism does that sleeping is the Io implementation's business: under the Io.Threaded it runs on today it is one poll(2) over both descriptors, and under an io_uring one it would be two submissions, with nothing here changing.

With one exception, and it is not by choice: Zig 0.17's Io.Threaded has no concurrent batch on Windows — asking it to await two operations at once returns error.ConcurrencyUnavailable — so there the keyboard loop waits on WaitForMultipleObjects over the console and an event, which is the same wait by the platform's own name. Everything either side of it is unchanged, and the day Io.Threaded grows the capability the special case can go. See Program.waitForKeyWindows.

Two terminals are owned by two different things, and keeping that straight is most of reading main.zig. The serial port belongs to term.Table, which speaks termios2 on Linux so that any baud rate the driver takes can be asked for, and a DCB on Windows, where every rate is a plain number and the question does not arise. Yours belongs to vaxis.Tty, which opens it, makes it raw, restores it, and dispatches SIGWINCH. The reading is still zettacom's, because the keyboard loop has to wake for a signal, the port going away, an idle period and the command prefix giving up — none of which vaxis.Loop knows how to wait on.

Two implementation notes worth knowing before reading any of it. Terminals are restored explicitly rather than through atexit, because Zig's std.process.exit does not run atexit handlers — term.Table.restoreAll is called from each exit path instead. And nothing is ever forked, so a deadly signal has no children to take down with it and only sets a flag the keyboard loop notices.

src/
  main.zig          the program: options, keyboard loop, commands
  cli.zig           argument parsing
  config.zig        the Ziggy configuration file
  glob.zig          port-path matching for config profiles
  charmap.zig       --omap/--emap character mappings
  ttyq.zig          the output queue and its reading end
  ui.zig            the emulated screen and the chrome round it
  browse.zig        the file and directory pickers
  keys.zig          keystrokes to the bytes the far end expects
  streams.zig       the console lock, and the taps either side of the wire
  log.zig           the traffic log and its snapshot sidecar
  transfer.zig      file transfers, on zig-modem
  hex.zig           the hex parser, for [Alt-a w]
  fdio.zig          writing to a raw descriptor
  lock.zig          flock and UUCP lock files
  term.zig          terminal settings framework
  term/
    backend.zig     comptime backend selection + the contract
    types.zig       Parity, Flow, Modem, error set
    linux.zig       termios2 backend
    windows.zig     DCB backend
    bsd.zig         termios backend, macOS and the BSDs
  sys.zig           OS primitives: the comptime seam
  sys/
    linux.zig       raw syscalls, no libc
    windows.zig     Win32, through zigwin32
    bsd.zig         libc, macOS and the BSDs

All four are implemented. There is no platform left whose file is a stub; a system none of them names fails to compile with a message saying so, rather than building something that does nothing.

Baud rates

Any rate the driver will accept can be requested, not just the ones with a Bxxxx constant, because the Linux backend uses the termios2 interface (TCGETS2/TCSETS2 with BOTHER). So -b 31250 for MIDI, or -b 76800, work directly. Set NO_CUSTOM_BAUD in the environment to restrict zettacom to the standard rates, for drivers that mishandle the others.

Note that a driver may quietly substitute a rate it prefers. zettacom always reads back what was actually applied and shows both when they differ:

*** baud: 31250 (38400) ***

Where this lives

git clone https://git.jcollie.dev/jeff/zettacom.git

The Tangled mirror

The repository is mirrored on Tangled, an atproto-backed forge, at https://tangled.org/jcollie.dev/zettacom:

git clone https://tangled.org/jcollie.dev/zettacom

Cloning with Radicle

The repository is also published on Radicle, a peer-to-peer code forge, where it is findable only by its Repository ID:

rad clone rad:zovE5mSkp3Wkbyo1EUsPVGnQByqF

If you do not have Radicle set up yet, install the rad CLI and create an identity first:

curl -sSf https://radicle.xyz/install | sh
rad auth

Cloning also seeds the repository, which helps keep it available on the network.

Acknowledgements

zettacom began as a port of picocom by Nick Patavalis, and owes it a great deal. The terminal settings framework, the character mappings, the output queue and the shape of the command line all started there, and much of the command line is still compatible with it.

It has grown apart from picocom since — a terminal emulator and everything that follows from one, file transfers of its own, a configuration file — but the debt stands. Thanks to Nick Patavalis and to everyone who has contributed to picocom over the years.

The terminal emulator that made most of that divergence possible is libghostty-vt, out of Ghostty by Mitchell Hashimoto and the Ghostty developers. zettacom does not so much use it as stand on it: the screen, the scrollback, the search and the key encoder are all its work, and what is here is chrome around them.

The chrome itself is drawn with libvaxis by Tim Culverhouse, which also reads the keyboard, negotiates what the terminal can do, and owns the terminal while zettacom is running.

zig-modem speaks the four transfer protocols, and ziggy by Loris Cro parses the configuration file.

References cited

What zettacom was built from and what it was built against: the libraries it stands on, the program it is a port of, and the specifications that say what a serial port, a terminal and a configuration directory are. The transfer protocols themselves — XMODEM, YMODEM, ZMODEM and Kermit — are cited in zig-modem, which implements them, rather than here.

Software

Cro, Loris. Ziggy: a data serialization language. https://ziggy-lang.io. The configuration file's language and parser. The Zig types in src/config.zig are the schema, and ziggy validates against them as it deserializes, which is what produces the line-and-column errors.

Culverhouse, Tim. libvaxis. https://github.com/rockorager/libvaxis. Draws zettacom's own terminal, reads the keyboard, negotiates what the terminal can do, opens and restores /dev/tty, and dispatches SIGWINCH. Its parser is what tells Alt-a from a pressed escape key.

Hashimoto, Mitchell, and the Ghostty contributors. libghostty-vt, in Ghostty. https://github.com/ghostty-org/ghostty. The terminal emulator port output is fed into: the screen, the scrollback, the styles, the search behind Alt-a o, the key encoder behind keys.zig, and the state snapshot the traffic log's sidecar holds. Its src/terminal/snapshot/ documents the record format log.zig writes entries around.

Marler, Jonathan. zigwin32: Zig bindings for the Win32 API. https://github.com/marlersoft/zigwin32. Where the Win32 declarations in sys/windows.zig and term/windows.zig come from, generated from the same metadata Microsoft's own projections are built from rather than written out by hand.

Ollie, Jeffrey C. zig-datetime. https://git.jcollie.dev/jeff/zig-datetime. Dates, times and timezones. One thing here wants them — the name a new traffic log is given, which is the moment it was started — and that one thing is a calendar. See log.stampedName.

Ollie, Jeffrey C. zig-modem. https://git.jcollie.dev/jeff/zig-modem. XMODEM, YMODEM, ZMODEM and Kermit, and the filter that owns both directions of the port so that a transfer starting by itself is noticed.

Ollie, Jeffrey C. zon2nix. https://github.com/jcollie/zon2nix. Reads build.zig.zon, follows every transitive dependency and writes the Nix expression zig build --system is handed, so the sandboxed build fetches nothing.

Patavalis, Nick. picocom: minimal dumb-terminal emulation program. https://github.com/npat-efault/picocom. The program zettacom is a port of. picocom.c, term.c, termios2.c, custbaud_bsd.c, fdio.c and the USE_FLOCK/UUCP_LOCK_DIR handling are each cited from the zettacom file that ports them.

Zig Software Foundation. The Zig programming language, version 0.17.0. https://ziglang.org/. std.Io is what everything that waits goes through — the port, the timers, the keyboard — and std.Io.Reader and std.Io.Writer are the interfaces the taps in streams.zig implement.

The terminal, three ways

IEEE and The Open Group. The Open Group Base Specifications Issue 8, Chapter 11: General Terminal Interface. IEEE Std 1003.1-2024. 2024. https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap11.html. termios, and the meaning of the flags term.zig is a framework around: the c_cflag bits for parity, data bits and stop bits, CLOCAL, HUPCL, CRTSCTS, IXON/IXOFF, and the VMIN/VTIME pair the reading task depends on. term/bsd.zig is this interface and nothing else.

The Linux man-pages project. ioctl_tty(2) — ioctls for terminals and serial lines. https://man7.org/linux/man-pages/man2/ioctl_tty.2.html. TCGETS2 and TCSETS2 with BOTHER, which is how term/linux.zig asks a Linux driver for a rate that has no Bxxxx constant — 31250 for MIDI, say — and TIOCMGET, TIOCMBIS and TIOCMBIC, which are the modem lines on the status bar.

Microsoft. DCB structure (winbase.h). https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-dcb. A serial port's settings on Windows, read and written whole with GetCommState and SetCommState. term/windows.zig is a read-modify-write of this structure, with COMMTIMEOUTS travelling beside it so that restoring the port restores its timeouts too.

What the two terminals say to each other

ConEmu. ANSI escape codes: OSC 9;4 taskbar progress. https://conemu.github.io/en/AnsiEscapeCodes.html. The sequence ui.zig writes to tell the terminal how far a transfer has got, so a tab or a taskbar icon can show it while zettacom is not on screen. --no-terminal-progress turns it off.

kitty. The kitty keyboard protocol. https://sw.kovidgoyal.net/kitty/keyboard-protocol/. Under this protocol Alt-a arrives as a key with a modifier on it rather than as an escape followed by a letter, so the command prefix is simply not the escape key and --key-wait 0 becomes the right answer. zettacom asks for it at startup and takes the legacy encoding from a terminal that does not answer.

Nachman, George. Proprietary escape codes: OSC 9 notifications. https://iterm2.com/documentation-escape-codes.html. The desktop notification a finished transfer raises, sent through libvaxis. --no-notify turns it off, and so does --quiet.

Where files and locks go

freedesktop.org. XDG Base Directory Specification, version 0.8. https://specifications.freedesktop.org/basedir-spec/latest/. Why the configuration file is at $XDG_CONFIG_HOME/zettacom/config.ziggy, and why ~/.config is the fallback when that variable is unset rather than a second place to look. See config.defaultPath.

Free Software Foundation Europe. REUSE Specification, version 3.3. https://reuse.software/spec-3.3/. How every file in the tree carries its copyright and licence, and what reuse lint checks against. REUSE.toml covers the two generated files that cannot hold a header.

The Linux Foundation. Filesystem Hierarchy Standard, version 3.0. 2015. https://refspecs.linuxfoundation.org/FHS_3.0/fhs-3.0.html. Section 5.9, /var/lock, which is where the UUCP lock files in lock.zig go when the build asks for that convention instead of flock — the one older programs such as minicom, cu and pppd know about.

License

GPL-2.0-or-later. Copyright is held by Nick Patavalis for the picocom code this began as, and by Jeffrey C. Ollie for zettacom. The tree is REUSE compliant; run reuse lint.