feat(host,orchestrator): wire intent gate + DesignSession into chat runtime

Task #27. Routes user messages through `op_orchestrator::classify_intent`
in `chat_session::launch_if_pending` — `Intent::Design` with a configured
`agent::Provider` launches into the orchestrator pipeline; everything
else (chat intent, or design intent with no Provider) falls through to
the existing `ChatProvider` CLI path.

The orchestrator is `async + &mut EditorState`; the chat path is built
around worker threads + blocking iterators (`ChatProvider::send`). Codex
architectural review picked Option E (UI-owned actor + RemoteDocSink +
ack channel):

- Worker thread owns a `RemoteDocSink` that forwards each `apply(cmd)`
  over an mpsc channel to the UI thread, which `apply()`s on the real
  `EditorState` and replies with an ack carrying a fresh state snapshot.
- `RemoteDocSink::state()` reads from a locally cached mirror updated
  by each ack — covers `EditorCommand::InsertSubtree`'s ID-remapping +
  history bookkeeping which must run on the UI thread.
- Two mpsc channels per turn: progress deltas (Planning / SubtaskStarted
  / etc.) into the chat transcript, and `DesignCmdReq` for apply +
  undo-batch boundaries.
- `BeginUndoBatch` / `EndUndoBatch` are forwarded as own `DesignCmdOp`
  variants so the UI can route them through real history batching once
  `op-editor-core` exposes that API (currently no-op, matching
  `DesktopDocSink`).

Provider source MVP: `OPENPENCIL_ANTHROPIC_API_KEY` (preferred) or
`ANTHROPIC_API_KEY` from env constructs an `AnthropicProvider`. Model
override via `OPENPENCIL_ORCHESTRATOR_MODEL` (defaults to
claude-sonnet-4-6). When neither key is set, design intent falls back
to the chat-CLI path so the user still gets an answer.

Wiring:
- `main.rs` `current_design: Option<DesignSession>` field next to
  `current_chat`; mutually exclusive routing in `launch_if_pending`.
- `app_handler.rs` `RedrawRequested` pumps both `pump_commands`
  (apply + ack) and `pump_progress` (deltas + summary); WaitUntil tick
  schedules a 33 ms wake when either session is in flight.
- `keyboard_input.rs` Enter / send sites pass both Option<&mut> args.

Provider features: `agent` crate gains the `anthropic` cargo feature in
`op-host-desktop/Cargo.toml` so `AnthropicProvider` is reachable
(previously `default-features = false` left it gated out — chat path
only needed the trait + engine wiring).

3 new `RemoteDocSink` tests cover ack round-trip + closed-channel safety
+ undo-batch signal distinguishability. Workspace 1882 passed / 0
failed. cargo fmt + clippy --workspace -D warnings clean.

