1pub mod agent_health;
2pub mod context;
3pub mod dashboard;
4pub mod discover;
5pub mod market_status;
6pub mod menu;
7pub mod rules_view;
8pub mod tui_render;
9pub mod watch;
10
11use console::Style;
12use unicode_width::UnicodeWidthStr;
13
14pub fn terminal_width() -> usize {
16 console::Term::stdout().size().1.max(80) as usize
17}
18
19pub fn rule(title: &str) -> String {
21 let width = terminal_width().min(120);
22 let plain_len = UnicodeWidthStr::width(title);
23 let pad = width.saturating_sub(plain_len + 4);
24 let left = pad / 2;
25 let right = pad - left;
26 format!(
27 "{}{}{}",
28 "─".repeat(left),
29 Style::new().bold().apply_to(format!(" {title} ")),
30 "─".repeat(right)
31 )
32}
33
34pub fn bar(ratio: f64, width: usize) -> String {
36 let ratio = ratio.clamp(0.0, 1.0);
37 let filled = (ratio * width as f64).round() as usize;
38 let empty = width.saturating_sub(filled);
39 format!(
40 "{}{}",
41 Style::new().green().apply_to("█".repeat(filled)),
42 Style::new().dim().apply_to("░".repeat(empty))
43 )
44}
45
46pub fn status_dot(running: bool) -> String {
47 if running {
48 Style::new().green().apply_to("●").to_string()
49 } else {
50 Style::new().red().apply_to("○").to_string()
51 }
52}
53
54pub fn clock_dot() -> String {
55 Style::new().dim().apply_to("◷").to_string()
56}
57
58pub fn panel_width_for(title: &str, lines: &[String], max_width: usize) -> usize {
60 let title_plain = format!(" {title} ");
61 let title_need = UnicodeWidthStr::width(title_plain.as_str()) + 2;
62 let content_need = lines
63 .iter()
64 .map(|l| strip_ansi_width(l) + 4)
65 .max()
66 .unwrap_or(0);
67 title_need.max(content_need).clamp(28, max_width)
68}
69
70pub fn panel(title: &str, lines: &[String], width: usize) -> String {
72 let width = width.max(28);
73 let border_inner = width.saturating_sub(2);
74 let title_plain = format!(" {title} ");
75 let title_w = UnicodeWidthStr::width(title_plain.as_str());
76 let dash_total = border_inner.saturating_sub(title_w);
77 let dash_left = dash_total / 2;
78 let dash_right = dash_total - dash_left;
79
80 let mut out = String::new();
81 out.push('╭');
82 out.push_str(&"─".repeat(dash_left));
83 out.push_str(&Style::new().bold().apply_to(&title_plain).to_string());
84 out.push_str(&"─".repeat(dash_right));
85 out.push_str("╮\n");
86
87 for line in lines {
88 let visible = strip_ansi_width(line);
89 let pad = width.saturating_sub(visible + 4);
90 out.push('│');
91 out.push(' ');
92 out.push_str(line);
93 out.push_str(&" ".repeat(pad));
94 out.push(' ');
95 out.push_str("│\n");
96 }
97
98 out.push('╰');
99 out.push_str(&"─".repeat(border_inner));
100 out.push('╯');
101 out
102}
103
104pub fn panel_fit(title: &str, lines: &[String], max_width: usize) -> String {
106 let width = panel_width_for(title, lines, max_width);
107 panel(title, lines, width)
108}
109
110pub fn kv_line(key: &str, value: &str, key_width: usize) -> String {
112 format!(" {key:key_width$} {value}", key_width = key_width)
113}
114
115pub fn two_column(left: String, right: String, total_width: usize) -> String {
117 let gap = 2;
118 let col_w = (total_width.saturating_sub(gap)) / 2;
119 let left_lines: Vec<&str> = left.lines().collect();
120 let right_lines: Vec<&str> = right.lines().collect();
121 let rows = left_lines.len().max(right_lines.len());
122
123 let mut out = String::new();
124 for i in 0..rows {
125 let l = left_lines.get(i).copied().unwrap_or("");
126 let r = right_lines.get(i).copied().unwrap_or("");
127 let l_vis = strip_ansi_width(l);
128 let pad = col_w.saturating_sub(l_vis);
129 out.push_str(l);
130 out.push_str(&" ".repeat(pad));
131 out.push_str(&" ".repeat(gap));
132 out.push_str(r);
133 out.push('\n');
134 }
135 out.trim_end().to_string()
136}
137
138pub fn ago_secs(secs: i64) -> String {
139 if secs < 0 {
140 return "just now".into();
141 }
142 if secs < 60 {
143 return format!("{secs}s ago");
144 }
145 if secs < 3600 {
146 return format!("{}m ago", secs / 60);
147 }
148 if secs < 86_400 {
149 return format!("{}h ago", secs / 3600);
150 }
151 format!("{}d ago", secs / 86_400)
152}
153
154pub fn format_duration_secs(secs: u64) -> String {
155 if secs < 60 {
156 format!("{secs}s")
157 } else if secs < 3600 {
158 format!("{}m", secs / 60)
159 } else {
160 format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
161 }
162}
163
164fn strip_ansi_width(s: &str) -> usize {
165 let stripped = console::strip_ansi_codes(s);
166 UnicodeWidthStr::width(stripped.as_ref())
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn bar_clamps() {
175 assert!(bar(1.5, 8).contains('█'));
176 assert!(bar(0.0, 8).contains('░'));
177 }
178
179 #[test]
180 fn panel_has_corners() {
181 let p = panel("Test", &["line".into()], 40);
182 assert!(p.starts_with('╭'));
183 assert!(p.contains('╯'));
184 }
185
186 #[test]
187 fn panel_lines_match_border_width() {
188 let lines = vec![" tick interval 2m (120)".into()];
189 let p = panel_fit("Schedule", &lines, 120);
190 let border_len = p.lines().next().unwrap().chars().count();
191 for line in p.lines().skip(1) {
192 if line.starts_with('│') {
193 assert_eq!(
194 line.chars().count(),
195 border_len,
196 "mismatched line width: {line}"
197 );
198 }
199 }
200 }
201
202 #[test]
203 fn panel_fit_shrinks_to_content() {
204 let lines = vec![" short".into()];
205 let w = panel_width_for("Accounts", &lines, 120);
206 assert!(w < 80, "expected compact panel, got width {w}");
207 }
208}