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:
- A typed
Sendchannel carryingBox<dyn FnOnce(&mut App) + Send>. - A poster closure handed to
GuiBridge::builder, moving the sender onto the MCP thread. - Build and spawn the bridge and its background server.
- A drain loop on the main thread, applying each posted closure via
cx.update. - Keeping
RunningBridgealive 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
[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.
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 Bridge | Does |
|---|---|
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 HttpBridge | Does |
|---|---|
.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 BoundBridge | Does |
|---|---|
.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, …):
// 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:
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:
| Alias | Is |
|---|---|
PvApp | AppHandle<App> |
PvWindow | Window<App> — no clash with gpui::Window |
PvFields, PvTable, PvList, PvText, PvNotice, PvMedia, PvBlock | The block handles |
PvAction, PvBlockRef, PvBlocks<'a>, PvActions<'a> | The rest |
PvUi | Ui<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:
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.