A Rust CLI for publishing thought records. Designed to work with thought.stream.
at main 283 lines 8.7 kB view raw
1use anyhow::Result; 2use chrono::{DateTime, Utc}; 3use crossterm::{ 4 event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, 5 execute, 6 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 7}; 8use ratatui::{ 9 backend::CrosstermBackend, 10 layout::{Constraint, Direction, Layout}, 11 style::{Color, Modifier, Style}, 12 text::{Line, Span}, 13 widgets::{Block, Borders, List, ListItem, Paragraph}, 14 Frame, Terminal, 15}; 16use std::{ 17 io::{self, Stdout}, 18 time::Duration, 19}; 20use tokio::sync::mpsc; 21 22use crate::client::AtProtoClient; 23 24#[derive(Debug, Clone)] 25pub struct Message { 26 pub handle: String, 27 pub content: String, 28 pub timestamp: DateTime<Utc>, 29 pub is_own: bool, 30} 31 32impl Message { 33 pub fn new(handle: String, content: String, is_own: bool) -> Self { 34 Self { 35 handle, 36 content, 37 timestamp: Utc::now(), 38 is_own, 39 } 40 } 41 42 pub fn format_display(&self) -> String { 43 let time_str = self.timestamp.format("%H:%M:%S").to_string(); 44 format!("[{}] {}: {}", time_str, self.handle, self.content) 45 } 46} 47 48pub struct TuiApp { 49 messages: Vec<Message>, 50 input: String, 51 scroll_offset: usize, 52 status: String, 53 message_count: usize, 54 connected: bool, 55 should_quit: bool, 56} 57 58impl TuiApp { 59 pub fn new() -> Self { 60 Self { 61 messages: Vec::new(), 62 input: String::new(), 63 scroll_offset: 0, 64 status: "Connecting...".to_string(), 65 message_count: 0, 66 connected: false, 67 should_quit: false, 68 } 69 } 70 71 pub fn add_message(&mut self, message: Message) { 72 self.messages.push(message); 73 self.message_count += 1; 74 75 // Keep only last 1000 messages 76 if self.messages.len() > 1000 { 77 self.messages.remove(0); 78 } 79 80 // Auto-scroll to bottom unless user is scrolling up 81 if self.scroll_offset == 0 { 82 self.scroll_offset = 0; // Stay at bottom 83 } 84 } 85 86 pub fn set_connection_status(&mut self, connected: bool) { 87 self.connected = connected; 88 self.status = if connected { 89 format!("Connected • {} messages", self.message_count) 90 } else { 91 "Reconnecting...".to_string() 92 }; 93 } 94 95 pub fn handle_input(&mut self, key: KeyCode) -> Option<String> { 96 match key { 97 KeyCode::Enter => { 98 if !self.input.is_empty() { 99 let message = self.input.clone(); 100 self.input.clear(); 101 return Some(message); 102 } 103 } 104 KeyCode::Char(c) => { 105 self.input.push(c); 106 } 107 KeyCode::Backspace => { 108 self.input.pop(); 109 } 110 KeyCode::Up => { 111 if self.scroll_offset < self.messages.len().saturating_sub(1) { 112 self.scroll_offset += 1; 113 } 114 } 115 KeyCode::Down => { 116 if self.scroll_offset > 0 { 117 self.scroll_offset -= 1; 118 } 119 } 120 KeyCode::PageUp => { 121 self.scroll_offset = (self.scroll_offset + 10).min(self.messages.len().saturating_sub(1)); 122 } 123 KeyCode::PageDown => { 124 self.scroll_offset = self.scroll_offset.saturating_sub(10); 125 } 126 KeyCode::Esc => { 127 self.should_quit = true; 128 } 129 _ => {} 130 } 131 None 132 } 133 134 pub fn should_quit(&self) -> bool { 135 self.should_quit 136 } 137 138 pub fn draw(&self, frame: &mut Frame) { 139 let vertical = Layout::default() 140 .direction(Direction::Vertical) 141 .constraints([ 142 Constraint::Min(0), // Messages area 143 Constraint::Length(3), // Status area 144 Constraint::Length(3), // Input area 145 ]) 146 .split(frame.size()); 147 148 // Render messages 149 let visible_messages: Vec<ListItem> = self.messages 150 .iter() 151 .rev() 152 .skip(self.scroll_offset) 153 .take(vertical[0].height as usize - 2) // Account for borders 154 .map(|msg| { 155 let style = if msg.is_own { 156 Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) 157 } else { 158 Style::default().fg(Color::White) 159 }; 160 161 ListItem::new(Line::from(Span::styled(msg.format_display(), style))) 162 }) 163 .collect::<Vec<_>>() 164 .into_iter() 165 .rev() 166 .collect(); 167 168 let messages_list = List::new(visible_messages) 169 .block(Block::default().borders(Borders::ALL).title("Messages")); 170 frame.render_widget(messages_list, vertical[0]); 171 172 // Render status 173 let status_style = if self.connected { 174 Style::default().fg(Color::Green) 175 } else { 176 Style::default().fg(Color::Yellow) 177 }; 178 179 let status_paragraph = Paragraph::new(self.status.clone()) 180 .style(status_style) 181 .block(Block::default().borders(Borders::ALL).title("Status")); 182 frame.render_widget(status_paragraph, vertical[1]); 183 184 // Render input 185 let input_paragraph = Paragraph::new(self.input.clone()) 186 .block(Block::default().borders(Borders::ALL).title("Input (Esc to quit)")); 187 frame.render_widget(input_paragraph, vertical[2]); 188 } 189} 190 191pub async fn run_tui( 192 client: &AtProtoClient, 193 mut message_rx: mpsc::UnboundedReceiver<Message>, 194) -> Result<()> { 195 // Setup terminal 196 enable_raw_mode()?; 197 let mut stdout = io::stdout(); 198 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 199 let backend = CrosstermBackend::new(stdout); 200 let mut terminal = Terminal::new(backend)?; 201 202 let mut app = TuiApp::new(); 203 204 // Add welcome message 205 app.add_message(Message::new( 206 "system".to_string(), 207 "Welcome to Think TUI! Connecting to jetstream...".to_string(), 208 false, 209 )); 210 211 let result = run_tui_loop(&mut terminal, &mut app, client, &mut message_rx).await; 212 213 // Restore terminal 214 disable_raw_mode()?; 215 execute!( 216 terminal.backend_mut(), 217 LeaveAlternateScreen, 218 DisableMouseCapture 219 )?; 220 terminal.show_cursor()?; 221 222 result 223} 224 225async fn run_tui_loop( 226 terminal: &mut Terminal<CrosstermBackend<Stdout>>, 227 app: &mut TuiApp, 228 client: &AtProtoClient, 229 message_rx: &mut mpsc::UnboundedReceiver<Message>, 230) -> Result<()> { 231 loop { 232 // Draw the UI 233 terminal.draw(|f| app.draw(f))?; 234 235 // Handle events with a timeout so we can check for messages 236 if event::poll(Duration::from_millis(100))? { 237 if let Event::Key(key) = event::read()? { 238 if key.kind == KeyEventKind::Press { 239 // Handle Ctrl+C 240 if matches!(key.code, KeyCode::Char('c')) && key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) { 241 break; 242 } 243 244 // Handle other input 245 if let Some(message) = app.handle_input(key.code) { 246 // Publish the message 247 match client.publish_blip(&message).await { 248 Ok(_) => { 249 // Add our own message to the display 250 app.add_message(Message::new( 251 "you".to_string(), 252 message, 253 true, 254 )); 255 } 256 Err(e) => { 257 // Add error message 258 app.add_message(Message::new( 259 "error".to_string(), 260 format!("Failed to publish: {}", e), 261 false, 262 )); 263 } 264 } 265 } 266 } 267 } 268 } 269 270 // Check for new messages from jetstream 271 while let Ok(message) = message_rx.try_recv() { 272 app.add_message(message); 273 app.set_connection_status(true); 274 } 275 276 // Check if we should quit 277 if app.should_quit() { 278 break; 279 } 280 } 281 282 Ok(()) 283}