使用 purview-gpui
核心 crate 不链接任何工具包,所以把它接到 gpui 上,就意味着每个应用都要重写同一段胶水代码。purview-gpui 就是这段代码,只写一次。
它解决的问题
gpui 的 App 和 AsyncApp 都是 !Send 的:只能在主线程上访问。而 MCP 服务端跑在后台线程上。于是,一个想拿到 &mut App 的处理器就需要一条从后台线程回到主线程的 Send 通路——而 gpui 并没有提供公开、且不依赖 channel 的方式把闭包投递过去。
手写的话,这里有五个零件:
- 一个类型化的
Sendchannel,传递Box<dyn FnOnce(&mut App) + Send>。 - 一个投递闭包,交给
GuiBridge::builder,把发送端移到 MCP 线程上。 - 构建并启动 bridge 及其后台服务端。
- 主线程上的一个排空循环,用
cx.update逐个执行投递过来的闭包。 - 让
RunningBridge活过初始化阶段——也就是对外提供服务一节里提到的那个陷阱。
用上这个 crate 之后
只需一条语句。channel 依然存在——它是必需的 Send 桥梁——但你再也不会看到它。
安装
[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。
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, …):
// 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 动作和按钮点击的写法完全一致:
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::Window 与 gpui::Window 的命名冲突:
| 别名 | 等价于 |
|---|---|
PvApp | AppHandle<App> |
PvWindow | Window<App>——与 gpui::Window 不冲突 |
PvFields、PvTable、PvList、PvText、PvNotice、PvMedia、PvBlock | 各类内容块句柄 |
PvAction、PvBlockRef、PvBlocks<'a>、PvActions<'a> | 其余类型 |
PvUi | Ui<App>——async 处理器用来切回主线程的那个句柄 |
该 crate 还重新导出了 Purview 的非泛型类型(ActionError、Outcome、Severity、Block、Field、NoArgs、on_ui),它们都不与 gpui 冲突——所以 use purview_gpui::*; 可以和 use gpui::*; 放在一起,不会有问题。
cx.purview()
PurviewAppExt 可以把已安装的句柄从 global 里取回来。由于 Context<V> 会 deref 到 App,它在任何视图或处理器内部都能用——于是一个窗口可以打开另一个窗口,而不必让谁把 AppHandle 一路穿过各层构造函数:
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,是因为从这里看传输方式确实是未知的——而在初始化现场你已经知道了,所以那边该用不带 Option 的 BoundBridge::local_addr()。
拿到这个地址之后该怎么办,参见客户端从哪里连进来。