feat: chat cli demo app via file transport

This commit is contained in:
kaichaosun 2026-04-14 17:07:24 +08:00
parent 3b2f3df321
commit 16a280634b
No known key found for this signature in database
GPG Key ID: 223E0F992F4F03BF
6 changed files with 1020 additions and 0 deletions

16
chat-cli/Cargo.toml Normal file
View File

@ -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"

128
chat-cli/README.md Normal file
View File

@ -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 <bundle>` (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 <user> <bundle>` | Connect to a user using their introduction bundle |
| `/chats` | List all your established chats |
| `/switch <user>` | Switch to a different chat |
| `/delete <user>` | 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/<username>/`
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 |
|------|---------|
| `<username>.db` | SQLite database for identity keys, inbox keys, chat metadata, and Double Ratchet state |
| `<username>_state.json` | CLI state: username↔chat mappings, message history, active chat |
| `transport/<username>/` | 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

454
chat-cli/src/app.rs Normal file
View File

@ -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<DisplayMessage>,
}
/// App state that gets persisted.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct AppState {
/// Map from remote username to chat session.
pub sessions: HashMap<String, ChatSession>,
/// Currently active chat (remote username).
pub active_chat: Option<String>,
}
/// 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<Introduction>,
/// Persisted app state.
pub state: AppState,
/// Global messages (shown when no active chat).
pub global_messages: Vec<DisplayMessage>,
/// 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<Self> {
// 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<String> {
let intro = self.manager.create_intro_bundle()?;
let bundle_str: Vec<u8> = 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<Option<String>> {
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 <user> <bundle> - Connect to a user");
self.add_system_message("/chats - List all chats");
self.add_system_message("/switch <user> - Switch to chat with user");
self.add_system_message("/delete <user> - 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 <username> <bundle>".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 <username>".to_string()));
}
self.switch_chat(args)?;
Ok(Some(format!("Switched to {}", args)))
}
"/delete" => {
if args.is_empty() {
return Ok(Some("Usage: /delete <username>".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
}

95
chat-cli/src/main.rs Normal file
View File

@ -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 <bundle>` (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<String> = std::env::args().collect();
if args.len() < 2 {
eprintln!("Usage: {} <username>", 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(())
}

146
chat-cli/src/transport.rs Normal file
View File

@ -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<u8>,
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<String>,
}
impl FileTransport {
/// Create a new file transport.
pub fn new(user_name: &str, data_dir: &PathBuf) -> Result<Self> {
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<u8>) -> 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<MessageEnvelope> {
// 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::<MessageEnvelope>(&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<String> {
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
}

181
chat-cli/src/ui.rs Normal file
View File

@ -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<CrosstermBackend<Stdout>>;
/// Initialize the terminal.
pub fn init() -> io::Result<Tui> {
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<ListItem> = 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<bool> {
// 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)
}