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