We’re dissecting how Ghostty turns keybindings into a tiny language with its own parser, data model, and runtime. Ghostty is a fast, modern terminal emulator, and Binding.zig is the core file that decides what every keypress actually does. I’m Mahmoud Zalt, an AI solutions architect, and we’ll use this file as a case study in how to design configuration as a language instead of a pile of ad‑hoc strings.
We’ll see how Ghostty models triggers and actions as first‑class types, stores bindings in a trie‑like structure that supports sequences and chains, and still keeps lookups cheap enough to run on every keystroke. By the end, you’ll have a concrete pattern for building your own configuration language with a clean, testable runtime.
Bindings as a tiny language
Most applications treat keybindings as a map from stringified shortcuts to callbacks. Ghostty goes further: it defines a small configuration language with prefixes, sequences, chains, and parameters, and then gives that language a proper interpreter.
keybind = global:shift+KeyA=new_window
keybind = a>b=new_tab
keybind = chain=close_surface
The language is built around three concepts:
Trigger: what key combination the user pressed,Action: what the terminal should do,Set: a structure that maps triggers (including sequences) to actions.
Binding lines are parsed by a small Parser that emits semantic elements instead of substrings:
pub const Parser = struct {
pub const Elem = union(enum) {
leader: Trigger,
binding: Binding,
chain: Action,
};
pub fn init(raw_input: []const u8) Error!Parser { ... }
pub fn next(self: *Parser) Error!?Elem { ... }
};
Each call to next yields one logical piece: a leader key in a sequence, the final binding, or a chain=. Everything above the parser layer works with domain types instead of raw ASCII, which is the core move: configuration is treated as a small language with an AST and runtime, not just text split on delimiters.
Triggers and actions as domain types
With a parser in place, the next question is how to represent the “words” of this language. Ghostty answers with two rich types: Trigger for key input and Action for behavior.
Triggers that match how users think
Trigger is more than a keycode and some bits:
pub const Trigger = struct {
key: Trigger.Key = .{ .physical = .unidentified },
mods: key.Mods = .{},
pub const Key = union(C.Tag) {
physical: key.Key,
unicode: u21,
catch_all,
};
};
A trigger can be:
- a physical key like
KeyAor an arrow key, - a specific Unicode codepoint (for bindings like
öor+), - a
catch_allthat matches anything not otherwise bound.
The parser for triggers accepts multiple modifiers in any order (shift+ctrl+a, a+shift), human‑friendly aliases (cmd, control, opt), W3C names (KeyA), direct Unicode, and a backwards‑compatibility map for legacy names like zero and kp_1. Internally, it enforces two critical rules:
- Exactly one key per trigger. A string like
a+bis rejected. Multi‑key sequences are expressed with>at the language level, not by overloadingTrigger. - Compatibility is quarantined. Legacy key names live in a dedicated
StaticStringMapmarked as “Ghostty 1.1.x compatibility,” so the rest of the code doesn’t care about historical quirks.
Actions as verbs, not integer IDs
On the other side of the binding language is Action. Instead of a numeric ID plus a big switch, Ghostty uses a tagged union with strongly typed payloads:
pub const Action = union(enum) {
ignore,
unbind,
csi: []const u8,
esc: []const u8,
text: []const u8,
cursor_key: CursorKey,
reset,
copy_to_clipboard: CopyToClipboard,
// ... many more ...
crash: CrashThread,
};
The union covers terminal I/O, window management, search, tabs, splits, quick terminal, inspector, and more. To keep this manageable, the implementation leans on Zig’s type reflection (@typeInfo) to derive the parsing logic from the union definition itself:
pub fn parse(input: []const u8) !Action {
const colonIdx = std.mem.indexOf(u8, input, ":");
const action = input[0..(colonIdx orelse input.len)];
if (action.len == 0) return Error.InvalidFormat;
const info = @typeInfo(Action).@"union";
inline for (info.fields) |field| {
if (std.mem.eql(u8, action, field.name)) {
// dispatch based on field.type via parseParameter
// ...
}
}
return Error.InvalidAction;
}
parseParameter inspects the type of each variant and chooses how to interpret the parameter:
- enums via
stringToEnum, - ints and floats via
parseInt/parseFloat, - tuple structs (e.g.
SplitResizeParameterasdirection,amount), - custom types with their own
parsefunction, likeWriteScreen.
The key property is locality: adding a new action is usually “add one variant with the right type (and maybe a parse method)”, not “touch the parser, formatter, and several switch statements.” The configuration grammar tracks the domain model automatically through reflection.
The binding set as a key tree
Now that we have triggers and actions, we need to store many bindings—including multi‑key sequences like ctrl+x>c—and look them up quickly for each keystroke. Ghostty does this with Set, a small trie‑like structure built on top of hash maps.
Config line --> Parser --> Trigger / Action / Flags
|
v
Set (trie of triggers)
^
|
KeyEvent
Set sits between config parsing and runtime key events, acting as a tree of key sequences.At its core, Set is a hash map from Trigger to a Value union:
pub const Set = struct {
const HashMap = std.ArrayHashMapUnmanaged(
Trigger,
Value,
Context(Trigger),
true,
);
bindings: HashMap = .{};
pub const Value = union(enum) {
leader: *Set, // next step in a sequence
leaf: Leaf, // single action
leaf_chained: LeafChained, // multiple actions
};
};
If you think of keys as directories and final actions as files, a binding like a>b=new_window looks like this:
- in the root
Set, triggeramaps toleader: *Set, - in that nested
Set, triggerbmaps to aleafholding the action and flags.
Insertion is handled by parseAndPut. Instead of mutating as it goes, it runs in two phases:
- A dry pass with the parser that fully validates the sequence, actions, and flags.
- A second pass that actually walks or allocates nested
Setinstances, filling inleaderandleafentries and updating a reverse map fromActiontoTriggerfor GUI accelerators (with constraints: no multi‑key sequences, no performable‑only bindings).
Chaining actions safely
Bindings can also chain multiple actions to the same trigger. For example:
keybind = a=new_window
keybind = chain=new_tab
keybind = chain=close_surface
Pressing a now runs new_window, then new_tab, then close_surface. Implementing this well has two parts: representing chains, and deciding where each chain=... attaches.
From single leaf to chained leaf
Chains are represented with a pair of leaf types:
pub const Leaf = struct {
action: Action,
flags: Flags,
};
pub const LeafChained = struct {
actions: std.ArrayList(Action),
flags: Flags,
};
Bindings start life as a leaf. The first time a chain is appended, the code converts the leaf into leaf_chained and builds a small list of actions:
pub fn appendChain(
self: *Set,
alloc: Allocator,
action: Action,
) (Allocator.Error || error{NoChainParent})!void {
assert(action != .unbind);
const parent = self.chain_parent orelse return error.NoChainParent;
switch (parent.value_ptr.*) {
.leader => unreachable,
.leaf_chained => |*leaf| try leaf.actions.append(alloc, action),
.leaf => |leaf| {
var actions: std.ArrayList(Action) = .empty;
try actions.ensureTotalCapacity(alloc, 2);
actions.appendAssumeCapacity(leaf.action);
actions.appendAssumeCapacity(action);
parent.value_ptr.* = .{ .leaf_chained = .{
.actions = actions,
.flags = leaf.flags,
} };
parent.set.fixupReverseForAction(leaf.action, parent.key_ptr.*);
},
}
}
Flags are carried over unchanged, and the reverse Action → Trigger mapping is adjusted so it still reflects the original single action. Chained actions are intentionally omitted from that reverse map, since GUI accelerators do not model “one shortcut triggers three things.”
Tracking where chains attach
The second challenge is figuring out which binding a chain=... refers to. The public API sees only a stream of lines; it doesn’t pass around handles to bindings. To support this, Set keeps a small piece of mutable state:
/// The chain parent is the information necessary to attach a chained
/// action to the proper location in our mapping.
chain_parent: ?ChainParent = null;
const ChainParent = struct {
key_ptr: *Trigger,
value_ptr: *Value,
set: *Set,
};
Whenever a binding is successfully inserted or updated (put, putFlags, parseAndPut), chain_parent is set to point at that entry. Whenever a removal or failure occurs, chain_parent is cleared. appendChain uses this pointer to find the correct leaf or leaf_chained to mutate.
This implicit state is one of the more delicate parts of the design. The code mitigates the risk with extensive tests around chain_parent, assertions (for example, a leader can never be a chain parent), and explicit comments documenting when chaining is valid.
Keeping lookups fast
All of this expressiveness—sequences, chains, rich triggers, a large action space—still sits on the hot path. Every key event goes through the binding set. Ghostty’s runtime keeps that cost small and predictable.
Runtime lookup with getEvent
Key events reach Set.getEvent, which tries a short sequence of lookups against the trie:
pub fn getEvent(self: *const Set, event: KeyEvent) ?Entry {
var trigger: Trigger = .{
.mods = event.mods.binding(),
.key = .{ .physical = event.key },
};
if (self.get(trigger)) |v| return v;
// Try single-codepoint UTF-8 text
if (event.utf8.len > 0) unicode: {
const view = std.unicode.Utf8View.init(event.utf8) catch break :unicode;
var it = view.iterator();
const cp = it.nextCodepoint() orelse break :unicode;
if (it.nextCodepoint() != null) break :unicode;
trigger.key = .{ .unicode = cp };
if (self.get(trigger)) |v| return v;
}
// Fallback to unshifted codepoint
if (event.unshifted_codepoint > 0) {
trigger.key = .{ .unicode = event.unshifted_codepoint };
if (self.get(trigger)) |v| return v;
}
// Finally catch_all, with and then without modifiers
trigger.key = .catch_all;
if (self.get(trigger)) |v| return v;
if (!trigger.mods.empty()) {
trigger.mods = .{};
if (self.get(trigger)) |v| return v;
}
return null;
}
The lookup strategy is straightforward:
- Try the physical key with modifiers.
- Try a single Unicode codepoint from the event’s UTF‑8 text.
- Try an “unshifted” codepoint, if available.
- Fall back to
catch_all, first with modifiers, then without.
The hot path allocates nothing and performs a small, fixed number of hash map lookups. Unicode handling is intentionally constrained to “exactly one codepoint” cases. Case folding for Unicode triggers lives inside Trigger.hash and Trigger.foldedEqual, so the map behaves correctly without complicating callers.
Hashing and equality that match semantics
Trigger and Action both implement custom hashing and equality that match the semantics Ghostty cares about.
For Trigger:
- modifiers must match exactly,
physicalkeys compare by their enum value,unicodekeys use a folded representation for hashing in the binding context so reasonable case handling is possible,catch_allis its own equivalence class.
For Action:
- equality is deep, including nested structs,
- hashing uses Wyhash and bitcasts floats to avoid surprises.
This is crucial because the binding set also maintains a reverse map (Action → Trigger) to support GUI accelerators. If hashing or equality disagreed with how bindings are stored, that map would be silently wrong.
Lessons you can reuse
Ghostty’s Binding.zig is a compact example of designing a configuration language and its runtime around a real domain—keybindings—without giving up performance. The same patterns apply to any serious, configuration‑driven system.
-
Treat configuration as a language.
Define a small grammar and a parser that emits domain objects like
Trigger,Action, andFlags, instead of pushing strings upward. Small iterators such asParserlet you stream elements like sequence leaders and chain actions cleanly. -
Model verbs as a typed union.
Replace integer action IDs with a tagged union whose variants carry meaningful payloads. Use type reflection (or your language’s equivalent) to derive parsing, formatting, cloning, hashing, and equality so adding a new action is a local change.
-
Use trie‑like structures for sequences.
A nested
Setofleader: *Setentries gives you multi‑key sequences withO(k)lookup in the sequence length and keeps prefixes separate from final actions. -
Validate first, mutate second.
For complex updates—like inserting entire sequences—run a non‑mutating validation pass. Only once the intent is fully valid do you touch internal maps. This keeps the structure consistent even when parsing fails.
-
Isolate backwards compatibility.
Legacy formats and names belong in small, well‑named tables with tests, not scattered conditionals. Ghostty’s backwards‑compatible key names are confined to one map marked explicitly as compatibility glue.
-
Be explicit about tricky state.
When you need internal mutable state like
chain_parentto keep the public API simple, document its invariants clearly and test transitions aggressively. Don’t pretend it’s harmless; constrain it.
Keybindings tend to accrete requirements—global shortcuts, per‑surface actions, sequences, chains, GUI accelerators, and compatibility layers. Ghostty shows that treating them as a proper language with a small runtime lets you keep that complexity under control.
If you’re building configuration for a terminal, a game, or a control plane, the same pattern applies: define a minimal grammar, map it to strong types, and run it through a tight, well‑tested interpreter. That’s the core lesson from Binding.zig—and a design you can adopt far beyond keybindings.



