agent_tui/
presenter.rs

1use serde_json::Value;
2
3use crate::common::Colors;
4use crate::common::ValueExt;
5use crate::ipc::ClientError;
6
7/// Trait for presenting output to the user.
8///
9/// This trait abstracts the output formatting, allowing the CLI to support
10/// multiple output formats (text, JSON) without duplicating logic in handlers.
11pub trait Presenter {
12    /// Present a success result with optional warning.
13    fn present_success(&self, message: &str, warning: Option<&str>);
14
15    /// Present an error message.
16    fn present_error(&self, message: &str);
17
18    /// Present a structured value (for JSON output, shows the raw value).
19    fn present_value(&self, value: &Value);
20
21    /// Present a client error with suggestions.
22    fn present_client_error(&self, error: &ClientError);
23
24    /// Present a simple key-value pair.
25    fn present_kv(&self, key: &str, value: &str);
26
27    /// Present a session ID.
28    fn present_session_id(&self, session_id: &str, label: Option<&str>);
29
30    /// Present an element reference.
31    fn present_element_ref(&self, element_ref: &str, info: Option<&str>);
32
33    /// Present a list header.
34    fn present_list_header(&self, title: &str);
35
36    /// Present a list item.
37    fn present_list_item(&self, item: &str);
38
39    /// Present a dim/info message.
40    fn present_info(&self, message: &str);
41
42    /// Present a bold header.
43    fn present_header(&self, text: &str);
44
45    /// Present raw text without formatting.
46    fn present_raw(&self, text: &str);
47
48    /// Present a wait result (found/timeout with elapsed time).
49    fn present_wait_result(&self, result: &WaitResult);
50
51    /// Present an assertion result (passed/failed with condition).
52    fn present_assert_result(&self, result: &AssertResult);
53
54    /// Present health check information.
55    fn present_health(&self, health: &HealthResult);
56
57    /// Present cleanup operation results.
58    fn present_cleanup(&self, result: &CleanupResult);
59
60    /// Present find operation results.
61    fn present_find(&self, result: &FindResult);
62}
63
64/// Result of a wait operation.
65pub struct WaitResult {
66    pub found: bool,
67    pub elapsed_ms: u64,
68}
69
70impl WaitResult {
71    /// Parse a wait result from JSON response.
72    pub fn from_json(value: &Value) -> Self {
73        Self {
74            found: value.bool_or("found", false),
75            elapsed_ms: value.u64_or("elapsed_ms", 0),
76        }
77    }
78}
79
80/// Result of an assertion.
81pub struct AssertResult {
82    pub passed: bool,
83    pub condition: String,
84}
85
86/// Health check result.
87pub struct HealthResult {
88    pub status: String,
89    pub pid: u64,
90    pub uptime_ms: u64,
91    pub session_count: u64,
92    pub version: String,
93    pub socket_path: Option<String>,
94    pub pid_file_path: Option<String>,
95}
96
97impl HealthResult {
98    /// Parse a health result from JSON response.
99    /// The `socket_path` and `pid_file_path` are only included if `verbose` is true.
100    pub fn from_json(value: &Value, verbose: bool) -> Self {
101        use crate::ipc::socket_path;
102
103        let (socket, pid_file) = if verbose {
104            let socket = socket_path();
105            let pid_file = socket.with_extension("pid");
106            (
107                Some(socket.display().to_string()),
108                Some(pid_file.display().to_string()),
109            )
110        } else {
111            (None, None)
112        };
113
114        Self {
115            status: value.str_or("status", "unknown").to_string(),
116            pid: value.u64_or("pid", 0),
117            uptime_ms: value.u64_or("uptime_ms", 0),
118            session_count: value.u64_or("session_count", 0),
119            version: value.str_or("version", "?").to_string(),
120            socket_path: socket,
121            pid_file_path: pid_file,
122        }
123    }
124}
125
126/// Result of a cleanup operation.
127pub struct CleanupResult {
128    pub cleaned: usize,
129    pub failures: Vec<CleanupFailure>,
130}
131
132/// A single cleanup failure.
133pub struct CleanupFailure {
134    pub session_id: String,
135    pub error: String,
136}
137
138/// Result of a find operation.
139pub struct FindResult {
140    pub count: u64,
141    pub elements: Vec<ElementInfo>,
142}
143
144impl FindResult {
145    /// Parse a find result from JSON response.
146    pub fn from_json(value: &Value) -> Self {
147        let count = value.u64_or("count", 0);
148        let elements = value
149            .get("elements")
150            .and_then(|v| v.as_array())
151            .map(|arr| {
152                arr.iter()
153                    .map(|el| ElementInfo {
154                        element_ref: el.str_or("ref", "").to_string(),
155                        element_type: el.str_or("type", "").to_string(),
156                        label: el.str_or("label", "").to_string(),
157                        focused: el.bool_or("focused", false),
158                    })
159                    .collect()
160            })
161            .unwrap_or_default();
162
163        Self { count, elements }
164    }
165}
166
167/// Information about a found element.
168pub struct ElementInfo {
169    pub element_ref: String,
170    pub element_type: String,
171    pub label: String,
172    pub focused: bool,
173}
174
175/// Text presenter for human-readable output.
176pub struct TextPresenter;
177
178impl Presenter for TextPresenter {
179    fn present_success(&self, message: &str, warning: Option<&str>) {
180        println!("{} {}", Colors::success("✓"), message);
181        if let Some(w) = warning {
182            eprintln!("{} {}", Colors::dim("Warning:"), w);
183        }
184    }
185
186    fn present_error(&self, message: &str) {
187        eprintln!("{} {}", Colors::error("Error:"), message);
188    }
189
190    fn present_value(&self, value: &Value) {
191        if let Some(s) = value.as_str() {
192            println!("{}", s);
193        } else if let Some(n) = value.as_u64() {
194            println!("{}", n);
195        } else if let Some(b) = value.as_bool() {
196            println!("{}", b);
197        } else {
198            println!(
199                "{}",
200                serde_json::to_string_pretty(value).unwrap_or_default()
201            );
202        }
203    }
204
205    fn present_client_error(&self, error: &ClientError) {
206        eprintln!("{} {}", Colors::error("Error:"), error);
207        if let Some(suggestion) = error.suggestion() {
208            eprintln!("{} {}", Colors::dim("Suggestion:"), suggestion);
209        }
210        if error.is_retryable() {
211            eprintln!(
212                "{}",
213                Colors::dim("(This error may be transient - retry may succeed)")
214            );
215        }
216    }
217
218    fn present_kv(&self, key: &str, value: &str) {
219        println!("  {}: {}", key, value);
220    }
221
222    fn present_session_id(&self, session_id: &str, label: Option<&str>) {
223        if let Some(l) = label {
224            println!("{} {}", l, Colors::session_id(session_id));
225        } else {
226            println!("{}", Colors::session_id(session_id));
227        }
228    }
229
230    fn present_element_ref(&self, element_ref: &str, info: Option<&str>) {
231        if let Some(i) = info {
232            println!("{} {}", Colors::element_ref(element_ref), i);
233        } else {
234            println!("{}", Colors::element_ref(element_ref));
235        }
236    }
237
238    fn present_list_header(&self, title: &str) {
239        println!("{}", Colors::bold(title));
240    }
241
242    fn present_list_item(&self, item: &str) {
243        println!("  {}", item);
244    }
245
246    fn present_info(&self, message: &str) {
247        println!("{}", Colors::dim(message));
248    }
249
250    fn present_header(&self, text: &str) {
251        println!("{}", Colors::bold(text));
252    }
253
254    fn present_raw(&self, text: &str) {
255        println!("{}", text);
256    }
257
258    fn present_wait_result(&self, result: &WaitResult) {
259        if result.found {
260            println!("Found after {}ms", result.elapsed_ms);
261        } else {
262            eprintln!("Timeout after {}ms - not found", result.elapsed_ms);
263            std::process::exit(1);
264        }
265    }
266
267    fn present_assert_result(&self, result: &AssertResult) {
268        if result.passed {
269            println!(
270                "{} Assertion passed: {}",
271                Colors::success("✓"),
272                result.condition
273            );
274        } else {
275            eprintln!(
276                "{} Assertion failed: {}",
277                Colors::error("✗"),
278                result.condition
279            );
280            std::process::exit(1);
281        }
282    }
283
284    fn present_health(&self, health: &HealthResult) {
285        println!(
286            "{} {}",
287            Colors::bold("Daemon status:"),
288            Colors::success(&health.status)
289        );
290        println!("  PID: {}", health.pid);
291        println!("  Uptime: {}", format_uptime_ms(health.uptime_ms));
292        println!("  Sessions: {}", health.session_count);
293        println!("  Version: {}", Colors::dim(&health.version));
294
295        if let (Some(socket), Some(pid_file)) = (&health.socket_path, &health.pid_file_path) {
296            println!();
297            println!("{}", Colors::bold("Connection:"));
298            println!("  Socket: {}", socket);
299            println!("  PID file: {}", pid_file);
300        }
301    }
302
303    fn present_cleanup(&self, result: &CleanupResult) {
304        if result.cleaned > 0 {
305            println!(
306                "{} Cleaned up {} session(s)",
307                Colors::success("Done:"),
308                result.cleaned
309            );
310        } else if result.failures.is_empty() {
311            println!("{}", Colors::dim("No sessions to clean up"));
312        }
313
314        if !result.failures.is_empty() {
315            eprintln!();
316            eprintln!(
317                "{} Failed to clean up {} session(s):",
318                Colors::error("Error:"),
319                result.failures.len()
320            );
321            for failure in &result.failures {
322                eprintln!(
323                    "  {}: {}",
324                    Colors::session_id(&failure.session_id),
325                    failure.error
326                );
327            }
328        }
329    }
330
331    fn present_find(&self, result: &FindResult) {
332        if result.count == 0 {
333            println!("{}", Colors::dim("No elements found"));
334        } else {
335            println!(
336                "{} Found {} element(s):",
337                Colors::success("✓"),
338                result.count
339            );
340            for el in &result.elements {
341                let focused = if el.focused {
342                    Colors::success(" *focused*")
343                } else {
344                    String::new()
345                };
346                println!(
347                    "  {} [{}:{}]{}",
348                    Colors::element_ref(&el.element_ref),
349                    el.element_type,
350                    el.label,
351                    focused
352                );
353            }
354        }
355    }
356}
357
358/// Format milliseconds as human-readable duration.
359fn format_uptime_ms(uptime_ms: u64) -> String {
360    let secs = uptime_ms / 1000;
361    let mins = secs / 60;
362    let hours = mins / 60;
363    if hours > 0 {
364        format!("{}h {}m {}s", hours, mins % 60, secs % 60)
365    } else if mins > 0 {
366        format!("{}m {}s", mins, secs % 60)
367    } else {
368        format!("{}s", secs)
369    }
370}
371
372/// JSON presenter for machine-readable output.
373pub struct JsonPresenter;
374
375impl Presenter for JsonPresenter {
376    fn present_success(&self, message: &str, warning: Option<&str>) {
377        let mut output = serde_json::json!({
378            "success": true,
379            "message": message
380        });
381        if let Some(w) = warning {
382            output["warning"] = serde_json::json!(w);
383        }
384        println!(
385            "{}",
386            serde_json::to_string_pretty(&output).unwrap_or_default()
387        );
388    }
389
390    fn present_error(&self, message: &str) {
391        let output = serde_json::json!({
392            "success": false,
393            "error": message
394        });
395        eprintln!(
396            "{}",
397            serde_json::to_string_pretty(&output).unwrap_or_default()
398        );
399    }
400
401    fn present_value(&self, value: &Value) {
402        println!(
403            "{}",
404            serde_json::to_string_pretty(value).unwrap_or_default()
405        );
406    }
407
408    fn present_client_error(&self, error: &ClientError) {
409        eprintln!("{}", error.to_json());
410    }
411
412    fn present_kv(&self, key: &str, value: &str) {
413        let output = serde_json::json!({ key: value });
414        println!(
415            "{}",
416            serde_json::to_string_pretty(&output).unwrap_or_default()
417        );
418    }
419
420    fn present_session_id(&self, session_id: &str, label: Option<&str>) {
421        let output = if let Some(l) = label {
422            serde_json::json!({ "label": l, "session_id": session_id })
423        } else {
424            serde_json::json!({ "session_id": session_id })
425        };
426        println!(
427            "{}",
428            serde_json::to_string_pretty(&output).unwrap_or_default()
429        );
430    }
431
432    fn present_element_ref(&self, element_ref: &str, info: Option<&str>) {
433        let output = if let Some(i) = info {
434            serde_json::json!({ "ref": element_ref, "info": i })
435        } else {
436            serde_json::json!({ "ref": element_ref })
437        };
438        println!(
439            "{}",
440            serde_json::to_string_pretty(&output).unwrap_or_default()
441        );
442    }
443
444    fn present_list_header(&self, _title: &str) {
445        // No-op for JSON - the structure conveys the meaning
446    }
447
448    fn present_list_item(&self, item: &str) {
449        // In JSON mode, we'd typically collect items and output as array
450        // For simple cases, output each item
451        println!("\"{}\"", item);
452    }
453
454    fn present_info(&self, message: &str) {
455        let output = serde_json::json!({ "info": message });
456        println!(
457            "{}",
458            serde_json::to_string_pretty(&output).unwrap_or_default()
459        );
460    }
461
462    fn present_header(&self, _text: &str) {
463        // No-op for JSON
464    }
465
466    fn present_raw(&self, text: &str) {
467        // For JSON, wrap in a structure
468        let output = serde_json::json!({ "output": text });
469        println!(
470            "{}",
471            serde_json::to_string_pretty(&output).unwrap_or_default()
472        );
473    }
474
475    fn present_wait_result(&self, result: &WaitResult) {
476        let output = serde_json::json!({
477            "found": result.found,
478            "elapsed_ms": result.elapsed_ms
479        });
480        println!(
481            "{}",
482            serde_json::to_string_pretty(&output).unwrap_or_default()
483        );
484    }
485
486    fn present_assert_result(&self, result: &AssertResult) {
487        let output = serde_json::json!({
488            "condition": result.condition,
489            "passed": result.passed
490        });
491        println!(
492            "{}",
493            serde_json::to_string_pretty(&output).unwrap_or_default()
494        );
495    }
496
497    fn present_health(&self, health: &HealthResult) {
498        let mut output = serde_json::json!({
499            "status": health.status,
500            "pid": health.pid,
501            "uptime_ms": health.uptime_ms,
502            "session_count": health.session_count,
503            "version": health.version
504        });
505        if let Some(socket) = &health.socket_path {
506            output["socket_path"] = serde_json::json!(socket);
507        }
508        if let Some(pid_file) = &health.pid_file_path {
509            output["pid_file_path"] = serde_json::json!(pid_file);
510        }
511        println!(
512            "{}",
513            serde_json::to_string_pretty(&output).unwrap_or_default()
514        );
515    }
516
517    fn present_cleanup(&self, result: &CleanupResult) {
518        let failures: Vec<_> = result
519            .failures
520            .iter()
521            .map(|f| {
522                serde_json::json!({
523                    "session": f.session_id,
524                    "error": f.error
525                })
526            })
527            .collect();
528        let output = serde_json::json!({
529            "sessions_cleaned": result.cleaned,
530            "sessions_failed": result.failures.len(),
531            "failures": failures
532        });
533        println!(
534            "{}",
535            serde_json::to_string_pretty(&output).unwrap_or_default()
536        );
537    }
538
539    fn present_find(&self, result: &FindResult) {
540        let elements: Vec<_> = result
541            .elements
542            .iter()
543            .map(|el| {
544                serde_json::json!({
545                    "ref": el.element_ref,
546                    "type": el.element_type,
547                    "label": el.label,
548                    "focused": el.focused
549                })
550            })
551            .collect();
552        let output = serde_json::json!({
553            "count": result.count,
554            "elements": elements
555        });
556        println!(
557            "{}",
558            serde_json::to_string_pretty(&output).unwrap_or_default()
559        );
560    }
561}
562
563/// Create a presenter based on the output format.
564pub fn create_presenter(format: &crate::commands::OutputFormat) -> Box<dyn Presenter> {
565    match format {
566        crate::commands::OutputFormat::Json => Box::new(JsonPresenter),
567        crate::commands::OutputFormat::Text => Box::new(TextPresenter),
568    }
569}
570
571/// Helper struct for presenting spawn results.
572pub struct SpawnResult {
573    pub session_id: String,
574    pub pid: u32,
575}
576
577impl SpawnResult {
578    pub fn present(&self, presenter: &dyn Presenter) {
579        presenter.present_session_id(&self.session_id, Some(&Colors::success("Session started:")));
580        presenter.present_kv("PID", &self.pid.to_string());
581    }
582
583    pub fn to_json(&self) -> Value {
584        serde_json::json!({
585            "session_id": self.session_id,
586            "pid": self.pid
587        })
588    }
589}
590
591/// Helper struct for presenting session list results.
592pub struct SessionListResult {
593    pub sessions: Vec<SessionListItem>,
594    pub active_session: Option<String>,
595}
596
597pub struct SessionListItem {
598    pub id: String,
599    pub command: String,
600    pub pid: u64,
601    pub running: bool,
602    pub cols: u64,
603    pub rows: u64,
604}
605
606impl SessionListResult {
607    pub fn present(&self, presenter: &dyn Presenter) {
608        if self.sessions.is_empty() {
609            presenter.present_info("No active sessions");
610        } else {
611            presenter.present_list_header("Active sessions:");
612            for session in &self.sessions {
613                let is_active = self.active_session.as_ref() == Some(&session.id);
614                let active_marker = if is_active {
615                    Colors::success(" (active)")
616                } else {
617                    String::new()
618                };
619                let status = if session.running {
620                    Colors::success("running")
621                } else {
622                    Colors::error("stopped")
623                };
624                let item = format!(
625                    "{} - {} [{}] {}x{} pid:{}{}",
626                    Colors::session_id(&session.id),
627                    session.command,
628                    status,
629                    session.cols,
630                    session.rows,
631                    session.pid,
632                    active_marker
633                );
634                presenter.present_list_item(&item);
635            }
636        }
637    }
638}
639
640/// View wrapper for element JSON values.
641///
642/// Provides convenient access to element properties from JSON responses.
643pub struct ElementView<'a>(pub &'a Value);
644
645impl ElementView<'_> {
646    /// Get the element reference (e.g., "@btn1").
647    pub fn ref_str(&self) -> &str {
648        self.0.str_or("ref", "")
649    }
650
651    /// Get the element type (e.g., "button", "input").
652    pub fn el_type(&self) -> &str {
653        self.0.str_or("type", "")
654    }
655
656    /// Get the element label.
657    pub fn label(&self) -> &str {
658        self.0.str_or("label", "")
659    }
660
661    /// Check if the element is focused.
662    pub fn focused(&self) -> bool {
663        self.0.bool_or("focused", false)
664    }
665
666    /// Check if the element is selected.
667    pub fn selected(&self) -> bool {
668        self.0.bool_or("selected", false)
669    }
670
671    /// Get the element's value, if any.
672    pub fn value(&self) -> Option<&str> {
673        self.0.get("value").and_then(|v| v.as_str())
674    }
675
676    /// Get the element's position as (row, col).
677    pub fn position(&self) -> (u64, u64) {
678        let pos = self.0.get("position");
679        let row = pos
680            .and_then(|p| p.get("row"))
681            .and_then(|v| v.as_u64())
682            .unwrap_or(0);
683        let col = pos
684            .and_then(|p| p.get("col"))
685            .and_then(|v| v.as_u64())
686            .unwrap_or(0);
687        (row, col)
688    }
689
690    /// Get the focused indicator string (colored for text output).
691    pub fn focused_indicator(&self) -> String {
692        if self.focused() {
693            Colors::success(" *focused*")
694        } else {
695            String::new()
696        }
697    }
698
699    /// Get the selected indicator string (colored for text output).
700    pub fn selected_indicator(&self) -> String {
701        if self.selected() {
702            Colors::info(" *selected*")
703        } else {
704            String::new()
705        }
706    }
707
708    /// Get the label suffix (e.g., ":Submit" or empty string if no label).
709    pub fn label_suffix(&self) -> String {
710        if self.label().is_empty() {
711            String::new()
712        } else {
713            format!(":{}", self.label())
714        }
715    }
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721
722    #[test]
723    fn test_text_presenter_success() {
724        let presenter = TextPresenter;
725        // Just verify it doesn't panic
726        presenter.present_success("Test message", None);
727        presenter.present_success("Test with warning", Some("Warning text"));
728    }
729
730    #[test]
731    fn test_json_presenter_success() {
732        let presenter = JsonPresenter;
733        // Just verify it doesn't panic
734        presenter.present_success("Test message", None);
735        presenter.present_success("Test with warning", Some("Warning text"));
736    }
737
738    #[test]
739    fn test_text_presenter_error() {
740        let presenter = TextPresenter;
741        presenter.present_error("Test error");
742    }
743
744    #[test]
745    fn test_json_presenter_error() {
746        let presenter = JsonPresenter;
747        presenter.present_error("Test error");
748    }
749
750    #[test]
751    fn test_spawn_result_to_json() {
752        let result = SpawnResult {
753            session_id: "abc123".to_string(),
754            pid: 1234,
755        };
756        let json = result.to_json();
757        assert_eq!(json["session_id"], "abc123");
758        assert_eq!(json["pid"], 1234);
759    }
760
761    #[test]
762    fn test_wait_result_struct() {
763        let result = WaitResult {
764            found: true,
765            elapsed_ms: 150,
766        };
767        assert!(result.found);
768        assert_eq!(result.elapsed_ms, 150);
769    }
770
771    #[test]
772    fn test_assert_result_struct() {
773        let result = AssertResult {
774            passed: true,
775            condition: "text:hello".to_string(),
776        };
777        assert!(result.passed);
778        assert_eq!(result.condition, "text:hello");
779    }
780
781    #[test]
782    fn test_health_result_struct() {
783        let result = HealthResult {
784            status: "healthy".to_string(),
785            pid: 1234,
786            uptime_ms: 60000,
787            session_count: 5,
788            version: "0.3.0".to_string(),
789            socket_path: Some("/tmp/agent-tui.sock".to_string()),
790            pid_file_path: None,
791        };
792        assert_eq!(result.status, "healthy");
793        assert_eq!(result.session_count, 5);
794    }
795
796    #[test]
797    fn test_cleanup_result_struct() {
798        let result = CleanupResult {
799            cleaned: 3,
800            failures: vec![CleanupFailure {
801                session_id: "sess1".to_string(),
802                error: "session not found".to_string(),
803            }],
804        };
805        assert_eq!(result.cleaned, 3);
806        assert_eq!(result.failures.len(), 1);
807    }
808
809    #[test]
810    fn test_find_result_struct() {
811        let result = FindResult {
812            count: 2,
813            elements: vec![
814                ElementInfo {
815                    element_ref: "@btn1".to_string(),
816                    element_type: "button".to_string(),
817                    label: "Submit".to_string(),
818                    focused: true,
819                },
820                ElementInfo {
821                    element_ref: "@btn2".to_string(),
822                    element_type: "button".to_string(),
823                    label: "Cancel".to_string(),
824                    focused: false,
825                },
826            ],
827        };
828        assert_eq!(result.count, 2);
829        assert_eq!(result.elements.len(), 2);
830        assert!(result.elements[0].focused);
831    }
832
833    #[test]
834    fn test_json_presenter_wait_result() {
835        let presenter = JsonPresenter;
836        let result = WaitResult {
837            found: true,
838            elapsed_ms: 100,
839        };
840        // Just verify it doesn't panic
841        presenter.present_wait_result(&result);
842    }
843
844    #[test]
845    fn test_json_presenter_assert_result() {
846        let presenter = JsonPresenter;
847        let result = AssertResult {
848            passed: true,
849            condition: "element:@btn1".to_string(),
850        };
851        // Just verify it doesn't panic
852        presenter.present_assert_result(&result);
853    }
854
855    #[test]
856    fn test_json_presenter_health() {
857        let presenter = JsonPresenter;
858        let health = HealthResult {
859            status: "healthy".to_string(),
860            pid: 1234,
861            uptime_ms: 60000,
862            session_count: 2,
863            version: "0.3.0".to_string(),
864            socket_path: None,
865            pid_file_path: None,
866        };
867        // Just verify it doesn't panic
868        presenter.present_health(&health);
869    }
870
871    #[test]
872    fn test_json_presenter_cleanup() {
873        let presenter = JsonPresenter;
874        let result = CleanupResult {
875            cleaned: 2,
876            failures: vec![],
877        };
878        // Just verify it doesn't panic
879        presenter.present_cleanup(&result);
880    }
881
882    #[test]
883    fn test_json_presenter_find() {
884        let presenter = JsonPresenter;
885        let result = FindResult {
886            count: 1,
887            elements: vec![ElementInfo {
888                element_ref: "@inp1".to_string(),
889                element_type: "input".to_string(),
890                label: "Email".to_string(),
891                focused: false,
892            }],
893        };
894        // Just verify it doesn't panic
895        presenter.present_find(&result);
896    }
897
898    #[test]
899    fn test_format_uptime_ms_seconds() {
900        assert_eq!(format_uptime_ms(5000), "5s");
901        assert_eq!(format_uptime_ms(45000), "45s");
902    }
903
904    #[test]
905    fn test_format_uptime_ms_minutes() {
906        assert_eq!(format_uptime_ms(60000), "1m 0s");
907        assert_eq!(format_uptime_ms(90000), "1m 30s");
908        assert_eq!(format_uptime_ms(300000), "5m 0s");
909    }
910
911    #[test]
912    fn test_format_uptime_ms_hours() {
913        assert_eq!(format_uptime_ms(3600000), "1h 0m 0s");
914        assert_eq!(format_uptime_ms(5400000), "1h 30m 0s");
915        assert_eq!(format_uptime_ms(7265000), "2h 1m 5s");
916    }
917}