apex · updated October 11, 2026 · 159904621b04

The UI Client (apex-ui)

Relevant source files

apex-ui is apex's graphical client. It is a gpui application in the apex-client crate, and the app bundle runs it. It draws a session's state the way acme draws its screen: columns, windows, tags, bodies, terminals and pages. It turns the mouse and keyboard into log entries. The client is not a thin view. It runs a full Node replica and, once it attaches, it takes the leases and becomes the leader of the buffer, window and layout shards (see Sessions, Shards and Leadership). Every edit the user makes is sequenced in the client and shipped to the daemon, not the other way round.

This page covers how the client is put together: the command line and launch in main.rs, the Acme entity in app.rs, the Backend that puts the server either behind a socket or in-process, the sync/render loop, and the TextElement that paints text straight from core state. Other pages cover the rest: Mouse, Keyboard and Look for input, Pickers and Overlays for overlays, Sessions, Tabs and Window Chrome for tabs, the sidebar and the title bar, and Themes, Fonts and Colour for themes. Terminal painting is on Terminals and web views are on The I/O Plane and Pages.

Architecture at a glance

The crate builds one binary, apex-ui ([[bin]] name = "apex-ui"). It depends on apex-core for state, Node and tiling, apex-server for Server, Link, the protocol and providers, and apex-edit. It also pulls in gpui from Zed's tree (main branch, with runtime_shaders and font-kit), core-text, objc and block2 for macOS, and wry for native web views. On aarch64 it pins pathfinder_simd with pf-no-simd. main.rs declares about three dozen modules (main.rs:14-49). Most of them add to the one Acme type with further impl Acme blocks.

Module Role
main.rs Command line, app startup, open_window, and impl Render for Acme, which builds every frame's element tree (about 1,400 lines)
app.rs Acme, the per-window entity: Log + Node + Backend, input handlers, sync, measurement, execute and look (about 5,800 lines)
shell.rs The app shell: menus and key bindings, theme/font/palette setters, login-shell environment, which sessions to reopen, starting the daemon, installing the apex command, and the session selector with its known hosts and recent sessions
pool.rs Tabs (TabId) and parked sessions: links kept attached while not shown
text_element.rs TextElement, a custom gpui Element that shapes and paints a tag, top row or body from core state, and TextLayout for hit-testing. Also the tag head, a window status's pill and its icons, and the scrollbar and handle painters
term_element.rs TermElement, the terminal grid painter (Terminals)
web.rs, webbar.rs, outline.rs Native WKWebView placement, page headers, and the page outline drawn as ticks in a page's scrollbar (I/O plane)
menu.rs, look.rs, warp.rs, cursor.rs B4 menu, live Look, pointer warps, cursor styles (input)
finder.rs, quickopen.rs, commands.rs, completion.rs, tagedit.rs, cwdbar.rs, field.rs Pickers and overlays (overlays)
sidebar.rs, titlebar.rs, titlefit.rs, switcher.rs, miniature.rs, shelf.rs, strips.rs, glide.rs, toasts.rs, attention.rs, procs.rs, standing.rs, restart.rs Chrome around the tiled windows (chrome)
bunny.rs The small drawing shown in an empty column
theme.rs, fonts.rs, contrast.rs Palettes, font sets, terminal contrast (themes)
Diagram

Sources: crates/apex-client/Cargo.toml:1-30, crates/apex-client/src/main.rs:14-49, crates/apex-client/src/app.rs:382-392, crates/apex-client/README.md:1-23

The command line and launch

main() parses its arguments by hand (main.rs:704-730) into a Target (main.rs:690-702), which says where the window's session lives:

Flag Effect Target
(none) [files] Attach to the local daemon, starting it if needed. Reopen the last session, else the first existing one, else make default Url (from shell::plan)
--session S That local session Url { SessionUrl::local(S) }
--attach [SOCKET] Another daemon socket. A following argument is taken only if it ends in .sock, else the default socket is used. Sets APEX_SOCKET (affects socket)
--via CMD Speak the protocol over CMD's stdin/stdout Via
--remote DEST / --ssh DEST A destination through its provider (user@host, provider:name), via Dest::parse Url with provider
--url URL A session URL, parsed by SessionUrl::parse. A bad one exits with status 2 Url
--local Run the Server in this process Local
-psn_… Ignored (Finder passes it) —

