A Rust CLI for publishing thought records. Designed to work with thought.stream.
at main 9.1 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, Text}, 13 widgets::{Block, Borders, Paragraph, Wrap}, 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 // Scroll up by 1 line 112 self.scroll_offset = self.scroll_offset.saturating_add(1); 113 } 114 KeyCode::Down => { 115 // Scroll down by 1 line 116 self.scroll_offset = self.scroll_offset.saturating_sub(1); 117 } 118 KeyCode::PageUp => { 119 // Scroll up by 10 lines 120 self.scroll_offset = self.scroll_offset.saturating_add(10); 121 } 122 KeyCode::PageDown => { 123 // Scroll down by 10 lines 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.area()); 147 148 // Render messages 149 let mut message_lines = Vec::new(); 150 151 // Convert messages to styled lines in reverse chronological order (newest first) 152 for msg in self.messages.iter().rev() { 153 let style = if msg.is_own { 154 Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) 155 } else { 156 Style::default().fg(Color::White) 157 }; 158 159 message_lines.push(Line::from(Span::styled(msg.format_display(), style))); 160 } 161 162 let messages_text = Text::from(message_lines); 163 let messages_paragraph = Paragraph::new(messages_text) 164 .block(Block::default().borders(Borders::ALL).title("Messages")) 165 .wrap(Wrap { trim: true }) 166 .scroll((self.scroll_offset as u16, 0)); 167 frame.render_widget(messages_paragraph, vertical[0]); 168 169 // Render status 170 let status_style = if self.connected { 171 Style::default().fg(Color::Green) 172 } else { 173 Style::default().fg(Color::Yellow) 174 }; 175 176 let status_paragraph = Paragraph::new(self.status.clone()) 177 .style(status_style) 178 .block(Block::default().borders(Borders::ALL).title("Status")); 179 frame.render_widget(status_paragraph, vertical[1]); 180 181 // Render input 182 let input_paragraph = Paragraph::new(self.input.clone()) 183 .block(Block::default().borders(Borders::ALL).title("Input (Esc to quit)")); 184 frame.render_widget(input_paragraph, vertical[2]); 185 } 186} 187 188pub async fn run_tui( 189 client: &mut AtProtoClient, 190 mut message_rx: mpsc::UnboundedReceiver<Message>, 191) -> Result<()> { 192 // Setup terminal 193 enable_raw_mode()?; 194 let mut stdout = io::stdout(); 195 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 196 let backend = CrosstermBackend::new(stdout); 197 let mut terminal = Terminal::new(backend)?; 198 199 let mut app = TuiApp::new(); 200 201 // Add welcome message 202 app.add_message(Message::new( 203 "system".to_string(), 204 "Welcome to Think TUI! Connecting to jetstream...".to_string(), 205 false, 206 )); 207 208 let result = run_tui_loop(&mut terminal, &mut app, client, &mut message_rx).await; 209 210 // Restore terminal 211 disable_raw_mode()?; 212 execute!( 213 terminal.backend_mut(), 214 LeaveAlternateScreen, 215 DisableMouseCapture 216 )?; 217 terminal.show_cursor()?; 218 219 result 220} 221 222async fn run_tui_loop( 223 terminal: &mut Terminal<CrosstermBackend<Stdout>>, 224 app: &mut TuiApp, 225 client: &mut AtProtoClient, 226 message_rx: &mut mpsc::UnboundedReceiver<Message>, 227) -> Result<()> { 228 loop { 229 // Draw the UI 230 terminal.draw(|f| app.draw(f))?; 231 232 // Handle events with a timeout so we can check for messages 233 if event::poll(Duration::from_millis(100))? { 234 if let Event::Key(key) = event::read()? { 235 if key.kind == KeyEventKind::Press { 236 // Handle Ctrl+C 237 if matches!(key.code, KeyCode::Char('c')) && key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) { 238 break; 239 } 240 241 // Handle other input 242 if let Some(message) = app.handle_input(key.code) { 243 // Publish the message 244 match client.publish_blip(&message).await { 245 Ok(_) => { 246 // Add our own message to the display 247 app.add_message(Message::new( 248 "you".to_string(), 249 message, 250 true, 251 )); 252 253 // Save updated session (in case token was refreshed) 254 if let Ok(store) = crate::credentials::CredentialStore::new() { 255 if let Some(session) = client.get_session() { 256 let _ = store.store_session(session); 257 } 258 } 259 } 260 Err(e) => { 261 // Add error message 262 app.add_message(Message::new( 263 "error".to_string(), 264 format!("Failed to publish: {}", e), 265 false, 266 )); 267 } 268 } 269 } 270 } 271 } 272 } 273 274 // Check for new messages from jetstream 275 while let Ok(message) = message_rx.try_recv() { 276 app.add_message(message); 277 app.set_connection_status(true); 278 } 279 280 // Check if we should quit 281 if app.should_quit() { 282 break; 283 } 284 } 285 286 Ok(()) 287}