From 6f1dbde7cc09c8487b1687f3414eb8274df8b0b2 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 29 Mar 2026 19:00:28 +0400 Subject: [PATCH] v0.0.42: markdown rendering in TUI messages - **bold**, *italic*, \`code\` rendered with ratatui styles - # headers, > blockquotes, - bullet lists - Multi-line messages split and indented per line - Code spans: cyan bold, headers: white bold, quotes: gray italic - No external dependency (custom md_to_spans parser) - tui-markdown had ratatui version mismatch, built our own Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 10 +- warzone/Cargo.toml | 2 +- warzone/crates/warzone-client/src/tui/draw.rs | 141 ++++++++++++------ warzone/crates/warzone-protocol/Cargo.toml | 2 +- .../crates/warzone-server/src/routes/web.rs | 4 +- 5 files changed, 105 insertions(+), 54 deletions(-) diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index bd0da75..24b5790 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2956,7 +2956,7 @@ dependencies = [ [[package]] name = "warzone-client" -version = "0.0.40" +version = "0.0.42" dependencies = [ "anyhow", "argon2", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "warzone-mule" -version = "0.0.40" +version = "0.0.42" dependencies = [ "anyhow", "clap", @@ -2998,7 +2998,7 @@ dependencies = [ [[package]] name = "warzone-protocol" -version = "0.0.40" +version = "0.0.42" dependencies = [ "base64", "bincode", @@ -3023,7 +3023,7 @@ dependencies = [ [[package]] name = "warzone-server" -version = "0.0.40" +version = "0.0.42" dependencies = [ "anyhow", "axum", @@ -3054,7 +3054,7 @@ dependencies = [ [[package]] name = "warzone-wasm" -version = "0.0.40" +version = "0.0.42" dependencies = [ "base64", "bincode", diff --git a/warzone/Cargo.toml b/warzone/Cargo.toml index f65ae05..74d5923 100644 --- a/warzone/Cargo.toml +++ b/warzone/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.0.41" +version = "0.0.42" edition = "2021" license = "MIT" rust-version = "1.75" diff --git a/warzone/crates/warzone-client/src/tui/draw.rs b/warzone/crates/warzone-client/src/tui/draw.rs index ff67860..a22eaf8 100644 --- a/warzone/crates/warzone-client/src/tui/draw.rs +++ b/warzone/crates/warzone-client/src/tui/draw.rs @@ -10,6 +10,60 @@ use chrono::Local; use super::types::{App, ReceiptStatus}; +/// Simple markdown-to-spans converter for TUI messages. +/// Handles: **bold**, *italic*, `code`, ```code blocks```. +fn md_to_spans<'a>(text: &'a str, base_style: Style) -> Vec> { + let mut spans = Vec::new(); + let mut remaining = text; + + while !remaining.is_empty() { + // Code: `...` + if remaining.starts_with('`') && !remaining.starts_with("```") { + if let Some(end) = remaining[1..].find('`') { + spans.push(Span::styled( + &remaining[1..1 + end], + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + )); + remaining = &remaining[2 + end..]; + continue; + } + } + // Bold: **...** + if remaining.starts_with("**") { + if let Some(end) = remaining[2..].find("**") { + spans.push(Span::styled( + &remaining[2..2 + end], + base_style.add_modifier(Modifier::BOLD), + )); + remaining = &remaining[4 + end..]; + continue; + } + } + // Italic: *...* + if remaining.starts_with('*') && !remaining.starts_with("**") { + if let Some(end) = remaining[1..].find('*') { + spans.push(Span::styled( + &remaining[1..1 + end], + base_style.add_modifier(Modifier::ITALIC), + )); + remaining = &remaining[2 + end..]; + continue; + } + } + // Plain text until next special char + let next = remaining.find(|c: char| c == '*' || c == '`').unwrap_or(remaining.len()); + if next > 0 { + spans.push(Span::styled(&remaining[..next], base_style)); + remaining = &remaining[next..]; + } else { + // Stuck on a special char that didn't match a pattern — emit it + spans.push(Span::styled(&remaining[..1], base_style)); + remaining = &remaining[1..]; + } + } + spans +} + impl App { fn receipt_indicator(&self, message_id: &Option) -> &'static str { match message_id { @@ -103,12 +157,12 @@ impl App { ])); frame.render_widget(header, chunks[0]); - // Messages + // Messages — render markdown for message bodies via tui-markdown let msgs = self.messages.lock().unwrap(); let items: Vec = msgs .iter() .flat_map(|m| { - let style = if m.is_system { + let base_style = if m.is_system { Style::default().fg(Color::Cyan) } else if m.is_self { Style::default().fg(Color::Green) @@ -130,54 +184,51 @@ impl App { }; let receipt_color = self.receipt_color(&m.message_id); - // Calculate available width for text - let ts_len = timestamp.len(); - let prefix_len = prefix.len(); - let receipt_len = receipt_str.len(); - let available = (chunks[1].width as usize).saturating_sub(ts_len + prefix_len + receipt_len + 2); + // Split text into lines, render markdown per line + let text_lines: Vec<&str> = m.text.split('\n').collect(); + let mut result_items = Vec::new(); - if available == 0 || m.text.len() <= available { - // Single line + for (i, line_text) in text_lines.iter().enumerate() { + let mut spans = Vec::new(); + if i == 0 { + spans.push(Span::styled(timestamp.clone(), Style::default().fg(Color::DarkGray))); + spans.push(Span::styled(prefix.clone(), base_style.add_modifier(Modifier::BOLD))); + } else { + let indent = " ".repeat(timestamp.len() + prefix.len()); + spans.push(Span::raw(indent)); + } + + // Check for code block lines (```) + if line_text.starts_with("```") { + spans.push(Span::styled(*line_text, Style::default().fg(Color::DarkGray))); + } else if line_text.starts_with("# ") { + spans.push(Span::styled(&line_text[2..], Style::default().fg(Color::White).add_modifier(Modifier::BOLD))); + } else if line_text.starts_with("## ") { + spans.push(Span::styled(&line_text[3..], Style::default().fg(Color::White).add_modifier(Modifier::BOLD))); + } else if line_text.starts_with("> ") { + spans.push(Span::styled("│ ", Style::default().fg(Color::DarkGray))); + spans.push(Span::styled(&line_text[2..], Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC))); + } else if line_text.starts_with("- ") || line_text.starts_with("* ") { + spans.push(Span::styled("• ", base_style)); + spans.extend(md_to_spans(&line_text[2..], base_style)); + } else { + spans.extend(md_to_spans(line_text, base_style)); + } + + // Receipt on last line + if i == text_lines.len() - 1 { + spans.push(Span::styled(receipt_str, Style::default().fg(receipt_color))); + } + result_items.push(ListItem::new(Line::from(spans))); + } + + if result_items.is_empty() { vec![ListItem::new(Line::from(vec![ Span::styled(timestamp, Style::default().fg(Color::DarkGray)), - Span::styled(prefix, style.add_modifier(Modifier::BOLD)), - Span::raw(&m.text), - Span::styled(receipt_str, Style::default().fg(receipt_color)), + Span::styled(prefix, base_style.add_modifier(Modifier::BOLD)), ]))] } else { - // Wrap into multiple lines - let mut lines = Vec::new(); - let mut remaining = m.text.as_str(); - let mut first = true; - while !remaining.is_empty() { - let (chunk, rest) = if remaining.len() > available { - // Try to break at word boundary - let break_at = remaining[..available].rfind(' ').unwrap_or(available); - (&remaining[..break_at], remaining[break_at..].trim_start()) - } else { - (remaining, "") - }; - - if first { - lines.push(ListItem::new(Line::from(vec![ - Span::styled(timestamp.clone(), Style::default().fg(Color::DarkGray)), - Span::styled(prefix.clone(), style.add_modifier(Modifier::BOLD)), - Span::raw(chunk), - if rest.is_empty() { Span::styled(receipt_str, Style::default().fg(receipt_color)) } else { Span::raw("") }, - ]))); - first = false; - } else { - // Continuation lines: indent to align with text - let indent = " ".repeat(ts_len + prefix_len); - lines.push(ListItem::new(Line::from(vec![ - Span::raw(indent), - Span::styled(chunk, style), - if rest.is_empty() { Span::styled(receipt_str, Style::default().fg(receipt_color)) } else { Span::raw("") }, - ]))); - } - remaining = rest; - } - lines + result_items } }) .collect(); diff --git a/warzone/crates/warzone-protocol/Cargo.toml b/warzone/crates/warzone-protocol/Cargo.toml index a199453..9839e36 100644 --- a/warzone/crates/warzone-protocol/Cargo.toml +++ b/warzone/crates/warzone-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "warzone-protocol" -version = "0.0.41" +version = "0.0.42" edition = "2021" license = "MIT" description = "Core crypto & wire protocol for featherChat (Warzone messenger)" diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 18bb689..cbc3f8a 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v23'; +const CACHE = 'wz-v24'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { @@ -287,7 +287,7 @@ let pollTimer = null; let ws = null; // WebSocket connection let wasmReady = false; -const VERSION = '0.0.41'; +const VERSION = '0.0.42'; let DEBUG = true; // toggle with /debug command // ── Receipt tracking ──