A fourth variant, Target::Choose(why), is used when nothing should be reopened: macOS asked not to (ApplePersistenceIgnoreState), or the previous launch died before it "settled". shell::reopen_refused detects this through a launching marker file next to the state file. The marker is written at launch and removed 15 seconds later or on quit (shell.rs:465-499, main.rs:816-825). The window then opens on no session with the session selector up. --session still wins over a refusal (main.rs:857-859).

Before gpui starts, shell::adopt_login_shell_environment runs $SHELL -l -i -c 'env -0 2>/dev/null || env' and adopts the result, skipping _, SHLVL, PWD, OLDPWD, TERM, TERM_PROGRAM and TERM_SESSION_ID. An app launched from the Finder otherwise gets LaunchServices' bare PATH, and the daemon it starts would inherit that too (shell.rs:322-351, main.rs:736-740). Inside application().run, main installs the Dock icon, cursors, symbol font, font sets, the tab Pool, the theme, the menus, the app-level actions and the key bindings from shell::bindings(). The actions cover theme, palette, font set and size, contrast, caret blink, smooth caret, layout animations, sidebar, Quit and Install CLI (main.rs:741-812). Then it picks the target:

apex has one OS window. Every other open session is a tab in it, restored by Pool::restore unless the launch is choosing. shell::save_open records the window's URL and frame (and full-screen) in the state file, skipping windows with no socket and via windows (shell.rs:408-445). Quitting saves the open windows, sets QUITTING, calls close_link on every window so bridges and daemons see the attachment leave, and closes the pool (main.rs:779-797). Quitting from the Dock or by AppleScript does not run that action, so on_app_quit saves and clears the marker too (main.rs:898-906). Closing the window, through the red button, Exit or CloseWindow, parks the session in the pool instead of detaching it.

Sources: crates/apex-client/src/main.rs:690-915, crates/apex-client/src/shell.rs:322-556

Opening a window and driving it

open_window (main.rs:919) builds the gpui window with a transparent title bar and app_owns_titlebar_drag, since the title bar is apex's own. It then constructs the Acme entity according to the target. The client is woken in two different ways, depending on the backend:

