A Rust CLI for publishing thought records. Designed to work with thought.stream.
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}