//! Full-screen Ratatui TUI for quicprochat. //! //! Layout: //! ┌──────────────┬──────────────────────────────────────────┐ //! │ Channels │ Messages │ //! │ (20%) │ (80%) │ //! │ │ │ //! │ ├──────────────────────────────────────────┤ //! │ │ Input bar │ //! └──────────────┴──────────────────────────────────────────┘ //! //! Keyboard: //! Enter — send message //! Up / Down — scroll message history //! Tab — next channel //! Shift+Tab — prev channel //! Ctrl+C / q — quit use std::path::Path; use std::sync::Arc; use std::time::Duration; use anyhow::Context; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{ backend::CrosstermBackend, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, Frame, Terminal, }; use tokio::sync::mpsc; use tokio::time::interval; use crate::{ClientAuth, init_auth}; use super::commands::{opaque_login, opaque_register}; use super::conversation::{now_ms, ConversationId, StoredMessage}; use super::rpc::{ connect_node, enqueue, fetch_hybrid_key, fetch_wait, try_hybrid_decrypt, upload_hybrid_key, upload_key_package, }; use super::session::SessionState; use super::state::load_or_init_state; use super::token_cache::{load_cached_session, save_cached_session}; use quicprochat_core::{ AppMessage, DiskKeyStore, GroupMember, IdentityKeypair, ReceivedMessage, hybrid_encrypt, parse as parse_app_msg, serialize_chat, }; use quicprochat_proto::node_capnp::node_service; // ── App events ─────────────────────────────────────────────────────────────── /// Events sent from background tasks into the main TUI loop. enum TuiEvent { /// A key event from the terminal. Key(event::KeyEvent), /// New messages received from the server (conv_id, sender_short, body). NewMessages(Vec<(ConversationId, String, String)>), /// Tick — redraw periodically even if nothing happened. Tick, } // ── Display message ─────────────────────────────────────────────────────────── #[derive(Clone)] struct DisplayMessage { sender: String, body: String, timestamp_ms: u64, is_outgoing: bool, } // ── App state ───────────────────────────────────────────────────────────────── struct App { /// Channel (conversation) names shown in the sidebar. channel_names: Vec, /// Conversation IDs, parallel to `channel_names`. channel_ids: Vec, /// Index of the selected channel in the sidebar. selected_channel: usize, /// Messages for the currently active channel. messages: Vec, /// Current input buffer. input: String, /// Scroll offset (0 = bottom). scroll_offset: usize, /// Whether the user has requested quit. should_quit: bool, /// Short identity string for display. identity_short: String, } impl App { fn new(session: &SessionState) -> anyhow::Result { let convs = session.conv_store.list_conversations()?; let channel_names: Vec = convs.iter().map(|c| c.display_name.clone()).collect(); let channel_ids: Vec = convs.iter().map(|c| c.id.clone()).collect(); Ok(Self { channel_names, channel_ids, selected_channel: 0, messages: Vec::new(), input: String::new(), scroll_offset: 0, should_quit: false, identity_short: session.identity_short(), }) } fn active_conv_id(&self) -> Option<&ConversationId> { self.channel_ids.get(self.selected_channel) } /// Reload messages for the currently selected channel from the session store. fn reload_messages(&mut self, session: &SessionState) -> anyhow::Result<()> { let conv_id = match self.active_conv_id() { Some(id) => id.clone(), None => { self.messages.clear(); return Ok(()); } }; let stored = session.conv_store.load_recent_messages(&conv_id, 200)?; self.messages = stored .into_iter() .map(|m| { let sender = if m.is_outgoing { format!("me({})", &self.identity_short) } else if let Some(name) = &m.sender_name { name.clone() } else { // Shorten sender key to 8 hex chars. let hex_short = hex::encode(&m.sender_key[..m.sender_key.len().min(4)]); format!("{hex_short}") }; DisplayMessage { sender, body: m.body, timestamp_ms: m.timestamp_ms, is_outgoing: m.is_outgoing, } }) .collect(); // Reset scroll to bottom on channel switch. self.scroll_offset = 0; Ok(()) } fn select_next_channel(&mut self, session: &SessionState) { if self.channel_names.is_empty() { return; } self.selected_channel = (self.selected_channel + 1) % self.channel_names.len(); let _ = self.reload_messages(session); } fn select_prev_channel(&mut self, session: &SessionState) { if self.channel_names.is_empty() { return; } if self.selected_channel == 0 { self.selected_channel = self.channel_names.len() - 1; } else { self.selected_channel -= 1; } let _ = self.reload_messages(session); } fn scroll_up(&mut self) { self.scroll_offset = self.scroll_offset.saturating_add(1); } fn scroll_down(&mut self) { self.scroll_offset = self.scroll_offset.saturating_sub(1); } /// Append newly received messages to the in-memory list (no DB reload needed /// since we already have them from the poll task, but we also save them via /// the session so they appear on reload). fn append_messages(&mut self, msgs: Vec<(ConversationId, String, String)>) { let active = self.active_conv_id().cloned(); for (conv_id, sender, body) in msgs { if active.as_ref() == Some(&conv_id) { self.messages.push(DisplayMessage { sender, body, timestamp_ms: now_ms(), is_outgoing: false, }); // Snap to bottom if user wasn't scrolled. if self.scroll_offset == 0 { // Already at bottom — nothing to do. } } } } } // ── Drawing ─────────────────────────────────────────────────────────────────── fn ui(frame: &mut Frame, app: &App) { let size = frame.area(); // Top-level split: sidebar | main area. let h_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(20), Constraint::Percentage(80)]) .split(size); // Main area split: messages | input bar. let v_chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(3), Constraint::Length(3)]) .split(h_chunks[1]); draw_sidebar(frame, app, h_chunks[0]); draw_messages(frame, app, v_chunks[0]); draw_input(frame, app, v_chunks[1]); } fn draw_sidebar(frame: &mut Frame, app: &App, area: Rect) { let items: Vec = app .channel_names .iter() .enumerate() .map(|(i, name)| { let style = if i == app.selected_channel { Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD | Modifier::REVERSED) } else { Style::default().fg(Color::Cyan) }; ListItem::new(Line::from(Span::styled(name.clone(), style))) }) .collect(); let block = Block::default() .title(" Channels ") .borders(Borders::ALL) .style(Style::default().fg(Color::DarkGray)); let mut list_state = ListState::default(); if !app.channel_names.is_empty() { list_state.select(Some(app.selected_channel)); } frame.render_stateful_widget( List::new(items).block(block), area, &mut list_state, ); } fn draw_messages(frame: &mut Frame, app: &App, area: Rect) { let channel_title = app .channel_names .get(app.selected_channel) .map(|n| format!(" {n} ")) .unwrap_or_else(|| " Messages ".to_string()); let block = Block::default() .title(channel_title) .borders(Borders::ALL) .style(Style::default().fg(Color::DarkGray)); let inner_height = area.height.saturating_sub(2) as usize; // Build lines from messages (newest at bottom). let mut lines: Vec = app .messages .iter() .map(|m| { let ts = format_timestamp(m.timestamp_ms); let ts_span = Span::styled(ts, Style::default().fg(Color::DarkGray)); let sender_style = if m.is_outgoing { Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) }; let sender_span = Span::styled(format!(" {} ", m.sender), sender_style); let body_span = Span::raw(m.body.clone()); Line::from(vec![ts_span, sender_span, body_span]) }) .collect(); // Apply scroll: scroll_offset=0 means newest at bottom. let total = lines.len(); let visible_start = if total > inner_height { let bottom = total - app.scroll_offset.min(total); bottom.saturating_sub(inner_height) } else { 0 }; let visible_end = if total > inner_height { total - app.scroll_offset.min(total) } else { total }; let visible_lines: Vec = lines .drain(visible_start..visible_end.min(lines.len())) .collect(); let paragraph = Paragraph::new(visible_lines) .block(block) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, area); } fn draw_input(frame: &mut Frame, app: &App, area: Rect) { let block = Block::default() .title(" Input (Enter=send, Tab=switch channel, q/Ctrl+C=quit) ") .borders(Borders::ALL) .style(Style::default().fg(Color::DarkGray)); let input_text = Paragraph::new(app.input.as_str()) .block(block) .style(Style::default().fg(Color::White)); frame.render_widget(input_text, area); // Position cursor at end of input. let cursor_x = area.x + 1 + app.input.len() as u16; let cursor_y = area.y + 1; if cursor_x < area.x + area.width - 1 { frame.set_cursor_position((cursor_x, cursor_y)); } } fn format_timestamp(ms: u64) -> String { // Simple HH:MM format from epoch ms. let secs = ms / 1000; let hours = (secs / 3600) % 24; let minutes = (secs / 60) % 60; format!("[{:02}:{:02}] ", hours, minutes) } // ── Message polling task ────────────────────────────────────────────────────── /// Background task that polls the server for new messages and sends them via `tx`. async fn poll_task( mut session: SessionState, client: node_service::Client, tx: mpsc::Sender, ) { let mut poll_interval = interval(Duration::from_millis(1000)); poll_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { poll_interval.tick().await; let identity_bytes = session.identity_bytes(); let payloads = match fetch_wait(&client, &identity_bytes, 0).await { Ok(p) => p, Err(_) => continue, }; if payloads.is_empty() { continue; } let mut new_msgs: Vec<(ConversationId, String, String)> = Vec::new(); let my_key = session.identity_bytes(); let mut sorted = payloads; sorted.sort_by_key(|(seq, _)| *seq); for (_seq, payload) in &sorted { let mls_payload = match try_hybrid_decrypt(session.hybrid_kp.as_ref(), payload) { Ok(b) => b, Err(_) => payload.clone(), }; let conv_ids: Vec = session.members.keys().cloned().collect(); for conv_id in &conv_ids { let member = match session.members.get_mut(conv_id) { Some(m) => m, None => continue, }; match member.receive_message(&mls_payload) { Ok(ReceivedMessage::Application(plaintext)) => { let (sender_key, app_bytes) = { let after_unpad = quicprochat_core::padding::unpad(&plaintext) .unwrap_or_else(|_| plaintext.clone()); if quicprochat_core::sealed_sender::is_sealed(&after_unpad) { match quicprochat_core::sealed_sender::unseal(&after_unpad) { Ok((sk, inner)) => (sk.to_vec(), inner), Err(_) => (my_key.clone(), after_unpad), } } else { (my_key.clone(), after_unpad) } }; let (body, msg_id, msg_type, ref_msg_id) = match parse_app_msg(&app_bytes) { Ok((_, AppMessage::Chat { message_id, body })) => ( String::from_utf8_lossy(&body).to_string(), Some(message_id), "chat", None, ), Ok((_, AppMessage::Reply { ref_msg_id, body })) => ( String::from_utf8_lossy(&body).to_string(), None, "reply", Some(ref_msg_id), ), Ok((_, AppMessage::Reaction { ref_msg_id, emoji })) => ( String::from_utf8_lossy(&emoji).to_string(), None, "reaction", Some(ref_msg_id), ), _ => ( String::from_utf8_lossy(&app_bytes).to_string(), None, "chat", None, ), }; let stored = StoredMessage { conversation_id: conv_id.clone(), message_id: msg_id, sender_key: sender_key.clone(), sender_name: None, body: body.clone(), msg_type: msg_type.into(), ref_msg_id, timestamp_ms: now_ms(), is_outgoing: false, }; if session.conv_store.save_message(&stored).is_ok() { let sender_short = hex::encode(&sender_key[..sender_key.len().min(4)]); new_msgs.push((conv_id.clone(), sender_short, body)); } let _ = session.conv_store.update_activity(conv_id, now_ms()); let _ = session.save_member(conv_id); break; } Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => { let _ = session.save_member(conv_id); break; } _ => {} } } } if !new_msgs.is_empty() { if tx.send(TuiEvent::NewMessages(new_msgs)).await.is_err() { break; } } } } // ── Send message ────────────────────────────────────────────────────────────── async fn send_message( session: &mut SessionState, client: &node_service::Client, conv_id: &ConversationId, text: &str, ) -> anyhow::Result<()> { let my_key = session.identity_bytes(); let identity = Arc::clone(&session.identity); let member = session .members .get_mut(conv_id) .context("no GroupMember for this conversation")?; // Wrap in structured AppMessage format. let app_payload = serialize_chat(text.as_bytes(), None) .context("serialize app message")?; // Metadata protection: seal + pad. let sealed = quicprochat_core::sealed_sender::seal(&identity, &app_payload); let padded = quicprochat_core::padding::pad(&sealed); let ct = member.send_message(&padded).context("MLS encrypt")?; let recipients: Vec> = member .member_identities() .into_iter() .filter(|id| id.as_slice() != my_key.as_slice()) .collect(); for recipient_key in &recipients { let peer_hybrid_pk = fetch_hybrid_key(client, recipient_key).await?; let payload = if let Some(ref pk) = peer_hybrid_pk { hybrid_encrypt(pk, &ct, b"", b"").context("hybrid encrypt")? } else { ct.clone() }; enqueue(client, recipient_key, &payload).await?; } // Extract message_id from what we just serialized. let msg_id = parse_app_msg(&app_payload) .ok() .and_then(|(_, m)| match m { AppMessage::Chat { message_id, .. } => Some(message_id), _ => None, }); // Save outgoing message. let stored = StoredMessage { conversation_id: conv_id.clone(), message_id: msg_id, sender_key: my_key, sender_name: Some("you".into()), body: text.to_string(), msg_type: "chat".into(), ref_msg_id: None, timestamp_ms: now_ms(), is_outgoing: true, }; session.conv_store.save_message(&stored)?; session.conv_store.update_activity(conv_id, now_ms())?; session.save_member(conv_id)?; Ok(()) } // ── TUI entry point ─────────────────────────────────────────────────────────── /// Entry point for `qpc tui`. Sets up the terminal, runs the event loop, and /// restores the terminal on exit. pub async fn run_tui( state_path: &Path, server: &str, ca_cert: &Path, server_name: &str, password: Option<&str>, username: Option<&str>, opaque_password: Option<&str>, access_token: &str, device_id: Option<&str>, ) -> anyhow::Result<()> { // ── Auth ────────────────────────────────────────────────────────────────── let resolved_token = resolve_tui_access_token( state_path, server, ca_cert, server_name, password, username, opaque_password, access_token, ) .await?; let token_bytes = hex::decode(&resolved_token) .unwrap_or_else(|_| resolved_token.into_bytes()); let auth_ctx = ClientAuth::from_raw(token_bytes, device_id.map(String::from)); init_auth(auth_ctx); // ── Session + RPC ───────────────────────────────────────────────────────── let mut session = SessionState::load(state_path, password)?; let client = connect_node(server, ca_cert, server_name).await?; // Auto-upload KeyPackage. let _ = auto_upload_keys_tui(&session, &client).await; // ── Terminal setup ──────────────────────────────────────────────────────── enable_raw_mode().context("enable raw mode")?; let mut stdout = std::io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture) .context("enter alternate screen")?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend).context("create terminal")?; let result = tui_loop(&mut terminal, &mut session, client).await; // ── Terminal cleanup (always restore, even on error) ─────────────────── disable_raw_mode().ok(); execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture ) .ok(); terminal.show_cursor().ok(); session.save_all()?; result } async fn tui_loop( terminal: &mut Terminal>, session: &mut SessionState, client: node_service::Client, ) -> anyhow::Result<()> { let mut app = App::new(session)?; app.reload_messages(session)?; let (event_tx, mut event_rx) = mpsc::channel::(256); // ── Keyboard event task ─────────────────────────────────────────────────── let key_tx = event_tx.clone(); tokio::task::spawn_local(async move { loop { // crossterm event polling — 50ms timeout so we can tick. match event::poll(Duration::from_millis(50)) { Ok(true) => { if let Ok(Event::Key(key)) = event::read() { if key_tx.send(TuiEvent::Key(key)).await.is_err() { break; } } } Ok(false) => { // No event — send a tick so the UI redraws. if key_tx.send(TuiEvent::Tick).await.is_err() { break; } } Err(_) => break, } } }); // ── Message poll task ───────────────────────────────────────────────────── // Clone session state for the poll task (it needs its own SessionState). let poll_session = SessionState::load( &session.state_path.clone(), session.password.as_ref().map(|p| p.as_str()), )?; let poll_tx = event_tx.clone(); tokio::task::spawn_local(poll_task(poll_session, client.clone(), poll_tx)); // ── Main loop ───────────────────────────────────────────────────────────── loop { terminal.draw(|f| ui(f, &app)).context("draw")?; match event_rx.recv().await { None => break, Some(TuiEvent::Tick) => { // Just redraw. } Some(TuiEvent::NewMessages(msgs)) => { app.append_messages(msgs); } Some(TuiEvent::Key(key)) => { match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { app.should_quit = true; } KeyCode::Char('q') if app.input.is_empty() => { app.should_quit = true; } KeyCode::Enter => { let text = app.input.trim().to_string(); if !text.is_empty() { app.input.clear(); if let Some(conv_id) = app.active_conv_id().cloned() { match send_message(session, &client, &conv_id, &text).await { Ok(()) => { // Add to in-memory list immediately. app.messages.push(DisplayMessage { sender: format!("me({})", app.identity_short), body: text, timestamp_ms: now_ms(), is_outgoing: true, }); } Err(_e) => { // Silently drop — user will see nothing happened. } } } } } KeyCode::Char(c) => { app.input.push(c); } KeyCode::Backspace => { app.input.pop(); } KeyCode::Up => { app.scroll_up(); } KeyCode::Down => { app.scroll_down(); } KeyCode::Tab => { if key.modifiers.contains(KeyModifiers::SHIFT) { app.select_prev_channel(session); } else { app.select_next_channel(session); } app.reload_messages(session)?; } _ => {} } } } if app.should_quit { break; } } Ok(()) } // ── Startup helpers ─────────────────────────────────────────────────────────── async fn auto_upload_keys_tui( session: &SessionState, client: &node_service::Client, ) -> anyhow::Result<()> { let ks_path = session.state_path.with_extension("pending.ks"); let ks = DiskKeyStore::persistent(&ks_path).unwrap_or_else(|_| DiskKeyStore::ephemeral()); let mut member = GroupMember::new_with_state( Arc::clone(&session.identity), ks, None, false, ); let kp_bytes = member.generate_key_package().context("generate KeyPackage")?; let id_key = session.identity.public_key_bytes(); upload_key_package(client, &id_key, &kp_bytes).await?; if let Some(ref hkp) = session.hybrid_kp { upload_hybrid_key(client, &id_key, &hkp.public_key()).await?; } Ok(()) } async fn resolve_tui_access_token( state_path: &Path, server: &str, ca_cert: &Path, server_name: &str, state_password: Option<&str>, username: Option<&str>, opaque_password: Option<&str>, cli_access_token: &str, ) -> anyhow::Result { if !cli_access_token.is_empty() { return Ok(cli_access_token.to_string()); } if let Some(cached) = load_cached_session(state_path, state_password) { return Ok(cached.token_hex); } let username = match username { Some(u) => u.to_string(), None => { use std::io::Write; eprint!("Username: "); std::io::stderr().flush().ok(); let mut input = String::new(); std::io::stdin() .read_line(&mut input) .context("failed to read username")?; let trimmed = input.trim().to_string(); anyhow::ensure!(!trimmed.is_empty(), "username is required"); trimmed } }; let opaque_password = match opaque_password { Some(p) => p.to_string(), None => rpassword::read_password().context("failed to read password")?, }; let state = load_or_init_state(state_path, state_password)?; let identity = IdentityKeypair::from_seed(state.identity_seed); let identity_key = identity.public_key_bytes().to_vec(); let node_client = connect_node(server, ca_cert, server_name).await?; match opaque_register(&node_client, &username, &opaque_password, Some(&identity_key)).await { Ok(()) | Err(_) => {} } let token_bytes = opaque_login(&node_client, &username, &opaque_password, &identity_key) .await .context("OPAQUE login failed")?; let token_hex = hex::encode(&token_bytes); save_cached_session(state_path, &username, &token_hex, state_password)?; Ok(token_hex) }