use anyhow::Result; use chrono::{DateTime, Utc}; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{ backend::CrosstermBackend, layout::{Constraint, Direction, Layout}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, Paragraph}, Frame, Terminal, }; use std::{ io::{self, Stdout}, time::Duration, }; use tokio::sync::mpsc; use crate::client::AtProtoClient; #[derive(Debug, Clone)] pub struct Message { pub handle: String, pub content: String, pub timestamp: DateTime, pub is_own: bool, } impl Message { pub fn new(handle: String, content: String, is_own: bool) -> Self { Self { handle, content, timestamp: Utc::now(), is_own, } } pub fn format_display(&self) -> String { let time_str = self.timestamp.format("%H:%M:%S").to_string(); format!("[{}] {}: {}", time_str, self.handle, self.content) } } pub struct TuiApp { messages: Vec, input: String, scroll_offset: usize, status: String, message_count: usize, connected: bool, should_quit: bool, } impl TuiApp { pub fn new() -> Self { Self { messages: Vec::new(), input: String::new(), scroll_offset: 0, status: "Connecting...".to_string(), message_count: 0, connected: false, should_quit: false, } } pub fn add_message(&mut self, message: Message) { self.messages.push(message); self.message_count += 1; // Keep only last 1000 messages if self.messages.len() > 1000 { self.messages.remove(0); } // Auto-scroll to bottom unless user is scrolling up if self.scroll_offset == 0 { self.scroll_offset = 0; // Stay at bottom } } pub fn set_connection_status(&mut self, connected: bool) { self.connected = connected; self.status = if connected { format!("Connected • {} messages", self.message_count) } else { "Reconnecting...".to_string() }; } pub fn handle_input(&mut self, key: KeyCode) -> Option { match key { KeyCode::Enter => { if !self.input.is_empty() { let message = self.input.clone(); self.input.clear(); return Some(message); } } KeyCode::Char(c) => { self.input.push(c); } KeyCode::Backspace => { self.input.pop(); } KeyCode::Up => { if self.scroll_offset < self.messages.len().saturating_sub(1) { self.scroll_offset += 1; } } KeyCode::Down => { if self.scroll_offset > 0 { self.scroll_offset -= 1; } } KeyCode::PageUp => { self.scroll_offset = (self.scroll_offset + 10).min(self.messages.len().saturating_sub(1)); } KeyCode::PageDown => { self.scroll_offset = self.scroll_offset.saturating_sub(10); } KeyCode::Esc => { self.should_quit = true; } _ => {} } None } pub fn should_quit(&self) -> bool { self.should_quit } pub fn draw(&self, frame: &mut Frame) { let vertical = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(0), // Messages area Constraint::Length(3), // Status area Constraint::Length(3), // Input area ]) .split(frame.size()); // Render messages let visible_messages: Vec = self.messages .iter() .rev() .skip(self.scroll_offset) .take(vertical[0].height as usize - 2) // Account for borders .map(|msg| { let style = if msg.is_own { Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; ListItem::new(Line::from(Span::styled(msg.format_display(), style))) }) .collect::>() .into_iter() .rev() .collect(); let messages_list = List::new(visible_messages) .block(Block::default().borders(Borders::ALL).title("Messages")); frame.render_widget(messages_list, vertical[0]); // Render status let status_style = if self.connected { Style::default().fg(Color::Green) } else { Style::default().fg(Color::Yellow) }; let status_paragraph = Paragraph::new(self.status.clone()) .style(status_style) .block(Block::default().borders(Borders::ALL).title("Status")); frame.render_widget(status_paragraph, vertical[1]); // Render input let input_paragraph = Paragraph::new(self.input.clone()) .block(Block::default().borders(Borders::ALL).title("Input (Esc to quit)")); frame.render_widget(input_paragraph, vertical[2]); } } pub async fn run_tui( client: &AtProtoClient, mut message_rx: mpsc::UnboundedReceiver, ) -> Result<()> { // Setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; let mut app = TuiApp::new(); // Add welcome message app.add_message(Message::new( "system".to_string(), "Welcome to Think TUI! Connecting to jetstream...".to_string(), false, )); let result = run_tui_loop(&mut terminal, &mut app, client, &mut message_rx).await; // Restore terminal disable_raw_mode()?; execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; terminal.show_cursor()?; result } async fn run_tui_loop( terminal: &mut Terminal>, app: &mut TuiApp, client: &AtProtoClient, message_rx: &mut mpsc::UnboundedReceiver, ) -> Result<()> { loop { // Draw the UI terminal.draw(|f| app.draw(f))?; // Handle events with a timeout so we can check for messages if event::poll(Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { // Handle Ctrl+C if matches!(key.code, KeyCode::Char('c')) && key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) { break; } // Handle other input if let Some(message) = app.handle_input(key.code) { // Publish the message match client.publish_blip(&message).await { Ok(_) => { // Add our own message to the display app.add_message(Message::new( "you".to_string(), message, true, )); } Err(e) => { // Add error message app.add_message(Message::new( "error".to_string(), format!("Failed to publish: {}", e), false, )); } } } } } } // Check for new messages from jetstream while let Ok(message) = message_rx.try_recv() { app.add_message(message); app.set_connection_status(true); } // Check if we should quit if app.should_quit() { break; } } Ok(()) }