Skip to main content

agent_tui_adapters/adapters/
presenter.rs

1#![expect(clippy::print_stdout, reason = "CLI output is emitted here")]
2#![expect(clippy::print_stderr, reason = "CLI output is emitted here")]
3
4//! CLI output presenter.
5
6use crate::adapters::RpcValue;
7use crate::common::Colors;
8use clap::ValueEnum;
9
10/// Output format for CLI commands
11#[derive(Clone, Copy, Debug, ValueEnum, Default, PartialEq)]
12pub enum OutputFormat {
13    #[default]
14    Text,
15    Json,
16}
17
18pub trait Presenter {
19    fn present_success(&self, message: &str, warning: Option<&str>);
20
21    fn present_error(&self, message: &str);
22
23    fn present_value(&self, value: &RpcValue);
24
25    fn present_client_error(&self, error: &ClientErrorView);
26
27    fn present_kv(&self, key: &str, value: &str);
28
29    fn present_session_id(&self, session_id: &str, label: Option<&str>);
30
31    fn present_list_header(&self, title: &str);
32
33    fn present_list_item(&self, item: &str);
34
35    fn present_info(&self, message: &str);
36
37    fn present_header(&self, text: &str);
38
39    fn present_raw(&self, text: &str);
40
41    fn present_wait_result(&self, result: &WaitResult);
42
43    fn present_assert_result(&self, result: &AssertResult);
44
45    fn present_cleanup(&self, result: &CleanupResult);
46}
47
48#[derive(Clone, Debug)]
49pub struct ClientErrorView {
50    pub message: String,
51    pub suggestion: Option<String>,
52    pub retryable: bool,
53    pub json: Option<String>,
54}
55
56pub struct WaitResult {
57    pub found: bool,
58    pub elapsed_ms: u64,
59}
60
61impl WaitResult {
62    pub fn from_json(value: &RpcValue) -> Self {
63        Self {
64            found: value.bool_or("found", false),
65            elapsed_ms: value.u64_or("elapsed_ms", 0),
66        }
67    }
68}
69
70pub struct AssertResult {
71    pub passed: bool,
72    pub condition: String,
73}
74
75pub struct CleanupResult {
76    pub cleaned: usize,
77    pub failures: Vec<CleanupFailure>,
78}
79
80pub struct CleanupFailure {
81    pub session_id: String,
82    pub error: String,
83}
84
85pub struct TextPresenter;
86const PROGRAM_NAME: &str = "agent-tui";
87
88impl Presenter for TextPresenter {
89    fn present_success(&self, message: &str, warning: Option<&str>) {
90        println!("{} {}", Colors::success("✓"), message);
91        if let Some(w) = warning {
92            eprintln!("{} {}", Colors::dim("Warning:"), w);
93        }
94    }
95
96    fn present_error(&self, message: &str) {
97        eprintln!("{}: {} {}", PROGRAM_NAME, Colors::error("Error:"), message);
98    }
99
100    fn present_value(&self, value: &RpcValue) {
101        let value_ref = value.as_ref();
102        if let Some(s) = value_ref.as_str() {
103            println!("{s}");
104        } else if let Some(n) = value_ref.as_u64() {
105            println!("{n}");
106        } else if let Some(b) = value_ref.as_bool() {
107            println!("{b}");
108        } else {
109            println!("{}", value.to_pretty_json());
110        }
111    }
112
113    fn present_client_error(&self, error: &ClientErrorView) {
114        eprintln!(
115            "{}: {} {}",
116            PROGRAM_NAME,
117            Colors::error("Error:"),
118            error.message
119        );
120        if let Some(suggestion) = error.suggestion.as_deref() {
121            eprintln!("{} {}", Colors::dim("Suggestion:"), suggestion);
122        }
123        if error.retryable {
124            eprintln!(
125                "{}",
126                Colors::dim("(This error may be transient - retry may succeed)")
127            );
128        }
129    }
130
131    fn present_kv(&self, key: &str, value: &str) {
132        println!("  {key}: {value}");
133    }
134
135    fn present_session_id(&self, session_id: &str, label: Option<&str>) {
136        if let Some(l) = label {
137            println!("{} {}", l, Colors::session_id(session_id));
138        } else {
139            println!("{}", Colors::session_id(session_id));
140        }
141    }
142
143    fn present_list_header(&self, title: &str) {
144        println!("{}", Colors::bold(title));
145    }
146
147    fn present_list_item(&self, item: &str) {
148        println!("  {item}");
149    }
150
151    fn present_info(&self, message: &str) {
152        println!("{}", Colors::dim(message));
153    }
154
155    fn present_header(&self, text: &str) {
156        println!("{}", Colors::bold(text));
157    }
158
159    fn present_raw(&self, text: &str) {
160        println!("{text}");
161    }
162
163    fn present_wait_result(&self, result: &WaitResult) {
164        if result.found {
165            println!("Found after {}ms", result.elapsed_ms);
166        } else {
167            println!("Timeout after {}ms - not found", result.elapsed_ms);
168        }
169    }
170
171    fn present_assert_result(&self, result: &AssertResult) {
172        if result.passed {
173            println!(
174                "{} Assertion passed: {}",
175                Colors::success("✓"),
176                result.condition
177            );
178        } else {
179            eprintln!(
180                "{}: {} Assertion failed: {}",
181                PROGRAM_NAME,
182                Colors::error("Error:"),
183                result.condition
184            );
185        }
186    }
187
188    fn present_cleanup(&self, result: &CleanupResult) {
189        if result.cleaned > 0 {
190            println!(
191                "{} Cleaned up {} session(s)",
192                Colors::success("Done:"),
193                result.cleaned
194            );
195        } else if result.failures.is_empty() {
196            println!("{}", Colors::dim("No sessions to clean up"));
197        }
198
199        if !result.failures.is_empty() {
200            eprintln!();
201            eprintln!(
202                "{}: {} Failed to clean up {} session(s):",
203                PROGRAM_NAME,
204                Colors::error("Error:"),
205                result.failures.len()
206            );
207            for failure in &result.failures {
208                eprintln!(
209                    "  {}: {}",
210                    Colors::session_id(&failure.session_id),
211                    failure.error
212                );
213            }
214        }
215    }
216}
217
218pub struct JsonPresenter;
219
220impl Presenter for JsonPresenter {
221    fn present_success(&self, message: &str, warning: Option<&str>) {
222        let mut output = serde_json::json!({
223            "success": true,
224            "message": message
225        });
226        if let Some(w) = warning {
227            output["warning"] = serde_json::json!(w);
228        }
229        println!(
230            "{}",
231            serde_json::to_string_pretty(&output).unwrap_or_default()
232        );
233    }
234
235    fn present_error(&self, message: &str) {
236        let output = serde_json::json!({
237            "success": false,
238            "error": message
239        });
240        eprintln!(
241            "{}",
242            serde_json::to_string_pretty(&output).unwrap_or_default()
243        );
244    }
245
246    fn present_value(&self, value: &RpcValue) {
247        println!("{}", value.to_pretty_json());
248    }
249
250    fn present_client_error(&self, error: &ClientErrorView) {
251        if let Some(json) = error.json.as_deref() {
252            eprintln!("{json}");
253            return;
254        }
255
256        let mut output = serde_json::json!({
257            "success": false,
258            "error": error.message,
259            "retryable": error.retryable,
260        });
261        if let Some(suggestion) = error.suggestion.as_ref() {
262            output["suggestion"] = serde_json::json!(suggestion);
263        }
264        eprintln!(
265            "{}",
266            serde_json::to_string_pretty(&output).unwrap_or_default()
267        );
268    }
269
270    fn present_kv(&self, key: &str, value: &str) {
271        let output = serde_json::json!({ key: value });
272        println!(
273            "{}",
274            serde_json::to_string_pretty(&output).unwrap_or_default()
275        );
276    }
277
278    fn present_session_id(&self, session_id: &str, label: Option<&str>) {
279        let output = if let Some(l) = label {
280            serde_json::json!({ "label": l, "session_id": session_id })
281        } else {
282            serde_json::json!({ "session_id": session_id })
283        };
284        println!(
285            "{}",
286            serde_json::to_string_pretty(&output).unwrap_or_default()
287        );
288    }
289
290    fn present_list_header(&self, _title: &str) {}
291
292    fn present_list_item(&self, item: &str) {
293        println!("\"{item}\"");
294    }
295
296    fn present_info(&self, message: &str) {
297        let output = serde_json::json!({ "info": message });
298        println!(
299            "{}",
300            serde_json::to_string_pretty(&output).unwrap_or_default()
301        );
302    }
303
304    fn present_header(&self, _text: &str) {}
305
306    fn present_raw(&self, text: &str) {
307        let output = serde_json::json!({ "output": text });
308        println!(
309            "{}",
310            serde_json::to_string_pretty(&output).unwrap_or_default()
311        );
312    }
313
314    fn present_wait_result(&self, result: &WaitResult) {
315        let output = serde_json::json!({
316            "found": result.found,
317            "elapsed_ms": result.elapsed_ms
318        });
319        println!(
320            "{}",
321            serde_json::to_string_pretty(&output).unwrap_or_default()
322        );
323    }
324
325    fn present_assert_result(&self, result: &AssertResult) {
326        let output = serde_json::json!({
327            "condition": result.condition,
328            "passed": result.passed
329        });
330        println!(
331            "{}",
332            serde_json::to_string_pretty(&output).unwrap_or_default()
333        );
334    }
335
336    fn present_cleanup(&self, result: &CleanupResult) {
337        let failures: Vec<_> = result
338            .failures
339            .iter()
340            .map(|f| {
341                serde_json::json!({
342                    "session": f.session_id,
343                    "error": f.error
344                })
345            })
346            .collect();
347        let output = serde_json::json!({
348            "sessions_cleaned": result.cleaned,
349            "sessions_failed": result.failures.len(),
350            "failures": failures
351        });
352        println!(
353            "{}",
354            serde_json::to_string_pretty(&output).unwrap_or_default()
355        );
356    }
357}
358
359pub fn create_presenter(format: &OutputFormat) -> Box<dyn Presenter> {
360    match format {
361        OutputFormat::Json => Box::new(JsonPresenter),
362        OutputFormat::Text => Box::new(TextPresenter),
363    }
364}
365
366pub struct SpawnResult {
367    pub session_id: String,
368    pub pid: u32,
369}
370
371impl SpawnResult {
372    pub fn present(&self, presenter: &dyn Presenter) {
373        presenter.present_session_id(&self.session_id, Some(&Colors::success("Session started:")));
374        presenter.present_kv("PID", &self.pid.to_string());
375    }
376
377    pub fn to_json(&self) -> RpcValue {
378        RpcValue::new(serde_json::json!({
379            "session_id": self.session_id,
380            "pid": self.pid
381        }))
382    }
383}
384
385pub struct SessionListResult {
386    pub sessions: Vec<SessionListItem>,
387    pub active_session: Option<String>,
388}
389
390pub struct SessionListItem {
391    pub id: String,
392    pub command: String,
393    pub pid: u64,
394    pub running: bool,
395    pub cols: u64,
396    pub rows: u64,
397}
398
399impl SessionListResult {
400    pub fn present(&self, presenter: &dyn Presenter) {
401        if self.sessions.is_empty() {
402            presenter.present_info("No active sessions");
403        } else {
404            presenter.present_list_header("Active sessions:");
405            for session in &self.sessions {
406                let is_active = self.active_session.as_ref() == Some(&session.id);
407                let active_marker = if is_active {
408                    Colors::success(" (active)")
409                } else {
410                    String::new()
411                };
412                let status = if session.running {
413                    Colors::success("running")
414                } else {
415                    Colors::error("stopped")
416                };
417                let item = format!(
418                    "{} - {} [{}] {}x{} pid:{}{}",
419                    Colors::session_id(&session.id),
420                    session.command,
421                    status,
422                    session.cols,
423                    session.rows,
424                    session.pid,
425                    active_marker
426                );
427                presenter.present_list_item(&item);
428            }
429        }
430    }
431}
432
433#[cfg(test)]
434#[path = "presenter_tests.rs"]
435mod tests;