From 16a280634be4208efd18e71b2fff62a9b945a29c Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Tue, 14 Apr 2026 17:07:24 +0800 Subject: [PATCH] feat: chat cli demo app via file transport --- chat-cli/Cargo.toml | 16 ++ chat-cli/README.md | 128 +++++++++++ chat-cli/src/app.rs | 454 ++++++++++++++++++++++++++++++++++++++ chat-cli/src/main.rs | 95 ++++++++ chat-cli/src/transport.rs | 146 ++++++++++++ chat-cli/src/ui.rs | 181 +++++++++++++++ 6 files changed, 1020 insertions(+) create mode 100644 chat-cli/Cargo.toml create mode 100644 chat-cli/README.md create mode 100644 chat-cli/src/app.rs create mode 100644 chat-cli/src/main.rs create mode 100644 chat-cli/src/transport.rs create mode 100644 chat-cli/src/ui.rs diff --git a/chat-cli/Cargo.toml b/chat-cli/Cargo.toml new file mode 100644 index 0000000..e7c4e8e --- /dev/null +++ b/chat-cli/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "chat-cli" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "chat-cli" +path = "src/main.rs" + +[dependencies] +logos-chat = { path = "../conversations" } +ratatui = "0.29" +crossterm = "0.29" +anyhow = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/chat-cli/README.md b/chat-cli/README.md new file mode 100644 index 0000000..aff6278 --- /dev/null +++ b/chat-cli/README.md @@ -0,0 +1,128 @@ +# Chat CLI + +A terminal chat application built with [ratatui](https://ratatui.rs/) using the logos-chat library. + +## Features + +- πŸ’¬ End-to-end encrypted messaging using the Double Ratchet algorithm +- πŸ“ File-based transport for local simulation (no network required) +- πŸ’Ύ Persistent storage (SQLite + JSON state) +- πŸ”„ Multiple chat support with chat switching +- πŸ–₯️ Beautiful terminal UI with ratatui + +## Usage + +Run two instances with different usernames in separate terminals: + +### Terminal 1 (Alice) + +```bash +cargo run -p chat-cli -- alice +``` + +### Terminal 2 (Bob) + +```bash +cargo run -p chat-cli -- bob +``` + +### Establishing a Connection + +1. In Alice's terminal, type `/intro` to generate an introduction bundle +2. Copy the bundle string (starts with `Bundle:`) +3. In Bob's terminal, type `/connect alice ` (paste Alice's bundle) +4. Bob can now send messages to Alice +5. Alice will see Bob's initial "Hello!" message and can reply + +### Commands + +| Command | Description | +|---------|-------------| +| `/help` | Show available commands | +| `/intro` | Generate and display your introduction bundle | +| `/connect ` | Connect to a user using their introduction bundle | +| `/chats` | List all your established chats | +| `/switch ` | Switch to a different chat | +| `/delete ` | Delete a chat (removes session and crypto state) | +| `/peers` | List transport-level peers (users with inbox directories) | +| `/status` | Show connection status and your address | +| `/clear` | Clear current chat's message history | +| `/quit` or `Esc` or `Ctrl+C` | Exit the application | + +#### `/peers` vs `/chats` + +- **`/peers`**: Shows users whose CLI has been started (have inbox directories). These are potential contacts you *could* message. +- **`/chats`**: Shows users you have an **encrypted session** with (via `/connect`). These are active conversations. + +### Sending Messages + +Simply type your message and press Enter. Messages are automatically encrypted and delivered via file-based transport. + +## How It Works + +### File-Based Transport + +Messages are passed between users via files in a shared directory: + +1. Each user has an "inbox" directory at `chat-cli-data/transport//` +2. When Alice sends a message to Bob, it's written as a JSON file in Bob's inbox +3. Bob's client watches for new files and processes incoming messages +4. Files are deleted after processing + +### Storage + +Data is stored in the `chat-cli-data/` directory: + +| File | Purpose | +|------|---------| +| `.db` | SQLite database for identity keys, inbox keys, chat metadata, and Double Ratchet state | +| `_state.json` | CLI state: username↔chat mappings, message history, active chat | +| `transport//` | Inbox directory for receiving messages | + +### Encryption + +All messages are encrypted using: +- X3DH key agreement for initial key exchange +- Double Ratchet algorithm for ongoing message encryption +- ChaCha20-Poly1305 for authenticated encryption + +## Example Session + +``` +# Terminal 1 (Alice) +$ cargo run -p chat-cli -- alice + +/intro +# Output: Bundle:abc123...def456 + +# Terminal 2 (Bob) +$ cargo run -p chat-cli -- bob + +/connect alice Bundle:abc123...def456 +# Connected! Bob sends "Hello!" automatically + +# Now type messages in either terminal to chat! + +# To see your chats: +/chats +# Output: alice (active) + +# To switch between chats (if you have multiple): +/switch alice +``` + +## Architecture + +``` +chat-cli/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ main.rs # Entry point +β”‚ β”œβ”€β”€ app.rs # Application state and logic +β”‚ β”œβ”€β”€ transport.rs # File-based message transport +β”‚ └── ui.rs # Ratatui terminal UI +``` + +The CLI uses logos-chat as a library without modifying it: +- `ChatManager` handles all encryption/decryption +- `Introduction` bundles enable key exchange +- `AddressedEnvelope` carries encrypted messages diff --git a/chat-cli/src/app.rs b/chat-cli/src/app.rs new file mode 100644 index 0000000..45213d2 --- /dev/null +++ b/chat-cli/src/app.rs @@ -0,0 +1,454 @@ +//! Chat application logic. + +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use logos_chat::{ChatManager, Introduction, StorageConfig}; +use serde::{Deserialize, Serialize}; + +use crate::transport::FileTransport; + +/// A chat message for display. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DisplayMessage { + pub from_self: bool, + pub content: String, + pub timestamp: u64, +} + +/// Metadata for a chat session (persisted). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatSession { + pub chat_id: String, + pub remote_user: String, + pub messages: Vec, +} + +/// App state that gets persisted. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct AppState { + /// Map from remote username to chat session. + pub sessions: HashMap, + /// Currently active chat (remote username). + pub active_chat: Option, +} + +/// The chat application state. +pub struct ChatApp { + /// The logos-chat manager. + pub manager: ChatManager, + /// File-based transport for message passing. + pub transport: FileTransport, + /// Our introduction bundle (to share with others). + pub intro_bundle: Option, + /// Persisted app state. + pub state: AppState, + /// Global messages (shown when no active chat). + pub global_messages: Vec, + /// Input buffer. + pub input: String, + /// Status message. + pub status: String, + /// Our user name. + pub user_name: String, + /// Path to state file. + state_path: PathBuf, +} + +impl ChatApp { + /// Create a new chat application. + pub fn new(user_name: &str, data_dir: &PathBuf) -> Result { + // Create database path + let db_path = data_dir.join(format!("{}.db", user_name)); + std::fs::create_dir_all(data_dir)?; + + // Open or create the chat manager with file-based storage + let manager = ChatManager::open(StorageConfig::File(db_path.to_string_lossy().to_string())) + .context("Failed to open ChatManager")?; + + // Create file-based transport + let transport = + FileTransport::new(user_name, data_dir).context("Failed to create file transport")?; + + // Load persisted state + let state_path = data_dir.join(format!("{}_state.json", user_name)); + let state = Self::load_state(&state_path); + + // Count existing chats + let chat_count = state.sessions.len(); + let status = if chat_count > 0 { + format!( + "Welcome back, {}! {} chat(s) loaded. Type /help for commands.", + user_name, chat_count + ) + } else { + format!("Welcome, {}! Type /help for commands.", user_name) + }; + + Ok(Self { + manager, + transport, + intro_bundle: None, + state, + global_messages: Vec::new(), + input: String::new(), + status, + user_name: user_name.to_string(), + state_path, + }) + } + + /// Load state from file. + fn load_state(path: &PathBuf) -> AppState { + if path.exists() { + if let Ok(contents) = fs::read_to_string(path) { + if let Ok(state) = serde_json::from_str(&contents) { + return state; + } + } + } + AppState::default() + } + + /// Save state to file. + fn save_state(&self) -> Result<()> { + let json = serde_json::to_string_pretty(&self.state)?; + fs::write(&self.state_path, json)?; + Ok(()) + } + + /// Get the current chat session (if any). + pub fn current_session(&self) -> Option<&ChatSession> { + self.state + .active_chat + .as_ref() + .and_then(|name| self.state.sessions.get(name)) + } + + /// Get the current messages to display. + pub fn messages(&self) -> Vec<&DisplayMessage> { + if let Some(session) = self.current_session() { + session.messages.iter().collect() + } else { + // Show global messages when no active chat + self.global_messages.iter().collect() + } + } + + /// Create and display our introduction bundle. + pub fn create_intro(&mut self) -> Result { + let intro = self.manager.create_intro_bundle()?; + let bundle_str: Vec = intro.clone().into(); + let bundle_string = String::from_utf8_lossy(&bundle_str).to_string(); + self.intro_bundle = Some(intro); + self.status = "Introduction bundle created. Share it with others!".to_string(); + Ok(bundle_string) + } + + /// Connect to another user using their introduction bundle. + pub fn connect(&mut self, remote_user: &str, bundle_str: &str) -> Result<()> { + // Check if we already have a chat with this user + if self.state.sessions.contains_key(remote_user) { + return Err(anyhow::anyhow!( + "Already have a chat with {}. Use /switch {} to switch to it.", + remote_user, + remote_user + )); + } + + let intro = Introduction::try_from(bundle_str.as_bytes()) + .map_err(|e| anyhow::anyhow!("Invalid bundle: {:?}", e))?; + + let (chat_id, envelopes) = self.manager.start_private_chat(&intro, "πŸ‘‹ Hello!")?; + + // Send the envelopes via file transport + for envelope in envelopes { + self.transport.send(remote_user, envelope.data)?; + } + + // Create new session + let mut session = ChatSession { + chat_id: chat_id.clone(), + remote_user: remote_user.to_string(), + messages: Vec::new(), + }; + session.messages.push(DisplayMessage { + from_self: true, + content: "πŸ‘‹ Hello!".to_string(), + timestamp: now(), + }); + + self.state.sessions.insert(remote_user.to_string(), session); + self.state.active_chat = Some(remote_user.to_string()); + self.save_state()?; + + self.status = format!("Connected to {}!", remote_user); + Ok(()) + } + + /// Switch to a different chat. + pub fn switch_chat(&mut self, remote_user: &str) -> Result<()> { + if self.state.sessions.contains_key(remote_user) { + self.state.active_chat = Some(remote_user.to_string()); + self.save_state()?; + self.status = format!("Switched to chat with {}", remote_user); + Ok(()) + } else { + Err(anyhow::anyhow!( + "No chat with {}. Use /chats to list available chats.", + remote_user + )) + } + } + + /// Delete a chat session. + pub fn delete_chat(&mut self, remote_user: &str) -> Result<()> { + if let Some(session) = self.state.sessions.remove(remote_user) { + // Also delete from the library's storage + if let Err(e) = self.manager.delete_chat(&session.chat_id) { + // Log but don't fail - the CLI state is already updated + self.status = format!("Warning: failed to delete crypto state: {}", e); + } + + // If we deleted the active chat, clear it + if self.state.active_chat.as_deref() == Some(remote_user) { + // Switch to another chat if available, otherwise clear + self.state.active_chat = self.state.sessions.keys().next().cloned(); + } + + self.save_state()?; + self.status = format!("Deleted chat with {}", remote_user); + Ok(()) + } else { + Err(anyhow::anyhow!( + "No chat with {}. Use /chats to list available chats.", + remote_user + )) + } + } + + /// Send a message in the current chat. + pub fn send_message(&mut self, content: &str) -> Result<()> { + let active = self + .state + .active_chat + .clone() + .ok_or_else(|| anyhow::anyhow!("No active chat. Use /connect or /switch first."))?; + + let session = self + .state + .sessions + .get(&active) + .ok_or_else(|| anyhow::anyhow!("Chat session not found"))?; + + let chat_id = session.chat_id.clone(); + let remote_user = session.remote_user.clone(); + + let envelopes = self.manager.send_message(&chat_id, content.as_bytes())?; + + for envelope in envelopes { + self.transport.send(&remote_user, envelope.data)?; + } + + // Update messages + if let Some(session) = self.state.sessions.get_mut(&active) { + session.messages.push(DisplayMessage { + from_self: true, + content: content.to_string(), + timestamp: now(), + }); + } + self.save_state()?; + + Ok(()) + } + + /// Process incoming messages from transport. + pub fn process_incoming(&mut self) -> Result<()> { + while let Some(envelope) = self.transport.try_recv() { + self.handle_incoming_envelope(&envelope)?; + } + Ok(()) + } + + /// Handle an incoming envelope. + fn handle_incoming_envelope( + &mut self, + envelope: &crate::transport::MessageEnvelope, + ) -> Result<()> { + match self.manager.handle_incoming(&envelope.data) { + Ok(content) => { + let from_user = &envelope.from; + let chat_id = content.conversation_id.clone(); + + // Find or create session for this user + if !self.state.sessions.contains_key(from_user) { + // New chat from someone + let session = ChatSession { + chat_id: chat_id.clone(), + remote_user: from_user.clone(), + messages: Vec::new(), + }; + self.state.sessions.insert(from_user.clone(), session); + self.state.active_chat = Some(from_user.clone()); + self.status = format!("New chat from {}!", from_user); + } + + let message = String::from_utf8_lossy(&content.data).to_string(); + if !message.is_empty() { + if let Some(session) = self.state.sessions.get_mut(from_user) { + session.messages.push(DisplayMessage { + from_self: false, + content: message, + timestamp: envelope.timestamp, + }); + } + } + + self.save_state()?; + } + Err(e) => { + self.status = format!("Error: {}", e); + } + } + Ok(()) + } + + /// Add a system message to the current chat (for display only). + fn add_system_message(&mut self, content: &str) { + let msg = DisplayMessage { + from_self: true, + content: content.to_string(), + timestamp: now(), + }; + + if let Some(active) = &self.state.active_chat.clone() { + if let Some(session) = self.state.sessions.get_mut(active) { + session.messages.push(msg); + return; + } + } + // No active chat - add to global messages + self.global_messages.push(msg); + } + + /// Handle a command (starts with /). + pub fn handle_command(&mut self, cmd: &str) -> Result> { + let parts: Vec<&str> = cmd.splitn(2, ' ').collect(); + let command = parts[0]; + let args = parts.get(1).copied().unwrap_or(""); + + match command { + "/help" => { + self.add_system_message("── Commands ──"); + self.add_system_message("/intro - Show your introduction bundle"); + self.add_system_message("/connect - Connect to a user"); + self.add_system_message("/chats - List all chats"); + self.add_system_message("/switch - Switch to chat with user"); + self.add_system_message("/delete - Delete chat with user"); + self.add_system_message("/peers - List transport peers"); + self.add_system_message("/status - Show connection status"); + self.add_system_message("/clear - Clear current chat messages"); + self.add_system_message("/quit or Esc or Ctrl+C - Exit"); + Ok(Some("Help displayed".to_string())) + } + "/intro" => { + let bundle = self.create_intro()?; + self.add_system_message("── Your Introduction Bundle ──"); + self.add_system_message(&bundle); + self.add_system_message("Share this bundle with others to connect!"); + Ok(Some("Bundle created".to_string())) + } + "/connect" => { + let connect_parts: Vec<&str> = args.splitn(2, ' ').collect(); + if connect_parts.len() < 2 { + return Ok(Some("Usage: /connect ".to_string())); + } + let remote_user = connect_parts[0]; + let bundle = connect_parts[1]; + self.connect(remote_user, bundle)?; + Ok(Some(format!("Connected to {}", remote_user))) + } + "/chats" => { + let sessions: Vec<_> = self.state.sessions.keys().cloned().collect(); + if sessions.is_empty() { + Ok(Some("No chats yet. Use /connect to start one.".to_string())) + } else { + self.add_system_message(&format!("── Your Chats ({}) ──", sessions.len())); + for name in &sessions { + let marker = if Some(name) == self.state.active_chat.as_ref() { + " (active)" + } else { + "" + }; + self.add_system_message(&format!(" β€’ {}{}", name, marker)); + } + Ok(Some(format!("{} chat(s)", sessions.len()))) + } + } + "/switch" => { + if args.is_empty() { + return Ok(Some("Usage: /switch ".to_string())); + } + self.switch_chat(args)?; + Ok(Some(format!("Switched to {}", args))) + } + "/delete" => { + if args.is_empty() { + return Ok(Some("Usage: /delete ".to_string())); + } + self.delete_chat(args)?; + Ok(Some(format!("Deleted chat with {}", args))) + } + "/peers" => { + let peers = self.transport.list_peers(); + if peers.is_empty() { + Ok(Some( + "No peers found. Start another chat-cli instance.".to_string(), + )) + } else { + self.add_system_message(&format!("── Peers ({}) ──", peers.len())); + for peer in &peers { + self.add_system_message(&format!(" β€’ {}", peer)); + } + Ok(Some(format!("{} peer(s)", peers.len()))) + } + } + "/status" => { + let chats = self.state.sessions.len(); + let active = self.state.active_chat.as_deref().unwrap_or("none"); + let status = format!( + "User: {}\nAddress: {}\nChats: {}\nActive: {}", + self.user_name, + self.manager.local_address(), + chats, + active + ); + Ok(Some(status)) + } + "/clear" => { + if let Some(active) = &self.state.active_chat { + if let Some(session) = self.state.sessions.get_mut(active) { + session.messages.clear(); + self.save_state()?; + } + } + Ok(Some("Messages cleared".to_string())) + } + "/quit" => Ok(None), + _ => Ok(Some(format!( + "Unknown command: {}. Type /help for commands.", + command + ))), + } + } +} + +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 +} diff --git a/chat-cli/src/main.rs b/chat-cli/src/main.rs new file mode 100644 index 0000000..88b85b4 --- /dev/null +++ b/chat-cli/src/main.rs @@ -0,0 +1,95 @@ +//! Chat CLI - A terminal chat application using logos-chat. +//! +//! This application demonstrates how to use the logos-chat library +//! with file-based transport for local communication. +//! +//! # Usage +//! +//! Run two instances with different usernames: +//! +//! ```bash +//! # Terminal 1 +//! cargo run -p chat-cli -- alice +//! +//! # Terminal 2 +//! cargo run -p chat-cli -- bob +//! ``` +//! +//! Then in alice's terminal: +//! 1. Type `/intro` to get your introduction bundle +//! 2. Copy the bundle string +//! +//! In bob's terminal: +//! 1. Type `/connect alice ` (paste alice's bundle) +//! +//! Now bob can send messages to alice, and alice can reply. + +mod app; +mod transport; +mod ui; + +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +/// Get the data directory (in project folder). +fn get_data_dir() -> PathBuf { + // Use the directory where the binary is or current working directory + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + PathBuf::from(manifest_dir) + .parent() + .unwrap_or(&PathBuf::from(".")) + .join("chat-cli-data") +} + +fn main() -> Result<()> { + // Parse arguments + let args: Vec = std::env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: {} ", args[0]); + eprintln!("\nExample:"); + eprintln!(" Terminal 1: {} alice", args[0]); + eprintln!(" Terminal 2: {} bob", args[0]); + std::process::exit(1); + } + + let user_name = &args[1]; + + // Setup data directory in project folder + let data_dir = get_data_dir(); + std::fs::create_dir_all(&data_dir).context("Failed to create data directory")?; + + println!("Starting chat as '{}'...", user_name); + println!("Data dir: {:?}", data_dir); + + // Create app + let mut app = app::ChatApp::new(user_name, &data_dir).context("Failed to create chat app")?; + + // Initialize terminal UI + let mut terminal = ui::init().context("Failed to initialize terminal")?; + + // Main loop + let result = run_app(&mut terminal, &mut app); + + // Restore terminal + ui::restore().context("Failed to restore terminal")?; + + result +} + +fn run_app(terminal: &mut ui::Tui, app: &mut app::ChatApp) -> Result<()> { + loop { + // Process incoming messages + app.process_incoming()?; + + // Draw UI + terminal.draw(|frame| ui::draw(frame, app))?; + + // Handle input + if !ui::handle_events(app)? { + break; + } + } + + Ok(()) +} diff --git a/chat-cli/src/transport.rs b/chat-cli/src/transport.rs new file mode 100644 index 0000000..09c102b --- /dev/null +++ b/chat-cli/src/transport.rs @@ -0,0 +1,146 @@ +//! File-based transport for local chat communication. +//! +//! Messages are passed between users via files in a shared directory. + +use std::collections::HashSet; +use std::fs; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// A message envelope for transport. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageEnvelope { + pub from: String, + pub data: Vec, + pub timestamp: u64, +} + +/// File-based transport for local communication. +pub struct FileTransport { + /// Our user name. + user_name: String, + /// Base directory for transport files. + base_dir: PathBuf, + /// Our inbox directory. + inbox_dir: PathBuf, + /// Set of processed message files (to avoid reprocessing). + processed: HashSet, +} + +impl FileTransport { + /// Create a new file transport. + pub fn new(user_name: &str, data_dir: &PathBuf) -> Result { + let base_dir = data_dir.join("transport"); + let inbox_dir = base_dir.join(user_name); + + // Create our inbox directory + fs::create_dir_all(&inbox_dir) + .context("Failed to create inbox directory")?; + + Ok(Self { + user_name: user_name.to_string(), + base_dir, + inbox_dir, + processed: HashSet::new(), + }) + } + + /// Send a message to a specific user. + pub fn send(&self, to_user: &str, data: Vec) -> Result<()> { + let target_dir = self.base_dir.join(to_user); + + // Create target inbox if it doesn't exist + fs::create_dir_all(&target_dir) + .context("Failed to create target inbox")?; + + let envelope = MessageEnvelope { + from: self.user_name.clone(), + data, + timestamp: now(), + }; + + // Write message to a unique file + let filename = format!("{}_{}.json", self.user_name, now()); + let filepath = target_dir.join(&filename); + + let json = serde_json::to_string_pretty(&envelope)?; + fs::write(&filepath, json) + .context("Failed to write message file")?; + + Ok(()) + } + + /// Try to receive an incoming message (non-blocking). + pub fn try_recv(&mut self) -> Option { + // List files in our inbox + let entries = match fs::read_dir(&self.inbox_dir) { + Ok(e) => e, + Err(_) => return None, + }; + + for entry in entries.flatten() { + let path = entry.path(); + + // Skip non-json files + if path.extension().map(|e| e != "json").unwrap_or(true) { + continue; + } + + let filename = path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + + // Skip already processed files + if self.processed.contains(&filename) { + continue; + } + + // Try to read and parse the message + if let Ok(contents) = fs::read_to_string(&path) { + if let Ok(envelope) = serde_json::from_str::(&contents) { + // Mark as processed and delete + self.processed.insert(filename); + let _ = fs::remove_file(&path); + return Some(envelope); + } + } + } + + None + } + + /// List available peers (users with inbox directories). + pub fn list_peers(&self) -> Vec { + let mut peers = Vec::new(); + + if let Ok(entries) = fs::read_dir(&self.base_dir) { + for entry in entries.flatten() { + if entry.path().is_dir() { + if let Some(name) = entry.file_name().to_str() { + if name != self.user_name { + peers.push(name.to_string()); + } + } + } + } + } + + peers + } + + /// Get our user name. + #[allow(dead_code)] + pub fn user_name(&self) -> &str { + &self.user_name + } +} + +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 +} diff --git a/chat-cli/src/ui.rs b/chat-cli/src/ui.rs new file mode 100644 index 0000000..e827e22 --- /dev/null +++ b/chat-cli/src/ui.rs @@ -0,0 +1,181 @@ +//! Terminal UI using ratatui. + +use std::io::{self, Stdout}; + +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, + Frame, Terminal, +}; + +use crate::app::ChatApp; + +pub type Tui = Terminal>; + +/// Initialize the terminal. +pub fn init() -> io::Result { + execute!(io::stdout(), EnterAlternateScreen)?; + enable_raw_mode()?; + let backend = CrosstermBackend::new(io::stdout()); + Terminal::new(backend) +} + +/// Restore the terminal to its original state. +pub fn restore() -> io::Result<()> { + disable_raw_mode()?; + execute!(io::stdout(), LeaveAlternateScreen)?; + Ok(()) +} + +/// Draw the UI. +pub fn draw(frame: &mut Frame, app: &ChatApp) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(10), // Messages + Constraint::Length(3), // Input + Constraint::Length(3), // Status + ]) + .split(frame.area()); + + draw_header(frame, app, chunks[0]); + draw_messages(frame, app, chunks[1]); + draw_input(frame, app, chunks[2]); + draw_status(frame, app, chunks[3]); +} + +fn draw_header(frame: &mut Frame, app: &ChatApp, area: Rect) { + let title = match app.current_session() { + Some(session) => format!(" πŸ’¬ Chat: {} ↔ {} ", app.user_name, session.remote_user), + None => format!(" πŸ’¬ {} (no active chat - use /connect or /chats) ", app.user_name), + }; + + let header = Paragraph::new(title) + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .block(Block::default().borders(Borders::ALL)); + + frame.render_widget(header, area); +} + +fn draw_messages(frame: &mut Frame, app: &ChatApp, area: Rect) { + let remote_name = app + .current_session() + .map(|s| s.remote_user.as_str()) + .unwrap_or("Them"); + + let messages: Vec = app + .messages() + .iter() + .map(|msg| { + let (prefix, style) = if msg.from_self { + ("You", Style::default().fg(Color::Green)) + } else { + (remote_name, Style::default().fg(Color::Yellow)) + }; + + let content = Line::from(vec![ + Span::styled(format!("{}: ", prefix), style.add_modifier(Modifier::BOLD)), + Span::raw(&msg.content), + ]); + + ListItem::new(content) + }) + .collect(); + + let title = match app.current_session() { + Some(session) => format!(" Messages with {} ", session.remote_user), + None => " Messages ".to_string(), + }; + + let messages_widget = List::new(messages) + .block(Block::default().title(title).borders(Borders::ALL)); + + frame.render_widget(messages_widget, area); +} + +fn draw_input(frame: &mut Frame, app: &ChatApp, area: Rect) { + let input = Paragraph::new(app.input.as_str()) + .style(Style::default().fg(Color::White)) + .block(Block::default().title(" Input (Enter to send) ").borders(Borders::ALL)); + + frame.render_widget(input, area); + + // Show cursor + frame.set_cursor_position(( + area.x + app.input.len() as u16 + 1, + area.y + 1, + )); +} + +fn draw_status(frame: &mut Frame, app: &ChatApp, area: Rect) { + let status = Paragraph::new(app.status.as_str()) + .style(Style::default().fg(Color::Gray)) + .block(Block::default().title(" Status ").borders(Borders::ALL)) + .wrap(Wrap { trim: true }); + + frame.render_widget(status, area); +} + +/// Handle keyboard events. +pub fn handle_events(app: &mut ChatApp) -> io::Result { + // Poll for events with a short timeout to allow checking incoming messages + if event::poll(std::time::Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + if key.kind != KeyEventKind::Press { + return Ok(true); + } + + match key.code { + KeyCode::Esc => return Ok(false), + // Handle Ctrl+C + KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { + return Ok(false); + } + KeyCode::Enter => { + if !app.input.is_empty() { + let input = std::mem::take(&mut app.input); + + if input.starts_with('/') { + match app.handle_command(&input) { + Ok(Some(response)) => { + app.status = response; + } + Ok(None) => { + // Quit signal + return Ok(false); + } + Err(e) => { + app.status = format!("Error: {}", e); + } + } + } else if app.current_session().is_some() { + if let Err(e) = app.send_message(&input) { + app.status = format!("Send error: {}", e); + } + } else { + app.status = "No active chat. Use /connect first.".to_string(); + } + } + } + KeyCode::Char(c) => { + app.input.push(c); + } + KeyCode::Backspace => { + app.input.pop(); + } + _ => {} + } + } + } + + Ok(true) +}