cryptic_tui/
ui.rs

1//! Terminal user interface rendering.
2//!
3//! This module handles all UI rendering using the Ratatui framework.
4//! It provides functions to draw the splash screen, main screen with tabs,
5//! and all UI components including message history with date separators.
6//!
7//! # Layout
8//!
9//! The main screen consists of:
10//! - Tab bar at the top
11//! - Tab content area
12//! - Status bar at the bottom
13//!
14//! ## Available Tabs
15//!
16//! - **Chat**: User list and message history with infinite scrolling
17//! - **Admin**: Administrative functions
18//! - **Status**: Connection and system status
19//! - **Help**: Keyboard shortcuts and help
20//!
21//! # Message Display
22//!
23//! Messages are rendered with:
24//! - Date separators ("Today", "Yesterday", or formatted dates)
25//! - Timestamps in 12-hour format (HH:MM AM/PM)
26//! - Emoji and markdown formatting via the [`formatting`] module
27//! - Loading indicators for history pagination
28//! - Scroll position indicators
29
30use chrono::{Local, TimeZone};
31use ratatui::{
32    layout::{Constraint, Direction, Layout, Rect},
33    style::{Color, Modifier, Style},
34    text::{Line, Span},
35    widgets::{Block, Borders, List, ListItem, Paragraph, Tabs, Wrap},
36    Frame,
37};
38
39use crate::app::{AdminField, App, AppScreen, Tab};
40use crate::formatting;
41
42/// Main drawing function - delegates to splash or main screen.
43///
44/// # Arguments
45///
46/// * `f` - Ratatui frame to draw on
47/// * `app` - Application state
48pub fn draw(f: &mut Frame, app: &App) {
49    if app.screen == AppScreen::Splash {
50        render_splash(f, app, f.area());
51        return;
52    }
53
54    let chunks = Layout::default()
55        .direction(Direction::Vertical)
56        .constraints([
57            Constraint::Length(3), // Tab bar
58            Constraint::Min(0),    // Content area
59            Constraint::Length(1), // Status bar
60        ])
61        .split(f.area());
62
63    render_tabs(f, app, chunks[0]);
64
65    match app.active_tab {
66        Tab::Chat => render_chat_tab(f, app, chunks[1]),
67        Tab::Admin => render_admin_tab(f, app, chunks[1]),
68        Tab::Status => render_status_tab(f, app, chunks[1]),
69        Tab::Help => render_help_tab(f, app, chunks[1]),
70    }
71
72    // Render status bar at the bottom
73    render_status_bar(f, app, chunks[2]);
74}
75
76fn render_tabs(f: &mut Frame, app: &App, area: Rect) {
77    // Explicit type annotation to satisfy collect inference
78    let titles: Vec<Line<'static>> = ["Chat", "Admin", "Status", "Help"]
79        .iter()
80        .map(|t| Line::from(*t))
81        .collect();
82
83    let tabs = Tabs::new(titles)
84        .block(Block::default().borders(Borders::ALL).title("Cryptic TUI"))
85        .select(app.active_tab as usize)
86        .style(Style::default().fg(Color::White))
87        .highlight_style(
88            Style::default()
89                .fg(Color::Cyan)
90                .add_modifier(Modifier::BOLD),
91        );
92
93    f.render_widget(tabs, area);
94}
95
96fn render_chat_tab(f: &mut Frame, app: &App, area: Rect) {
97    let chunks = Layout::default()
98        .direction(Direction::Horizontal)
99        .constraints([
100            Constraint::Percentage(30), // User list
101            Constraint::Percentage(70), // Chat area
102        ])
103        .split(area);
104
105    // Render user list
106    render_user_list(f, app, chunks[0]);
107
108    // Render chat area
109    render_chat_area(f, app, chunks[1]);
110}
111
112fn render_user_list(f: &mut Frame, app: &App, area: Rect) {
113    let users: Vec<ListItem> = app
114        .users
115        .iter()
116        .enumerate()
117        .map(|(i, user)| {
118            let status = if user.online { "●" } else { "○" };
119            let style = if i == app.selected_user {
120                Style::default()
121                    .fg(Color::Cyan)
122                    .add_modifier(Modifier::BOLD)
123            } else {
124                Style::default()
125            };
126
127            let content = format!(
128                "{} {} {}",
129                status,
130                user.name,
131                if user.online { "online" } else { "offline" }
132            );
133
134            ListItem::new(content).style(style)
135        })
136        .collect();
137
138    let user_list = List::new(users)
139        .block(
140            Block::default()
141                .borders(Borders::ALL)
142                .title("Users (↑/↓ to select, Enter to chat)"),
143        )
144        .style(Style::default().fg(Color::White));
145
146    f.render_widget(user_list, area);
147}
148
149fn render_chat_area(f: &mut Frame, app: &App, area: Rect) {
150    let chunks = Layout::default()
151        .direction(Direction::Vertical)
152        .constraints([
153            Constraint::Length(3), // Chat header
154            Constraint::Min(0),    // Messages
155            Constraint::Length(3), // Input box
156        ])
157        .split(area);
158
159    // Chat header
160    let peer_name = app.current_peer.as_deref().unwrap_or("No chat selected");
161    let header = Paragraph::new(format!("Chat with: {}", peer_name))
162        .block(Block::default().borders(Borders::ALL))
163        .style(
164            Style::default()
165                .fg(Color::Green)
166                .add_modifier(Modifier::BOLD),
167        );
168    f.render_widget(header, chunks[0]);
169
170    // Messages
171    render_messages(f, app, chunks[1]);
172
173    // Input box with cursor and horizontal scrolling
174    let input_width = chunks[2].width.saturating_sub(2) as usize; // subtract borders
175    let cursor_pos = app.input_cursor;
176
177    // Calculate horizontal scroll to keep cursor visible
178    let scroll_offset = if cursor_pos >= input_width {
179        cursor_pos - input_width + 1
180    } else {
181        0
182    };
183
184    let input = Paragraph::new(app.input_buffer.as_str())
185        .block(
186            Block::default()
187                .borders(Borders::ALL)
188                .title("Message (Enter to send, Tab for next tab)"),
189        )
190        .style(Style::default().fg(Color::White))
191        .scroll((0, scroll_offset as u16)); // Horizontal scroll
192
193    f.render_widget(input, chunks[2]);
194
195    // Set cursor position in the input area
196    if app.current_peer.is_some() {
197        let cursor_x = chunks[2].x + 1 + (cursor_pos.saturating_sub(scroll_offset)) as u16;
198        let cursor_y = chunks[2].y + 1;
199        f.set_cursor_position((cursor_x, cursor_y));
200    }
201}
202
203fn render_messages(f: &mut Frame, app: &App, area: Rect) {
204    let mut lines: Vec<Line> = Vec::new();
205    let mut current_scroll_offset: usize = 0;
206    let mut has_messages = false;
207
208    if let Some(peer) = &app.current_peer {
209        if let Some(conv) = app.get_conversation(peer) {
210            has_messages = !conv.messages.is_empty();
211            current_scroll_offset = conv.scroll_offset;
212            // Show loading indicator if loading history
213            if conv.is_loading {
214                lines.push(Line::from(Span::styled(
215                    "⏳ Loading older messages...",
216                    Style::default()
217                        .fg(Color::Gray)
218                        .add_modifier(Modifier::ITALIC),
219                )));
220            }
221
222            // Show "no more history" indicator if at top and exhausted
223            if !conv.has_more_history && !conv.messages.is_empty() {
224                lines.push(Line::from(Span::styled(
225                    "─── Beginning of conversation ───",
226                    Style::default().fg(Color::DarkGray),
227                )));
228            }
229
230            // Render messages with date separators
231            let mut last_date: Option<String> = None;
232
233            for msg in conv.messages.iter() {
234                let msg_date = format_message_date(msg.timestamp);
235
236                // Insert date separator if date changed
237                if last_date.as_ref() != Some(&msg_date) {
238                    lines.push(Line::from(Span::styled(
239                        format!("─── {} ───", msg_date),
240                        Style::default()
241                            .fg(Color::Blue)
242                            .add_modifier(Modifier::BOLD),
243                    )));
244                    last_date = Some(msg_date);
245                }
246
247                // Format message with timestamp
248                let time_str = format_message_time(msg.timestamp);
249                let base_color = if msg.is_sent {
250                    Color::Cyan
251                } else {
252                    Color::Yellow
253                };
254
255                let prefix = if msg.is_sent { "me" } else { &msg.from };
256
257                // Process message content for emoji and markdown
258                let formatted_content = formatting::process_text(&msg.content);
259
260                // Build the line with timestamp, prefix, and formatted content
261                let mut line_spans = vec![
262                    Span::styled(format!("[{}] ", time_str), Style::default().fg(base_color)),
263                    Span::styled(format!("{}: ", prefix), Style::default().fg(base_color)),
264                ];
265
266                // Add formatted content spans with base color applied
267                for span in formatted_content.spans {
268                    let mut style = span.style;
269                    // Preserve formatting modifiers but use base color if no color set
270                    if style.fg.is_none() {
271                        style.fg = Some(base_color);
272                    }
273                    line_spans.push(Span::styled(span.content, style));
274                }
275
276                lines.push(Line::from(line_spans));
277            }
278
279            // Show scroll position indicator
280            if has_messages {
281                let scroll_info = if current_scroll_offset > 0 {
282                    format!("↑ Scrolled up {} lines | Ctrl+U/D or PgUp/PgDn to scroll | Home/End to jump", 
283                            current_scroll_offset)
284                } else {
285                    "↓ At newest messages | Ctrl+U or PgUp to scroll up".to_string()
286                };
287
288                lines.push(Line::from(Span::styled(
289                    scroll_info,
290                    Style::default()
291                        .fg(Color::DarkGray)
292                        .add_modifier(Modifier::ITALIC),
293                )));
294            }
295
296            if lines.is_empty() {
297                lines.push(Line::from("No messages yet. Start typing!"));
298            }
299        } else {
300            lines.push(Line::from("No messages yet. Start typing!"));
301        }
302    } else {
303        lines.push(Line::from("Select a user from the list to start chatting"));
304    }
305
306    let title = if let Some(peer) = &app.current_peer {
307        format!("Messages - {}", peer)
308    } else {
309        "Messages".to_string()
310    };
311
312    // Calculate scroll position using rendered line heights to account for wrapping
313    let inner_width = area.width.saturating_sub(2) as usize; // subtract borders
314    let inner_width = inner_width.max(1);
315    let visible_height = area.height.saturating_sub(2) as usize; // subtract borders
316    let visible_height = visible_height.max(1);
317
318    let total_height: usize = lines
319        .iter()
320        .map(|line| line_display_height(line, inner_width))
321        .sum();
322
323    let bottom_start = total_height.saturating_sub(visible_height);
324    let effective_offset = current_scroll_offset.min(bottom_start);
325    let scroll_y_lines = bottom_start.saturating_sub(effective_offset);
326    let scroll_y = scroll_y_lines.min(u16::MAX as usize) as u16;
327
328    if has_messages {
329        if let Some(last_line) = lines.last_mut() {
330            let scroll_info = if effective_offset > 0 {
331                format!(
332                    "↑ Scrolled up {} lines | Ctrl+U/D or PgUp/PgDn to scroll | Home/End to jump",
333                    effective_offset
334                )
335            } else {
336                "↓ At newest messages | Ctrl+U or PgUp to scroll up".to_string()
337            };
338
339            *last_line = Line::from(Span::styled(
340                scroll_info,
341                Style::default()
342                    .fg(Color::DarkGray)
343                    .add_modifier(Modifier::ITALIC),
344            ));
345        }
346    }
347
348    let paragraph = Paragraph::new(lines)
349        .block(Block::default().borders(Borders::ALL).title(title))
350        .style(Style::default().fg(Color::White))
351        .scroll((scroll_y, 0)) // Scroll vertically by scroll_y lines
352        .wrap(Wrap { trim: false }); // Enable word wrapping, preserve leading spaces
353
354    f.render_widget(paragraph, area);
355}
356
357/// Estimate how many terminal rows a line will occupy once wrapped.
358fn line_display_height(line: &Line, available_width: usize) -> usize {
359    if available_width == 0 {
360        return 1;
361    }
362
363    let width = line.width();
364    if width == 0 {
365        1
366    } else {
367        // Ceiling division to determine how many rows the line spans
368        (width + available_width - 1) / available_width
369    }
370}
371
372/// Format timestamp as a date label for message grouping.
373/// Returns "Today", "Yesterday", or "Mon, Jan 15, 2025"
374fn format_message_date(timestamp: u64) -> String {
375    // Convert u64 to i64 safely (timestamps shouldn't exceed i64::MAX)
376    let timestamp_i64 = timestamp.min(i64::MAX as u64) as i64;
377
378    let msg_time = match Local.timestamp_opt(timestamp_i64, 0) {
379        chrono::LocalResult::Single(dt) => dt,
380        _ => return "Unknown date".to_string(),
381    };
382
383    let now = Local::now();
384    let msg_date = msg_time.date_naive();
385    let today = now.date_naive();
386
387    if msg_date == today {
388        "Today".to_string()
389    } else if msg_date == today - chrono::Duration::days(1) {
390        "Yesterday".to_string()
391    } else {
392        msg_time.format("%a, %b %d, %Y").to_string()
393    }
394}
395
396/// Format timestamp as time for individual messages.
397/// Returns "10:23 AM" format
398fn format_message_time(timestamp: u64) -> String {
399    // Convert u64 to i64 safely (timestamps shouldn't exceed i64::MAX)
400    let timestamp_i64 = timestamp.min(i64::MAX as u64) as i64;
401
402    match Local.timestamp_opt(timestamp_i64, 0) {
403        chrono::LocalResult::Single(dt) => dt.format("%I:%M %p").to_string(),
404        _ => "??:??".to_string(),
405    }
406}
407
408fn render_admin_tab(f: &mut Frame, app: &App, area: Rect) {
409    use crate::app::AdminMode;
410
411    match app.admin_mode {
412        AdminMode::Menu => render_admin_menu(f, app, area),
413        AdminMode::RegisterUser => render_admin_register(f, app, area),
414        AdminMode::SuspendUser => render_admin_suspend(f, app, area),
415        AdminMode::ReactivateUser => render_admin_reactivate(f, app, area),
416        AdminMode::RevokeUser => render_admin_revoke(f, app, area),
417        AdminMode::ListCertificates => render_admin_certificates(f, app, area),
418    }
419}fn render_admin_menu(f: &mut Frame, app: &App, area: Rect) {
420    let mut content = vec![
421        Line::from(Span::styled(
422            "Admin Panel - User Management",
423            Style::default()
424                .fg(Color::Cyan)
425                .add_modifier(Modifier::BOLD),
426        )),
427        Line::from(""),
428        Line::from("Available actions:"),
429        Line::from(""),
430        Line::from(vec![
431            Span::styled("  l", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
432            Span::raw(" - List registered users"),
433        ]),
434        Line::from(vec![
435            Span::styled("  r", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
436            Span::raw(" - Register new user"),
437        ]),
438        Line::from(vec![
439            Span::styled("  s", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
440            Span::raw(" - Suspend user"),
441        ]),
442        Line::from(vec![
443            Span::styled("  a", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
444            Span::raw(" - Reactivate (activate) user"),
445        ]),
446        Line::from(vec![
447            Span::styled("  v", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
448            Span::raw(" - Revoke user"),
449        ]),
450        Line::from(vec![
451            Span::styled("  c", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
452            Span::raw(" - Certificate management"),
453        ]),
454        Line::from(""),
455    ];
456
457    // Show user list if available
458    if let Some(ref users) = app.detailed_users {
459        content.push(Line::from(""));
460        content.push(Line::from(Span::styled(
461            format!("Registered Users ({})", users.len()),
462            Style::default()
463                .fg(Color::Green)
464                .add_modifier(Modifier::BOLD),
465        )));
466        content.push(Line::from(""));
467        
468        // Table header
469        content.push(Line::from(vec![Span::styled(
470            format!(
471                "{:<12} {:<10} {:<8} {:<12} {:<12} {:<12}",
472                "Username", "Status", "Online", "Registered", "Last Seen", "GPG FP (short)"
473            ),
474            Style::default()
475                .fg(Color::Green)
476                .add_modifier(Modifier::BOLD),
477        )]));
478        content.push(Line::from("─".repeat(80)));
479
480        // Table rows
481        for (idx, user) in users.iter().enumerate() {
482            let online_indicator = if user.online { "🟢 Yes" } else { "⚫ No" };
483            let registered = format_timestamp(user.registered_at);
484            let last_seen = format_timestamp(user.last_seen);
485            let gpg_short = if user.gpg_fp.len() >= 8 {
486                &user.gpg_fp[user.gpg_fp.len() - 8..]
487            } else {
488                &user.gpg_fp
489            };
490
491            let status_style = match user.status.as_str() {
492                "active" => Style::default().fg(Color::Green),
493                "suspended" => Style::default().fg(Color::Yellow),
494                "revoked" => Style::default().fg(Color::Red),
495                _ => Style::default().fg(Color::Gray),
496            };
497
498            let online_style = if user.online {
499                Style::default().fg(Color::Green)
500            } else {
501                Style::default().fg(Color::Gray)
502            };
503
504            // Highlight selected user
505            let is_selected = idx == app.admin_selected_user;
506            let selection_marker = if is_selected { "→ " } else { "  " };
507
508            let mut line_style = Style::default();
509            if is_selected {
510                line_style = line_style.add_modifier(Modifier::BOLD).fg(Color::Cyan);
511            }
512
513            content.push(Line::from(vec![
514                Span::styled(selection_marker, line_style),
515                Span::styled(format!("{:<10} ", truncate_string(&user.username, 10)), line_style),
516                Span::styled(format!("{:<10} ", user.status), if is_selected { line_style } else { status_style }),
517                Span::styled(format!("{:<8} ", online_indicator), if is_selected { line_style } else { online_style }),
518                Span::styled(format!("{:<12} ", registered), line_style),
519                Span::styled(format!("{:<12} ", last_seen), line_style),
520                Span::styled(gpg_short.to_string(), if is_selected { line_style } else { Style::default().fg(Color::DarkGray) }),
521            ]));
522
523            // Display metadata if available
524            if let Some(ref metadata) = user.metadata {
525                if !metadata.fields.is_empty() {
526                    // Sort keys for consistent display
527                    let mut keys: Vec<_> = metadata.fields.keys().collect();
528                    keys.sort();
529                    
530                    let metadata_str = keys
531                        .iter()
532                        .map(|k| format!("{}: {}", k, metadata.fields.get(*k).unwrap_or(&"".to_string())))
533                        .collect::<Vec<_>>()
534                        .join(", ");
535                    
536                    content.push(Line::from(Span::styled(
537                        format!("    └─ {}", metadata_str),
538                        Style::default()
539                            .fg(Color::DarkGray)
540                            .add_modifier(Modifier::ITALIC),
541                    )));
542                }
543            }
544        }
545
546        content.push(Line::from(""));
547        content.push(Line::from(Span::styled(
548            "Use ↑/↓ to select user",
549            Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC),
550        )));
551    } else {
552        content.push(Line::from(Span::styled(
553            "Press 'l' to load user list from server",
554            Style::default().fg(Color::Yellow),
555        )));
556    }
557
558    if let Some(ref status) = app.status_message {
559        content.push(Line::from(""));
560        content.push(Line::from(Span::styled(
561            status.clone(),
562            Style::default()
563                .fg(Color::LightCyan)
564                .add_modifier(Modifier::ITALIC),
565        )));
566    }
567
568    let paragraph = Paragraph::new(content)
569        .block(Block::default().borders(Borders::ALL).title("Admin"))
570        .style(Style::default().fg(Color::White))
571        .wrap(Wrap { trim: false })
572        .scroll((app.admin_scroll_offset, 0));
573
574    f.render_widget(paragraph, area);
575}
576
577fn render_admin_register(f: &mut Frame, app: &App, area: Rect) {
578    let chunks = Layout::default()
579        .direction(Direction::Vertical)
580        .constraints([
581            Constraint::Length(8), // Form section
582            Constraint::Min(0),     // Spacer
583        ])
584        .split(area);
585
586    render_admin_form(f, app, chunks[0]);
587}
588
589fn render_admin_suspend(f: &mut Frame, app: &App, area: Rect) {
590    let mut content = vec![
591        Line::from(Span::styled(
592            "Suspend User",
593            Style::default()
594                .fg(Color::Yellow)
595                .add_modifier(Modifier::BOLD),
596        )),
597        Line::from(""),
598    ];
599
600    if let Some(user) = app.admin_get_selected_user() {
601        let gpg_short = if user.gpg_fp.len() >= 16 {
602            &user.gpg_fp[user.gpg_fp.len() - 16..]
603        } else {
604            &user.gpg_fp
605        };
606
607        content.push(Line::from(vec![
608            Span::raw("Username: "),
609            Span::styled(&user.username, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
610        ]));
611        content.push(Line::from(vec![
612            Span::raw("Fingerprint: "),
613            Span::styled(gpg_short, Style::default().fg(Color::Gray)),
614        ]));
615        content.push(Line::from(vec![
616            Span::raw("Current Status: "),
617            Span::styled(&user.status, match user.status.as_str() {
618                "active" => Style::default().fg(Color::Green),
619                "suspended" => Style::default().fg(Color::Yellow),
620                "revoked" => Style::default().fg(Color::Red),
621                _ => Style::default().fg(Color::Gray),
622            }),
623        ]));
624        content.push(Line::from(""));
625
626        if user.status == "suspended" {
627            content.push(Line::from(Span::styled(
628                "⚠ User is already suspended",
629                Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC),
630            )));
631            content.push(Line::from(""));
632            content.push(Line::from(Span::styled(
633                "Press Esc to return to menu",
634                Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
635            )));
636        } else if user.status == "revoked" {
637            content.push(Line::from(Span::styled(
638                "✗ Cannot suspend revoked user",
639                Style::default().fg(Color::Red).add_modifier(Modifier::ITALIC),
640            )));
641            content.push(Line::from(""));
642            content.push(Line::from(Span::styled(
643                "Press Esc to return to menu",
644                Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
645            )));
646        } else {
647            content.push(Line::from(Span::styled(
648                "Press Enter to confirm suspension, or Esc to cancel",
649                Style::default().fg(Color::White),
650            )));
651        }
652    } else {
653        content.push(Line::from(Span::styled(
654            "No user selected",
655            Style::default().fg(Color::Red),
656        )));
657        content.push(Line::from(""));
658        content.push(Line::from(Span::styled(
659            "Press Esc to return to menu",
660            Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
661        )));
662    }
663
664    if let Some(ref status) = app.status_message {
665        content.push(Line::from(""));
666        content.push(Line::from(Span::styled(
667            status.clone(),
668            Style::default()
669                .fg(Color::LightCyan)
670                .add_modifier(Modifier::ITALIC),
671        )));
672    }
673
674    let paragraph = Paragraph::new(content)
675        .block(Block::default().borders(Borders::ALL).title("Suspend User"))
676        .style(Style::default().fg(Color::White))
677        .wrap(Wrap { trim: false })
678        .scroll((app.admin_scroll_offset, 0));
679
680    f.render_widget(paragraph, area);
681}
682
683fn render_admin_reactivate(f: &mut Frame, app: &App, area: Rect) {
684    let mut content = vec![
685        Line::from(Span::styled(
686            "Reactivate User",
687            Style::default()
688                .fg(Color::Green)
689                .add_modifier(Modifier::BOLD),
690        )),
691        Line::from(""),
692    ];
693
694    if let Some(user) = app.admin_get_selected_user() {
695        let gpg_short = if user.gpg_fp.len() >= 16 {
696            &user.gpg_fp[user.gpg_fp.len() - 16..]
697        } else {
698            &user.gpg_fp
699        };
700
701        content.push(Line::from(vec![
702            Span::raw("Username: "),
703            Span::styled(&user.username, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
704        ]));
705        content.push(Line::from(vec![
706            Span::raw("Fingerprint: "),
707            Span::styled(gpg_short, Style::default().fg(Color::Gray)),
708        ]));
709        content.push(Line::from(vec![
710            Span::raw("Current Status: "),
711            Span::styled(&user.status, match user.status.as_str() {
712                "active" => Style::default().fg(Color::Green),
713                "suspended" => Style::default().fg(Color::Yellow),
714                "revoked" => Style::default().fg(Color::Red),
715                _ => Style::default().fg(Color::Gray),
716            }),
717        ]));
718        content.push(Line::from(""));
719
720        if user.status == "active" {
721            content.push(Line::from(Span::styled(
722                "✓ User is already active",
723                Style::default().fg(Color::Green).add_modifier(Modifier::ITALIC),
724            )));
725            content.push(Line::from(""));
726            content.push(Line::from(Span::styled(
727                "Press Esc to return to menu",
728                Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
729            )));
730        } else if user.status == "revoked" {
731            content.push(Line::from(Span::styled(
732                "✗ Cannot reactivate revoked user",
733                Style::default().fg(Color::Red).add_modifier(Modifier::ITALIC),
734            )));
735            content.push(Line::from(""));
736            content.push(Line::from(Span::styled(
737                "Press Esc to return to menu",
738                Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
739            )));
740        } else {
741            content.push(Line::from(Span::styled(
742                "Press Enter to confirm reactivation, or Esc to cancel",
743                Style::default().fg(Color::White),
744            )));
745        }
746    } else {
747        content.push(Line::from(Span::styled(
748            "No user selected",
749            Style::default().fg(Color::Red),
750        )));
751        content.push(Line::from(""));
752        content.push(Line::from(Span::styled(
753            "Press Esc to return to menu",
754            Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
755        )));
756    }
757
758    if let Some(ref status) = app.status_message {
759        content.push(Line::from(""));
760        content.push(Line::from(Span::styled(
761            status.clone(),
762            Style::default()
763                .fg(Color::LightCyan)
764                .add_modifier(Modifier::ITALIC),
765        )));
766    }
767
768    let paragraph = Paragraph::new(content)
769        .block(Block::default().borders(Borders::ALL).title("Reactivate User"))
770        .style(Style::default().fg(Color::White))
771        .wrap(Wrap { trim: false })
772        .scroll((app.admin_scroll_offset, 0));
773
774    f.render_widget(paragraph, area);
775}
776
777fn render_admin_revoke(f: &mut Frame, app: &App, area: Rect) {
778    let mut content = vec![
779        Line::from(Span::styled(
780            "Revoke User",
781            Style::default()
782                .fg(Color::Red)
783                .add_modifier(Modifier::BOLD),
784        )),
785        Line::from(""),
786    ];
787
788    if let Some(user) = app.admin_get_selected_user() {
789        let gpg_short = if user.gpg_fp.len() >= 16 {
790            &user.gpg_fp[user.gpg_fp.len() - 16..]
791        } else {
792            &user.gpg_fp
793        };
794
795        content.push(Line::from(vec![
796            Span::raw("Username: "),
797            Span::styled(&user.username, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
798        ]));
799        content.push(Line::from(vec![
800            Span::raw("Fingerprint: "),
801            Span::styled(gpg_short, Style::default().fg(Color::Gray)),
802        ]));
803        content.push(Line::from(vec![
804            Span::raw("Current Status: "),
805            Span::styled(&user.status, match user.status.as_str() {
806                "active" => Style::default().fg(Color::Green),
807                "suspended" => Style::default().fg(Color::Yellow),
808                "revoked" => Style::default().fg(Color::Red),
809                _ => Style::default().fg(Color::Gray),
810            }),
811        ]));
812        content.push(Line::from(""));
813
814        if user.status == "revoked" {
815            content.push(Line::from(Span::styled(
816                "✗ User is already revoked",
817                Style::default().fg(Color::Red).add_modifier(Modifier::ITALIC),
818            )));
819            content.push(Line::from(""));
820            content.push(Line::from(Span::styled(
821                "Press Esc to return to menu",
822                Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
823            )));
824        } else {
825            content.push(Line::from(Span::styled(
826                "⚠️  WARNING: Revoking a user is PERMANENT and cannot be undone!",
827                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
828            )));
829            content.push(Line::from(""));
830            content.push(Line::from(Span::styled(
831                "Press Enter to confirm revocation, or Esc to cancel",
832                Style::default().fg(Color::White),
833            )));
834        }
835    } else {
836        content.push(Line::from(Span::styled(
837            "No user selected",
838            Style::default().fg(Color::Red),
839        )));
840        content.push(Line::from(""));
841        content.push(Line::from(Span::styled(
842            "Press Esc to return to menu",
843            Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
844        )));
845    }
846
847    if let Some(ref status) = app.status_message {
848        content.push(Line::from(""));
849        content.push(Line::from(Span::styled(
850            status.clone(),
851            Style::default()
852                .fg(Color::LightCyan)
853                .add_modifier(Modifier::ITALIC),
854        )));
855    }
856
857    let paragraph = Paragraph::new(content)
858        .block(Block::default().borders(Borders::ALL).title("Revoke User"))
859        .style(Style::default().fg(Color::White))
860        .wrap(Wrap { trim: false })
861        .scroll((app.admin_scroll_offset, 0));
862
863    f.render_widget(paragraph, area);
864}
865
866fn render_admin_certificates(f: &mut Frame, app: &App, area: Rect) {
867    let mut content = vec![
868        Line::from(Span::styled(
869            "Certificate Management",
870            Style::default()
871                .fg(Color::Cyan)
872                .add_modifier(Modifier::BOLD),
873        )),
874        Line::from(""),
875    ];
876
877    if let Some(ref username) = app.certificates_for_user {
878        content.push(Line::from(vec![
879            Span::raw("User: "),
880            Span::styled(username, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
881        ]));
882        content.push(Line::from(""));
883    }
884
885    match &app.certificates {
886        Some(certs) if !certs.is_empty() => {
887            content.push(Line::from(Span::styled(
888                format!("Found {} certificate(s):", certs.len()),
889                Style::default().fg(Color::Green),
890            )));
891            content.push(Line::from(""));
892
893            for (idx, cert) in certs.iter().enumerate() {
894                content.push(Line::from(Span::styled(
895                    format!("Certificate #{}:", idx + 1),
896                    Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
897                )));
898                content.push(Line::from(vec![
899                    Span::raw("  Serial: "),
900                    Span::styled(&cert.serial, Style::default().fg(Color::White)),
901                ]));
902                
903                // Format issued_at timestamp
904                let issued_str = if cert.issued_at > 0 {
905                    use chrono::{DateTime, Utc};
906                    DateTime::<Utc>::from_timestamp(cert.issued_at, 0)
907                        .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
908                        .unwrap_or_else(|| format!("{}", cert.issued_at))
909                } else {
910                    "N/A".to_string()
911                };
912                content.push(Line::from(vec![
913                    Span::raw("  Issued At: "),
914                    Span::styled(issued_str, Style::default().fg(Color::Green)),
915                ]));
916                
917                // Format expires_at timestamp
918                let expires_str = if cert.expires_at > 0 {
919                    use chrono::{DateTime, Utc};
920                    DateTime::<Utc>::from_timestamp(cert.expires_at, 0)
921                        .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
922                        .unwrap_or_else(|| format!("{}", cert.expires_at))
923                } else {
924                    "N/A".to_string()
925                };
926                content.push(Line::from(vec![
927                    Span::raw("  Expires At: "),
928                    Span::styled(expires_str, Style::default().fg(Color::Green)),
929                ]));
930                
931                // Status with color coding
932                let status_color = match cert.status.as_str() {
933                    "valid" => Color::Green,
934                    "revoked" => Color::Red,
935                    "expired" => Color::Yellow,
936                    _ => Color::Gray,
937                };
938                content.push(Line::from(vec![
939                    Span::raw("  Status: "),
940                    Span::styled(&cert.status, Style::default().fg(status_color).add_modifier(Modifier::BOLD)),
941                ]));
942                
943                // Show revocation info if revoked
944                if cert.status == "revoked" {
945                    if let Some(revoked_at) = cert.revoked_at {
946                        let revoked_str = {
947                            use chrono::{DateTime, Utc};
948                            DateTime::<Utc>::from_timestamp(revoked_at, 0)
949                                .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
950                                .unwrap_or_else(|| format!("{}", revoked_at))
951                        };
952                        content.push(Line::from(vec![
953                            Span::raw("  Revoked At: "),
954                            Span::styled(revoked_str, Style::default().fg(Color::Red)),
955                        ]));
956                    }
957                    if let Some(ref revoked_by) = cert.revoked_by {
958                        content.push(Line::from(vec![
959                            Span::raw("  Revoked By: "),
960                            Span::styled(revoked_by, Style::default().fg(Color::Red)),
961                        ]));
962                    }
963                    if let Some(ref reason) = cert.revoked_reason {
964                        content.push(Line::from(vec![
965                            Span::raw("  Reason: "),
966                            Span::styled(reason, Style::default().fg(Color::Red)),
967                        ]));
968                    }
969                }
970                
971                content.push(Line::from(""));
972            }
973
974            content.push(Line::from(Span::styled(
975                "Press Esc to return to menu",
976                Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
977            )));
978        }
979        Some(_) => {
980            content.push(Line::from(Span::styled(
981                "No certificates found for this user",
982                Style::default().fg(Color::Yellow),
983            )));
984            content.push(Line::from(""));
985            content.push(Line::from(Span::styled(
986                "Press Esc to return to menu",
987                Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
988            )));
989        }
990        None => {
991            if let Some(user) = app.admin_get_selected_user() {
992                content.push(Line::from(vec![
993                    Span::raw("Selected user: "),
994                    Span::styled(&user.username, Style::default().fg(Color::Cyan)),
995                ]));
996                content.push(Line::from(""));
997                content.push(Line::from(Span::styled(
998                    "Press Enter to load certificates, or Esc to cancel",
999                    Style::default().fg(Color::White),
1000                )));
1001            } else {
1002                content.push(Line::from(Span::styled(
1003                    "No user selected",
1004                    Style::default().fg(Color::Red),
1005                )));
1006                content.push(Line::from(""));
1007                content.push(Line::from(Span::styled(
1008                    "Press Esc to return to menu",
1009                    Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC),
1010                )));
1011            }
1012        }
1013    }
1014
1015    if let Some(ref status) = app.status_message {
1016        content.push(Line::from(""));
1017        content.push(Line::from(Span::styled(
1018            status.clone(),
1019            Style::default()
1020                .fg(Color::LightCyan)
1021                .add_modifier(Modifier::ITALIC),
1022        )));
1023    }
1024
1025    let paragraph = Paragraph::new(content)
1026        .block(Block::default().borders(Borders::ALL).title("Certificate Management"))
1027        .style(Style::default().fg(Color::White))
1028        .wrap(Wrap { trim: false })
1029        .scroll((app.admin_scroll_offset, 0));
1030
1031    f.render_widget(paragraph, area);
1032}
1033
1034fn render_admin_form(f: &mut Frame, app: &App, area: Rect) {
1035    use AdminField::*;
1036
1037    let block = Block::default()
1038        .borders(Borders::ALL)
1039        .title("Register New User");
1040    let inner = block.inner(area);
1041    f.render_widget(block, area);
1042
1043    let sections = Layout::default()
1044        .direction(Direction::Vertical)
1045        .constraints([
1046            Constraint::Length(3),  // Instructions (2 lines + space)
1047            Constraint::Length(1),  // GPG Fingerprint field
1048            Constraint::Length(1),  // Key Path field
1049            Constraint::Length(1),  // Metadata field
1050            Constraint::Length(2),  // Metadata help text
1051            Constraint::Length(1),  // Submit hint
1052            Constraint::Min(0),     // Status message / remaining space
1053        ])
1054        .split(inner);
1055
1056    let instruction_text = vec![
1057        Line::from("Provide the user's GPG fingerprint and the path to their public key."),
1058        Line::from("Press Tab to switch fields | Enter to submit | Esc to clear | Ctrl+Shift+L to load user list"),
1059    ];
1060
1061    let instructions = Paragraph::new(instruction_text)
1062        .wrap(Wrap { trim: true })
1063        .style(Style::default().fg(Color::White));
1064    f.render_widget(instructions, sections[0]);
1065
1066    // GPG Fingerprint label and field in one line
1067    let fp_value = app.admin_form.gpg_fp.as_str();
1068    let fp_cursor = fp_value.chars().count();
1069    let fp_style = if app.admin_form.active_field == GpgFingerprint {
1070        Style::default()
1071            .fg(Color::Cyan)
1072            .add_modifier(Modifier::BOLD)
1073    } else {
1074        Style::default()
1075    };
1076    
1077    let fp_label_width = 18; // "GPG Fingerprint: "
1078    let fp_field_width = sections[1].width.saturating_sub(fp_label_width) as usize;
1079    let fp_scroll = if fp_field_width > 0 && fp_cursor >= fp_field_width {
1080        fp_cursor - fp_field_width + 1
1081    } else {
1082        0
1083    };
1084    
1085    let fp_line = Line::from(vec![
1086        Span::styled("GPG Fingerprint: ", Style::default().fg(Color::Yellow)),
1087        Span::styled(fp_value, fp_style),
1088    ]);
1089    
1090    let fp_paragraph = Paragraph::new(vec![fp_line])
1091        .style(Style::default().fg(Color::White))
1092        .scroll((0, fp_scroll as u16));
1093    f.render_widget(fp_paragraph, sections[1]);
1094
1095    // Key Path label and field in one line
1096    let key_value = app.admin_form.key_path.as_str();
1097    let key_cursor = key_value.chars().count();
1098    let key_style = if app.admin_form.active_field == KeyPath {
1099        Style::default()
1100            .fg(Color::Cyan)
1101            .add_modifier(Modifier::BOLD)
1102    } else {
1103        Style::default()
1104    };
1105    
1106    let key_label_width = 11; // "Key Path: "
1107    let key_field_width = sections[2].width.saturating_sub(key_label_width) as usize;
1108    let key_scroll = if key_field_width > 0 && key_cursor >= key_field_width {
1109        key_cursor - key_field_width + 1
1110    } else {
1111        0
1112    };
1113    
1114    let key_line = Line::from(vec![
1115        Span::styled("Key Path: ", Style::default().fg(Color::Yellow)),
1116        Span::styled(key_value, key_style),
1117    ]);
1118    
1119    let key_paragraph = Paragraph::new(vec![key_line])
1120        .style(Style::default().fg(Color::White))
1121        .scroll((0, key_scroll as u16));
1122    f.render_widget(key_paragraph, sections[2]);
1123
1124    // Metadata label and field in one line
1125    let meta_value = app.admin_form.metadata.as_str();
1126    let meta_cursor = meta_value.chars().count();
1127    let meta_style = if app.admin_form.active_field == Metadata {
1128        Style::default()
1129            .fg(Color::Cyan)
1130            .add_modifier(Modifier::BOLD)
1131    } else {
1132        Style::default()
1133    };
1134    
1135    let meta_label_width = 11; // "Metadata: "
1136    let meta_field_width = sections[3].width.saturating_sub(meta_label_width) as usize;
1137    let meta_scroll = if meta_field_width > 0 && meta_cursor >= meta_field_width {
1138        meta_cursor - meta_field_width + 1
1139    } else {
1140        0
1141    };
1142    
1143    let meta_line = Line::from(vec![
1144        Span::styled("Metadata: ", Style::default().fg(Color::Yellow)),
1145        Span::styled(meta_value, meta_style),
1146    ]);
1147    
1148    let meta_paragraph = Paragraph::new(vec![meta_line])
1149        .style(Style::default().fg(Color::White))
1150        .scroll((0, meta_scroll as u16));
1151    f.render_widget(meta_paragraph, sections[3]);
1152
1153    // Metadata help text
1154    let help_text = vec![
1155        Line::from(Span::styled(
1156            "  (Optional) Example: --name Bob Smith --team Engineering --birthdate 24 Dec",
1157            Style::default()
1158                .fg(Color::DarkGray)
1159                .add_modifier(Modifier::ITALIC),
1160        )),
1161    ];
1162    let help_para = Paragraph::new(help_text);
1163    f.render_widget(help_para, sections[4]);
1164
1165    let hint_line = if app.admin_form_complete() {
1166        Line::from(Span::styled(
1167            "Press Enter to register user",
1168            Style::default()
1169                .fg(Color::LightGreen)
1170                .add_modifier(Modifier::ITALIC),
1171        ))
1172    } else {
1173        Line::from(Span::styled(
1174            "GPG Fingerprint and Key Path are required before submitting",
1175            Style::default()
1176                .fg(Color::DarkGray)
1177                .add_modifier(Modifier::ITALIC),
1178        ))
1179    };
1180    let hint = Paragraph::new(vec![hint_line]).wrap(Wrap { trim: true });
1181    f.render_widget(hint, sections[5]);
1182
1183    if let Some(status) = &app.status_message {
1184        let status_para = Paragraph::new(vec![Line::from(Span::styled(
1185            status.clone(),
1186            Style::default()
1187                .fg(Color::LightCyan)
1188                .add_modifier(Modifier::ITALIC),
1189        ))])
1190        .wrap(Wrap { trim: true });
1191        f.render_widget(status_para, sections[6]);
1192    }
1193
1194    // Position cursor at the end of the active field
1195    match app.admin_form.active_field {
1196        GpgFingerprint => {
1197            let visible_cursor = fp_cursor.saturating_sub(fp_scroll);
1198            let cursor_x = sections[1].x + fp_label_width as u16 + visible_cursor.min(fp_field_width) as u16;
1199            let cursor_y = sections[1].y;
1200            f.set_cursor_position((cursor_x, cursor_y));
1201        }
1202        KeyPath => {
1203            let visible_cursor = key_cursor.saturating_sub(key_scroll);
1204            let cursor_x = sections[2].x + key_label_width as u16 + visible_cursor.min(key_field_width) as u16;
1205            let cursor_y = sections[2].y;
1206            f.set_cursor_position((cursor_x, cursor_y));
1207        }
1208        Metadata => {
1209            let visible_cursor = meta_cursor.saturating_sub(meta_scroll);
1210            let cursor_x = sections[3].x + meta_label_width as u16 + visible_cursor.min(meta_field_width) as u16;
1211            let cursor_y = sections[3].y;
1212            f.set_cursor_position((cursor_x, cursor_y));
1213        }
1214    }
1215}
1216
1217/// Format unix timestamp to human-readable string
1218fn format_timestamp(timestamp: u64) -> String {
1219    use std::time::{Duration, SystemTime, UNIX_EPOCH};
1220
1221    let datetime = UNIX_EPOCH + Duration::from_secs(timestamp);
1222    let now = SystemTime::now();
1223
1224    if let Ok(duration) = now.duration_since(datetime) {
1225        let secs = duration.as_secs();
1226        if secs < 60 {
1227            format!("{}s ago", secs)
1228        } else if secs < 3600 {
1229            format!("{}m ago", secs / 60)
1230        } else if secs < 86400 {
1231            format!("{}h ago", secs / 3600)
1232        } else if secs < 2592000 {
1233            format!("{}d ago", secs / 86400)
1234        } else {
1235            format!("{}mo ago", secs / 2592000)
1236        }
1237    } else {
1238        "future".to_string()
1239    }
1240}
1241
1242fn render_status_tab(f: &mut Frame, app: &App, area: Rect) {
1243    // Split into two sections: connection status and engine status
1244    let chunks = Layout::default()
1245        .direction(Direction::Vertical)
1246        .constraints([
1247            Constraint::Length(10), // Connection status section
1248            Constraint::Min(10),    // Engine status section
1249        ])
1250        .split(area);
1251
1252    // === Connection Status Section ===
1253    let connection_icon = match app.connection_status {
1254        crate::erlang::ConnectionStatus::Connected => "🟢",
1255        crate::erlang::ConnectionStatus::Connecting => "🟡",
1256        crate::erlang::ConnectionStatus::Disconnected => "⚫",
1257        crate::erlang::ConnectionStatus::Error => "🔴",
1258    };
1259
1260    let node_info = if let Some(ref node_name) = app.erlang_node_name {
1261        format!("{} {}", connection_icon, node_name)
1262    } else {
1263        format!("{} Not configured", connection_icon)
1264    };
1265
1266    let status_info = vec![
1267        Line::from(Span::styled(
1268            "Connection Status",
1269            Style::default().add_modifier(Modifier::BOLD),
1270        )),
1271        Line::from(""),
1272        Line::from(format!(
1273            "Mode: {}",
1274            if app.is_mock_mode {
1275                "Mock Data"
1276            } else {
1277                "Live"
1278            }
1279        )),
1280        Line::from(format!("Erlang node: {}", node_info)),
1281        Line::from(format!("Status: {}", app.connection_status.as_str())),
1282        Line::from(""),
1283        Line::from(format!(
1284            "Users loaded: {} | Active conversations: {}",
1285            app.users.len(),
1286            app.messages.len()
1287        )),
1288    ];
1289
1290    let connection_paragraph = Paragraph::new(status_info)
1291        .block(Block::default().borders(Borders::ALL).title("Connection"))
1292        .style(Style::default().fg(Color::White));
1293
1294    f.render_widget(connection_paragraph, chunks[0]);
1295
1296    // === Engine Status Section ===
1297    let mut engine_info = vec![];
1298
1299    // Add certificate renewal status at the top if available
1300    if let Some(ref status) = app.engine_status {
1301        if let Some(expires_at) = status.cert_expires_at {
1302            engine_info.push(Line::from(Span::styled(
1303                "Certificate Status",
1304                Style::default()
1305                    .add_modifier(Modifier::BOLD)
1306                    .fg(Color::Magenta),
1307            )));
1308            engine_info.push(Line::from(""));
1309
1310            // Format expiration date
1311            let expires_date = chrono::DateTime::from_timestamp(expires_at, 0)
1312                .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
1313                .unwrap_or_else(|| "Unknown".to_string());
1314
1315            // Calculate days until expiry
1316            let days_until_expiry = if let Some(seconds) = status.cert_time_until_expiry {
1317                seconds / 86400
1318            } else {
1319                0
1320            };
1321
1322            let expiry_color = if days_until_expiry < 1 {
1323                Color::Red
1324            } else if days_until_expiry < 3 {
1325                Color::Yellow
1326            } else {
1327                Color::Green
1328            };
1329
1330            let status_icon = if days_until_expiry < 1 {
1331                "🔴"
1332            } else if days_until_expiry < 3 {
1333                "🟡"
1334            } else {
1335                "🟢"
1336            };
1337
1338            engine_info.push(Line::from(vec![
1339                Span::raw(format!("{} ", status_icon)),
1340                Span::styled("Expires: ", Style::default().fg(Color::Yellow)),
1341                Span::styled(expires_date, Style::default().fg(expiry_color)),
1342                Span::raw(format!(" ({} days)", days_until_expiry)),
1343            ]));
1344
1345            if status.cert_renewal_in_progress {
1346                engine_info.push(Line::from(Span::styled(
1347                    "⏳ Renewal in progress...",
1348                    Style::default().fg(Color::Cyan).add_modifier(Modifier::ITALIC),
1349                )));
1350            } else {
1351                engine_info.push(Line::from(Span::styled(
1352                    "Press 'n' to renew certificate now",
1353                    Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC),
1354                )));
1355            }
1356
1357            engine_info.push(Line::from(""));
1358            engine_info.push(Line::from(""));
1359        }
1360    }
1361
1362    if let Some(ref status) = app.engine_status {
1363        engine_info.push(Line::from(Span::styled(
1364            "Cryptic Engine Status",
1365            Style::default()
1366                .add_modifier(Modifier::BOLD)
1367                .fg(Color::Cyan),
1368        )));
1369        engine_info.push(Line::from(""));
1370
1371        // Format uptime
1372        let uptime_str = format_uptime(status.uptime_ms);
1373
1374        engine_info.push(Line::from(vec![
1375            Span::styled("Username: ", Style::default().fg(Color::Yellow)),
1376            Span::raw(status.username.clone()),
1377            Span::raw("  |  "),
1378            Span::styled("Uptime: ", Style::default().fg(Color::Yellow)),
1379            Span::raw(uptime_str),
1380        ]));
1381
1382        engine_info.push(Line::from(vec![
1383            Span::styled("Active Sessions: ", Style::default().fg(Color::Yellow)),
1384            Span::raw(format!("{}", status.active_sessions)),
1385            Span::raw("  |  "),
1386            Span::styled("Messages: ", Style::default().fg(Color::Yellow)),
1387            Span::raw(format!("{}", status.message_count)),
1388            Span::raw("  |  "),
1389            Span::styled("Errors: ", Style::default().fg(Color::Yellow)),
1390            Span::raw(format!("{}", status.error_count)),
1391        ]));
1392
1393        engine_info.push(Line::from(""));
1394
1395        if !status.session_details.is_empty() {
1396            engine_info.push(Line::from(Span::styled(
1397                "Double Ratchet Sessions:",
1398                Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
1399            )));
1400            engine_info.push(Line::from(""));
1401
1402            // Table headers - align with data columns
1403            engine_info.push(Line::from(vec![
1404                Span::styled(
1405                    format!("{:<12}", "Peer"),
1406                    Style::default()
1407                        .add_modifier(Modifier::BOLD)
1408                        .fg(Color::Green),
1409                ),
1410                Span::styled(
1411                    format!("{:>6}", "Step"),
1412                    Style::default()
1413                        .add_modifier(Modifier::BOLD)
1414                        .fg(Color::Green),
1415                ),
1416                Span::styled(
1417                    format!("{:>6}", "Send"),
1418                    Style::default()
1419                        .add_modifier(Modifier::BOLD)
1420                        .fg(Color::Green),
1421                ),
1422                Span::styled(
1423                    format!("{:>6}", "Recv"),
1424                    Style::default()
1425                        .add_modifier(Modifier::BOLD)
1426                        .fg(Color::Green),
1427                ),
1428                Span::styled(
1429                    format!("{:>6}", "Prev"),
1430                    Style::default()
1431                        .add_modifier(Modifier::BOLD)
1432                        .fg(Color::Green),
1433                ),
1434                Span::styled(
1435                    format!("{:>9}", "Skipped"),
1436                    Style::default()
1437                        .add_modifier(Modifier::BOLD)
1438                        .fg(Color::Green),
1439                ),
1440                Span::raw("  "),
1441                Span::styled(
1442                    "State",
1443                    Style::default()
1444                        .add_modifier(Modifier::BOLD)
1445                        .fg(Color::Green),
1446                ),
1447            ]));
1448
1449            // Session rows
1450            for session in &status.session_details {
1451                engine_info.push(Line::from(vec![
1452                    Span::raw(format!(
1453                        "{:<12}",
1454                        truncate_string(&session.peer_username, 12)
1455                    )),
1456                    Span::raw(format!("{:>6}", session.dh_ratchet_step)),
1457                    Span::raw(format!("{:>6}", session.send_msg_number)),
1458                    Span::raw(format!("{:>6}", session.recv_msg_number)),
1459                    Span::raw(format!("{:>6}", session.prev_recv_chain_length)),
1460                    Span::raw(format!("{:>9}", session.skipped_keys_count)),
1461                    Span::raw("  "),
1462                    Span::styled(
1463                        &session.current_state,
1464                        if session.current_state.contains("error") {
1465                            Style::default().fg(Color::Red)
1466                        } else if session.receiving_chain_active || session.sending_chain_active {
1467                            Style::default().fg(Color::Green)
1468                        } else {
1469                            Style::default().fg(Color::Gray)
1470                        },
1471                    ),
1472                ]));
1473            }
1474
1475            // Add explanation text
1476            engine_info.push(Line::from(""));
1477            engine_info.push(Line::from(Span::styled(
1478                "Legend:",
1479                Style::default()
1480                    .fg(Color::Gray)
1481                    .add_modifier(Modifier::ITALIC),
1482            )));
1483            engine_info.push(Line::from(Span::styled(
1484                "  Step: DH ratchet steps (key rotations) that have occurred",
1485                Style::default()
1486                    .fg(Color::DarkGray)
1487                    .add_modifier(Modifier::ITALIC),
1488            )));
1489            engine_info.push(Line::from(Span::styled(
1490                "  Send/Recv: Current chain message counters (reset on DH step)",
1491                Style::default()
1492                    .fg(Color::DarkGray)
1493                    .add_modifier(Modifier::ITALIC),
1494            )));
1495            engine_info.push(Line::from(Span::styled(
1496                "  Prev: Messages from the previous receiving chain",
1497                Style::default()
1498                    .fg(Color::DarkGray)
1499                    .add_modifier(Modifier::ITALIC),
1500            )));
1501            engine_info.push(Line::from(Span::styled(
1502                "  Skipped: Out-of-order messages cached for delayed delivery",
1503                Style::default()
1504                    .fg(Color::DarkGray)
1505                    .add_modifier(Modifier::ITALIC),
1506            )));
1507        } else {
1508            engine_info.push(Line::from(Span::styled(
1509                "No active sessions",
1510                Style::default()
1511                    .fg(Color::DarkGray)
1512                    .add_modifier(Modifier::ITALIC),
1513            )));
1514        }
1515
1516        engine_info.push(Line::from(""));
1517        engine_info.push(Line::from(Span::styled(
1518            "Press 'r' to refresh engine status",
1519            Style::default().fg(Color::Gray),
1520        )));
1521    } else {
1522        engine_info.push(Line::from(Span::styled(
1523            "Engine Status",
1524            Style::default()
1525                .add_modifier(Modifier::BOLD)
1526                .fg(Color::Cyan),
1527        )));
1528        engine_info.push(Line::from(""));
1529        engine_info.push(Line::from(Span::styled(
1530            "No engine status available. Press 'r' to refresh.",
1531            Style::default().fg(Color::Yellow),
1532        )));
1533    }
1534
1535    let engine_paragraph = Paragraph::new(engine_info)
1536        .block(
1537            Block::default()
1538                .borders(Borders::ALL)
1539                .title("System Status"),
1540        )
1541        .style(Style::default().fg(Color::White))
1542        .scroll((app.status_scroll_offset, 0));
1543
1544    f.render_widget(engine_paragraph, chunks[1]);
1545}
1546
1547/// Format uptime from microseconds to human-readable string
1548fn format_uptime(uptime_us: u64) -> String {
1549    let seconds = uptime_us / 1_000_000;
1550    let minutes = seconds / 60;
1551    let hours = minutes / 60;
1552    let days = hours / 24;
1553
1554    if days > 0 {
1555        format!("{}d {}h", days, hours % 24)
1556    } else if hours > 0 {
1557        format!("{}h {}m", hours, minutes % 60)
1558    } else if minutes > 0 {
1559        format!("{}m {}s", minutes, seconds % 60)
1560    } else {
1561        format!("{}s", seconds)
1562    }
1563}
1564
1565/// Truncate a string to a maximum length, adding "..." if needed
1566fn truncate_string(s: &str, max_len: usize) -> String {
1567    if s.len() <= max_len {
1568        s.to_string()
1569    } else if max_len > 3 {
1570        format!("{}...", &s[..max_len - 3])
1571    } else {
1572        s[..max_len].to_string()
1573    }
1574}
1575
1576fn render_help_tab(f: &mut Frame, app: &App, area: Rect) {
1577    let help_text = vec![
1578        Line::from(Span::styled(
1579            "Keyboard Shortcuts",
1580            Style::default().add_modifier(Modifier::BOLD),
1581        )),
1582        Line::from(""),
1583        Line::from("Global:"),
1584        Line::from("  Ctrl+Q   - Quit application"),
1585        Line::from("  Tab      - Next tab"),
1586        Line::from("  Shift+Tab - Previous tab"),
1587        Line::from(""),
1588        Line::from("Chat Tab:"),
1589        Line::from("  ↑/↓      - Navigate user list"),
1590        Line::from("  Enter    - Select user / Send message"),
1591        Line::from("  Ctrl+U   - Scroll up messages (or PgUp / Fn+↑)"),
1592        Line::from("  Ctrl+D   - Scroll down messages (or PgDn / Fn+↓)"),
1593        Line::from("  Home     - Jump to beginning of conversation"),
1594        Line::from("  End      - Jump to latest messages"),
1595        Line::from("  Esc      - Clear input"),
1596        Line::from(""),
1597        Line::from("Text Editing (Emacs-style):"),
1598        Line::from("  Ctrl+A   - Move to beginning of line"),
1599        Line::from("  Ctrl+E   - Move to end of line"),
1600        Line::from("  Ctrl+B   - Move cursor left (or ←)"),
1601        Line::from("  Ctrl+F   - Move cursor right (or →)"),
1602        Line::from("  Backspace - Delete before cursor"),
1603        Line::from("  Delete   - Delete at cursor"),
1604        Line::from(""),
1605        Line::from("Status Tab:"),
1606        Line::from("  r        - Refresh engine status"),
1607        Line::from("  n        - Renew certificate now"),
1608        Line::from(""),
1609        Line::from("Admin Tab:"),
1610        Line::from("  Ctrl+Shift+L - Load/refresh detailed user list"),
1611        Line::from("  Tab/↑/↓ - Switch between register form fields"),
1612        Line::from("  Enter    - Submit new user registration"),
1613        Line::from("  Esc      - Clear register form"),
1614        Line::from("  Ctrl+H/L - Navigate to previous/next tab"),
1615        Line::from("  r        - Revoke user (coming soon)"),
1616        Line::from("  s        - Suspend user (coming soon)"),
1617        Line::from(""),
1618        Line::from(Span::styled(
1619            "MacBook Users: Use Ctrl+U/D for scrolling (easier than Fn+↑/↓)",
1620            Style::default().fg(Color::Yellow),
1621        )),
1622    ];
1623
1624    let paragraph = Paragraph::new(help_text)
1625        .block(Block::default().borders(Borders::ALL).title("Help"))
1626        .style(Style::default().fg(Color::White))
1627        .scroll((app.help_scroll_offset, 0));
1628
1629    f.render_widget(paragraph, area);
1630}
1631
1632fn render_splash(f: &mut Frame, app: &App, area: Rect) {
1633    // Centered layout
1634    let outer = Layout::default()
1635        .direction(Direction::Vertical)
1636        .constraints([
1637            Constraint::Percentage(30),
1638            Constraint::Percentage(40),
1639            Constraint::Percentage(30),
1640        ])
1641        .split(area);
1642
1643    let inner = Layout::default()
1644        .direction(Direction::Horizontal)
1645        .constraints([
1646            Constraint::Percentage(25),
1647            Constraint::Percentage(50),
1648            Constraint::Percentage(25),
1649        ])
1650        .split(outer[1]);
1651
1652    let panel_area = inner[1];
1653    let title = Span::styled(
1654        "CRYPTIC TERMINAL UI",
1655        Style::default()
1656            .fg(Color::Cyan)
1657            .add_modifier(Modifier::BOLD),
1658    );
1659
1660    let masked = if app.passphrase_input.is_empty() {
1661        "".to_string()
1662    } else {
1663        "*".repeat(app.passphrase_input.chars().count())
1664    };
1665
1666    let mut lines: Vec<Line> = vec![
1667        Line::from(title),
1668        Line::from(""),
1669        Line::from("Secure messaging interface"),
1670        Line::from(""),
1671        Line::from(Span::styled(
1672            "Enter passphrase to unlock local key material",
1673            Style::default().fg(Color::White),
1674        )),
1675        Line::from(""),
1676        Line::from(format!("Passphrase: {}", masked)),
1677        Line::from(""),
1678        Line::from("Enter continue | Esc clear | Ctrl+Q quit"),
1679    ];
1680
1681    if let Some(err) = &app.splash_error {
1682        lines.push(Line::from(Span::styled(
1683            err,
1684            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1685        )));
1686    }
1687
1688    let block = Block::default()
1689        .title("Unlock")
1690        .borders(Borders::ALL)
1691        .border_style(Style::default().fg(Color::Cyan));
1692
1693    let paragraph = Paragraph::new(lines)
1694        .block(block)
1695        .alignment(ratatui::layout::Alignment::Center);
1696
1697    f.render_widget(paragraph, panel_area);
1698}
1699
1700fn render_status_bar(f: &mut Frame, app: &App, area: Rect) {
1701    let text = if let Some(msg) = app.get_current_system_message() {
1702        // System message with distinct styling
1703        Span::styled(
1704            format!(" ℹ {}", msg),
1705            Style::default()
1706                .fg(Color::Black)
1707                .bg(Color::LightCyan)
1708                .add_modifier(Modifier::BOLD),
1709        )
1710    } else {
1711        // Default status bar shows server connection state
1712        let (icon, status_text) = if app.server_connection_up {
1713            ("🟢", "Server up!")
1714        } else {
1715            ("🔴", "Server down!")
1716        };
1717        
1718        Span::styled(
1719            format!(" {} {}", icon, status_text),
1720            Style::default()
1721                .fg(Color::White)
1722                .bg(Color::DarkGray),
1723        )
1724    };
1725
1726    let paragraph = Paragraph::new(Line::from(text));
1727    f.render_widget(paragraph, area);
1728}