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, normalize_paste, parse_kick_broadcast,
35    parse_status_broadcast, parse_subscription_broadcast, seed_online_users_from_who,
36    wrap_input_display, Action, InputState,
37};
38use render::{
39    assign_color, build_member_panel_spans, find_view_start, format_message,
40    member_panel_row_width, render_tab_bar, user_color, welcome_splash, 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 spans = build_member_panel_spans(u, status, tier, &color_map);
648                        ListItem::new(Line::from(spans))
649                    })
650                    .collect();
651
652                let panel_content_width = online_users_ref
653                    .iter()
654                    .map(|u| {
655                        let status = user_statuses_ref.get(u).map(|s| s.as_str()).unwrap_or("");
656                        let tier = subscription_tiers_ref.get(u).copied();
657                        member_panel_row_width(u, status, tier)
658                    })
659                    .max()
660                    .unwrap_or(10);
661                let panel_width = (panel_content_width as u16 + 2)
662                    .min(msg_chunk.width / 3)
663                    .max(12);
664                let panel_height =
665                    (online_users_ref.len() as u16 + 2).min(msg_chunk.height.saturating_sub(1));
666
667                let panel_x = msg_chunk.x + msg_chunk.width - panel_width - 1;
668                let panel_y = msg_chunk.y + 1;
669
670                let panel_rect = Rect {
671                    x: panel_x,
672                    y: panel_y,
673                    width: panel_width,
674                    height: panel_height,
675                };
676
677                f.render_widget(Clear, panel_rect);
678                let panel = List::new(panel_items).block(
679                    Block::default()
680                        .title(" members ")
681                        .borders(Borders::ALL)
682                        .border_style(Style::default().fg(Color::DarkGray)),
683                );
684                f.render_widget(panel, panel_rect);
685            }
686
687            // Render the command palette popup above the input box when active.
688            if input_state.palette.active && !input_state.palette.filtered.is_empty() {
689                let palette_items: Vec<ListItem> = input_state
690                    .palette
691                    .filtered
692                    .iter()
693                    .enumerate()
694                    .map(|(row, &idx)| {
695                        let item = &input_state.palette.commands[idx];
696                        let style = if row == input_state.palette.selected {
697                            Style::default()
698                                .fg(Color::Black)
699                                .bg(Color::Cyan)
700                                .add_modifier(Modifier::BOLD)
701                        } else {
702                            Style::default().fg(Color::White)
703                        };
704                        ListItem::new(Line::from(vec![
705                            Span::styled(
706                                format!("{:<16}", item.usage),
707                                style.add_modifier(Modifier::BOLD),
708                            ),
709                            Span::styled(
710                                format!("  {}", item.description),
711                                if row == input_state.palette.selected {
712                                    Style::default().fg(Color::Black).bg(Color::Cyan)
713                                } else {
714                                    Style::default().fg(Color::DarkGray)
715                                },
716                            ),
717                        ]))
718                    })
719                    .collect();
720
721                let popup_height =
722                    (input_state.palette.filtered.len() as u16 + 2).min(msg_chunk.height);
723                let popup_y = input_chunk.y.saturating_sub(popup_height);
724                let popup_rect = Rect {
725                    x: input_chunk.x,
726                    y: popup_y,
727                    width: input_chunk.width,
728                    height: popup_height,
729                };
730
731                f.render_widget(Clear, popup_rect);
732                let palette_list = List::new(palette_items).block(
733                    Block::default()
734                        .title(" commands ")
735                        .borders(Borders::ALL)
736                        .border_style(Style::default().fg(Color::Cyan)),
737                );
738                f.render_widget(palette_list, popup_rect);
739            }
740
741            // Render the mention picker popup above the cursor when active.
742            if input_state.mention.active && !input_state.mention.filtered.is_empty() {
743                let mention_items: Vec<ListItem> = input_state
744                    .mention
745                    .filtered
746                    .iter()
747                    .enumerate()
748                    .map(|(row, user)| {
749                        let style = if row == input_state.mention.selected {
750                            Style::default()
751                                .fg(Color::Black)
752                                .bg(user_color(user, &color_map))
753                                .add_modifier(Modifier::BOLD)
754                        } else {
755                            Style::default().fg(user_color(user, &color_map))
756                        };
757                        ListItem::new(Line::from(Span::styled(format!("@{user}"), style)))
758                    })
759                    .collect();
760
761                let popup_height =
762                    (input_state.mention.filtered.len() as u16 + 2).min(msg_chunk.height);
763                let popup_y = input_chunk.y.saturating_sub(popup_height);
764                let max_width = input_state
765                    .mention
766                    .filtered
767                    .iter()
768                    .map(|u| u.len() + 1) // '@' + username
769                    .max()
770                    .unwrap_or(8) as u16
771                    + 4; // borders + padding
772                let popup_width = max_width.min(input_chunk.width / 2).max(8);
773                let popup_x = cursor_x
774                    .saturating_sub(1)
775                    .min(input_chunk.x + input_chunk.width.saturating_sub(popup_width));
776                let popup_rect = Rect {
777                    x: popup_x,
778                    y: popup_y,
779                    width: popup_width,
780                    height: popup_height,
781                };
782
783                f.render_widget(Clear, popup_rect);
784                let mention_list = List::new(mention_items).block(
785                    Block::default()
786                        .title(" @ ")
787                        .borders(Borders::ALL)
788                        .border_style(Style::default().fg(Color::Yellow)),
789                );
790                f.render_widget(mention_list, popup_rect);
791            }
792        })?;
793
794        if event::poll(std::time::Duration::from_millis(50))? {
795            match event::read()? {
796                Event::Key(key) => {
797                    let online_users = &tabs[active_tab].online_users;
798                    match handle_key(
799                        key,
800                        &mut input_state,
801                        online_users,
802                        msg_area_height,
803                        input_content_width,
804                    ) {
805                        Some(Action::Send(payload)) => {
806                            if let Err(e) = tabs[active_tab]
807                                .write_half
808                                .write_all(format!("{payload}\n").as_bytes())
809                                .await
810                            {
811                                result = Err(e.into());
812                                break 'main;
813                            }
814                        }
815                        Some(Action::Quit) => break 'main,
816                        Some(Action::NextTab) => {
817                            if tabs.len() > 1 {
818                                let next = (active_tab + 1) % tabs.len();
819                                switch_to_tab(&mut tabs, &mut active_tab, &mut input_state, next);
820                            }
821                        }
822                        Some(Action::PrevTab) => {
823                            if tabs.len() > 1 {
824                                let prev = if active_tab == 0 {
825                                    tabs.len() - 1
826                                } else {
827                                    active_tab - 1
828                                };
829                                switch_to_tab(&mut tabs, &mut active_tab, &mut input_state, prev);
830                            }
831                        }
832                        Some(Action::SwitchTab(idx)) => {
833                            if idx < tabs.len() {
834                                switch_to_tab(&mut tabs, &mut active_tab, &mut input_state, idx);
835                            }
836                        }
837                        Some(Action::DmRoom {
838                            target_user,
839                            content,
840                        }) => {
841                            let cfg = DmTabConfig {
842                                socket_path: &socket_path,
843                                username,
844                                history_lines,
845                            };
846                            if let Err(e) = handle_dm_action(
847                                &mut tabs,
848                                &mut active_tab,
849                                &mut input_state,
850                                &cfg,
851                                target_user,
852                                content,
853                            )
854                            .await
855                            {
856                                result = Err(e);
857                                break 'main;
858                            }
859                        }
860                        None => {}
861                    }
862                }
863                Event::Paste(text) => {
864                    let clean = normalize_paste(&text);
865                    input_state.input.insert_str(input_state.cursor_pos, &clean);
866                    input_state.cursor_pos += clean.len();
867                    input_state.mention.active = false;
868                }
869                Event::Resize(_, _) => {}
870                _ => {}
871            }
872        }
873
874        // Drain any messages that arrived during the poll (all tabs).
875        for (i, t) in tabs.iter_mut().enumerate() {
876            let is_active = i == active_tab;
877            if matches!(
878                t.drain_messages(&mut color_map, is_active),
879                DrainResult::Disconnected
880            ) && is_active
881            {
882                break 'main;
883            }
884        }
885
886        frame_count = frame_count.wrapping_add(1);
887    }
888
889    disable_raw_mode()?;
890    execute!(
891        terminal.backend_mut(),
892        DisableBracketedPaste,
893        LeaveAlternateScreen
894    )?;
895    terminal.show_cursor()?;
896
897    // Restore stderr so post-TUI error messages appear on the terminal.
898    #[cfg(unix)]
899    restore_stderr(saved_stderr_fd);
900
901    result
902}
903
904// ── Stderr redirection ───────────────────────────────────────────────────────
905
906/// Redirect stderr (fd 2) to `~/.room/room.log` so that `eprintln!` output
907/// from the broker does not corrupt the TUI alternate screen. Returns the
908/// saved fd so it can be restored after the TUI exits.
909#[cfg(unix)]
910fn redirect_stderr_to_log() -> Option<i32> {
911    let log_path = crate::paths::room_home().join("room.log");
912
913    let file = match std::fs::OpenOptions::new()
914        .create(true)
915        .append(true)
916        .open(&log_path)
917    {
918        Ok(f) => f,
919        Err(_) => return None,
920    };
921
922    // Save the current stderr fd so we can restore it later.
923    let saved = unsafe { libc::dup(libc::STDERR_FILENO) };
924    if saved < 0 {
925        return None;
926    }
927
928    let log_fd = file.as_raw_fd();
929    if unsafe { libc::dup2(log_fd, libc::STDERR_FILENO) } < 0 {
930        unsafe { libc::close(saved) };
931        return None;
932    }
933
934    Some(saved)
935}
936
937/// Restore stderr to its original fd after leaving the TUI.
938#[cfg(unix)]
939fn restore_stderr(saved: Option<i32>) {
940    if let Some(fd) = saved {
941        unsafe {
942            libc::dup2(fd, libc::STDERR_FILENO);
943            libc::close(fd);
944        }
945    }
946}
947
948// ── Tests ─────────────────────────────────────────────────────────────────────
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953    use chrono::Utc;
954
955    fn make_msg(user: &str, content: &str) -> Message {
956        Message::Message {
957            id: "test-id".into(),
958            room: "test-room".into(),
959            user: user.into(),
960            ts: Utc::now(),
961            content: content.into(),
962            seq: None,
963        }
964    }
965
966    fn make_join(user: &str) -> Message {
967        Message::Join {
968            id: "test-id".into(),
969            room: "test-room".into(),
970            user: user.into(),
971            ts: Utc::now(),
972            seq: None,
973        }
974    }
975
976    fn make_leave(user: &str) -> Message {
977        Message::Leave {
978            id: "test-id".into(),
979            room: "test-room".into(),
980            user: user.into(),
981            ts: Utc::now(),
982            seq: None,
983        }
984    }
985
986    fn make_system(content: &str) -> Message {
987        Message::System {
988            id: "test-id".into(),
989            room: "test-room".into(),
990            user: "broker".into(),
991            ts: Utc::now(),
992            content: content.into(),
993            seq: None,
994        }
995    }
996
997    // ── RoomTab::process_message tests ────────────────────────────────────
998
999    #[tokio::test]
1000    async fn process_message_adds_user_on_join() {
1001        let (_, rx) = mpsc::unbounded_channel();
1002        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1003        let mut tab = RoomTab {
1004            room_id: "test".into(),
1005            messages: Vec::new(),
1006            online_users: Vec::new(),
1007            user_statuses: HashMap::new(),
1008            subscription_tiers: HashMap::new(),
1009            unread_count: 0,
1010            scroll_offset: 0,
1011            msg_rx: rx,
1012            write_half: wh,
1013        };
1014        let mut cm = ColorMap::new();
1015
1016        tab.process_message(make_join("alice"), &mut cm, true);
1017        assert_eq!(tab.online_users, vec!["alice"]);
1018        assert_eq!(tab.messages.len(), 1);
1019    }
1020
1021    #[tokio::test]
1022    async fn process_message_removes_user_on_leave() {
1023        let (_, rx) = mpsc::unbounded_channel();
1024        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1025        let mut tab = RoomTab {
1026            room_id: "test".into(),
1027            messages: Vec::new(),
1028            online_users: vec!["alice".into()],
1029            user_statuses: HashMap::new(),
1030            subscription_tiers: HashMap::new(),
1031            unread_count: 0,
1032            scroll_offset: 0,
1033            msg_rx: rx,
1034            write_half: wh,
1035        };
1036        let mut cm = ColorMap::new();
1037
1038        tab.process_message(make_leave("alice"), &mut cm, true);
1039        assert!(tab.online_users.is_empty());
1040    }
1041
1042    #[tokio::test]
1043    async fn process_message_increments_unread_when_inactive() {
1044        let (_, rx) = mpsc::unbounded_channel();
1045        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1046        let mut tab = RoomTab {
1047            room_id: "test".into(),
1048            messages: Vec::new(),
1049            online_users: Vec::new(),
1050            user_statuses: HashMap::new(),
1051            subscription_tiers: HashMap::new(),
1052            unread_count: 0,
1053            scroll_offset: 0,
1054            msg_rx: rx,
1055            write_half: wh,
1056        };
1057        let mut cm = ColorMap::new();
1058
1059        tab.process_message(make_msg("bob", "hello"), &mut cm, false);
1060        assert_eq!(tab.unread_count, 1);
1061
1062        tab.process_message(make_msg("bob", "world"), &mut cm, false);
1063        assert_eq!(tab.unread_count, 2);
1064    }
1065
1066    #[tokio::test]
1067    async fn process_message_no_unread_when_active() {
1068        let (_, rx) = mpsc::unbounded_channel();
1069        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1070        let mut tab = RoomTab {
1071            room_id: "test".into(),
1072            messages: Vec::new(),
1073            online_users: Vec::new(),
1074            user_statuses: HashMap::new(),
1075            subscription_tiers: HashMap::new(),
1076            unread_count: 0,
1077            scroll_offset: 0,
1078            msg_rx: rx,
1079            write_half: wh,
1080        };
1081        let mut cm = ColorMap::new();
1082
1083        tab.process_message(make_msg("bob", "hello"), &mut cm, true);
1084        assert_eq!(tab.unread_count, 0);
1085    }
1086
1087    #[tokio::test]
1088    async fn process_message_seeds_user_from_message_sender() {
1089        let (_, rx) = mpsc::unbounded_channel();
1090        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1091        let mut tab = RoomTab {
1092            room_id: "test".into(),
1093            messages: Vec::new(),
1094            online_users: Vec::new(),
1095            user_statuses: HashMap::new(),
1096            subscription_tiers: HashMap::new(),
1097            unread_count: 0,
1098            scroll_offset: 0,
1099            msg_rx: rx,
1100            write_half: wh,
1101        };
1102        let mut cm = ColorMap::new();
1103
1104        tab.process_message(make_msg("charlie", "hi"), &mut cm, true);
1105        assert_eq!(tab.online_users, vec!["charlie"]);
1106        assert!(cm.contains_key("charlie"));
1107    }
1108
1109    #[tokio::test]
1110    async fn process_message_does_not_duplicate_existing_user() {
1111        let (_, rx) = mpsc::unbounded_channel();
1112        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1113        let mut tab = RoomTab {
1114            room_id: "test".into(),
1115            messages: Vec::new(),
1116            online_users: vec!["alice".into()],
1117            user_statuses: HashMap::new(),
1118            subscription_tiers: HashMap::new(),
1119            unread_count: 0,
1120            scroll_offset: 0,
1121            msg_rx: rx,
1122            write_half: wh,
1123        };
1124        let mut cm = ColorMap::new();
1125
1126        tab.process_message(make_msg("alice", "hi"), &mut cm, true);
1127        assert_eq!(tab.online_users.len(), 1);
1128    }
1129
1130    // ── drain_messages tests ──────────────────────────────────────────────
1131
1132    #[tokio::test]
1133    async fn drain_messages_processes_pending() {
1134        let (tx, rx) = mpsc::unbounded_channel();
1135        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1136        let mut tab = RoomTab {
1137            room_id: "test".into(),
1138            messages: Vec::new(),
1139            online_users: Vec::new(),
1140            user_statuses: HashMap::new(),
1141            subscription_tiers: HashMap::new(),
1142            unread_count: 0,
1143            scroll_offset: 0,
1144            msg_rx: rx,
1145            write_half: wh,
1146        };
1147        let mut cm = ColorMap::new();
1148
1149        tx.send(make_msg("bob", "one")).unwrap();
1150        tx.send(make_msg("bob", "two")).unwrap();
1151
1152        let result = tab.drain_messages(&mut cm, true);
1153        assert!(matches!(result, DrainResult::Ok));
1154        assert_eq!(tab.messages.len(), 2);
1155    }
1156
1157    #[tokio::test]
1158    async fn drain_messages_detects_disconnect() {
1159        let (tx, rx) = mpsc::unbounded_channel();
1160        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1161        let mut tab = RoomTab {
1162            room_id: "test".into(),
1163            messages: Vec::new(),
1164            online_users: Vec::new(),
1165            user_statuses: HashMap::new(),
1166            subscription_tiers: HashMap::new(),
1167            unread_count: 0,
1168            scroll_offset: 0,
1169            msg_rx: rx,
1170            write_half: wh,
1171        };
1172        let mut cm = ColorMap::new();
1173
1174        drop(tx);
1175        let result = tab.drain_messages(&mut cm, true);
1176        assert!(matches!(result, DrainResult::Disconnected));
1177    }
1178
1179    #[tokio::test]
1180    async fn drain_messages_empty_returns_ok() {
1181        let (_tx, rx) = mpsc::unbounded_channel::<Message>();
1182        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1183        let mut tab = RoomTab {
1184            room_id: "test".into(),
1185            messages: Vec::new(),
1186            online_users: Vec::new(),
1187            user_statuses: HashMap::new(),
1188            subscription_tiers: HashMap::new(),
1189            unread_count: 0,
1190            scroll_offset: 0,
1191            msg_rx: rx,
1192            write_half: wh,
1193        };
1194        let mut cm = ColorMap::new();
1195
1196        let result = tab.drain_messages(&mut cm, true);
1197        assert!(matches!(result, DrainResult::Ok));
1198        assert!(tab.messages.is_empty());
1199    }
1200
1201    #[tokio::test]
1202    async fn process_system_message_parses_status() {
1203        let (_, rx) = mpsc::unbounded_channel();
1204        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1205        let mut tab = RoomTab {
1206            room_id: "test".into(),
1207            messages: Vec::new(),
1208            online_users: vec!["alice".into()],
1209            user_statuses: HashMap::new(),
1210            subscription_tiers: HashMap::new(),
1211            unread_count: 0,
1212            scroll_offset: 0,
1213            msg_rx: rx,
1214            write_half: wh,
1215        };
1216        let mut cm = ColorMap::new();
1217
1218        tab.process_message(make_system("alice set status: coding"), &mut cm, true);
1219        assert_eq!(tab.user_statuses.get("alice").unwrap(), "coding");
1220    }
1221
1222    #[tokio::test]
1223    async fn process_subscription_broadcast_sets_tier() {
1224        let (_, rx) = mpsc::unbounded_channel();
1225        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1226        let mut tab = RoomTab {
1227            room_id: "test".into(),
1228            messages: Vec::new(),
1229            online_users: vec!["alice".into()],
1230            user_statuses: HashMap::new(),
1231            subscription_tiers: HashMap::new(),
1232            unread_count: 0,
1233            scroll_offset: 0,
1234            msg_rx: rx,
1235            write_half: wh,
1236        };
1237        let mut cm = ColorMap::new();
1238
1239        tab.process_message(
1240            make_system("alice subscribed to test (tier: mentions_only)"),
1241            &mut cm,
1242            true,
1243        );
1244        assert_eq!(
1245            tab.subscription_tiers.get("alice").copied(),
1246            Some(SubscriptionTier::MentionsOnly),
1247        );
1248
1249        // Upgrading to Full clears non-Full indicator.
1250        tab.process_message(
1251            make_system("alice subscribed to test (tier: full)"),
1252            &mut cm,
1253            true,
1254        );
1255        assert_eq!(
1256            tab.subscription_tiers.get("alice").copied(),
1257            Some(SubscriptionTier::Full),
1258        );
1259    }
1260
1261    #[tokio::test]
1262    async fn process_leave_clears_subscription_tier() {
1263        let (_, rx) = mpsc::unbounded_channel();
1264        let (_, wh) = tokio::net::UnixStream::pair().unwrap().1.into_split();
1265        let mut tab = RoomTab {
1266            room_id: "test".into(),
1267            messages: Vec::new(),
1268            online_users: vec!["alice".into()],
1269            user_statuses: HashMap::new(),
1270            subscription_tiers: HashMap::from([(
1271                "alice".to_owned(),
1272                SubscriptionTier::MentionsOnly,
1273            )]),
1274            unread_count: 0,
1275            scroll_offset: 0,
1276            msg_rx: rx,
1277            write_half: wh,
1278        };
1279        let mut cm = ColorMap::new();
1280
1281        tab.process_message(make_leave("alice"), &mut cm, true);
1282        assert!(tab.subscription_tiers.get("alice").is_none());
1283    }
1284}