Skip to content
termworksPublic

About

terminal multiplexer based on libghostty

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

hexe

A session-based terminal workspace where the frontend is disposable and your shells are not.

Crash the terminal frontend, restart it, reattach, and your terminals keep running exactly where you left them.


How it works

hexe splits into four layers:

  • hexe terminal — the terminal UI frontend (aliases: hexe mux, hexe multiplexer).
  • shared frontend runtime — attach lifecycle, transport, and the frontend-side session projection.
  • hexe session / hexe ses — the session authority that owns canonical session state.
  • hexe pod — one per pane. Owns the PTY, holds the shell, buffers output even while detached.

See architecture for the full picture.


Palette namespaces

A program can claim its own 256-colour table — one of 32 numbered slots — for the output it writes. Recolour that table and only its cells change — the rest of the pane stays exactly as it was, on screen and in scrollback, with no redraw from the application.

hexe palette set --ns 4 33=#ff00aa bg=#1a1020
hexe palette get                                    # what is actually set

An application drives it directly, with nothing to negotiate first — claim a slot, print, release it:

printf '\033]1330;set;4;33=#ff00aa\033\\'
printf '\033]1330;use;4\033\\'
printf 'this line resolves colour 33 through slot 4\n'
printf '\033]1330;end\033\\'

Slot 0 is what unclaimed output resolves against, slot 1 is hexe's own chrome — borders, status bar, float titles — and slots 2–31 are yours.

hexe holds the colours and resolves the indexes; it never decides which cells belong to which slot. Every cell records the slot that was current when it was written — the number itself, so there is no mapping to lose — and two slots are correct on screen at once, with a repaint reaching scrollback. Setting slot 0 recolours the ordinary palette; setting slot 1 recolours hexe's own furniture. Anything hexe does not recognise — an unknown name, a program that claims nothing, another terminal entirely — falls back to your own palette, so a default install looks exactly as it did before.

See the palette protocol for the sequences to emit.


Foreground colour mixing

OSC 1331 can soften ordinary ANSI, 256-colour, default or truecolour text against its effective cell background without changing the application's SGR palette choices.

printf '\033]1331;use;fg=30\033\\'
printf '\033[31m30%% red, 70%% cell background\033[0m\n'
printf '\033]1331;end\033\\'

The percentage is recorded per cell and composes with OSC 1330 namespaces. See foreground colour mixing for the exact private protocol, fallback rules, capability query and host-palette refresh behavior.


Anything can drive it

Everything hexe knows about its panes, floats, tabs and session has one definition — the live Lua API — and a socket hands that same API to any program that can open one. There is no second list of fields to drift out of step with the first, and nothing has to scrape formatted output for facts hexe holds exactly.

hexe api panes                      # every pane, as JSON
hexe api count '"panes"'
hexe api act '{"type":"split.v"}'
hexe api send '"904dd85…"' '"make test\n"'

hexe api is a thin client; the socket is the interface. Frames are a 4-byte big-endian length and a JSON body, so a gateway, a phone client behind one, or a shell script are all the same amount of work. Instead of polling, a client can subscribe and be told.

Three doors, each with its own authority, because a grant a caller can decline is not a grant:

socket who holds it may do
api@<session>.sock you everything
plug@<session>.<name>.sock one plugin only what it declared
pane@<uuid>.sock whatever runs in that pane read and type into that pane

A plugin is a package that declares what it needs — stream, typing, keyboard, popup — and is handed a socket carrying exactly that; a verb it did not ask for is refused by name. A pane's socket is exported to its shell as $HEXE_PANE_API_SOCKET, so a program in a pane finally has something it can safely be given: a session-wide call is refused, and a selector naming another pane resolves to nothing rather than to the caller's own.

For another Lua — a shell, an editor, a sibling tool — hexe lua-api prints a plain-Lua client, and the client verb returns the same source over the socket for a host that cannot shell out:

local mux = hexe.connect()            -- or connect_pane(), inside a pane
for _, pane in ipairs(mux.panes()) do print(pane.name, pane.cwd) end

See the control socket, access and plugins.


Docs

One document per feature in docs/, each opening with a recording of it running: how it works, how it differs from tmux, what it cannot do, and where it lives in the tree. Every claim in them was checked against the source or a running build, and the recordings are made by hexe itself — see recording.

Four processes frontend, runtime, SES, pod — and why only one is disposable
Sessions detach, reattach, replay, adoption, and what is written down
Pods one daemon per pane: the PTY, the backlog ring, exactly-once input
Instances two whole stacks on one machine that cannot see each other
Panes and tabs the split tree, geometric focus, zoom, broadcast, select-and-swap
Floats overlay panes with lifetimes: per-directory, sticky, exclusive, sandboxed
Keybindings no prefix: chords, conditions, and what happens to the key afterwards
Reading what happened scrollback search, copy-mode, OSC 133 prompt marks
Overlays and popups notifications, questions, pickers, keycast, pane labels
Painting the bar, titles, sprites and popups are drawn by an external painter
Shell integration what a shell reports to the mux, and how the prompt is drawn
Palette protocol a program claims its own 256-colour table for the output it writes
Foreground mixing mix ordinary ANSI foregrounds with each cell's effective background
Configuration one Lua file, a schema that refuses typos, reload without losing panes
Project sessions .hexe.lua, freezing a session, and the trust ledger
Isolation namespaces and cgroups per pane — and what it needs from the kernel
The command line addressing sessions, panes and pods from a script
The control socket the live API over a socket: what any program can ask and do
Access what a helper may do — stream, typing, keyboard, popup — declared and enforced
Plugins install, declare, approve, remove — a package, not a command string
Streaming a pane a pane's bytes, who is watching, and how to cut them off
Dictation speech to text as a tool hexe drives, and the sign that a mic is open
Names how panes and sessions get names, and what a name is allowed to be
Decorations borders, titles and what a pane is allowed to draw around itself
Recording hexe writes asciicasts of itself; every film in the docs was made that way

