zyterm
A serial terminal built for long sessions with hardware. One C binary, nothing to configure, libc the only runtime dependency.

Try it in two minutes
There's nothing to configure. Grab the .deb, point it at your adapter, and you're in.
Install
Download the latest .deb (amd64 or arm64) from Releases and sudo dpkg -i zyterm_*.deb. Or make && sudo make install from source. A C compiler and make are the whole toolchain.
Connect
zyterm /dev/ttyUSB0 -b 115200. Swap the node for whatever ls /dev/tty* shows.
Quit
Ctrl + A then q. You can pull the USB out at any time, more on that below.
You land in a live HUD with scrollback and search out of the box. No Python, no Node, no daemon.
Reading survives a disconnect
Pull the USB out of your dev board halfway through a boot log. The lines you were reading don't go anywhere. Scroll up. Search. Copy what you need. Plug the board back in. The view stays where you left it.
A bright ◆ DISCONNECTED pill in the HUD keeps the state visible. The popup auto-hides the moment you scroll, so the history is yours to use. When the device comes back, a small ✓ reconnected flash, and the live tail resumes without disturbing what you were reading.
The same patience covers a cold start: launch zyterm before the board is even plugged in and it waits for the device instead of aborting, then connects the moment the port appears.
On by default. Pass --no-reconnect to opt out. With --port-glob "/dev/ttyUSB*" or --match-vid-pid 0403:6001 it re-discovers the port on every replug, so a board that re-enumerates on a different node still comes back.
Type while the board talks
A UART at speed moves a lot of bytes per second. Most terminals share one screen between output and input, so a wall of bytes scrolls right over the line you are entering. zyterm splits the screen into three zones with ANSI scroll regions: HUD at the top, output streaming in the middle, your prompt and cursor anchored at the bottom.
You type. The board talks. Both happen at once.
When the rates climb high enough to matter, --threaded adds an opt-in worker that drains the device into a lock-free ring buffer, so a flood of boot log can't out-run your keystrokes.
Find any line
Built for reading, not just watching.
Search the scrollback
Ctrl + A then / opens a substring search over the scrollback. n and N step matches.
Watch patterns
--watch ERROR --watch panic highlights matching lines in colour as the bytes arrive.
Bookmarks & macros
After Ctrl + A, + drops a bookmark and [ lists them. F-keys fire bound macros.
Scrollback, command history, and bookmarks live in memory and are cleared on exit. There's no dotfile written for them yet. It's a deliberate, documented choice, not a silent gap.
Reads in colour, without trusting the device
Device-emitted colour renders by default. Your firmware's <wrn> yellow and <err> red show up as colour, not as ^[[1;33m caret litter, and a shell's Tab-completion columns line up the way they would in a normal terminal.
The catch every other terminal ignores: the bytes a device sends are untrusted. Your terminal is a trust sink. Bytes written to it can move the cursor, set the window title, or drive an OSC 52 clipboard write, and a device that prints the right escape gets all of that for free. No exotic firmware required: a cat > /dev/ttyUSB0 from the other side is enough. So zyterm runs device output through a bounded SGR-only filter: colour passes, everything else (clipboard, title, cursor, erase, private modes) is neutralized to inert caret text.
The first version denied every device escape outright. The one that ships allows exactly one class back, because SGR is the only escape that can't drive the terminal: it sets colour and weight, nothing more. The risk was never the colour, it was the parser. So the answer was to make the parser small enough to trust, a three-state machine that accepts a sequence only when every parameter byte is a digit, a semicolon, or a colon, backed by a fixed buffer that aborts to inert text rather than grow. A sequence like CSI ? 1 m looks like colour but carries a private marker, so it never passes.
--no-sgr selects strict deny-all if you want it, and Ctrl + A then G flips to full raw passthrough for the times you need every byte verbatim, like driving KGDB. The same posture covers the browser bridge: it binds loopback only, validates Host/Origin on both its write routes and its read streams (/ws, /stream), and --http-token adds a bearer gate before you ever tunnel the port.
Drag to select, release to copy
Drag with the mouse to select text from anywhere on screen. Release. The bytes land on the system clipboard. Wayland, X11, SSH, tmux: all covered by a cascade of fallbacks, transparent to you.
Selections stay anchored to the data, not the rows. Drag, scroll down to verify the end, then copy. The selection doesn't drift.
Under the hood
One C binary. Single-threaded by default: one poll(2) loop, one context struct, no shared state. The path from a byte on the wire to a pixel on screen is short, and the input bar gets to share it.
The source is split into a stack of modules with a compiler-enforced dependency chain. Each layer may only include the one beneath it, so a render change can't reach into the serial layer by accident.
The hard parts have been thought about. Mouse selection in a scrollback buffer means mapping screen columns to byte offsets while stepping over invisible ANSI escapes and multi-byte UTF-8. The implementations are direct and small.
/* Visible column to byte offset. The buffer holds raw bytes
* including ANSI SGR colour codes; the mapping has to step
* over invisible escape runs to land on a real character. */
size_t col_to_byte(const char *s, size_t len, int col_target) {
size_t i = 0;
int col = 0;
while (i < len && col < col_target) {
unsigned char b = (unsigned char)s[i];
if (b == 0x1b) { i = skip_esc(s, len, i); continue; }
/* advance by the UTF-8 character width carried in the lead byte */
size_t step = (b & 0x80) == 0 ? 1
: (b & 0xE0) == 0xC0 ? 2
: (b & 0xF0) == 0xE0 ? 3
: 4;
if (i + step > len) step = len - i;
i += step;
col++;
}
return i;
}The optional --threaded reader holds a private dup(2) of the serial fd and drains it into a lock-free ring, so a close-and-reopen on the main thread (autobaud, manual reconnect, hot-unplug) can't race the read. Shutdown is the same trick in reverse: closing the worker's private copy makes its blocked read return at once, with nothing to wait on.
The audit I ran on my own code
A serial terminal sits in a more hostile spot than it looks. Two surfaces can quietly widen its trust boundary: a web page in your browser the moment --http is on, and the device on the far end of the cable, always. So at one point I stopped adding features and ran a source-review audit of my own code, reading the risky subsystems by hand and sweeping the rest for the usual classes: races, leaks, memory safety, integer overflow, protocol logic. Every suspected bug was re-checked against the source before it counted.
The sharpest finding was the browser bridge. The live HTTP view wrote its POST body straight to the serial line with no check on who sent it. A text/plain POST is a CORS "simple request", so any site you visited while the bridge was running could fire one at 127.0.0.1 and push commands onto your board: a reset, a bootloader write, a shell on the target, with nothing on screen to show for it. DNS rebinding reached it even without permissive CORS. The fix pins Host and Origin to a loopback literal by default and answers anything else with a 403, so the built-in UI still works with no config while a cross-site request is turned away. --http-token layers a bearer check on top for when you deliberately tunnel the port, and the bridge only ever offers read methods to other origins.
The local sockets got the same scrutiny. The --detach and --metrics sockets had been sitting in /tmp under the inherited umask, reachable by any other user on the machine. They moved to a per-user runtime directory, are created owner-only, and check the peer's user id on every connection. Permissions and peer-credentials rather than a token, since nothing in a browser can reach them.
What stayed with me isn't the list of fixes. It's the discipline around them: each defect class became a written invariant tied to the exact bug that taught it, so the same mistake can't quietly return. Fix the class, not the instance.
And the rest of the toolbox
Everything above is the daily-driver surface. The long tail, all verified working against the source tree:
| Area | What you get |
|---|---|
| Framing & CRC | raw / COBS / SLIP / HDLC / length-prefixed decoders, with none / CCITT / IBM / CRC-32 checks, cycled live with Ctrl + A F / K. |
| File transfer | XMODEM (CRC and checksum) and YMODEM send in-tree; YMODEM receive and ZMODEM shell out to lrzsz. |
| Transports | Local serial, tcp://, and telnet:// (with IAC handling). |
| Logging & replay | text / JSONL / raw logs with size rotation, asciinema --rec casts, and --replay at any speed. |
| Browser bridge | --http serves a live view over HTTP + SSE + WebSocket, with --metrics exposing Prometheus text on a UNIX socket. |
| Automation | --on-match /RE/=CMD, --on-connect, --on-disconnect event hooks (a send: action injects TX); --filter pipes RX through any command. |
| Sessions | --detach / --attach over a Unix socket; --profile config files with inotify hot-reload. |
| Extras | Hex view, autobaud probe, line-ending translation, a live settings dialog (Ctrl + A o: serial, screen, keyboard, logging), --diff of two captures, and a small embedding API (zyterm_embed.a). |
The in-app controls all sit behind one Ctrl + A command menu; the network and automation features are launch-time flags. Ctrl + A then ? shows the full keybinding reference, dismissed by any key.
How it compares
A rough snapshot against the usual suspects on Linux. None of these are bad tools. Pick whatever fits.
| Feature | minicom | picocom | screen | tio | zyterm |
|---|---|---|---|---|---|
| Scrollback + in-stream search | — | — | · | · | ✓ |
| Live HUD (baud, throughput, sparkline) | — | — | — | — | ✓ |
| USB hot-plug rediscovery | — | — | — | ✓ | ✓ |
Network transports (tcp:// / telnet://) | — | — | — | · | ✓ |
| Timestamped + JSONL logging | · | · | — | ✓ | ✓ |
| HTTP / SSE bridge for browsers | — | — | — | — | ✓ |
| Single C binary, no runtime deps | ✓ | ✓ | ✓ | ✓ | ✓ |
How it's built
One C binary. libc is the only runtime dependency, and clipboard support dlopens libxcb at runtime rather than linking it, so it works headless too. Linux is the supported target on purpose: termios2 custom baud rates, inotify for config reload, /sys/class/tty for USB discovery. It may build on other Unixes, but no binaries ship for them. MIT licensed.
Every push builds under gcc and clang, each with and without the address and undefined-behaviour sanitizers, and runs the test suite on all of them. The amd64 and arm64 .deb packages are built from the same source tree, the same way, every release, and a tag is refused unless it matches the version baked into the binary.
A couple of paths are deferred on purpose rather than shipped half-done: rfc2217:// (run ser2net in raw mode and connect with tcp:// in the meantime) and real multi-pane. They're tracked openly in the repo's STATUS.md rather than oversold here.
Decisions, on the record
A lot of the work here was deciding what not to build, and writing down why. The repo keeps three layers apart on purpose: the decisions (why a thing is the way it is), the reference (how it works today), and a set of invariants where each rule is tied to the exact bug that taught it. A safety property you have written down is one you can test and review, instead of one you hope holds.
A few of the calls:
- Reconnect is on by default, because bench hardware is unreliable on purpose. You reset the board, reflash it, unplug the adapter to move it, and it comes back on a different
/dev/ttyUSB*. A terminal that exits on hang-up throws away your session for an event that is routine and self-healing, so zyterm waits and re-resolves the port instead. - History stays in memory. A serial console is not a shell. Its "commands" are device-specific byte sequences for one board on one bench, and a password typed at a login prompt has no business landing in a world-readable dotfile. Connection profiles earn a file on disk; scratch history does not.
- The unfinished fast path is not advertised. An experimental epoll-and-splice reader sits in the tree but is not wired into the loop, so it is not offered as a flag. The bar is plain: wired and tested, or removed. Until then it is not a feature.
Why it's here
For my portfolio, zyterm is a focused systems artifact: a tight poll(2) event loop, careful ownership across an optional reader thread, terminal UX that survives a device unplug, and a security posture (untrusted device RX, a loopback-pinned bridge, owner-only local control sockets) designed in rather than bolted on. Small, readable, and honest about what it doesn't do.
Get zyterm
amd64 + arm64 .deb for Ubuntu / Debian
Source
github.com/iskandarputra/zyterm
Getting started
Your first session, flags, and shortcuts
sudo dpkg -i zyterm_*.deb
zyterm /dev/ttyUSB0 -b 115200Quit with Ctrl + A then x. Pull the USB out whenever you like.