A GPU terminal emulator for Wayland on libghostty-vt, drawing with Vulkan, running a program on a pty or a serial port.
  • Zig 91.5%
  • Nix 8.1%
  • GLSL 0.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 3168213288
Some checks are pending
test / test (push) Has started running
Add tabs
A window now holds tabs: Tab.zig takes the session and what the window
kept about it (render state, selection, title, hold) out of Window, which
keeps the platform window, the renderer and the list. Only the shown tab
is drawn, but every tab's session is polled and resized with the window.

decor/TabBar.zig draws the strip of tabs under the title bar, shown once
there are two (window.tab_bar = auto | always | never): a press shows a
tab, the middle button or its x closes it, + or a double-click on the
empty bar opens one. Key bindings are GNOME Terminal's: ctrl+shift+t and
ctrl+shift+w, ctrl+page_up/page_down and ctrl+tab, ctrl+shift+page_up/
page_down to move, alt+1..9; close_window moves to ctrl+shift+q. A new
tab starts in the shown tab's directory, from OSC 7 or /proc/PID/cwd.

`harrier --tab` and the new-tab action put a tab in the window last
opened or focused, raising it with the activation token. LaunchCommand
now opens one window with a tab per command, as the Terminal Intent
suggests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KaBaVYY8vv3qA6sgbQBuZT
2026-10-10 17:38:00 -05:00
.forgejo/workflows Add a Home Manager module, and harrier validate-config to check its file 2026-10-10 16:08:46 -05:00
dbus Implement the Terminal Intent: org.freedesktop.Terminal1 2026-10-10 16:38:58 -05:00
dist Add tabs 2026-10-10 17:38:00 -05:00
LICENSES One harrier for every window, through the session bus 2026-10-10 14:59:31 -05:00
nix Add a Home Manager module, and harrier validate-config to check its file 2026-10-10 16:08:46 -05:00
shaders A window: Wayland platform, Vulkan renderer, input and the app loop 2026-10-10 13:10:04 -05:00
src Add tabs 2026-10-10 17:38:00 -05:00
tests/nixos Add tabs 2026-10-10 17:38:00 -05:00
tools Start harrier: the headless core 2026-10-10 12:15:00 -05:00
.gitignore Start harrier: the headless core 2026-10-10 12:15:00 -05:00
build.zig Implement the Terminal Intent: org.freedesktop.Terminal1 2026-10-10 16:38:58 -05:00
build.zig.zon Draw box drawing, blocks, braille and the like ourselves 2026-10-10 15:40:25 -05:00
build.zig.zon.nix One harrier for every window, through the session bus 2026-10-10 14:59:31 -05:00
flake.lock Add a Home Manager module, and harrier validate-config to check its file 2026-10-10 16:08:46 -05:00
flake.nix Add a Home Manager module, and harrier validate-config to check its file 2026-10-10 16:08:46 -05:00
package.nix One harrier for every window, through the session bus 2026-10-10 14:59:31 -05:00
README.md Add tabs 2026-10-10 17:38:00 -05:00
REUSE.toml Draw box drawing, blocks, braille and the like ourselves 2026-10-10 15:40:25 -05:00

harrier

A GPU terminal emulator in Zig 0.17. The terminal itself — parsing, the screen, scrollback, modes, key and mouse encoding — is libghostty-vt. harrier is everything around it: a Wayland window, a Vulkan renderer, fonts, and a program on a pseudo-terminal or a serial port at the other end.

$ harrier                                   # your shell
$ harrier -e htop                           # a program
$ harrier --serial /dev/ttyUSB0 --baud 9600 # a serial port

One harrier serves every window. A launch with a harrier already running hands its window to that one and exits, so a new window opens at once. Where the systemd user service is installed, the session bus starts harrier for the first window too; see Running as a service.

It is new, and runs on Linux under Wayland. The seams for a Windows port are in place (see Design), and nothing behind them exists yet.

