agent_tui_adapters/adapters/
presenter.rs1#![expect(clippy::print_stdout, reason = "CLI output is emitted here")]
2#![expect(clippy::print_stderr, reason = "CLI output is emitted here")]
3
4use crate::adapters::RpcValue;
7use crate::common::Colors;
8use clap::ValueEnum;
9
10#[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;