A from-scratch Zig reimplementation of the Rust system-monitor TUI. Zig has
no equivalent of sysinfo, ratatui/crossterm, tokio, or serde+toml,
so this isn't a syntax transliteration — the OS-facing pieces are rewritten
against Linux's /proc filesystem and raw terminal control directly.
- Zig 0.14.x (built and tested against 0.14.1 specifically).
- Linux. All system-data collection goes through
/proc; there's no macOS or Windows backend. Porting further would mean addingsysctl-based collectors for macOS and WMI/PDH-based ones for Windows — a genuinely separate effort, not a small tweak.
The current stable release, 0.16.0 (April 2026), shipped a sweeping
rewrite ("I/O as an Interface") that threads an Io instance through
essentially every file, process, and even random-number operation, on top
of 0.15's earlier std.io.Writer/Reader overhaul ("Writergate"). Both
are recent enough, and large enough, that I couldn't write against them
reliably without a compiler to check my work against — even Zig's own
release notes describe parts of the new interface as experimental. 0.14.1
is the last release before that rewrite: stable, thoroughly documented,
and what most existing Zig code and tutorials still target. If you want
this on 0.16+, the diff is substantial (Io params on most calls in
term.zig, config.zig, and the collectors) — happy to help with that
migration as a separate pass if you'd like it.
zig build runor build then run separately:
zig build
./zig-out/bin/system-monitorPress q or Esc to quit, ↑/↓ to move the process selection,
PgUp/PgDn to jump by 10.
I don't have a way to hand you code I merely believe compiles — I got a
real Zig 0.14.1 toolchain running in my sandbox (the ziglang PyPI
package, which bundles official release binaries) and used it:
zig buildsucceeds with no errors or warnings.- Every collector ran against this sandbox's real
/procand returned sane values — including watching CPU% correctly jump to ~100% and ayesprocess correctly rise to the top of the process table when I ran one as a synthetic load, which confirms the delta-based CPU sampling (see below) is actually computing the right thing, not just compiling. - The full interactive binary ran end-to-end attached to a real
pseudo-terminal (Python's
ptymodule): raw mode, alternate screen, colors, the Unicode gauge/box characters, the process table, three ticks at the correct ~1-second cadence,qcorrectly quitting, and the terminal being cleanly restored afterward (cursor shown, alternate screen exited) — all confirmed from the captured raw output. - The config file load → edit → reload round-trip was tested directly, including comment lines.
term.zig (raw mode, polling, ioctl for terminal size) is the one file
most dependent on exact OS/libc behavior rather than pure logic — it's
kept isolated so if anything ever needs adjusting on a different machine,
the change stays contained to that one file.
build.zig
src/
main.zig entry point, tick/input loop
models.zig data structs (→ models.rs)
utils.zig byte/percent formatting (→ utils.rs)
controller.zig process-list scroll state (→ controller.rs)
config.zig settings load/save (→ config/mod.rs)
term.zig raw mode, alt screen, key polling (→ crossterm)
ui.zig frame rendering (→ ui/mod.rs, ratatui)
collector/
collector.zig aggregates the sub-collectors (→ collector/mod.rs)
cpu.zig, memory.zig, disk.zig, network.zig, process.zig
| Rust crate / file | Zig approach |
|---|---|
sysinfo |
Direct /proc parsing: /proc/stat, /proc/meminfo, /proc/net/dev, /proc/[pid]/{stat,comm,status}. Disk space shells out to df -P -k (see disk.zig for why, not a syscall). |
ratatui + crossterm |
term.zig (raw std.posix termios/poll/ioctl) + ui.zig (hand-written ANSI rendering — text gauges instead of ratatui's bordered-box widgets). |
tokio |
None needed. The original's tokio::select! between a tick and a 10ms input-poll sleep becomes a plain loop in main.zig that checks elapsed time each iteration — same two branches, sequenced instead of raced. |
serde + toml |
A small hand-rolled parser/writer in config.zig for the two known fields. The file it writes is still valid basic TOML. |
anyhow / thiserror |
Zig's built-in error unions; no crate needed. |
I kept this a faithful port by default, but flagged (rather than silently fixed) a couple of things:
process_limitis actually used. In the Rust version,config.rsdefinesprocess_limit(default 15) butui/mod.rs'srender_processeshardcodes.take(15)instead of reading it — the setting was loaded but never wired up.ui.zigreads it fromSettings. Since the default is also 15, behavior is identical unless you actually change the setting. If you want to match the original's hardcoded-15 exactly, pass15instead ofsettings.process_limitinmain.zig.- CPU sampling no longer blocks. The original's
cpu.rssleeps 200ms inside everycollect()call to get two samples far enough apart (that's howsysinfo's refresh works).cpu.ziginstead keeps the previous/proc/statsample across ticks and diffs against it each time — the same techniquetop/htopuse — so collection never blocks. Only visible effect: CPU% reads 0% on the very first frame instead of after a 200ms delay. - Disk targets
/explicitly. The original takes whatever disksysinfohappens to enumerate first, which depends on OS-reported ordering.disk.zigtargets the root filesystem by name instead — changeDiskCollector.mount_pointif you want a different one.
Everything else — including things I didn't fix, like network stats
being collected but never shown in the UI, and Ctrl+C not quitting the
app because raw mode disables ISIG and nothing handles it explicitly —
matches the original on purpose.
$XDG_CONFIG_HOME/system-monitor/config.toml, falling back to
$HOME/.config/system-monitor/config.toml — same location logic as the
original's dirs::config_dir() on Linux. Created with defaults
(update_ms = 1000, process_limit = 15) on first run if missing.