使用 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,把 sender 移到 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>。整個初始化過程中唯一會失敗的一步——而它能失敗的也只有繫結 socket 這一件事 |
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 只安裝一次
第二次安裝會取代第一次,並停掉它的伺服器;開發階段有 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 而言它永遠是 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()。
拿到這個位址之後該怎麼辦,參見客戶端從哪裡連進來。