What it does

  • The terminal is Ghostty's. It uses libghostty-vt from Ghostty's zig-0.17 branch (pull request #14519): xterm and kitty keyboard and mouse encoding, synchronized output, OSC 52 clipboard writes, titles, scrollback, and the rest of what Ghostty's own terminal does.
  • A pty or a serial port. The window shows whatever the backend is connected to. A program runs on a pseudo-terminal with TERM, COLORTERM=truecolor and TERM_PROGRAM=harrier set. A serial port is opened raw through zig-serial, at any rate the driver accepts.
  • Wayland, without libwayland. The window and its input go through zig-wayland-native and zig-xkb:
    • fractional scaling;
    • server-side decorations;
    • key repeat;
    • dead keys and the Compose key;
    • the clipboard, and the primary selection: select to copy, middle-click to paste;
    • input methods through text-input-v3;
    • cursor shapes;
    • its own title bar and frame where the compositor draws none.
  • Vulkan, through dma-bufs. Every frame is instanced quads drawn from a glyph atlas. There is no wl_display for a Vulkan swapchain, so harrier does what a swapchain does itself: it allocates exportable images the compositor reads directly, in a layout (DRM format modifier) both sides share. The frame is drawn straight into one of these.
    • Without modifier support (RADV on Polaris and older, say), there is no image both can use. The buffer is then a linear dma-buf, and the frame is copied into it on the GPU.
    • Sync is explicit (linux-drm-syncobj) where the compositor offers it, and implicit otherwise.
    • Where no memory can be shared at all, the frame is read back into shared memory.
  • Jeff's font stack.
    • Fonts are found the way fontconfig finds them, by zig-font-config.

    • Text is shaped by zig-font-shaper, a port of HarfBuzz, so ligatures and combining marks work. Only rows that changed are shaped again.

    • Glyphs are drawn by zig-font-renderer, including COLR color glyphs.

    • Some glyphs are drawn rather than taken from the font, the way Ghostty does it, with Ghostty's own drawing code:

      • box drawing, block elements and shades;
      • braille;
      • Powerline's separators and branch symbols;
      • geometric shapes;
      • the symbols for legacy computing (sextants, octants, wedges).

      Drawn to the cell, lines meet their neighbours without gaps and braille dots fill the cell, whatever the font's own versions look like.

  • Tabs. A window holds as many terminals as it is given, with a strip of tabs above them once there are two. New tabs start in the directory the shown one is in.
  • One process, many windows. The first harrier owns dev.jcollie.harrier on the session bus and serves org.freedesktop.Application. Later launches hand their windows to it with their command, working directory and environment, and launchers use the same interface through the desktop entry's DBusActivatable=true. Windows opened this way take focus through xdg-activation.
  • A terminal for other programs to launch in. harrier implements the Terminal Intent, org.freedesktop.Terminal1, so a launcher can run a program whose desktop file says Terminal=true in it without knowing harrier's command line.
  • Configured in struthio. Its programs can ask which machine they are running on.

Building and running

$ nix develop
$ zig build run                       # build, then run your shell
$ zig build run -- -e vim README.md
$ zig build test --summary all

The development shell has Zig 0.17.0, glslang for the shaders, the Vulkan loader and its validation layer, and REUSE. --validation turns the layer on.

The font libraries are built ReleaseSafe even in a Debug build. Reading fontconfig's cached patterns takes seconds when they are unoptimized.

The package and the VM test:

$ nix build                                   # result/bin/harrier
$ nix build .#checks.x86_64-linux.sway        # harrier in a NixOS VM

The package's Zig dependencies come from build.zig.zon.nix, which zon2nix generates. After changing build.zig.zon, regenerate it:

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

The VM test runs harrier in a headless sway, with lavapipe standing in for a GPU. pixman offers no dma-bufs, so this run presents through shared memory. It checks that the screenshot has text in it.

Usage

harrier [options] [-e command [args...]]
harrier validate-config [--hostname NAME] FILE

  -e, --command PROG ARGS...  run PROG in the window instead of the shell
  --serial PATH               open a serial port instead of running a program
  --baud RATE                 the serial port's rate (default 115200)
  --config PATH               the configuration file to read; also keeps
                              the window in this process
  --tab                       open a tab in the harrier window last used,
                              rather than a window
  --standalone                open the window in this process rather than
                              handing it to a harrier already running
  --service                   run as the session bus service, with no
                              window until one is asked for
  --validation                turn on Vulkan's validation layer
  --version, -h, --help

The default key bindings:

Keys Action
ctrl+shift+c / ctrl+shift+v copy / paste
shift+insert, middle button paste the primary selection
shift+page_up / shift+page_down scroll a page
ctrl+shift+home / ctrl+shift+end scroll to the top / bottom
ctrl+=, ctrl+-, ctrl+0 font size larger, smaller, as configured
ctrl+shift+n / ctrl+shift+q new window / close window
ctrl+shift+t / ctrl+shift+w new tab / close tab
ctrl+page_down / ctrl+page_up, ctrl+tab / ctrl+shift+tab next / previous tab
ctrl+shift+page_up / ctrl+shift+page_down move the tab left / right
alt+1 … alt+8, alt+9 that tab, the last tab

Holding shift takes the mouse back from a program that has asked for it, so that text can still be selected.

They are GNOME Terminal's. alt+1 to alt+9 are taken from programs that use them, as GNOME Terminal takes them; .unbind them to give them back.

On the tab bar, a press shows a tab, the middle button or the × closes one, and + or a double-click on the empty part opens one. Closing the last tab closes the window. With keep-terminal-open or a program that could not be started, a tab stays after its program has gone, and a key closes it.

Running as a service

zig build -Dbindir=/where/harrier/is installs four things beside the binary. Each of the first three names the binary by its absolute path, which is why the option exists; the Nix package passes it.

  • share/applications/dev.jcollie.harrier.desktop: the desktop entry, with DBusActivatable=true, Implements=org.freedesktop.Terminal1 and a New Window action.
  • share/dbus-1/services/dev.jcollie.harrier.service: lets the session bus start harrier when a window is asked for, through systemd.
  • share/systemd/user/app-dev.jcollie.harrier.service: harrier as a user service, harrier --service. It starts with no window, opens the ones it is asked for, and exits five minutes after the last one closes (see service.idle_exit_seconds). Stopping it closes its windows.
  • share/icons/hicolor/*/apps/dev.jcollie.harrier.png: the icon.

The Terminal Intent

The same object, /dev/jcollie/harrier, serves org.freedesktop.Terminal1 beside org.freedesktop.Application, as the Terminal Intent asks. LaunchCommand opens one window with a tab for each command it is given, as the specification suggests, or with the shell for none:

  • exec is the program and its arguments; empty runs the shell.
  • env is set on top of harrier's environment rather than replacing it.
  • working_directory is where it starts. Without one, the desktop entry's Path is used, and failing that $HOME.
  • desktop_entry, when given, names the tabs after the entry's Name. One that is not a desktop entry refuses the call with org.freedesktop.DBus.Error.InvalidArgs.
  • keep-terminal-open keeps each tab after its program exits, saying how it exited, until a key is pressed.
  • The activation token in platform_data goes to the window.

A program that cannot be started still gets its tab, saying why and waiting for a key, and the rest of the commands are launched regardless. The paths and arguments are ay; the NUL that GLib's bytestrings end with is dropped.

To try it by hand, busctl takes each ay as a length and its bytes:

$ busctl --user call dev.jcollie.harrier /dev/jcollie/harrier \
    org.freedesktop.Terminal1 LaunchCommand 'aa{sv}aya{sv}a{sv}' \
    1 1 exec aay 1 3 116 111 112 \
    0 1 keep-terminal-open b true 0

(gdbus call with b'…' literals nested in variants is no use here: it sent corrupted bytes when this was tested.)

On NixOS, or with the Home Manager module:

{
  environment.systemPackages = [ harrier ];
  services.dbus.packages = [ harrier ];
  systemd.packages = [ harrier ];
}

The service gets its environment from the systemd user manager, which needs the graphical session's: WAYLAND_DISPLAY above all. Desktop sessions export it at login with dbus-update-activation-environment --systemd WAYLAND_DISPLAY, or systemctl --user import-environment. A window handed over from a shell still runs with that shell's environment and directory, because the launch sends both.

Configuration

harrier reads harrier/config.struthio from the configuration directory known-folders finds: $XDG_CONFIG_HOME, or ~/.config. Without the file, it uses the defaults. A struthio file is ZON that can make decisions:

.{
    .font = .{
        .family = "Iosevka Term",
        .size = if (ctx.hostname == "laptop") 11 else 13,
        .features = .{ "ss05" },
    },
    .colors = .{ .background = "#1d1f21", .foreground = "#c5c8c6" },
    .cursor = .{ .style = .bar, .blink = false },
    .command = .{ "fish", "--login" },
    .keybind = .{
        .{ .keys = "super+c", .action = .copy },
        .{ .keys = "ctrl+shift+n", .action = .unbind },
    },
}

ctx.hostname and ctx.os say where it is running, and @env("NAME") reads the environment. Every field is in src/config/Config.zig:

  • font: the family, as a fontconfig pattern; the size in points; OpenType features; ligatures on or off; and builtin_glyphs, on by default, which draws box drawing, blocks, braille and the like to fit the cell rather than taking them from the font.

  • colors: the foreground, the background, the cursor, and palette overrides. A color is anything libghostty-vt parses, X11 names included.

  • cursor: the style and whether it blinks.

  • window: the padding, the initial size in cells, and who draws the title bar and frame:

    • .server (the default) asks the compositor, and harrier draws its own where the compositor will not, as on GNOME or weston;
    • .client always draws harrier's;
    • .none draws nothing.

    And tab_bar: .auto (the default) shows the tabs once there are two, .always from the first, .never not at all.

  • command or serial: what the window shows. serial takes the path, rate, data bits, parity, stop bits and flow control.

  • term: what TERM is set to (xterm-256color).

  • scrollback_bytes: how much scrollback to keep.

  • present: .auto, .dmabuf or .shm.

  • gpu: part of a Vulkan device's name ("Radeon", "llvmpipe"), to choose one on a machine with several. Unset uses the device the compositor composites with.

  • service: idle_exit_seconds, how long the service keeps running without a window. The default is 300; null keeps it running.

  • keybind: bindings added on top of the defaults.

Checking a configuration

$ harrier validate-config ~/.config/harrier/config.struthio
/home/me/.config/harrier/config.struthio: colors.palette[3]: "gren" is not a color
/home/me/.config/harrier/config.struthio: keybind[0]: "hyper+x" is not a key harrier can read

validate-config evaluates the file and checks what struthio sees only as strings: the colors, the key bindings and the font features. It reports every problem rather than the first, exits 1 if there were any, and opens no window. A running harrier would refuse to start over a bad color but only warn about a bad binding or feature; this treats them all as mistakes. --hostname NAME sets what the file sees as ctx.hostname, to check a file meant for another machine.

Home Manager

The flake has a Home Manager module, homeModules.harrier. It installs harrier, writes its configuration, and installs the D-Bus service file and systemd user unit through dbus.packages and systemd.user.packages, so that one harrier opens every window.

The configuration is a struthio file, given as text or as a file, not generated from Nix settings:

{
  inputs.harrier.url = "git+https://git.jcollie.dev/jeff/harrier.git";

  # in a Home Manager configuration:
  imports = [ inputs.harrier.homeModules.harrier ];

  programs.harrier = {
    enable = true;
    configText = ''
      .{
          .font = .{ .family = "Iosevka Term", .size = if (ctx.hostname == "laptop") 11 else 13 },
      }
    '';
    # or: configFile = ./harrier.struthio;
    # what ctx.hostname is while it is checked; the sandbox is `localhost`
    validateHostname = "laptop";
  };
}
  • configText or configFile: one or the other, or neither for the defaults. A Nix path in configFile is copied into the store; a string naming a file outside it ("/home/me/dotfiles/harrier.struthio") is linked to where it is, so editing it needs no rebuild.
  • validate (on by default): the file is run through harrier validate-config as it is built, so a mistake fails home-manager switch rather than the next window. A file linked from outside the store cannot be read by the build and is not checked. The check runs in the build sandbox, where @env finds almost nothing set.
  • validateHostname: what ctx.hostname is during the check.
  • service.enable (on by default): the D-Bus and systemd user service. The service reads its configuration when it starts, and stopping it closes every window, so switching does not restart it; a new configuration applies once it next starts.
  • package: harrier from this flake, by default.

Design

                    ┌────────────── main thread ──────────────┐
 compositor ◀──────▶│ platform  ─▶ Window ─▶ frame.Builder    │
 (Wayland)          │ (events)       │         ─▶ Renderer ─▶ Target ─▶ dma-buf / shm
                    │                │ lock                    │
                    └────────────────┼─────────────────────────┘
                                     ▼
                              termio.Session  (Terminal + stream, behind a mutex)
                                     ▲ nextSlice     │ outbox
                         IO thread ──┘               ▼
                              Backend: Pty | Serial
  • backend/: what is on the other side.
    • Backend is a tagged union: Pty, which forks the program onto a pseudo-terminal using raw system calls, and Serial.
    • A Windows port adds a ConPTY variant.
  • Activation: the session bus. It owns the name and serves org.freedesktop.Application and org.freedesktop.Terminal1, on a thread of its own, through zig-dbus-service. Its handlers only queue the windows and tabs asked for and wake the main loop. It is also the client a launch uses to hand its window, or with --tab its tab, over: the new-window and new-tab actions.
  • termio/Session: one terminal.
    • Its IO thread is the only reader and writer of the backend. It feeds what it reads through libghostty-vt's stream with the terminal's lock held.
    • Keystrokes, and the terminal's own replies to queries, go out through a locked outbox, so the main thread never blocks on a slow port.
  • platform/: the window system, chosen at compile time.
    • The Wayland backend speaks in the portable types of Event.zig.
    • A Windows backend would produce the same types from window messages.
  • renderer/:
    • frame.Builder turns libghostty-vt's RenderState into rectangles.
    • vulkan/Renderer draws them in one instanced call.
    • A Target says where the frame goes. Either it hands the renderer a framebuffer on its own image to draw into, or the renderer draws offscreen and the target copies the image out. The targets are wayland/Dmabuf, which does either, and wayland/Shm, which copies. A Windows target would be a swapchain on VK_KHR_win32_surface.
  • Window and Tab: a window is the platform window, the renderer and its tabs; a tab is a session and what the window keeps about it — what was last drawn of it, its selection, its title. Only the shown tab is drawn. Every tab's session is polled, so a hidden program's output is read and its exit noticed, and every tab is resized with the window.
  • decor/Decorations: harrier's own title bar and frame, drawn by the same renderer as the grid. It is the insets around the grid, the hit-test that turns a press into a move or resize request, and the quads that draw it. Nothing in it is specific to Wayland.
  • decor/TabBar: the strip of tabs, drawn by harrier under whoever's title bar, likewise as insets, a hit-test and quads.
  • font/:
    • Grid: the four styled fallback lists, and the cell.
    • shape: one row at a time, with each cell's index as its cluster.
    • Glyphs: rasterized once, into one of two atlases.
    • sprite/: the glyphs harrier draws itself. draw/ and canvas.zig are Ghostty's src/font/sprite, drawing with z2d; Sprite.zig collects the draw<CP> and draw<MIN>_<MAX> functions into a table of ranges at compile time, as Ghostty's Face.zig does. The shaper hands such a cell over without shaping it.
    • Atlas: a skyline packer ported from Ghostty's, after Jylänki.

Testing

zig build test covers:

  • the pty and serial backends, against real pseudo-terminals;
  • the title bar and frame: insets, and what is under the pointer at each edge, corner and button;
  • a session end to end: a program's output, a device-attributes query answered back through the pty, titles, OSC 52, keystrokes, resizes;
  • configuration and key bindings, and validate-config's checks;
  • the tab bar: when it shows, and what is under the pointer as tabs multiply and narrow;
  • the atlas, and shaping and rasterizing with whatever fonts the machine has;
  • the built-in glyphs: every code point in every range draws, and lines and blocks land on the cell's edges.

The Wayland and Vulkan code is compiled by the tests, run in the VM test, and exercised by running it. It has been run under a headless sway: typing, Compose, dead keys, CJK fallback, 256-color and SGR rendering, dma-buf presentation with explicit sync, a serial port through a socat pty pair, and harrier's own decorations, both forced on sway and falling back on weston, and the built-in glyphs beside the font's own. Drawing straight into a dma-buf has been run on lavapipe (gpu = "llvmpipe"), whose images sway imports, including a resize, with the validation layer clean. The RX 480 it was developed on has no modifier support, so there it copies.

nix flake check runs the package's tests in the sandbox and these NixOS VM tests:

  • sway: a window on a headless sway, with text in it;
  • activation: two launches, one systemd user service started by the session bus, taking both windows; --tab, opening a tab in the last window rather than a window; then LaunchCommand: one window with a tab per command, named after the desktop entry, one held after its program exits and one saying its program does not exist, a bad desktop entry refused, and keys closing the held tabs and then the window;
  • home-manager: the Home Manager module alone: the configuration written where harrier reads it, the service found through what the module linked, and a window drawn in the configured background.

config-rejected checks that a configuration with mistakes in it fails the build that wants it, naming each one.

Not yet

  • Not drawn yet:
    • splits, and dragging tabs about or out into a window of their own;
    • a confirmation for pastes that contain a newline (they go through as xterm sends them);
    • kitty graphics.
  • Emoji: emoji fonts made of bitmaps (CBDT, sbix) are not drawn, so emoji come from whatever outline font has them.
  • Platforms: the BSDs and macOS have no serial backend in zig-serial, and nothing here has a Windows backend.

Where this lives

The canonical repository is on my Forgejo instance, and Tangled carries a mirror of it:

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

CI runs on the Forgejo repository.

License

MIT; see LICENSES/MIT.txt. The project follows the REUSE specification.

src/font/Atlas.zig and everything in src/font/sprite/ but Sprite.zig and font.zig are ported from Ghostty, © 2024 Mitchell Hashimoto and Ghostty contributors, also MIT.

The icon, in dist/icons, is cropped from Becky Matsubara's photograph Circus hudsonius, male perched, Berkeley, California (2018), licensed CC BY 2.0. It was scaled and converted to PNG.

References cited