1use std::time::Instant;
17
18use super::template::html_escape;
19use pulldown_cmark::{CowStr, Options, Parser, html};
20use serde_json;
21use tracing::{debug, info, trace};
22
23#[derive(Debug, thiserror::Error)]
25pub enum RenderError {
26 #[error("invalid message: {0}")]
28 InvalidMessage(String),
29 #[error("parse error: {0}")]
31 ParseError(String),
32}
33
34#[derive(Debug, Clone)]
36pub struct RenderOptions {
37 pub show_timestamps: bool,
39
40 pub show_tool_calls: bool,
42
43 pub syntax_highlighting: bool,
45
46 pub wrap_code: bool,
48
49 pub collapse_threshold: usize,
52
53 pub code_preview_lines: usize,
55
56 pub agent_slug: Option<String>,
58}
59
60impl Default for RenderOptions {
61 fn default() -> Self {
62 Self {
63 show_timestamps: true,
64 show_tool_calls: true,
65 syntax_highlighting: true,
66 wrap_code: false,
67 collapse_threshold: 0, code_preview_lines: 20,
69 agent_slug: None,
70 }
71 }
72}
73
74#[derive(Debug, Clone)]
76pub struct Message {
77 pub role: String,
79
80 pub content: String,
82
83 pub timestamp: Option<String>,
85
86 pub tool_call: Option<ToolCall>,
88
89 pub index: Option<usize>,
91
92 pub author: Option<String>,
94}
95
96#[derive(Debug, Clone)]
98pub struct ToolCall {
99 pub name: String,
101
102 pub input: String,
104
105 pub output: Option<String>,
107
108 pub status: Option<ToolStatus>,
110
111 pub correlation_id: Option<String>,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum ToolStatus {
118 Success,
119 Error,
120 Pending,
121}
122
123impl ToolStatus {
124 fn css_class(&self) -> &'static str {
125 match self {
126 ToolStatus::Success => "tool-status-success",
127 ToolStatus::Error => "tool-status-error",
128 ToolStatus::Pending => "tool-status-pending",
129 }
130 }
131
132 fn icon_svg(&self) -> &'static str {
133 match self {
134 ToolStatus::Success => ICON_CHECK,
135 ToolStatus::Error => ICON_X,
136 ToolStatus::Pending => ICON_LOADER,
137 }
138 }
139
140 fn label(&self) -> &'static str {
141 match self {
142 ToolStatus::Success => "success",
143 ToolStatus::Error => "error",
144 ToolStatus::Pending => "pending",
145 }
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum MessageGroupType {
157 User,
159 Assistant,
161 System,
163 ToolOnly,
165}
166
167impl MessageGroupType {
168 pub fn role_icon(&self) -> &'static str {
170 match self {
171 MessageGroupType::User => "user",
172 MessageGroupType::Assistant => "assistant",
173 MessageGroupType::System => "system",
174 MessageGroupType::ToolOnly => "tool",
175 }
176 }
177}
178
179#[derive(Debug, Clone)]
184pub struct ToolResult {
185 pub tool_name: String,
187 pub content: String,
189 pub status: ToolStatus,
191 pub correlation_id: Option<String>,
193}
194
195impl ToolResult {
196 pub fn new(
198 tool_name: impl Into<String>,
199 content: impl Into<String>,
200 status: ToolStatus,
201 ) -> Self {
202 Self {
203 tool_name: tool_name.into(),
204 content: content.into(),
205 status,
206 correlation_id: None,
207 }
208 }
209
210 pub fn with_correlation_id(mut self, id: impl Into<String>) -> Self {
212 self.correlation_id = Some(id.into());
213 self
214 }
215
216 pub fn is_error(&self) -> bool {
218 self.status == ToolStatus::Error
219 }
220}
221
222#[derive(Debug, Clone)]
227pub struct ToolCallWithResult {
228 pub call: ToolCall,
230 pub result: Option<ToolResult>,
232 pub correlation_id: Option<String>,
234}
235
236impl ToolCallWithResult {
237 pub fn new(call: ToolCall) -> Self {
239 let correlation_id = call.correlation_id.clone();
240 Self {
241 call,
242 result: None,
243 correlation_id,
244 }
245 }
246
247 pub fn with_correlation_id(mut self, id: impl Into<String>) -> Self {
249 self.correlation_id = Some(id.into());
250 self
251 }
252
253 pub fn with_result(mut self, result: ToolResult) -> Self {
255 self.result = Some(result);
256 self
257 }
258
259 pub fn has_result(&self) -> bool {
261 self.result.is_some()
262 }
263
264 pub fn is_error(&self) -> bool {
266 self.result.as_ref().is_some_and(|r| r.is_error())
267 }
268
269 pub fn effective_status(&self) -> ToolStatus {
271 self.result
272 .as_ref()
273 .map(|r| r.status)
274 .or(self.call.status)
275 .unwrap_or(ToolStatus::Pending)
276 }
277}
278
279#[derive(Debug, Clone)]
286pub struct MessageGroup {
287 pub group_type: MessageGroupType,
289 pub primary: Message,
291 pub tool_calls: Vec<ToolCallWithResult>,
293 pub start_timestamp: Option<String>,
295 pub end_timestamp: Option<String>,
297}
298
299impl MessageGroup {
300 pub fn new(primary: Message, group_type: MessageGroupType) -> Self {
302 let end_timestamp = primary.timestamp.clone();
303 let start_timestamp = primary.timestamp.clone();
304 Self {
305 group_type,
306 primary,
307 tool_calls: Vec::new(),
308 start_timestamp,
309 end_timestamp,
310 }
311 }
312
313 pub fn user(primary: Message) -> Self {
315 Self::new(primary, MessageGroupType::User)
316 }
317
318 pub fn assistant(primary: Message) -> Self {
320 Self::new(primary, MessageGroupType::Assistant)
321 }
322
323 pub fn system(primary: Message) -> Self {
325 Self::new(primary, MessageGroupType::System)
326 }
327
328 pub fn tool_only(primary: Message) -> Self {
330 Self::new(primary, MessageGroupType::ToolOnly)
331 }
332
333 pub fn add_tool_call(&mut self, call: ToolCall, correlation_id: Option<String>) {
335 tracing::trace!(
336 tool_name = %call.name,
337 correlation_id = ?correlation_id,
338 "Adding tool call to message group"
339 );
340 let mut tc = ToolCallWithResult::new(call);
341 if let Some(id) = correlation_id {
342 tc = tc.with_correlation_id(id);
343 }
344 self.tool_calls.push(tc);
345 }
346
347 pub fn add_tool_result(&mut self, result: ToolResult) {
352 if let Some(ref corr_id) = result.correlation_id {
354 for tc in &mut self.tool_calls {
355 if tc.correlation_id.as_ref() == Some(corr_id) {
356 tracing::trace!(
357 tool_name = %result.tool_name,
358 correlation_id = %corr_id,
359 "Matched tool result to call"
360 );
361 tc.result = Some(result);
362 return;
363 }
364 }
365 tracing::warn!(
366 tool_name = %result.tool_name,
367 correlation_id = %corr_id,
368 "Could not match correlated tool result to any call"
369 );
370 return;
371 }
372
373 for tc in &mut self.tool_calls {
375 if tc.result.is_none() && tc.call.name == result.tool_name {
376 tracing::trace!(
377 tool_name = %result.tool_name,
378 "Matched tool result to call by name"
379 );
380 tc.result = Some(result);
381 return;
382 }
383 }
384
385 tracing::warn!(
386 tool_name = %result.tool_name,
387 correlation_id = ?result.correlation_id,
388 "Could not match tool result to any call"
389 );
390 }
391
392 pub fn update_end_timestamp(&mut self, timestamp: Option<String>) {
394 if let Some(ts) = timestamp {
395 match (&self.end_timestamp, &ts) {
396 (Some(existing), new) if new > existing => {
397 self.end_timestamp = Some(ts);
398 }
399 (None, _) => {
400 self.end_timestamp = Some(ts);
401 }
402 _ => {}
403 }
404 }
405 }
406
407 pub fn tool_count(&self) -> usize {
409 self.tool_calls.len()
410 }
411
412 pub fn has_errors(&self) -> bool {
414 self.tool_calls.iter().any(|tc| tc.is_error())
415 }
416
417 pub fn all_tools_complete(&self) -> bool {
419 self.tool_calls.iter().all(|tc| tc.has_result())
420 }
421
422 pub fn tool_summary(&self) -> (usize, usize, usize) {
424 let mut success = 0;
425 let mut error = 0;
426 let mut pending = 0;
427 for tc in &self.tool_calls {
428 match tc.effective_status() {
429 ToolStatus::Success => success += 1,
430 ToolStatus::Error => error += 1,
431 ToolStatus::Pending => pending += 1,
432 }
433 }
434 (success, error, pending)
435 }
436}
437
438const ICON_USER: &str = r#"<svg class="lucide-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>"#;
444
445const ICON_BOT: &str = r#"<svg class="lucide-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>"#;
447
448const ICON_WRENCH: &str = r#"<svg class="lucide-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>"#;
450
451const ICON_SETTINGS: &str = r#"<svg class="lucide-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 .73 2.73l-.22.39a2 2 0 0 0-2.73.73l-.15-.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>"#;
453
454const ICON_MESSAGE: &str = r#"<svg class="lucide-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>"#;
456
457const ICON_TERMINAL: &str = r#"<svg class="lucide-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/></svg>"#;
459
460const ICON_FILE_TEXT: &str = r#"<svg class="lucide-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg>"#;
462
463const ICON_PENCIL: &str = r#"<svg class="lucide-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 .73 2.73l-.22.38a2 2 0 0 0-.73 2.73l.22.39a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V4a2 2 0 0 0-2-2z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>"#;
465
466const ICON_SEARCH: &str = r#"<svg class="lucide-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>"#;
468
469const ICON_GLOBE: &str = r#"<svg class="lucide-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>"#;
471
472const ICON_CHECK: &str = r#"<svg class="lucide-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>"#;
474
475const ICON_X: &str = r#"<svg class="lucide-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>"#;
477
478const ICON_LOADER: &str = r#"<svg class="lucide-icon lucide-spin" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></svg>"#;
480
481const ICON_MAIL: &str = r#"<svg class="lucide-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>"#;
483
484const ICON_DATABASE: &str = r#"<svg class="lucide-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></svg>"#;
486
487const ICON_SPARKLES: &str = r#"<svg class="lucide-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>"#;
489
490pub fn agent_css_class(slug: &str) -> &'static str {
494 let slug = slug.trim().to_ascii_lowercase().replace('-', "_");
495 match slug.as_str() {
496 "claude_code" | "claude" => "agent-claude",
497 "codex" | "codex_cli" => "agent-codex",
498 "cursor" | "cursor_ai" => "agent-cursor",
499 "chatgpt" | "openai" => "agent-chatgpt",
500 "gemini" | "gemini_cli" | "google" => "agent-gemini",
501 "aider" => "agent-aider",
502 "copilot" | "copilot_cli" | "github_copilot" | "github_copilot_cli" => "agent-copilot",
503 "cody" | "sourcegraph" => "agent-cody",
504 "windsurf" => "agent-windsurf",
505 "amp" => "agent-amp",
506 "grok" => "agent-grok",
507 "cline" | "clawdbot" | "kimi" => "agent-gemini",
508 "opencode" | "qwen" => "agent-codex",
509 "pi_agent" | "factory" | "droid" => "agent-aider",
510 "openclaw" => "agent-copilot",
511 "vibe" | "mistral" => "agent-chatgpt",
512 "crush" => "agent-amp",
513 "hermes" => "agent-hermes",
514 _ => "agent-default",
515 }
516}
517
518pub fn agent_display_name(slug: &str) -> &'static str {
520 let slug = slug.trim().to_ascii_lowercase().replace('-', "_");
521 match slug.as_str() {
522 "claude_code" | "claude" => "Claude",
523 "codex" | "codex_cli" => "Codex",
524 "cursor" | "cursor_ai" => "Cursor",
525 "chatgpt" | "openai" => "ChatGPT",
526 "gemini" | "gemini_cli" | "google" => "Gemini",
527 "aider" => "Aider",
528 "copilot" | "github_copilot" => "GitHub Copilot",
529 "copilot_cli" | "github_copilot_cli" => "GitHub Copilot CLI",
530 "cody" | "sourcegraph" => "Cody",
531 "windsurf" => "Windsurf",
532 "amp" => "Amp",
533 "grok" => "Grok",
534 "cline" => "Cline",
535 "opencode" => "OpenCode",
536 "pi_agent" => "Pi Agent",
537 "factory" | "droid" => "Factory",
538 "openclaw" => "OpenClaw",
539 "clawdbot" => "ClawdBot",
540 "vibe" => "Vibe",
541 "mistral" => "Mistral",
542 "crush" => "Crush",
543 "hermes" => "Hermes",
544 "kimi" => "Kimi",
545 "qwen" => "Qwen",
546 _ => "AI Assistant",
547 }
548}
549
550const MAX_VISIBLE_BADGES: usize = 6;
556
557pub fn render_message_groups(
563 groups: &[MessageGroup],
564 options: &RenderOptions,
565) -> Result<String, RenderError> {
566 let started = Instant::now();
567 let mut html = String::with_capacity(groups.len() * 3000);
568
569 let agent_class = options
571 .agent_slug
572 .as_ref()
573 .map(|s| agent_css_class(s))
574 .unwrap_or("");
575
576 info!(
577 component = "renderer",
578 operation = "render_message_groups",
579 group_count = groups.len(),
580 agent_slug = options.agent_slug.as_deref().unwrap_or(""),
581 "Rendering conversation from message groups"
582 );
583
584 if !agent_class.is_empty() {
585 html.push_str(&format!(
586 r#"<div class="conversation-messages {}">"#,
587 agent_class
588 ));
589 html.push('\n');
590 }
591
592 for (idx, group) in groups.iter().enumerate() {
593 html.push_str(&render_message_group(group, idx, options)?);
594 html.push('\n');
595 }
596
597 if !agent_class.is_empty() {
598 html.push_str("</div>\n");
599 }
600
601 debug!(
602 component = "renderer",
603 operation = "render_message_groups_complete",
604 duration_ms = started.elapsed().as_millis(),
605 bytes = html.len(),
606 groups = groups.len(),
607 "Message groups rendered"
608 );
609
610 Ok(html)
611}
612
613fn render_message_group(
621 group: &MessageGroup,
622 index: usize,
623 options: &RenderOptions,
624) -> Result<String, RenderError> {
625 let started = Instant::now();
626 trace!(
627 component = "renderer",
628 operation = "render_message_group",
629 index = index,
630 group_type = ?group.group_type,
631 tool_count = group.tool_count(),
632 "Rendering message group"
633 );
634
635 let role_class = match group.group_type {
637 MessageGroupType::User => "message-user",
638 MessageGroupType::Assistant => "message-assistant",
639 MessageGroupType::System => "message-system",
640 MessageGroupType::ToolOnly => "message-tool",
641 };
642
643 let role_icon = match group.group_type {
645 MessageGroupType::User => ICON_USER,
646 MessageGroupType::Assistant => ICON_BOT,
647 MessageGroupType::System => ICON_SETTINGS,
648 MessageGroupType::ToolOnly => ICON_WRENCH,
649 };
650
651 let author_display = group
653 .primary
654 .author
655 .as_ref()
656 .map(|a| super::template::html_escape(a))
657 .unwrap_or_else(|| match group.group_type {
658 MessageGroupType::User => "You".to_string(),
659 MessageGroupType::Assistant => "Assistant".to_string(),
660 MessageGroupType::System => "System".to_string(),
661 MessageGroupType::ToolOnly => "Tool".to_string(),
662 });
663
664 let anchor_id = group
666 .primary
667 .index
668 .or(Some(index))
669 .map(|idx| format!(r#" id="msg-{}""#, idx))
670 .unwrap_or_default();
671
672 let timestamp_html = if options.show_timestamps {
674 if let Some(ts) = &group.start_timestamp {
675 format!(
676 r#"<time class="message-time" datetime="{}">{}</time>"#,
677 super::template::html_escape(ts),
678 super::template::html_escape(&format_timestamp(ts))
679 )
680 } else {
681 String::new()
682 }
683 } else {
684 String::new()
685 };
686
687 let content_html = render_content(&group.primary.content, options);
689
690 let (tool_badges_html, overflow_count) =
692 if options.show_tool_calls && !group.tool_calls.is_empty() {
693 render_tool_badges_with_overflow(&group.tool_calls, options)
694 } else {
695 (String::new(), 0)
696 };
697
698 let aria_label = if group.tool_calls.is_empty() {
700 format!("{} message", group.group_type.role_icon())
701 } else {
702 format!(
703 "{} message with {} tool call{}",
704 group.group_type.role_icon(),
705 group.tool_calls.len(),
706 if group.tool_calls.len() == 1 { "" } else { "s" }
707 )
708 };
709
710 let content_bytes = group.primary.content.len();
712 let mut content_chars = 0; let should_collapse =
714 options.collapse_threshold > 0 && content_bytes > options.collapse_threshold && {
715 let mut chars = group.primary.content.chars();
716 let mut count = 0;
717 while count <= options.collapse_threshold && chars.next().is_some() {
718 count += 1;
719 }
720 content_chars = if count > options.collapse_threshold {
721 count + chars.count()
723 } else {
724 count
725 };
726 content_chars > options.collapse_threshold
727 };
728
729 let (content_wrapper_start, content_wrapper_end) = if should_collapse {
730 let preview_chars = options.collapse_threshold.min(500);
731 let safe_len = byte_index_for_char_count(&group.primary.content, preview_chars);
732 let preview = group.primary.content.get(..safe_len).unwrap_or("");
733 (
734 format!(
735 r#"<details class="message-collapse">
736 <summary>
737 <span class="message-preview">{}</span>
738 <span class="message-expand-hint">Click to expand ({} chars)</span>
739 </summary>
740 <div class="message-expanded">"#,
741 super::template::html_escape(preview),
742 content_chars
743 ),
744 "</div></details>".to_string(),
745 )
746 } else {
747 (String::new(), String::new())
748 };
749
750 let content_section = if content_html.trim().is_empty() {
752 String::new()
753 } else {
754 format!(
755 r#"
756 <div class="message-content">
757 {wrapper_start}{content}{wrapper_end}
758 </div>"#,
759 wrapper_start = content_wrapper_start,
760 content = content_html,
761 wrapper_end = content_wrapper_end,
762 )
763 };
764
765 let tool_container = if !tool_badges_html.is_empty() {
767 format!(
768 r#"<div class="message-header-right" role="group" aria-label="Tool calls{}">
769 {badges}
770 </div>"#,
771 if overflow_count > 0 {
772 format!(" ({} shown, {} more)", MAX_VISIBLE_BADGES, overflow_count)
773 } else {
774 String::new()
775 },
776 badges = tool_badges_html,
777 )
778 } else {
779 r#"<div class="message-header-right"></div>"#.to_string()
780 };
781
782 let rendered = format!(
783 r#" <article class="message {role_class}"{anchor} role="article" aria-label="{aria_label}">
784 <header class="message-header">
785 <div class="message-header-left">
786 <span class="message-icon" aria-hidden="true">{role_icon}</span>
787 <span class="message-author">{author}</span>
788 {timestamp}
789 </div>
790 {tool_container}
791 </header>{content_section}
792 </article>"#,
793 role_class = role_class,
794 anchor = anchor_id,
795 aria_label = super::template::html_escape(&aria_label),
796 role_icon = role_icon,
797 author = author_display,
798 timestamp = timestamp_html,
799 tool_container = tool_container,
800 content_section = content_section,
801 );
802
803 debug!(
804 component = "renderer",
805 operation = "render_message_group_complete",
806 index = index,
807 duration_ms = started.elapsed().as_millis(),
808 bytes = rendered.len(),
809 "Message group rendered"
810 );
811
812 Ok(rendered)
813}
814
815fn render_tool_badges_with_overflow(
820 tools: &[ToolCallWithResult],
821 _options: &RenderOptions,
822) -> (String, usize) {
823 if tools.is_empty() {
824 return (String::new(), 0);
825 }
826
827 if tools.len() <= MAX_VISIBLE_BADGES {
828 let badges: String = tools
830 .iter()
831 .map(|tool| render_single_tool_badge(tool, false))
832 .collect::<Vec<_>>()
833 .join("\n ");
834 (badges, 0)
835 } else {
836 let badges: String = tools
839 .iter()
840 .enumerate()
841 .map(|(idx, tool)| render_single_tool_badge(tool, idx >= MAX_VISIBLE_BADGES))
842 .collect::<Vec<_>>()
843 .join("\n ");
844
845 let overflow_count = tools.len() - MAX_VISIBLE_BADGES;
846 let overflow_badge = format!(
847 r#"<button class="tool-badge tool-overflow"
848 aria-label="{count} more tool{s}"
849 aria-expanded="false"
850 data-overflow-count="{count}">
851 <span class="tool-badge-text">+{count}</span>
852 </button>"#,
853 count = overflow_count,
854 s = if overflow_count == 1 { "" } else { "s" },
855 );
856
857 (
858 format!("{}\n {}", badges, overflow_badge),
859 overflow_count,
860 )
861 }
862}
863
864fn render_single_tool_badge(tool: &ToolCallWithResult, overflow_extra: bool) -> String {
866 let icon = get_tool_lucide_icon(&tool.call.name);
867 let status = tool.effective_status();
868 let status_class = status.css_class();
869 let status_label = status.label();
870 let status_icon = status.icon_svg();
871 let overflow_extra_class = if overflow_extra {
872 " tool-overflow-extra"
873 } else {
874 ""
875 };
876
877 let formatted_input = format_json_or_raw(&tool.call.input);
879 let formatted_output = tool
880 .result
881 .as_ref()
882 .map(|r| format_json_or_raw(&r.content))
883 .unwrap_or_default();
884
885 let popover_input = if !formatted_input.trim().is_empty() {
886 format!(
887 r#"<div class="tool-popover-section"><span class="tool-popover-label">Input</span><pre><code>{}</code></pre></div>"#,
888 super::template::html_escape(&formatted_input)
889 )
890 } else {
891 String::new()
892 };
893
894 let popover_output = if !formatted_output.trim().is_empty() {
895 format!(
896 r#"<div class="tool-popover-section"><span class="tool-popover-label">Output</span><pre><code>{}</code></pre></div>"#,
897 super::template::html_escape(&formatted_output)
898 )
899 } else {
900 String::new()
901 };
902
903 let status_badge = if !status_label.is_empty() {
904 format!(
905 r#"<span class="tool-badge-status {}">{}</span>"#,
906 status_label, status_icon
907 )
908 } else {
909 String::new()
910 };
911
912 format!(
913 r#"<button class="tool-badge {status_class}{overflow_extra_class}"
914 aria-label="{name}: {status_label}"
915 aria-expanded="false"
916 data-tool-name="{name}">
917 <span class="tool-badge-icon">{icon}</span>
918 <span class="tool-badge-status">{status_icon}</span>
919 <div class="tool-popover" role="tooltip">
920 <div class="tool-popover-header">{icon} <span>{name}</span> {status_badge}</div>
921 {input}{output}
922 </div>
923 </button>"#,
924 status_class = status_class,
925 overflow_extra_class = overflow_extra_class,
926 name = super::template::html_escape(&tool.call.name),
927 status_label = status_label,
928 icon = icon,
929 status_icon = status_icon,
930 status_badge = status_badge,
931 input = popover_input,
932 output = popover_output,
933 )
934}
935
936fn get_tool_lucide_icon(tool_name: &str) -> &'static str {
938 match tool_name.to_lowercase().as_str() {
939 "bash" | "shell" | "terminal" => ICON_TERMINAL,
940 "read" | "read_file" | "readfile" => ICON_FILE_TEXT,
941 "write" | "write_file" | "writefile" | "edit" => ICON_PENCIL,
942 "glob" | "find" | "grep" | "search" | "websearch" => ICON_SEARCH,
943 "webfetch" | "fetch" | "http" | "curl" => ICON_GLOBE,
944 "task" | "agent" => ICON_SPARKLES,
945 n if n.starts_with("mcp__mcp-agent-mail") => ICON_MAIL,
946 n if n.contains("sql") || n.contains("db") || n.contains("database") => ICON_DATABASE,
947 _ => ICON_WRENCH,
948 }
949}
950
951pub fn render_message(message: &Message, options: &RenderOptions) -> Result<String, RenderError> {
953 let started = Instant::now();
954 trace!(
955 component = "renderer",
956 operation = "render_message",
957 message_index = message.index.unwrap_or(0),
958 has_index = message.index.is_some(),
959 role = message.role.as_str(),
960 content_len = message.content.len(),
961 "Rendering message"
962 );
963
964 let role_class = match message.role.as_str() {
966 "user" => "message-user",
967 "assistant" | "agent" => "message-assistant",
968 "tool" => "message-tool",
969 "system" => "message-system",
970 _ => "",
971 };
972
973 let anchor_id = message
975 .index
976 .map(|idx| format!(r#" id="msg-{}""#, idx))
977 .unwrap_or_default();
978
979 let author_display = message
981 .author
982 .as_ref()
983 .map(|a| html_escape(a))
984 .unwrap_or_else(|| format_role_display(&message.role));
985
986 let timestamp_html = if options.show_timestamps {
987 if let Some(ts) = &message.timestamp {
988 format!(
989 r#"<time class="message-time" datetime="{}">{}</time>"#,
990 html_escape(ts),
991 html_escape(&format_timestamp(ts))
992 )
993 } else {
994 String::new()
995 }
996 } else {
997 String::new()
998 };
999
1000 let content_html = render_content(&message.content, options);
1001
1002 let content_bytes = message.content.len();
1004 let mut content_chars = 0; let should_collapse =
1006 options.collapse_threshold > 0 && content_bytes > options.collapse_threshold && {
1007 let mut chars = message.content.chars();
1008 let mut count = 0;
1009 while count <= options.collapse_threshold && chars.next().is_some() {
1010 count += 1;
1011 }
1012 content_chars = if count > options.collapse_threshold {
1013 count + chars.count()
1015 } else {
1016 count
1017 };
1018 content_chars > options.collapse_threshold
1019 };
1020
1021 let (content_wrapper_start, content_wrapper_end) = if should_collapse {
1022 debug!(
1023 component = "renderer",
1024 operation = "collapse_message",
1025 message_index = message.index.unwrap_or(0),
1026 content_len = content_chars,
1027 collapse_threshold = options.collapse_threshold,
1028 "Collapsing long message"
1029 );
1030 let preview_chars = options.collapse_threshold.min(500);
1031 let safe_len = byte_index_for_char_count(&message.content, preview_chars);
1033 let preview = message.content.get(..safe_len).unwrap_or("");
1034 (
1035 format!(
1036 r#"<details class="message-collapse">
1037 <summary>
1038 <span class="message-preview">{}</span>
1039 <span class="message-expand-hint">Click to expand ({} chars)</span>
1040 </summary>
1041 <div class="message-expanded">"#,
1042 html_escape(preview),
1043 content_chars
1044 ),
1045 "</div></details>".to_string(),
1046 )
1047 } else {
1048 (String::new(), String::new())
1049 };
1050
1051 let tool_badges_html = if options.show_tool_calls {
1053 if let Some(tc) = &message.tool_call {
1054 render_tool_badge(tc, options)
1055 } else {
1056 String::new()
1057 }
1058 } else {
1059 String::new()
1060 };
1061
1062 let role_icon = match message.role.as_str() {
1064 "user" => ICON_USER,
1065 "assistant" | "agent" => ICON_BOT,
1066 "tool" => ICON_WRENCH,
1067 "system" => ICON_SETTINGS,
1068 _ => ICON_MESSAGE,
1069 };
1070
1071 let content_section = if content_html.trim().is_empty() {
1073 String::new()
1074 } else {
1075 format!(
1076 r#"
1077 <div class="message-content">
1078 {wrapper_start}{content}{wrapper_end}
1079 </div>"#,
1080 wrapper_start = content_wrapper_start,
1081 content = content_html,
1082 wrapper_end = content_wrapper_end,
1083 )
1084 };
1085
1086 let rendered = format!(
1087 r#" <article class="message {role_class}"{anchor} role="article" aria-label="{role} message">
1088 <header class="message-header">
1089 <div class="message-header-left">
1090 <span class="message-icon" aria-hidden="true">{role_icon}</span>
1091 <span class="message-author">{author}</span>
1092 {timestamp}
1093 </div>
1094 <div class="message-header-right">
1095 {tool_badges}
1096 </div>
1097 </header>{content_section}
1098 </article>"#,
1099 role_class = role_class,
1100 anchor = anchor_id,
1101 role = html_escape(&message.role),
1102 role_icon = role_icon,
1103 author = author_display,
1104 timestamp = timestamp_html,
1105 content_section = content_section,
1106 tool_badges = tool_badges_html,
1107 );
1108
1109 debug!(
1110 component = "renderer",
1111 operation = "render_message_complete",
1112 message_index = message.index.unwrap_or(0),
1113 duration_ms = started.elapsed().as_millis(),
1114 bytes = rendered.len(),
1115 "Message rendered"
1116 );
1117
1118 Ok(rendered)
1119}
1120
1121fn format_role_display(role: &str) -> String {
1123 match role {
1124 "user" => "You".to_string(),
1125 "assistant" | "agent" => "Assistant".to_string(),
1126 "tool" => "Tool".to_string(),
1127 "system" => "System".to_string(),
1128 other => html_escape(other),
1129 }
1130}
1131
1132fn render_content(content: &str, _options: &RenderOptions) -> String {
1135 use pulldown_cmark::{Event, Tag};
1136
1137 let mut opts = Options::empty();
1139 opts.insert(Options::ENABLE_STRIKETHROUGH);
1140 opts.insert(Options::ENABLE_TABLES);
1141 opts.insert(Options::ENABLE_FOOTNOTES);
1142 opts.insert(Options::ENABLE_TASKLISTS);
1143 opts.insert(Options::ENABLE_SMART_PUNCTUATION);
1144
1145 let parser = Parser::new_ext(content, opts).map(|event| match event {
1147 Event::Html(html) => Event::Text(html),
1149 Event::InlineHtml(html) => Event::Text(html),
1150 Event::Start(Tag::Link {
1152 link_type,
1153 dest_url,
1154 title,
1155 id,
1156 }) => Event::Start(Tag::Link {
1157 link_type,
1158 dest_url: sanitize_markdown_dest_url(dest_url),
1159 title,
1160 id,
1161 }),
1162 Event::Start(Tag::Image {
1163 link_type,
1164 dest_url,
1165 title,
1166 id,
1167 }) => Event::Start(Tag::Image {
1168 link_type,
1169 dest_url: sanitize_markdown_dest_url(dest_url),
1170 title,
1171 id,
1172 }),
1173 other => other,
1175 });
1176
1177 let mut html_output = String::new();
1178 html::push_html(&mut html_output, parser);
1179
1180 html_output
1181}
1182
1183fn sanitize_markdown_dest_url(dest_url: CowStr<'_>) -> CowStr<'_> {
1184 let trimmed = dest_url.trim();
1185 if !trimmed.contains(':') {
1188 return dest_url;
1189 }
1190
1191 let mut normalized = String::with_capacity(16);
1193 for ch in trimmed
1194 .chars()
1195 .filter(|c| !c.is_ascii_whitespace() && !c.is_ascii_control())
1196 {
1197 normalized.push(ch.to_ascii_lowercase());
1198 if normalized.len() >= 15 {
1199 break;
1200 }
1201 }
1202
1203 if normalized.starts_with("javascript:")
1204 || normalized.starts_with("vbscript:")
1205 || normalized.starts_with("data:")
1206 {
1207 "#".into()
1208 } else {
1209 dest_url
1210 }
1211}
1212
1213fn render_tool_badge(tool_call: &ToolCall, options: &RenderOptions) -> String {
1215 let started = Instant::now();
1216 trace!(
1217 component = "renderer",
1218 operation = "render_tool_badge",
1219 tool = tool_call.name.as_str(),
1220 input_len = tool_call.input.len(),
1221 output_len = tool_call.output.as_ref().map(|s| s.len()).unwrap_or(0),
1222 "Rendering tool badge"
1223 );
1224
1225 let (status_class, status_icon_svg, status_label) = tool_call
1227 .status
1228 .as_ref()
1229 .map(|s| (s.css_class(), s.icon_svg(), s.label()))
1230 .unwrap_or(("", "", ""));
1231
1232 let formatted_input = format_json_or_raw(&tool_call.input);
1234
1235 let tool_icon = match tool_call.name.to_lowercase().as_str() {
1237 "bash" | "shell" => ICON_TERMINAL,
1238 "read" | "read_file" => ICON_FILE_TEXT,
1239 "write" | "write_file" | "edit" => ICON_PENCIL,
1240 "glob" | "find" | "grep" | "search" | "websearch" => ICON_SEARCH,
1241 "webfetch" | "fetch" | "http" => ICON_GLOBE,
1242 "task" => ICON_SPARKLES,
1243 n if n.starts_with("mcp__mcp-agent-mail") => ICON_MAIL,
1244 n if n.contains("sql") || n.contains("db") => ICON_DATABASE,
1245 _ => ICON_WRENCH,
1246 };
1247
1248 let _ = options;
1250
1251 let input_preview = formatted_input.clone();
1253
1254 let output_preview = if let Some(output) = &tool_call.output {
1255 format_json_or_raw(output)
1256 } else {
1257 String::new()
1258 };
1259
1260 let popover_input = if !input_preview.trim().is_empty() {
1262 format!(
1263 r#"<div class="tool-popover-section"><span class="tool-popover-label">Input</span><pre><code>{}</code></pre></div>"#,
1264 html_escape(&input_preview)
1265 )
1266 } else {
1267 String::new()
1268 };
1269
1270 let popover_output = if !output_preview.is_empty() {
1271 format!(
1272 r#"<div class="tool-popover-section"><span class="tool-popover-label">Output</span><pre><code>{}</code></pre></div>"#,
1273 html_escape(&output_preview)
1274 )
1275 } else {
1276 String::new()
1277 };
1278
1279 let rendered = format!(
1281 r#"<span class="tool-badge {status_class}" tabindex="0" role="button" aria-label="{name} tool call">
1282 <span class="tool-badge-icon">{icon}</span>
1283 {status_badge}
1284 <div class="tool-popover" role="tooltip">
1285 <div class="tool-popover-header">{icon} <span>{name}</span> {status_badge}</div>
1286 {input}{output}
1287 </div>
1288 </span>"#,
1289 icon = tool_icon,
1290 name = html_escape(&tool_call.name),
1291 status_class = status_class,
1292 status_badge = if !status_label.is_empty() {
1293 format!(
1294 r#"<span class="tool-badge-status {}">{}</span>"#,
1295 status_label, status_icon_svg
1296 )
1297 } else {
1298 String::new()
1299 },
1300 input = popover_input,
1301 output = popover_output,
1302 );
1303
1304 debug!(
1305 component = "renderer",
1306 operation = "render_tool_badge_complete",
1307 tool = tool_call.name.as_str(),
1308 duration_ms = started.elapsed().as_millis(),
1309 bytes = rendered.len(),
1310 "Tool call rendered"
1311 );
1312
1313 rendered
1314}
1315
1316fn format_json_or_raw(s: &str) -> String {
1318 if let Ok(value) = serde_json::from_str::<serde_json::Value>(s)
1320 && let Ok(pretty) = serde_json::to_string_pretty(&value)
1321 {
1322 return pretty;
1323 }
1324 s.to_string()
1325}
1326
1327fn format_timestamp(ts: &str) -> String {
1329 if ts.len() >= 19
1331 && ts.is_char_boundary(10)
1332 && ts.is_char_boundary(11)
1333 && ts.is_char_boundary(19)
1334 && let (Some(date_part), Some(time_part)) = (ts.get(..10), ts.get(11..19))
1335 {
1336 format!("{} {}", date_part, time_part)
1337 } else {
1338 ts.to_string()
1339 }
1340}
1341
1342#[cfg(test)]
1344fn truncate_to_char_boundary(s: &str, max_bytes: usize) -> usize {
1345 if max_bytes >= s.len() {
1346 return s.len();
1347 }
1348 let mut end = max_bytes;
1350 while end > 0 && !s.is_char_boundary(end) {
1351 end -= 1;
1352 }
1353 end
1354}
1355
1356fn byte_index_for_char_count(s: &str, max_chars: usize) -> usize {
1358 if max_chars == 0 {
1359 return 0;
1360 }
1361 s.char_indices()
1362 .nth(max_chars)
1363 .map(|(idx, _)| idx)
1364 .unwrap_or(s.len())
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369 use super::*;
1370
1371 #[test]
1372 fn test_render_error_display_strings() {
1373 assert_eq!(
1374 RenderError::InvalidMessage("missing role".to_string()).to_string(),
1375 "invalid message: missing role"
1376 );
1377 assert_eq!(
1378 RenderError::ParseError("bad markdown".to_string()).to_string(),
1379 "parse error: bad markdown"
1380 );
1381 }
1382
1383 fn test_message(role: &str, content: &str) -> Message {
1384 Message {
1385 role: role.to_string(),
1386 content: content.to_string(),
1387 timestamp: None,
1388 tool_call: None,
1389 index: None,
1390 author: None,
1391 }
1392 }
1393
1394 #[test]
1395 fn test_render_message_user() {
1396 let msg = test_message("user", "Hello, world!");
1397 let opts = RenderOptions::default();
1398 let html = render_message(&msg, &opts).unwrap();
1399
1400 assert!(html.contains("message-user"));
1401 assert!(html.contains("Hello, world!"));
1402 assert!(html.contains("lucide-icon")); assert!(html.contains("M19 21v-2")); }
1405
1406 #[test]
1407 fn test_render_message_with_code() {
1408 let msg = test_message("assistant", "Here's code:\n```rust\nfn main() {}\n```");
1409 let opts = RenderOptions {
1410 syntax_highlighting: true,
1411 ..Default::default()
1412 };
1413 let html = render_message(&msg, &opts).unwrap();
1414
1415 assert!(html.contains("<pre>"));
1416 assert!(html.contains("language-rust"));
1417 assert!(html.contains("fn main()"));
1418 assert!(html.contains("lucide-icon")); }
1420
1421 #[test]
1422 fn test_url_with_query_params_not_double_escaped() {
1423 let msg = test_message("user", "Visit https://example.com?a=1&b=2 for info");
1427 let html = render_message(&msg, &RenderOptions::default()).unwrap();
1428
1429 assert!(
1431 html.contains("https://example.com?a=1&b=2"),
1432 "URL should have single-escaped ampersand. HTML: {}",
1433 html
1434 );
1435 assert!(
1436 !html.contains("&amp;"),
1437 "URL should NOT be double-escaped. HTML: {}",
1438 html
1439 );
1440 }
1441
1442 #[test]
1443 fn test_html_escape_in_content() {
1444 let msg = test_message("user", "<script>alert('xss')</script>");
1445 let html = render_message(&msg, &RenderOptions::default()).unwrap();
1446 assert!(!html.contains("<script>"));
1447 assert!(html.contains("<script>"));
1448 }
1449
1450 #[test]
1451 fn test_javascript_url_sanitized_in_markdown_links() {
1452 let msg = test_message("user", "[click](javascript:alert(1))");
1453 let html = render_message(&msg, &RenderOptions::default()).unwrap();
1454 assert!(
1455 !html.contains("javascript:"),
1456 "javascript: URL should be sanitized, got: {}",
1457 html
1458 );
1459 assert!(html.contains("click")); }
1461
1462 #[test]
1463 fn test_vbscript_and_data_urls_sanitized() {
1464 let msg = test_message("user", "[a](vbscript:foo) [b](data:text/html,<script>)");
1465 let html = render_message(&msg, &RenderOptions::default()).unwrap();
1466 assert!(
1467 !html.contains("vbscript:"),
1468 "vbscript: URL should be sanitized, got: {}",
1469 html
1470 );
1471 assert!(
1472 !html.contains("data:text"),
1473 "data: URL should be sanitized, got: {}",
1474 html
1475 );
1476 }
1477
1478 #[test]
1479 fn test_unsafe_markdown_image_urls_sanitized() {
1480 let msg = test_message(
1481 "user",
1482 ") >)",
1483 );
1484 let html = render_message(&msg, &RenderOptions::default()).unwrap();
1485 assert!(
1486 !html.contains("javascript:"),
1487 "unsafe image URL should be sanitized, got: {}",
1488 html
1489 );
1490 assert!(
1491 !html.contains("data:image"),
1492 "data: image URL should be sanitized, got: {}",
1493 html
1494 );
1495 assert!(
1496 html.contains("<img"),
1497 "image markup should still render, got: {}",
1498 html
1499 );
1500 assert!(
1501 html.contains("src=\"#\""),
1502 "unsafe image src should be rewritten, got: {}",
1503 html
1504 );
1505 }
1506
1507 #[test]
1508 fn test_normal_markdown_image_urls_not_affected() {
1509 let msg = test_message("user", "");
1510 let html = render_message(&msg, &RenderOptions::default()).unwrap();
1511 assert!(
1512 html.contains("https://example.com/logo.png"),
1513 "normal image URLs should be preserved, got: {}",
1514 html
1515 );
1516 }
1517
1518 #[test]
1519 fn test_javascript_url_case_insensitive() {
1520 let msg = test_message("user", "[x](JaVaScRiPt:alert(1))");
1521 let html = render_message(&msg, &RenderOptions::default()).unwrap();
1522 assert!(
1523 !html.contains("javascript:"),
1524 "case-variant javascript: should be sanitized, got: {}",
1525 html
1526 );
1527 assert!(
1528 !html.contains("JaVaScRiPt:"),
1529 "case-variant javascript: should be sanitized, got: {}",
1530 html
1531 );
1532 }
1533
1534 #[test]
1535 fn test_sanitize_markdown_dest_url_blocks_control_character_variants() {
1536 assert!(
1537 sanitize_markdown_dest_url("java\tscript:alert(1)".into()) == CowStr::from("#"),
1538 "tab-obfuscated javascript: URL should be rejected"
1539 );
1540 assert!(
1541 sanitize_markdown_dest_url("\u{0000}data:image/svg+xml,<svg/onload=1>".into())
1542 == CowStr::from("#"),
1543 "control-character data: URL should be rejected"
1544 );
1545 }
1546
1547 #[test]
1548 fn test_normal_urls_not_affected() {
1549 let msg = test_message("user", "[link](https://example.com)");
1550 let html = render_message(&msg, &RenderOptions::default()).unwrap();
1551 assert!(
1552 html.contains("https://example.com"),
1553 "normal URLs should be preserved, got: {}",
1554 html
1555 );
1556 }
1557
1558 #[test]
1559 fn test_format_role_display_escapes_unknown_roles() {
1560 let display = format_role_display("<img src=x onerror=alert(1)>");
1561 assert!(
1562 !display.contains("<img"),
1563 "unknown role should be HTML-escaped, got: {}",
1564 display
1565 );
1566 assert!(display.contains("<img"));
1567 }
1568
1569 #[test]
1570 fn test_agent_css_class() {
1571 assert_eq!(agent_css_class("claude_code"), "agent-claude");
1572 assert_eq!(agent_css_class("codex"), "agent-codex");
1573 assert_eq!(agent_css_class("cursor"), "agent-cursor");
1574 assert_eq!(agent_css_class("gemini"), "agent-gemini");
1575 assert_eq!(agent_css_class("opencode"), "agent-codex");
1576 assert_eq!(agent_css_class("copilot-cli"), "agent-copilot");
1577 assert_eq!(agent_css_class("qwen"), "agent-codex");
1578 assert_eq!(agent_css_class("hermes"), "agent-hermes");
1579 assert_eq!(agent_css_class("unknown"), "agent-default");
1580 }
1581
1582 #[test]
1583 fn test_agent_display_name() {
1584 assert_eq!(agent_display_name("claude_code"), "Claude");
1585 assert_eq!(agent_display_name("codex"), "Codex");
1586 assert_eq!(agent_display_name("github_copilot"), "GitHub Copilot");
1587 assert_eq!(agent_display_name("copilot-cli"), "GitHub Copilot CLI");
1588 assert_eq!(agent_display_name("opencode"), "OpenCode");
1589 assert_eq!(agent_display_name("pi_agent"), "Pi Agent");
1590 assert_eq!(agent_display_name("factory"), "Factory");
1591 assert_eq!(agent_display_name("openclaw"), "OpenClaw");
1592 assert_eq!(agent_display_name("clawdbot"), "ClawdBot");
1593 assert_eq!(agent_display_name("vibe"), "Vibe");
1594 assert_eq!(agent_display_name("crush"), "Crush");
1595 assert_eq!(agent_display_name("kimi"), "Kimi");
1596 assert_eq!(agent_display_name("qwen"), "Qwen");
1597 assert_eq!(agent_display_name("unknown"), "AI Assistant");
1598 }
1599
1600 #[test]
1601 fn connector_registry_slugs_have_specific_html_identity() {
1602 for (slug, _) in crate::indexer::get_connector_factories() {
1603 assert_ne!(
1604 agent_css_class(slug),
1605 "agent-default",
1606 "registered connector {slug} should not use default HTML export styling"
1607 );
1608 assert_ne!(
1609 agent_display_name(slug),
1610 "AI Assistant",
1611 "registered connector {slug} should have a specific HTML export display name"
1612 );
1613 }
1614 }
1615
1616 #[test]
1617 fn test_tool_status_rendering() {
1618 let msg = Message {
1619 role: "tool".to_string(),
1620 content: "Tool executed".to_string(),
1621 timestamp: None,
1622 tool_call: Some(ToolCall {
1623 name: "Bash".to_string(),
1624 input: r#"{"command": "ls -la"}"#.to_string(),
1625 output: Some("file1.txt\nfile2.txt".to_string()),
1626 status: Some(ToolStatus::Success),
1627 correlation_id: None,
1628 }),
1629 index: None,
1630 author: None,
1631 };
1632
1633 let html = render_message(&msg, &RenderOptions::default()).unwrap();
1634 assert!(html.contains("tool-status-success"));
1635 assert!(html.contains("lucide-icon")); assert!(html.contains("M20 6 9 17l-5-5")); assert!(html.contains("polyline points=\"4 17 10 11 4 5\"")); }
1639
1640 #[test]
1641 fn test_message_with_index() {
1642 let msg = Message {
1643 role: "user".to_string(),
1644 content: "Test message".to_string(),
1645 timestamp: None,
1646 tool_call: None,
1647 index: Some(42),
1648 author: None,
1649 };
1650
1651 let html = render_message(&msg, &RenderOptions::default()).unwrap();
1652 assert!(html.contains(r#"id="msg-42""#));
1653 }
1654
1655 #[test]
1656 fn test_message_with_author() {
1657 let msg = Message {
1658 role: "user".to_string(),
1659 content: "Test message".to_string(),
1660 timestamp: None,
1661 tool_call: None,
1662 index: None,
1663 author: Some("Alice".to_string()),
1664 };
1665
1666 let html = render_message(&msg, &RenderOptions::default()).unwrap();
1667 assert!(html.contains("Alice"));
1668 }
1669
1670 #[test]
1671 fn test_format_json_or_raw() {
1672 let json_input = r#"{"key":"value"}"#;
1674 let formatted = format_json_or_raw(json_input);
1675 assert!(formatted.contains('\n')); let raw_input = "not json at all";
1679 let formatted = format_json_or_raw(raw_input);
1680 assert_eq!(formatted, raw_input);
1681 }
1682
1683 #[test]
1684 fn test_long_message_collapse() {
1685 let long_content = "x".repeat(2000);
1686 let msg = test_message("user", &long_content);
1687 let opts = RenderOptions {
1688 collapse_threshold: 1000,
1689 ..Default::default()
1690 };
1691
1692 let html = render_message(&msg, &opts).unwrap();
1693 assert!(html.contains("<details"));
1694 assert!(html.contains("Click to expand"));
1695 }
1696
1697 #[test]
1698 fn test_tool_icons_for_different_tools() {
1699 let tools_and_svg_markers = vec![
1701 ("Read", "M15 2H6a2 2 0 0 0-2 2v16"), ("Write", "M21.174 6.812"), ("Bash", "polyline points=\"4 17 10 11 4 5\""), ("Grep", "circle cx=\"11\" cy=\"11\" r=\"8\""), ("WebFetch", "circle cx=\"12\" cy=\"12\" r=\"10\""), ];
1707
1708 for (tool_name, svg_marker) in tools_and_svg_markers {
1709 let tc = ToolCall {
1710 name: tool_name.to_string(),
1711 input: "{}".to_string(),
1712 output: None,
1713 status: None,
1714 correlation_id: None,
1715 };
1716 let html = render_tool_badge(&tc, &RenderOptions::default());
1717 assert!(
1718 html.contains("lucide-icon"),
1719 "Tool {} should have lucide-icon class",
1720 tool_name
1721 );
1722 assert!(
1723 html.contains(svg_marker),
1724 "Tool {} should have SVG marker '{}', got: {}",
1725 tool_name,
1726 svg_marker,
1727 html
1728 );
1729 }
1730 }
1731
1732 #[test]
1737 fn test_truncate_to_char_boundary() {
1738 assert_eq!(truncate_to_char_boundary("hello", 3), 3);
1740 assert_eq!(truncate_to_char_boundary("hello", 10), 5);
1741
1742 let japanese = "日本語";
1745 assert_eq!(japanese.len(), 9);
1746 assert_eq!(truncate_to_char_boundary(japanese, 4), 3);
1748 assert_eq!(truncate_to_char_boundary(japanese, 6), 6);
1750 }
1751
1752 #[test]
1753 fn test_long_message_collapse_utf8_safe() {
1754 let content_with_emoji = "This is a message with emoji 🎉🎊🎈 ".repeat(50);
1756 let msg = test_message("user", &content_with_emoji);
1757 let opts = RenderOptions {
1758 collapse_threshold: 100,
1759 ..Default::default()
1760 };
1761
1762 let html = render_message(&msg, &opts).unwrap();
1764 assert!(html.contains("<details"));
1765 assert!(!html.is_empty());
1767 }
1768
1769 #[test]
1770 fn test_collapse_threshold_uses_character_count() {
1771 let msg = test_message("user", &"é".repeat(60));
1773 let opts = RenderOptions {
1774 collapse_threshold: 100,
1775 ..Default::default()
1776 };
1777
1778 let html = render_message(&msg, &opts).unwrap();
1780 assert!(
1781 !html.contains("<details"),
1782 "message should not collapse when char count is below threshold"
1783 );
1784 }
1785
1786 #[test]
1787 fn test_tool_output_with_unicode_renders_safely() {
1788 let long_output_with_unicode = "结果: ".repeat(5000); let msg = Message {
1792 role: "tool".to_string(),
1793 content: "Tool result".to_string(),
1794 timestamp: None,
1795 tool_call: Some(ToolCall {
1796 name: "Test".to_string(),
1797 input: "{}".to_string(),
1798 output: Some(long_output_with_unicode),
1799 status: Some(ToolStatus::Success),
1800 correlation_id: None,
1801 }),
1802 index: None,
1803 author: None,
1804 };
1805
1806 let html = render_message(&msg, &RenderOptions::default()).unwrap();
1808 assert!(html.contains("tool-badge"));
1810 assert!(html.contains("tool-popover-section"));
1811 assert!(html.contains("结果"));
1813 }
1814
1815 #[test]
1816 fn test_format_timestamp_utf8_safe() {
1817 let weird_ts = "2026-01-25T12:30:00日本語";
1819 let formatted = format_timestamp(weird_ts);
1820 assert!(!formatted.is_empty());
1822 }
1823
1824 fn test_tool_call(name: &str) -> ToolCall {
1829 ToolCall {
1830 name: name.to_string(),
1831 input: r#"{"test": "input"}"#.to_string(),
1832 output: Some("test output".to_string()),
1833 status: Some(ToolStatus::Success),
1834 correlation_id: None,
1835 }
1836 }
1837
1838 fn test_tool_call_with_result(name: &str, status: ToolStatus) -> ToolCallWithResult {
1839 let call = test_tool_call(name);
1840 let result = ToolResult::new(name, "test output", status);
1841 ToolCallWithResult::new(call).with_result(result)
1842 }
1843
1844 #[test]
1845 fn test_render_message_group_user() {
1846 let msg = test_message("user", "Hello, assistant!");
1847 let group = MessageGroup::user(msg);
1848 let opts = RenderOptions::default();
1849 let html = render_message_group(&group, 0, &opts).unwrap();
1850
1851 assert!(html.contains("message-user"));
1852 assert!(html.contains("Hello, assistant!"));
1853 assert!(html.contains(r#"role="article""#));
1854 assert!(html.contains("lucide-icon")); }
1856
1857 #[test]
1858 fn test_render_message_group_assistant_with_tools() {
1859 let msg = test_message("assistant", "Let me read that file.");
1860 let mut group = MessageGroup::assistant(msg);
1861
1862 group.add_tool_call(test_tool_call("Read"), Some("toolu_abc123".to_string()));
1864 group.add_tool_result(
1865 ToolResult::new("Read", "file contents here", ToolStatus::Success)
1866 .with_correlation_id("toolu_abc123"),
1867 );
1868
1869 let opts = RenderOptions::default();
1870 let html = render_message_group(&group, 0, &opts).unwrap();
1871
1872 assert!(html.contains("message-assistant"));
1873 assert!(html.contains("Let me read that file."));
1874 assert!(html.contains("tool-badge")); assert!(html.contains("Read")); assert!(html.contains(r#"role="group""#)); assert!(html.contains("aria-label")); }
1879
1880 #[test]
1881 fn test_tool_result_uses_exact_correlation_before_name_fallback() {
1882 let msg = test_message("assistant", "Reading two files.");
1883 let mut group = MessageGroup::assistant(msg);
1884 group.add_tool_call(test_tool_call("Read"), Some("toolu_first".to_string()));
1885 group.add_tool_call(test_tool_call("Read"), Some("toolu_second".to_string()));
1886
1887 group.add_tool_result(
1888 ToolResult::new("Read", "second file contents", ToolStatus::Success)
1889 .with_correlation_id("toolu_second"),
1890 );
1891
1892 assert!(
1893 group.tool_calls[0].result.is_none(),
1894 "correlated result must not attach to the first same-name tool call"
1895 );
1896 assert_eq!(
1897 group.tool_calls[1]
1898 .result
1899 .as_ref()
1900 .map(|result| result.content.as_str()),
1901 Some("second file contents")
1902 );
1903 }
1904
1905 #[test]
1906 fn test_mismatched_correlated_tool_result_does_not_fall_back_by_name() {
1907 let msg = test_message("assistant", "Reading a file.");
1908 let mut group = MessageGroup::assistant(msg);
1909 group.add_tool_call(test_tool_call("Read"), Some("toolu_expected".to_string()));
1910
1911 group.add_tool_result(
1912 ToolResult::new("Read", "wrong file contents", ToolStatus::Success)
1913 .with_correlation_id("toolu_other"),
1914 );
1915
1916 assert!(
1917 group.tool_calls[0].result.is_none(),
1918 "a result with an explicit mismatched provider ID must not attach by name"
1919 );
1920 }
1921
1922 #[test]
1923 fn test_tool_call_with_result_preserves_call_correlation_id() {
1924 let mut call = test_tool_call("Read");
1925 call.correlation_id = Some("toolu_from_call".to_string());
1926
1927 let tool = ToolCallWithResult::new(call);
1928
1929 assert_eq!(tool.correlation_id.as_deref(), Some("toolu_from_call"));
1930 }
1931
1932 #[test]
1933 fn test_render_message_group_multiple_tools() {
1934 let msg = test_message("assistant", "I'll run several commands.");
1935 let mut group = MessageGroup::assistant(msg);
1936
1937 let tools = ["Bash", "Read", "Write"];
1939 for (i, name) in tools.iter().enumerate() {
1940 group.add_tool_call(test_tool_call(name), Some(format!("toolu_{}", i)));
1941 }
1942
1943 let opts = RenderOptions::default();
1944 let html = render_message_group(&group, 0, &opts).unwrap();
1945
1946 for tool_name in tools {
1948 assert!(
1949 html.contains(tool_name),
1950 "Should contain badge for {}",
1951 tool_name
1952 );
1953 }
1954 assert!(html.contains("with 3 tool calls")); }
1956
1957 #[test]
1958 fn test_render_tool_badges_overflow() {
1959 let tool_names = [
1961 "Read", "Write", "Bash", "Glob", "Grep", "WebFetch", "Task", "Search",
1962 ];
1963 let tools: Vec<ToolCallWithResult> = tool_names
1964 .iter()
1965 .map(|name| test_tool_call_with_result(name, ToolStatus::Success))
1966 .collect();
1967
1968 let opts = RenderOptions::default();
1969 let (html, overflow) = render_tool_badges_with_overflow(&tools, &opts);
1970
1971 assert!(overflow > 0, "Should have overflow");
1973 assert_eq!(overflow, tools.len() - MAX_VISIBLE_BADGES);
1974 for name in tool_names {
1975 assert!(html.contains(name), "overflow HTML should retain {name}");
1976 }
1977 assert_eq!(html.matches("tool-overflow-extra").count(), overflow);
1978
1979 assert!(html.contains("tool-overflow"));
1981 assert!(html.contains(&format!("+{}", overflow)));
1982 }
1983
1984 #[test]
1985 fn test_render_tool_badges_no_overflow() {
1986 let tools: Vec<ToolCallWithResult> = ["Read", "Write", "Bash"]
1987 .iter()
1988 .map(|name| test_tool_call_with_result(name, ToolStatus::Success))
1989 .collect();
1990
1991 let opts = RenderOptions::default();
1992 let (html, overflow) = render_tool_badges_with_overflow(&tools, &opts);
1993
1994 assert_eq!(overflow, 0);
1995 assert!(!html.contains("tool-overflow"));
1996 assert!(html.contains("Read"));
1997 assert!(html.contains("Write"));
1998 assert!(html.contains("Bash"));
1999 }
2000
2001 #[test]
2002 fn test_render_single_tool_badge_success() {
2003 let tool = test_tool_call_with_result("Bash", ToolStatus::Success);
2004 let html = render_single_tool_badge(&tool, false);
2005
2006 assert!(html.contains("tool-badge"));
2007 assert!(html.contains("tool-status-success"));
2008 assert!(html.contains("Bash"));
2009 assert!(html.contains(r#"aria-label="Bash: success""#));
2010 assert!(html.contains("lucide-icon")); }
2012
2013 #[test]
2014 fn test_render_single_tool_badge_error() {
2015 let tool = test_tool_call_with_result("Bash", ToolStatus::Error);
2016 let html = render_single_tool_badge(&tool, false);
2017
2018 assert!(html.contains("tool-status-error"));
2019 assert!(html.contains(r#"aria-label="Bash: error""#));
2020 }
2021
2022 #[test]
2023 fn test_render_single_tool_badge_with_inline_popover() {
2024 let tool = test_tool_call_with_result("Read", ToolStatus::Success);
2025 let html = render_single_tool_badge(&tool, false);
2026
2027 assert!(html.contains(r#"data-tool-name="Read""#));
2028 assert!(html.contains("tool-popover"));
2029 assert!(html.contains("tool-popover-label"));
2030 }
2031
2032 #[test]
2033 fn test_render_single_tool_badge_can_mark_overflow_extra() {
2034 let tool = test_tool_call_with_result("Search", ToolStatus::Success);
2035 let html = render_single_tool_badge(&tool, true);
2036
2037 assert!(html.contains(r#"class="tool-badge tool-status-success tool-overflow-extra""#));
2038 assert!(html.contains(r#"data-tool-name="Search""#));
2039 }
2040
2041 #[test]
2042 fn test_get_tool_lucide_icon() {
2043 assert!(get_tool_lucide_icon("Bash").contains("polyline")); assert!(get_tool_lucide_icon("Read").contains("M15 2H6")); assert!(get_tool_lucide_icon("Write").contains("M21.174")); assert!(get_tool_lucide_icon("Glob").contains("circle cx=\"11\"")); assert!(get_tool_lucide_icon("WebFetch").contains("circle cx=\"12\" cy=\"12\" r=\"10\"")); assert!(get_tool_lucide_icon("mcp__mcp-agent-mail__send").contains("rect width=\"20\"")); assert!(get_tool_lucide_icon("unknown_tool").contains("path d=\"M14.7 6.3")); }
2052
2053 #[test]
2054 fn test_render_message_groups_empty() {
2055 let groups: Vec<MessageGroup> = vec![];
2056 let opts = RenderOptions::default();
2057 let html = render_message_groups(&groups, &opts).unwrap();
2058
2059 assert!(html.is_empty() || !html.contains("conversation-messages"));
2061 }
2062
2063 #[test]
2064 fn test_render_message_groups_with_agent_class() {
2065 let groups = vec![
2066 MessageGroup::user(test_message("user", "Hello")),
2067 MessageGroup::assistant(test_message("assistant", "Hi there")),
2068 ];
2069 let opts = RenderOptions {
2070 agent_slug: Some("claude_code".to_string()),
2071 ..Default::default()
2072 };
2073 let html = render_message_groups(&groups, &opts).unwrap();
2074
2075 assert!(html.contains("agent-claude"));
2076 assert!(html.contains("conversation-messages"));
2077 assert!(html.contains("message-user"));
2078 assert!(html.contains("message-assistant"));
2079 }
2080
2081 #[test]
2082 fn test_render_message_group_system() {
2083 let msg = test_message("system", "You are a helpful assistant.");
2084 let group = MessageGroup::system(msg);
2085 let opts = RenderOptions::default();
2086 let html = render_message_group(&group, 0, &opts).unwrap();
2087
2088 assert!(html.contains("message-system"));
2089 assert!(html.contains("System")); assert!(html.contains("You are a helpful assistant."));
2091 }
2092
2093 #[test]
2094 fn test_render_message_group_tool_only() {
2095 let msg = test_message("tool", "Tool result content");
2096 let group = MessageGroup::tool_only(msg);
2097 let opts = RenderOptions::default();
2098 let html = render_message_group(&group, 0, &opts).unwrap();
2099
2100 assert!(html.contains("message-tool"));
2101 }
2102
2103 #[test]
2104 fn test_render_message_group_with_timestamp() {
2105 let mut msg = test_message("user", "Test message");
2106 msg.timestamp = Some("2026-01-25T14:30:00Z".to_string());
2107 let group = MessageGroup::user(msg);
2108
2109 let opts = RenderOptions {
2110 show_timestamps: true,
2111 ..Default::default()
2112 };
2113 let html = render_message_group(&group, 0, &opts).unwrap();
2114
2115 assert!(html.contains("<time"));
2116 assert!(html.contains("datetime="));
2117 assert!(html.contains("2026-01-25"));
2118 }
2119
2120 #[test]
2121 fn test_render_message_group_without_timestamps() {
2122 let mut msg = test_message("user", "Test message");
2123 msg.timestamp = Some("2026-01-25T14:30:00Z".to_string());
2124 let group = MessageGroup::user(msg);
2125
2126 let opts = RenderOptions {
2127 show_timestamps: false,
2128 ..Default::default()
2129 };
2130 let html = render_message_group(&group, 0, &opts).unwrap();
2131
2132 assert!(!html.contains("<time"));
2133 }
2134
2135 #[test]
2136 fn test_render_message_group_tool_badges_hidden_when_disabled() {
2137 let msg = test_message("assistant", "Let me check that file.");
2138 let mut group = MessageGroup::assistant(msg);
2139 group.add_tool_call(test_tool_call("Read"), None);
2140
2141 let opts = RenderOptions {
2142 show_tool_calls: false,
2143 ..Default::default()
2144 };
2145 let html = render_message_group(&group, 0, &opts).unwrap();
2146
2147 assert!(!html.contains("tool-badge"));
2148 }
2149
2150 #[test]
2151 fn test_render_message_group_with_collapse() {
2152 let long_content = "x".repeat(2000);
2153 let msg = test_message("user", &long_content);
2154 let group = MessageGroup::user(msg);
2155
2156 let opts = RenderOptions {
2157 collapse_threshold: 1000,
2158 ..Default::default()
2159 };
2160 let html = render_message_group(&group, 0, &opts).unwrap();
2161
2162 assert!(html.contains("<details"));
2163 assert!(html.contains("message-collapse"));
2164 assert!(html.contains("Click to expand"));
2165 }
2166
2167 #[test]
2168 fn test_render_message_group_anchors() {
2169 let mut msg = test_message("user", "Test message");
2170 msg.index = Some(42);
2171 let group = MessageGroup::user(msg);
2172 let opts = RenderOptions::default();
2173 let html = render_message_group(&group, 0, &opts).unwrap();
2174
2175 assert!(html.contains(r#"id="msg-42""#));
2176 }
2177
2178 #[test]
2179 fn test_render_message_group_uses_fallback_index() {
2180 let msg = test_message("user", "Test message");
2182 let group = MessageGroup::user(msg);
2183 let opts = RenderOptions::default();
2184 let html = render_message_group(&group, 5, &opts).unwrap();
2185
2186 assert!(html.contains(r#"id="msg-5""#));
2187 }
2188
2189 #[test]
2190 fn test_tool_badge_preserves_full_input_in_popover() {
2191 let long_input = r#"{"command": ""#.to_owned() + &"x".repeat(500) + r#""}"#;
2192 let mut call = test_tool_call("Bash");
2193 call.input = long_input;
2194 let tool = ToolCallWithResult::new(call);
2195 let html = render_single_tool_badge(&tool, false);
2196
2197 assert!(html.contains("tool-popover-section"));
2199 assert!(html.contains(&"x".repeat(100))); }
2201
2202 #[test]
2203 fn test_tool_badge_accessibility() {
2204 let tool = test_tool_call_with_result("Read", ToolStatus::Success);
2205 let html = render_single_tool_badge(&tool, false);
2206
2207 assert!(html.contains("<button"));
2209 assert!(html.contains("</button>"));
2210 assert!(html.contains("aria-label="));
2212 assert!(html.contains("aria-expanded="));
2214 }
2215
2216 #[test]
2217 fn test_render_message_groups_all_roles() {
2218 let groups = vec![
2219 MessageGroup::user(test_message("user", "User message")),
2220 MessageGroup::assistant(test_message("assistant", "Assistant response")),
2221 MessageGroup::system(test_message("system", "System context")),
2222 MessageGroup::tool_only(test_message("tool", "Tool result")),
2223 ];
2224 let opts = RenderOptions::default();
2225 let html = render_message_groups(&groups, &opts).unwrap();
2226
2227 assert!(html.contains("message-user"));
2228 assert!(html.contains("message-assistant"));
2229 assert!(html.contains("message-system"));
2230 assert!(html.contains("message-tool"));
2231 }
2232}