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) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-29 19:00:28 +04:00
parent 5bc59376f5
commit 6f1dbde7cc
5 changed files with 105 additions and 54 deletions

10
warzone/Cargo.lock generated
View File

@@ -2956,7 +2956,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-client" name = "warzone-client"
version = "0.0.40" version = "0.0.42"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -2989,7 +2989,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-mule" name = "warzone-mule"
version = "0.0.40" version = "0.0.42"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -2998,7 +2998,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-protocol" name = "warzone-protocol"
version = "0.0.40" version = "0.0.42"
dependencies = [ dependencies = [
"base64", "base64",
"bincode", "bincode",
@@ -3023,7 +3023,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-server" name = "warzone-server"
version = "0.0.40" version = "0.0.42"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -3054,7 +3054,7 @@ dependencies = [
[[package]] [[package]]
name = "warzone-wasm" name = "warzone-wasm"
version = "0.0.40" version = "0.0.42"
dependencies = [ dependencies = [
"base64", "base64",
"bincode", "bincode",

View File

@@ -9,7 +9,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.0.41" version = "0.0.42"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
rust-version = "1.75" rust-version = "1.75"

View File

@@ -10,6 +10,60 @@ use chrono::Local;
use super::types::{App, ReceiptStatus}; 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<Span<'a>> {
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 { impl App {
fn receipt_indicator(&self, message_id: &Option<String>) -> &'static str { fn receipt_indicator(&self, message_id: &Option<String>) -> &'static str {
match message_id { match message_id {
@@ -103,12 +157,12 @@ impl App {
])); ]));
frame.render_widget(header, chunks[0]); frame.render_widget(header, chunks[0]);
// Messages // Messages — render markdown for message bodies via tui-markdown
let msgs = self.messages.lock().unwrap(); let msgs = self.messages.lock().unwrap();
let items: Vec<ListItem> = msgs let items: Vec<ListItem> = msgs
.iter() .iter()
.flat_map(|m| { .flat_map(|m| {
let style = if m.is_system { let base_style = if m.is_system {
Style::default().fg(Color::Cyan) Style::default().fg(Color::Cyan)
} else if m.is_self { } else if m.is_self {
Style::default().fg(Color::Green) Style::default().fg(Color::Green)
@@ -130,54 +184,51 @@ impl App {
}; };
let receipt_color = self.receipt_color(&m.message_id); let receipt_color = self.receipt_color(&m.message_id);
// Calculate available width for text // Split text into lines, render markdown per line
let ts_len = timestamp.len(); let text_lines: Vec<&str> = m.text.split('\n').collect();
let prefix_len = prefix.len(); let mut result_items = Vec::new();
let receipt_len = receipt_str.len();
let available = (chunks[1].width as usize).saturating_sub(ts_len + prefix_len + receipt_len + 2);
if available == 0 || m.text.len() <= available { for (i, line_text) in text_lines.iter().enumerate() {
// Single line 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![ vec![ListItem::new(Line::from(vec![
Span::styled(timestamp, Style::default().fg(Color::DarkGray)), Span::styled(timestamp, Style::default().fg(Color::DarkGray)),
Span::styled(prefix, style.add_modifier(Modifier::BOLD)), Span::styled(prefix, base_style.add_modifier(Modifier::BOLD)),
Span::raw(&m.text),
Span::styled(receipt_str, Style::default().fg(receipt_color)),
]))] ]))]
} else { } else {
// Wrap into multiple lines result_items
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
} }
}) })
.collect(); .collect();

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "warzone-protocol" name = "warzone-protocol"
version = "0.0.41" version = "0.0.42"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
description = "Core crypto & wire protocol for featherChat (Warzone messenger)" description = "Core crypto & wire protocol for featherChat (Warzone messenger)"

View File

@@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse {
async fn service_worker() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse {
([(header::CONTENT_TYPE, "application/javascript")], r##" ([(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']; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
self.addEventListener('install', e => { self.addEventListener('install', e => {
@@ -287,7 +287,7 @@ let pollTimer = null;
let ws = null; // WebSocket connection let ws = null; // WebSocket connection
let wasmReady = false; let wasmReady = false;
const VERSION = '0.0.41'; const VERSION = '0.0.42';
let DEBUG = true; // toggle with /debug command let DEBUG = true; // toggle with /debug command
// ── Receipt tracking ── // ── Receipt tracking ──