TUI chat interface with real-time E2E encrypted messaging

`warzone chat [peer-fp] -s <server>` launches an interactive terminal UI:
- Header: your fingerprint, peer fingerprint, server URL
- Message area: color-coded (green=you, yellow=peer, cyan=system)
- Input bar with cursor at bottom
- Background polling every 2s for incoming messages
- Full X3DH + Double Ratchet on send/receive
- Session persistence across messages

Commands in TUI:
- /peer <fingerprint> — set who you're chatting with
- /info — show your fingerprint
- /quit or /q or Esc or Ctrl+C — exit

Usage:
  warzone chat "6baf:6d0b:4541:9cae:f06b:83da:69bc:05ee" -s http://localhost:7700

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-26 22:59:08 +04:00
parent 6d4a09a0c6
commit a298c9430c
3 changed files with 510 additions and 6 deletions

View File

@@ -49,6 +49,8 @@ enum Commands {
},
/// Launch interactive TUI chat
Chat {
/// Peer fingerprint to chat with (optional, can set with /peer in TUI)
peer: Option<String>,
/// Server URL
#[arg(short, long, default_value = "http://localhost:7700")]
server: String,
@@ -80,8 +82,16 @@ async fn main() -> anyhow::Result<()> {
Commands::Recv { server } => {
cli::recv::run(&server).await?;
}
Commands::Chat { server } => {
println!("TODO: launch TUI connected to {}", server);
Commands::Chat { peer, server } => {
// Auto-register
let _ = cli::init::register_with_server(&server).await;
let seed = keystore::load_seed()?;
let identity = seed.derive_identity();
let our_fp = identity.public_identity().fingerprint.to_string();
let db = storage::LocalDb::open()?;
tui::run_tui(our_fp, peer, server, identity, db).await?;
}
}

View File

@@ -1,2 +1,496 @@
// TUI App struct and event loop.
// TODO: implement in Phase 1 step 10.
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{Context, Result};
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
use ratatui::Frame;
use warzone_protocol::identity::IdentityKeyPair;
use warzone_protocol::ratchet::RatchetState;
use warzone_protocol::types::Fingerprint;
use warzone_protocol::x3dh;
use x25519_dalek::PublicKey;
use crate::cli::send::WireMessage;
use crate::net::ServerClient;
use crate::storage::LocalDb;
pub struct App {
pub input: String,
pub messages: Arc<Mutex<Vec<ChatLine>>>,
pub our_fp: String,
pub peer_fp: Option<String>,
pub server_url: String,
pub should_quit: bool,
}
#[derive(Clone)]
pub struct ChatLine {
pub sender: String,
pub text: String,
pub is_system: bool,
pub is_self: bool,
}
impl App {
pub fn new(our_fp: String, peer_fp: Option<String>, server_url: String) -> Self {
let messages = Arc::new(Mutex::new(vec![ChatLine {
sender: "system".into(),
text: format!("You are {}", our_fp),
is_system: true,
is_self: false,
}]));
if let Some(ref peer) = peer_fp {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!("Chatting with {}", peer),
is_system: true,
is_self: false,
});
} else {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: "No peer set. Use /peer <fingerprint> to start chatting".into(),
is_system: true,
is_self: false,
});
}
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: "Commands: /peer <fp>, /info, /quit".into(),
is_system: true,
is_self: false,
});
App {
input: String::new(),
messages,
our_fp,
peer_fp,
server_url,
should_quit: false,
}
}
pub fn add_message(&self, line: ChatLine) {
self.messages.lock().unwrap().push(line);
}
pub fn draw(&self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // header
Constraint::Min(5), // messages
Constraint::Length(3), // input
])
.split(frame.area());
// Header
let peer_str = self
.peer_fp
.as_deref()
.unwrap_or("no peer");
let header = Paragraph::new(Line::from(vec![
Span::styled("WZ ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::styled(&self.our_fp, Style::default().fg(Color::Green)),
Span::raw(""),
Span::styled(peer_str, Style::default().fg(Color::Yellow)),
Span::styled(
format!(" [{}]", self.server_url),
Style::default().fg(Color::DarkGray),
),
]));
frame.render_widget(header, chunks[0]);
// Messages
let msgs = self.messages.lock().unwrap();
let items: Vec<ListItem> = msgs
.iter()
.map(|m| {
let style = if m.is_system {
Style::default().fg(Color::Cyan)
} else if m.is_self {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Yellow)
};
let prefix = if m.is_system {
"*** ".to_string()
} else {
format!("{}: ", &m.sender[..m.sender.len().min(12)])
};
ListItem::new(Line::from(vec![
Span::styled(prefix, style.add_modifier(Modifier::BOLD)),
Span::raw(&m.text),
]))
})
.collect();
let messages_widget = List::new(items)
.block(Block::default().borders(Borders::TOP));
frame.render_widget(messages_widget, chunks[1]);
// Input
let input_widget = Paragraph::new(self.input.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(" message "),
)
.wrap(Wrap { trim: false });
frame.render_widget(input_widget, chunks[2]);
// Cursor
let x = (self.input.len() as u16 + 1).min(chunks[2].width - 2);
frame.set_cursor_position((chunks[2].x + x, chunks[2].y + 1));
}
pub async fn handle_send(
&mut self,
identity: &IdentityKeyPair,
db: &LocalDb,
client: &ServerClient,
) {
let text = self.input.trim().to_string();
self.input.clear();
if text.is_empty() {
return;
}
// Commands
if text == "/quit" || text == "/q" {
self.should_quit = true;
return;
}
if text == "/info" {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Your fingerprint: {}", self.our_fp),
is_system: true,
is_self: false,
});
return;
}
if text.starts_with("/peer ") {
let fp = text[6..].trim().to_string();
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Peer set to {}", fp),
is_system: true,
is_self: false,
});
self.peer_fp = Some(fp);
return;
}
// Send message
let peer = match &self.peer_fp {
Some(p) => p.clone(),
None => {
self.add_message(ChatLine {
sender: "system".into(),
text: "No peer set. Use /peer <fingerprint>".into(),
is_system: true,
is_self: false,
});
return;
}
};
let peer_fp = match Fingerprint::from_hex(&peer) {
Ok(fp) => fp,
Err(_) => {
self.add_message(ChatLine {
sender: "system".into(),
text: "Invalid peer fingerprint".into(),
is_system: true,
is_self: false,
});
return;
}
};
let our_pub = identity.public_identity();
let mut ratchet = db.load_session(&peer_fp).ok().flatten();
let wire_msg = if let Some(ref mut state) = ratchet {
match state.encrypt(text.as_bytes()) {
Ok(encrypted) => {
let _ = db.save_session(&peer_fp, state);
WireMessage::Message {
sender_fingerprint: our_pub.fingerprint.to_string(),
ratchet_message: encrypted,
}
}
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Encrypt failed: {}", e),
is_system: true,
is_self: false,
});
return;
}
}
} else {
// X3DH
let bundle = match client.fetch_bundle(&peer).await {
Ok(b) => b,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Failed to fetch bundle: {}", e),
is_system: true,
is_self: false,
});
return;
}
};
let x3dh_result = match x3dh::initiate(identity, &bundle) {
Ok(r) => r,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("X3DH failed: {}", e),
is_system: true,
is_self: false,
});
return;
}
};
let their_spk = PublicKey::from(bundle.signed_pre_key.public_key);
let mut state = RatchetState::init_alice(x3dh_result.shared_secret, their_spk);
match state.encrypt(text.as_bytes()) {
Ok(encrypted) => {
let _ = db.save_session(&peer_fp, &state);
WireMessage::KeyExchange {
sender_fingerprint: our_pub.fingerprint.to_string(),
sender_identity_encryption_key: *our_pub.encryption.as_bytes(),
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id,
ratchet_message: encrypted,
}
}
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Encrypt failed: {}", e),
is_system: true,
is_self: false,
});
return;
}
}
};
let encoded = match bincode::serialize(&wire_msg) {
Ok(e) => e,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Serialize failed: {}", e),
is_system: true,
is_self: false,
});
return;
}
};
match client.send_message(&peer, &encoded).await {
Ok(_) => {
self.add_message(ChatLine {
sender: self.our_fp[..12].to_string(),
text: text.clone(),
is_system: false,
is_self: true,
});
}
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Send failed: {}", e),
is_system: true,
is_self: false,
});
}
}
}
}
/// Poll for incoming messages in the background.
pub async fn poll_loop(
messages: Arc<Mutex<Vec<ChatLine>>>,
our_fp: String,
identity: IdentityKeyPair,
db: Arc<LocalDb>,
client: ServerClient,
) {
loop {
tokio::time::sleep(Duration::from_secs(2)).await;
let raw_msgs = match client.poll_messages(&our_fp).await {
Ok(m) => m,
Err(_) => continue,
};
for raw in &raw_msgs {
match bincode::deserialize::<WireMessage>(raw) {
Ok(WireMessage::KeyExchange {
sender_fingerprint,
sender_identity_encryption_key,
ephemeral_public,
used_one_time_pre_key_id,
ratchet_message,
}) => {
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
Ok(fp) => fp,
Err(_) => continue,
};
let spk_secret = match db.load_signed_pre_key(1) {
Ok(Some(s)) => s,
_ => continue,
};
let otpk_secret = if let Some(id) = used_one_time_pre_key_id {
db.take_one_time_pre_key(id).ok().flatten()
} else {
None
};
let their_id_x25519 = PublicKey::from(sender_identity_encryption_key);
let their_eph = PublicKey::from(ephemeral_public);
let shared_secret = match x3dh::respond(
&identity,
&spk_secret,
otpk_secret.as_ref(),
&their_id_x25519,
&their_eph,
) {
Ok(s) => s,
Err(_) => continue,
};
let mut state = RatchetState::init_bob(shared_secret, spk_secret);
match state.decrypt(&ratchet_message) {
Ok(plaintext) => {
let text = String::from_utf8_lossy(&plaintext).to_string();
let _ = db.save_session(&sender_fp, &state);
messages.lock().unwrap().push(ChatLine {
sender: sender_fingerprint[..12].to_string(),
text,
is_system: false,
is_self: false,
});
}
Err(_) => continue,
}
}
Ok(WireMessage::Message {
sender_fingerprint,
ratchet_message,
}) => {
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
Ok(fp) => fp,
Err(_) => continue,
};
let mut state = match db.load_session(&sender_fp) {
Ok(Some(s)) => s,
_ => continue,
};
match state.decrypt(&ratchet_message) {
Ok(plaintext) => {
let text = String::from_utf8_lossy(&plaintext).to_string();
let _ = db.save_session(&sender_fp, &state);
messages.lock().unwrap().push(ChatLine {
sender: sender_fingerprint[..12].to_string(),
text,
is_system: false,
is_self: false,
});
}
Err(_) => continue,
}
}
Err(_) => continue,
}
}
}
}
/// Run the TUI event loop.
pub async fn run_tui(
our_fp: String,
peer_fp: Option<String>,
server_url: String,
identity: IdentityKeyPair,
db: LocalDb,
) -> Result<()> {
let mut terminal = ratatui::init();
let client = ServerClient::new(&server_url);
let db = Arc::new(db);
let mut app = App::new(our_fp.clone(), peer_fp, server_url);
// Spawn background poll task
// We need a second IdentityKeyPair for the poll loop — re-derive from seed
let poll_identity = crate::keystore::load_seed()?.derive_identity();
let poll_messages = app.messages.clone();
let poll_client = client.clone();
let poll_db = db.clone();
let poll_fp = our_fp.clone();
tokio::spawn(async move {
poll_loop(poll_messages, poll_fp, poll_identity, poll_db, poll_client).await;
});
loop {
terminal.draw(|frame| app.draw(frame))?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Enter => {
app.handle_send(&identity, &db, &client).await;
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.should_quit = true;
}
KeyCode::Backspace => {
app.input.pop();
}
KeyCode::Char(c) => {
app.input.push(c);
}
KeyCode::Esc => {
app.should_quit = true;
}
_ => {}
}
}
}
if app.should_quit {
break;
}
}
ratatui::restore();
Ok(())
}

View File

@@ -1,3 +1,3 @@
// TUI scaffold — ratatui app.
// TODO: implement in Phase 1 step 10.
pub mod app;
pub use app::run_tui;