Quick start

Nix. The flake builds a static musl binary on x86_64-linux and aarch64-linux:

nix build                      # result/bin/hexe
nix run . -- --help
nix flake check                # build and package smoke checks

Add this input to another flake:

inputs.hexe.url = "github:termworks/hexe/develop";

Accept hexe in your flake's outputs arguments. In a development shell, add hexe.packages.${system}.default to packages. For NixOS, pass hexe through specialArgs, accept it in the module arguments, and use:

environment.systemPackages = [
  hexe.packages.${pkgs.stdenv.hostPlatform.system}.default
];

For Home Manager, use extraSpecialArgs and home.packages instead. The named package is hexe.packages.${system}.hexe; it is the same as default. Use github:termworks/hexe once this packaging is merged into the default branch. Remote URLs require the changes to be committed and pushed first. nix develop still opens the development shell. Example configuration and runtime Lua files are installed under the package's share/hexe/; they are not copied into your home directory automatically.

Each package and architecture uses one stable pin, such as hexe-x86_64-linux, with --keep-revisions 5. The five newest pin revisions are protected from cache cleanup; each binary retains its actual package version. Older revisions become eligible for garbage collection and may need rebuilding.

Binary cache. The shared cache is termworks. Accept the flake's cache configuration when prompted, or run cachix use termworks on the consumer machine. When using Hexe as an input of another flake, configure the consumer's cache too. Use a release tag whose cache workflow has succeeded:

nix build github:termworks/hexe/vX.Y.Z

Replace vX.Y.Z with the desired published tag. Only pushed v* tags trigger .github/workflows/nix-cache.yml; branch pushes do not publish. The workflow builds on native x86_64 and ARM64 Linux runners, uploads and pins the runtime closures, then checks downloads and CLI startup on fresh runners with builders disabled. Create a Cachix per-cache write token and store it as the GitHub Actions secret CACHIX_AUTH_TOKEN for this repository. Never commit the token. Modified build inputs or features can require a new build rather than a cached download.

Build. Needs Zig 0.15.2. A static musl binary:

scripts/vendor-ghostty.sh                                  # once: fetch + patch ghostty-vt
scripts/vendor-yazap.sh                                    # once: fetch + patch yazap
zig build -Doptimize=ReleaseFast -Dstrip=true -Dtarget=x86_64-linux-musl

That is the whole build, and it is what CI runs — CI has no oslo, so the pins live in the scripts rather than in a recipe. With oslo the recipes in .make.lua wrap them. There is no Makefile; the targets come from oslo:

oslo make vendor    # fetch the pinned dependencies and apply hexe's patches
oslo make build     # the build above, then size and which hexe is on $PATH
oslo make install   # …and copy it everywhere hexe already is
oslo make test      # the Zig unit tests
oslo make configs   # install config/ into ~/.config/hexe, then check it loads
oslo make smoke     # the live end-to-end suite
oslo make           # every target, with a line each

Build with oslo make build rather than a bare zig build: the default is a Debug binary, which is slow enough to look like a bug.

Run:

hexe terminal new             # a new session, named after a pokemon
hexe terminal new --name work # or named by you
hexe                          # bare: attach to a session rooted here, or load ./.hexe.lua

Detach and come back. Detach is a keybinding, so it is whatever your config says — there is no built-in chord:

hexe.key({ hexe.key.ctrl, hexe.key.alt, hexe.key.d }, hexe.action.detach())
hexe session list                    # what is running, attached or not
hexe terminal attach work            # by name, or by uuid prefix

Config lives at ~/.config/hexe/init.lua and is Lua. See configuration, and keybindings for the binding language.


History

Started as bash and Python hacks wrapped around tmux. Absolutely cursed code. Shell scripts spawning tmux sessions, Python daemons talking to tmux through send-keys, config files that were basically more shell scripts. It was wild. But it worked, and it was the workflow I wanted.

Rewrote it properly in Rust on top of tmux-rs, got far, learned a lot about terminal internals. But that crate is mostly unsafe and you're still building on top of tmux's architecture rather than escaping it.

Then Ghostty came out. Saw what Mitchell was doing with Zig and decided to start from scratch. Zero regrets. Zig is a joy, Ghostty's VT implementation is solid, and the architecture finally matches what I actually wanted to build.


Credits

About

terminal multiplexer based on libghostty

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages