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
@@ -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"@@ -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" }@@ -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, gamutnew file mode 100644@@ -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 = truenew file mode 100644@@ -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.new file mode 100644@@ -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"));+ }+}new file mode 100644@@ -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};new file mode 100644@@ -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+}