Skip to main content

board_watch/
view.rs

1//! Turning what the client assembled into lines a person reads.
2//!
3//! Every helper here names a [`Session`] without bounding its transport. That
4//! is not incidental: an application's view layer only ever *reads* a session,
5//! and a `T: Transport` bound on the type would force every one of these
6//! signatures to repeat a constraint none of them use. `ag-ui-client` keeps the
7//! bound on the impl blocks that make requests, and this module is what spends
8//! that.
9
10use 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
22/// The run the session last heard about, for the footer.
23///
24/// No `T: Transport` — this only reads.
25pub 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
32/// How many messages the conversation holds.
33pub fn message_count<T, S>(session: &Session<T, S>) -> usize {
34    session.messages().len()
35}
36
37/// The panel drawn after each run: the board, then where it came from.
38///
39/// Names `Session<T, Board>` — a concrete state, still no transport bound.
40pub 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        // `None` is not "empty": it means no STATE_* event has arrived at all,
49        // and a view that renders the two the same way hides a broken agent.
50        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
70/// The surface the conversation is carrying, recovered from history.
71///
72/// The client's half of what the agent does to decide create-versus-update: the
73/// A2UI operations are in the transcript, so anything holding the transcript can
74/// replay them. No hand-written message mapping — that is what the toolkit's
75/// `ag-ui` feature is for.
76pub fn surface_in_history(messages: &[Message]) -> Option<PriorSurface> {
77    find_prior_surface_in(messages).filter(|prior| !prior.deleted)
78}
79
80/// Draws an `a2ui_operations` envelope, or `None` if it is not one.
81///
82/// The sniff every A2UI front-end does: a tool result either carries the
83/// envelope key or is an ordinary result, and nothing else tells them apart.
84pub 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
109/// Appends the lines one component draws as, children included.
110///
111/// A real renderer owns a widget toolkit; this walks far enough to prove the
112/// surface arrived whole — every child reference resolved, every binding
113/// evaluated, the list template instantiated once per item.
114fn 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        // Every container in the basic catalog spells its children the same
136        // way, so one arm covers them.
137        _ => 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                    // Entering the item scope is what makes the template's
150                    // relative paths resolve.
151                    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
160/// Resolves one property: a literal, a `{"path": …}` binding, or a call.
161fn 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
173/// Shortens a payload so one tool result is one line.
174pub 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    /// The compile-time half of this module's claim: a helper that names a
189    /// session needs no transport bound. If the bound migrates back onto
190    /// `Session`, this file stops compiling.
191    #[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}