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