Skip to main content

tpt_appfront_core/
devtools.rs

1//! Human-facing devtools inspector for the `UITree`.
2//!
3//! Reuses the AI-agent [`AgentState`]/[`ElementSummary`] snapshot (see
4//! [`crate::agent`]) plus a pretty-printed view of the tree itself, so a
5//! developer can inspect the structure, the interactive/data elements an AI
6//! agent would see, and — when signals are named via
7//! [`crate::signal::Signal::labeled`] — which signals have been firing.
8//!
9//! The output is plain text (ideal for a terminal/console devtools panel) and
10//! also renderable to a self-contained HTML snippet via [`to_html`].
11
12use crate::agent::{AgentState, ElementSummary};
13use crate::signal::signal_activity;
14use crate::ui_tree::{NodeKind, UITree};
15
16/// A complete devtools report: the tree view, the agent-state view, and the
17/// signal-activity view.
18#[derive(Debug, Clone)]
19pub struct DevtoolsReport {
20    /// Pretty-printed `UITree` with per-node metadata annotations.
21    pub tree: String,
22    /// Human-readable listing of the `AgentState` snapshot.
23    pub state: String,
24    /// Per-label signal-write counts (empty when no signals are labeled).
25    pub signals: String,
26}
27
28/// Pretty-prints a `UITree` as an indented tree, annotating each node with its
29/// `meta` (class, key, dynamic flag, AI action, on_click presence, assigned id).
30pub fn inspect_tree<Msg>(ui: &UITree<Msg>) -> String {
31    let mut out = String::new();
32    write_node(ui, &mut out, "", true);
33    out
34}
35
36fn node_header<Msg>(ui: &UITree<Msg>) -> String {
37    match &ui.kind {
38        NodeKind::Container { children } => format!("Container ({} children)", children.len()),
39        NodeKind::Heading { level, text } => format!("h{level} \"{text}\""),
40        NodeKind::Text { text } => format!("Text \"{text}\""),
41        NodeKind::Button { label } => format!("Button \"{label}\""),
42        NodeKind::Input { value } => format!("Input value=\"{value}\""),
43        NodeKind::Textarea { value } => format!("Textarea value=\"{value}\""),
44        NodeKind::Checkbox { label, checked } => {
45            format!("Checkbox \"{label}\" checked={checked}")
46        }
47        NodeKind::Select { options, selected } => format!(
48            "Select [{options:?}] selected=\"{selected}\""
49        ),
50        NodeKind::Radio { name, options, selected } => format!(
51            "Radio name=\"{name}\" [{options:?}] selected=\"{selected}\""
52        ),
53        NodeKind::List { items } => format!("List ({}) items", items.len()),
54        NodeKind::DataGrid { columns, rows } => {
55            format!(
56                "DataGrid [{}] {}x{}",
57                columns.join(", "),
58                columns.len(),
59                rows.len()
60            )
61        }
62        NodeKind::Portal { target, .. } => format!("Portal -> \"{target}\""),
63    }
64}
65
66fn node_annotations<Msg>(ui: &UITree<Msg>) -> Vec<String> {
67    let meta = &ui.meta;
68    let mut parts = Vec::new();
69    if let Some(id) = meta.data_appfront_id {
70        parts.push(format!("#{id}"));
71    }
72    if let Some(class) = &meta.class {
73        parts.push(format!("class=\"{class}\""));
74    }
75    if let Some(key) = &meta.key {
76        parts.push(format!("key=\"{key}\""));
77    }
78    if meta.is_dynamic {
79        parts.push("dynamic".into());
80    }
81    if let Some(action) = &meta.ai.action {
82        parts.push(format!("ai:{action}"));
83    }
84    if meta.on_click.is_some() {
85        parts.push("on_click".into());
86    }
87    parts
88}
89
90fn node_children<Msg>(ui: &UITree<Msg>) -> &[UITree<Msg>] {
91    match &ui.kind {
92        NodeKind::Container { children } => children,
93        NodeKind::List { items } => items,
94        _ => &[],
95    }
96}
97
98fn write_node<Msg>(ui: &UITree<Msg>, out: &mut String, prefix: &str, is_last: bool) {
99    let branch = if is_last { "└─ " } else { "├─ " };
100    out.push_str(prefix);
101    out.push_str(branch);
102    out.push_str(&node_header(ui));
103
104    let annotations = node_annotations(ui);
105    if !annotations.is_empty() {
106        out.push_str("  ");
107        out.push_str(&annotations.join(" "));
108    }
109    out.push('\n');
110
111    let children = node_children(ui);
112    let child_prefix = format!("{}{}", prefix, if is_last { "   " } else { "│  " });
113    for (i, child) in children.iter().enumerate() {
114        write_node(child, out, &child_prefix, i + 1 == children.len());
115    }
116}
117
118/// Renders an [`AgentState`] snapshot as a readable listing of the
119/// interactive and data elements an AI agent would observe.
120pub fn inspect_state(state: &AgentState) -> String {
121    let mut out = String::new();
122    out.push_str(&format!("route: {}\n", state.current_route));
123    out.push_str(&format!(
124        "interactive elements ({}):\n",
125        state.interactive_elements.len()
126    ));
127    for el in &state.interactive_elements {
128        out.push_str("  - ");
129        out.push_str(&element_line(el));
130        out.push('\n');
131    }
132    out.push_str(&format!("data elements ({}):\n", state.data_elements.len()));
133    for el in &state.data_elements {
134        out.push_str("  - ");
135        out.push_str(&element_line(el));
136        out.push('\n');
137    }
138    out
139}
140
141fn element_line(el: &ElementSummary) -> String {
142    let mut s = el.kind.clone();
143    if let Some(label) = &el.label {
144        s.push_str(&format!(" \"{label}\""));
145    }
146    if let Some(value) = &el.value {
147        s.push_str(&format!(" value=\"{value}\""));
148    }
149    if let Some(action) = &el.action {
150        s.push_str(&format!(" action={action}"));
151    }
152    let params: Vec<String> = el.params.iter().map(|(k, v)| format!("{k}={v}")).collect();
153    if !params.is_empty() {
154        s.push_str(&format!(" ({})", params.join(", ")));
155    }
156    if let Some(desc) = &el.description {
157        s.push_str(&format!(" — {desc}"));
158    }
159    s
160}
161
162/// Builds a full [`DevtoolsReport`] from a `UITree` and its [`AgentState`].
163pub fn render<Msg>(ui: &UITree<Msg>, state: &AgentState) -> DevtoolsReport {
164    let activity = signal_activity();
165    let signals = if activity.is_empty() {
166        "(no labeled signals — name signals with `Signal::labeled` to track writes)".into()
167    } else {
168        let mut entries: Vec<(&String, &u64)> = activity.iter().collect();
169        entries.sort_by_key(|(name, _)| *name);
170        entries
171            .into_iter()
172            .map(|(name, count)| format!("  - {name}: {count} write(s)"))
173            .collect::<Vec<_>>()
174            .join("\n")
175    };
176
177    DevtoolsReport {
178        tree: inspect_tree(ui),
179        state: inspect_state(state),
180        signals,
181    }
182}
183
184/// Renders a [`DevtoolsReport`] as a self-contained HTML snippet suitable for
185/// embedding in a devtools panel (no external CSS/JS).
186pub fn to_html(report: &DevtoolsReport) -> String {
187    let esc = |s: &str| {
188        s.replace('&', "&amp;")
189            .replace('<', "&lt;")
190            .replace('>', "&gt;")
191    };
192    format!(
193        "<div class=\"appfront-devtools\">\n  \
194         <h3>UI Tree</h3>\n  <pre>{tree}</pre>\n  \
195         <h3>Agent State</h3>\n  <pre>{state}</pre>\n  \
196         <h3>Signal Activity</h3>\n  <pre>{signals}</pre>\n\
197         </div>",
198        tree = esc(&report.tree),
199        state = esc(&report.state),
200        signals = esc(&report.signals),
201    )
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::agent::query_state;
208
209    #[derive(Debug, Clone, PartialEq)]
210    enum Msg {
211        Increment,
212    }
213
214    fn sample_ui() -> UITree<Msg> {
215        UITree::container(|c| {
216            c.heading(1, "Dashboard").class("title");
217            c.container(|inner| {
218                inner
219                    .button("+1")
220                    .on_click(Msg::Increment)
221                    .ai_action("increment")
222                    .ai_description("Increment the counter");
223            });
224            c.input("hello world");
225        })
226    }
227
228    #[test]
229    fn inspect_tree_shows_structure_and_annotations() {
230        let ui = sample_ui();
231        let out = inspect_tree(&ui);
232        assert!(out.contains("Container ("), "root container");
233        assert!(out.contains("h1 \"Dashboard\""), "heading node");
234        assert!(out.contains("class=\"title\""), "heading class annotation");
235        assert!(out.contains("Button \"+1\""), "button node");
236        assert!(out.contains("ai:increment"), "ai action annotation");
237        assert!(out.contains("on_click"), "on_click annotation");
238        assert!(out.contains("Input value=\"hello world\""), "input node");
239        // Nested container should be indented under the root.
240        assert!(
241            out.contains("│  ") || out.contains("   "),
242            "has indentation"
243        );
244    }
245
246    #[test]
247    fn inspect_state_lists_interactive_and_data_elements() {
248        let ui = sample_ui();
249        let state = query_state(&ui);
250        let out = inspect_state(&state);
251        assert!(out.contains("interactive elements (2):"));
252        assert!(out.contains("data elements (1):"));
253        assert!(out.contains("action=increment"));
254        assert!(out.contains("h1 \"Dashboard\""));
255    }
256
257    #[test]
258    fn render_produces_a_full_report() {
259        let ui = sample_ui();
260        let state = query_state(&ui);
261        let report = render(&ui, &state);
262        assert!(report.tree.contains("Container ("));
263        assert!(report.state.contains("route:"));
264        // No labeled signals were used, so the fallback message is shown.
265        assert!(report.signals.contains("no labeled signals"));
266    }
267
268    #[test]
269    fn to_html_escapes_and_wraps_report() {
270        let ui = sample_ui();
271        let state = query_state(&ui);
272        let report = render(&ui, &state);
273        let html = to_html(&report);
274        assert!(html.starts_with("<div class=\"appfront-devtools\">"));
275        assert!(html.contains("<pre>"));
276        assert!(!html.contains("<script"));
277    }
278
279    #[test]
280    fn signal_activity_is_reported_when_labeled() {
281        use crate::signal::{reset_signal_activity, Signal};
282
283        reset_signal_activity();
284        let count = Signal::new(0i32).labeled("count");
285        count.set(1);
286        count.set(2);
287        // Setting the same value still records a write (activity is write-count).
288        count.set(2);
289
290        let ui = sample_ui();
291        let state = query_state(&ui);
292        let report = render(&ui, &state);
293        assert!(
294            report.signals.contains("count: 3 write(s)"),
295            "got: {}",
296            report.signals
297        );
298    }
299}