Skip to main content

coding_agent_search/html_export/
renderer.rs

1//! Conversation to HTML rendering.
2//!
3//! Converts session messages into semantic HTML markup with proper
4//! role-based styling, agent-specific theming, and syntax highlighting support.
5//!
6//! # Features
7//!
8//! - **Role-based styling**: User, assistant, tool, and system messages
9//! - **Agent-specific theming**: Visual differentiation for supported agents
10//! - **Code blocks**: Syntax highlighting with Prism.js language classes
11//! - **Tool calls**: Collapsible details with formatted JSON
12//! - **Long message collapse**: Optional folding for lengthy content
13//! - **XSS prevention**: All user content is properly escaped
14//! - **Accessible**: Semantic HTML with ARIA attributes
15
16use 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/// Errors that can occur during rendering.
24#[derive(Debug, thiserror::Error)]
25pub enum RenderError {
26    /// Invalid message data
27    #[error("invalid message: {0}")]
28    InvalidMessage(String),
29    /// Content parsing failed
30    #[error("parse error: {0}")]
31    ParseError(String),
32}
33
34/// Options for rendering conversations.
35#[derive(Debug, Clone)]
36pub struct RenderOptions {
37    /// Show message timestamps
38    pub show_timestamps: bool,
39
40    /// Show tool call details
41    pub show_tool_calls: bool,
42
43    /// Enable syntax highlighting markers (for Prism.js)
44    pub syntax_highlighting: bool,
45
46    /// Wrap long lines in code blocks
47    pub wrap_code: bool,
48
49    /// Collapse messages longer than this threshold (characters)
50    /// Set to 0 to disable collapsing
51    pub collapse_threshold: usize,
52
53    /// Maximum lines to show in collapsed code blocks preview
54    pub code_preview_lines: usize,
55
56    /// Agent slug for agent-specific styling
57    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, // Disabled by default
68            code_preview_lines: 20,
69            agent_slug: None,
70        }
71    }
72}
73
74/// A message to render.
75#[derive(Debug, Clone)]
76pub struct Message {
77    /// Role: user, assistant, tool, system
78    pub role: String,
79
80    /// Message content (may contain markdown)
81    pub content: String,
82
83    /// Optional timestamp (ISO 8601)
84    pub timestamp: Option<String>,
85
86    /// Optional tool call information
87    pub tool_call: Option<ToolCall>,
88
89    /// Optional message index for anchoring
90    pub index: Option<usize>,
91
92    /// Optional author name (for multi-participant sessions)
93    pub author: Option<String>,
94}
95
96/// Tool call information.
97#[derive(Debug, Clone)]
98pub struct ToolCall {
99    /// Tool name (e.g., "Bash", "Read", "Write")
100    pub name: String,
101
102    /// Tool input/arguments (usually JSON)
103    pub input: String,
104
105    /// Tool output/result
106    pub output: Option<String>,
107
108    /// Execution status (success, error, etc.)
109    pub status: Option<ToolStatus>,
110
111    /// Provider correlation ID linking a tool call to its later result.
112    pub correlation_id: Option<String>,
113}
114
115/// Status of a tool execution.
116#[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// ============================================
150// Message Grouping Types for Consolidated Rendering
151// ============================================
152/// Type of message group for rendering decisions.
153///
154/// Determines how a group of related messages should be styled and displayed.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum MessageGroupType {
157    /// User-initiated message (question, instruction, etc.)
158    User,
159    /// Assistant/agent response with potential tool calls
160    Assistant,
161    /// System message (context, instructions)
162    System,
163    /// Orphan tool calls without a parent assistant message
164    ToolOnly,
165}
166
167impl MessageGroupType {
168    /// Get the role icon for this group type.
169    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/// Extended tool result with status and content.
180///
181/// Represents the output from a tool execution, paired with metadata
182/// for correlation and status tracking.
183#[derive(Debug, Clone)]
184pub struct ToolResult {
185    /// Tool name this result responds to.
186    pub tool_name: String,
187    /// Result content (may be truncated for display).
188    pub content: String,
189    /// Execution status.
190    pub status: ToolStatus,
191    /// Correlation ID to match with the originating call (e.g., tool_use_id).
192    pub correlation_id: Option<String>,
193}
194
195impl ToolResult {
196    /// Create a new tool result.
197    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    /// Set the correlation ID for matching with tool calls.
211    pub fn with_correlation_id(mut self, id: impl Into<String>) -> Self {
212        self.correlation_id = Some(id.into());
213        self
214    }
215
216    /// Check if this result indicates an error.
217    pub fn is_error(&self) -> bool {
218        self.status == ToolStatus::Error
219    }
220}
221
222/// Tool call paired with its result for correlation.
223///
224/// Keeps a tool invocation together with its response, enabling
225/// consolidated rendering of the complete tool interaction.
226#[derive(Debug, Clone)]
227pub struct ToolCallWithResult {
228    /// The original tool call.
229    pub call: ToolCall,
230    /// The result (if received).
231    pub result: Option<ToolResult>,
232    /// Correlation ID (tool_use_id in Claude format).
233    pub correlation_id: Option<String>,
234}
235
236impl ToolCallWithResult {
237    /// Create a new tool call without a result yet.
238    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    /// Set the correlation ID.
248    pub fn with_correlation_id(mut self, id: impl Into<String>) -> Self {
249        self.correlation_id = Some(id.into());
250        self
251    }
252
253    /// Attach a result to this tool call.
254    pub fn with_result(mut self, result: ToolResult) -> Self {
255        self.result = Some(result);
256        self
257    }
258
259    /// Check if this tool call has a result.
260    pub fn has_result(&self) -> bool {
261        self.result.is_some()
262    }
263
264    /// Check if the tool call resulted in an error.
265    pub fn is_error(&self) -> bool {
266        self.result.as_ref().is_some_and(|r| r.is_error())
267    }
268
269    /// Get the effective status (from result or call).
270    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/// A group of related messages for consolidated rendering.
280///
281/// Represents a logical unit of conversation: a primary message (user question
282/// or assistant response) along with all associated tool calls and their results.
283/// This enables rendering an entire interaction as a cohesive block rather than
284/// separate messages.
285#[derive(Debug, Clone)]
286pub struct MessageGroup {
287    /// Group type for rendering decisions.
288    pub group_type: MessageGroupType,
289    /// The primary message (user or assistant text).
290    pub primary: Message,
291    /// Tool calls paired with their results.
292    pub tool_calls: Vec<ToolCallWithResult>,
293    /// Timestamp of the first message/action in this group.
294    pub start_timestamp: Option<String>,
295    /// Timestamp of the last message/action in this group.
296    pub end_timestamp: Option<String>,
297}
298
299impl MessageGroup {
300    /// Create a new message group with a primary message.
301    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    /// Create a user message group.
314    pub fn user(primary: Message) -> Self {
315        Self::new(primary, MessageGroupType::User)
316    }
317
318    /// Create an assistant message group.
319    pub fn assistant(primary: Message) -> Self {
320        Self::new(primary, MessageGroupType::Assistant)
321    }
322
323    /// Create a system message group.
324    pub fn system(primary: Message) -> Self {
325        Self::new(primary, MessageGroupType::System)
326    }
327
328    /// Create a tool-only group (orphan tool calls).
329    pub fn tool_only(primary: Message) -> Self {
330        Self::new(primary, MessageGroupType::ToolOnly)
331    }
332
333    /// Add a tool call to this group.
334    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    /// Add a tool result, matching it with an existing call by correlation ID.
348    ///
349    /// If a matching call is found, the result is attached to it.
350    /// If no match is found, the result is dropped with a warning.
351    pub fn add_tool_result(&mut self, result: ToolResult) {
352        // Try to match by correlation ID first
353        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        // Fall back to matching by tool name (first unmatched call)
374        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    /// Update the end timestamp if the given timestamp is later.
393    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    /// Get the number of tool calls in this group.
408    pub fn tool_count(&self) -> usize {
409        self.tool_calls.len()
410    }
411
412    /// Check if any tool call in this group resulted in an error.
413    pub fn has_errors(&self) -> bool {
414        self.tool_calls.iter().any(|tc| tc.is_error())
415    }
416
417    /// Check if all tool calls have results.
418    pub fn all_tools_complete(&self) -> bool {
419        self.tool_calls.iter().all(|tc| tc.has_result())
420    }
421
422    /// Get a summary of tool call statuses for display.
423    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
438// ============================================
439// Lucide SVG Icons (16x16, stroke-width: 2)
440// ============================================
441
442/// User icon - for user messages
443const 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
445/// Bot icon - for assistant messages
446const 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
448/// Wrench icon - for tool messages
449const 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
451/// Settings icon - for system messages
452const 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
454/// Message square icon - fallback
455const 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
457/// Terminal icon - for bash/shell
458const 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
460/// File text icon - for read
461const 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
463/// Pencil icon - for write/edit
464const 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
466/// Search icon - for glob/grep/search
467const 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
469/// Globe icon - for web fetch
470const 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
472/// Check icon - for success status
473const 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
475/// X icon - for error status
476const 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
478/// Loader icon - for pending status
479const 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
481/// Mail icon - for MCP agent mail
482const 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
484/// Database icon - for data operations
485const 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
487/// Sparkles icon - for AI/task operations
488const 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
490/// Get the CSS class for an agent slug.
491///
492/// Maps agent identifiers to their visual styling class.
493pub 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
518/// Get human-readable agent name.
519pub 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
550// ============================================================================
551// MessageGroup Rendering (Consolidated Tool Calls)
552// ============================================================================
553
554/// Maximum number of tool badges to show before overflow indicator.
555const MAX_VISIBLE_BADGES: usize = 6;
556
557/// Render a list of message groups to HTML (consolidated rendering).
558///
559/// This is the preferred rendering method when messages have been grouped
560/// via `group_messages_for_export()`. Each group renders as a single article
561/// with all associated tool calls shown as compact badges.
562pub 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    // Add agent-specific class to conversation wrapper if specified
570    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
613/// Render a single message group to HTML.
614///
615/// A message group consists of:
616/// - A primary message (user/assistant/system)
617/// - Zero or more associated tool calls with their results
618///
619/// The output is a single `<article>` element with tool badges in the header.
620fn 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    // Role class based on group type
636    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    // Role icon
644    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    // Author display
652    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    // Message anchor
665    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    // Timestamp
673    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    // Render content
688    let content_html = render_content(&group.primary.content, options);
689
690    // Render tool badges with overflow handling
691    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    // ARIA label for the article
699    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    // Check for content collapse
711    let content_bytes = group.primary.content.len();
712    let mut content_chars = 0; // Calculated lazily
713    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                // We know it exceeds, but we need the full count for display
722                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    // Only render content div if there's actual content
751    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    // Tool badges container with accessibility
766    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
815/// Render tool badges with overflow handling.
816///
817/// When there are more than `MAX_VISIBLE_BADGES` tool calls, shows the first N
818/// badges plus a "+X more" overflow indicator.
819fn 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        // Render all badges
829        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        // Render all badges so the overflow control can reveal the extra tools.
837        // The extra badges are hidden by CSS until the header is expanded.
838        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
864/// Render a single tool badge as a button with Lucide SVG icon.
865fn 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    // Format input/output for popover (full content, pretty-printed if JSON)
878    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
936/// Get the appropriate Lucide SVG icon for a tool by name.
937fn 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
951/// Render a single message to HTML.
952pub 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    // Role class for semantic styling (matches styles.rs)
965    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    // Message anchor for deep linking
974    let anchor_id = message
975        .index
976        .map(|idx| format!(r#" id="msg-{}""#, idx))
977        .unwrap_or_default();
978
979    // Author display (falls back to role)
980    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    // Check if message should be collapsed
1003    let content_bytes = message.content.len();
1004    let mut content_chars = 0; // Calculated lazily
1005    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                // We know it exceeds, but we need the full count for display
1014                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        // Safe truncation at char boundary to avoid panic on multi-byte UTF-8.
1032        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    // Tool badges rendered as compact icons in header (upper-right)
1052    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    // Role icon for visual differentiation - using Lucide SVG icons
1063    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    // Only render content div if there's actual content
1072    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
1121/// Format role for display.
1122fn 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
1132/// Render message content, converting markdown to HTML using pulldown-cmark.
1133/// Raw HTML in the input is escaped for security (XSS prevention).
1134fn render_content(content: &str, _options: &RenderOptions) -> String {
1135    use pulldown_cmark::{Event, Tag};
1136
1137    // Configure pulldown-cmark with all common extensions
1138    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    // Parse markdown and filter out raw HTML for security
1146    let parser = Parser::new_ext(content, opts).map(|event| match event {
1147        // Convert raw HTML to escaped text (XSS prevention)
1148        Event::Html(html) => Event::Text(html),
1149        Event::InlineHtml(html) => Event::Text(html),
1150        // Sanitize link destinations to prevent javascript:/vbscript:/data: XSS
1151        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        // Pass through all other events
1174        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    // Quick check: if it doesn't contain ':', it can't be a dangerous scheme.
1186    // This avoids allocation for most common URLs (http://, https://, or relative paths).
1187    if !trimmed.contains(':') {
1188        return dest_url;
1189    }
1190
1191    // Schemes like javascript: are short. We only need to check the beginning.
1192    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
1213/// Render a compact tool badge with hover popover for the message header.
1214fn 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    // Status indicator - get CSS class and SVG icon
1226    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    // Format input as pretty JSON if possible
1233    let formatted_input = format_json_or_raw(&tool_call.input);
1234
1235    // Tool icon based on name - using Lucide SVG icons
1236    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    // Suppress unused warning for options - may be used for future customization
1249    let _ = options;
1250
1251    // Preserve full input/output for popover display (no truncation)
1252    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    // Build popover content
1261    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    // Compact badge with hover popover - using SVG icons
1280    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
1316/// Try to format as pretty JSON, otherwise return raw.
1317fn format_json_or_raw(s: &str) -> String {
1318    // Try to parse as JSON and pretty print
1319    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
1327/// Format a timestamp for display.
1328fn format_timestamp(ts: &str) -> String {
1329    // Simple formatting: "2024-01-15T10:30:00Z" -> "2024-01-15 10:30:00"
1330    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/// Find the largest byte index <= `max_bytes` that is on a UTF-8 char boundary.
1343#[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    // Walk backwards from max_bytes to find a char boundary
1349    let mut end = max_bytes;
1350    while end > 0 && !s.is_char_boundary(end) {
1351        end -= 1;
1352    }
1353    end
1354}
1355
1356/// Convert a maximum character count to a safe byte index in `s`.
1357fn 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")); // SVG Lucide icon
1403        assert!(html.contains("M19 21v-2")); // User icon path
1404    }
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")); // SVG Lucide icon (bot)
1419    }
1420
1421    #[test]
1422    fn test_url_with_query_params_not_double_escaped() {
1423        // Test that URLs with & in query params are correctly escaped once, not twice.
1424        // The render_content function HTML-escapes first, then render_links processes.
1425        // If render_links re-escapes, &amp; becomes &amp;amp; (broken).
1426        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        // Should contain &amp; (single escape), NOT &amp;amp; (double escape)
1430        assert!(
1431            html.contains("https://example.com?a=1&amp;b=2"),
1432            "URL should have single-escaped ampersand. HTML: {}",
1433            html
1434        );
1435        assert!(
1436            !html.contains("&amp;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("&lt;script&gt;"));
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")); // link text preserved
1460    }
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            "![a](javascript:alert(1)) ![b](data:image/svg+xml,<svg/onload=alert(1)>)",
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", "![logo](https://example.com/logo.png)");
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("&lt;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")); // SVG icon
1636        assert!(html.contains("M20 6 9 17l-5-5")); // Check icon path (success)
1637        assert!(html.contains("polyline points=\"4 17 10 11 4 5\"")); // Terminal icon path (bash)
1638    }
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        // Valid JSON gets pretty printed
1673        let json_input = r#"{"key":"value"}"#;
1674        let formatted = format_json_or_raw(json_input);
1675        assert!(formatted.contains('\n')); // Pretty printed has newlines
1676
1677        // Invalid JSON passes through unchanged
1678        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        // Check that different tools get appropriate Lucide SVG icons
1700        let tools_and_svg_markers = vec![
1701            ("Read", "M15 2H6a2 2 0 0 0-2 2v16"), // FileText icon path
1702            ("Write", "M21.174 6.812"),           // Pencil icon path
1703            ("Bash", "polyline points=\"4 17 10 11 4 5\""), // Terminal icon
1704            ("Grep", "circle cx=\"11\" cy=\"11\" r=\"8\""), // Search icon
1705            ("WebFetch", "circle cx=\"12\" cy=\"12\" r=\"10\""), // Globe icon
1706        ];
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    // ========================================================================
1733    // UTF-8 boundary safety tests
1734    // ========================================================================
1735
1736    #[test]
1737    fn test_truncate_to_char_boundary() {
1738        // ASCII string
1739        assert_eq!(truncate_to_char_boundary("hello", 3), 3);
1740        assert_eq!(truncate_to_char_boundary("hello", 10), 5);
1741
1742        // UTF-8 multi-byte characters
1743        // "日本語" = 3 chars, 9 bytes (each char is 3 bytes)
1744        let japanese = "日本語";
1745        assert_eq!(japanese.len(), 9);
1746        // Truncating at byte 4 should back up to byte 3 (end of first char)
1747        assert_eq!(truncate_to_char_boundary(japanese, 4), 3);
1748        // Truncating at byte 6 should stay at 6 (end of second char)
1749        assert_eq!(truncate_to_char_boundary(japanese, 6), 6);
1750    }
1751
1752    #[test]
1753    fn test_long_message_collapse_utf8_safe() {
1754        // Create a message with multi-byte UTF-8 content that would panic if sliced incorrectly
1755        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        // Should not panic even though the emoji may be at the slice boundary
1763        let html = render_message(&msg, &opts).unwrap();
1764        assert!(html.contains("<details"));
1765        // The preview should be valid UTF-8 (this would fail if we sliced incorrectly)
1766        assert!(!html.is_empty());
1767    }
1768
1769    #[test]
1770    fn test_collapse_threshold_uses_character_count() {
1771        // "é" is 2 bytes in UTF-8, so this string has 60 chars but 120 bytes.
1772        let msg = test_message("user", &"é".repeat(60));
1773        let opts = RenderOptions {
1774            collapse_threshold: 100,
1775            ..Default::default()
1776        };
1777
1778        // Should NOT collapse because threshold is in characters, not bytes.
1779        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        // Create a very long tool output with multi-byte chars
1789        let long_output_with_unicode = "结果: ".repeat(5000); // Chinese characters
1790
1791        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        // Should not panic with long multi-byte output
1807        let html = render_message(&msg, &RenderOptions::default()).unwrap();
1808        // Verify we have a tool badge with full content in popover
1809        assert!(html.contains("tool-badge"));
1810        assert!(html.contains("tool-popover-section"));
1811        // Full content is preserved (no truncation) — popovers scroll
1812        assert!(html.contains("结果"));
1813    }
1814
1815    #[test]
1816    fn test_format_timestamp_utf8_safe() {
1817        // Malformed timestamp with multi-byte chars (edge case)
1818        let weird_ts = "2026-01-25T12:30:00日本語";
1819        let formatted = format_timestamp(weird_ts);
1820        // Should not panic and should produce valid output
1821        assert!(!formatted.is_empty());
1822    }
1823
1824    // ========================================================================
1825    // MessageGroup Rendering Tests
1826    // ========================================================================
1827
1828    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")); // Has role icon
1855    }
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        // Add tool calls
1863        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")); // Has tool badge
1875        assert!(html.contains("Read")); // Tool name in badge
1876        assert!(html.contains(r#"role="group""#)); // Accessibility for tool container
1877        assert!(html.contains("aria-label")); // Accessible
1878    }
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        // Add multiple tool calls
1938        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        // Should have all tool badges
1947        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")); // Aria label mentions count
1955    }
1956
1957    #[test]
1958    fn test_render_tool_badges_overflow() {
1959        // Create more tools than MAX_VISIBLE_BADGES
1960        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        // Should render all badges and hide extras until the overflow control expands them.
1972        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        // Should have overflow badge
1980        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")); // Has SVG icon
2011    }
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        // Check icon mappings
2044        assert!(get_tool_lucide_icon("Bash").contains("polyline")); // Terminal
2045        assert!(get_tool_lucide_icon("Read").contains("M15 2H6")); // FileText
2046        assert!(get_tool_lucide_icon("Write").contains("M21.174")); // Pencil
2047        assert!(get_tool_lucide_icon("Glob").contains("circle cx=\"11\"")); // Search
2048        assert!(get_tool_lucide_icon("WebFetch").contains("circle cx=\"12\" cy=\"12\" r=\"10\"")); // Globe
2049        assert!(get_tool_lucide_icon("mcp__mcp-agent-mail__send").contains("rect width=\"20\"")); // Mail
2050        assert!(get_tool_lucide_icon("unknown_tool").contains("path d=\"M14.7 6.3")); // Wrench fallback
2051    }
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        // Should just have the wrapper if agent class is set
2060        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")); // Author display
2090        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        // No message index, should use the group index
2181        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        // Inline popovers preserve full content (scrollable), no truncation
2198        assert!(html.contains("tool-popover-section"));
2199        assert!(html.contains(&"x".repeat(100))); // Full content present
2200    }
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        // Must be a button (keyboard accessible)
2208        assert!(html.contains("<button"));
2209        assert!(html.contains("</button>"));
2210        // Must have aria-label
2211        assert!(html.contains("aria-label="));
2212        // Must have aria-expanded for popover
2213        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}