Mitchell Hashimoto has a theory about terminal emulation: most programs that implement it get it wrong. IDEs, CI/CD platforms, multiplexers, log viewers. Hundreds of them, each rolling their own, and most end up "incomplete, buggy, and slow."
His fix is libghostty. Take the terminal core that already powers Ghostty and ship it as a reusable C library. Last week, he started actually doing the extraction. Seven PRs in four days.
But first he had to deal with Surface.zig.
The god file
Surface.zig is 237KB. Roughly 7,000 lines of Zig. It's the file where Ghostty handles everything a user can do to a terminal window: rendering, input, scrolling, selection, mouse events, IME, resize, focus, links, clipboard. If you click anywhere in Ghostty, your event passes through Surface.
Sitting inside that file: 213 lines that encode mouse events into terminal escape sequences. Five different wire protocols. X10. UTF-8. SGR. URxvt. SGR-Pixels. Each one has its own rules for button codes, coordinate limits, modifier encoding. All five living in a single function called mouseReport.
You can't ship a C library that depends on a 237KB god file. Surface knows about GTK windows and macOS views and cursor positions. A library consumer has none of that. So PR #11538 pulled the encoding out.
The function was doing three different jobs at once: figuring out whether an event should be reported at all (X10 only reports clicks, normal mode skips motion), computing the button code (translate a button enum into a protocol-specific integer, apply modifier bits), and formatting the actual escape sequence. All tangled together in ~230 lines.
// Before: deeply coupled to Surface, could fail
fn mouseReport(
self: *Surface,
button: ?input.MouseButton,
action: MouseReportAction, // Surface-local enum
mods: input.Mods,
pos: apprt.CursorPos,
) !void {
// 230 lines mixing event filtering, button code math,
// and five different wire format encoders
}Mitchell pulled all of it into input/mouse_encode.zig. The new module takes explicit options (tracking mode, format, terminal size, pressed button state), writes to a generic writer, and has zero dependencies on Surface. You can test every protocol, every button code, every edge case without standing up a terminal.
// After: Surface just delegates
fn mouseReport(self: *Surface, button: ?input.MouseButton,
action: input.MouseAction, mods: input.Mods, pos: apprt.CursorPos,
) void { // can't fail anymore
const opts: input.mouse_encode.Options = .fromTerminal(
&self.io.terminal, self.size,
);
var data: termio.Message.WriteReq.Small.Array = undefined;
var writer: std.Io.Writer = .fixed(&data);
input.mouse_encode.encode(&writer, .{
.button = button, .action = action,
.mods = mods, .pos = .{ .x = pos.x, .y = pos.y },
}, opts) orelse return;
}There's a nice side effect here. mouseReport used to return !void (Zig's "can fail" return type). After the extraction, it returns plain void. The only failure mode was running out of buffer space, which is impossible with a fixed-size buffer. Seven try keywords disappeared from Surface.zig because of that one signature change.
6 files changed. 729 in the blast radius.
We ran the PR through Explore and the graph told a different story than the diff.
Surface.zig sits near the center of Ghostty's architecture. Every platform backend imports it. GTK, macOS AppKit, iOS UIKit, WASM. And Surface itself imports the terminal core, input handling, rendering, fonts, and I/O. In a codebase of 861 files and 13,464 dependency nodes, it's one of the most connected files in the entire project.

When you click on Surface.zig in the 3D graph and expand the blast radius, the ripple goes in both directions. Upstream, the changed files touch the terminal core and input system. Downstream, Surface is consumed by every platform runtime. Change its API, even just the error set on one function, and every platform target is affected.
That's how 6 changed files cascade to 729. Almost half the codebase.

But the interesting part: the extraction actually shrinks the blast radius going forward. Before this PR, if you wanted to change how SGR mouse encoding works, you'd touch Surface.zig, and 729 files would light up. After this PR, you'd touch mouse_encode.zig, which has 2 imports. The graph quantifies what good refactoring does. Future changes to mouse encoding are now cheap.
The sprint
PR #11538 was just the first step. Two days later, Mitchell opened PR #11553 and built a C API on top of the extracted module: ghostty_mouse_encoder_new(), ghostty_mouse_event_set_button(), ghostty_mouse_encoder_encode(). 1,485 lines, 21 files, C headers, a build example, the Zig-to-C bridge. None of that would've been possible with the encoding logic still tangled inside Surface.
Then he did the same thing for focus encoding, terminal mode reporting, DECRPM reports, and size reports. Here's the full sprint:
| PR | What happened | Lines | Explore |
|---|---|---|---|
| #11538 | Mouse encoding pulled into pure module | +839/−217 | View in 3D |
| #11553 | Mouse encoding exposed as C API | +1,485/−50 | View in 3D |
| #11577 | Focus encoding, C + Zig API | +226/−16 | View in 3D |
| #11578 | DECRPM mode report extracted | +132/−26 | |
| #11579 | Terminal modes exposed as C API | +499/−1 | View in 3D |
| #11607 | Size report encoding, own file | +394/−47 | View in 3D |
Seven PRs. Four days. 4,000+ lines. Each one: extract, test, expose. You can trace the whole thing in the explore links above.
See it yourself
Explore ghostty-org/ghostty#11538 in 3D
Click Surface.zig. Expand the blast radius. Watch 729 files light up across every platform backend. Then click mouse_encode.zig and see how small its footprint is by comparison. That's the before and after of this refactor, visible in one view.
Want this on every PR your team opens? The CodeLayers GitHub Action posts an interactive 3D visualization as a comment. Two-minute setup, runs on every push.
Blast Radius is a weekly series where we look at real pull requests through 3D dependency graphs. Got a PR you want us to look at? Tell us on Bluesky.