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