Skip to main content

tmai_core/transcript/
renderer.rs

1//! Preview text renderer for transcript records.
2
3use super::types::TranscriptRecord;
4
5/// Truncate a string at a char boundary, respecting UTF-8
6fn truncate_chars(s: &str, max_chars: usize) -> String {
7    let truncated: String = s.chars().take(max_chars).collect();
8    if truncated.len() < s.len() {
9        format!("{}...", truncated)
10    } else {
11        truncated
12    }
13}
14
15/// Render transcript records into human-readable preview text
16pub fn render_preview(records: &[TranscriptRecord], max_lines: usize) -> String {
17    let mut lines = Vec::new();
18
19    for record in records {
20        match record {
21            TranscriptRecord::User { text } => {
22                let first_line = text.lines().next().unwrap_or(text);
23                lines.push(format!("▶ User: {}", truncate_chars(first_line, 120)));
24            }
25            TranscriptRecord::AssistantText { text } => {
26                // Show first few lines of assistant text
27                for (i, line) in text.lines().enumerate() {
28                    if i >= 5 {
29                        lines.push("  ...".to_string());
30                        break;
31                    }
32                    let truncated = truncate_chars(line, 120);
33                    if i == 0 {
34                        lines.push(format!("◀ {}", truncated));
35                    } else {
36                        lines.push(format!("  {}", truncated));
37                    }
38                }
39            }
40            TranscriptRecord::ToolUse {
41                tool_name,
42                input_summary,
43            } => {
44                if input_summary.is_empty() {
45                    lines.push(format!("  ⚙ {}", tool_name));
46                } else {
47                    lines.push(format!("  ⚙ {}: {}", tool_name, input_summary));
48                }
49            }
50            TranscriptRecord::ToolResult { output_summary } => {
51                let first_line = output_summary.lines().next().unwrap_or(output_summary);
52                lines.push(format!("  ✓ {}", truncate_chars(first_line, 100)));
53            }
54        }
55    }
56
57    // Keep only last max_lines
58    if lines.len() > max_lines {
59        let start = lines.len() - max_lines;
60        lines = lines[start..].to_vec();
61    }
62
63    lines.join("\n")
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn test_render_preview_user_and_assistant() {
72        let records = vec![
73            TranscriptRecord::User {
74                text: "Fix the bug in main.rs".to_string(),
75            },
76            TranscriptRecord::AssistantText {
77                text: "I'll look at main.rs and fix the issue.".to_string(),
78            },
79        ];
80
81        let result = render_preview(&records, 100);
82        assert!(result.contains("▶ User: Fix the bug"));
83        assert!(result.contains("◀ I'll look at main.rs"));
84    }
85
86    #[test]
87    fn test_render_preview_with_tools() {
88        let records = vec![
89            TranscriptRecord::ToolUse {
90                tool_name: "Bash".to_string(),
91                input_summary: "cargo test".to_string(),
92            },
93            TranscriptRecord::ToolResult {
94                output_summary: "test result: ok".to_string(),
95            },
96        ];
97
98        let result = render_preview(&records, 100);
99        assert!(result.contains("⚙ Bash: cargo test"));
100        assert!(result.contains("✓ test result: ok"));
101    }
102
103    #[test]
104    fn test_render_preview_max_lines() {
105        let records: Vec<TranscriptRecord> = (0..20)
106            .map(|i| TranscriptRecord::User {
107                text: format!("Message {}", i),
108            })
109            .collect();
110
111        let result = render_preview(&records, 5);
112        let line_count = result.lines().count();
113        assert_eq!(line_count, 5);
114    }
115
116    #[test]
117    fn test_render_preview_empty() {
118        let result = render_preview(&[], 100);
119        assert!(result.is_empty());
120    }
121
122    #[test]
123    fn test_truncate_chars_multibyte() {
124        // Japanese text that would panic with byte-based truncation
125        let text = "これは日本語のテストです。とても長い文章を書いています。";
126        let result = truncate_chars(text, 10);
127        assert!(result.ends_with("..."));
128        // Should not panic
129    }
130
131    #[test]
132    fn test_truncate_chars_ascii() {
133        let text = "short";
134        let result = truncate_chars(text, 120);
135        assert_eq!(result, "short");
136    }
137}