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