offline_window (main.rs:1106) makes an Acme over a blank in-process session pointed at a URL, with connected = false and a waiting message. It is used for remote targets before their link exists, for Choose, and when a local attach fails (which also stores connect_error's text with wait_failed). A later Reconnect (⌘⇧R) or the picker attaches it for real. open_window also follows the system appearance for the System theme and saves the window's frame whenever its bounds change.

Acme::over (app.rs:1698) is the shared constructor. Besides filling in the hundred-odd fields, it starts two timers:

Sources: crates/apex-client/src/main.rs:917-1130, crates/apex-client/src/app.rs:1667-1780

The Acme entity and the Backend

Acme is a gpui entity, one per OS window. Its core is three fields:

pub enum Backend {
    /// In this process, sharing the log.
    Local(Server),
    /// Behind a socket; the log is a mirror.
    Remote(Link),
}

pub struct Acme {
    pub log: Log,
    pub node: Node,
    pub backend: Backend,
    // ... about a hundred more fields of UI state
}

The module comment states the design: "Every change goes through the leader node as log entries. The server either runs in-process and shares the log, or sits behind a socket: the client's code path is the same, only the Backend differs." When the user types, type_text (app.rs:5439) calls self.node.insert(&mut self.log, v, s). Cut, paste and snarf call node.cut, node.replace_selection and node.snarf, or append LayoutOp::Snarf directly. The node sequences the entries into self.log. With Backend::Remote that log is the Link's mirror, which assigns sequence numbers ahead of the daemon (see The Attach Protocol).

Constructor Backend What it does
Acme::new (app.rs:744) Local Fresh Log, attach as AttachmentKind::Ui, init_session, Server::new, default rules, open the named files (or .) through server.open_file + perform
Acme::attach (app.rs:771) Remote Take a parked session from the pool if there is one, else connect_targeted. Files open in the last column
Acme::attach_via (app.rs:1506) Remote --via: connect_via, URL provider "via"
Acme::from_parked (app.rs:800) Remote Rebuild from a pool::Parked (link, log, node, pending previews, snarfouts, goto)
Acme::adopt (app.rs:1525) Remote Swap a link made elsewhere (the pool's background attach) into an existing window, resetting per-session UI state

connect_link (app.rs:1440) connects to the local socket with Link::over_streams_creating. For a destination it first runs providers::deploy and then bridges through the provider's attach command (remote::bridge_child). connect_existing_targeted (app.rs:1471) does the same with Link::connect / Link::over_streams, which do not create a session that has gone. Every link goes through arm (app.rs:1219), which sends the theme's terminal colours (ClientConfig) and installs this client's own plumbing rules with mine: true, so they go when it detaches:

The daemon routes such client rules back to the client as asks. answer_asks (app.rs:1291) services them, running open (the platform's opener) or preview, and replies with ClientMsg::Applied.

Wake targets and parking. A link's reader thread holds a forwarding closure from pool::WakeTarget, and the target behind it can be re-pointed. While the session is shown, the wake goes to the window. Acme::park (app.rs:821) swaps the link, log and node out into a Parked value for the pool, puts a blank in-process stand-in in their place, and resets per-session UI caches (pool.rs:123-168). A parked session keeps its leases. The daemon still forwards tools' proposals to it, and the pool applies them. park_into_pool (app.rs:905) is what the red button and close_requested call.

Leadership and fencing. fenced() (app.rs:2328) is true when the backend is remote and the layout shard's lease is held by another attachment or has been released. The window title then reads "watching (another client leads)" (current_title, app.rs:1636), and the top row's square shows it. in_process() (app.rs:2324) tells a real --local session apart from the blank stand-in an offline window holds: both are Backend::Local, but only the stand-in has a wake.

Sources: crates/apex-client/src/app.rs:382-392, crates/apex-client/src/app.rs:744-910, crates/apex-client/src/app.rs:1219-1300, crates/apex-client/src/app.rs:1440-1660, crates/apex-client/src/app.rs:2324-2330, crates/apex-client/src/pool.rs:1-20

Sync: keeping the replica and the wire in step

Three methods move data between the node, the log and the backend.

after() (app.rs:2844) runs after a command. In-process, it lets the server do what it was handed: server.poll_execs → perform, rule walks for B2'd verbs (take_plumb_starts → plumb_local), and close_orphan_terms. Over a link it only flushes. Then it calls sync.

poll_remote() (app.rs:2743) handles everything the reader thread has queued:

  1. link.poll(&mut node, &mut log) applies incoming entries and proposals. It returns false once the link is gone, which sets link_closed.
  2. It collects OSC 52 clips, completion candidates and session switches, and follows a rename made elsewhere (the metalog's label).
  3. It shows windows the link reports as made. Diagnostic windows stay stashed, and their news becomes a toast.
  4. It applies acme's xfidwrite scroll rule to program output (take_outputs): a view follows output if the insertion point was on screen, showing it three quarters down. Output before iq1 shifts it along.
  5. It handles a session ended under it (leave_requested), runs answer_asks and page_answers, then sync, and turns an outside Exit (node.quit_requested) into close_requested.

sync() (app.rs:1916) runs after every input handler and twice per frame. It drains web events, closed-window tracking, ^F candidates and diagnostic_news (toasts). Then it drains the node's effect queues into client state: take_shows into show_at, take_client_finds (Look in terminals and pages), terminal search results, take_gotos, take_switches, a pending_goto waiting for its file to open, and a pending_note taken in a tab that was still attaching. Finally it calls link.flush(&self.log) and node.catch_up(&self.log), then suppress_arrivals and take_warp. The doc comment states the guarantee: "nothing the user typed is ever more than a frame away from the daemon".

Diagram

Terminal input goes straight to the backend instead of through entries, because the daemon leads the term shard. term_key (app.rs:2885), term_type, term_paste and term_wheel call the in-process Server directly, or send ClientMsg::TermKey/TermType/TermPaste/TermScroll. Each first calls input_to (app.rs:2875), which drops the client-side terminal selection when a full-screen program (the alternate screen) has the terminal. Plumbing from B3 works the same way: look_at (app.rs:5608) walks the rules locally with plumb_local (app.rs:5742), or sends ClientMsg::Plumb.

Sources: crates/apex-client/src/app.rs:1916-2020, crates/apex-client/src/app.rs:2743-2930, crates/apex-client/src/app.rs:5608-5630

Rendering a frame

impl Render for Acme lives in main.rs, not app.rs (main.rs:68-688). Each frame it does the following:

  1. Housekeeping. It advances the overview animation, handles a pending session switch, leave_requested and close_requested (parking the session and removing the window), and resolves a pending pointer warp against the previous frame's layouts.
  2. sync(); measure(viewport); sync(); measure gives the tiling this frame's metrics and the OS window's size. Syncing again picks up the entries that produced. Then schedule_warp and an updated window title (current_title).
  3. Reset per-frame records. It re-anchors the ^F completion list and the path picker, clears layouts, term_layouts, web_bars, the shelf's preview marks, overlay bounds, title marks (titlefit) and web cuts and toast positions, then calls sync_notes and sync_pulls.
  4. The root div. It binds every menu action (Undo → menu_edit("undo"), Put → menu_command("Put"), tab switching, ⌘P/⌘O/⌘⇧P, Find…) and every mouse and key handler (key_down, mouse_down/up/move for all buttons including Navigate for B4/B5, mouse_pressure for force-click B3, scroll_wheel). It is padded down by title_h() and, when the sidebar is pinned, becomes a row with the sidebar first.
  5. A waiting tab. If waiting is set, the frame is a spinner (while a link is coming) or the failed attach's actions (waiting_actions: retry countdown, Reconnect, Restart daemon), a message, the title bar, the selector, the overview and the session preview, and nothing else.
  6. The tiled area. It walks glided_layout(), the replicated Layout with glides applied. For each visible column it draws the ground, then either a strip (strip_element for stashed, minimized_element for minimized columns) or the column tag (TextElement { view: ViewId::ColTag }). A column with no windows and enough room shows empty_column, a faint hint of ways to put something there, with the bunny when there is room for it. For each window it draws a "card" inset by CARD_X/CARD_Y: a tag TextElement (or web_header for a URL page), then a body chosen by Body kind. Text gets a TextElement, Term a TermElement, a page a canvas whose prepaint calls web_place to position the native WKWebView, and a tool page with no tool running a placeholder. A page also gets a scrollbar canvas: it records the strip in web_bars, works out the thumb, and passes the web view a BarLook that carries the page's outline as ticks (outline::ticks). Resize handles go on the lines between windows and between columns.
  7. Overlays. The drag preview, strip slice, toasts, the B4 menu with its fade-in and blink-then-fade (RAN), then the title bar, standing banner, selector, finder, quickopen, command palette, completion, tag overlays, process card, the page outline's list (outline_panel), cwd picker, overview and session preview.
  8. The cutter. It is a deferred, zero-sized canvas painted last. It calls webs.set_holes with every overlay's bounds, so the native web views, which sit above everything gpui paints, get holes cut where overlays must show through. It also sets a veil over the pages while the selector, finder or command palette has the window.

All placement uses the integer rectangles from the replicated layout (col.r, s.r, s.body) through an absolute-positioning helper at(x, y, w, h, el). gpui's flexbox is used only for the chrome. The tiled area is offset by left() (the pinned sidebar's SIDEBAR_W, app.rs:2711) and top() (app.rs:2704; title_h() at main.rs:1144 is the tag line plus BORDER, at least 38 px). While a ctrl-tab switch is sliding, the area is shifted by slide_off and the outgoing session is drawn beside it.

Measuring for the tiling

The tiling in apex_core::tiling asks an Info trait for font metrics (see Tiling and Layout). The client supplies ClientInfo (app.rs:115-152) and installs it each frame in measure (app.rs:2617) as node.tiling = Box::new(ClientInfo { … }) (app.rs:2641). Its numbers come from the previous paint:

measure then resizes the layout to the viewport. Rows start at -(font + BORDER), because the top row is drawn in the title bar. It calls node.refit_window for any window whose tag now wraps to a different number of lines than its slot allows (acme's winsettag). If a tag grew or shrank under the pointer, it queues a Pending::Restore warp, as acme's winresize moves the mouse. The client therefore feeds pixel geometry from its own fonts into replicated LayoutOps. The review's critique of this is below.

Sources: crates/apex-client/src/main.rs:68-688, crates/apex-client/src/main.rs:1144-1146, crates/apex-client/src/app.rs:115-152, crates/apex-client/src/app.rs:2617-2720

Painting text from core state: TextElement

TextElement { acme: Entity<Acme>, view: ViewId } is a hand-written gpui Element (text_element.rs:1295, text_element.rs:1669). It does not use gpui's text widgets. It reads the buffer's rope, selection and origin straight out of node.state each frame, so there is no separate view model to keep in step. A ViewId is one of Top, ColTag(c), Tag(w) or Body(w), which app::Kind mirrors as Top, ColTag, WinTag, Body (app.rs:34).

Diagram

request_layout fills the box it is given ("acme's tiling decides every rectangle"). Only the top row is measured instead, from its process pills plus text; it does not wrap (text_element.rs:1681-1733).

Acme::source(view) (app.rs:2927) gathers everything the element needs into a Source:

A view in a strip column gets an empty Source, so no text is laid out at zero width.

prepaint shapes the lines with shape() (text_element.rs:1491). shape expands tabs to TABSTOP (4, text_element.rs:45), keeps a display-byte → rune map, prepends the tag head, and cuts the line into TextRuns wherever ink or face changes. Those changes include the sweep, the selection, head atoms, the verb icon cells (set in .SystemUIFont so an em is an em), and the name set in a medium weight. Then gpui's shape_text shapes it with an optional wrap width. The result is a LineInfo with its wrapped sub-rows.

paint draws selections, sweeps, glyphs, the caret, the window's handle "dot" (dirty, stale, live, notification ping, progress), the status pill and the overlay scroller. Last, it stores a TextLayout in acme.layouts[view] (text_element.rs:1962-2380; the insert is at line 2376).

TextLayout is how input finds its way back to runes. offset_at(pos) maps a point to a rune offset through gpui's closest_index_for_position and the display map. point_of(off) goes the other way. row_from steps the origin by rows, atom_at and atom_bounds hit-test the tag head, and scrollbar/layout_box are the left lane's regions (text_element.rs:821-940). Acme::locate(pos) (app.rs:3146) checks web scrollbars, then term_layouts, then layouts, and returns a (Target, Region) such as Region::Text(offset), Atom, Scrollbar or LayoutBox. Overlays and the stash preview mask what lies under them. Input therefore always hit-tests against what was actually painted in the last frame.

Sources: crates/apex-client/src/text_element.rs:821-940, crates/apex-client/src/text_element.rs:1295-1459, crates/apex-client/src/text_element.rs:1669-2380, crates/apex-client/src/app.rs:2927-3200

What the client handles itself

Most commands go through node.exec, which makes Exec entries that the server or tools can see and claim (see The Node). Acme::execute (app.rs:5490) intercepts some words first and handles them locally:

Word Handled by the client because…
End (-f) Checks node.session_clean, then ends the session through end_session (app.rs:867)
Send in a terminal The terminal selection is client-side (term_sel). Its text is typed into the pty
A page's own verbs (page_verbs in the HTML head, e.g. apex diff's Prev/Next) Run inside the web view (webs.verb)
Back/Fwd/Get in an unowned URL page The page's own history and reload (page_nav)
Snarf in a terminal Copies the client-side terminal selection (term_copy)
Paste Syncs the system clipboard into the snarf buffer before exec

After node.exec, Cut and Snarf copy the snarf buffer to the system clipboard. An Executed::Quit quits the app in-process, or parks and closes the window over a link (close_now, app.rs:932). Executed::Failed and errors go to report. Other client-side state that never reaches the log includes iq1 and typed_start (Home/End and Escape targets), the terminal selection and sweeps, Smooth trackpad scrolling between whole lines, glides, the caret blink, hover and hint state, and the per-window seen-state for notifications and toasts. ⌘F/⌘G and live Look search locally (look.rs) and select through the node.

Errors go to +Errors windows instead of dialogs. notice (app.rs:5477) and report (app.rs:5482) call node.errors. If an attach fails, the window stays open, offline, with connect_error's text, and a daemon of another build gets a "Reconnect (⌘⇧R)" hint. reconnect (app.rs:1591) closes the stuck link and lets the pool attach again.

Sources: crates/apex-client/src/app.rs:5439-5640, crates/apex-client/src/app.rs:1591-1660, crates/apex-client/src/app.rs:867-940

The review's notes

ARCHITECTURE.md, the October 2026 review, is critical of the client's shape. Its line references predate later growth; app.rs is now about 5,800 lines, shell.rs nearly 1,800 (it has taken on the session selector and its host and recent-session lists), main.rs about 1,400 and text_element.rs about 2,400.

Sources: ARCHITECTURE.md:279-331, ARCHITECTURE.md:342-354, ARCHITECTURE.md:417-459, ARCHITECTURE.md:536-548, ARCHITECTURE.md:900-935

Previous: Remote Hosts and ProvidersNext: Mouse, Keyboard and Look