Skip to main content

moltbook_cli/
display.rs

1//! Visual presentation and terminal formatting for the Moltbook CLI.
2//!
3//! This module provides utilities for relative time calculation, terminal width
4//! detection, and high-fidelity rendering of Moltbook data structures using
5//! Unicode box-drawing characters and ANSI colors.
6
7use crate::api::types::{Agent, DmRequest, Post, SearchResult, Submolt};
8use chrono::{DateTime, Utc};
9use colored::*;
10use terminal_size::{Width, terminal_size};
11
12/// Detects the available terminal width for responsive layout.
13///
14/// Priority:
15/// 1. `COLUMNS` environment variable.
16/// 2. `terminal_size` system call.
17/// 3. Default fallback of 80 characters.
18fn 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
34/// Formats a UTC timestamp into a human-readable relative string (e.g., "2h ago").
35///
36/// Supports: "just now", minutes, hours, days, or YYYY-MM-DD for older items.
37fn 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
59/// Prints a success message with a green checkmark.
60pub fn success(msg: &str) {
61    println!("{} {}", "✅".green(), msg.bright_green());
62}
63
64/// Prints an error message with a red cross.
65pub fn error(msg: &str) {
66    eprintln!("{} {}", "❌".red().bold(), msg.bright_red());
67}
68
69/// Prints an informational message with a cyan icon.
70pub fn info(msg: &str) {
71    println!("{} {}", "ℹ️ ".cyan(), msg.bright_cyan());
72}
73
74/// Prints a warning message with a yellow triangle.
75pub fn warn(msg: &str) {
76    println!("{} {}", "⚠️ ".yellow(), msg.bright_yellow());
77}
78
79
80/// Renders a Moltbook post in a premium box-styled layout.
81///
82/// # Arguments
83///
84/// * `post` - The post object to display.
85/// * `index` - Optional positional index for use in lists.
86pub 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    // Handle submolt name fallback
134    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); // #1 + space + space + score
228    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
287/// Renders a comprehensive profile view for an agent.
288///
289/// Displays agent stats, karma, following/follower counts, and owner information
290/// in a structured, multi-section layout.
291pub 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
454/// Displays a DM request with action guidance.
455pub 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    // Calculate padding for the 'from' line
473    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    ); // +9 roughly for ansi
514    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}