Skip to content

Using purview-gpui

The core crate links no toolkit, so binding it to gpui means writing the same block of glue in every app. purview-gpui is that block, written once.

The problem it solves

gpui's App and AsyncApp are !Send: they may only be touched on the main thread. The MCP server runs on a background thread. So a handler that wants &mut App needs a Send path from that thread back to the main one — and gpui offers no public, channel-free way to post a closure there.

By hand, that is five moving parts:

  1. A typed Send channel carrying Box<dyn FnOnce(&mut App) + Send>.
  2. A poster closure handed to GuiBridge::builder, moving the sender onto the MCP thread.
  3. Build and spawn the bridge and its background server.
  4. A drain loop on the main thread, applying each posted closure via cx.update.
  5. Keeping RunningBridge alive past the end of setup — the trap from Serving.

With the crate

One statement. The channel still exists — it is the necessary Send bridge — but you never see it.

Installing

toml
[dependencies]
purview-gpui = "0.1"
gpui = "0.2.2"          # must be the same gpui your UI crates use
schemars = "1"          # only if you use .params()/.returns()
serde = { version = "1", features = ["derive"] }

Version alignment

gpui must resolve to a single copy in the graph — every crate in your project has to agree on the version, or the App types will not unify and the errors will be cryptic. Check with cargo tree -i gpui.

One-call setup

Call Bridge inside Application::run, where the live App is in hand. It returns a purview::AppHandle<App> — from there you are back to the core API.

rust
use gpui::*;
use purview_gpui::Bridge;

fn main() {
    Application::new().run(|cx: &mut App| {
        let papp = Bridge::new()
            .instructions_append("A counter. Use increment/decrement.")
            .http()
            .bind("127.0.0.1:8931")
            .expect("bind the MCP port")
            .install(cx);

        // `papp` is a purview::AppHandle<App> — describe windows as usual.
        let win = papp.open("Counter");
        win.set_summary("A counter you can drive from the UI or an agent");
    });
}
On BridgeDoes
Bridge::new()Starts the builder
.instructions_append(s)Same as the core builder's
.install_stdio(cx)Serves stdio on a background thread
.http()Switches to HttpBridge — everything HTTP-only lives there
On HttpBridgeDoes
.token(token)Requires Authorization: Bearer <token> — see Authorization
.bind(addr)Builds and binds, returning io::Result<BoundBridge>. The one part of setup that can fail — and a socket bind is all it can fail at
On BoundBridgeDoes
.local_addr()The address bound — a SocketAddr, not an Option. Pass port 0 and this is how you learn what the OS chose
.install(cx)Serves on a background thread and returns the AppHandle. Cannot fail
.app()The AppHandle before the server starts, if you want to declare windows first

Why it is two steps

bind is separate so that local_addr() exists before anything is serving — that is what makes port 0 usable. It also collects the only failure into one place, which is why install has no Result and no panicking twin.

The same reasoning puts token on HttpBridge rather than on Bridge: it is meaningless over stdio, which has no header to carry it. The core crate splits identically, at GuiBridge::http().

Declaring windows through .app() and installing afterwards means the projection is complete before any client can read it. install returns the same handle, so skip .app() unless you want that ordering.

What it wires for you

The Send channel, the poster, build(), the background server, and the main-thread drain loop. The RunningBridge is stashed in a gpui global, so its lifetime is exactly the App's: the server stops gracefully when your app tears down, and there is no handle for you to accidentally drop.

Install once per App

A second install would replace the first and stop its server; a debug_assert catches it in development.

Entity handlers

By hand, every action that drives a view repeats the same two moves — clone the entity, then entity.update(app, …):

rust
// Without the helper:
let c = counter.clone();
win.actions().add("increment", "Increment").on(on_ui(move |app: &mut App| {
    let n = c.update(app, |c, cx| { c.bump(1, cx); c.count });
    Ok::<_, ActionError>(Outcome::message(format!("count = {n}")))
}));

on_entity collapses that. The closure receives &mut V and &mut Context<V> — the same pair a cx.listener gets, so an agent action and a button click are written the same way:

rust
use purview_gpui::{on_entity, on_entity_with};

// No parameters:
win.actions().add("increment", "Increment").on(on_entity(&counter, |c, cx| {
    c.bump(1, cx);
    Ok::<_, ActionError>(Outcome::message(format!("count = {}", c.count)))
}));

// With parameters — pair it with .params::<T>() so the schema matches:
win.actions().add("add", "Add a todo")
    .params::<Add>()
    .on(on_entity_with(&todos, |p: Add, this, cx| {
        this.mutate(cx, |s| s.items.push(Todo::new(p.text)));
        Ok::<_, ActionError>(Outcome::message("added"))
    }));

When to skip it

on_entity targets exactly one entity. A handler that touches several, or reads a global first, should use plain on_ui(|cx: &mut App| …) — always available.

Type aliases

Every Purview handle is generic over Cx. For gpui that is always App, so the crate ships aliases — which also resolves the clash between purview::Window and gpui::Window:

AliasIs
PvAppAppHandle<App>
PvWindowWindow<App> — no clash with gpui::Window
PvFields, PvTable, PvList, PvText, PvNotice, PvMedia, PvBlockThe block handles
PvAction, PvBlockRef, PvBlocks<'a>, PvActions<'a>The rest
PvUiUi<App> — what an async handler captures to hop back

The crate also re-exports Purview's non-generic types (ActionError, Outcome, Severity, Block, Field, NoArgs, on_ui), none of which clash with gpui — so use purview_gpui::*; is safe next to use gpui::*;.

cx.purview()

PurviewAppExt reads the installed handle back out of the global. Because Context<V> derefs to App, it works inside any view or handler — so a window can open another without anyone threading an AppHandle through constructors:

rust
use purview_gpui::PurviewAppExt;

// Inside a cx.listener, or any handler with &mut App:
let detail = cx.purview().open("Detail");
detail.set_summary("Opened on demand");

// Non-panicking variant when a bridge may not be installed:
if let Some(app) = cx.try_purview() { /* … */ }

// The served address, from anywhere. `None` for stdio, or if no bridge was
// installed — from here the transport is genuinely unknown, which is why this
// one is an Option and `BoundBridge::local_addr()` is not.
if let Some(addr) = cx.purview_addr() {
    // Render it, or put "copy the connect command" behind a button.
}

At setup you already know the transport, so take the address from BoundBridge::local_addr() instead. See Where the client connects for what to do with it.