Skip to content

使用 purview-gpui

核心 crate 不链接任何工具包,所以把它接到 gpui 上,就意味着每个应用都要重写同一段胶水代码。purview-gpui 就是这段代码,只写一次。

它解决的问题

gpui 的 AppAsyncApp 都是 !Send 的:只能在主线程上访问。而 MCP 服务端跑在后台线程上。于是,一个想拿到 &mut App 的处理器就需要一条从后台线程回到主线程的 Send 通路——而 gpui 并没有提供公开、且不依赖 channel 的方式把闭包投递过去。

手写的话,这里有五个零件:

  1. 一个类型化的 Send channel,传递 Box<dyn FnOnce(&mut App) + Send>
  2. 一个投递闭包,交给 GuiBridge::builder,把发送端移到 MCP 线程上。
  3. 构建并启动 bridge 及其后台服务端。
  4. 主线程上的一个排空循环,用 cx.update 逐个执行投递过来的闭包。
  5. RunningBridge 活过初始化阶段——也就是对外提供服务一节里提到的那个陷阱。

用上这个 crate 之后

只需一条语句。channel 依然存在——它是必需的 Send 桥梁——但你再也不会看到它。

安装

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"] }

版本对齐

依赖图中 gpui 必须解析为唯一一份——项目里所有 crate 都得就版本达成一致,否则 App 类型无法统一,报错信息也会晦涩难懂。可以用 cargo tree -i gpui 检查。

一次调用完成初始化

Application::run 内部调用 Bridge,此时正好拿得到活跃的 App。它返回一个 purview::AppHandle<App>——从这里开始,你就回到了核心 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");
    });
}
Bridge 上的方法作用
Bridge::new()创建构建器
.instructions_append(s)与核心构建器的同名方法一致
.install_stdio(cx)在后台线程上提供 stdio 服务
.http()切换到 HttpBridge——HTTP 专属的一切都在那边
HttpBridge 上的方法作用
.token(token)要求请求携带 Authorization: Bearer <token>——参见授权
.bind(addr)构建并绑定,返回 io::Result<BoundBridge>。整个初始化过程中唯一会失败的一步——而它能失败的也只有绑定套接字这一件事
BoundBridge 上的方法作用
.local_addr()已绑定的地址——是 SocketAddr 而非 Option。传入端口 0 时,这就是你得知操作系统挑了哪个端口的途径
.install(cx)在后台线程上提供服务并返回 AppHandle。不可能失败
.app()服务器启动之前AppHandle,用于想先声明窗口的场合

为什么要分两步

bind 单独一步,是为了让 local_addr() 在任何东西开始服务之前就已经存在——这正是端口 0 可用的前提。它同时把唯一的失败点收拢到一处,所以 install 既不返回 Result,也不需要一个 panic 版的孪生方法。

同样的道理把 token 放在 HttpBridge 而不是 Bridge 上:它在 stdio 上毫无意义,因为根本没有头可以承载它。核心 crate 在 GuiBridge::http() 处做了完全一致的切分。

通过 .app() 先声明窗口、随后再 install,意味着任何客户端能读到投影时它都已经是完整的。install 返回的是同一个句柄,所以除非你要这个先后顺序,.app() 可以不用。

它替你接好了什么

Send channel、投递闭包、build()、后台服务端,以及主线程上的排空循环。RunningBridge 被存放在一个 gpui global 里,因此它的生命周期恰好等于 App 的:应用销毁时服务端优雅停止,而你手上根本没有会被误丢弃的句柄。

每个 App 只 install 一次

第二次 install 会替换掉第一次并停掉它的服务端;开发期有 debug_assert 会捕获这种情况。

Entity 处理器

手写时,每个驱动视图的动作都在重复同样两步——克隆 entity,然后 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 把这两步收了起来。闭包接收 &mut V&mut Context<V>——正是 cx.listener 拿到的那一对,于是 Agent 动作和按钮点击的写法完全一致:

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"))
    }));

什么时候不该用它

on_entity 只针对单个 entity。如果一个处理器要操作多个 entity,或者需要先读取某个 global,就该用普通的 on_ui(|cx: &mut App| …)——它永远可用。

类型别名

Purview 的每个句柄都对 Cx 泛型。在 gpui 里 Cx 恒为 App,因此该 crate 提供了一组别名——这同时也解决了 purview::Windowgpui::Window 的命名冲突:

别名等价于
PvAppAppHandle<App>
PvWindowWindow<App>——与 gpui::Window 不冲突
PvFieldsPvTablePvListPvTextPvNoticePvMediaPvBlock各类内容块句柄
PvActionPvBlockRefPvBlocks<'a>PvActions<'a>其余类型
PvUiUi<App>——async 处理器用来切回主线程的那个句柄

该 crate 还重新导出了 Purview 的非泛型类型(ActionErrorOutcomeSeverityBlockFieldNoArgson_ui),它们都不与 gpui 冲突——所以 use purview_gpui::*; 可以和 use gpui::*; 放在一起,不会有问题。

cx.purview()

PurviewAppExt 可以把已安装的句柄从 global 里取回来。由于 Context<V> 会 deref 到 App,它在任何视图或处理器内部都能用——于是一个窗口可以打开另一个窗口,而不必让谁把 AppHandle 一路穿过各层构造函数:

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.
if let Some(addr) = cx.purview_addr() {
    // Render it, or put "copy the connect command" behind a button.
}

purview_addr 返回 Option,是因为从这里看传输方式确实是未知的——而在初始化现场你已经知道了,所以那边该用不带 OptionBoundBridge::local_addr()

拿到这个地址之后该怎么办,参见客户端从哪里连进来