1use ag_ui::Message;
11use ag_ui::client::Session;
12use ag_ui_a2ui::agui::find_prior_surface_in;
13use ag_ui_a2ui::binding::Scope;
14use ag_ui_a2ui::constants::ROOT_ID;
15use ag_ui_a2ui::toolkit::envelope::{is_operations_envelope, unwrap_operations_envelope};
16use ag_ui_a2ui::toolkit::history::PriorSurface;
17use ag_ui_a2ui::{AgentPayload, ChildList, Component};
18use serde_json::Value;
19
20use crate::board::Board;
21
22pub fn run_id<T, S>(session: &Session<T, S>) -> String {
26 session
27 .applier()
28 .run_id()
29 .map_or_else(|| "—".to_owned(), |id| id.as_str().to_owned())
30}
31
32pub fn message_count<T, S>(session: &Session<T, S>) -> usize {
34 session.messages().len()
35}
36
37pub fn panel<T>(session: &Session<T, Board>) -> Vec<String> {
41 let mut lines = vec!["┌ board".to_owned()];
42
43 match session.state() {
44 Some(board) if !board.tasks.is_empty() => {
45 lines.push(format!("│ {}", board.summary()));
46 lines.extend(board.tasks.iter().map(|task| format!("│ {}", task.line())));
47 }
48 Some(_) => lines.push("│ (empty)".to_owned()),
51 None => lines.push("│ (no state published)".to_owned()),
52 }
53
54 let surface = match surface_in_history(session.messages()) {
55 Some(prior) => format!(
56 " · surface {} ({})",
57 prior.surface_id,
58 prior.components.len()
59 ),
60 None => String::new(),
61 };
62 lines.push(format!(
63 "└ run {} · {} messages{surface}",
64 run_id(session),
65 message_count(session)
66 ));
67 lines
68}
69
70pub fn surface_in_history(messages: &[Message]) -> Option<PriorSurface> {
77 find_prior_surface_in(messages).filter(|prior| !prior.deleted)
78}
79
80pub fn surface_lines(payload: &str) -> Option<Vec<String>> {
85 let value: Value = serde_json::from_str(payload).ok()?;
86 if !is_operations_envelope(&value) {
87 return None;
88 }
89
90 let operations = unwrap_operations_envelope(&value).ok()?;
91 let mut components: Vec<Component> = Vec::new();
92 let mut data = Value::Null;
93 for operation in &operations {
94 match &operation.payload {
95 AgentPayload::UpdateComponents(payload) => components.clone_from(&payload.components),
96 AgentPayload::UpdateDataModel(payload) => data = payload.value.clone(),
97 _ => {}
98 }
99 }
100 if components.is_empty() {
101 return None;
102 }
103
104 let mut lines = Vec::new();
105 draw(&components, &Scope::root(&data), ROOT_ID, &mut lines);
106 Some(lines)
107}
108
109fn draw(components: &[Component], scope: &Scope<'_>, id: &str, lines: &mut Vec<String>) {
115 let Some(component) = components.iter().find(|component| component.id == id) else {
116 lines.push(format!("<no component {id}>"));
117 return;
118 };
119
120 match component.component.as_str() {
121 "Text" => lines.push(bound(scope, component, "text")),
122 "CheckBox" => {
123 let mark = if bound(scope, component, "value") == "true" {
124 "x"
125 } else {
126 " "
127 };
128 lines.push(format!("[{mark}] {}", bound(scope, component, "label")));
129 }
130 "Card" => {
131 if let Some(child) = component.prop("child").and_then(Value::as_str) {
132 draw(components, scope, child, lines);
133 }
134 }
135 _ => match component.prop("children").and_then(ChildList::from_value) {
138 Some(ChildList::Ids(ids)) => {
139 for child in ids {
140 draw(components, scope, &child, lines);
141 }
142 }
143 Some(ChildList::Template(template)) => {
144 let count = scope
145 .resolve(&template.path)
146 .and_then(Value::as_array)
147 .map_or(0, Vec::len);
148 for index in 0..count {
149 let item = scope.item(&template.path, index);
152 draw(components, &item, &template.component_id, lines);
153 }
154 }
155 None => lines.push(format!("<{} has no children>", component.component)),
156 },
157 }
158}
159
160fn bound(scope: &Scope<'_>, component: &Component, key: &str) -> String {
162 let Some(raw) = component.prop(key) else {
163 return String::new();
164 };
165 match scope.resolve_dynamic(raw) {
166 Ok(Value::String(text)) => text,
167 Ok(Value::Null) => String::new(),
168 Ok(other) => other.to_string(),
169 Err(error) => format!("<{error}>"),
170 }
171}
172
173pub fn clip(text: &str, width: usize) -> String {
175 let flat = text.replace('\n', " ");
176 if flat.chars().count() <= width {
177 return flat;
178 }
179 let kept: String = flat.chars().take(width.saturating_sub(1)).collect();
180 format!("{kept}…")
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use ag_ui::client::transport::ReplayTransport;
187
188 #[test]
192 fn view_helpers_read_a_session_without_bounding_its_transport() {
193 let session: Session<ReplayTransport, Board> = Session::new(ReplayTransport::new([]), "t");
194 assert_eq!(message_count(&session), 0);
195 assert_eq!(run_id(&session), "—");
196 assert_eq!(panel(&session)[1], "│ (no state published)");
197 }
198
199 #[test]
200 fn a_result_that_is_not_a_surface_draws_nothing() {
201 assert!(surface_lines(r#"{"id":1}"#).is_none());
202 assert!(surface_lines("not json at all").is_none());
203 }
204
205 #[test]
206 fn clipping_counts_characters_not_bytes() {
207 assert_eq!(clip("héllo wörld", 7), "héllo …");
208 assert_eq!(clip("short", 40), "short");
209 assert_eq!(clip("two\nlines", 40), "two lines");
210 }
211}