tpt_appfront_core/
devtools.rs1use crate::agent::{AgentState, ElementSummary};
13use crate::signal::signal_activity;
14use crate::ui_tree::{NodeKind, UITree};
15
16#[derive(Debug, Clone)]
19pub struct DevtoolsReport {
20 pub tree: String,
22 pub state: String,
24 pub signals: String,
26}
27
28pub 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
118pub 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
162pub 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
184pub fn to_html(report: &DevtoolsReport) -> String {
187 let esc = |s: &str| {
188 s.replace('&', "&")
189 .replace('<', "<")
190 .replace('>', ">")
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 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 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 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}