Skip to main content

ai_crew_sync/client/
render.rs

1//! Human-readable output. `--json` bypasses all of this and prints the raw
2//! structured content, so scripts never depend on the shape of these lines.
3
4use anyhow::Context;
5use serde_json::Value;
6
7use super::{ClientCmd, LockCmd, NoteCmd, TaskCmd};
8
9pub(super) fn field<'v>(v: &'v Value, key: &str) -> &'v str {
10    v.get(key).and_then(Value::as_str).unwrap_or("")
11}
12
13fn render_messages(value: &Value) {
14    let messages = value["messages"].as_array().cloned().unwrap_or_default();
15    if messages.is_empty() {
16        println!("(no messages)");
17        return;
18    }
19    for m in &messages {
20        let target = m["channel"]
21            .as_str()
22            .map(|c| format!("#{c}"))
23            .or_else(|| m["to"].as_str().map(|t| format!("@{t}")))
24            .unwrap_or_default();
25        println!(
26            "[{}] {} {} → {}: {}",
27            m["id"],
28            field(m, "created_at"),
29            field(m, "from"),
30            target,
31            field(m, "body"),
32        );
33    }
34    if value["truncated"].as_bool() == Some(true) {
35        println!("(truncated at limit; raise --limit to see more)");
36    }
37}
38
39fn render_task_line(t: &Value) {
40    let holder = t["claimed_by"]
41        .as_str()
42        .map(|h| format!(" ({h})"))
43        .unwrap_or_default();
44    let expired = if t["lease_expired"].as_bool() == Some(true) {
45        " [lease expired]"
46    } else {
47        ""
48    };
49    println!(
50        "{:<24} {:<8}{}{} {}",
51        field(t, "key"),
52        field(t, "status"),
53        holder,
54        expired,
55        field(t, "title"),
56    );
57}
58
59pub(super) fn render(cmd: &ClientCmd, value: &Value) -> anyhow::Result<()> {
60    match cmd {
61        ClientCmd::Whoami => {
62            println!(
63                "{} @ {} — {} unread DM(s), {} claimed task(s)",
64                field(value, "agent"),
65                field(value, "team"),
66                value["unread_direct_messages"],
67                value["open_claimed_tasks"],
68            );
69        }
70        ClientCmd::Read { .. } | ClientCmd::Search { .. } => render_messages(value),
71        ClientCmd::Send { .. } => {
72            let m = &value["message"];
73            println!("sent [{}] to {}", m["id"], value["delivered_to"]);
74        }
75        ClientCmd::Ask { .. } => {
76            if value["answered"].as_bool() == Some(true) {
77                let a = &value["answer"];
78                println!("{}: {}", field(a, "from"), field(a, "body"));
79            } else {
80                println!("(no answer yet)");
81                println!("{}", field(value, "suggestion"));
82            }
83        }
84        ClientCmd::Attach { .. } => {
85            println!(
86                "attached [{}] {} ({} bytes)",
87                value["id"],
88                field(value, "filename"),
89                value["size_bytes"]
90            );
91        }
92        ClientCmd::Download { out, .. } => {
93            use base64::Engine;
94            let data = base64::engine::general_purpose::STANDARD
95                .decode(field(value, "data_base64"))
96                .context("server returned invalid base64")?;
97            let path = out
98                .clone()
99                .unwrap_or_else(|| std::path::PathBuf::from(field(value, "filename")));
100            std::fs::write(&path, data)
101                .with_context(|| format!("cannot write {}", path.display()))?;
102            println!(
103                "wrote {} ({} bytes, {})",
104                path.display(),
105                value["size_bytes"],
106                field(value, "content_type")
107            );
108        }
109        ClientCmd::ChannelCreate { .. } => {
110            println!("channel #{} ready", field(value, "name"));
111        }
112        ClientCmd::Channels => {
113            let channels = value["channels"].as_array().cloned().unwrap_or_default();
114            if channels.is_empty() {
115                println!("(no channels yet)");
116            }
117            for c in &channels {
118                println!(
119                    "#{:<20} {:>5} msg(s)  {}",
120                    field(c, "name"),
121                    c["message_count"],
122                    field(c, "topic"),
123                );
124            }
125        }
126        ClientCmd::Agents { .. } => {
127            for a in value["agents"].as_array().cloned().unwrap_or_default() {
128                let place = match (a["repo"].as_str(), a["branch"].as_str()) {
129                    (Some(r), Some(b)) => format!(" {r}@{b}"),
130                    (Some(r), None) => format!(" {r}"),
131                    _ => String::new(),
132                };
133                println!(
134                    "{:<20} {:<8}{} {}",
135                    field(&a, "name"),
136                    field(&a, "status"),
137                    place,
138                    field(&a, "activity"),
139                );
140            }
141            println!("({} online)", value["online_count"]);
142        }
143        ClientCmd::Tasks { .. } => {
144            let tasks = value["tasks"].as_array().cloned().unwrap_or_default();
145            if tasks.is_empty() {
146                println!("(no tasks)");
147            }
148            for t in &tasks {
149                render_task_line(t);
150            }
151            println!("({} open, {} claimed)", value["open"], value["claimed"]);
152        }
153        ClientCmd::Task(TaskCmd::Show { .. }) => {
154            render_task_line(&value["task"]);
155            if let Some(desc) = value["task"]["description"].as_str() {
156                println!("  {desc}");
157            }
158            if let Some(result) = value["task"]["result"].as_str() {
159                println!("  result: {result}");
160            }
161            for e in value["history"].as_array().cloned().unwrap_or_default() {
162                println!(
163                    "  {} {} {} {}",
164                    field(&e, "created_at"),
165                    field(&e, "event"),
166                    field(&e, "agent"),
167                    field(&e, "detail"),
168                );
169            }
170        }
171        ClientCmd::Task(TaskCmd::Claim { .. } | TaskCmd::Next { .. }) => {
172            if value["claimed"].as_bool() == Some(true) {
173                print!("claimed: ");
174                render_task_line(&value["task"]);
175            } else {
176                println!(
177                    "not claimed: {}",
178                    value["reason"].as_str().unwrap_or("unknown reason")
179                );
180            }
181        }
182        ClientCmd::Task(_) => {
183            render_task_line(value);
184        }
185        ClientCmd::Notes { .. } | ClientCmd::Note(NoteCmd::Search { .. }) => {
186            let notes = value["notes"].as_array().cloned().unwrap_or_default();
187            if notes.is_empty() {
188                println!("(no notes)");
189            }
190            for n in &notes {
191                let first_line = field(n, "value").lines().next().unwrap_or("").to_owned();
192                println!("{}/{}: {}", field(n, "scope"), field(n, "key"), first_line);
193            }
194        }
195        ClientCmd::Note(NoteCmd::Get { .. }) => {
196            if value["found"].as_bool() == Some(true) {
197                let n = &value["note"];
198                println!(
199                    "{}/{} (by {}, {})",
200                    field(n, "scope"),
201                    field(n, "key"),
202                    field(n, "updated_by"),
203                    field(n, "updated_at"),
204                );
205                println!("{}", field(n, "value"));
206            } else {
207                println!("not found");
208            }
209        }
210        ClientCmd::Note(NoteCmd::Set { .. }) => {
211            println!("saved {}/{}", field(value, "scope"), field(value, "key"));
212        }
213        ClientCmd::Note(NoteCmd::Rm { .. }) => {
214            println!("{}", field(value, "detail"));
215        }
216        ClientCmd::Beat { .. } => {
217            println!(
218                "presence updated: {} {}",
219                field(value, "status"),
220                field(value, "activity"),
221            );
222        }
223        ClientCmd::Wait { .. } => {
224            if value["woke"].as_bool() == Some(true) {
225                for e in value["events"].as_array().cloned().unwrap_or_default() {
226                    println!("[{}] {}", field(&e, "kind"), field(&e, "summary"));
227                }
228            } else {
229                println!("(nothing happened)");
230            }
231            println!("{}", field(value, "suggestion"));
232        }
233        ClientCmd::Lock(LockCmd::List) => {
234            let locks = value["locks"].as_array().cloned().unwrap_or_default();
235            if locks.is_empty() {
236                println!("(no locks held)");
237            }
238            for l in &locks {
239                println!(
240                    "{:<28} {} until {}  {}",
241                    field(l, "name"),
242                    field(l, "holder"),
243                    field(l, "expires_at"),
244                    field(l, "purpose"),
245                );
246            }
247        }
248        ClientCmd::Lock(LockCmd::Acquire { .. }) => {
249            if value["acquired"].as_bool() == Some(true) {
250                let l = &value["lock"];
251                println!(
252                    "acquired {} until {}",
253                    field(l, "name"),
254                    field(l, "expires_at")
255                );
256            } else {
257                println!("not acquired: {}", field(value, "reason"));
258            }
259        }
260        ClientCmd::Lock(LockCmd::Release { .. }) => {
261            println!("{}", field(value, "detail"));
262        }
263        ClientCmd::Digest { .. } => {
264            println!("— last {}h —", value["hours"]);
265            for c in value["channels"].as_array().cloned().unwrap_or_default() {
266                println!("#{}: {} msg(s)", field(&c, "name"), c["message_count"]);
267                for m in c["last_messages"].as_array().cloned().unwrap_or_default() {
268                    println!("   {}: {}", field(&m, "from"), field(&m, "body"));
269                }
270            }
271            let moved = value["tasks_moved"].as_array().cloned().unwrap_or_default();
272            if !moved.is_empty() {
273                println!("tasks:");
274                for t in &moved {
275                    println!(
276                        "   {} → {} {}",
277                        field(t, "key"),
278                        field(t, "status"),
279                        t["claimed_by"]
280                            .as_str()
281                            .map(|s| format!("({s})"))
282                            .unwrap_or_default(),
283                    );
284                }
285            }
286            for n in value["notes_updated"]
287                .as_array()
288                .cloned()
289                .unwrap_or_default()
290            {
291                println!(
292                    "note {}/{} by {}",
293                    field(&n, "scope"),
294                    field(&n, "key"),
295                    field(&n, "updated_by")
296                );
297            }
298            println!(
299                "({} open, {} claimed, {} lock(s))",
300                value["open_tasks"],
301                value["claimed_tasks"],
302                value["active_locks"].as_array().map(Vec::len).unwrap_or(0),
303            );
304        }
305        _ => {
306            println!("{}", serde_json::to_string_pretty(value).unwrap());
307        }
308    }
309    Ok(())
310}