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, Text}, widgets::{Block, Borders, Paragraph, Wrap}, 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 => { // Scroll up by 1 line self.scroll_offset = self.scroll_offset.saturating_add(1); } KeyCode::Down => { // Scroll down by 1 line self.scroll_offset = self.scroll_offset.saturating_sub(1); } KeyCode::PageUp => { // Scroll up by 10 lines self.scroll_offset = self.scroll_offset.saturating_add(10); } KeyCode::PageDown => { // Scroll down by 10 lines 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.area()); // Render messages let mut message_lines = Vec::new(); // Convert messages to styled lines in reverse chronological order (newest first) for msg in self.messages.iter().rev() { let style = if msg.is_own { Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; message_lines.push(Line::from(Span::styled(msg.format_display(), style))); } let messages_text = Text::from(message_lines); let messages_paragraph = Paragraph::new(messages_text) .block(Block::default().borders(Borders::ALL).title("Messages")) .wrap(Wrap { trim: true }) .scroll((self.scroll_offset as u16, 0)); frame.render_widget(messages_paragraph, 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: &mut 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: &mut 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, )); // Save updated session (in case token was refreshed) if let Ok(store) = crate::credentials::CredentialStore::new() { if let Some(session) = client.get_session() { let _ = store.store_session(session); } } } 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(()) }