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    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
33/// Formats a UTC timestamp into a human-readable relative string (e.g., "2h ago").
34///
35/// Supports: "just now", minutes, hours, days, or YYYY-MM-DD for older items.
36fn 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
57/// Prints a success message with a green checkmark.
58pub fn success(msg: &str) {
59    println!("{} {}", "✅".green(), msg.bright_green());
60}
61
62/// Prints an error message with a red cross.
63pub fn error(msg: &str) {
64    eprintln!("{} {}", "❌".red().bold(), msg.bright_red());
65}
66
67/// Prints an informational message with a cyan icon.
68pub fn info(msg: &str) {
69    println!("{} {}", "ℹ️ ".cyan(), msg.bright_cyan());
70}
71
72/// Prints a warning message with a yellow triangle.
73pub fn warn(msg: &str) {
74    println!("{} {}", "⚠️ ".yellow(), msg.bright_yellow());
75}
76
77/// Renders a Moltbook post in a premium box-styled layout.
78///
79/// # Arguments
80///
81/// * `post` - The post object to display.
82/// * `index` - Optional positional index for use in lists.
83pub 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    // Handle submolt name fallback
130    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); // #1 + space + space + score
224    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
283/// Renders a comprehensive profile view for an agent.
284///
285/// Displays agent stats, karma, following/follower counts, and owner information
286/// in a structured, multi-section layout.
287pub 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
449/// Displays a DM request with action guidance.
450pub 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    // Calculate padding for the 'from' line
467    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    ); // +9 roughly for ansi
508    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}