Getting started
zz is a terminal multiplexer whose panes are not all terminals. A pane can be a shell or a full Chromium, and both live in one tmux layout, answer one prefix key, and get drawn on one GPU surface. A background daemon owns the sessions, so closing the window detaches instead of killing your work.
This page takes you from nothing installed to a workspace you can drive, then covers the knobs and the build.
Install
Section titled “Install”One command on macOS and Linux:
curl -fsSL https://zzmux.sh/install.sh | shThe script reads the machine and picks the route. On macOS it downloads the
notarized disk image, copies zz.app into /Applications (or ~/Applications
when that is not writable), and links zz onto your PATH. On Linux with dpkg
and apt it installs the .deb, which needs root and is the route that carries
the AppArmor profile Ubuntu 24.04+ wants. On any other Linux it unpacks the
release tarball under ~/.local with no root at all: ~/.local/bin/zz,
~/.local/lib/zz, and a desktop entry the launcher finds. Every download is
checked against the release’s .sha256.
curl -fsSL https://zzmux.sh/install.sh | sh -s -- --headless # CLI + daemon only, for hosts you ssh intocurl -fsSL https://zzmux.sh/install.sh | sh -s -- --beta # newest betacurl -fsSL https://zzmux.sh/install.sh | sh -s -- --version 0.3.0 # a specific releasecurl -fsSL https://zzmux.sh/install.sh | sh -s -- --prefix /opt/zz # Linux: tarball, this prefixRerun the same command to upgrade. If Homebrew already manages your install the
script says so and stops; use brew upgrade there. To remove a tarball install
delete lib/zz, bin/zz, share/applications/zz.desktop, and the zz icons
under share/icons/hicolor from the prefix; a .deb install goes with
sudo apt remove zz.
Package managers work too, and the raw artifacts live on the
releases page. CI also builds every
platform on every push, so the artifacts of a green run give you the tip of
main.
Apple Silicon, macOS 11 Big Sur or newer. The cask puts zz.app in
/Applications and zz on your PATH, so the window and the CLI come from the
same install:
brew install --cask demfabris/zz/zzBetas ship to demfabris/zz/zz@beta, which stable releases also update, so a
beta install keeps upgrading through stables. It conflicts with zz: pick one.
Intel Macs compile from source but nothing tests or ships them.
Take zz-linux-X64.AppImage, mark it executable, run it:
chmod +x zz-linux-X64.AppImage./zz-linux-X64.AppImageDebian and Ubuntu 24.04+ have a .deb, which puts the runtime in /usr/lib/zz,
zz on your PATH, and the desktop entry and icons where the shell looks for
them:
sudo apt install ./zz-<version>-linux-<arch>.debArch users can build a native package from the checkout with
just pacman-package, or just pacman-install to build and install in one
step; the Debian equivalents are just deb-package and just deb-install.
Wayland is the most exercised host; X11 is compiled in and works. Chromium picks
the backend itself. You need unprivileged user namespaces enabled, because zz
never passes --no-sandbox. Ubuntu 24.04 and later restrict those to binaries
with an AppArmor profile that grants them
(kernel.apparmor_restrict_unprivileged_userns=1). The .deb installs one at
/etc/apparmor.d/zz; from the AppImage or a bare bundle, browser panes need
either your own profile for that path or the restriction turned off.
Windows
Section titled “Windows”Windows 10 or newer, x64. Take zz-windows-X64.zip and unpack it anywhere, or
run the installer, which defaults to a per-user install under
%LOCALAPPDATA%\Programs\zz and needs no elevation.
First run
Section titled “First run”Launch it. There is no daemon to install and no service to enable: zz looks
for a socket, and if nothing answers within three seconds it starts a daemon
itself and attaches.
The socket lives at $XDG_RUNTIME_DIR/zz/default.sock, falling back to
$TMPDIR/zz-$USER/default.sock. Windows uses a named pipe,
\\.\pipe\zz-<user>-default. Set ZZ_SOCKET or pass --socket <path> to run a
second, isolated daemon.
When zz finds a tmux or Ghostty config, it offers to import it. Accepting copies tmux commands
into a marked block in zz/mux.conf and Ghostty appearance into zz/config as concrete values.
Unsupported tmux commands stay visible as comments. zz leaves both donor files untouched and
does not read them on its own. Settings lets you choose another source path or re-import.
The first GUI attach lazily creates session 0, so you land in a terminal immediately. The empty
workspace with its key hints appears only after the connected GUI loses its last session; press
Enter there to create a replacement.
Closing the window detaches. The daemon keeps every PTY, layout, and browser URL alive, and relaunching puts you back where you were. The daemon does not survive a reboot, and it shuts itself down once no sessions and no clients remain.
The model
Section titled “The model”zz uses tmux’s object graph, with tmux’s sigils:
| Object | Sigil | What it is |
|---|---|---|
| Session | $0 |
The top of the tree. Holds windows. |
| Window | @1 |
A page of panes with one layout, not an OS window. |
| Pane | %2 |
One terminal or one browser. |
| Split | ^3 |
A node in the layout tree. Stable across relayouts. |
| Client | c4 |
One attached connection. A session can hold several. |
Three things surprise people coming from tmux:
- The OS window shows one window of one session. Switching mux windows repaints it. The sidebar tree is your window switcher, not a second desktop window.
- There is no tab object. Browser panes have tabs, but they belong to the
pane and never appear in a
-ttarget. - Machines sit above sessions. Remote hosts get their own root in the sidebar tree, and they deliberately stay out of the target grammar.
IDs are stable for the daemon’s lifetime, which is what makes scripting work.
The keys
Section titled “The keys”The prefix is C-b, same as tmux, and set -g prefix C-a moves it.
Pressing the prefix twice sends a literal one through to the shell.
Windows
| Key | Does |
|---|---|
| c | New window |
| n p l | Next, previous, last |
| 0–9 | Select window by index |
| , | Rename window |
| & | Kill window |
Panes
| Key | Does |
|---|---|
| % “ | Split right, split down |
| arrows | Move focus |
| o ; | Next pane, last pane |
| q | Number every pane for a second; click or type a digit |
| z | Zoom toggle |
| M-arrows | Resize by 5 cells (repeatable) |
| C-arrows | Resize by 1 cell (repeatable) |
| { } | Swap with the previous or next pane |
| ! x | Break out to its own window, kill |
| Space | Cycle layouts |
| E | Spread evenly |
| M-1–M-7 | The seven named layouts |
Everything else
| Key | Does |
|---|---|
| [ | Copy mode |
| ] = | Paste buffer, choose a buffer |
| s w | Focus the sidebar tree |
| $ | Rename session |
| : | Command palette |
| ? | Every binding, in a pager |
| r | Reload config |
A prefix key with nothing bound to it is swallowed, never typed into your shell. d is unbound on purpose: closing the window is how you detach.
Keys the app owns
Section titled “Keys the app owns”These are compiled-in GPUI shortcuts rather than mux bindings, so bind-key
cannot reach them.
| macOS | Linux and Windows | Does |
|---|---|---|
cmd-, |
ctrl-, |
Settings |
cmd-= cmd-- cmd-0 |
ctrl-… |
UI zoom, 50% to 300% |
cmd-f |
ctrl-shift-f |
Find in the terminal |
cmd-c cmd-v cmd-a cmd-k |
ctrl-shift-… |
Copy, paste, select all, clear scrollback |
ctrl-= ctrl-- |
same | Terminal font size, across every terminal pane |
cmd-w cmd-q |
not bound | Close the active pane, quit |
Terminal panes also take shift-PageUp and shift-PageDown for the scrollback, and mouse selection copies on release without entering copy mode.
Picking what goes in a pane
Section titled “Picking what goes in a pane”C-b % and C-b “ split a shell by default,
just like tmux. To open a picker, choose Pane picker in Settings → Multiplexer
or add bind % split-window --kind picker -h to mux.conf.
In the picker, press t for a terminal or b for a browser. Escape closes the pane again.
A browser pane runs a real Chromium off-screen and draws onto the same GPU
surface as your terminals. It takes the browser chords you already know:
cmd-t / ctrl-t for a tab, cmd-l / ctrl-l for the address bar, cmd-r /
ctrl-r to reload, cmd-alt-i / ctrl-shift-i for DevTools. Clicking a URL in
a terminal opens it in the nearest browser pane of the same window. See
Browser panes.
Two more pane types, experimental
Section titled “Two more pane types, experimental”Agent panes run Claude Code or Codex over the Agent Client Protocol, with
the conversation drawn natively. Editor panes are a built-in text editor
with vim mode. Neither appears in the picker until you turn it on under
Settings → Advanced → System → Experimental, or set
experimental-agent-pane = true / experimental-editor-pane = true in
zz/config.
Agent panes ship in every build. The editor pane is still compiled out by default, so following that one along means building it in:
cargo build --features editor-paneWithout the cargo feature the matching config key reads false whatever the file says. Expect rough edges; see Agent panes.
Driving it from a shell
Section titled “Driving it from a shell”The zz on your PATH is the same binary as the app. Any of the 58 supported
commands works from any shell against the running daemon:
zz list-paneszz send-keys -t %3 'make test' Enterzz split-window -hzz new-window -n logsInside a pane you can usually skip the target. Every terminal pane carries
ZZ_PANE, ZZ_SESSION, and ZZ_SOCKET, and the CLI resolves an untargeted
command against the pane that invoked it, exactly like tmux’s $TMUX_PANE:
zz display-message -p '#S:#I.#P is #{pane_id}'#=> 0:0.1 is %10Targets take tmux syntax: $session, @window, %pane, plus compound forms
like docs:main.0. -F format strings work on list-sessions, list-windows,
and list-panes.
Other machines
Section titled “Other machines”Add a host and its sessions join your sidebar tree next to the local ones:
zz fleet add desktop you@desktopzz fleet listzz fleet remove desktopThat writes one line into zz/config, which you can equally type yourself:
host-desktop = ssh://you@desktophost-gpu = ssh://gpu-box:2222Click a session in the tree to attach, or target the host from a shell with
zz --host desktop list-sessions.
The transport is plain OpenSSH. zz shells out to ssh, probes for the remote
socket, starts a daemon over the same connection if none is running, and
forwards the socket with ssh -L. (Windows cannot forward a unix socket, so it
bridges a zz proxy over stdio instead.) Your keys, your agent, and your
~/.ssh/config aliases all apply. Nothing to pair, no port to open.
On the remote you need two things: ssh access, and zz on the login shell’s
PATH. The headless install is all a remote needs and pulls in no display libraries.
The protocol version has to match exactly on both ends.
Browser panes always render on your machine, but a pane opened while you are
attached to a remote host sends its traffic back out through that host, so
localhost:3000 is the dev server there. The same ssh session does the
tunnelling. Set browser-egress = false to keep browsing local.
Configuration
Section titled “Configuration”Two files, both plain text, both under a zz/ directory in your config
location (~/.config/zz/ on Linux, also
~/Library/Application Support/zz/ on macOS, %APPDATA%\zz\ on Windows):
| File | Grammar | Owns | Reload |
|---|---|---|---|
zz/config |
Ghostty-style key = value |
Chrome, panes, browser, hosts, terminal appearance | Polled every 500 ms, applied live |
zz/mux.conf |
tmux commands | Prefix, key bindings, mux options, status line | Daemon start, and C-b r |
Neither file is created for you. zz runs on built-in defaults until you write one, and deleting a file returns you to those defaults.
The fastest way in is the annotated sample, which lists every client-side knob at its default value:
mkdir -p ~/.config/zz && cp examples/config ~/.config/zz/configThe knobs you will actually reach for
Section titled “The knobs you will actually reach for”| Key | Default | Does |
|---|---|---|
theme-mode |
system |
system, light, or dark |
chrome-preset-dark |
unset | The palette for dark mode: tokyo-night, tokyo-night-storm, catppuccin-mocha, catppuccin-macchiato, catppuccin-frappe, gruvbox-dark, nord, dracula, one-dark, github-dark, everforest-dark, rose-pine, rose-pine-moon, solarized-dark, ayu-dark, breeze-dark, adwaita-dark, ubuntu-dark, ubuntu-terminal, macos-classic-dark |
chrome-preset-light |
unset | The palette for light mode: tokyo-night-day, catppuccin-latte, gruvbox-light, alucard, one-light, github-light, everforest-light, rose-pine-dawn, solarized-light, ayu-light, breeze-light, adwaita-light, ubuntu-light, macos-classic-light |
chrome-background, chrome-foreground, chrome-accent |
unset | The three palette roots you can set. Everything else derives from them; status colors follow the palette |
pane-gaps |
false |
The card treatment. Off pins the next three to zero |
pane-margin |
6 |
Gap between panes and at the window edge, 0–32 |
pane-corner-radius |
13.5 |
0–32 |
pane-border-width |
0.5 |
0–8, zero disables |
widget-corner-radius |
6 |
Widget corners, 0–24px; set 25 for fully rounded buttons, fields, and rows |
animations |
true |
Interface transitions, loading indicators, scrollbars, and animated UI images |
theme |
unset | A Ghostty theme file by name, or light:a,dark:b |
font-family |
platform default | Terminal font stack. font-size is 13 |
background-opacity |
1.0 |
Terminal background tint over an opaque pane |
window-background-blur |
false |
Blurred app chrome, where the compositor supports it |
prefix |
C-b |
The mux prefix |
mode-keys |
emacs |
vi or emacs in copy mode |
history-limit |
10000 |
Scrollback lines, 0–1000000 |
set-clipboard |
external |
OSC 52 policy: on, external, off |
tray |
true |
Tray icon. Applies live |
quit-daemon-on-exit |
false |
Kill sessions when the app quits |
browser-search-provider |
google |
google, duckduckgo, or brave |
browser-egress |
true |
Route a remote pane’s traffic through its host |
Terminal appearance uses Ghostty’s spellings, so theme, font-family,
font-feature, palette, cursor-style, minimum-contrast, and per-edge
window-padding-* all behave the way your Ghostty config already does.
How values resolve
Section titled “How values resolve”Built-in default, then a theme file, then your zz/config line, then anything
you change at runtime. Later entries in a file beat earlier ones. A bad value
keeps the previous one and logs a diagnostic rather than failing the load.
Mux options are the interesting case: a prefix = C-a in zz/config outranks
set -g prefix C-b in mux.conf, whatever order they appear in, because
zz/config overrides are replayed after every mux load.
Rebinding
Section titled “Rebinding”Mux keys take tmux syntax in zz/mux.conf:
set -g prefix C-abind -n F1 select-window -t :1bind -T copy-mode-vi v send-keys -X begin-selectionFlags follow tmux’s grammar, so clustered and attached forms parse: -nr works
as well as -n -r, and -Tcopy-mode-vi as well as -T copy-mode-vi. Chords
chain on a literal \;. Application shortcuts (cmd-,, UI zoom, browser tabs)
are compiled in and stay where they are.
Settings
Section titled “Settings”cmd-, or ctrl-, opens Settings as a route inside the
window. Ten pages, grouped as Appearance, Tools, and Advanced. Terminal has Appearance
rows above its zz/config editor; Multiplexer has Options and split shortcut rows above
zz/mux.conf. Both pages offer a donor path, Choose, and Import. Structured file rows
show saved overrides or defaults and offer Reset.
How it works
Section titled “How it works”One binary plays four roles: the window, the daemon, the command line, and the stdio proxy that carries a remote session. Which one you get depends on the first argument.
The daemon holds everything. It owns the mux tree, every PTY, and the frame fanout, and it is the only thing that knows what your sessions look like. The desktop window, the TUI, and the GPUI mobile client all attach over the same socket with the same protocol. Several can attach to one session at once, each with its own scroll position, selection, and copy-mode cursor over shared panes.
The wire carries changed rows. Control messages travel one lane as compact binary; terminal output travels another as packed row patches with shared style and grapheme dictionaries. The daemon keeps exactly one pending frame per pane and lets a newer one replace a stale one, so a slow reader never builds a queue, it just skips ahead. A client that falls behind asks for one pane in full rather than resyncing everything. The protocol version is an exact-match gate, not a negotiation: a client and daemon that disagree refuse each other, and the window offers to restart the daemon for you.
Rendering is gpui, Zed’s renderer, from a patched fork. The patches exist mostly so Chromium and the terminal can share one GPU device, plus Ghostty-parity glyph rendering and the window shaping that a client-side-decorated frame needs. Terminal rows are cached against revision numbers the daemon mints during its diff, so an unchanged row replays cached glyphs instead of being reshaped.
Terminal emulation is libghostty-vt, Ghostty’s own VT engine, compiled
from Zig and reached over FFI. Everything around it (the actor, frames, copy
mode, search, appearance) is written here, and unsafe_code is denied across
the workspace. Kitty graphics, OSC 52 clipboard writes, OSC 7 and OSC 8, the
kitty keyboard protocol, bracketed paste, and OSC 10/11/12 color queries all
work. Sixel does not.
Browser panes run Chromium off-screen and hand each finished frame to the GPU without a trip through the CPU, on a path that depends on your platform:
| Platform | Path |
|---|---|
| macOS | IOSurface → Metal blit |
| Linux | DMA-BUF → wgpu external texture |
| Windows | Shared handle → D3D11 copy |
| Fallback | BGRA readback |
Frames cross a one-slot mailbox, so the newest always wins and damage from a dropped frame gets folded into its replacement. A focused pane paints at display refresh, an unfocused one at 30, and a hidden one not at all.
Everything the daemon holds lives in memory. Detaching keeps your PTYs, scrollback, layouts, and browser URLs; a reboot starts clean.
Build from source
Section titled “Build from source”Prerequisites
Section titled “Prerequisites”-
Rust 1.97.0. Pinned in
rust-toolchain.toml; rustup selects it for you. -
Zig 0.16.0. Pinned in
mise.toml. Install mise, thenmise install. Zig compileslibghostty-vt, the VT engine. -
CMake 3.21+ and Ninja. The CEF C++ wrapper is built from source.
-
just,git, and a network connection. The build clones a pinned Ghostty commit and downloads a matching CEF distribution. -
Linux, the same list CI installs:
Terminal window sudo apt-get install --yes cmake curl desktop-file-utils ninja-build \libfontconfig-dev libwayland-dev libx11-xcb-dev libxcb1-dev \libxkbcommon-dev libxkbcommon-x11-dev -
macOS needs full Xcode, not just the Command Line Tools:
actooland the Metal toolchain only ship with it. -
Windows needs the MSVC Rust toolchain and the Visual Studio C++ build tools. Run
justfrom Git Bash; the recipes are bash.
Building
Section titled “Building”just build mac # or: linux, windows -> release bundle in dist/zzjust run mac # or: linux -> debug build, straight into a windowjust dmg # macOS: dist/zz-macos.dmgjust zip-windows # Windows: dist/zz-windows.zipjust pacman-package # Arch: a native packagejust deb-package # Debian/Ubuntu: dist/zz-linux.debThere is no cross-compilation; build each platform on itself. Checks are plain cargo:
cargo fmt --all -- --checkcargo clippy --workspace --all-targets --all-features -- -D warningscargo test --workspace --all-featuresThe first build downloads a CEF distribution and compiles its wrapper, which
costs roughly 600 MB on macOS and more on Linux. By default that lands in
OUT_DIR, so every worktree pays again. Point them all at one cache:
export CEF_PATH="$HOME/.cache/cef"Agent panes are compiled in by default and gated at runtime by
experimental-agent-pane. The editor pane is still behind a cargo feature and
compiled out:
just run linux --features editor-pane