Skip to main content

ui/
agent.rs

1// Universal application automation through the existing input, command and render paths.
2// Domain semantics remain in App::actions/action; no screen-coordinate inference is used.
3use alloc::{format, string::String, vec::Vec};
4use core::cell::Cell;
5use agent_core::{Access, ActionContext, ActionError, ActionResult, ActionSpec, Dispatcher, ErrorCode, Host, Resource, Value};
6use agent_core::schema::{Property, Schema, OBJECT, STRING, U64};
7use crate::{App, Ui, Runtime, Sem, consts::*};
8use crate::ctx::hit_find_list;
9use plat::policy::{AgentRule, Verdict};
10
11#[derive(Default)]
12pub(crate) struct State {
13    enabled: bool,
14    connected: bool,
15    render_id: u64,
16    images: Vec<(String, Vec<u8>)>,
17    controls: Revision, // App::action_revision's stand-in: what an action depends on
18    shown: Revision,    // uiRevision: everything ui.state reports
19    pub(crate) reveal: Option<(u8, u32)>, // (window, id): the scroll views bring this widget into view
20    #[cfg(not(any(target_arch = "wasm32", target_os = "android")))]
21    server: Option<agent_io::ipc::Server>,
22}
23
24// A number for a state known by its hash: moves when the hash read differs from the last one.  A
25// window draws for every request and for the user's pointer, so its frames say nothing about a
26// change.
27#[derive(Default)]
28struct Revision {
29    hash: Cell<u64>,
30    count: Cell<u64>,
31}
32impl Revision {
33    fn read(&self, hash: u64) -> u64 {
34        if hash != self.hash.get() {
35            self.hash.set(hash);
36            self.bump();
37        }
38        self.count.get()
39    }
40    fn bump(&self) { self.count.set(self.count.get() + 1); }
41}
42
43/// the title bar's button for external control: its label says whether a client is connected
44pub(crate) const ID_BADGE: u32 = 0xFFFF_7440;
45
46static NODE: Schema = Schema::Object { properties: &[
47    Property { name: "id", schema: &U64, required: true },
48    Property { name: "window", schema: &U64, required: false },
49], additional: false };
50static COMMAND: Schema = Schema::Object { properties: &[
51    Property { name: "id", schema: &U64, required: true },
52    Property { name: "arg", schema: &U64, required: false },
53], additional: false };
54static TEXT: Schema = Schema::Object { properties: &[
55    Property { name: "text", schema: &STRING, required: true },
56], additional: false };
57static KEY: Schema = Schema::Object { properties: &[
58    Property { name: "key", schema: &Schema::Unsigned { min: 1, max: 255 }, required: true },
59    Property { name: "modifiers", schema: &Schema::Unsigned { min: 0, max: 7 }, required: false },
60], additional: false };
61static ACTIONS: [ActionSpec; 7] = [
62    ActionSpec::read("ui.state", "Inspect window, focus, commands, capabilities and paginated widgets").schemas(&OBJECT, &OBJECT),
63    ActionSpec::write("ui.command", "Execute an existing enabled numeric command").schemas(&COMMAND, &OBJECT),
64    ActionSpec { revision_required: false, ..ActionSpec::write("ui.focus", "Focus an enabled widget by its semantic identifier").schemas(&NODE, &OBJECT) },
65    ActionSpec::write("ui.click", "Press an enabled widget through its real input handler, scrolled into view; refused when the press would not reach it").schemas(&NODE, &OBJECT),
66    ActionSpec::write("ui.type", "Type Unicode text into the currently focused control").schemas(&TEXT, &OBJECT),
67    ActionSpec::write("ui.key", "Deliver a virtual key and modifiers through the normal accelerator/input path").schemas(&KEY, &OBJECT),
68    ActionSpec::read("ui.render", "Render a PNG and return a separately readable resource"),
69];
70
71struct UiHost<'a> {
72    ui: &'a mut Ui,
73    app: &'a mut dyn App,
74    specs: Vec<ActionSpec>,
75}
76impl<'a> UiHost<'a> {
77    fn new(ui: &'a mut Ui, app: &'a mut dyn App) -> Self {
78        let mut specs = ACTIONS.to_vec();
79        specs.extend_from_slice(app.actions());
80        Self { ui, app, specs }
81    }
82    fn event(&mut self, event: plat::Event) {
83        plat::evq_push(event);
84        self.ui.process_events(self.app);
85    }
86    fn node(&self, input: &Value) -> Result<Sem, ActionError> {
87        let id = number(input, "id", 0)?;
88        let window = number(input, "window", 0)?;
89        let node = self.ui.sems.iter().rev().find(|s| s.id == id && s.win as u32 == window)
90            .ok_or_else(|| error(ErrorCode::NotFound, "Widget does not exist in the requested window"))?;
91        if node.state & SS_DISABLED != 0 { return Err(Miss::new("disabled", "Widget is disabled", node, None, 0).error(self.ui)); }
92        if (self.ui.m.win.inert >> (window & 31)) & 1 != 0 { return Err(Miss::new("modal", "A modal window blocks this window", node, None, 0).error(self.ui)); }
93        let modal = self.ui.uiw_parts(window).map_or(0, |p| p.ctx.modal_prev);
94        if (node.layer as u32) < modal { return Err(Miss::new("modal", "A modal dialog blocks this widget", node, None, 0).error(self.ui)); }
95        Ok(node.clone())
96    }
97    // The widget ui.click presses and the point.  A widget that does not show is brought into view
98    // by the scroll views around it, as for the keyboard focus: no action of this interface scrolls.
99    fn click_target(&mut self, input: &Value) -> Result<(Sem, (i32, i32)), ActionError> {
100        let node = self.node(input)?;
101        let window = node.win as u32;
102        if node.rect[2] <= 0 || node.rect[3] <= 0 { return Err(Miss::new("clipped", "Widget has no visible hit rectangle", &node, None, 0).error(self.ui)); }
103        if (window != 0 && !self.ui.uiw_drawable(window)) || (!self.ui.headless && self.ui.uiw_pw(window) < 0) {
104            return Err(Miss::new("window", "The widget's window is not shown", &node, None, 0).error(self.ui));
105        }
106        match self.ui.agent_aim(&node, false) {
107            Ok(point) => return Ok((node, point)),
108            Err(miss) if miss.reason == "popup" || node.id == 0 => return Err(miss.error(self.ui)),
109            Err(_) => {}
110        }
111        self.ui.agent.reveal = Some((node.win, node.id));
112        self.ui.run_passes(self.app);
113        self.ui.agent.reveal = None;
114        let node = self.node(input)?;
115        match self.ui.agent_aim(&node, true) {
116            Ok(point) => Ok((node, point)),
117            Err(miss) => Err(miss.error(self.ui)),
118        }
119    }
120    // after an action; a change it made need not show in the controls (the app's own state)
121    fn settle(&mut self, action: &ActionSpec) {
122        if action.mutation { self.ui.agent.controls.bump(); }
123        self.ui.run_passes(self.app);
124        self.ui.render();
125    }
126    // What ui.state reports, as a hash.  controls: only what an action depends on: not where a
127    // widget is (ui.click finds it anew), not the pointer over it, not what merely shows (texts,
128    // images, a running progress bar).  A client coming or going is no change of the app.
129    fn state_hash(&self, controls: bool) -> u64 {
130        let ui = &*self.ui;
131        let mut bytes = Vec::new();
132        bytes.extend_from_slice(ui.title().as_bytes());
133        bytes.extend_from_slice(&ui.focused().to_le_bytes());
134        bytes.extend_from_slice(&ui.modal_active().to_le_bytes());
135        bytes.extend(ui.desc.cmds.iter().map(|c| (ui.cmd_flags(c.id) & CF_DISABLED != 0) as u8));
136        for s in ui.sems.iter().filter(|s| s.id != ID_BADGE) {
137            if controls && matches!(s.kind, K_BOX | K_TEXT | K_PROGRESS | K_ICON | K_IMAGE | K_HEADING) { continue; }
138            let state = if controls { s.state & !(SS_HOVER | SS_PRESSED) } else { s.state };
139            bytes.extend_from_slice(&s.id.to_le_bytes());
140            bytes.extend_from_slice(&s.kind.to_le_bytes());
141            bytes.extend_from_slice(&state.to_le_bytes());
142            bytes.extend_from_slice(&s.value.to_le_bytes());
143            bytes.extend_from_slice(&[s.win, s.layer]);
144            bytes.extend_from_slice(s.label.as_deref().unwrap_or("").as_bytes());
145            bytes.push(0);
146            if !controls { s.rect.iter().for_each(|v| bytes.extend_from_slice(&v.to_le_bytes())); }
147        }
148        base::hashring::hash64(&bytes)
149    }
150    fn ui_revision(&self) -> u64 { self.ui.agent.shown.read(self.state_hash(false)) }
151}
152impl Host for UiHost<'_> {
153    fn actions(&self) -> &[ActionSpec] { &self.specs }
154    fn revision(&self) -> u64 { self.app.action_revision().unwrap_or_else(|| self.ui.agent.controls.read(self.state_hash(true))) }
155    fn capabilities(&self) -> u64 { plat::capabilities().available }
156    fn availability(&self, action: &ActionSpec) -> Result<(), ActionError> {
157        if let Some(reason) = plat::capabilities().unavailable_reason(action.capabilities) {
158            return Err(error(ErrorCode::CapabilityUnavailable, reason));
159        }
160        // The permission question is the user's: an agent cannot answer it through the GUI.
161        if action.mutation && action.name.starts_with("ui.") && super::perm::asking() && !plat::policy::questions().is_empty() {
162            return Err(error(ErrorCode::Unavailable, "A permission question waits for the user's answer"));
163        }
164        // Widget input can operate the active dialog; domain changes cannot bypass it.
165        if action.mutation && self.ui.modal_active() != 0 && !matches!(action.name, "ui.focus" | "ui.click" | "ui.type" | "ui.key") {
166            return Err(error(ErrorCode::Unavailable, "Close the active modal dialog before changing the application"));
167        }
168        if !action.name.starts_with("ui.") { self.app.action_available(action)?; }
169        Ok(())
170    }
171    // the action's own access, tightened by the manifest (agent.deny / agent.confirm)
172    fn access(&self, action: &ActionSpec) -> Access {
173        match (action.access, plat::policy::agent_rule(action.name)) {
174            (Access::Local, _) | (_, AgentRule::Deny) => Access::Local,
175            (Access::Confirm, _) | (_, AgentRule::Confirm) => Access::Confirm,
176            _ => Access::Open,
177        }
178    }
179    // the user's answer (perm.rs shows the question): this run, stored, or still open
180    fn confirm(&mut self, action: &ActionSpec, _input: &Value) -> Result<(), ActionError> {
181        let details = |reason: &str| Value::object([("action", Value::string(action.name)), ("reason", Value::string(reason))]);
182        match plat::policy::agent_ask(action.name, action.description) {
183            Verdict::Allow => Ok(()),
184            Verdict::Pending => {
185                self.ui.invalidate(); // the question shows in the next frame
186                Err(error(ErrorCode::ConfirmationRequired, "The user is asked to confirm this action; call it again after the answer").with_details(details("asked")))
187            }
188            Verdict::Deny => {
189                let rule = format!("agent {}", action.name);
190                let answered = plat::policy::decisions().iter().any(|d| d.rule == rule && d.answer.is_some());
191                let text = if answered { "The user did not allow this action" } else { "Nobody can confirm this action in this run" };
192                Err(error(ErrorCode::Forbidden, text).with_details(details(if answered { "user" } else { "unattended" })))
193            }
194        }
195    }
196    fn refused(&mut self, action: &ActionSpec) { plat::policy::agent_refused(action.name); }
197    fn execute(&mut self, action: &ActionSpec, input: &Value, context: &ActionContext) -> Result<ActionResult, ActionError> {
198        let value = match action.name {
199            "ui.state" => return Ok(ActionResult::completed(self.inspect(input)?, self.revision())),
200            "ui.command" => {
201                let id = number(input, "id", 0)?;
202                let arg = number(input, "arg", 0)?;
203                if self.ui.cmd_flags(id) & CF_DISABLED != 0 { return Err(error(ErrorCode::Unavailable, "Command is disabled in the current state")); }
204                let domain = self.app.actions().iter().find(|s| s.command_id == Some(id)).copied();
205                if let Some(spec) = domain {
206                    self.availability(&spec)?;
207                    // a command of a domain action has that action's access
208                    match self.access(&spec) {
209                        Access::Open => {}
210                        Access::Local => {
211                            self.refused(&spec);
212                            return Err(error(ErrorCode::Forbidden, "This action is not available to external control").with_details(Value::object([("action", Value::string(spec.name)), ("reason", Value::string("app"))])));
213                        }
214                        Access::Confirm => self.confirm(&spec, &Value::object([]))?,
215                    }
216                    let result = self.app.action(self.ui, &spec, &Value::object([]), context)?;
217                    self.settle(action);
218                    return Ok(ActionResult { revision: self.revision(), ..result });
219                }
220                self.ui.desc.cmds.iter().find(|c| c.id == id)
221                    .ok_or_else(|| error(ErrorCode::NotFound, "Numeric command is not registered"))?;
222                if !self.app.command(self.ui, id, arg) { return Err(error(ErrorCode::Unavailable, "Command is not available in the current state")); }
223                Value::object([("handled", Value::Bool(true))])
224            }
225            "ui.focus" => {
226                let node = self.node(input)?;
227                if node.win != 0 { return Err(error(ErrorCode::Unsupported, "Use ui.click to focus a control in a secondary window")); }
228                self.ui.focus(node.id);
229                Value::object([("focused", Value::u64(node.id as u64))])
230            }
231            "ui.click" => {
232                let (node, (x, y)) = self.click_target(input)?;
233                // the event queue speaks of platform windows, a headless run has none
234                let win = if self.ui.headless { node.win } else { self.ui.uiw_pw(node.win as u32) as u8 };
235                let event = plat::Event { win, x, y, btn: plat::BTN_LEFT, ..Default::default() };
236                for typ in [plat::E_MOVE, plat::E_DOWN, plat::E_UP] { self.event(plat::Event { typ, ..event }); }
237                Value::object([("activated", Value::u64(node.id as u64))])
238            }
239            "ui.type" => {
240                if self.ui.focused() == 0 { return Err(error(ErrorCode::Unavailable, "No text control has keyboard focus")); }
241                for ch in input.get("text").and_then(Value::as_str).unwrap_or("").chars() {
242                    self.event(plat::Event { typ: plat::E_TEXT, data: ch as u32, ..Default::default() });
243                }
244                Value::object([("accepted", Value::Bool(true))])
245            }
246            "ui.key" => {
247                let data = number(input, "key", 0)?;
248                let mods = number(input, "modifiers", 0)? as u8;
249                for typ in [plat::E_KEY_DOWN, plat::E_KEY_UP] { self.event(plat::Event { typ, data, mods, ..Default::default() }); }
250                Value::object([("accepted", Value::Bool(true))])
251            }
252            "ui.render" => {
253                self.settle(action);
254                self.ui.gpu_settle();
255                let image = self.ui.fb();
256                let bytes = img_png::encode(&image.px, image.w as usize, image.h as usize, image.stride as usize, false);
257                if bytes.len() > 16 * 1024 * 1024 { return Err(error(ErrorCode::LimitExceeded, "Rendered PNG exceeds 16 MiB")); }
258                self.ui.agent.render_id += 1;
259                let uri = format!("jfgui://render/{}.png", self.ui.agent.render_id);
260                let resource = Resource { uri: uri.clone(), name: String::from("Application screenshot"), mime_type: String::from("image/png"), size: Some(bytes.len() as u64) };
261                if self.ui.agent.images.len() == 4 { self.ui.agent.images.remove(0); }
262                self.ui.agent.images.push((uri, bytes));
263                let mut result = ActionResult::completed(Value::object([("image", resource.to_json())]), self.revision());
264                result.resources.push(resource);
265                return Ok(result);
266            }
267            _ => {
268                let result = self.app.action(self.ui, action, input, context)?;
269                self.settle(action);
270                return Ok(ActionResult { revision: self.revision(), ..result });
271            }
272        };
273        self.settle(action);
274        Ok(ActionResult::completed(value, self.revision()))
275    }
276    fn inspect(&self, query: &Value) -> Result<Value, ActionError> {
277        if query.get("domain").and_then(Value::as_str).is_some_and(|s| s != "ui") {
278            return self.app.action_inspect(query);
279        }
280        let revision = self.revision();
281        let ui_revision = self.ui_revision();
282        if query.get("sinceUiRevision").and_then(Value::as_u64) == Some(ui_revision) {
283            return Ok(Value::object([("revision", Value::u64(revision)), ("unchanged", Value::Bool(true))]));
284        }
285        let offset = query.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize;
286        let limit = query.get("limit").and_then(Value::as_u64).unwrap_or(100).clamp(1, 500) as usize;
287        let filter = query.get("filter").and_then(Value::as_str).unwrap_or("").to_lowercase();
288        let matches: Vec<_> = self.ui.sems.iter().filter(|s| filter.is_empty() || s.label.as_deref().unwrap_or("").to_lowercase().contains(&filter)).collect();
289        let widgets = matches.iter().skip(offset).take(limit).map(|s| Value::object([
290            ("id", Value::u64(s.id as u64)), ("window", Value::u64(s.win as u64)),
291            ("kind", Value::u64(s.kind as u64)), ("label", s.label.as_ref().map(|s| Value::string(s.clone())).unwrap_or(Value::Null)),
292            ("value", Value::i64(s.value as i64)), ("state", Value::u64(s.state as u64)), ("layer", Value::u64(s.layer as u64)),
293            ("rect", Value::Array(s.rect.iter().map(|v| Value::i64(*v as i64)).collect())),
294        ])).collect();
295        let commands = self.ui.desc.cmds.iter().map(|c| Value::object([("id", Value::u64(c.id as u64)), ("label", Value::string(c.label)), ("enabled", Value::Bool(self.ui.cmd_flags(c.id) & CF_DISABLED == 0))])).collect();
296        Ok(Value::object([
297            ("revision", Value::u64(revision)), ("uiRevision", Value::u64(ui_revision)),
298            ("title", Value::string(self.ui.title())), ("focus", Value::u64(self.ui.focused() as u64)),
299            ("modal", Value::u64(self.ui.modal_active() as u64)), ("capabilities", Value::u64(self.capabilities())),
300            ("control", Value::object([("enabled", Value::Bool(self.ui.agent.enabled)), ("connected", Value::Bool(self.ui.agent.connected))])),
301            ("widgets", Value::Array(widgets)), ("total", Value::u64(matches.len() as u64)),
302            ("nextOffset", if offset.saturating_add(limit) < matches.len() { Value::u64((offset + limit) as u64) } else { Value::Null }),
303            ("commands", Value::Array(commands)),
304        ]))
305    }
306    #[cfg(any(target_arch = "wasm32", target_os = "android"))]
307    fn task_status(&self, id: u64) -> Result<Value, ActionError> {
308        use plat::web::RequestStatus as S;
309        let state = u32::try_from(id).ok().and_then(plat::web::request_status).ok_or_else(|| error(ErrorCode::NotFound, "Task not found"))?;
310        let status = match state { S::Pending => "running", S::Durable | S::Exported | S::Completed => "complete", S::Cancelled => "cancelled", _ => "failed" };
311        Ok(Value::object([
312            ("id", Value::u64(id)), ("revision", Value::u64(self.revision())), ("status", Value::string(status)), ("cancellable", Value::Bool(false)),
313            ("storage", Value::string(match state { S::Durable => "durable", S::Exported => "exported", S::Pending => "pending", S::Quota => "quota_exceeded", S::Denied => "access_denied", S::Unsupported => "unsupported", S::Cancelled => "cancelled", S::Failed => "failed", S::Completed => "completed" })),
314        ]))
315    }
316    fn resource_read(&self, uri: &str, offset: u64, limit: usize) -> Result<Vec<u8>, ActionError> {
317        let Some((_, bytes)) = self.ui.agent.images.iter().find(|(name, _)| name == uri) else { return self.app.action_resource_read(uri, offset, limit.min(262_144)); };
318        let start = usize::try_from(offset).ok().filter(|start| *start <= bytes.len()).ok_or_else(|| error(ErrorCode::InvalidInput, "Resource offset is out of range"))?;
319        Ok(bytes[start..start.saturating_add(limit.min(262_144)).min(bytes.len())].to_vec())
320    }
321    fn resource_info(&self, uri: &str) -> Result<Resource, ActionError> {
322        let Some((_, bytes)) = self.ui.agent.images.iter().find(|(name, _)| name == uri) else { return self.app.action_resource_info(uri); };
323        Ok(Resource { uri: String::from(uri), name: String::from("Application screenshot"), mime_type: String::from("image/png"), size: Some(bytes.len() as u64) })
324    }
325}
326fn error(code: ErrorCode, text: &str) -> ActionError { ActionError::new(code, text) }
327
328// Why a widget cannot be used from outside: details.reason of the refusal, the point looked at
329// and the id of what a press there meets (0: nothing with an id).
330pub(crate) struct Miss {
331    pub(crate) reason: &'static str,
332    text: &'static str,
333    id: u32,
334    win: u8,
335    rect: [i32; 4],
336    pub(crate) point: Option<(i32, i32)>,
337    pub(crate) by: u32,
338}
339impl Miss {
340    fn new(reason: &'static str, text: &'static str, node: &Sem, point: Option<(i32, i32)>, by: u32) -> Self {
341        Self { reason, text, id: node.id, win: node.win, rect: node.rect, point, by }
342    }
343    fn error(self, ui: &Ui) -> ActionError {
344        let by = ui.sems.iter().rev().find(|s| self.by != 0 && s.id == self.by && s.win == self.win).map_or(Value::Null, |s| Value::object([
345            ("id", Value::u64(s.id as u64)), ("kind", Value::u64(s.kind as u64)),
346            ("label", s.label.as_ref().map_or(Value::Null, |l| Value::string(l.clone()))),
347        ]));
348        let numbers = |v: &[i32]| Value::Array(v.iter().map(|n| Value::i64(*n as i64)).collect());
349        error(ErrorCode::Unavailable, self.text).with_details(Value::object([
350            ("reason", Value::string(self.reason)), ("id", Value::u64(self.id as u64)), ("window", Value::u64(self.win as u64)),
351            ("rect", numbers(&self.rect)), ("point", self.point.map_or(Value::Null, |(x, y)| numbers(&[x, y]))), ("by", by),
352        ]))
353    }
354}
355fn number(input: &Value, key: &str, default: u32) -> Result<u32, ActionError> {
356    input.get(key).map_or(Ok(default), |v| v.as_u64().and_then(|v| u32::try_from(v).ok()).ok_or_else(|| error(ErrorCode::InvalidInput, "Integer is outside the supported range")))
357}
358
359impl Ui {
360    // Where a press reaches the widget, in window px, by the hit test the press itself will meet:
361    // the last pass of its window with clipped boxes, layers, the catcher under popups and the
362    // modal layer, not the record's rectangle.  The middle of the record, also on a canvas that
363    // hit tests what it draws (the nodes of a diagram have no box); with `parts` also what shows
364    // of its boxes (a row half under a bar, a link over two lines), then a part of it in the
365    // middle (a row over its list).
366    pub(crate) fn agent_aim(&self, node: &Sem, parts: bool) -> Result<(i32, i32), Miss> {
367        let Some(p) = self.uiw_parts(node.win as u32) else { return Err(Miss::new("window", "The widget's window is not shown", node, None, 0)); };
368        let (list, modal) = (&p.ctx.hits[p.ctx.hit_cur ^ 1], p.ctx.modal_prev);
369        // hit boxes are logical: a right-to-left window mirrors x, as it does with the press
370        let flip = |x: i32| if self.rtl { p.rt.fb.w - 1 - x } else { x };
371        let top = |x: i32, y: i32| hit_find_list(list, modal, x, y).0;
372        let middle = (flip(node.rect[0] + node.rect[2] / 2), node.rect[1] + node.rect[3] / 2);
373        let on_middle = top(middle.0, middle.1);
374        let under = self.sems.iter().rev().find(|s| on_middle != 0 && s.id == on_middle && s.win == node.win && s.layer == node.layer);
375        let own = || list.iter().filter(|h| node.id != 0 && h.id == node.id);
376        let shown = || own().filter(|h| h.x0 < h.x1 && h.y0 < h.y1);
377        if node.id != 0 && (on_middle == node.id || (own().next().is_none() && under.is_some_and(|s| s.kind == K_CANVAS))) {
378            return Ok((flip(middle.0), middle.1));
379        }
380        if parts && node.id != 0 {
381            // five by five points of each box, its middle first
382            let inside = shown().find_map(|h| core::iter::once(12).chain(0..25).find_map(|k: i32| {
383                let (x, y) = (h.x0 + (h.x1 - h.x0) * (2 * (k % 5) + 1) / 10, h.y0 + (h.y1 - h.y0) * (2 * (k / 5) + 1) / 10);
384                (top(x, y) == node.id).then_some((flip(x), y))
385            }));
386            if let Some(point) = inside { return Ok(point); }
387            let r = node.rect;
388            if under.is_some_and(|s| s.rect != r && s.rect[0] >= r[0] && s.rect[1] >= r[1] && s.rect[0] + s.rect[2] <= r[0] + r[2] && s.rect[1] + s.rect[3] <= r[1] + r[3]) {
389                return Ok((flip(middle.0), middle.1));
390            }
391        }
392        // what lies there; the middle of what shows of it, when the record's middle is elsewhere
393        let at = shown().next().map_or(middle, |h| (h.x0 + (h.x1 - h.x0) / 2, h.y0 + (h.y1 - h.y0) / 2));
394        let by = top(at.0, at.1);
395        let point = Some((flip(at.0), at.1));
396        if by == crate::popup::CATCH_ID {
397            return Err(Miss::new("popup", "An open popup takes this click and closes", node, point, 0));
398        }
399        if own().next().is_none() {
400            return Err(Miss::new("no_input", "This widget has no hit box of its own", node, point, by));
401        }
402        if shown().next().is_none() {
403            return Err(Miss::new("clipped", "The widget is outside the visible area", node, point, by));
404        }
405        Err(Miss::new("covered", "Something else lies over this widget", node, point, by))
406    }
407    /// (scr_close) the widget a click asked for, when it is one of the window being built (a
408    /// client's: click_target, a screen reader's: a11y_reveal)
409    pub(crate) fn agent_reveal(&self) -> Option<u32> {
410        self.agent.reveal.filter(|r| r.0 as u32 == self.uiw_cur).map(|r| r.1)
411    }
412    /// Whether external control was explicitly activated. No endpoint opens by reading this.
413    pub fn agent_enabled(&self) -> bool { self.agent.enabled }
414    /// Whether the active control session has received a client request.
415    pub fn agent_connected(&self) -> bool { self.agent.connected }
416    /// Enable or revoke external control. Revocation also discards screenshot resources.
417    pub fn set_agent_enabled(&mut self, enabled: bool) {
418        if enabled == self.agent.enabled { return; }
419        self.agent.enabled = enabled;
420        #[cfg(any(target_arch = "wasm32", target_os = "android"))]
421        plat::web::set_agent_enabled(enabled);
422        #[cfg(not(any(target_arch = "wasm32", target_os = "android")))]
423        if enabled && !self.headless {
424            match agent_io::ipc::Server::bind(self.desc.title, "1", agent_core::Protocol::Json) {
425                Ok(server) => { self.agent.server = Some(server); }
426                Err(code) => {
427                    self.agent.enabled = false;
428                    plat::write_stderr(format!("agent endpoint could not start ({code})\n").as_bytes());
429                }
430            }
431        }
432        if !enabled {
433            self.agent.connected = false;
434            self.agent.images.clear();
435            #[cfg(not(any(target_arch = "wasm32", target_os = "android")))]
436            { self.agent.server = None; }
437        }
438        self.invalidate();
439    }
440    #[cfg(not(any(target_arch = "wasm32", target_os = "android")))]
441    pub(crate) fn agent_poll(&mut self, app: &mut dyn App) {
442        let Some(mut server) = self.agent.server.take() else { return; };
443        let mut dispatcher = Dispatcher::new();
444        dispatcher.set_enabled(self.agent.enabled);
445        let changed = server.poll(&dispatcher, &mut UiHost::new(self, app));
446        let connection_changed = self.agent.connected != (server.connected() != 0);
447        self.agent.connected = server.connected() != 0;
448        if self.agent.enabled { self.agent.server = Some(server); }
449        if changed || connection_changed { self.invalidate(); }
450    }
451    pub(crate) fn agent_request(&mut self, app: &mut dyn App, session: &mut agent_core::RpcSession, line: &[u8]) -> Option<String> {
452        let mut dispatcher = Dispatcher::new();
453        dispatcher.set_enabled(self.agent.enabled);
454        self.agent.connected = self.agent.enabled;
455        session.handle(&dispatcher, &mut UiHost::new(self, app), line)
456    }
457}
458impl Runtime {
459    /// Explicitly opt into the same control dispatcher used by native JSON/MCP sessions.
460    pub fn set_agent_enabled(&mut self, enabled: bool) { self.ui.set_agent_enabled(enabled && !self.is_finished()); }
461    /// Handle one control request without IO. The caller owns one session per connection.
462    pub fn control(&mut self, session: &mut agent_core::RpcSession, line: &[u8]) -> Option<String> {
463        if self.is_finished() { self.ui.set_agent_enabled(false); }
464        self.ui.agent_request(self.app.as_mut(), session, line)
465    }
466}
467
468#[cfg(not(any(target_arch = "wasm32", target_os = "android")))]
469pub(crate) fn serve(ui: &mut Ui, app: &mut dyn App, protocol: agent_core::Protocol) -> i32 {
470    ui.set_agent_enabled(true);
471    ui.run_passes(app);
472    let mut session = agent_core::RpcSession::new(protocol, ui.desc.title, "1");
473    let code = agent_io::serve_stdio(|line| ui.agent_request(app, &mut session, line));
474    ui.set_agent_enabled(false);
475    app.command(ui, UIC_EXIT, 0);
476    code
477}
478
479#[cfg(any(target_arch = "wasm32", target_os = "android"))]
480pub(crate) fn serve(_ui: &mut Ui, _app: &mut dyn App, _protocol: agent_core::Protocol) -> i32 { 2 }