Building a terminal on GPUI and what makes Oxide different
I built a terminal emulator on GPUI, the UI framework behind the Zed editor, while knowing almost nothing about how GPUI works. This is what I figured out along the way, with the actual code, and why the result is a terminal where the file tree, the tabs, and the workspaces are part of the window instead of things you bolt on afterwards.
The problem
In the last post I explained why I wanted my own terminal: custom tabs that survive a tiling window manager, a file tree drawer, tmux-style workspaces without tmux. What I skipped was the part where I had to actually draw all of that on the screen.
If you want to build a GUI app in Rust, you have a few choices. egui and iced are the usual suspects. You could wrap a web view and write the UI in HTML, which felt like cheating for a terminal. Or you could use GPUI, which is what Zed uses, and Zed is a text editor that renders a lot of monospace text very quickly. That is exactly the job description for a terminal. It is on crates.io, so the whole dependency list for the hard parts is two lines:
[dependencies]
gpui = "=0.2.2"
alacritty_terminal = "=0.26.0"
alacritty_terminal is Alacritty's PTY and VT parser split out as a library. It handles spawning the shell, parsing escape sequences, and keeping the grid of cells up to date. GPUI handles the window, the layout, the text shaping, and the GPU. Oxide is the code in between, plus everything I wanted on top.
Here is the catch. GPUI has very little documentation. The real documentation is the Zed source code, which is enormous, and the framework has its own mental model that nobody hands you on the way in. I am still fairly new to Rust. So for the first couple of weeks the problem was not "how do I build a terminal," it was "why does the compiler keep saying no."
The approach
The five ideas you cannot read the code without
Every GPUI tutorial I wish existed would start here, so this is where I will start.
Entities. Your application state does not live in structs you hold directly. It lives in entities, and you hold a cheap, cloneable handle called Entity<T>. To touch the thing inside, you ask GPUI to run a closure against it. Creating a pane in Oxide looks like this:
let (config, theme) = (self.config.clone(), self.theme.clone());
let pane = cx.new(|cx| TerminalPane::new(config, theme, cwd, command, cx));
let subscription = cx.subscribe_in(&pane, window, Self::on_terminal_event);
self.panes.insert(id, pane);
self.pane_subscriptions.insert(id, subscription);
And using it later, from anywhere that has a context:
pane.update(cx, |t, cx| t.adjust_font(Some(1.0), cx)); // mutate
let title = pane.read(cx).title.clone(); // read
This took a while to click. I kept wanting a &mut TerminalPane I could hang onto. You do not get one. You get a handle, and you borrow the real thing for the length of a closure. Once I stopped fighting that, most of my borrow checker errors went away, because the framework was doing the bookkeeping I had been trying to do by hand.
Contexts. Almost every function takes a cx, and its type tells you what you are allowed to do. &mut App is the whole application: open windows, bind keys, spawn tasks. &mut Context<Self> means "I am inside this entity" and adds cx.notify() to say "re-render me," cx.emit() to tell subscribers something happened, and cx.listener() to bind a method as an event handler. &mut Window is the window: focus, bounds, the text system. Half of learning GPUI is learning which cx you have in your hand.
Render. An entity that implements Render describes its UI every frame it is dirty, using a fluent builder that will look familiar if you have used Tailwind. Nothing is retained between frames except entity state. Here is a trimmed piece of the tab bar:
bar = bar.child(
div()
.id(("tab", ix))
.h_full()
.px_3()
.flex()
.flex_row()
.items_center()
.gap_2()
.cursor_pointer()
.when(is_active, |d| d.bg(theme.background).text_color(theme.foreground))
.when(!is_active, |d| d.text_color(dim))
// Double-click renames; a single click selects.
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(move |this, ev: &gpui::MouseDownEvent, window, cx| {
if ev.click_count >= 2 {
this.open_tab_rename(ix, window, cx);
} else {
this.select_tab(ix, window, cx);
}
}),
)
// Drag a tab onto another to reorder.
.on_drag(TabDrag { ix }, move |_, _, _window, cx| {
cx.new(|_| TabDragLabel(drag_title.clone()))
})
.on_drop(cx.listener(move |this, drag: &TabDrag, _window, cx| {
this.move_tab(drag.ix, ix, cx);
}))
.child(title),
);
That is the entire tab. No tab widget, no tab controller, no delegate. A div with some styling, a click handler, and drag and drop. When I said in the last post that the native macOS tabs did not work under Aerospace, this is what replaced them, and honestly it was less code than fighting AppKit.
Focus, key contexts, and actions. Each focusable entity owns a FocusHandle. You attach it to a div along with a named key context, and keybindings only fire when focus is inside a subtree with a matching context. The terminal pane's root element starts like this:
div()
.id("terminal-pane")
.key_context(if vi_mode.is_some() { "TerminalVi" } else { "Terminal" })
.track_focus(&self.focus_handle)
.size_full()
.on_action(cx.listener(Self::paste))
.on_action(cx.listener(Self::copy))
.on_action(cx.listener(Self::toggle_search))
.on_action(cx.listener(Self::copy_mode))
.on_key_down(cx.listener(Self::on_key_down))
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
.on_scroll_wheel(cx.listener(Self::on_scroll_wheel))
Notice the key context changes when copy mode is on. That is how j scrolls in copy mode but types a j the rest of the time. Same element, different context, different bindings.
Actions are unit structs. Menus, keybindings, and the command palette all dispatch actions, and elements catch them on the way up. I define every action in one macro so the type, the config name, the palette title, and the search aliases can never drift apart:
oxide_actions! {
NewTab => "tab::new", "New Tab", "Tab", [], Root;
SplitRight => "pane::split_right", "Split Right", "Pane", ["vsplit", "vertical"], Root;
PaneZoom => "pane::zoom", "Toggle Pane Zoom", "Pane", ["maximize", "only"], Root;
CommandPalette => "app::palette", "Command Palette", "Application", ["commands"], Root;
}
The string in the middle is what you write on the right side of a [keymap] entry in config.toml. At startup the merged keymap gets handed to GPUI in one call:
cx.bind_keys(keymap::resolve(&config.keymap).bindings());
Subscriptions and tasks. cx.subscribe_in(&entity, window, handler) says "when that entity emits an event, call me." It returns a Subscription, and if you drop it, you have unsubscribed. I learned that one the annoying way, which is why Oxide keeps a HashMap<PaneId, Subscription> it never reads. Tasks are similar: cx.spawn hands a future to GPUI's foreground executor, which runs on the main thread between frames, and .detach() says "I do not need the handle." The pattern that shows up over and over is the drain loop:
cx.spawn(async move |this, cx| {
while let Some(event) = rx.next().await {
let alive = this
.update(cx, |pane, cx| match event {
SessionEvent::Term(event) => pane.handle_alac_event(event, cx),
SessionEvent::Marker(marker) => pane.handle_marker(marker, cx),
})
.is_ok();
if !alive {
break;
}
}
})
.detach();
That is_ok() check is doing real work. If the pane has been closed, this.update fails, and the task quietly ends instead of leaking forever. There is no tokio anywhere in the app. GPUI's executor is the runtime.
Where the terminal actually gets drawn
All of the above is div soup, and div soup is fine for a tab bar. It is not fine for a 200 by 60 grid of characters redrawing every time cargo build prints a line. One element per cell would be twelve thousand elements per frame.
So the grid is the one place Oxide drops below div and implements GPUI's low-level Element trait directly. The trait has three phases: ask for layout, prepare to paint, paint.
impl Element for TerminalElement {
type RequestLayoutState = ();
type PrepaintState = GridLayout;
fn request_layout(&mut self, ..., window: &mut Window, cx: &mut App) -> (LayoutId, ()) {
let mut style = Style::default();
style.size.width = relative(1.0).into();
style.size.height = relative(1.0).into();
(window.request_layout(style, [], cx), ())
}
fn prepaint(&mut self, ..., bounds: Bounds<Pixels>, window: &mut Window, cx: &mut App) -> GridLayout {
let focused = self.focused;
self.pane
.update(cx, |pane, _cx| layout_grid(pane, bounds, focused, window))
}
fn paint(&mut self, ..., layout: &mut GridLayout, window: &mut Window, cx: &mut App) {
for q in layout.bg_quads.drain(..) {
window.paint_quad(q);
}
for q in layout.selection_quads.drain(..) {
window.paint_quad(q);
}
let line_height = px(layout.cell_height);
for (row, line) in &layout.lines {
let origin = point(layout.origin.x, layout.origin.y + px(*row as f32 * layout.cell_height));
line.paint(origin, line_height, window, cx).ok();
}
// ...then the cursor
}
}
request_layout says "give me everything." prepaint gets the pixels and turns the terminal grid into a list of colored rectangles and shaped lines of text. paint just draws that list. The interesting part is layout_grid, and the most important thing in it is a comment:
// --- Lock the term, copy out, release. Never hold this into shaping. ---
let mut rows: Vec<Vec<CellSnap>> = (0..screen_lines).map(|_| Vec::new()).collect();
{
let term = session.term.lock();
let content = term.renderable_content();
for indexed in content.display_iter {
rows[row as usize].push(CellSnap {
c: cell.c,
zerowidth: cell.zerowidth().map(|z| z.to_vec()),
fg: cell.fg,
bg: cell.bg,
flags: cell.flags,
});
}
drop(term);
}
The grid lives behind a mutex because a separate thread is writing to it as the shell produces output. If the main thread holds that lock while it does the slow part, which is text shaping, the PTY thread blocks, the shell blocks, and the app freezes under heavy output. So the main thread grabs the lock, copies the visible cells into plain structs, and lets go. Everything after that works on the copy.
Then each row gets coalesced. Adjacent cells with the same background become one quad. Adjacent cells with the same foreground, weight, and style become one text run. A row that has not changed since the last frame reuses its shaped line from a cache keyed on a hash of its content. The result is that a full screen of output is a few hundred draw calls rather than a few thousand.
Repaints are throttled too. Alacritty sends a wakeup every time bytes land, which during cat bigfile is constantly. Oxide collapses those into at most one redraw every eight milliseconds:
fn schedule_repaint(&mut self, cx: &mut Context<Self>) {
if self.repaint_scheduled {
return;
}
self.repaint_scheduled = true;
let timer = cx.background_executor().timer(Duration::from_millis(8));
cx.spawn(async move |this, cx| {
timer.await;
this.update(cx, |pane, cx| {
pane.repaint_scheduled = false;
cx.notify();
})
.ok();
})
.detach();
}
A boolean flag and a one-shot timer. That same shape, flag plus timer, is how the tree follows cd, how git status refreshes, and how window bounds get saved. Once you have written it twice you start seeing it everywhere.
The thread that talks to the shell
Alacritty ships an event loop that reads the PTY, feeds the parser, and writes input back. I used it for a while. Then I wanted Oxide to know exactly which row a command started on, so the status bar could show elapsed time and a failed command could get a red gutter you can click to jump back to. The shell integration emits OSC 133 markers for that. Alacritty's parser drops unknown OSC sequences before anyone can see them.
So Oxide has its own PTY thread, about four hundred lines, same semantics, with one change. Instead of feeding the parser a whole chunk of bytes, it scans for markers first and feeds the parser slices that end at each one, sampling the cursor in between:
let chunk = &buf[..unprocessed];
let mut start = 0;
for (end, kind) in self.scanner.scan(chunk) {
state.parser.advance(&mut **terminal, &chunk[start..end]);
start = end;
let grid = terminal.grid();
let cursor = grid.cursor.point;
(self.markers)(Marker {
kind,
row: grid.history_size() + cursor.line.0.max(0) as usize,
column: cursor.column.0,
alt_screen: terminal.mode().contains(TermMode::ALT_SCREEN),
});
}
state.parser.advance(&mut **terminal, &chunk[start..]);
The marker bytes still go through the parser, which ignores them, so the grid is unchanged. But the cursor is read at the instant the marker completed, so the row is exact rather than "somewhere in this chunk." That exactness is what lets cmd-shift-c copy the last command's output and nothing else.
The bridge from that thread back to GPUI is deliberately boring:
#[derive(Clone)]
pub struct EventProxy(UnboundedSender<SessionEvent>);
impl EventListener for EventProxy {
fn send_event(&self, event: AlacEvent) {
self.0.unbounded_send(SessionEvent::Term(event)).ok();
}
}
It runs on the PTY thread, possibly while holding the terminal lock, so it does nothing but push onto a channel. The drain loop from earlier picks it up on the main thread. Every pane owns one OS thread for its PTY and two long-lived foreground tasks, one draining events and one blinking the cursor. That sounds like a lot until you remember a terminal has maybe a dozen panes open, not a thousand.
What didn't work
Updating an entity from inside render. Render is where you describe UI, not where you change state. But focus can move without going through any of my actions, like when you click a pane, so render is the first place that notices the active pane is stale. Updating the file tree entity from there panics. The fix is cx.defer, which runs the closure right after the current frame:
let tree = self.tree.clone();
// Deferred: this can run from render, where re-entrant entity updates
// are not allowed.
cx.defer(move |cx| {
tree.update(cx, |tree, cx| tree.set_root(cwd, cx));
});
Resizing on every frame. The first version sent the shell a new size whenever the window's bounds changed, which during a window drag is every frame. vim flickered and occasionally corrupted. Now the grid only resizes when the cell count actually changes:
let grid_changed = columns != pane.size.columns || screen_lines != pane.size.screen_lines;
pane.size = new_size;
if grid_changed {
// Resize only when the *cell* dimensions changed. This is the
// debounce that prevents SIGWINCH storms during window drags.
if let Some(session) = &pane.session {
session.resize(new_size);
}
}
Quitting. Alacritty's Pty::drop calls wait() on the child. An interactive shell that ignores SIGHUP will never exit, so cmd-q would hang forever with a window that would not close. The teardown now signals the shell, then joins the PTY thread on a detached thread, and escalates to SIGKILL after 300 milliseconds if the shell is being stubborn:
impl Drop for TerminalSession {
fn drop(&mut self) {
let _ = self.sender.send(Msg::Shutdown);
let child_pid = self.child_pid;
unsafe { libc::kill(child_pid, libc::SIGHUP); }
if let Some(join) = self.join.take() {
std::thread::spawn(move || {
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(300));
unsafe { libc::kill(child_pid, libc::SIGKILL); }
});
let _ = join.join();
});
}
}
}
A thread that spawns a thread is not the kind of code you write on purpose. It is the kind of code you write after your app has refused to quit for the third time.
Keybinding spellings. GPUI holds a prefix key for a moment before dispatching it alone, in case it is the start of a chord. That means cmd-k can clear the scrollback like Terminal.app, or it can start a chord, but not both. It also means macOS reports shifted punctuation as the shifted character, so shift-cmd-[ arrives as cmd-{, and on Linux the xkb path drops shift entirely so ctrl-shift-= arrives as ctrl-+. The default keymap binds both spellings of everything affected and has comments explaining why, because I will forget.
Split panes as percentages. My first split layout gave each child a percentage width. With a one-pixel divider between them, N children overflowed by N minus one pixels, or left a gap, depending on rounding. Flex weights fixed it, because taffy shares out whatever is left after the dividers in whole pixels:
let mut cell = div().min_w_0().min_h_0().overflow_hidden();
{
let style = cell.style();
style.flex_grow = Some(ratio.max(0.0001));
style.flex_shrink = Some(1.0);
style.flex_basis = Some(px(0.0).into());
}
The debug build. A cargo run binary is fine for poking at a change. It is not fine for running nvim, because an unoptimised GPUI redraw does not fit in a sixty hertz frame. The release build uses about a quarter of the CPU. I spent an embarrassing amount of time thinking my rendering was slow before I tried --release. Also, on macOS, GPUI compiles Metal shaders at build time, and the Metal toolchain is a separate download from Xcode. The first build takes several minutes and the error message when the toolchain is missing does not tell you that.
Results
The reason all of this matters is that everything in the window is part of one element tree. The file tree is not a plugin. The tab bar is not a native control being coerced. The status bar is not a second process. They are all entities in the same GPUI app, subscribing to the same events, sharing the same theme.
That is the actual answer to "what makes Oxide different," and it is less a feature than a consequence of the architecture:
The drawer follows the shell. The tree re-roots when you
cd, and it does this by asking the OS what the foreground process's working directory is, with no shell cooperation required:#[cfg(target_os = "linux")] pub fn foreground_cwd(&self) -> Option<PathBuf> { let pgrp = unsafe { libc::tcgetpgrp(self.master_fd) }; if pgrp <= 0 { return None; } std::fs::read_link(format!("/proc/{pgrp}/cwd")).ok() }On macOS it is
proc_pidinfoinstead of procfs. Either way, switching to a different split re-roots the tree to that shell's directory, because the tree is subscribed to the panes.Workspaces bring their layout back. The split tree is a plain
Node<T>enum, generic over the leaf type, so the same code serialises to JSON with saved directories and deserialises into live panes. Pinned workspaces restore tabs, splits, each pane's directory, and each pane's startup command.The terminal knows what is running. Because markers are read off the PTY with exact rows, the status bar shows elapsed time, tabs get activity dots, a background pane flashes red when its command fails, and a desktop notification tells you when the long thing you kicked off in another pane finished.
One TOML file, reloaded live. Font, colours, keymap, notifications, all of it. No account, no AI, no telemetry. The only network request Oxide makes is asking GitHub if there is a newer release.
It is also young, and I would rather say that than have you find out. There is no IME or dead-key composition yet. There are no inline images. Windows is not on the list. And app.rs is sixty-five hundred lines, which is what happens when a regular guy learns a framework by building the thing at the same time. Splitting it up is on the list.
The numbers, for the curious:
| Lines of Rust | about 24,000 |
| Largest file | app.rs, about 6,500 |
| First commit | August 29, 2026 |
| Platforms | macOS, Linux (Wayland and X11) |
Try it
If you have been thinking about building something on GPUI, my advice is short. Learn the five ideas above before you write a line, keep everything that can be tested without GPUI out of the entities, and read the Zed source when the docs run out, which is immediately. And build in release mode before you decide anything is slow.
Available in Oxide 0.6.1. Found a bug or have a better idea? Open an issue.