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},
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}