The `chat_orchestrator.rs::run_design_request` legacy entry stays for
now (used by future single-shot programmatic callers); the live path is
`DesignSession::start`. Validation providers stay `Skipped*` until
jian-skia `captureRegion` + vision LLM crate land (task #28).
This commit is contained in:
Fini 2026-05-24 15:11:34 +08:00
parent ad555231e4
commit 35312b960b
7 changed files with 868 additions and 23 deletions

285
Cargo.lock generated
View file

@ -38,9 +38,12 @@ name = "agent"
version = "0.1.0"
dependencies = [
"async-trait",
"bytes",
"eventsource-stream",
"futures",
"lru",
"rand 0.8.6",
"reqwest 0.13.3",
"schemars 1.2.1",
"serde",
"serde_json",
@ -113,7 +116,7 @@ dependencies = [
"base64",
"dirs 6.0.0",
"futures",
"reqwest",
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2",
@ -383,6 +386,28 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "base64"
version = "0.22.1"
@ -530,7 +555,7 @@ dependencies = [
"calloop",
"cfg_aliases",
"concurrent-queue",
"core-foundation",
"core-foundation 0.9.4",
"core-graphics",
"cursor-icon",
"dpi 0.1.1",
@ -629,6 +654,15 @@ dependencies = [
"error-code",
]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "combine"
version = "4.6.7"
@ -668,6 +702,16 @@ dependencies = [
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@ -681,7 +725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"core-foundation 0.9.4",
"core-graphics-types",
"foreign-types",
"libc",
@ -694,7 +738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"core-foundation 0.9.4",
"libc",
]
@ -910,6 +954,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dyn-clone"
version = "1.0.20"
@ -922,6 +972,15 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@ -1001,6 +1060,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "eventsource-stream"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab"
dependencies = [
"futures-core",
"nom",
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.4.1"
@ -1049,6 +1119,12 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
@ -1097,6 +1173,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.32"
@ -1379,6 +1461,25 @@ version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be136d9dacc2a13cc70bb6c8f902b414fb2641f8db1314637c6b7933411a8f82"
[[package]]
name = "h2"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
@ -1502,6 +1603,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@ -2037,6 +2139,22 @@ dependencies = [
"autocfg",
]
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@ -2638,7 +2756,7 @@ dependencies = [
"op-opmerge",
"op-orchestrator",
"op-pen-loader",
"reqwest",
"reqwest 0.12.28",
"rfd",
"serde",
"serde_json",
@ -2737,6 +2855,12 @@ dependencies = [
"serde_json",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "option-ext"
version = "0.2.0"
@ -2995,6 +3119,7 @@ version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"lru-slab",
@ -3264,11 +3389,56 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots 1.0.7",
]
[[package]]
name = "reqwest"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
dependencies = [
"base64",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.5.0",
"web-sys",
]
[[package]]
name = "rfd"
version = "0.14.1"
@ -3364,6 +3534,7 @@ version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
@ -3373,6 +3544,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
@ -3383,12 +3566,40 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@ -3424,6 +3635,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@ -3499,6 +3719,29 @@ dependencies = [
"tiny-skia",
]
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.11.1",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "semver"
version = "1.0.28"
@ -4300,6 +4543,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@ -4536,6 +4785,19 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
@ -4677,6 +4939,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
@ -5012,7 +5283,7 @@ dependencies = [
"calloop",
"cfg_aliases",
"concurrent-queue",
"core-foundation",
"core-foundation 0.9.4",
"core-graphics",
"cursor-icon",
"dpi 0.1.2",

View file

@ -160,7 +160,9 @@ dirs = "5"
# the trait + the engine wiring + `from_provider`; concrete
# Provider impls (Anthropic / OpenAI-compat / Ollama) flip on once the
# workspace rust-toolchain bumps.
agent = { path = "../../vendor/agent/crates/agent", default-features = false }
agent = { path = "../../vendor/agent/crates/agent", default-features = false, features = [
"anthropic",
] }
# Forked from bartolli/anthropic-agent-sdk — lives under
# `vendor/anthropic-agent-sdk/` so OP owns the source and can diverge
# from upstream. Provides the Claude Code CLI subprocess transport +

View file

@ -4,8 +4,8 @@
//! `DesktopApp` struct, its helper `impl`, and `fn main`.
use crate::{
chat_attachment, chat_session, cursor_icon, frame, git_jobs, menu, persistence, settings_io,
window_state, DesktopApp, INITIAL_VIEWPORT_H, INITIAL_VIEWPORT_W,
chat_attachment, chat_session, cursor_icon, design_session, frame, git_jobs, menu, persistence,
settings_io, window_state, DesktopApp, INITIAL_VIEWPORT_H, INITIAL_VIEWPORT_W,
};
use op_host_native::{NativeBackend, SharedSkiaContext};
use std::time::{Duration, Instant};
@ -344,6 +344,16 @@ impl ApplicationHandler for DesktopApp {
if chat_session::pump(&mut self.host, &mut self.current_chat) {
self.redraw_dirty = true;
}
// Drain orchestrator apply requests + progress events
// for any in-flight design turn (orchestrator runs off
// the UI thread; `RemoteDocSink` forwards mutations
// here each frame).
if design_session::pump_commands(&mut self.host, &mut self.current_design) {
self.redraw_dirty = true;
}
if design_session::pump_progress(&mut self.host, &mut self.current_design) {
self.redraw_dirty = true;
}
// Drain background model discovery once it lands.
if self.model_probe.poll_into(&mut self.host) {
self.redraw_dirty = true;
@ -406,8 +416,9 @@ impl ApplicationHandler for DesktopApp {
);
}
}
// Chat turn streaming → wake ~30 fps to pump deltas.
if self.current_chat.is_some() {
// Chat or design turn streaming → wake ~30 fps to pump
// deltas / orchestrator apply requests.
if self.current_chat.is_some() || self.current_design.is_some() {
event_loop.set_control_flow(ControlFlow::WaitUntil(
Instant::now() + Duration::from_millis(33),
));
@ -559,7 +570,11 @@ impl ApplicationHandler for DesktopApp {
);
// A click on the chat Send button raises
// `pending_send` — launch the provider turn.
if chat_session::launch_if_pending(&mut self.host, &mut self.current_chat) {
if chat_session::launch_if_pending(
&mut self.host,
&mut self.current_chat,
&mut self.current_design,
) {
self.request_redraw(true);
}
// A click on the attach button raises

View file

@ -8,15 +8,19 @@
//! message.
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::sync::Arc;
use std::thread;
use agent::provider::Provider;
use op_ai::chat_provider::{ChatDelta, ChatProvider, ChatRequest, CliName};
use op_editor_core::{ChatMessage, ChatToolCall};
use op_host_native::WidgetHostNative;
use op_orchestrator::{classify_intent, DesignRequest, Intent};
use crate::chat_claude::ClaudeCodeProvider;
use crate::chat_copilot::CopilotProvider;
use crate::chat_subprocess::SubprocessProvider;
use crate::design_session::DesignSession;
/// One in-flight chat turn. The worker thread owns the provider and
/// drains `provider.send()` into the channel; [`poll`] consumes
@ -142,17 +146,55 @@ impl ChatSession {
}
}
/// Drain `chat.pending_send` (raised by `ChatState::begin_send`)
/// into a fresh `ChatSession` against the Claude Code CLI.
/// `ClaudeCodeProvider` auto-discovers the `claude` binary. A send
/// fired mid-turn replaces the in-flight session — the old worker
/// thread drains harmlessly once its channel receiver drops.
/// Returns true when a turn was launched (caller redraws).
pub fn launch_if_pending(host: &mut WidgetHostNative, current: &mut Option<ChatSession>) -> bool {
/// Drain `chat.pending_send` (raised by `ChatState::begin_send`) and
/// route it through the orchestrator intent gate. `Intent::Design`
/// requests with a configured `agent::Provider` launch into
/// `current_design`; everything else (chat intent, or design intent
/// with no Provider available) falls through to the existing
/// `ChatProvider` path in `current_chat`. A send fired mid-turn
/// replaces the in-flight session — the old worker thread drains
/// harmlessly once its channel receiver drops.
///
/// Returns true when *any* turn was launched (caller redraws).
pub fn launch_if_pending(
host: &mut WidgetHostNative,
current_chat: &mut Option<ChatSession>,
current_design: &mut Option<DesignSession>,
) -> bool {
let Some(user_text) = host.editor_state_mut().chat.pending_send.take() else {
return false;
};
host.mark_editor_state_dirty();
// Intent gate (op_orchestrator::classify_intent) — design verbs
// route to the orchestrator pipeline when a Provider is available;
// otherwise we fall through to the existing chat path so the user
// still gets an answer.
if matches!(classify_intent(&user_text), Intent::Design) {
if let Some((provider, model)) = provider_for_design() {
*current_chat = None;
let initial_state = host.editor_state().clone();
let request = DesignRequest {
prompt: user_text,
model: Some(model.clone()),
provider: None,
design_md: initial_state.doc.design_md.clone(),
append_context: None,
concurrency: 1,
validation_enabled: false,
visual_ref_enabled: false,
};
*current_design = Some(DesignSession::start(
provider,
model,
request,
initial_state,
));
return true;
}
// Design intent but no Provider configured — fall through to
// the chat path. The assistant CLI will still answer the user
// (most CLIs handle design verbs as chat).
}
let agent_idx = host.editor_state().editor_ui.chat_selected_agent;
let Some(provider) = provider_for_agent(agent_idx) else {
// Selected agent has no `ChatProvider` bridge yet (Codex /
@ -165,7 +207,7 @@ pub fn launch_if_pending(host: &mut WidgetHostNative, current: &mut Option<ChatS
// `pump` keeps streaming the previous agent's deltas into
// this fresh error bubble (codex stop-gate: stale session
// overwrote the unwired-agent error text).
*current = None;
*current_chat = None;
let name = op_editor_core::AgentProvider::ALL
.get(agent_idx)
.map(|a| a.name())
@ -206,10 +248,30 @@ pub fn launch_if_pending(host: &mut WidgetHostNative, current: &mut Option<ChatS
effort,
attachments,
};
*current = Some(ChatSession::start(provider, req));
*current_chat = Some(ChatSession::start(provider, req));
true
}
/// Build an `agent::Provider` for the orchestrator's design path.
/// MVP: reads `OPENPENCIL_ANTHROPIC_API_KEY` (preferred) or
/// `ANTHROPIC_API_KEY` from the environment and constructs an
/// `AnthropicProvider`. Returns `None` when neither key is set so the
/// caller can fall back to the chat-CLI path honestly.
///
/// `OPENPENCIL_*` is checked first so users can isolate the
/// orchestrator's API key from any `ANTHROPIC_API_KEY` other tooling
/// might consume (e.g. `claude` CLI, the Anthropic SDK examples).
fn provider_for_design() -> Option<(Arc<dyn Provider>, String)> {
let api_key = std::env::var("OPENPENCIL_ANTHROPIC_API_KEY")
.ok()
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
.filter(|k| !k.is_empty())?;
let model = std::env::var("OPENPENCIL_ORCHESTRATOR_MODEL")
.unwrap_or_else(|_| "claude-sonnet-4-6".to_string());
let provider = agent::provider::anthropic::AnthropicProvider::new(api_key);
Some((Arc::new(provider) as Arc<dyn Provider>, model))
}
/// Build the `ChatProvider` for an agent index (into
/// `AgentProvider::ALL`: 0 ClaudeCode, 1 CodexCli, 2 OpenCode,
/// 3 GithubCopilot, 4 GeminiCli). Claude Code uses its dedicated

View file

@ -0,0 +1,483 @@
//! Background design-turn runner — orchestrator counterpart of [`ChatSession`](crate::chat_session).
//!
//! The orchestrator (`op_orchestrator::Orchestrator::run`) is `async`,
//! takes `&mut sink` (the `DocSink` trait — synchronous read + write
//! against `EditorState`), and runs to completion across multiple
//! `apply()` calls during scaffold → subtasks → cleanup. Two competing
//! constraints shape the threading model:
//!
//! - **UI owns the canonical `EditorState`.** `EditorCommand::apply`
//! does ID remapping + history bookkeeping that must run on the UI
//! thread (see `command_apply.rs`).
//! - **Don't freeze the UI for the whole turn.** A design turn can take
//! 10+ seconds; `block_on(run(...))` on the UI thread would lock the
//! window during that span.
//!
//! Resolution: the worker thread owns a **`RemoteDocSink`** that
//! forwards each `apply(cmd)` over an mpsc channel to the UI thread,
//! which `apply()`s on the real state and replies with an ack carrying
//! a fresh `EditorState` snapshot. `RemoteDocSink::state()` reads from
//! a locally cached mirror updated by each ack. The orchestrator never
//! sees the channel — it just calls `sink.apply()` synchronously, and
//! the worker's `apply` blocks until UI acks.
//!
//! Progress events emitted by the orchestrator (`Planning`,
//! `SubtaskStarted`, etc.) ride a separate channel into the chat
//! transcript, mirroring `ChatSession`'s delta channel.
//!
//! ## Lifecycle
//!
//! 1. Caller (`chat_session::launch_if_pending`) classifies intent.
//! For `Intent::Design` + a configured `agent::Provider`, builds a
//! `DesignSession` via [`DesignSession::start`].
//! 2. `start` clones the current `EditorState` for the worker's
//! initial mirror and spawns the worker thread. The worker calls
//! `block_on(Orchestrator::new().run(...))` against its
//! `RemoteDocSink`.
//! 3. UI event loop drains pending `DesignCmdReq` each frame via
//! [`pump_commands`] — applies on the real state, replies ack.
//! 4. UI event loop also drains `DesignDelta` via [`pump_progress`]
//! and renders progress into the trailing chat bubble.
//! 5. On `Done`, the session is dropped and the channels close.
//!
//! Aborting a turn drops `DesignSession`; the worker's next `apply`
//! sees the channel closed and returns `false`, ending the turn.
use std::sync::mpsc::{self, Receiver, Sender, SyncSender, TryRecvError};
use std::sync::Arc;
use std::thread;
use agent::provider::Provider;
use op_editor_core::{EditorCommand, EditorState};
use op_host_native::WidgetHostNative;
use op_orchestrator::{
AbortFlag, DesignRequest, DocSink, Orchestrator, OrchestratorError, Progress, RunSummary,
SkippedScreenshotProvider, SkippedVisionLlmClient, ValidationProviders,
};
use crate::chat_orchestrator::DesktopLlmClient;
use crate::chat_runtime::shared_runtime;
use crate::pre_validator::LintPreValidator;
/// One in-flight design turn.
pub struct DesignSession {
delta_rx: Receiver<DesignDelta>,
cmd_rx: Receiver<DesignCmdReq>,
finished: bool,
}
/// Progress / completion events emitted by the worker.
pub enum DesignDelta {
/// One `op_orchestrator::Progress` event.
Progress(Progress),
/// Terminal event — the orchestrator returned. The session is
/// finished once this arrives.
Done(Result<RunSummary, OrchestratorError>),
}
/// Request from worker to UI to apply one editor mutation (or undo-batch
/// boundary). The worker blocks until the matching ack arrives.
pub struct DesignCmdReq {
pub op: DesignCmdOp,
pub ack: SyncSender<DesignCmdAck>,
}
/// What the worker is asking the UI to do.
pub enum DesignCmdOp {
Apply(EditorCommand),
BeginUndoBatch,
EndUndoBatch,
}
/// UI's reply to one [`DesignCmdReq`]. Carries an `EditorState` clone
/// so the worker's mirror reflects ID-remapped state.
pub struct DesignCmdAck {
pub applied: bool,
pub new_state: EditorState,
}
/// Result of one non-blocking progress drain.
pub struct DesignPoll {
pub progress: Vec<Progress>,
/// Terminal summary when the turn ended; `None` while running.
pub summary: Option<Result<RunSummary, OrchestratorError>>,
pub finished: bool,
}
impl DesignPoll {
#[cfg(test)]
fn is_idle(&self) -> bool {
self.progress.is_empty() && self.summary.is_none() && !self.finished
}
}
impl DesignSession {
/// Spawn a worker that runs `Orchestrator::run` against a
/// `RemoteDocSink`. Returns immediately; the LLM turn streams off
/// the UI thread.
pub fn start(
provider: Arc<dyn Provider>,
default_model: String,
request: DesignRequest,
initial_state: EditorState,
) -> Self {
let (delta_tx, delta_rx) = mpsc::channel::<DesignDelta>();
let (cmd_tx, cmd_rx) = mpsc::channel::<DesignCmdReq>();
thread::Builder::new()
.name("op-design-turn".into())
.spawn(move || {
let llm = DesktopLlmClient::new(provider, default_model);
let mut sink = RemoteDocSink::new(cmd_tx, initial_state);
let abort = AbortFlag::new();
let pre_validator = LintPreValidator;
let screenshot = SkippedScreenshotProvider;
let vision = SkippedVisionLlmClient;
let providers = ValidationProviders {
pre_validator: &pre_validator,
screenshot: &screenshot,
vision: &vision,
system_prompt: String::new(),
};
let delta_tx_for_progress = delta_tx.clone();
let mut on_progress = move |p: Progress| {
let _ = delta_tx_for_progress.send(DesignDelta::Progress(p));
};
let summary = shared_runtime().block_on(Orchestrator::new().run(
request,
&mut sink,
&llm,
&mut on_progress,
&abort,
&providers,
));
let _ = delta_tx.send(DesignDelta::Done(summary));
})
.expect("spawn op-design-turn thread");
Self {
delta_rx,
cmd_rx,
finished: false,
}
}
/// Drain every progress delta ready right now. Non-blocking.
pub fn poll_progress(&mut self) -> DesignPoll {
let mut progress = Vec::new();
let mut summary = None;
loop {
match self.delta_rx.try_recv() {
Ok(DesignDelta::Progress(p)) => progress.push(p),
Ok(DesignDelta::Done(r)) => {
self.finished = true;
summary = Some(r);
break;
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
self.finished = true;
break;
}
}
}
DesignPoll {
progress,
summary,
finished: self.finished,
}
}
/// Drain every pending apply request. Returns the requests; the
/// caller must ack each one or the worker will hang on `recv`.
pub fn drain_cmd_requests(&mut self) -> Vec<DesignCmdReq> {
let mut out = Vec::new();
loop {
match self.cmd_rx.try_recv() {
Ok(req) => out.push(req),
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break,
}
}
out
}
/// Test-only accessor.
#[cfg(test)]
pub fn finished(&self) -> bool {
self.finished
}
}
/// Worker-side `DocSink` impl — forwards every mutation to the UI
/// thread over an mpsc channel and blocks on the ack. State reads
/// come from a locally cached mirror updated by each ack.
pub struct RemoteDocSink {
cmd_tx: Sender<DesignCmdReq>,
mirror: EditorState,
}
impl RemoteDocSink {
pub fn new(cmd_tx: Sender<DesignCmdReq>, initial_state: EditorState) -> Self {
Self {
cmd_tx,
mirror: initial_state,
}
}
fn send_and_wait(&mut self, op: DesignCmdOp) -> bool {
let (ack_tx, ack_rx) = mpsc::sync_channel::<DesignCmdAck>(1);
let req = DesignCmdReq { op, ack: ack_tx };
if self.cmd_tx.send(req).is_err() {
return false; // UI dropped the receiver — turn aborted
}
match ack_rx.recv() {
Ok(ack) => {
self.mirror = ack.new_state;
ack.applied
}
Err(_) => false,
}
}
}
impl DocSink for RemoteDocSink {
fn state(&self) -> &EditorState {
&self.mirror
}
fn apply(&mut self, cmd: EditorCommand) -> bool {
self.send_and_wait(DesignCmdOp::Apply(cmd))
}
fn begin_undo_batch(&mut self) {
let _ = self.send_and_wait(DesignCmdOp::BeginUndoBatch);
}
fn end_undo_batch(&mut self) {
let _ = self.send_and_wait(DesignCmdOp::EndUndoBatch);
}
}
/// Drain every pending apply request from the in-flight design
/// session and execute it against the real `EditorState`. Each
/// request gets an ack containing a fresh state snapshot so the
/// worker's mirror reflects ID-remapping. Returns true when at least
/// one command applied (caller should mark redraw dirty).
pub fn pump_commands(host: &mut WidgetHostNative, current: &mut Option<DesignSession>) -> bool {
let Some(session) = current.as_mut() else {
return false;
};
let reqs = session.drain_cmd_requests();
if reqs.is_empty() {
return false;
}
let state = host.editor_state_mut();
let mut any_applied = false;
for req in reqs {
let applied = match req.op {
DesignCmdOp::Apply(cmd) => state.apply(cmd),
// TODO(host): wire into op-editor-core history batch mode once
// available. Today undo-batch boundaries are no-ops, matching
// `DesktopDocSink`.
DesignCmdOp::BeginUndoBatch | DesignCmdOp::EndUndoBatch => true,
};
let snapshot = state.clone();
let ack = DesignCmdAck {
applied,
new_state: snapshot,
};
// If the ack fails to send, the worker already dropped its
// receiver (e.g. turn aborted) — nothing to do here.
let _ = req.ack.send(ack);
if applied {
any_applied = true;
}
}
if any_applied {
host.mark_editor_state_dirty();
}
any_applied
}
/// Drain every pending progress delta and fold it into the trailing
/// assistant message. Clears `current` once the terminal `Done`
/// arrives. Returns true when the transcript changed.
pub fn pump_progress(host: &mut WidgetHostNative, current: &mut Option<DesignSession>) -> bool {
let Some(session) = current.as_mut() else {
return false;
};
let poll = session.poll_progress();
let mut changed = false;
if !poll.progress.is_empty() {
let appended = render_progress(&poll.progress);
let chat = &mut host.editor_state_mut().chat;
if let Some(msg) = chat.messages.last_mut() {
msg.content.push_str(&appended);
changed = true;
}
}
if let Some(summary) = &poll.summary {
let chat = &mut host.editor_state_mut().chat;
if let Some(msg) = chat.messages.last_mut() {
match summary {
Ok(s) => {
let ok = s.subtasks.iter().filter(|o| o.error.is_none()).count();
let failed = s.subtasks.len() - ok;
msg.content.push_str(&format!(
"\n\nDone — {} subtask(s) succeeded, {} failed, {} node(s) total.",
ok, failed, s.total_nodes,
));
}
Err(e) => {
msg.content = format!("error: {e}");
}
}
msg.streaming = false;
changed = true;
}
}
if changed {
host.mark_editor_state_dirty();
}
if poll.finished {
*current = None;
}
changed
}
/// Render a list of `Progress` events into a human-readable line block
/// the chat transcript can append. Matches the spirit of TS
/// `apps/web/src/services/ai/visual-ref-orchestrator.ts` step labels.
fn render_progress(progress: &[Progress]) -> String {
let mut out = String::new();
for p in progress {
out.push('\n');
out.push_str(&progress_label(p));
}
out
}
fn progress_label(p: &Progress) -> String {
match p {
Progress::Planning => "• Planning…".into(),
Progress::ScaffoldDone => "• Scaffold ready".into(),
Progress::SubtaskStarted { id, label } => format!("• Subtask `{id}` — {label}"),
Progress::SubtaskDone { id, node_count } => {
format!("• Subtask `{id}` done ({node_count} nodes)")
}
Progress::SubtaskFailed { id, error } => format!("• Subtask `{id}` failed: {error}"),
Progress::CleanupDone => "• Cleanup done".into(),
Progress::ValidationStarted => "• Validation started".into(),
Progress::ValidationPreCheckDone { applied, .. } => {
format!("• Pre-validation applied {applied} fix(es)")
}
Progress::ValidationRoundStarted { round } => {
format!("• Vision round {round} started")
}
Progress::ValidationRoundDone {
round,
applied,
quality_score,
} => {
format!("• Vision round {round} done — {applied} fix(es), quality {quality_score}/100")
}
Progress::ValidationDone { total_applied } => {
format!("• Validation done — {total_applied} fix(es) total")
}
Progress::VisualRefStarted => "• Visual-ref pipeline started".into(),
Progress::VisualRefDesignSystem { var_count } => {
format!("• Design system ready — {var_count} variable(s) seeded")
}
Progress::VisualRefHtmlGenerated { byte_len } => {
format!("• Visual-ref HTML generated ({byte_len} bytes)")
}
Progress::VisualRefScreenshotReady { skipped } => {
if *skipped {
"• Visual-ref screenshot skipped".into()
} else {
"• Visual-ref screenshot captured".into()
}
}
Progress::VisualRefFallback { reason } => format!("• Visual-ref fallback: {reason}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use op_editor_core::EditorCommand;
/// `RemoteDocSink::apply` blocks until UI acks. When the UI side
/// drops the receiver, `apply` returns false instead of hanging.
#[test]
fn remote_doc_sink_returns_false_when_ui_channel_closed() {
let (tx, rx) = mpsc::channel::<DesignCmdReq>();
let mut sink = RemoteDocSink::new(tx, EditorState::new());
drop(rx); // simulate UI session dropped before the worker called apply
let applied = sink.apply(EditorCommand::ClearSelection);
assert!(!applied, "apply on closed channel must return false");
}
/// Happy-path round-trip: worker sends an apply request; UI thread
/// acks with an updated state snapshot; worker's mirror reflects it.
#[test]
fn remote_doc_sink_updates_mirror_on_ack() {
let (tx, rx) = mpsc::channel::<DesignCmdReq>();
let initial = EditorState::new();
let mut sink = RemoteDocSink::new(tx, initial.clone());
// Spawn UI-side faker that acks one request with a modified state.
let ui_thread = thread::spawn(move || {
let req = rx.recv().expect("worker should send one request");
let mut new_state = initial.clone();
// Mutate something the test can observe — viewport zoom.
new_state.viewport.zoom = 2.0;
let ack = DesignCmdAck {
applied: true,
new_state,
};
req.ack.send(ack).expect("ack must reach worker");
});
let applied = sink.apply(EditorCommand::ClearSelection);
ui_thread.join().expect("ui thread must finish");
assert!(applied, "ack reported applied=true");
assert_eq!(
sink.state().viewport.zoom,
2.0,
"mirror should reflect ack snapshot"
);
}
/// `BeginUndoBatch` and `EndUndoBatch` are forwarded as their own
/// `DesignCmdOp` variants so the UI can route them through the
/// real `History::begin_batch` / `end_batch` once wired.
#[test]
fn undo_batch_signals_are_distinguishable_on_the_wire() {
let (tx, rx) = mpsc::channel::<DesignCmdReq>();
let mut sink = RemoteDocSink::new(tx, EditorState::new());
let ui = thread::spawn(move || {
let mut kinds = Vec::new();
while let Ok(req) = rx.recv() {
let label = match req.op {
DesignCmdOp::Apply(_) => "apply",
DesignCmdOp::BeginUndoBatch => "begin",
DesignCmdOp::EndUndoBatch => "end",
};
kinds.push(label.to_string());
let _ = req.ack.send(DesignCmdAck {
applied: true,
new_state: EditorState::new(),
});
}
kinds
});
sink.begin_undo_batch();
sink.apply(EditorCommand::ClearSelection);
sink.end_undo_batch();
drop(sink); // close the channel so the ui-side recv loop exits
let kinds = ui.join().expect("ui thread finishes");
assert_eq!(kinds, vec!["begin", "apply", "end"]);
}
}

View file

@ -33,7 +33,11 @@ impl DesktopApp {
Key::Named(NamedKey::Enter) if !self.zoom_modifier => {
consumed = self.host.apply_send();
// apply_send may raise pending_send (chat send).
if chat_session::launch_if_pending(&mut self.host, &mut self.current_chat) {
if chat_session::launch_if_pending(
&mut self.host,
&mut self.current_chat,
&mut self.current_design,
) {
self.request_redraw(true);
}
}

View file

@ -16,6 +16,7 @@ mod chat_subprocess;
mod clipboard;
mod cursor_icon;
mod design_md_host;
mod design_session;
mod export;
mod export_pdf;
mod frame;
@ -84,6 +85,12 @@ struct DesktopApp {
/// `chat.pending_send`; the event loop drains that into a
/// `ChatSession` here and pumps deltas into the transcript.
current_chat: Option<chat_session::ChatSession>,
/// In-flight design-orchestrator turn, if any. Mutually exclusive
/// with `current_chat`: `chat_session::launch_if_pending` classifies
/// the user's message via `op_orchestrator::classify_intent` and
/// routes `Intent::Design` here (when an `agent::Provider` is
/// available), `Intent::Chat` to `current_chat`.
current_design: Option<design_session::DesignSession>,
/// Background AI-model discovery — probes the installed CLIs
/// on a worker thread; its result is drained into
/// `chat.available_models` on a later frame.
@ -169,6 +176,7 @@ impl DesktopApp {
current_path: None,
error: None,
current_chat: None,
current_design: None,
model_probe: model_discovery::ModelProbe::spawn(),
initial_file,
app_menu: None,