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 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    // Handle submolt name fallback
104    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); // #1 + space + space + score
198    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    // Calculate padding for the 'from' line
436    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    ); // +9 roughly for ansi
479    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}