apex · updated October 8, 2026 · 7507b874a9b6

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 and wry for native web views. main.rs declares about forty modules. 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
app.rs Acme, the per-window entity: Log + Node + Backend, input handlers, sync, measurement, execute and look
shell.rs The app shell: menus and key bindings, login-shell environment, which sessions to reopen, starting the daemon, the session selector
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
term_element.rs TermElement, the terminal grid painter (Terminals)
web.rs, webbar.rs Native WKWebView placement and page headers (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, 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)
theme.rs, fonts.rs, contrast.rs Palettes, font sets, terminal contrast (themes)
flowchart TD
    main["main.rs: parse args, start daemon, open_window"]
    acme["Acme entity (app.rs)"]
    node["Node replica (apex-core)"]
    log["Log (mirror, or the real one)"]
    backend{"Backend"}
    remote["Backend::Remote(Link)"]
    local["Backend::Local(Server)"]
    daemon["apexd over Unix socket or provider bridge"]
    render["impl Render for Acme (main.rs)"]
    te["TextElement / TermElement / web views"]
    main --> acme
    acme --> node
    acme --> log
    acme --> backend
    backend --> remote
    backend --> local
    remote --> daemon
    render --> acme
    render --> te
    te -->|"Acme::source(view)"| acme
    te -->|"layouts.insert"| acme

Sources: crates/apex-client/Cargo.toml:1-29, crates/apex-client/src/main.rs:1-60, crates/apex-client/src/app.rs:1-5, crates/apex-client/README.md:1-23

The command line and launch

main() parses its arguments by hand (main.rs:680-706) into a Target, 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) 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:459-493). The window then opens on no session with the session selector up.

Before gpui starts, shell::adopt_login_shell_environment runs $SHELL -l -i -c env and adopts the result. An app launched from the Finder otherwise gets LaunchServices' bare PATH, and the daemon it starts would inherit that too (shell.rs:318-345). Inside application().run, main installs the Dock icon, cursors, symbol font, font sets, the tab Pool, the theme, the menus, the app-level actions (theme, palette, font, Quit, Install CLI) and the key bindings from shell::bindings(). Then it picks the target:

apex has one OS window. Every other open session is a tab in it, restored by Pool::restore. Quitting saves the open windows (shell::save_open), sets QUITTING, calls close_link on every window so bridges and daemons see the attachment leave, and closes the pool (main.rs:755-773). 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:666-891, crates/apex-client/src/shell.rs:258-314, crates/apex-client/src/shell.rs:349-550

Opening a window and driving it

open_window 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 (main.rs:895-1067). The client is woken in two different ways, depending on the backend:

offline_window 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 (offline). A later Reconnect (⌘⇧R) or the picker attaches it for real (main.rs:1069-1106).

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

Sources: crates/apex-client/src/main.rs:893-1106, crates/apex-client/src/app.rs:1651-1764

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 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 (app.rs:5371-5403). 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 Local Fresh Log, attach as AttachmentKind::Ui, init_session, Server::new, default rules, open the named files (or .) through server.open_file + perform (app.rs:734-756)
Acme::attach Remote Take a parked session from the pool if there is one, else connect_targeted. Files open in the last column (app.rs:761-786)
Acme::attach_via Remote --via: connect_via, URL provider "via" (app.rs:1496-1510)
Acme::from_parked Remote Rebuild from a pool::Parked (link, log, node, pending previews, snarfouts, goto) (app.rs:790-805)
Acme::adopt Remote Swap a link made elsewhere (the pool's background attach) into an existing window, resetting per-session UI state (app.rs:1515-1561)

connect_link 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 does the same with Link::connect / Link::over_streams, which do not create a session that has gone (app.rs:1430-1479). Every link goes through arm, 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 (app.rs:1209-1242):

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

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 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 (app.rs:811-855, pool.rs:120-168). A parked session keeps its leases. The daemon still forwards tools' proposals to it, and the pool applies them.

Leadership and fencing. fenced() 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)", and the top row's square shows it (app.rs:2279-2282, app.rs:1626-1636). in_process() 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:381-682, crates/apex-client/src/app.rs:732-856, crates/apex-client/src/app.rs:1170-1242, crates/apex-client/src/app.rs:1413-1611, 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() 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 (plumb_local), and close_orphan_terms. Over a link it only flushes. Then it calls sync (app.rs:2795-2819).

