A terminal emulator. Successor to
footin the same way your hand is the successor to your foot.
hand is a native, keyboard-driven terminal emulator with no GTK, no VTE, and
no SDL — just a bare Wayland or X11 surface (EGL) on Linux, a Cocoa +
NSOpenGL window on macOS, and a from-scratch GPU terminal engine underneath.
It is a thin, well-mannered frontend over libtoe: hand opens the
window, spawns the child shell, reads your config, and gets out of the way.
toe does the actual terminal-ing.
Because someone looked at foot — a genuinely excellent terminal, we love it,
no notes — and thought: "you know what would make this better? A worse name and
a completely different codebase."
More seriously: hand is the successor to the original GTK/VTE termite,
reborn on a GPU stack that we own top to bottom. No 400 MB of GNOME libraries to
render ls. No mystery VTE version pinning. Just pixels, a PTY, and vibes
(literally — see config).
cmake -S . -B build
cmake --build build -j # the -j is not optional if you value your afternoon
./build/handOn Linux the build needs Wayland/X11/EGL/xkbcommon dev packages; on macOS
it needs only Homebrew's libepoxy + libpng (the Cocoa/OpenGL frameworks ship
with the OS) — CMake picks the Cocoa backend automatically via if(APPLE). Point
pkg-config at Homebrew if needed:
PKG_CONFIG_PATH="$(brew --prefix)/lib/pkgconfig:$(brew --prefix libpng)/lib/pkgconfig" \
cmake -S . -B build && cmake --build build -j && ./build/handThe build pulls in libtoe from the sibling ../toe directory if it's there,
and downloads it from GitHub if it isn't. Same deal for the config parser. It's
polite like that — checks the neighborhood before ordering online.
Pro tip: if
./build/handopens a window and a shell appears, congratulations, you now have a hand. Wave it around a bit.
hand is configured with a VIBE file — a small,
opinionated config format where key value, objects go in { }, # starts a
comment, and there is emphatically no = and no :. If you type port = 8080
into a VIBE file, VIBE will look at you with quiet disappointment and parse
nothing.
The config is read from the first of:
-c PATH/--config PATH$XDG_CONFIG_HOME/hand/config.vibe(or~/.config/hand/config.vibe)
If your config has a typo, hand prints the error to stderr and calmly falls
back to built-in defaults instead of throwing a tantrum and refusing to start.
A terminal that won't open because a hex color is malformed is not a friend.
A sample lives in config.vibe:
font {
family "monospace" # any fontconfig name
size 11 # points — hand scales it to pixels at 96 DPI for you
}
colors {
foreground "#dcdccc"
background "#171720"
}
That's the whole config surface today. If you were hoping for 200 tunable knobs, this may not be your terminal — but the four things you actually change are all here.
hand is small and layered — each concern is its own tiny, typed unit rather
than one big procedural loop:
| File | Role |
|---|---|
main.cpp |
just the shape of a frame: five named steps |
event_router.hpp |
input policy as an exhaustive std::visit over the closed event sum — one named handle() per event kind, no if ladder |
config.{hpp,cpp} |
VIBE → toe::Config, with a HexColor newtype (invalid strings can't masquerade as colours) and RAII over the C parser handle |
blink.hpp |
Millis, SquareWave<Tag, Period>, BlinkState — the cursor/text blink waves as strong types, not (ms/530)%2 scattered inline |
poll_set.hpp |
a Timeout value + a PollSet builder over poll(2) — you add() named fds and ask ready(fd), never juggle pollfd[3] + nfds |
Most of main.cpp is comments explaining the two or three genuinely clever bits:
- Zero-latency local echo. When you type,
handhands the byte to the child, thenpoll()s the PTY for a whole 3 milliseconds hoping the shell echoes it back in time to render in the same frame — because a keystroke that shows up one vsync late feels awful. Shells echo in microseconds, so this basically always wins and never actually waits. - It sleeps. The main loop
poll()s until the child has output, a window event lands, or the cursor-blink timer fires. No 100%-CPU busy-spin. Your fan will not knowhandis running. (During ayes/cat /dev/urandomflood it skips the nap and keeps draining, so the UI stays alive while the screen melts.) - It only draws when something changed. A
RenderKey{generation, blink}folds the damage counter and both blink phases into one comparable value; if this frame's key equals the last drawn one, nothing is rendered. Idle terminal = zero wasted GPU frames, except the ~530 ms heartbeat to blink the cursor. - The window title follows the app (OSC 0/2), OSC 52 clipboard requests
are honored, and inline-image animations (kitty
a=f) tick along at their intended framerate.
The window comes from toe::platform, the terminal from toe::Terminal, the
config from vibe.h. hand is the glue, and it's proud of being just glue.
Whatever libtoe needs, which is: a C++23 compiler, CMake, EGL, Wayland
(wayland-client/egl, xkbcommon), X11 (x11, xcb, xkbcommon-x11),
FreeType, HarfBuzz, Fontconfig, and epoxy.
Notably absent: GTK, VTE, SDL, Electron, a browser engine, a JavaScript
runtime, or 1.2 GB of node_modules. It renders text in a box. It does not need
a village.
hand doesn't work alone — it's the frontend of a three-part stack that all
lives under ../:
| Repo | Role |
|---|---|
hand |
this — the app: window, config, wiring (you are here) |
toe |
the engine: PTY, VT parser, grid, GPU renderer — pure Elm-style core |
vibe |
the config format hand reads (config.vibe) |
LGPL-2.0-or-later. Use it, ship it, fork it.
Runs vim. Runs tmux. Runs htop. Applauds when you're done. ✋