collective

Browse / github.com/storytold/craft-libs / patch

craft-ask-agent: shared Ask Agent queue, edit rule and bubble for every Craft app

craft-ask-agent: shared Ask Agent queue, edit rule and bubble for every Craft app Adds **`craft-ask-agent`**: the shared part of Ask Agent, so any Craft app can let the person hand a selection and an instruction to **the agent they already run** (Claude Code, Codex, any MCP client) and have it work in the document. The app calls no model and holds no key. It is already used, as a copy, by four app PRs: storytold/wordcraft#1413, storytold/gridcraft#1121, storytold/deckcraft#774 and storytold/pdfcraft#2340. Once this lands, each of them swaps its copy for this crate. ## What it looks like in the apps The four integrations below use the same shared UI and queue. Each record

Status
open
Score
0.00
Adopters
0 other operators
Author
CouchRiv (imported from pull request #142, open)
Base
82a24b006eefa036b43c09a67fb9a3fbd17ffe63
Size
+717 −0 in 8 files
Review
pending
Shared
2026-10-28
Id
ce216c6d236b2dfa751dc58a

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock Cargo.toml README.md crates/ask-agent/Cargo.toml crates/ask-agent/README.md crates/ask-agent/src/hub.rs crates/ask-agent/src/lib.rs crates/ask-agent/src/ui.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -590,6 +590,15 @@ dependencies = [  "serde_json", ] +[[package]]+name = "craft-ask-agent"+version = "0.1.0"+dependencies = [+ "egui",+ "serde",+ "serde_json",+]+ [[package]] name = "craft-atomic" version = "0.1.0"diff --git a/Cargo.toml b/Cargo.toml--- a/Cargo.toml+++ b/Cargo.toml@@ -20,6 +20,7 @@ hayro-interpret = { version = "=0.7.0", default-features = false, features = ["e hayro-syntax = { version = "=0.7.2", default-features = false, features = ["std"] } bytemuck = "1" thiserror = "2"+craft-ask-agent = { path = "crates/ask-agent" } craft-atomic = { path = "crates/atomic" } craft-cms = { path = "crates/cms" } craft-docpath = { path = "crates/docpath" }diff --git a/README.md b/README.md--- a/README.md+++ b/README.md@@ -26,6 +26,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit - [`craft-ai-core`](crates/ai-core/README.md): the data model and contract for AI in the Craft Apps (connections, models, tools, requests, jobs, consent, history, route spec). Pure, no I/O, wasm-safe. Contract in [docs/ai-shared-layer.md](docs/ai-shared-layer.md). - [`craft-pdf`](crates/pdf/README.md): independently optional PDF parsing, interpretation, rasterization and guarded authoring. Apps retain layout, fonts, editing and export policy; writer-only consumers do not enable the shared reader. +- [`craft-ask-agent`](crates/ask-agent/README.md): Ask Agent, the person's own agent working in the document from a selection and an instruction: the shared request queue, edit rule and control methods, and the egui chip and bubble. Selection, undo, authorship and holds stay in each app. - [`craft-atomic`](crates/atomic/README.md): Crash-safe atomic file writing and replacement with same-directory staging, directory fsync, Apple `F_FULLFSYNC` fallback, transient lock backoff, and fault injection for the Craft suite. - [`craft-cms`](crates/cms/README.md): pure-Rust ICC v2/v4 colour management: profile parsing   and writing, transforms, rendering intents, black point compensation, soft proofing, gamutdiff --git a/crates/ask-agent/Cargo.toml b/crates/ask-agent/Cargo.tomlnew file mode 100644--- /dev/null+++ b/crates/ask-agent/Cargo.toml@@ -0,0 +1,20 @@+[package]+name = "craft-ask-agent"+description = "Ask Agent: hand a selection and an instruction to the person's own agent (MCP / control channel), with the shared request queue and bubble."+version.workspace = true+edition.workspace = true+license.workspace = true+rust-version.workspace = true+repository.workspace = true++[features]+# `ui`: the chip and the bubble for egui apps.+egui = ["dep:egui"]++[dependencies]+egui = { workspace = true, optional = true }+serde = { version = "1", features = ["derive"] }+serde_json = "1"++[lints]+workspace = truediff --git a/crates/ask-agent/README.md b/crates/ask-agent/README.mdnew file mode 100644--- /dev/null+++ b/crates/ask-agent/README.md@@ -0,0 +1,44 @@+# craft-ask-agent++Ask Agent for the Craft apps: the person selects something, types an instruction, and **the agent they+already run** (Claude Code, Codex, any MCP client) does it in the document. The app calls no model and+holds no key.++## What is shared++- `hub` (always): the request queue (`ask`, `listen`, `reply`, `close`), the agents listening (`agents`,+  `colour`), the one edit rule (`may_edit`: while a request is open only the connection working on it+  edits, and only for it), what is held (`held`), and the control methods `agent.listen` /+  `agent.reply` (`AgentHub::handle`, called from the control channel's connection threads). Refusals+  carry stable codes: `HELD`, `NOT_OWNER`, `NO_REQUEST`, `INVALID_PARAMS`, `READ_ONLY_REQUEST`.+- `ui` (feature `egui`): `chip` beside the selection and `bubble` (instruction, agent picker, progress+  notes, answer, Keep / Undo / follow-up, and a compact Actions menu). egui's visuals only; the app passes its translation function.++## What stays in each app++The app adapter supplies the selection and document as JSON, grouping the agent's edits into one undo step,+authoring them "Agent (name)", refusing the person's edits that touch a held target, and a closing+comment with the agent's answer.++## App-specific action shortcuts++`ui::View::actions` accepts a slice of `ui::QuickAction { label, group, instruction }`.+An empty `group` places an item at the menu root; other values form native submenus.+Choosing an action only fills the editable instruction. The person still presses Send.+The app owns its presets and their wording; the shared UI adds no document operation or+permission. Prompts are suggestions, not enforcement of read-only or signing policy.++## Visible conversation++`ui::View::history` contains the prior instruction/answer pairs and `instruction` is the current request.+The bounded, collapsible Conversation panel exposes these exchanges in the bubble without changing+the wire protocol. Each app records the instruction with the agent's answer in its native comments+or notes so that the context remains after Keep. This is conversation context, not document version history.++## Wire protocol++- `agent.listen {timeoutMs?, agent?}` → `{id, instruction, history, selection, document, agent}` or `{none: true}`+- `agent.reply {id, text, done?, error?}` → `{id, done}`+- MCP tools in each app: `wait_for_request`, `reply_to_request`.++Used by WordCraft, GridCraft, DeckCraft and PdfCraft.diff --git a/crates/ask-agent/src/hub.rs b/crates/ask-agent/src/hub.rsnew file mode 100644--- /dev/null+++ b/crates/ask-agent/src/hub.rs@@ -0,0 +1,338 @@+//! The request queue: requests from the window, the agents that listen for them, and who may edit.+//! No egui and no app types, so the control channel threads can share it.++use std::collections::HashMap;+use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};+use std::time::Duration;++use serde::{Deserialize, Serialize};+use serde_json::{Value, json};++pub const NOT_OWNER: &str = "NOT_OWNER";+pub const NO_REQUEST: &str = "NO_REQUEST";+pub const INVALID_PARAMS: &str = "INVALID_PARAMS";+pub const READ_ONLY_REQUEST: &str = "READ_ONLY_REQUEST";+pub const HELD: &str = "HELD";++/// Longest `agent.listen` wait, and the wait when none is given.+pub const MAX_LISTEN: Duration = Duration::from_secs(60);+pub const DEFAULT_LISTEN: Duration = Duration::from_secs(30);++/// A refusal with a stable code.+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct HubError {+    pub code: &'static str,+    pub message: String,+}++impl std::fmt::Display for HubError {+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {+        write!(f, "{}: {}", self.code, self.message)+    }+}++impl std::error::Error for HubError {}++pub fn err(code: &'static str, message: impl Into<String>) -> HubError {+    HubError { code, message: message.into() }+}++/// One earlier exchange in a thread of follow-ups.+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]+pub struct Turn {+    pub instruction: String,+    pub answer: String,+}++/// What the agent receives: the instruction, the earlier turns, the selection and the document.+#[derive(Clone, Debug, Serialize, Deserialize)]+pub struct Request {+    pub id: u64,+    pub instruction: String,+    pub history: Vec<Turn>,+    pub selection: Value,+    pub document: Value,+    /// The agent asked for by name; `None` for any.+    pub agent: Option<String>,+}++#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Status {+    Queued,+    Working { conn: u64 },+    Answered,+    Failed,+}++/// What the window shows for a request.+#[derive(Clone, Debug)]+pub struct Snapshot {+    pub id: u64,+    pub status: Status,+    pub agent: Option<String>,+    pub notes: Vec<String>,+    pub answer: Option<String>,+    pub error: Option<String>,+}++struct Entry {+    request: Request,+    shown: Snapshot,+}++#[derive(Default)]+struct Inner {+    last_id: u64,+    entries: Vec<Entry>,+    /// Connections listening, with the name each gave.+    listeners: HashMap<u64, String>,+}++impl Inner {+    fn entry(&mut self, id: u64) -> Result<&mut Entry, HubError> {+        self.entries.iter_mut().find(|e| e.request.id == id).ok_or_else(|| err(NO_REQUEST, format!("no Ask Agent request {id}")))+    }+}++/// The queue, shared by the window and the control channel's connection threads.+#[derive(Clone, Default)]+pub struct AgentHub(Arc<(Mutex<Inner>, Condvar)>);++impl AgentHub {+    fn lock(&self) -> MutexGuard<'_, Inner> {+        self.0.0.lock().unwrap_or_else(PoisonError::into_inner)+    }++    /// Queue a request from the window; returns its id.+    pub fn ask(&self, mut request: Request) -> u64 {+        let mut inner = self.lock();+        inner.last_id += 1;+        request.id = inner.last_id;+        let shown = Snapshot { id: request.id, status: Status::Queued, agent: None, notes: vec![], answer: None, error: None };+        inner.entries.push(Entry { request, shown });+        self.0.1.notify_all();+        inner.last_id+    }++    /// Wait up to `wait` for a request this agent may take, and take it.+    pub fn listen(&self, conn: u64, name: Option<&str>, wait: Duration, alive: impl Fn() -> bool) -> Option<Request> {+        let name = name.map(str::trim).filter(|n| !n.is_empty()).unwrap_or("Agent").to_string();+        // Counted down in short steps rather than read from a clock, which wasm builds lack.+        let mut left = wait;+        let mut inner = self.lock();+        inner.listeners.insert(conn, name.clone());+        loop {+            let free = |e: &&mut Entry| e.shown.status == Status::Queued && e.request.agent.as_ref().is_none_or(|a| *a == name);+            if let Some(e) = inner.entries.iter_mut().find(free) {+                e.shown.status = Status::Working { conn };+                e.shown.agent = Some(name);+                return Some(e.request.clone());+            }+            if left.is_zero() || !alive() {+                return None;+            }+            let step = left.min(Duration::from_millis(250));+            inner = self.0.1.wait_timeout(inner, step).unwrap_or_else(PoisonError::into_inner).0;+            left -= step;+        }+    }++    /// A progress note (`done` false), the answer, or a failure, from the agent working on `id`.+    pub fn reply(&self, conn: u64, id: u64, text: &str, done: bool, error: Option<&str>) -> Result<(), HubError> {+        let mut inner = self.lock();+        let shown = &mut inner.entry(id)?.shown;+        if shown.status != (Status::Working { conn }) {+            return Err(err(NOT_OWNER, format!("Ask Agent request {id} is not yours: listen for it first")));+        }+        match (error, done) {+            (Some(e), _) => (shown.status, shown.error) = (Status::Failed, Some(e.into())),+            (None, true) => (shown.status, shown.answer) = (Status::Answered, Some(text.into())),+            (None, false) => shown.notes.push(text.into()),+        }+        Ok(())+    }++    /// The one edit rule: while a request is open, only the connection working on it edits, for it.+    pub fn may_edit(&self, conn: u64, request: Option<u64>) -> Result<(), HubError> {+        let mut inner = self.lock();+        let open = inner.entries.iter().any(|e| matches!(e.shown.status, Status::Queued | Status::Working { .. }));+        match request {+            None if open => Err(err(HELD, "the document is held for an Ask Agent request: pass its `request` id")),+            None => Ok(()),+            Some(id) => match inner.entry(id)?.shown.status {+                Status::Working { conn: c } if c == conn => Ok(()),+                Status::Answered | Status::Failed => Err(err(READ_ONLY_REQUEST, format!("Ask Agent request {id} is finished"))),+                _ => Err(err(NOT_OWNER, format!("Ask Agent request {id} is not yours: listen for it first"))),+            },+        }+    }++    /// The selection of each request being worked on, with its agent's name.+    pub fn held(&self) -> Vec<(Value, String)> {+        let inner = self.lock();+        let working = inner.entries.iter().filter(|e| matches!(e.shown.status, Status::Working { .. }));+        working.map(|e| (e.request.selection.clone(), e.shown.agent.clone().unwrap_or_default())).collect()+    }++    /// The connection is gone: its unanswered request goes back in the queue.+    pub fn disconnect(&self, conn: u64) {+        let mut inner = self.lock();+        inner.listeners.remove(&conn);+        for e in inner.entries.iter_mut().filter(|e| e.shown.status == Status::Working { conn }) {+            (e.shown.status, e.shown.agent) = (Status::Queued, None);+        }+        self.0.1.notify_all();+    }++    /// Names of the agents listening, sorted, for the picker.+    pub fn agents(&self) -> Vec<String> {+        let mut names: Vec<String> = self.lock().listeners.values().cloned().collect();+        names.sort();+        names.dedup();+        names+    }++    pub fn snapshot(&self, id: u64) -> Option<Snapshot> {+        self.lock().entries.iter().find(|e| e.request.id == id).map(|e| e.shown.clone())+    }++    /// The person is done with the request: it leaves the queue.+    pub fn close(&self, id: u64) {+        self.lock().entries.retain(|e| e.request.id != id);+    }++    /// Answer `agent.listen` and `agent.reply` for connection `conn`; `None` for any other method.+    pub fn handle(&self, conn: u64, method: &str, p: &Value, alive: impl Fn() -> bool) -> Option<Value> {+        let reply = match method {+            "agent.listen" => self.handle_listen(conn, p, alive),+            "agent.reply" => self.handle_reply(conn, p),+            _ => return None,+        };+        Some(match reply {+            Ok(result) => json!({"ok": true, "result": result}),+            Err(e) => json!({"ok": false, "error": e.message, "errorCode": e.code}),+        })+    }++    fn handle_listen(&self, conn: u64, p: &Value, alive: impl Fn() -> bool) -> Result<Value, HubError> {+        let wait = match p.get("timeoutMs") {+            None | Some(Value::Null) => DEFAULT_LISTEN,+            Some(v) => v.as_u64().map(Duration::from_millis).ok_or_else(|| err(INVALID_PARAMS, "`timeoutMs` is a number of milliseconds"))?,+        };+        let request = self.listen(conn, str_param(p, "agent")?, wait.min(MAX_LISTEN), alive);+        Ok(request.map_or_else(|| json!({"none": true}), |r| json!(r)))+    }++    fn handle_reply(&self, conn: u64, p: &Value) -> Result<Value, HubError> {+        let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| err(INVALID_PARAMS, "missing `id`: the request's id"))?;+        let done = match p.get("done") {+            None | Some(Value::Null) => true,+            Some(v) => v.as_bool().ok_or_else(|| err(INVALID_PARAMS, "`done` is true or false"))?,+        };+        let (text, error) = (str_param(p, "text")?.unwrap_or(""), str_param(p, "error")?);+        if text.trim().is_empty() && error.is_none() {+            return Err(err(INVALID_PARAMS, "missing `text`: what to show in the window"));+        }+        self.reply(conn, id, text, done, error)?;+        Ok(json!({"id": id, "done": done}))+    }+}++/// An optional string parameter.+pub fn str_param<'a>(p: &'a Value, key: &str) -> Result<Option<&'a str>, HubError> {+    match p.get(key) {+        None | Some(Value::Null) => Ok(None),+        Some(Value::String(s)) => Ok(Some(s)),+        Some(_) => Err(err(INVALID_PARAMS, format!("`{key}` is a string"))),+    }+}++/// A stable colour for an agent's name, from a palette readable on light and dark themes.+pub fn colour(name: &str) -> [u8; 3] {+    const PALETTE: [[u8; 3]; 8] =+        [[37, 99, 235], [5, 150, 105], [217, 119, 6], [219, 39, 119], [124, 58, 237], [8, 145, 178], [220, 38, 38], [101, 163, 13]];+    let hash = name.bytes().fold(0x811c_9dc5_u32, |h, b| (h ^ u32::from(b)).wrapping_mul(0x0100_0193));+    PALETTE[hash as usize % PALETTE.len()]+}++#[cfg(test)]+#[allow(clippy::unwrap_used)]+mod tests {+    use super::*;++    const NOW: Duration = Duration::ZERO;++    fn req(text: &str, agent: Option<&str>) -> Request {+        Request {+            id: 0,+            instruction: text.into(),+            history: vec![],+            selection: json!({"text": text}),+            document: json!({}),+            agent: agent.map(Into::into),+        }+    }++    #[test]+    fn a_request_goes_to_one_agent_who_answers_it() {+        let hub = AgentHub::default();+        let id = hub.ask(req("Tighten", None));+        let taken = hub.listen(1, Some("Claude"), NOW, || true).unwrap();+        assert_eq!((taken.id, hub.snapshot(id).unwrap().status), (id, Status::Working { conn: 1 }));+        assert!(hub.listen(2, Some("Codex"), NOW, || true).is_none(), "taken once");+        hub.reply(1, id, "Halfway", false, None).unwrap();+        hub.reply(1, id, "Done", true, None).unwrap();+        let s = hub.snapshot(id).unwrap();+        assert_eq!(+            (s.status, s.notes, s.answer.as_deref(), s.agent.as_deref()),+            (Status::Answered, vec!["Halfway".into()], Some("Done"), Some("Claude"))+        );+        assert_eq!(hub.reply(2, id, "x", true, None).unwrap_err().code, NOT_OWNER);+    }++    #[test]+    fn only_the_working_connection_edits_while_a_request_is_open() {+        let hub = AgentHub::default();+        assert_eq!(hub.may_edit(9, None), Ok(()), "nothing open: anyone edits");+        let id = hub.ask(req("Fix", None));+        hub.listen(1, None, NOW, || true).unwrap();+        for (conn, request, code) in [(1, Some(id), None), (2, Some(id), Some(NOT_OWNER)), (1, None, Some(HELD)), (1, Some(99), Some(NO_REQUEST))] {+            assert_eq!(hub.may_edit(conn, request).err().map(|e| e.code), code, "{conn} {request:?}");+        }+        assert_eq!(hub.held(), vec![(json!({"text": "Fix"}), "Agent".to_string())]);+        hub.reply(1, id, "Done", true, None).unwrap();+        assert_eq!(hub.may_edit(1, Some(id)).unwrap_err().code, READ_ONLY_REQUEST);+        assert!(hub.held().is_empty());+    }++    #[test]+    fn a_dropped_agent_hands_its_request_back_and_routing_follows_names() {+        let hub = AgentHub::default();+        let id = hub.ask(req("Review", Some("Codex")));+        assert!(hub.listen(1, Some("Claude"), NOW, || true).is_none(), "asked for Codex");+        hub.listen(2, Some("Codex"), NOW, || true).unwrap();+        assert_eq!(hub.agents(), vec!["Claude".to_string(), "Codex".to_string()]);+        hub.disconnect(2);+        assert_eq!((hub.snapshot(id).unwrap().status, hub.agents()), (Status::Queued, vec!["Claude".to_string()]));+        assert_eq!(hub.listen(3, Some("Codex"), NOW, || true).unwrap().id, id);+    }++    #[test]+    fn the_wire_methods_validate_their_parameters() {+        let hub = AgentHub::default();+        assert_eq!(hub.handle(1, "agent.listen", &json!({"timeoutMs": 0}), || true).unwrap()["result"], json!({"none": true}));+        let id = hub.ask(req("Go", None));+        assert_eq!(hub.handle(1, "agent.listen", &json!({"agent": "Claude", "timeoutMs": 0}), || true).unwrap()["result"]["id"], id);+        for bad in [json!({"text": "x"}), json!({"id": id}), json!({"id": id, "text": 3}), json!({"id": id, "text": "x", "done": "yes"})] {+            assert_eq!(hub.handle(1, "agent.reply", &bad, || true).unwrap()["errorCode"], INVALID_PARAMS, "{bad}");+        }+        assert_eq!(hub.handle(1, "agent.reply", &json!({"id": id, "text": "Done"}), || true).unwrap()["ok"], true);+        assert!(hub.handle(1, "app.save", &json!({}), || true).is_none());+    }++    #[test]+    fn colours_are_stable_and_spread() {+        assert_eq!(colour("Claude"), colour("Claude"));+        assert_ne!(colour("Claude"), colour("Codex"));+    }+}diff --git a/crates/ask-agent/src/lib.rs b/crates/ask-agent/src/lib.rsnew file mode 100644--- /dev/null+++ b/crates/ask-agent/src/lib.rs@@ -0,0 +1,19 @@+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]++//! Ask Agent for the Craft apps: the person selects something, types an instruction, and the agent+//! they already run (over MCP or the app's control channel) does it in the document.+//!+//! This crate holds what every app shares:+//! - [`hub`]: the request queue, the agents listening, the one rule for who may edit, what is held,+//!   and the two control methods `agent.listen` / `agent.reply` ([`AgentHub::handle`]);+//! - [`ui`] (feature `egui`): the chip beside the selection and the bubble with Keep, Undo and a+//!   follow-up field.+//!+//! Each app keeps its own part: the selection as JSON, its undo grouping, authorship ("Agent (name)")+//! and what "held" means for its documents. See the README.++pub mod hub;+#[cfg(feature = "egui")]+pub mod ui;++pub use hub::{AgentHub, HubError, Request, Snapshot, Status, Turn, colour};diff --git a/crates/ask-agent/src/ui.rs b/crates/ask-agent/src/ui.rsnew file mode 100644--- /dev/null+++ b/crates/ask-agent/src/ui.rs@@ -0,0 +1,285 @@+//! The chip and the bubble, in egui's own visuals (each app sets them from its theme). The app passes+//! its translation function.+//!+//! Layout: the chip sits just under the selection, where the bubble then opens. The bubble has three+//! bands on one left edge: a header (who gets the request, close), the content (conversation, status,+//! answer), and a footer row (Actions on the left, the primary button on the right). Every control in+//! the bubble has the same height.++use egui::{Align, Color32, Key, Layout, Rect, RichText, Stroke, pos2, vec2};++use crate::hub::{Snapshot, Status, Turn, colour};++/// The app's translation of an interface string.+pub type Tr<'a> = &'a dyn Fn(&'static str) -> String;++/// What the person did in the bubble.+#[derive(Clone, Debug, PartialEq, Eq)]+pub enum Action {+    /// The instruction, or a follow-up after an answer.+    Send(String),+    Keep,+    Undo,+    Close,+}++/// A shortcut that only prepares an editable instruction; Send remains explicit.+#[derive(Clone, Copy, Debug)]+pub struct QuickAction {+    pub label: &'static str,+    pub group: &'static str,+    pub instruction: &'static str,+}++/// What the bubble shows: the current request, if any, and the agents listening.+pub struct View<'a> {+    pub snapshot: Option<&'a Snapshot>,+    pub agents: &'a [String],+    pub actions: &'a [QuickAction],+    pub history: &'a [Turn],+    pub instruction: &'a str,+}++const WIDTH: f32 = 360.0;+/// One height for every control in the bubble, so the rows line up.+const ROW: f32 = 28.0;++/// The chip just under the selection, on its left edge, where the bubble opens; true when clicked.+pub fn chip(ctx: &egui::Context, anchor: Rect, tr: Tr) -> bool {+    egui::Area::new("ask_agent_chip".into())+        .order(egui::Order::Foreground)+        .fixed_pos(origin(ctx, anchor, 140.0))+        .constrain(true)+        .show(ctx, |ui| {+            let frame = egui::Frame::popup(ui.style()).corner_radius(14.0).inner_margin(egui::Margin::symmetric(10, 5));+            let inner = frame.show(ui, |ui| {+                ui.horizontal(|ui| {+                    ui.spacing_mut().item_spacing.x = 6.0;+                    sparkle(ui);+                    ui.label(RichText::new(tr("Ask Agent")).strong());+                })+            });+            let response = ui.interact(inner.response.rect, ui.id().with("chip"), egui::Sense::click());+            response.on_hover_cursor(egui::CursorIcon::PointingHand).clicked()+        })+        .inner+}++/// The bubble under `anchor`: instruction, the agent at work, its answer with Keep, Undo and a+/// follow-up field.+pub fn bubble(ctx: &egui::Context, anchor: Rect, view: &View, draft: &mut String, picked: &mut Option<String>, tr: Tr) -> Option<Action> {+    let mut action = None;+    // True on the first pass the bubble shows after at least one pass without it.+    let (id, pass) = (egui::Id::new("ask_agent_bubble_pass"), ctx.cumulative_pass_nr());+    let last = ctx.data(|d| d.get_temp::<u64>(id));+    ctx.data_mut(|d| d.insert_temp(id, pass));+    let opened = last.is_none_or(|last| last + 1 < pass);+    egui::Area::new("ask_agent_bubble".into()).order(egui::Order::Foreground).fixed_pos(origin(ctx, anchor, WIDTH + 40.0)).constrain(true).show(+        ctx,+        |ui| {+            egui::Frame::popup(ui.style()).corner_radius(12.0).inner_margin(egui::Margin::symmetric(16, 14)).show(ui, |ui| {+                ui.set_width(WIDTH);+                let spacing = ui.spacing_mut();+                spacing.item_spacing = vec2(8.0, 10.0);+                spacing.interact_size.y = ROW;+                spacing.button_padding = vec2(12.0, 4.0);+                let status = view.snapshot.map(|s| s.status);++                // Header: who the request is for, then close; the agent choice sits on the right.+                row(ui, Layout::left_to_right(Align::Center), |ui| {+                    match view.snapshot.and_then(|s| s.agent.as_deref()) {+                        Some(name) => agent_label(ui, name),+                        None => {+                            sparkle(ui);+                            ui.label(RichText::new(tr("Ask Agent")).strong());+                        }+                    }+                    ui.with_layout(Layout::right_to_left(Align::Center), |ui| {+                        if close_button(ui).on_hover_text(tr("Close")).clicked() {+                            action = Some(Action::Close);+                        }+                        if status.is_none() {+                            picker(ui, view.agents, picked, tr);+                        }+                    });+                });++                if !view.instruction.is_empty() || !view.history.is_empty() {+                    egui::CollapsingHeader::new(tr("Conversation"))+                        .id_salt(("ask_conversation", view.history.len()))+                        .default_open(!view.history.is_empty())+                        .show(ui, |ui| {+                            egui::ScrollArea::vertical().id_salt("ask_conversation_scroll").max_height(160.0).show(ui, |ui| {+                                for turn in view.history {+                                    ui.label(RichText::new(tr("You")).strong());+                                    ui.label(&turn.instruction);+                                    ui.label(RichText::new(tr("Agent")).strong());+                                    ui.label(&turn.answer);+                                    ui.separator();+                                }+                                if !view.instruction.is_empty() {+                                    ui.label(RichText::new(tr("Current instruction")).strong());+                                    ui.label(view.instruction);+                                }+                            });+                        });+                }++                if let Some(s) = view.snapshot {+                    for note in &s.notes {+                        ui.label(RichText::new(note).weak());+                    }+                }+                match status {+                    Some(Status::Queued) => _ = ui.label(RichText::new(tr("Waiting for an agent…")).weak()),+                    Some(Status::Working { .. }) => {+                        row(ui, Layout::left_to_right(Align::Center), |ui| {+                            ui.spinner();+                            ui.label(RichText::new(tr("Working on it…")).weak());+                        });+                    }+                    Some(Status::Failed) => {+                        let error = view.snapshot.and_then(|s| s.error.as_deref()).unwrap_or_default();+                        ui.label(RichText::new(error).color(ui.visuals().error_fg_color));+                    }+                    Some(Status::Answered) => {+                        ui.label(view.snapshot.and_then(|s| s.answer.as_deref()).unwrap_or_default());+                        // The decision on the right, primary last, like Send below.+                        row(ui, Layout::right_to_left(Align::Center), |ui| {+                            if ui.add(primary(ui, tr("Keep"))).clicked() {+                                action = Some(Action::Keep);+                            }+                            if ui.button(tr("Undo")).on_hover_text(tr("Undo all of the agent's edits")).clicked() {+                                action = Some(Action::Undo);+                            }+                        });+                        ui.separator();+                    }+                    None => {}+                }+                if matches!(status, None | Some(Status::Answered | Status::Failed)) {+                    let hint = if status.is_none() { tr("What should the agent do?") } else { tr("Reply to refine…") };+                    let field = ui.add(+                        egui::TextEdit::multiline(draft)+                            .hint_text(hint)+                            .desired_rows(3)+                            .desired_width(f32::INFINITY)+                            .margin(egui::Margin::symmetric(8, 6))+                            // Shift+Enter makes a new line; Enter alone sends, below.+                            .return_key(egui::KeyboardShortcut::new(egui::Modifiers::SHIFT, Key::Enter)),+                    );+                    // Focus the field once, when the bubble opens, so Tab can then reach the other controls.+                    if status.is_none() && opened {+                        field.request_focus();+                    }+                    let enter = field.has_focus() && ui.input(|i| i.key_pressed(Key::Enter) && !i.modifiers.shift);+                    // Footer: starting points on the left, Send on the right.+                    row(ui, Layout::left_to_right(Align::Center), |ui| {+                        if matches!(status, None | Some(Status::Answered)) && !view.actions.is_empty() {+                            quick_actions(ui, view.actions, draft, tr);+                        }+                        ui.with_layout(Layout::right_to_left(Align::Center), |ui| {+                            let ready = !draft.trim().is_empty();+                            if (ui.add_enabled(ready, primary(ui, tr("Send"))).clicked() || enter) && ready {+                                action = Some(Action::Send(std::mem::take(draft).trim().to_string()));+                            }+                        });+                    });+                }+            });+        },+    );+    action+}++/// Where the chip and the bubble open: under the selection, on its left edge, kept on screen.+fn origin(ctx: &egui::Context, anchor: Rect, width: f32) -> egui::Pos2 {+    let screen = ctx.content_rect();+    pos2(anchor.left().clamp(8.0, (screen.right() - width).max(8.0)), anchor.bottom() + 8.0)+}++/// One row of controls, exactly `ROW` high, whatever the layout direction.+fn row(ui: &mut egui::Ui, layout: Layout, add: impl FnOnce(&mut egui::Ui)) {+    ui.allocate_ui_with_layout(vec2(ui.available_width(), ROW), layout, add);+}++/// The one filled button of a row.+fn primary(ui: &egui::Ui, text: String) -> egui::Button<'static> {+    egui::Button::new(RichText::new(text).strong()).fill(ui.visuals().selection.bg_fill).min_size(vec2(72.0, ROW))+}++/// Native menus keep less common actions out of the writing surface.+fn quick_actions(ui: &mut egui::Ui, actions: &[QuickAction], draft: &mut String, tr: Tr) {+    ui.menu_button(tr("Actions"), |ui| {+        for (index, action) in actions.iter().enumerate() {+            if action.group.is_empty() {+                if ui.button(tr(action.label)).clicked() {+                    *draft = action.instruction.into();+                    ui.close();+                }+            } else if !actions[..index].iter().any(|previous| previous.group == action.group) {+                ui.menu_button(tr(action.group), |ui| {+                    for action in actions.iter().filter(|item| item.group == action.group) {+                        if ui.button(tr(action.label)).clicked() {+                            *draft = action.instruction.into();+                            ui.close();+                        }+                    }+                });+            }+        }+    })+    .response+    .on_hover_text(tr("Choose a starting instruction, then edit and send"));+}++/// Who the request goes to, when more than one agent listens. Called in a right-to-left row, so+/// the box comes first and its label lands on its left.+fn picker(ui: &mut egui::Ui, agents: &[String], picked: &mut Option<String>, tr: Tr) {+    if agents.len() < 2 {+        return;+    }+    egui::ComboBox::from_id_salt("ask_agent_picker").selected_text(picked.clone().unwrap_or_else(|| tr("Any"))).show_ui(ui, |ui| {+        ui.selectable_value(picked, None, tr("Any"));+        for name in agents {+            ui.selectable_value(picked, Some(name.clone()), name);+        }+    });+    ui.label(RichText::new(tr("Agent")).weak());+}++/// The agent's colour dot and name.+pub fn agent_label(ui: &mut egui::Ui, name: &str) {+    let [r, g, b] = colour(name);+    let (dot, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover());+    ui.painter().circle(dot.center(), 5.0, Color32::from_rgb(r, g, b), Stroke::NONE);+    ui.label(RichText::new(name).strong());+}++/// A drawn four-point sparkle in the selection colour, so it shows whatever the interface font.+fn sparkle(ui: &mut egui::Ui) {+    let (rect, _) = ui.allocate_exact_size(vec2(12.0, 12.0), egui::Sense::hover());+    let (c, colour) = (rect.center(), accent(ui.visuals()));+    for (long, short) in [(vec2(0.0, 6.0), vec2(1.6, 0.0)), (vec2(6.0, 0.0), vec2(0.0, 1.6))] {+        let points = vec![c - long, c + short, c + long, c - short];+        ui.painter().add(egui::Shape::convex_polygon(points, colour, Stroke::NONE));+    }+}++/// Of the selection's fill and text colours, the one that stands out more on the bubble.+fn accent(v: &egui::Visuals) -> Color32 {+    let luma = |c: Color32| 0.299 * f32::from(c.r()) + 0.587 * f32::from(c.g()) + 0.114 * f32::from(c.b());+    let (fill, text, bg) = (v.selection.bg_fill, v.selection.stroke.color, luma(v.window_fill));+    if (luma(fill) - bg).abs() > (luma(text) - bg).abs() { fill } else { text }+}++/// A drawn ✕, so it shows whatever the interface font.+fn close_button(ui: &mut egui::Ui) -> egui::Response {+    let (rect, response) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::click());+    let colour = if response.hovered() { ui.visuals().strong_text_color() } else { ui.visuals().weak_text_color() };+    let r = rect.shrink(5.0);+    for (a, b) in [(r.left_top(), r.right_bottom()), (r.right_top(), r.left_bottom())] {+        ui.painter().line_segment([a, b], Stroke::new(1.5, colour));+    }+    response+}