poll_remote() handles everything the reader thread has queued (app.rs:2694-2773):

  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() runs after every input handler and twice per frame. It drains the node's effect queues into client state: take_shows into show_at, take_client_finds (Look in terminals and pages), take_gotos, take_switches, and a pending_goto waiting for its file to open. Then it calls link.flush(&self.log) and node.catch_up(&self.log). The comment states the guarantee: "nothing the user typed is ever more than a frame away from the daemon" (app.rs:1897-1975).

sequenceDiagram
    participant U as "User input"
    participant A as "Acme"
    participant N as "Node (leader)"
    participant L as "Log / Link mirror"
    participant D as "apexd"
    U->>A: key_down / mouse_up
    A->>N: insert / exec / select
    N->>L: append entries (seq assigned locally)
    A->>A: after() then sync()
    A->>L: link.flush(log)
    L->>D: ClientMsg::Append
    D-->>L: Entries (terms, metalog), proposals
    L-->>A: Wake (reader thread)
    A->>L: poll_remote: link.poll(node, log)
    A->>N: catch_up, take_shows / take_gotos
    A->>A: cx.notify() then render

Terminal input goes straight to the backend instead of through entries, because the daemon leads the term shard. term_key, term_type, term_paste and term_wheel call the in-process Server directly, or send ClientMsg::TermKey/TermType/TermPaste/TermScroll (app.rs:2836-2874). Plumbing from B3 works the same way: look_at walks the rules locally with plumb_local, or sends ClientMsg::Plumb (app.rs:5540-5555).

Sources: crates/apex-client/src/app.rs:1897-1975, crates/apex-client/src/app.rs:2680-2874, crates/apex-client/src/app.rs:5533-5555

Rendering a frame

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

  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.
  3. Reset per-frame records. It clears layouts, term_layouts, web_bars, overlay bounds and title marks, 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).
  5. A waiting tab. If waiting is set, the frame is a spinner and a message with the title bar and selector, and nothing else.
  6. The tiled area. It walks glided_layout(), the replicated Layout with glides applied. For each column it draws the ground, the column tag (TextElement { view: ViewId::ColTag }), and for each window 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. Strips (minimized or stashed columns) get strip_element/minimized_element. 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, 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.

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) and top() (title_h(), which is the tag line plus BORDER, at least 38 px) (main.rs:1116-1122, app.rs:2652-2678).

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 and installs it each frame as node.tiling = Box::new(ClientInfo { … }) (app.rs:114-152, app.rs:2568-2592). 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 (app.rs:2600-2640). 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:62-663, crates/apex-client/src/main.rs:1108-1126, crates/apex-client/src/app.rs:114-152, crates/apex-client/src/app.rs:2565-2678

Painting text from core state: TextElement

TextElement { acme: Entity<Acme>, view: ViewId } is a hand-written gpui Element. 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:33-50).

flowchart LR
    RL["request_layout: fill the box the tiling gave"]
    PP["prepaint: Acme::source(view) then shape lines"]
    P["paint: selections, sweeps, glyphs, caret, handle, scroller"]
    TL["TextLayout stored in Acme.layouts"]
    HT["Acme::locate: hit-test next input"]
    RL --> PP --> P --> TL --> HT

request_layout fills the box it is given ("acme's tiling decides every rectangle"). The top row is measured instead, from its process pills plus text (text_element.rs:1591-1643).

Acme::source(view) gathers everything the element needs into a Source (app.rs:2878-3033, text_element.rs:1179-1249):

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:1401-1570). shape expands tabs to TABSTOP (4), 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) and the overlay scroller. Last, it stores a TextLayout in acme.layouts[view] (text_element.rs:2242-2259).

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:785-903). Acme::locate(pos) 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 (app.rs:3094-3138). Input therefore always hit-tests against what was actually painted in the last frame.

Sources: crates/apex-client/src/text_element.rs:738-903, crates/apex-client/src/text_element.rs:1150-1861, crates/apex-client/src/text_element.rs:2238-2261, crates/apex-client/src/app.rs:2876-3138

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 intercepts some words first and handles them locally (app.rs:5422-5531):

Word Handled by the client because…
End (-f) Checks node.session_clean, then ends the session through end_session
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
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. 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 and report call node.errors (app.rs:5407-5420). 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 (app.rs:1597-1605). reconnect closes the stuck link and lets the pool attach again (app.rs:1581-1595).

Sources: crates/apex-client/src/app.rs:5405-5531, crates/apex-client/src/app.rs:1581-1605, crates/apex-client/src/app.rs:536-682

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 nearly 6,000 lines.

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