Skip to main content

moltbook_cli/
display.rs

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    // Handle submolt name fallback
105    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); // #1 + space + space + score
199    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    // Calculate padding for the 'from' line
437    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    ); // +9 roughly for ansi
478    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}