Skip to main content

tpt_appfront_core/
agent.rs

1//! Programmatic API for AI agents to inspect and interact with the UI tree
2//! without needing a browser or canvas.
3//!
4//! # Functions
5//!
6//! | Function | Purpose |
7//! |---|---|
8//! | [`query_state`] | Walk the tree and return a structured [`AgentState`] snapshot |
9//! | [`navigate_to`]  | Change the current route (signals reactive system) |
10//! | [`trigger_event`] | Dispatch an event by `ai_action` name |
11//! | [`current_route`] | Return the current route path |
12
13use serde::Serialize;
14use std::cell::RefCell;
15
16use crate::signal::Signal;
17use crate::ui_tree::{NodeKind, UITree};
18
19/// Structured snapshot of the UI tree designed for LLM / AI agent consumption.
20///
21/// Returned by [`query_state`] and serializable as JSON.
22#[derive(Debug, Clone, Serialize)]
23pub struct AgentState {
24    /// Elements an agent can interact with (buttons, inputs, ...).
25    pub interactive_elements: Vec<ElementSummary>,
26    /// Elements that display data (headings, text, grids, ...).
27    pub data_elements: Vec<ElementSummary>,
28    /// The current route known to the reactive router.
29    pub current_route: String,
30}
31
32/// Lightweight description of a single UI element.
33#[derive(Debug, Clone, Serialize)]
34pub struct ElementSummary {
35    /// Stable element id assigned by [`UITree::assign_ids`] (if called).
36    pub id: Option<u64>,
37    /// Element type, e.g. `"button"`, `"input"`, `"h1"`, `"text"`, `"data_grid"`.
38    pub kind: String,
39    /// Human-readable label or heading text.
40    pub label: Option<String>,
41    /// Current value (for inputs).
42    pub value: Option<String>,
43    /// Machine-readable action name (`AiMeta::action`).
44    pub action: Option<String>,
45    /// Key-value parameters the action expects.
46    pub params: Vec<(String, String)>,
47    /// Human-readable description of the element's purpose.
48    pub description: Option<String>,
49}
50
51// ---------------------------------------------------------------------------
52// Reactive router — thread-local so it works in WASM and native contexts
53// without locking.
54// ---------------------------------------------------------------------------
55
56thread_local! {
57    static ROUTER: RefCell<Option<Signal<String>>> = const { RefCell::new(None) };
58}
59
60fn router() -> Signal<String> {
61    ROUTER.with(|r| {
62        r.borrow_mut()
63            .get_or_insert_with(|| Signal::new("/".to_string()))
64            .clone()
65    })
66}
67
68/// Returns the underlying route [`Signal`] so effects can subscribe to route
69/// changes (e.g. to rebuild the UI tree on navigation).
70///
71/// # Example
72///
73/// ```ignore
74/// let _handle = create_effect(|| {
75///     let route = route_signal().get();
76///     // rebuild UI based on route
77/// });
78/// ```
79pub fn route_signal() -> Signal<String> {
80    router()
81}
82
83/// Returns the current route path.
84pub fn current_route() -> String {
85    router().get()
86}
87
88/// Programmatically navigate to a new route. Updates the reactive route signal,
89/// causing any subscribed effects to re-run automatically.
90pub fn navigate_to(route: &str) {
91    router().set(route.to_string());
92}
93
94// ---------------------------------------------------------------------------
95// query_state
96// ---------------------------------------------------------------------------
97
98/// Walk the UI tree and return a structured [`AgentState`] describing every
99/// interactive and data-bearing element from an AI agent's perspective.
100///
101/// Interactive elements (buttons, inputs) include their `ai_action` name and
102/// parameters so an agent can invoke [`trigger_event`] with the matching name.
103pub fn query_state<Msg>(ui: &UITree<Msg>) -> AgentState {
104    let mut interactive = Vec::new();
105    let mut data = Vec::new();
106    walk(ui, &mut interactive, &mut data);
107    AgentState {
108        interactive_elements: interactive,
109        data_elements: data,
110        current_route: current_route(),
111    }
112}
113
114fn walk<Msg>(
115    node: &UITree<Msg>,
116    interactive: &mut Vec<ElementSummary>,
117    data: &mut Vec<ElementSummary>,
118) {
119    let id = node.meta.data_appfront_id;
120    let ai = &node.meta.ai;
121
122    match &node.kind {
123        NodeKind::Button { label } => {
124            interactive.push(ElementSummary {
125                id,
126                kind: "button".into(),
127                label: Some(label.clone()),
128                value: None,
129                action: ai.action.clone(),
130                params: ai.params.clone(),
131                description: ai.description.clone(),
132            });
133        }
134        NodeKind::Input { value } => {
135            interactive.push(ElementSummary {
136                id,
137                kind: "input".into(),
138                label: None,
139                value: Some(value.clone()),
140                action: ai.action.clone(),
141                params: ai.params.clone(),
142                description: ai.description.clone(),
143            });
144        }
145        NodeKind::Textarea { value } => {
146            interactive.push(ElementSummary {
147                id,
148                kind: "textarea".into(),
149                label: None,
150                value: Some(value.clone()),
151                action: ai.action.clone(),
152                params: ai.params.clone(),
153                description: ai.description.clone(),
154            });
155        }
156        NodeKind::Checkbox { label, checked } => {
157            interactive.push(ElementSummary {
158                id,
159                kind: "checkbox".into(),
160                label: Some(label.clone()),
161                value: Some(checked.to_string()),
162                action: ai.action.clone(),
163                params: ai.params.clone(),
164                description: ai.description.clone(),
165            });
166        }
167        NodeKind::Select { selected, .. } => {
168            interactive.push(ElementSummary {
169                id,
170                kind: "select".into(),
171                label: None,
172                value: Some(selected.clone()),
173                action: ai.action.clone(),
174                params: ai.params.clone(),
175                description: ai.description.clone(),
176            });
177        }
178        NodeKind::Radio { selected, .. } => {
179            interactive.push(ElementSummary {
180                id,
181                kind: "radio".into(),
182                label: None,
183                value: Some(selected.clone()),
184                action: ai.action.clone(),
185                params: ai.params.clone(),
186                description: ai.description.clone(),
187            });
188        }
189        NodeKind::Heading { level, text } => {
190            data.push(ElementSummary {
191                id,
192                kind: format!("h{level}"),
193                label: Some(text.clone()),
194                value: None,
195                action: None,
196                params: Vec::new(),
197                description: None,
198            });
199        }
200        NodeKind::Text { text } => {
201            data.push(ElementSummary {
202                id,
203                kind: "text".into(),
204                label: Some(text.clone()),
205                value: None,
206                action: None,
207                params: Vec::new(),
208                description: None,
209            });
210        }
211        NodeKind::Container { children } => {
212            for child in children {
213                walk(child, interactive, data);
214            }
215        }
216        NodeKind::List { items } => {
217            for item in items {
218                walk(item, interactive, data);
219            }
220        }
221        NodeKind::DataGrid { columns, rows } => {
222            data.push(ElementSummary {
223                id,
224                kind: "data_grid".into(),
225                label: Some(format!("[{}] — {} rows", columns.join(", "), rows.len())),
226                value: None,
227                action: None,
228                params: Vec::new(),
229                description: None,
230            });
231        }
232        NodeKind::Portal { content, .. } => {
233            // Surface the portal's content so agents observe its elements.
234            walk(content, interactive, data);
235        }
236    }
237}
238
239// ---------------------------------------------------------------------------
240// trigger_event
241// ---------------------------------------------------------------------------
242
243/// Find a UI element whose `ai_action` matches `action` and dispatch its
244/// `on_click` message via the provided `dispatch` callback.
245///
246/// Returns `true` if a matching node with a message handler was found and
247/// dispatched; returns `false` otherwise.
248pub fn trigger_event<Msg>(ui: &UITree<Msg>, action: &str, dispatch: &dyn Fn(Msg)) -> bool
249where
250    Msg: Clone,
251{
252    find_and_dispatch(ui, action, dispatch)
253}
254
255fn find_and_dispatch<Msg>(node: &UITree<Msg>, action: &str, dispatch: &dyn Fn(Msg)) -> bool
256where
257    Msg: Clone,
258{
259    if node.meta.ai.action.as_deref() == Some(action) {
260        if let Some(msg) = &node.meta.on_click {
261            dispatch(msg.clone());
262            return true;
263        }
264    }
265    match &node.kind {
266        NodeKind::Container { children } => {
267            for child in children {
268                if find_and_dispatch(child, action, dispatch) {
269                    return true;
270                }
271            }
272        }
273        NodeKind::List { items } => {
274            for item in items {
275                if find_and_dispatch(item, action, dispatch) {
276                    return true;
277                }
278            }
279        }
280        _ => {}
281    }
282    false
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[derive(Debug, Clone, PartialEq)]
290    enum TestMsg {
291        Submit,
292    }
293
294    fn sample_ui() -> UITree<TestMsg> {
295        UITree::container(|c| {
296            c.heading(1, "Dashboard").class("text-2xl font-bold");
297            c.button("Export")
298                .on_click(TestMsg::Submit)
299                .ai_action("export_data")
300                .ai_param("format", "csv")
301                .ai_description("Export the data as CSV");
302            c.input("hello")
303                .ai_action("search")
304                .ai_param("key", "query");
305            c.list(|l| {
306                l.text("Item A");
307                l.text("Item B");
308            });
309            c.data_grid(["A", "B"], [vec!["1", "2"], vec!["3", "4"]]);
310        })
311    }
312
313    #[test]
314    fn query_state_collects_interactive_and_data() {
315        let ui = sample_ui();
316        let state = query_state(&ui);
317
318        // Interactive: button + input
319        assert_eq!(state.interactive_elements.len(), 2);
320
321        let btn = &state.interactive_elements[0];
322        assert_eq!(btn.kind, "button");
323        assert_eq!(btn.label.as_deref(), Some("Export"));
324        assert_eq!(btn.action.as_deref(), Some("export_data"));
325        assert_eq!(btn.params, vec![("format".into(), "csv".into())]);
326        assert_eq!(btn.description.as_deref(), Some("Export the data as CSV"));
327
328        let input = &state.interactive_elements[1];
329        assert_eq!(input.kind, "input");
330        assert_eq!(input.value.as_deref(), Some("hello"));
331        assert_eq!(input.action.as_deref(), Some("search"));
332
333        // Data: heading + list items (2) + data_grid = 4
334        assert_eq!(state.data_elements.len(), 4);
335        assert_eq!(state.data_elements[0].kind, "h1");
336        assert_eq!(state.data_elements[0].label.as_deref(), Some("Dashboard"));
337        assert_eq!(state.data_elements[1].kind, "text");
338        assert_eq!(state.data_elements[1].label.as_deref(), Some("Item A"));
339        assert_eq!(state.data_elements[2].kind, "text");
340        assert_eq!(state.data_elements[2].label.as_deref(), Some("Item B"));
341        assert_eq!(state.data_elements[3].kind, "data_grid");
342        assert_eq!(
343            state.data_elements[3].label.as_deref(),
344            Some("[A, B] — 2 rows")
345        );
346
347        // Route defaults to "/"
348        assert_eq!(state.current_route, "/");
349    }
350
351    #[test]
352    fn trigger_event_dispatches_matching_action() {
353        let mut ui = sample_ui();
354        ui.assign_ids();
355
356        let dispatched = std::cell::Cell::new(None::<TestMsg>);
357        let dispatch = |msg: TestMsg| {
358            dispatched.set(Some(msg));
359        };
360
361        let result = trigger_event(&ui, "export_data", &dispatch);
362        assert!(result, "should find and dispatch the event");
363        assert_eq!(dispatched.take(), Some(TestMsg::Submit));
364    }
365
366    #[test]
367    fn trigger_event_returns_false_for_unknown_action() {
368        let ui = sample_ui();
369        let result = trigger_event(&ui, "nonexistent", &|_: TestMsg| {});
370        assert!(!result, "unknown action should return false");
371    }
372
373    #[test]
374    fn trigger_event_returns_false_when_no_on_click() {
375        // The input has ai_action("search") but no .on_click()
376        let ui = sample_ui();
377        let result = trigger_event(&ui, "search", &|_: TestMsg| {});
378        assert!(!result, "action without on_click should return false");
379    }
380
381    #[test]
382    fn navigate_updates_route() {
383        let before = current_route();
384        assert_eq!(before, "/", "default route is /");
385
386        navigate_to("/dashboard");
387        assert_eq!(current_route(), "/dashboard");
388
389        navigate_to("/settings");
390        assert_eq!(current_route(), "/settings");
391    }
392
393    #[test]
394    fn route_signal_is_reactive() {
395        use crate::signal::create_effect;
396        use std::rc::Rc;
397
398        let seen = Rc::new(std::cell::RefCell::new(Vec::new()));
399        let route = route_signal();
400
401        let seen_clone = Rc::clone(&seen);
402        let _handle = create_effect(move || {
403            let r = route.get();
404            seen_clone.borrow_mut().push(r);
405        });
406
407        // Effect runs once immediately with "/"
408        assert_eq!(seen.borrow().len(), 1);
409
410        navigate_to("/foo");
411        // Effect re-runs because navigate_to sets the signal
412        assert_eq!(seen.borrow().len(), 2);
413        assert_eq!(seen.borrow()[1], "/foo");
414    }
415
416    #[test]
417    fn query_state_respects_assign_ids() {
418        let mut ui = sample_ui();
419        ui.assign_ids();
420
421        let state = query_state(&ui);
422
423        // All elements should have ids assigned
424        for el in &state.interactive_elements {
425            assert!(el.id.is_some(), "interactive element should have id");
426        }
427        for el in &state.data_elements {
428            assert!(el.id.is_some(), "data element should have id");
429        }
430
431        // Button gets id 3 (root=1, heading=2, button=3, input=4, list=5, itemA=6, itemB=7, grid=8)
432        assert_eq!(state.interactive_elements[0].id, Some(3));
433    }
434
435    #[test]
436    fn query_state_flat_list_and_container() {
437        let mut ui: UITree<TestMsg> = UITree::container(|c| {
438            c.container(|inner| {
439                inner.button("Nested").ai_action("nested_btn");
440            });
441            c.list(|l| {
442                l.button("List button").ai_action("list_btn");
443            });
444        });
445        ui.assign_ids();
446
447        let state = query_state(&ui);
448
449        // Should find both buttons (one nested in container, one in list)
450        assert_eq!(state.interactive_elements.len(), 2);
451        assert_eq!(
452            state.interactive_elements[0].action.as_deref(),
453            Some("nested_btn")
454        );
455        assert_eq!(
456            state.interactive_elements[1].action.as_deref(),
457            Some("list_btn")
458        );
459    }
460}