Skip to main content

room_cli/tui/
mod.rs

1use std::collections::HashMap;
2use std::io;
3
4#[cfg(unix)]
5use std::os::unix::io::AsRawFd;
6
7use crossterm::{
8    event::{self, DisableBracketedPaste, EnableBracketedPaste, Event},
9    execute,
10    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
11};
12use ratatui::{
13    backend::CrosstermBackend,
14    layout::{Alignment, Constraint, Direction, Layout, Rect},
15    style::{Color, Modifier, Style},
16    text::{Line, Span, Text},
17    widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
18    Terminal,
19};
20use tokio::{
21    io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
22    sync::mpsc,
23};
24
25mod input;
26mod render;
27mod render_bots;
28mod widgets;
29
30use room_protocol::SubscriptionTier;
31
32use crate::message::Message;
33use input::{
34    build_payload, cursor_display_pos, handle_key, parse_kick_broadcast, parse_status_broadcast,
35    parse_subscription_broadcast, seed_online_users_from_who, wrap_input_display, Action,
36    InputState,
37};
38use render::{
39    assign_color, find_view_start, format_message, render_tab_bar, user_color, welcome_splash,
40    ColorMap, TabInfo,
41};
42
43/// Maximum visible content lines in the input box before it stops growing.
44const MAX_INPUT_LINES: usize = 6;
45
46/// Per-room state for the tabbed TUI. Each tab owns its message buffer,
47/// online user list, status map, and inbound message channel.
48struct RoomTab {
49    room_id: String,
50    messages: Vec<Message>,
51    online_users: Vec<String>,
52    user_statuses: HashMap<String, String>,
53    subscription_tiers: HashMap<String, SubscriptionTier>,
54    unread_count: usize,
55    scroll_offset: usize,
56    msg_rx: mpsc::UnboundedReceiver<Message>,
57    write_half: tokio::net::unix::OwnedWriteHalf,
58}
59
60/// Result of draining messages from a tab's channel.
61enum DrainResult {
62    /// Channel still open, messages drained.
63    Ok,
64    /// Channel closed — broker disconnected.
65    Disconnected,
66}
67
68impl RoomTab {
69    /// Process a single inbound message, updating online_users, statuses, and
70    /// the color map. Pushes the message into the buffer and increments unread
71    /// if `is_active` is false.
72    fn process_message(&mut self, msg: Message, color_map: &mut ColorMap, is_active: bool) {
73        match &msg {
74            Message::Join { user, .. } if !self.online_users.contains(user) => {
75                assign_color(user, color_map);
76                self.online_users.push(user.clone());
77            }
78            Message::Leave { user, .. } => {
79                self.online_users.retain(|u| u != user);
80                self.user_statuses.remove(user);
81                self.subscription_tiers.remove(user);
82            }
83            Message::Message { user, .. } if !self.online_users.contains(user) => {
84                assign_color(user, color_map);
85                self.online_users.push(user.clone());
86            }
87            Message::Message { user, .. } => {
88                assign_color(user, color_map);
89            }
90            Message::System { user, content, .. } if user == "broker" => {
91                seed_online_users_from_who(
92                    content,
93                    &mut self.online_users,
94                    &mut self.user_statuses,
95                );
96                if let Some((name, status)) = parse_status_broadcast(content) {
97                    self.user_statuses.insert(name, status);
98                }
99                if let Some(kicked) = parse_kick_broadcast(content) {
100                    self.online_users.retain(|u| u != kicked);
101                    self.user_statuses.remove(kicked);
102                    self.subscription_tiers.remove(kicked);
103                }
104                if let Some((name, tier)) = parse_subscription_broadcast(content) {
105                    self.subscription_tiers.insert(name, tier);
106                }
107                for u in &self.online_users {
108                    assign_color(u, color_map);
109                }
110            }
111            _ => {}
112        }
113        if !is_active {
114            self.unread_count += 1;
115        }
116        self.messages.push(msg);
117    }
118
119    /// Drain all pending messages from the channel into the message buffer.
120    fn drain_messages(&mut self, color_map: &mut ColorMap, is_active: bool) -> DrainResult {
121        loop {
122            match self.msg_rx.try_recv() {
123                Ok(msg) => self.process_message(msg, color_map, is_active),
124                Err(mpsc::error::TryRecvError::Empty) => return DrainResult::Ok,
125                Err(mpsc::error::TryRecvError::Disconnected) => return DrainResult::Disconnected,
126            }
127        }
128    }
129}
130
131/// Read the global token for `username` from the token file on disk.
132///
133/// Returns `None` if the file doesn't exist or can't be parsed.
134fn read_user_token(username: &str) -> Option<String> {
135    let path = crate::paths::global_token_path(username);
136    let data = std::fs::read_to_string(&path).ok()?;
137    let v: serde_json::Value = serde_json::from_str(data.trim()).ok()?;
138    v["token"].as_str().map(|s| s.to_owned())
139}
140
141/// Create or reuse a DM room and return a connected `RoomTab`.
142///
143/// 1. Sends `CREATE:<dm_room_id>` to the daemon socket. If the room already
144///    exists, the daemon returns `room_already_exists` — this is fine, we proceed.
145/// 2. Connects to the daemon with `ROOM:<dm_room_id>:<username>` for an
146///    interactive session.
147/// 3. Spawns a reader task and returns a `RoomTab` ready for the tab list.
148async fn open_dm_tab(
149    socket_path: &std::path::Path,
150    dm_room_id: &str,
151    username: &str,
152    target_user: &str,
153    history_lines: usize,
154) -> anyhow::Result<RoomTab> {
155    use tokio::net::UnixStream;
156
157    // Step 1: Create the DM room (idempotent — ignore "already exists").
158    // Read the user's token from the token file for authentication.
159    let token = read_user_token(username).unwrap_or_default();
160    let config = room_protocol::RoomConfig::dm(username, target_user);
161    let config_json = serde_json::to_string(&config)?;
162    let authed_config = crate::oneshot::transport::inject_token_into_config(&config_json, &token);
163    match crate::oneshot::transport::create_room(socket_path, dm_room_id, &authed_config).await {
164        Ok(_) => {}
165        Err(e) if e.to_string().contains("already exists") => {}
166        Err(e) => return Err(e),
167    }
168
169    // Step 2: Join the DM room via the daemon socket.
170    let stream = UnixStream::connect(socket_path).await?;
171    let (read_half, mut write_half) = stream.into_split();
172    let handshake = format!("ROOM:{dm_room_id}:{username}\n");
173    write_half.write_all(handshake.as_bytes()).await?;
174
175    // Step 3: Spawn reader task for the new tab.
176    let (tx, rx) = mpsc::unbounded_channel::<Message>();
177    let username_owned = username.to_owned();
178    let reader = BufReader::new(read_half);
179
180    tokio::spawn(async move {
181        let mut reader = reader;
182        let mut history_buf: Vec<Message> = Vec::new();
183        let mut joined = false;
184        let mut line = String::new();
185
186        loop {
187            line.clear();
188            match reader.read_line(&mut line).await {
189                Ok(0) => break,
190                Ok(_) => {
191                    let trimmed = line.trim();
192                    if trimmed.is_empty() {
193                        continue;
194                    }
195                    let Ok(msg) = serde_json::from_str::<Message>(trimmed) else {
196                        continue;
197                    };
198
199                    if joined {
200                        let _ = tx.send(msg);
201                    } else {
202                        let is_own_join =
203                            matches!(&msg, Message::Join { user, .. } if user == &username_owned);
204                        if is_own_join {
205                            joined = true;
206                            let start = history_buf.len().saturating_sub(history_lines);
207                            for h in history_buf.drain(start..) {
208                                let _ = tx.send(h);
209                            }
210                            let _ = tx.send(msg);
211                        } else {
212                            history_buf.push(msg);
213                        }
214                    }
215                }
216                Err(_) => break,
217            }
218        }
219    });
220
221    // Request /who to seed the member panel.
222    let who_payload = build_payload("/who");
223    write_half
224        .write_all(format!("{who_payload}\n").as_bytes())
225        .await?;
226
227    Ok(RoomTab {
228        room_id: dm_room_id.to_owned(),
229        messages: Vec::new(),
230        online_users: Vec::new(),
231        user_statuses: HashMap::new(),
232        subscription_tiers: HashMap::new(),
233        unread_count: 0,
234        scroll_offset: 0,
235        msg_rx: rx,
236        write_half,
237    })
238}
239
240/// Switch the active tab and sync scroll state between input and the new tab.
241fn switch_to_tab(
242    tabs: &mut [RoomTab],
243    active_tab: &mut usize,
244    input_state: &mut InputState,
245    idx: usize,
246) {
247    tabs[*active_tab].scroll_offset = input_state.scroll_offset;
248    *active_tab = idx;
249    tabs[*active_tab].unread_count = 0;
250    input_state.scroll_offset = tabs[*active_tab].scroll_offset;
251}
252
253/// Write a newline-terminated payload to a write half.
254async fn write_payload_to_tab(
255    write_half: &mut tokio::net::unix::OwnedWriteHalf,
256    payload: &str,
257) -> anyhow::Result<()> {
258    write_half
259        .write_all(format!("{payload}\n").as_bytes())
260        .await
261        .map_err(Into::into)
262}
263
264/// Parameters for opening a DM room tab. Bundles the session-scoped constants
265/// so `handle_dm_action` stays under the `too-many-arguments` threshold.
266struct DmTabConfig<'a> {
267    socket_path: &'a std::path::Path,
268    username: &'a str,
269    history_lines: usize,
270}
271
272/// Handle `Action::DmRoom` — open or reuse a DM tab and send the first message.
273///
274/// Returns `Ok(())` on success. On `Err`, the caller should set `result` and
275/// `break 'main` to exit the event loop cleanly.
276async fn handle_dm_action(
277    tabs: &mut Vec<RoomTab>,
278    active_tab: &mut usize,
279    input_state: &mut InputState,
280    cfg: &DmTabConfig<'_>,
281    target_user: String,
282    content: String,
283) -> anyhow::Result<()> {
284    let fallback = serde_json::json!({
285        "type": "dm",
286        "to": target_user,
287        "content": content
288    })
289    .to_string();
290
291    let Ok(dm_id) = room_protocol::dm_room_id(cfg.username, &target_user) else {
292        // Same user — send as intra-room DM; the broker will reject it cleanly.
293        return write_payload_to_tab(&mut tabs[*active_tab].write_half, &fallback).await;
294    };
295
296    if let Some(idx) = tabs.iter().position(|t| t.room_id == dm_id) {
297        // Tab already open — switch and send.
298        switch_to_tab(tabs, active_tab, input_state, idx);
299        return write_payload_to_tab(&mut tabs[*active_tab].write_half, &build_payload(&content))
300            .await;
301    }
302
303    // Create the DM room and open a new tab.
304    match open_dm_tab(
305        cfg.socket_path,
306        &dm_id,
307        cfg.username,
308        &target_user,
309        cfg.history_lines,
310    )
311    .await
312    {
313        Ok(new_tab) => {
314            tabs.push(new_tab);
315            tabs[*active_tab].scroll_offset = input_state.scroll_offset;
316            *active_tab = tabs.len() - 1;
317            input_state.scroll_offset = 0;
318            write_payload_to_tab(&mut tabs[*active_tab].write_half, &build_payload(&content)).await
319        }
320        Err(_) => {
321            // DM room creation failed — fall back to intra-room DM.
322            write_payload_to_tab(&mut tabs[*active_tab].write_half, &fallback).await
323        }
324    }
325}
326
327pub async fn run(
328    reader: BufReader<tokio::net::unix::OwnedReadHalf>,
329    write_half: tokio::net::unix::OwnedWriteHalf,
330    room_id: &str,
331    username: &str,
332    history_lines: usize,
333    socket_path: std::path::PathBuf,
334) -> anyhow::Result<()> {
335    let (msg_tx, msg_rx) = mpsc::unbounded_channel::<Message>();
336    let username_owned = username.to_owned();
337
338    // Spawn socket-reader task: buffers history until our join event,
339    // then streams live messages.
340    tokio::spawn(async move {
341        let mut reader = reader;
342        let mut history_buf: Vec<Message> = Vec::new();
343        let mut joined = false;
344        let mut line = String::new();
345
346        loop {
347            line.clear();
348            match reader.read_line(&mut line).await {
349                Ok(0) => break,
350                Ok(_) => {
351                    let trimmed = line.trim();
352                    if trimmed.is_empty() {
353                        continue;
354                    }
355                    let Ok(msg) = serde_json::from_str::<Message>(trimmed) else {
356                        continue;
357                    };
358
359                    if joined {
360                        let _ = msg_tx.send(msg);
361                    } else {
362                        let is_own_join =
363                            matches!(&msg, Message::Join { user, .. } if user == &username_owned);
364                        if is_own_join {
365                            joined = true;
366                            // Flush last N history entries then the join event
367                            let start = history_buf.len().saturating_sub(history_lines);
368                            for h in history_buf.drain(start..) {
369                                let _ = msg_tx.send(h);
370                            }
371                            let _ = msg_tx.send(msg);
372                        } else {
373                            history_buf.push(msg);
374                        }
375                    }
376                }
377                Err(_) => break,
378            }
379        }
380    });
381
382    let tab = RoomTab {
383        room_id: room_id.to_owned(),
384        messages: Vec::new(),
385        online_users: Vec::new(),
386        user_statuses: HashMap::new(),
387        subscription_tiers: HashMap::new(),
388        unread_count: 0,
389        scroll_offset: 0,
390        msg_rx,
391        write_half,
392    };
393
394    // Redirect stderr to ~/.room/room.log so that eprintln! from the broker
395    // (which runs in a background task) does not corrupt the TUI alternate screen.
396    #[cfg(unix)]
397    let saved_stderr_fd = redirect_stderr_to_log();
398
399    // Setup terminal
400    enable_raw_mode()?;
401    let mut stdout = io::stdout();
402    execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
403    let backend = CrosstermBackend::new(stdout);
404    let mut terminal = Terminal::new(backend)?;
405
406    let mut tabs: Vec<RoomTab> = vec![tab];
407    let mut active_tab: usize = 0;
408    let mut color_map = ColorMap::new();
409    let mut input_state = InputState::new();
410    let mut result: anyhow::Result<()> = Ok(());
411    let mut frame_count: usize = 0;
412
413    // Seed for generative bot faces — fixed per session so the splash is stable.
414    let splash_seed = std::time::SystemTime::now()
415        .duration_since(std::time::UNIX_EPOCH)
416        .map(|d| {
417            d.as_secs()
418                .wrapping_mul(6364136223846793005)
419                .wrapping_add(d.subsec_nanos() as u64)
420        })
421        .unwrap_or(0xdeadbeef_cafebabe);
422
423    // Seed online_users immediately so @mention autocomplete works for users
424    // who were already connected before we joined.
425    let who_payload = build_payload("/who");
426    tabs[active_tab]
427        .write_half
428        .write_all(format!("{who_payload}\n").as_bytes())
429        .await?;
430
431    'main: loop {
432        // Sync scroll_offset: handle_key modifies input_state.scroll_offset,
433        // but rendering reads from tabs[active_tab].scroll_offset.
434        tabs[active_tab].scroll_offset = input_state.scroll_offset;
435
436        // Drain pending messages from all tabs.
437        for (i, t) in tabs.iter_mut().enumerate() {
438            let is_active = i == active_tab;
439            if matches!(
440                t.drain_messages(&mut color_map, is_active),
441                DrainResult::Disconnected
442            ) && is_active
443            {
444                break 'main;
445            }
446        }
447
448        let show_tab_bar = tabs.len() > 1;
449
450        let term_area = terminal.size()?;
451        // Input content width is terminal width minus the two border columns.
452        let input_content_width = term_area.width.saturating_sub(2) as usize;
453
454        // Compute wrapped display rows for the input and the cursor position within them.
455        let input_display_rows = wrap_input_display(&input_state.input, input_content_width);
456        let total_input_rows = input_display_rows.len();
457        let visible_input_lines = total_input_rows.min(MAX_INPUT_LINES);
458        // +2 for top and bottom borders; minimum 3 (1 content line + 2 borders).
459        let input_box_height = (visible_input_lines + 2) as u16;
460
461        let (cursor_row, cursor_col) = cursor_display_pos(
462            &input_state.input,
463            input_state.cursor_pos,
464            input_content_width,
465        );
466
467        // Adjust vertical scroll so the cursor stays visible.
468        if cursor_row < input_state.input_row_scroll {
469            input_state.input_row_scroll = cursor_row;
470        }
471        if visible_input_lines > 0
472            && cursor_row >= input_state.input_row_scroll + visible_input_lines
473        {
474            input_state.input_row_scroll = cursor_row + 1 - visible_input_lines;
475        }
476
477        let content_width = term_area.width.saturating_sub(2) as usize;
478
479        // Build layout constraints: optional tab bar + message area + input box.
480        let constraints = if show_tab_bar {
481            vec![
482                Constraint::Length(1),
483                Constraint::Min(3),
484                Constraint::Length(input_box_height),
485            ]
486        } else {
487            vec![Constraint::Min(3), Constraint::Length(input_box_height)]
488        };
489
490        // Compute visible message lines by pre-computing the layout split.
491        let msg_area_height = {
492            let chunks = Layout::default()
493                .direction(Direction::Vertical)
494                .constraints(constraints.clone())
495                .split(Rect::new(0, 0, term_area.width, term_area.height));
496            let msg_chunk = if show_tab_bar { chunks[1] } else { chunks[0] };
497            msg_chunk.height.saturating_sub(2) as usize
498        };
499
500        let msg_texts: Vec<Text<'static>> = tabs[active_tab]
501            .messages
502            .iter()
503            .map(|m| format_message(m, content_width, &color_map))
504            .collect();
505
506        let heights: Vec<usize> = msg_texts.iter().map(|t| t.lines.len().max(1)).collect();
507        let total_lines: usize = heights.iter().sum();
508
509        // Clamp scroll offset so it can't exceed scrollable range
510        tabs[active_tab].scroll_offset = tabs[active_tab]
511            .scroll_offset
512            .min(total_lines.saturating_sub(msg_area_height));
513        // Sync clamped value back to input_state so handle_key sees the clamped value.
514        input_state.scroll_offset = tabs[active_tab].scroll_offset;
515
516        // Capture values needed by the draw closure (avoid borrowing tabs inside closure).
517        let scroll_offset = tabs[active_tab].scroll_offset;
518        let room_id_display = tabs[active_tab].room_id.clone();
519        let online_users_ref = &tabs[active_tab].online_users;
520        let user_statuses_ref = &tabs[active_tab].user_statuses;
521        let subscription_tiers_ref = &tabs[active_tab].subscription_tiers;
522        let messages_ref = &tabs[active_tab].messages;
523
524        // Build tab bar info for multi-tab rendering.
525        let tab_infos: Vec<TabInfo> = tabs
526            .iter()
527            .enumerate()
528            .map(|(i, t)| TabInfo {
529                room_id: t.room_id.clone(),
530                active: i == active_tab,
531                unread: t.unread_count,
532            })
533            .collect();
534
535        terminal.draw(|f| {
536            let chunks = Layout::default()
537                .direction(Direction::Vertical)
538                .constraints(constraints.clone())
539                .split(f.area());
540
541            let (tab_bar_chunk, msg_chunk, input_chunk) = if show_tab_bar {
542                (Some(chunks[0]), chunks[1], chunks[2])
543            } else {
544                (None, chunks[0], chunks[1])
545            };
546
547            // Render tab bar if multi-tab.
548            if let Some(bar_area) = tab_bar_chunk {
549                if let Some(bar_line) = render_tab_bar(&tab_infos) {
550                    let bar_widget =
551                        Paragraph::new(bar_line).style(Style::default().bg(Color::Black));
552                    f.render_widget(bar_widget, bar_area);
553                }
554            }
555
556            let view_bottom = total_lines.saturating_sub(scroll_offset);
557            let view_top = view_bottom.saturating_sub(msg_area_height);
558
559            let (start_msg_idx, skip_first) = find_view_start(&heights, view_top);
560
561            let visible: Vec<ListItem> = msg_texts[start_msg_idx..]
562                .iter()
563                .enumerate()
564                .map(|(i, text)| {
565                    if i == 0 && skip_first > 0 {
566                        ListItem::new(Text::from(text.lines[skip_first..].to_vec()))
567                    } else {
568                        ListItem::new(text.clone())
569                    }
570                })
571                .collect();
572
573            let title = if scroll_offset > 0 {
574                format!(" {} [↑ {} lines] ", room_id_display, scroll_offset)
575            } else {
576                format!(" {} ", room_id_display)
577            };
578
579            // Show the welcome splash when there are no chat messages yet.
580            let has_chat = messages_ref.iter().any(|m| {
581                matches!(
582                    m,
583                    Message::Message { .. }
584                        | Message::Reply { .. }
585                        | Message::Command { .. }
586                        | Message::DirectMessage { .. }
587                )
588            });
589
590            let version_title =
591                Line::from(format!(" v{} ", env!("CARGO_PKG_VERSION"))).alignment(Alignment::Right);
592
593            if !has_chat {
594                let splash_width = msg_chunk.width.saturating_sub(2) as usize;
595                let splash_height = msg_chunk.height.saturating_sub(2) as usize;
596                let splash = welcome_splash(frame_count, splash_width, splash_height, splash_seed);
597                let splash_widget = Paragraph::new(splash)
598                    .block(
599                        Block::default()
600                            .title(title.clone())
601                            .title_top(version_title)
602                            .borders(Borders::ALL)
603                            .border_style(Style::default().fg(Color::DarkGray)),
604                    )
605                    .alignment(Alignment::Left);
606                f.render_widget(splash_widget, msg_chunk);
607            } else {
608                let msg_list = List::new(visible).block(
609                    Block::default()
610                        .title(title)
611                        .title_top(version_title)
612                        .borders(Borders::ALL)
613                        .border_style(Style::default().fg(Color::DarkGray)),
614                );
615                f.render_widget(msg_list, msg_chunk);
616            }
617
618            // Render only the visible slice of wrapped input rows.
619            let end = (input_state.input_row_scroll + visible_input_lines).min(total_input_rows);
620            let display_text = input_display_rows[input_state.input_row_scroll..end].join("\n");
621
622            let input_widget = Paragraph::new(display_text)
623                .block(
624                    Block::default()
625                        .title(format!(" {username} "))
626                        .borders(Borders::ALL)
627                        .border_style(Style::default().fg(Color::Cyan)),
628                )
629                .style(Style::default().fg(Color::White));
630            f.render_widget(input_widget, input_chunk);
631
632            // Place terminal cursor inside the input box.
633            let visible_cursor_row = cursor_row - input_state.input_row_scroll;
634            let cursor_x = input_chunk.x + 1 + cursor_col as u16;
635            let cursor_y = input_chunk.y + 1 + visible_cursor_row as u16;
636            f.set_cursor_position((cursor_x, cursor_y));
637
638            // Render floating member status panel (top-right of message area).
639            // Hidden when terminal is too narrow (< 80 cols) or no users online.
640            const PANEL_MIN_TERM_WIDTH: u16 = 80;
641            if f.area().width >= PANEL_MIN_TERM_WIDTH && !online_users_ref.is_empty() {
642                let panel_items: Vec<ListItem> = online_users_ref
643                    .iter()
644                    .map(|u| {
645                        let status = user_statuses_ref.get(u).map(|s| s.as_str()).unwrap_or("");
646                        let tier = subscription_tiers_ref.get(u).copied();
647                        let mut spans = vec![Span::styled(
648                            format!(" {u}"),
649                            Style::default()
650                                .fg(user_color(u, &color_map))
651                                .add_modifier(Modifier::BOLD),
652                        )];
653                        match tier {
654                            Some(SubscriptionTier::MentionsOnly) => {
655                                spans.push(Span::styled(" @", Style::default().fg(Color::Yellow)));
656                            }
657                            Some(SubscriptionTier::Unsubscribed) => {
658                                spans.push(Span::styled(
659                                    " \u{2717}",
660                                    Style::default().fg(Color::Red),
661                                ));
662                            }
663                            _ => {}
664                        }
665                        if !status.is_empty() {
666                            spans.push(Span::styled(
667                                format!("  {status}"),
668                                Style::default().fg(Color::DarkGray),
669                            ));
670                        }
671                        spans.push(Span::raw(" "));
672                        ListItem::new(Line::from(spans))
673                    })
674                    .collect();
675
676                let panel_content_width = online_users_ref
677                    .iter()
678                    .map(|u| {
679                        let status = user_statuses_ref.get(u).map(|s| s.as_str()).unwrap_or("");
680                        let status_len = if status.is_empty() {
681                            0
682                        } else {
683                            status.len() + 2 // "  " + status
684                        };
685                        let tier = subscription_tiers_ref.get(u).copied();
686                        let tier_len = match tier {
687                            Some(SubscriptionTier::MentionsOnly)
688                            | Some(SubscriptionTier::Unsubscribed) => 2, // " @" or " ✗"
689                            _ => 0,
690                        };
691                        u.len() + 1 + tier_len + status_len + 1 // " " + name + tier + status + " "
692                    })
693                    .max()
694                    .unwrap_or(10);
695                let panel_width = (panel_content_width as u16 + 2)
696                    .min(msg_chunk.width / 3)
697                    .max(12);
698                let panel_height =
699                    (online_users_ref.len() as u16 + 2).min(msg_chunk.height.saturating_sub(1));
700
701                let panel_x = msg_chunk.x + msg_chunk.width - panel_width - 1;
702                let panel_y = msg_chunk.y + 1;
703
704                let panel_rect = Rect {
705                    x: panel_x,
706                    y: panel_y,
707                    width: panel_width,
708                    height: panel_height,
709                };
710
711                f.render_widget(Clear, panel_rect);
712                let panel = List::new(panel_items).block(
713                    Block::default()
714                        .title(" members ")
715                        .borders(Borders::ALL)
716                        .border_style(Style::default().fg(Color::DarkGray)),
717                );
718                f.render_widget(panel, panel_rect);
719            }
720
721            // Render the command palette popup above the input box when active.
722            if input_state.palette.active && !input_state.palette.filtered.is_empty() {
723                let palette_items: Vec<ListItem> = input_state
724                    .palette
725                    .filtered
726                    .iter()
727                    .enumerate()
728                    .map(|(row, &idx)| {
729                        let item = &input_state.palette.commands[idx];
730                        let style = if row == input_state.palette.selected {
731                            Style::default()
732                                .fg(Color::Black)
733                                .bg(Color::Cyan)
734                                .add_modifier(Modifier::BOLD)
735                        } else {
736                            Style::default().fg(Color::White)
737                        };
738                        ListItem::new(Line::from(vec![
739                            Span::styled(
740                                format!("{:<16}", item.usage),
741                                style.add_modifier(Modifier::BOLD),
742                            ),
743                            Span::styled(
744                                format!("  {}", item.description),
745                                if row == input_state.palette.selected {
746                                    Style::default().fg(Color::Black).bg(Color::Cyan)
747                                } else {
748                                    Style::default().fg(Color::DarkGray)
749                                },
750                            ),
751                        ]))
752                    })
753                    .collect();
754
755                let popup_height =
756                    (input_state.palette.filtered.len() as u16 + 2).min(msg_chunk.height);
757                let popup_y = input_chunk.y.saturating_sub(popup_height);
758                let popup_rect = Rect {
759                    x: input_chunk.x,
760                    y: popup_y,
761                    width: input_chunk.width,
762                    height: popup_height,
763                };
764
765                f.render_widget(Clear, popup_rect);
766                let palette_list = List::new(palette_items).block(
767                    Block::default()
768                        .title(" commands ")
769                        .borders(Borders::ALL)
770                        .border_style(Style::default().fg(Color::Cyan)),
771                );
772                f.render_widget(palette_list, popup_rect);
773            }
774
775            // Render the mention picker popup above the cursor when active.
776            if input_state.mention.active && !input_state.mention.filtered.is_empty() {
777                let mention_items: Vec<ListItem> = input_state
778                    .mention
779                    .filtered
780                    .iter()
781                    .enumerate()
782                    .map(|(row, user)| {
783                        let style = if row == input_state.mention.selected {
784                            Style::default()
785                                .fg(Color::Black)
786                                .bg(user_color(user, &color_map))
787                                .add_modifier(Modifier::BOLD)
788                        } else {
789                            Style::default().fg(user_color(user, &color_map))
790                        };
791                        ListItem::new(Line::from(Span::styled(format!("@{user}"), style)))
792                    })
793                    .collect();
794
795                let popup_height =
796                    (input_state.mention.filtered.len() as u16 + 2).min(msg_chunk.height);
797                let popup_y = input_chunk.y.saturating_sub(popup_height);
798                let max_width = input_state
799                    .mention
800                    .filtered
801                    .iter()
802                    .map(|u| u.len() + 1) // '@' + username
803                    .max()
804                    .unwrap_or(8) as u16
805                    + 4; // borders + padding
806                let popup_width = max_width.min(input_chunk.width / 2).max(8);
807                let popup_x = cursor_x
808                    .saturating_sub(1)
809                    .min(input_chunk.x + input_chunk.width.saturating_sub(popup_width));
810                let popup_rect = Rect {
811                    x: popup_x,
812                    y: popup_y,
813                    width: popup_width,
814                    height: popup_height,
815                };
816
817                f.render_widget(Clear, popup_rect);
818                let mention_list = List::new(mention_items).block(
819                    Block::default()
820                        .title(" @ ")
821                        .borders(Borders::ALL)
822                        .border_style(Style::default().fg(Color::Yellow)),
823                );
824                f.render_widget(mention_list, popup_rect);
825            }
826        })?;
827
828        if event::poll(std::time::Duration::from_millis(50))? {
829            match event::read()? {
830                Event::Key(key) => {
831                    let online_users = &tabs[active_tab].online_users;
832                    match handle_key(
833                        key,
834                        &mut input_state,
835                        online_users,
836                        msg_area_height,
837                        input_content_width,
838                    ) {
839                        Some(Action::Send(payload)) => {
840                            if let Err(e) = tabs[active_tab]
841                                .write_half
842                                .write_all(format!("{payload}\n").as_bytes())
843                                .await
844                            {
845                                result = Err(e.into());
846                                break 'main;
847                            }
848                        }
849                        Some(Action::Quit) => break 'main,
850                        Some(Action::NextTab) => {
851                            if tabs.len() > 1 {
852                                let next = (active_tab + 1) % tabs.len();
853                                switch_to_tab(&mut tabs, &mut active_tab, &mut input_state, next);
854                            }
855                        }
856                        Some(Action::PrevTab) => {
857                            if tabs.len() > 1 {
858                                let prev = if active_tab == 0 {
859                                    tabs.len() - 1
860                                } else {
861                                    active_tab - 1
862                                };
863                                switch_to_tab(&mut tabs, &mut active_tab, &mut input_state, prev);
864                            }
865                        }
866                        Some(Action::SwitchTab(idx)) => {
867                            if idx < tabs.len() {
868                                switch_to_tab(&mut tabs, &mut active_tab, &mut input_state, idx);
869                            }
870                        }
871                        Some(Action::DmRoom {
872                            target_user,
873                            content,
874                        }) => {
875                            let cfg = DmTabConfig {
876                                socket_path: &socket_path,
877                                username,
878                                history_lines,
879                            };
880                            if let Err(e) = handle_dm_action(
881                                &mut tabs,
882                                &mut active_tab,
883                                &mut input_state,
884                                &cfg,
885                                target_user,
886                                content,
887                            )
888                            .await
889                            {
890                                result = Err(e);
891                                break 'main;
892                            }
893                        }
894                        None => {}
895                    }
896                }
897                Event::Paste(text) => {
898                    // Normalize line endings: \r\n → \n, stray \r → \n.
899                    let clean = text.replace("\r\n", "\n").replace('\r', "\n");
900                    input_state.input.insert_str(input_state.cursor_pos, &clean);
901                    input_state.cursor_pos += clean.len();
902                    input_state.mention.active = false;
903                }
904                Event::Resize(_, _) => {}
905                _ => {}
906            }
907        }
908
909        // Drain any messages that arrived during the poll (all tabs).
910        for (i, t) in tabs.iter_mut().enumerate() {
911            let is_active = i == active_tab;
912            if matches!(
913                t.drain_messages(&mut color_map, is_active),
914                DrainResult::Disconnected
915            ) && is_active
916            {
917                break 'main;
918            }
919        }
920
921        frame_count = frame_count.wrapping_add(1);
922    }
923
924    disable_raw_mode()?;
925    execute!(
926        terminal.backend_mut(),
927        DisableBracketedPaste,
928        LeaveAlternateScreen
929    )?;
930    terminal.show_cursor()?;
931
932    // Restore stderr so post-TUI error messages appear on the terminal.
933    #[cfg(unix)]
934    restore_stderr(saved_stderr_fd);
935
936    result
937}
938
939// ── Stderr redirection ───────────────────────────────────────────────────────
940
941/// Redirect stderr (fd 2) to `~/.room/room.log` so that `eprintln!` output
942/// from the broker does not corrupt the TUI alternate screen. Returns the
943/// saved fd so it can be restored after the TUI exits.
944#[cfg(unix)]
945fn redirect_stderr_to_log() -> Option<i32> {
946    let log_path = crate::paths::room_home().join("room.log");
947
948    let file = match std::fs::OpenOptions::new()
949        .create(true)
950        .append(true)
951        .open(&log_path)
952    {
953        Ok(f) => f,
954        Err(_) => return None,
955    };
956
957    // Save the current stderr fd so we can restore it later.
958    let saved = unsafe { libc::dup(libc::STDERR_FILENO) };
959    if saved < 0 {
960        return None;
961    }
962
963    let log_fd = file.as_raw_fd();
964    if unsafe { libc::dup2(log_fd, libc::STDERR_FILENO) } < 0 {
965        unsafe { libc::close(saved) };
966        return None;
967    }
968
969    Some(saved)
970}
971
972/// Restore stderr to its original fd after leaving the TUI.
973#[cfg(unix)]
974fn restore_stderr(saved: Option<i32>) {
975    if let Some(fd) = saved {
976        unsafe {
977            libc::dup2(fd, libc::STDERR_FILENO);
978            libc::close(fd);
979        }
980    }
981}
982
983// ── Tests ─────────────────────────────────────────────────────────────────────
984
985#[cfg(test)]
986mod tests {
987    use super::*;
988    use chrono::Utc;
989
990    fn make_msg(user: &str, content: &str) -> Message {
991        Message::Message {
992            id: "test-id".into(),
993            room: "test-room".into(),
994            user: user.into(),
995            ts: Utc::now(),
996            content: content.into(),
997            seq: None,
998        }
999    }
1000
1001    fn make_join(user: &str) -> Message {
1002        Message::Join {
1003            id: "test-id".into(),
1004            room: "test-room".into(),
1005            user: user.into(),
1006            ts: Utc::now(),
1007            seq: None,
1008        }
1009    }
1010
1011    fn make_leave(user: &str) -> Message {
1012        Message::Leave {
1013            id: "test-id".into(),
1014            room: "test-room".into(),
1015            user: user.into(),
1016            ts: Utc::now(),
1017            seq: None,
1018        }
1019    }
1020
1021    fn make_system(content: &str) -> Message {
1022        Message::System {
1023            id: "test-id".into(),
1024            room: "test-room".into(),
1025            user: "broker".into(),
1026            ts: Utc::now(),
1027            content: content.into(),
1028            seq: None,
1029        }
1030    }
1031
1032    // ── RoomTab::process_message tests ────────────────────────────────────
1033
1034    #[tokio::test]
1035    async fn process_message_adds_user_on_join() {
1036        let (_, rx) = mpsc::unbounded_channel();
1037        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1038        let mut tab = RoomTab {
1039            room_id: "test".into(),
1040            messages: Vec::new(),
1041            online_users: Vec::new(),
1042            user_statuses: HashMap::new(),
1043            subscription_tiers: HashMap::new(),
1044            unread_count: 0,
1045            scroll_offset: 0,
1046            msg_rx: rx,
1047            write_half: wh,
1048        };
1049        let mut cm = ColorMap::new();
1050
1051        tab.process_message(make_join("alice"), &mut cm, true);
1052        assert_eq!(tab.online_users, vec!["alice"]);
1053        assert_eq!(tab.messages.len(), 1);
1054    }
1055
1056    #[tokio::test]
1057    async fn process_message_removes_user_on_leave() {
1058        let (_, rx) = mpsc::unbounded_channel();
1059        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1060        let mut tab = RoomTab {
1061            room_id: "test".into(),
1062            messages: Vec::new(),
1063            online_users: vec!["alice".into()],
1064            user_statuses: HashMap::new(),
1065            subscription_tiers: HashMap::new(),
1066            unread_count: 0,
1067            scroll_offset: 0,
1068            msg_rx: rx,
1069            write_half: wh,
1070        };
1071        let mut cm = ColorMap::new();
1072
1073        tab.process_message(make_leave("alice"), &mut cm, true);
1074        assert!(tab.online_users.is_empty());
1075    }
1076
1077    #[tokio::test]
1078    async fn process_message_increments_unread_when_inactive() {
1079        let (_, rx) = mpsc::unbounded_channel();
1080        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1081        let mut tab = RoomTab {
1082            room_id: "test".into(),
1083            messages: Vec::new(),
1084            online_users: Vec::new(),
1085            user_statuses: HashMap::new(),
1086            subscription_tiers: HashMap::new(),
1087            unread_count: 0,
1088            scroll_offset: 0,
1089            msg_rx: rx,
1090            write_half: wh,
1091        };
1092        let mut cm = ColorMap::new();
1093
1094        tab.process_message(make_msg("bob", "hello"), &mut cm, false);
1095        assert_eq!(tab.unread_count, 1);
1096
1097        tab.process_message(make_msg("bob", "world"), &mut cm, false);
1098        assert_eq!(tab.unread_count, 2);
1099    }
1100
1101    #[tokio::test]
1102    async fn process_message_no_unread_when_active() {
1103        let (_, rx) = mpsc::unbounded_channel();
1104        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1105        let mut tab = RoomTab {
1106            room_id: "test".into(),
1107            messages: Vec::new(),
1108            online_users: Vec::new(),
1109            user_statuses: HashMap::new(),
1110            subscription_tiers: HashMap::new(),
1111            unread_count: 0,
1112            scroll_offset: 0,
1113            msg_rx: rx,
1114            write_half: wh,
1115        };
1116        let mut cm = ColorMap::new();
1117
1118        tab.process_message(make_msg("bob", "hello"), &mut cm, true);
1119        assert_eq!(tab.unread_count, 0);
1120    }
1121
1122    #[tokio::test]
1123    async fn process_message_seeds_user_from_message_sender() {
1124        let (_, rx) = mpsc::unbounded_channel();
1125        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1126        let mut tab = RoomTab {
1127            room_id: "test".into(),
1128            messages: Vec::new(),
1129            online_users: Vec::new(),
1130            user_statuses: HashMap::new(),
1131            subscription_tiers: HashMap::new(),
1132            unread_count: 0,
1133            scroll_offset: 0,
1134            msg_rx: rx,
1135            write_half: wh,
1136        };
1137        let mut cm = ColorMap::new();
1138
1139        tab.process_message(make_msg("charlie", "hi"), &mut cm, true);
1140        assert_eq!(tab.online_users, vec!["charlie"]);
1141        assert!(cm.contains_key("charlie"));
1142    }
1143
1144    #[tokio::test]
1145    async fn process_message_does_not_duplicate_existing_user() {
1146        let (_, rx) = mpsc::unbounded_channel();
1147        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1148        let mut tab = RoomTab {
1149            room_id: "test".into(),
1150            messages: Vec::new(),
1151            online_users: vec!["alice".into()],
1152            user_statuses: HashMap::new(),
1153            subscription_tiers: HashMap::new(),
1154            unread_count: 0,
1155            scroll_offset: 0,
1156            msg_rx: rx,
1157            write_half: wh,
1158        };
1159        let mut cm = ColorMap::new();
1160
1161        tab.process_message(make_msg("alice", "hi"), &mut cm, true);
1162        assert_eq!(tab.online_users.len(), 1);
1163    }
1164
1165    // ── drain_messages tests ──────────────────────────────────────────────
1166
1167    #[tokio::test]
1168    async fn drain_messages_processes_pending() {
1169        let (tx, rx) = mpsc::unbounded_channel();
1170        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1171        let mut tab = RoomTab {
1172            room_id: "test".into(),
1173            messages: Vec::new(),
1174            online_users: Vec::new(),
1175            user_statuses: HashMap::new(),
1176            subscription_tiers: HashMap::new(),
1177            unread_count: 0,
1178            scroll_offset: 0,
1179            msg_rx: rx,
1180            write_half: wh,
1181        };
1182        let mut cm = ColorMap::new();
1183
1184        tx.send(make_msg("bob", "one")).unwrap();
1185        tx.send(make_msg("bob", "two")).unwrap();
1186
1187        let result = tab.drain_messages(&mut cm, true);
1188        assert!(matches!(result, DrainResult::Ok));
1189        assert_eq!(tab.messages.len(), 2);
1190    }
1191
1192    #[tokio::test]
1193    async fn drain_messages_detects_disconnect() {
1194        let (tx, rx) = mpsc::unbounded_channel();
1195        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1196        let mut tab = RoomTab {
1197            room_id: "test".into(),
1198            messages: Vec::new(),
1199            online_users: Vec::new(),
1200            user_statuses: HashMap::new(),
1201            subscription_tiers: HashMap::new(),
1202            unread_count: 0,
1203            scroll_offset: 0,
1204            msg_rx: rx,
1205            write_half: wh,
1206        };
1207        let mut cm = ColorMap::new();
1208
1209        drop(tx);
1210        let result = tab.drain_messages(&mut cm, true);
1211        assert!(matches!(result, DrainResult::Disconnected));
1212    }
1213
1214    #[tokio::test]
1215    async fn drain_messages_empty_returns_ok() {
1216        let (_tx, rx) = mpsc::unbounded_channel::<Message>();
1217        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1218        let mut tab = RoomTab {
1219            room_id: "test".into(),
1220            messages: Vec::new(),
1221            online_users: Vec::new(),
1222            user_statuses: HashMap::new(),
1223            subscription_tiers: HashMap::new(),
1224            unread_count: 0,
1225            scroll_offset: 0,
1226            msg_rx: rx,
1227            write_half: wh,
1228        };
1229        let mut cm = ColorMap::new();
1230
1231        let result = tab.drain_messages(&mut cm, true);
1232        assert!(matches!(result, DrainResult::Ok));
1233        assert!(tab.messages.is_empty());
1234    }
1235
1236    #[tokio::test]
1237    async fn process_system_message_parses_status() {
1238        let (_, rx) = mpsc::unbounded_channel();
1239        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1240        let mut tab = RoomTab {
1241            room_id: "test".into(),
1242            messages: Vec::new(),
1243            online_users: vec!["alice".into()],
1244            user_statuses: HashMap::new(),
1245            subscription_tiers: HashMap::new(),
1246            unread_count: 0,
1247            scroll_offset: 0,
1248            msg_rx: rx,
1249            write_half: wh,
1250        };
1251        let mut cm = ColorMap::new();
1252
1253        tab.process_message(make_system("alice set status: coding"), &mut cm, true);
1254        assert_eq!(tab.user_statuses.get("alice").unwrap(), "coding");
1255    }
1256
1257    #[tokio::test]
1258    async fn process_subscription_broadcast_sets_tier() {
1259        let (_, rx) = mpsc::unbounded_channel();
1260        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1261        let mut tab = RoomTab {
1262            room_id: "test".into(),
1263            messages: Vec::new(),
1264            online_users: vec!["alice".into()],
1265            user_statuses: HashMap::new(),
1266            subscription_tiers: HashMap::new(),
1267            unread_count: 0,
1268            scroll_offset: 0,
1269            msg_rx: rx,
1270            write_half: wh,
1271        };
1272        let mut cm = ColorMap::new();
1273
1274        tab.process_message(
1275            make_system("alice subscribed to test (tier: mentions_only)"),
1276            &mut cm,
1277            true,
1278        );
1279        assert_eq!(
1280            tab.subscription_tiers.get("alice").copied(),
1281            Some(SubscriptionTier::MentionsOnly),
1282        );
1283
1284        // Upgrading to Full clears non-Full indicator.
1285        tab.process_message(
1286            make_system("alice subscribed to test (tier: full)"),
1287            &mut cm,
1288            true,
1289        );
1290        assert_eq!(
1291            tab.subscription_tiers.get("alice").copied(),
1292            Some(SubscriptionTier::Full),
1293        );
1294    }
1295
1296    #[tokio::test]
1297    async fn process_leave_clears_subscription_tier() {
1298        let (_, rx) = mpsc::unbounded_channel();
1299        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1300        let mut tab = RoomTab {
1301            room_id: "test".into(),
1302            messages: Vec::new(),
1303            online_users: vec!["alice".into()],
1304            user_statuses: HashMap::new(),
1305            subscription_tiers: HashMap::from([(
1306                "alice".to_owned(),
1307                SubscriptionTier::MentionsOnly,
1308            )]),
1309            unread_count: 0,
1310            scroll_offset: 0,
1311            msg_rx: rx,
1312            write_half: wh,
1313        };
1314        let mut cm = ColorMap::new();
1315
1316        tab.process_message(make_leave("alice"), &mut cm, true);
1317        assert!(tab.subscription_tiers.get("alice").is_none());
1318    }
1319}