use kordophoned_client as daemon; use anyhow::Result; use crossterm::event::{Event as CEvent, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use ratatui::prelude::*; use ratatui::widgets::*; use std::sync::mpsc; use std::time::{Duration, Instant}; use unicode_width::UnicodeWidthStr; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ViewMode { List, Chat, Split, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Focus { Navigation, Input, } struct AppState { conversations: Vec, selected_idx: usize, selected_conversation_id: Option, messages: Vec, active_conversation_id: Option, active_conversation_title: String, status: String, input: String, focus: Focus, transcript_scroll: u16, pinned_to_bottom: bool, refresh_conversations_in_flight: bool, refresh_messages_in_flight: bool, } impl AppState { fn new() -> Self { Self { conversations: Vec::new(), selected_idx: 0, selected_conversation_id: None, messages: Vec::new(), active_conversation_id: None, active_conversation_title: String::new(), status: String::new(), input: String::new(), focus: Focus::Navigation, transcript_scroll: 0, pinned_to_bottom: true, refresh_conversations_in_flight: false, refresh_messages_in_flight: false, } } fn select_next(&mut self) { if self.conversations.is_empty() { self.selected_idx = 0; self.selected_conversation_id = None; return; } self.selected_idx = (self.selected_idx + 1).min(self.conversations.len() - 1); self.selected_conversation_id = self .conversations .get(self.selected_idx) .map(|c| c.id.clone()); } fn select_prev(&mut self) { if self.conversations.is_empty() { self.selected_idx = 0; self.selected_conversation_id = None; return; } self.selected_idx = self.selected_idx.saturating_sub(1); self.selected_conversation_id = self .conversations .get(self.selected_idx) .map(|c| c.id.clone()); } fn open_selected_conversation(&mut self) { if let Some(conv) = self.conversations.get(self.selected_idx) { self.active_conversation_id = Some(conv.id.clone()); self.active_conversation_title = conv.title.clone(); self.selected_conversation_id = Some(conv.id.clone()); self.messages.clear(); self.transcript_scroll = 0; self.pinned_to_bottom = true; self.focus = Focus::Input; self.status = "Loading…".to_string(); } } } fn view_mode(width: u16, has_active_conversation: bool, requested: ViewMode) -> ViewMode { let min_conversations = 24u16; let min_chat = 44u16; let min_total = min_conversations + 1 + min_chat; if width >= min_total { return ViewMode::Split; } if has_active_conversation { requested } else { ViewMode::List } } fn ui(frame: &mut Frame, app: &AppState, requested_view: ViewMode) { let area = frame.area(); let mode = view_mode( area.width, app.active_conversation_id.is_some(), requested_view, ); let show_input = matches!(mode, ViewMode::Chat | ViewMode::Split) && app.active_conversation_id.is_some(); let chunks = if show_input { Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ]) .split(area) } else { Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(1)]) .split(area) }; let (main_area, input_area, status_area) = if show_input { (chunks[0], Some(chunks[1]), chunks[2]) } else { (chunks[0], None, chunks[1]) }; match mode { ViewMode::Split => { let left_width = (main_area.width / 3).clamp(24, 40); let cols = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Length(left_width), Constraint::Min(1)]) .split(main_area); render_conversations(frame, app, cols[0], true); render_transcript(frame, app, cols[1], true); } ViewMode::List => render_conversations(frame, app, main_area, false), ViewMode::Chat => render_transcript(frame, app, main_area, false), } if let Some(input_area) = input_area { let input_scroll_x = render_input(frame, app, input_area); if app.focus == Focus::Input { let cursor_col = visual_width_u16(app.input.as_str()); let mut x = input_area .x .saturating_add(1) .saturating_add(cursor_col.saturating_sub(input_scroll_x)); let max_x = input_area .x .saturating_add(input_area.width.saturating_sub(2)); x = x.min(max_x); let y = input_area.y + 1; frame.set_cursor_position(Position { x, y }); } } render_status(frame, app, status_area, mode); } fn render_conversations(frame: &mut Frame, app: &AppState, area: Rect, in_split: bool) { let title = if in_split { "Conversations (↑/↓, Enter)" } else { "Conversations (↑/↓, Enter to open)" }; let items = app .conversations .iter() .map(|c| { let unread = if c.unread_count > 0 { format!(" ({})", c.unread_count) } else { String::new() }; let header = Line::from(vec![ Span::styled( c.title.clone(), Style::default().add_modifier(Modifier::BOLD), ), Span::raw(unread), ]); let preview = Line::from(Span::styled( c.preview.clone(), Style::default().fg(Color::DarkGray), )); ListItem::new(vec![header, preview]) }) .collect::>(); let mut state = ListState::default(); state.select(if app.conversations.is_empty() { None } else { Some(app.selected_idx) }); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(title)) .highlight_style( Style::default() .bg(Color::Blue) .fg(Color::White) .add_modifier(Modifier::BOLD), ) .highlight_symbol("▸ "); frame.render_stateful_widget(list, area, &mut state); } fn render_transcript(frame: &mut Frame, app: &AppState, area: Rect, in_split: bool) { let title = if let Some(_) = app.active_conversation_id { if in_split { format!("{} (Esc: nav, Tab: focus)", app.active_conversation_title) } else { format!("{} (Esc: back)", app.active_conversation_title) } } else { "Chat".to_string() }; let mut lines: Vec = Vec::new(); for message in &app.messages { let ts = time::OffsetDateTime::from_unix_timestamp(message.date_unix) .unwrap_or(time::OffsetDateTime::UNIX_EPOCH) .format(&time::format_description::well_known::Rfc3339) .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()); lines.push(Line::from(vec![ Span::styled( message.sender.clone(), Style::default().add_modifier(Modifier::BOLD), ), Span::raw(" "), Span::styled(ts, Style::default().fg(Color::DarkGray)), ])); let mut rendered_any_text = false; for text_line in message.text.lines() { rendered_any_text = true; lines.push(Line::from(Span::raw(text_line.to_string()))); } if !rendered_any_text { lines.push(Line::from(Span::styled( "", Style::default().fg(Color::DarkGray), ))); } lines.push(Line::from(Span::raw(""))); } if lines.is_empty() { lines.push(Line::from(Span::styled( "No messages.", Style::default().fg(Color::DarkGray), ))); } let paragraph = Paragraph::new(Text::from(lines)) .block(Block::default().borders(Borders::ALL).title(title)) .wrap(Wrap { trim: false }) .scroll((app.transcript_scroll, 0)); frame.render_widget(paragraph, area); } fn render_input(frame: &mut Frame, app: &AppState, area: Rect) -> u16 { let title = if app.focus == Focus::Input { "Reply (Enter to send)" } else { "Reply (press i to type)" }; let inner_width = area.width.saturating_sub(2).max(1); let cursor_col = visual_width_u16(app.input.as_str()); let input_scroll_x = cursor_col.saturating_sub(inner_width.saturating_sub(1)); let input = Paragraph::new(app.input.as_str()) .block(Block::default().borders(Borders::ALL).title(title)) .scroll((0, input_scroll_x)); frame.render_widget(input, area); input_scroll_x } fn render_status(frame: &mut Frame, app: &AppState, area: Rect, mode: ViewMode) { let mut parts = vec![ format!("{} convs", app.conversations.len()), match mode { ViewMode::Split => "split".to_string(), ViewMode::List => "list".to_string(), ViewMode::Chat => "chat".to_string(), }, ]; if !app.status.trim().is_empty() { parts.push(app.status.clone()); } let line = parts.join(" | "); frame.render_widget( Paragraph::new(line).block(Block::default().borders(Borders::TOP)), area, ); } fn main() -> Result<()> { enable_raw_mode()?; let mut stdout = std::io::stdout(); crossterm::execute!( stdout, crossterm::terminal::EnterAlternateScreen, crossterm::event::EnableMouseCapture )?; let backend = ratatui::backend::CrosstermBackend::new(stdout); let mut terminal = ratatui::Terminal::new(backend)?; let res = run_app(&mut terminal); disable_raw_mode()?; crossterm::execute!( terminal.backend_mut(), crossterm::event::DisableMouseCapture, crossterm::terminal::LeaveAlternateScreen )?; terminal.show_cursor()?; res } fn run_app( terminal: &mut ratatui::Terminal>, ) -> Result<()> { let (request_tx, request_rx) = mpsc::channel::(); let (event_tx, event_rx) = mpsc::channel::(); let _worker = daemon::spawn_worker(request_rx, event_tx); let tick_rate = Duration::from_millis(150); let refresh_rate = Duration::from_secs(2); let mut last_tick = Instant::now(); let mut last_refresh = Instant::now() - refresh_rate; let mut requested_view = ViewMode::List; let mut app = AppState::new(); app.status = "Connecting…".to_string(); request_tx.send(daemon::Request::RefreshConversations).ok(); app.refresh_conversations_in_flight = true; loop { let size = terminal.size()?; while let Ok(evt) = event_rx.try_recv() { match evt { daemon::Event::Conversations(convs) => { let keep_selected_id = app .selected_conversation_id .clone() .or_else(|| app.active_conversation_id.clone()); app.refresh_conversations_in_flight = false; app.status.clear(); app.conversations = convs; if app.conversations.is_empty() { app.selected_idx = 0; app.selected_conversation_id = None; } else if let Some(id) = keep_selected_id { if let Some(idx) = app.conversations.iter().position(|c| c.id == id) { app.selected_idx = idx; app.selected_conversation_id = Some(id); } else { app.selected_idx = 0; app.selected_conversation_id = Some(app.conversations[0].id.clone()); } } else { app.selected_idx = app.selected_idx.min(app.conversations.len() - 1); app.selected_conversation_id = Some(app.conversations[app.selected_idx].id.clone()); } } daemon::Event::Messages { conversation_id, messages, } => { app.refresh_messages_in_flight = false; if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) { let was_pinned = app.pinned_to_bottom; app.messages = messages; app.pinned_to_bottom = was_pinned; } } daemon::Event::MessageSent { conversation_id, outgoing_id, } => { if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) { app.status = outgoing_id .as_deref() .map(|id| format!("Sent ({id})")) .unwrap_or_else(|| "Sent".to_string()); app.refresh_messages_in_flight = false; request_tx .send(daemon::Request::RefreshMessages { conversation_id }) .ok(); app.refresh_messages_in_flight = true; } } daemon::Event::MarkedRead => {} daemon::Event::ConversationSyncTriggered { conversation_id } => { if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) { app.status = "Syncing…".to_string(); } } daemon::Event::ConversationsUpdated => { if !app.refresh_conversations_in_flight { request_tx.send(daemon::Request::RefreshConversations).ok(); app.refresh_conversations_in_flight = true; } if let Some(cid) = app.active_conversation_id.clone() { if !app.refresh_messages_in_flight { request_tx .send(daemon::Request::RefreshMessages { conversation_id: cid, }) .ok(); app.refresh_messages_in_flight = true; } } } daemon::Event::MessagesUpdated { conversation_id } => { if !app.refresh_conversations_in_flight { request_tx.send(daemon::Request::RefreshConversations).ok(); app.refresh_conversations_in_flight = true; } if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) { if !app.refresh_messages_in_flight { request_tx .send(daemon::Request::RefreshMessages { conversation_id }) .ok(); app.refresh_messages_in_flight = true; } } } daemon::Event::UpdateStreamReconnected => { if !app.refresh_conversations_in_flight { request_tx.send(daemon::Request::RefreshConversations).ok(); app.refresh_conversations_in_flight = true; } if let Some(cid) = app.active_conversation_id.clone() { if !app.refresh_messages_in_flight { request_tx .send(daemon::Request::RefreshMessages { conversation_id: cid, }) .ok(); app.refresh_messages_in_flight = true; } } } daemon::Event::Error(e) => { app.refresh_conversations_in_flight = false; app.refresh_messages_in_flight = false; app.status = e; } } } apply_transcript_scroll_policy(&mut app, size, requested_view); terminal.draw(|f| ui(f, &app, requested_view))?; let timeout = tick_rate.saturating_sub(last_tick.elapsed()); if crossterm::event::poll(timeout)? { if let CEvent::Key(key) = crossterm::event::read()? { if key.kind != KeyEventKind::Press { continue; } let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); match (key.code, ctrl) { (KeyCode::Char('c'), true) => return Ok(()), _ => {} } let screen_mode = view_mode( size.width, app.active_conversation_id.is_some(), requested_view, ); let max_scroll = max_transcript_scroll(&app, size, requested_view); match screen_mode { ViewMode::List => match key.code { KeyCode::Up => app.select_prev(), KeyCode::Down => app.select_next(), KeyCode::Enter => { app.open_selected_conversation(); if app.active_conversation_id.is_some() { requested_view = ViewMode::Chat; if let Some(cid) = app.active_conversation_id.clone() { request_tx .send(daemon::Request::MarkRead { conversation_id: cid.clone(), }) .ok(); request_tx .send(daemon::Request::SyncConversation { conversation_id: cid.clone(), }) .ok(); request_tx .send(daemon::Request::RefreshMessages { conversation_id: cid, }) .ok(); app.refresh_messages_in_flight = true; } } } _ => {} }, ViewMode::Chat => match key.code { KeyCode::Esc => { requested_view = ViewMode::List; app.focus = Focus::Navigation; } KeyCode::Char('i') if app.focus != Focus::Input => app.focus = Focus::Input, _ => { handle_chat_keys(&mut app, &request_tx, key.code, max_scroll); } }, ViewMode::Split => match key.code { KeyCode::Tab => { app.focus = match app.focus { Focus::Navigation => Focus::Input, Focus::Input => Focus::Navigation, } } KeyCode::Esc => app.focus = Focus::Navigation, KeyCode::Char('i') if app.focus != Focus::Input => app.focus = Focus::Input, KeyCode::Up => { if app.focus == Focus::Navigation { app.select_prev() } else { scroll_up(&mut app, 1); } } KeyCode::Down => { if app.focus == Focus::Navigation { app.select_next() } else { scroll_down(&mut app, 1, max_scroll); } } KeyCode::Enter => { if app.focus == Focus::Navigation { app.open_selected_conversation(); requested_view = ViewMode::Chat; if let Some(cid) = app.active_conversation_id.clone() { request_tx .send(daemon::Request::MarkRead { conversation_id: cid.clone(), }) .ok(); request_tx .send(daemon::Request::SyncConversation { conversation_id: cid.clone(), }) .ok(); request_tx .send(daemon::Request::RefreshMessages { conversation_id: cid, }) .ok(); app.refresh_messages_in_flight = true; } } else { handle_chat_keys(&mut app, &request_tx, key.code, max_scroll); } } _ => handle_chat_keys(&mut app, &request_tx, key.code, max_scroll), }, } } } if last_refresh.elapsed() >= refresh_rate { if !app.refresh_conversations_in_flight { request_tx.send(daemon::Request::RefreshConversations).ok(); app.refresh_conversations_in_flight = true; } if let Some(cid) = app.active_conversation_id.clone() { if !app.refresh_messages_in_flight { request_tx .send(daemon::Request::RefreshMessages { conversation_id: cid, }) .ok(); app.refresh_messages_in_flight = true; } } last_refresh = Instant::now(); } if last_tick.elapsed() >= tick_rate { last_tick = Instant::now(); } } } fn handle_chat_keys( app: &mut AppState, request_tx: &mpsc::Sender, code: KeyCode, max_scroll: u16, ) { match code { KeyCode::PageUp => scroll_up(app, 10), KeyCode::PageDown => scroll_down(app, 10, max_scroll), _ => {} } if app.focus != Focus::Input { return; } match code { KeyCode::Enter => { let text = app.input.trim().to_string(); if text.is_empty() { return; } let Some(conversation_id) = app.active_conversation_id.clone() else { app.status = "No conversation selected".to_string(); return; }; request_tx .send(daemon::Request::SendMessage { conversation_id, text, }) .ok(); app.refresh_messages_in_flight = true; app.input.clear(); } KeyCode::Backspace => { app.input.pop(); } KeyCode::Char(c) => { if !c.is_control() { app.input.push(c); } } _ => {} } } fn scroll_up(app: &mut AppState, amount: u16) { if amount > 0 { app.pinned_to_bottom = false; } app.transcript_scroll = app.transcript_scroll.saturating_sub(amount); } fn scroll_down(app: &mut AppState, amount: u16, max_scroll: u16) { app.transcript_scroll = app.transcript_scroll.saturating_add(amount); if app.transcript_scroll >= max_scroll { app.transcript_scroll = max_scroll; app.pinned_to_bottom = true; } } fn transcript_inner_width(size: Size, app: &AppState, requested_view: ViewMode) -> u16 { let mode = view_mode( size.width, app.active_conversation_id.is_some(), requested_view, ); let outer_width = match mode { ViewMode::Split => { let left_width = (size.width / 3).clamp(24, 40); size.width.saturating_sub(left_width) } ViewMode::Chat => size.width, ViewMode::List => 0, }; outer_width.saturating_sub(2).max(1) } fn visual_line_count(s: &str, inner_width: u16) -> u16 { let w = s.width(); if w == 0 { return 1; } let iw = inner_width.max(1) as usize; ((w + iw - 1) / iw).min(u16::MAX as usize) as u16 } fn visual_width_u16(s: &str) -> u16 { s.width().min(u16::MAX as usize) as u16 } fn transcript_content_visual_lines(messages: &[daemon::ChatMessage], inner_width: u16) -> u16 { if messages.is_empty() { return 1; } let mut total: u32 = 0; for message in messages { let ts = time::OffsetDateTime::from_unix_timestamp(message.date_unix) .unwrap_or(time::OffsetDateTime::UNIX_EPOCH) .format(&time::format_description::well_known::Rfc3339) .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()); let header = format!("{} {}", message.sender, ts); total += visual_line_count(&header, inner_width) as u32; let mut rendered_any_text = false; for text_line in message.text.lines() { rendered_any_text = true; total += visual_line_count(text_line, inner_width) as u32; } if !rendered_any_text { total += visual_line_count("", inner_width) as u32; } total += 1; // spacer line } total.min(u16::MAX as u32) as u16 } fn transcript_viewport_height(size: Size, app: &AppState, requested_view: ViewMode) -> u16 { let mode = view_mode( size.width, app.active_conversation_id.is_some(), requested_view, ); let show_input = matches!(mode, ViewMode::Chat | ViewMode::Split) && app.active_conversation_id.is_some(); let transcript_height = if show_input { size.height.saturating_sub(4) // input (3) + status (1) } else { size.height.saturating_sub(1) // status }; match mode { ViewMode::Chat | ViewMode::Split => transcript_height.saturating_sub(2), // borders ViewMode::List => 0, } } fn max_transcript_scroll(app: &AppState, size: Size, requested_view: ViewMode) -> u16 { let viewport_height = transcript_viewport_height(size, app, requested_view); let inner_width = transcript_inner_width(size, app, requested_view); let content = transcript_content_visual_lines(&app.messages, inner_width); content.saturating_sub(viewport_height) } fn apply_transcript_scroll_policy(app: &mut AppState, size: Size, requested_view: ViewMode) { let max_scroll = max_transcript_scroll(app, size, requested_view); if app.pinned_to_bottom { app.transcript_scroll = max_scroll; } else { app.transcript_scroll = app.transcript_scroll.min(max_scroll); } }