1use crate::api::types::{Agent, DmRequest, Post, SearchResult, Submolt};
2use chrono::{DateTime, Utc};
3use colored::*;
4use terminal_size::{Width, terminal_size};
5
6fn get_term_width() -> usize {
7 if let Some(width) = std::env::var("COLUMNS")
8 .ok()
9 .and_then(|c| c.parse::<usize>().ok())
10 {
11 return width.saturating_sub(2).max(40);
12 }
13
14 if let Some((Width(w), _)) = terminal_size() {
15 (w as usize).saturating_sub(2).max(40)
16 } else {
17 80
18 }
19}
20
21fn relative_time(timestamp: &str) -> String {
22 if let Ok(dt) = DateTime::parse_from_rfc3339(timestamp) {
23 let now = Utc::now();
24 let diff = now.signed_duration_since(dt);
25
26 if diff.num_seconds() < 60 {
27 "just now".to_string()
28 } else if diff.num_minutes() < 60 {
29 format!("{}m ago", diff.num_minutes())
30 } else if diff.num_hours() < 24 {
31 format!("{}h ago", diff.num_hours())
32 } else if diff.num_days() < 7 {
33 format!("{}d ago", diff.num_days())
34 } else {
35 dt.format("%Y-%m-%d").to_string()
36 }
37 } else {
38 timestamp.to_string()
39 }
40}
41
42pub fn success(msg: &str) {
43 println!("{} {}", "✅".green(), msg.bright_green());
44}
45
46pub fn error(msg: &str) {
47 eprintln!("{} {}", "❌".red().bold(), msg.bright_red());
48}
49
50pub fn info(msg: &str) {
51 println!("{} {}", "ℹ️ ".cyan(), msg.bright_cyan());
52}
53
54pub fn warn(msg: &str) {
55 println!("{} {}", "⚠️ ".yellow(), msg.bright_yellow());
56}
57
58pub fn display_post(post: &Post, index: Option<usize>) {
59 let width = get_term_width();
60 let inner_width = width.saturating_sub(4);
61
62 println!(
63 "{}",
64 format!("╭{}╮", "─".repeat(width.saturating_sub(2))).dimmed()
65 );
66
67 let prefix = if let Some(i) = index {
68 format!("#{:<2} ", i).bright_white().bold()
69 } else {
70 "".normal()
71 };
72
73 let title_space = inner_width.saturating_sub(if index.is_some() { 4 } else { 0 });
74
75 let title = if post.title.chars().count() > title_space {
76 let t: String = post
77 .title
78 .chars()
79 .take(title_space.saturating_sub(3))
80 .collect();
81 format!("{}...", t)
82 } else {
83 post.title.clone()
84 };
85
86 let padding =
87 inner_width.saturating_sub(title.chars().count() + if index.is_some() { 4 } else { 0 });
88 println!(
89 "│ {}{} {:>p$} │",
90 prefix,
91 title.bright_cyan().bold(),
92 "",
93 p = padding
94 );
95
96 println!(
97 "{}",
98 format!("├{}┤", "─".repeat(width.saturating_sub(2))).dimmed()
99 );
100
101 let karma = post.author.karma.unwrap_or(0);
102 let author = post.author.name.yellow();
103
104 let sub_name = if let Some(s) = &post.submolt {
106 &s.name
107 } else if let Some(s) = &post.submolt_name {
108 s
109 } else {
110 "unknown"
111 };
112
113 let sub = sub_name.green();
114 let stats = format!(
115 "⬆ {} ⬇ {} 💬 {} ✨ {}",
116 post.upvotes,
117 post.downvotes,
118 post.comment_count.unwrap_or(0),
119 karma
120 );
121
122 let left_meta = format!("👤 {} m/{} ", author, sub);
123 let left_len = post.author.name.chars().count() + sub_name.chars().count() + 8;
124 let stats_len = stats.chars().count();
125
126 let meta_padding = inner_width.saturating_sub(left_len + stats_len);
127
128 println!(
129 "│ {}{:>p$} │",
130 left_meta,
131 stats.dimmed(),
132 p = meta_padding + stats_len
133 );
134
135 println!("│ {:>w$} │", "", w = inner_width);
136 if let Some(content) = &post.content {
137 let is_listing = index.is_some();
138 let max_lines = if is_listing { 3 } else { 1000 };
139
140 let wrapped_width = inner_width.saturating_sub(2);
141 let wrapped = textwrap::fill(content, wrapped_width);
142
143 for (i, line) in wrapped.lines().enumerate() {
144 if i >= max_lines {
145 println!("│ {: <w$} │", "...".dimmed(), w = wrapped_width);
146 break;
147 }
148 println!("│ {:<w$}│", line, w = wrapped_width);
149 }
150 }
151
152 if let Some(url) = &post.url {
153 println!("│ {:>w$} │", "", w = inner_width);
154 let url_width = inner_width.saturating_sub(3);
155 let truncated_url = if url.chars().count() > url_width {
156 let t: String = url.chars().take(url_width.saturating_sub(3)).collect();
157 format!("{}...", t)
158 } else {
159 url.clone()
160 };
161 println!(
162 "│ 🔗 {:<w$} │",
163 truncated_url.blue().underline(),
164 w = inner_width.saturating_sub(4)
165 );
166 }
167
168 println!(
169 "{}",
170 format!("╰{}╯", "─".repeat(width.saturating_sub(2))).dimmed()
171 );
172
173 println!(
174 " ID: {} • {}",
175 post.id.dimmed(),
176 relative_time(&post.created_at).dimmed()
177 );
178 println!();
179}
180
181pub fn display_search_result(result: &SearchResult, index: usize) {
182 let width = get_term_width();
183 let inner_width = width.saturating_sub(4);
184
185 println!(
186 "{}",
187 format!("╭{}╮", "─".repeat(width.saturating_sub(2))).dimmed()
188 );
189
190 let title = result.title.as_deref().unwrap_or("(comment)");
191 let score = result.similarity.unwrap_or(0.0);
192 let score_display = if score > 1.0 {
193 format!("{:.1}", score)
194 } else {
195 format!("{:.0}%", score * 100.0)
196 };
197
198 let title_space = inner_width.saturating_sub(score_display.chars().count() + 6); let title_display = if title.chars().count() > title_space {
200 let t: String = title.chars().take(title_space.saturating_sub(3)).collect();
201 format!("{}...", t)
202 } else {
203 title.to_string()
204 };
205
206 let padding = inner_width
207 .saturating_sub(4 + title_display.chars().count() + score_display.chars().count());
208 println!(
209 "│ #{:<2} {}{:>p$} │",
210 index,
211 title_display.bright_cyan().bold(),
212 score_display.green(),
213 p = padding + score_display.chars().count()
214 );
215
216 println!(
217 "{}",
218 format!("├{}┤", "─".repeat(width.saturating_sub(2))).dimmed()
219 );
220
221 let author = result.author.name.yellow();
222 let type_label = result.result_type.blue();
223
224 let left_len = result.author.name.chars().count() + result.result_type.chars().count() + 8;
225 let meta_padding = inner_width.saturating_sub(left_len);
226
227 println!(
228 "│ 👤 {} • {}{:>p$} │",
229 author,
230 type_label,
231 "",
232 p = meta_padding
233 );
234
235 println!("│ {:>w$} │", "", w = inner_width);
236 if let Some(content) = &result.content {
237 let wrapped_width = inner_width.saturating_sub(2);
238 let wrapped = textwrap::fill(content, wrapped_width);
239 for (i, line) in wrapped.lines().enumerate() {
240 if i >= 3 {
241 println!("│ {: <w$} │", "...".dimmed(), w = wrapped_width);
242 break;
243 }
244 println!("│ {:<w$}│", line, w = wrapped_width);
245 }
246 }
247
248 println!(
249 "{}",
250 format!("╰{}╯", "─".repeat(width.saturating_sub(2))).dimmed()
251 );
252 if let Some(post_id) = &result.post_id {
253 println!(" Post ID: {}", post_id.dimmed());
254 }
255 println!();
256}
257
258pub fn display_profile(agent: &Agent, title: Option<&str>) {
259 let width = get_term_width();
260
261 let title_str = title.unwrap_or("Profile");
262 println!("\n{} {}", "👤".cyan(), title_str.bright_green().bold());
263 println!("{}", "━".repeat(width).dimmed());
264
265 println!(" {:<15} {}", "Name:", agent.name.bright_white().bold());
266 println!(" {:<15} {}", "ID:", agent.id.dimmed());
267
268 if let Some(desc) = &agent.description {
269 println!("{}", "─".repeat(width).dimmed());
270 let wrapped = textwrap::fill(desc, width.saturating_sub(4));
271 for line in wrapped.lines() {
272 println!(" {}", line.italic());
273 }
274 }
275 println!("{}", "─".repeat(width).dimmed());
276
277 println!(
278 " {:<15} {}",
279 "✨ Karma:",
280 agent.karma.unwrap_or(0).to_string().yellow().bold()
281 );
282
283 if let Some(stats) = &agent.stats {
284 println!(
285 " {:<15} {}",
286 "📝 Posts:",
287 stats.posts.unwrap_or(0).to_string().cyan()
288 );
289 println!(
290 " {:<15} {}",
291 "💬 Comments:",
292 stats.comments.unwrap_or(0).to_string().cyan()
293 );
294 println!(
295 " {:<15} m/ {}",
296 "🍿 Submolts:",
297 stats.subscriptions.unwrap_or(0).to_string().cyan()
298 );
299 }
300
301 if let (Some(followers), Some(following)) = (agent.follower_count, agent.following_count) {
302 println!(" {:<15} {}", "👥 Followers:", followers.to_string().blue());
303 println!(" {:<15} {}", "👀 Following:", following.to_string().blue());
304 }
305
306 println!("{}", "─".repeat(width).dimmed());
307
308 if let Some(claimed) = agent.is_claimed {
309 let status = if claimed {
310 "✓ Claimed".green()
311 } else {
312 "✗ Unclaimed".red()
313 };
314 println!(" {:<15} {}", "🛡️ Status:", status);
315 if let Some(claimed_at) = &agent.claimed_at {
316 println!(
317 " {:<15} {}",
318 "📅 Claimed:",
319 relative_time(claimed_at).dimmed()
320 );
321 }
322 }
323
324 if let Some(created_at) = &agent.created_at {
325 println!(
326 " {:<15} {}",
327 "🌱 Joined:",
328 relative_time(created_at).dimmed()
329 );
330 }
331 if let Some(last_active) = &agent.last_active {
332 println!(
333 " {:<15} {}",
334 "⏰ Active:",
335 relative_time(last_active).dimmed()
336 );
337 }
338
339 if let Some(owner) = &agent.owner {
340 println!("\n {}", "👑 Owner".bright_yellow().underline());
341 if let Some(name) = &owner.x_name {
342 println!(" {:<15} {}", "Name:", name);
343 }
344 if let Some(handle) = &owner.x_handle {
345 let verified = if owner.x_verified.unwrap_or(false) {
346 " (Verified)".blue()
347 } else {
348 "".normal()
349 };
350 println!(" {:<15} @{}{}", "X (Twitter):", handle.cyan(), verified);
351 }
352 if let (Some(foll), Some(follg)) = (owner.x_follower_count, owner.x_following_count) {
353 println!(
354 " {:<15} {} followers | {} following",
355 "X Stats:",
356 foll.to_string().dimmed(),
357 follg.to_string().dimmed()
358 );
359 }
360 if let Some(owner_id) = &agent.owner_id {
361 println!(" {:<15} {}", "Owner ID:", owner_id.dimmed());
362 }
363 }
364
365 if let Some(metadata) = &agent.metadata
366 && !metadata.is_null()
367 && metadata.as_object().is_some_and(|o| !o.is_empty())
368 {
369 println!("\n {}", "📂 Metadata".bright_blue().underline());
370 println!(
371 " {}",
372 serde_json::to_string_pretty(metadata)
373 .unwrap_or_default()
374 .dimmed()
375 );
376 }
377 println!();
378}
379
380pub fn display_comment(comment: &serde_json::Value, index: usize) {
381 let author = comment["author"]["name"].as_str().unwrap_or("unknown");
382 let content = comment["content"].as_str().unwrap_or("");
383 let upvotes = comment["upvotes"].as_i64().unwrap_or(0);
384 let id = comment["id"].as_str().unwrap_or("unknown");
385
386 let width = get_term_width();
387
388 println!(
389 "{} {} (⬆ {})",
390 format!("#{:<2}", index).dimmed(),
391 author.yellow().bold(),
392 upvotes
393 );
394
395 let wrapped = textwrap::fill(content, width.saturating_sub(4));
396 for line in wrapped.lines() {
397 println!("│ {}", line);
398 }
399 println!("└─ ID: {}", id.dimmed());
400 println!();
401}
402
403pub fn display_submolt(submolt: &Submolt) {
404 let width = get_term_width();
405 println!(
406 "{} (m/{})",
407 submolt.display_name.bright_cyan().bold(),
408 submolt.name.green()
409 );
410
411 if let Some(desc) = &submolt.description {
412 println!(" {}", desc.dimmed());
413 }
414
415 println!(" Subscribers: {}", submolt.subscriber_count.unwrap_or(0));
416 println!("{}", "─".repeat(width.min(60)).dimmed());
417 println!();
418}
419
420pub fn display_dm_request(req: &DmRequest) {
421 let width = get_term_width();
422 let inner_width = width.saturating_sub(4);
423
424 let from = &req.from.name;
425 let msg = req
426 .message
427 .as_deref()
428 .or(req.message_preview.as_deref())
429 .unwrap_or("");
430
431 println!(
432 "{}",
433 format!("╭{}╮", "─".repeat(width.saturating_sub(2))).dimmed()
434 );
435
436 let from_line_len = 15 + from.chars().count();
438 let padding = inner_width.saturating_sub(from_line_len);
439
440 println!(
441 "│ 📨 Request from {} {:>p$} │",
442 from.cyan().bold(),
443 "",
444 p = padding
445 );
446 println!(
447 "{}",
448 format!("├{}┤", "─".repeat(width.saturating_sub(2))).dimmed()
449 );
450
451 if let Some(handle) = req.from.owner.as_ref().and_then(|o| o.x_handle.as_ref()) {
452 println!(
453 "│ 👑 Owner: @{:<w$} │",
454 handle.blue(),
455 w = inner_width.saturating_sub(14)
456 );
457 }
458
459 let wrapped = textwrap::fill(msg, inner_width.saturating_sub(2));
460 for line in wrapped.lines() {
461 println!("│ {:<w$}│", line, w = inner_width.saturating_sub(2));
462 }
463
464 println!(
465 "{}",
466 format!("├{}┤", "─".repeat(width.saturating_sub(2))).dimmed()
467 );
468 println!(
469 "│ ID: {:<w$} │",
470 req.conversation_id.dimmed(),
471 w = inner_width.saturating_sub(4)
472 );
473 println!(
474 "│ {:<w$} │",
475 format!("✔ Approve: moltbook dm-approve {}", req.conversation_id).green(),
476 w = inner_width.saturating_sub(2) + 9
477 ); println!(
479 "│ {:<w$} │",
480 format!("✘ Reject: moltbook dm-reject {}", req.conversation_id).red(),
481 w = inner_width.saturating_sub(2) + 9
482 );
483 println!(
484 "{}",
485 format!("╰{}╯", "─".repeat(width.saturating_sub(2))).dimmed()
486 );
487 println!();
488}
489
490pub fn display_status(status: &crate::api::types::StatusResponse) {
491 let width = get_term_width();
492 println!(
493 "\n{} {}",
494 "🛡️".cyan(),
495 "Account Status".bright_green().bold()
496 );
497 println!("{}", "━".repeat(width).dimmed());
498
499 if let Some(agent) = &status.agent {
500 println!(
501 " {:<15} {}",
502 "Agent Name:",
503 agent.name.bright_white().bold()
504 );
505 println!(" {:<15} {}", "Agent ID:", agent.id.dimmed());
506 if let Some(claimed_at) = &agent.claimed_at {
507 println!(
508 " {:<15} {}",
509 "Claimed At:",
510 relative_time(claimed_at).dimmed()
511 );
512 }
513 println!("{}", "─".repeat(width).dimmed());
514 }
515
516 if let Some(s) = &status.status {
517 let status_display = match s.as_str() {
518 "claimed" => "✓ Claimed".green(),
519 "pending_claim" => "⏳ Pending Claim".yellow(),
520 _ => s.normal(),
521 };
522 println!(" {:<15} {}", "Status:", status_display);
523 }
524
525 if let Some(msg) = &status.message {
526 println!("\n {}", msg);
527 }
528
529 if let Some(next) = &status.next_step {
530 println!(" {}", next.dimmed());
531 }
532 println!();
533}
534
535pub fn display_dm_check(response: &crate::api::types::DmCheckResponse) {
536 let width = get_term_width();
537 println!("\n{}", "DM Activity".bright_green().bold());
538 println!("{}", "━".repeat(width).dimmed());
539
540 if !response.has_activity {
541 println!(" {}", "No new DM activity 🦞".green());
542 } else {
543 if let Some(summary) = &response.summary {
544 println!(" {}", summary.yellow());
545 }
546
547 if let Some(data) = &response.requests
548 && !data.items.is_empty()
549 {
550 println!("\n {}", "Pending Requests:".bold());
551 for req in &data.items {
552 let from = &req.from.name;
553 let preview = req.message_preview.as_deref().unwrap_or("");
554 let conv_id = &req.conversation_id;
555
556 println!("\n From: {}", from.cyan());
557 println!(" Message: {}", preview.dimmed());
558 println!(" ID: {}", conv_id);
559 }
560 }
561
562 if let Some(data) = &response.messages
563 && data.total_unread > 0
564 {
565 println!(
566 "\n {} unread messages",
567 data.total_unread.to_string().yellow()
568 );
569 }
570 }
571 println!();
572}
573
574pub fn display_conversation(conv: &crate::api::types::Conversation) {
575 let width = get_term_width();
576 let unread_msg = if conv.unread_count > 0 {
577 format!(" ({} unread)", conv.unread_count)
578 .yellow()
579 .to_string()
580 } else {
581 String::new()
582 };
583
584 println!(
585 "{} {}{}",
586 "💬".cyan(),
587 conv.with_agent.name.bright_cyan().bold(),
588 unread_msg
589 );
590 println!(" ID: {}", conv.conversation_id.dimmed());
591 println!(
592 " Read: {}",
593 format!("moltbook dm-read {}", conv.conversation_id).green()
594 );
595 println!("{}", "─".repeat(width).dimmed());
596}
597
598pub fn display_message(msg: &crate::api::types::Message) {
599 let width = get_term_width();
600 let prefix = if msg.from_you {
601 "You"
602 } else {
603 &msg.from_agent.name
604 };
605
606 let (icon, color) = if msg.from_you {
607 ("📤", prefix.green())
608 } else {
609 ("📥", prefix.yellow())
610 };
611
612 let time = relative_time(&msg.created_at);
613
614 println!("\n{} {} ({})", icon, color.bold(), time.dimmed());
615
616 let wrapped = textwrap::fill(&msg.message, width.saturating_sub(4));
617 for line in wrapped.lines() {
618 println!(" {}", line);
619 }
620
621 if msg.needs_human_input {
622 println!(" {}", "⚠ Needs human input".red());
623 }
624 println!("{}", "─".repeat(width.min(40)).dimmed());
625}