diff --git a/server/src/chat/commands/mod.rs b/server/src/chat/commands/mod.rs index 92ade181..66786d0d 100644 --- a/server/src/chat/commands/mod.rs +++ b/server/src/chat/commands/mod.rs @@ -180,6 +180,54 @@ pub fn commands() -> &'static [BotCommand] { ] } +/// Like [`try_handle_command`] but returns `(plain_body, html_body)`. +/// +/// The plain body is unchanged Markdown text suitable for the Matrix `body` +/// field (non-HTML clients). The HTML body is suitable for `formatted_body`. +/// +/// The pipeline-status command (no args) injects Matrix `` +/// tags on the traffic-light dots. All other commands produce HTML by running +/// the plain body through pulldown-cmark. +pub fn try_handle_command_with_html( + dispatch: &CommandDispatch<'_>, + message: &str, +) -> Option<(String, String)> { + let command_text = strip_bot_mention(message, dispatch.bot_name, dispatch.bot_user_id); + let trimmed = command_text.trim(); + if !trimmed.is_empty() { + let (cmd_name, args) = match trimmed.split_once(char::is_whitespace) { + Some((c, a)) => (c, a.trim()), + None => (trimmed, ""), + }; + // Only the no-arg status variant shows the pipeline with traffic-light + // dots; `status ` is a triage dump that needs no colour tags. + if cmd_name.eq_ignore_ascii_case("status") && args.is_empty() { + let body = status::build_pipeline_status(dispatch.project_root, dispatch.agents); + let html = status::build_pipeline_status_html(dispatch.project_root, dispatch.agents); + return Some((body, html)); + } + } + // Generic path: plain text body → Markdown-to-HTML. + let body = try_handle_command(dispatch, message)?; + let html = plain_to_html(&body); + Some((body, html)) +} + +/// Convert a Markdown string to HTML using the same options as the Matrix +/// transport's `markdown_to_html` helper. +fn plain_to_html(markdown: &str) -> String { + use pulldown_cmark::{Options, Parser, html}; + let normalized = crate::chat::util::normalize_line_breaks(markdown); + let options = Options::ENABLE_TABLES + | Options::ENABLE_FOOTNOTES + | Options::ENABLE_STRIKETHROUGH + | Options::ENABLE_TASKLISTS; + let parser = Parser::new_ext(&normalized, options); + let mut out = String::new(); + html::push_html(&mut out, parser); + out +} + /// Try to match a user message against a registered bot command. /// /// The message is expected to be the raw body text (e.g., `"@timmy help"`). diff --git a/server/src/chat/commands/status.rs b/server/src/chat/commands/status.rs index 34272076..138b2e43 100644 --- a/server/src/chat/commands/status.rs +++ b/server/src/chat/commands/status.rs @@ -122,6 +122,34 @@ fn read_stage_items( items } +/// Build the HTML `formatted_body` for the pipeline status with Matrix colour +/// tags on the traffic-light dots. +/// +/// Converts the plain-text pipeline status (Markdown) to HTML via +/// pulldown-cmark and wraps each traffic-light character in a +/// `` tag so Matrix clients display them in +/// colour. +pub(super) fn build_pipeline_status_html(project_root: &std::path::Path, agents: &AgentPool) -> String { + use pulldown_cmark::{Options, Parser, html}; + + let plain = build_pipeline_status(project_root, agents); + let normalized = crate::chat::util::normalize_line_breaks(&plain); + let options = Options::ENABLE_TABLES + | Options::ENABLE_FOOTNOTES + | Options::ENABLE_STRIKETHROUGH + | Options::ENABLE_TASKLISTS; + let parser = Parser::new_ext(&normalized, options); + let mut html_out = String::new(); + html::push_html(&mut html_out, parser); + + // Wrap each traffic-light character with a Matrix colour tag. + html_out + .replace('\u{2717}', "\u{2717}") // ✗ blocked + .replace('\u{25D1}', "\u{25D1}") // ◑ throttled + .replace('\u{25CF}', "\u{25CF}") // ● running + .replace('\u{25CB}', "\u{25CB}") // ○ idle +} + /// Build the full pipeline status text formatted for Matrix (markdown). pub(super) fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String { // Build a map from story_id → active AgentInfo for quick lookup. @@ -444,6 +472,81 @@ mod tests { // -- traffic_light_dot -------------------------------------------------- + // -- build_pipeline_status_html (colored dots) -------------------------- + + #[test] + fn html_status_colors_idle_dot_grey() { + use std::io::Write; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let stage_dir = tmp.path().join(".storkit/work/2_current"); + std::fs::create_dir_all(&stage_dir).unwrap(); + + let story_path = stage_dir.join("42_story_idle.md"); + let mut f = std::fs::File::create(&story_path).unwrap(); + writeln!(f, "---\nname: Idle Story\n---\n").unwrap(); + + let agents = AgentPool::new_test(3000); + let html = build_pipeline_status_html(tmp.path(), &agents); + + assert!( + html.contains("\u{25CB}"), + "idle dot should be grey (#888888): {html}" + ); + } + + #[test] + fn html_status_colors_blocked_dot_red() { + use std::io::Write; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let stage_dir = tmp.path().join(".storkit/work/2_current"); + std::fs::create_dir_all(&stage_dir).unwrap(); + + let story_path = stage_dir.join("42_story_blocked.md"); + let mut f = std::fs::File::create(&story_path).unwrap(); + writeln!(f, "---\nname: Blocked Story\nblocked: true\n---\n").unwrap(); + + let agents = AgentPool::new_test(3000); + let html = build_pipeline_status_html(tmp.path(), &agents); + + assert!( + html.contains("\u{2717}"), + "blocked dot should be red (#cc0000): {html}" + ); + } + + #[test] + fn html_status_plain_text_body_unchanged() { + use std::io::Write; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let stage_dir = tmp.path().join(".storkit/work/2_current"); + std::fs::create_dir_all(&stage_dir).unwrap(); + + let story_path = stage_dir.join("42_story_idle.md"); + let mut f = std::fs::File::create(&story_path).unwrap(); + writeln!(f, "---\nname: Idle Story\n---\n").unwrap(); + + let agents = AgentPool::new_test(3000); + let plain = build_pipeline_status(tmp.path(), &agents); + + // Plain text must still use bare Unicode dots (no HTML tags). + assert!( + plain.contains('\u{25CB}'), + "plain text should have bare Unicode idle dot: {plain}" + ); + assert!( + !plain.contains("data-mx-color"), + "plain text must not contain HTML colour attributes: {plain}" + ); + } + + // -- traffic_light_dot -------------------------------------------------- + #[test] fn dot_idle_when_no_agent() { assert_eq!(traffic_light_dot(false, false, false), "\u{25CB} "); // ○ diff --git a/server/src/chat/transport/matrix/bot/messages.rs b/server/src/chat/transport/matrix/bot/messages.rs index 8652241e..fde9805d 100644 --- a/server/src/chat/transport/matrix/bot/messages.rs +++ b/server/src/chat/transport/matrix/bot/messages.rs @@ -186,10 +186,9 @@ pub(super) async fn on_room_message( ambient_rooms: &ctx.ambient_rooms, room_id: &room_id_str, }; - if let Some(response) = super::super::commands::try_handle_command(&dispatch, &user_message) { + if let Some((response, response_html)) = super::super::commands::try_handle_command_with_html(&dispatch, &user_message) { slog!("[matrix-bot] Handled bot command from {sender}"); - let html = markdown_to_html(&response); - if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await + if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &response_html).await && let Ok(event_id) = msg_id.parse() { ctx.bot_sent_event_ids.lock().await.insert(event_id);