use ansi_to_tui::IntoText;
use crossterm::{
QueueableCommand,
style::{self, Color},
};
use ratatui::{
style::{Color as RatatuiColor, Style},
text::{Line, Span},
};
use std::io::{self, Write};
use std::sync::{Arc, Mutex};
use termimad::MadSkin;
#[inline]
pub(crate) fn contains_ansi(text: &str) -> bool {
text.contains("\x1b[")
}
#[derive(Debug, Clone)]
pub struct SessionResult {
pub duration_ms: u64,
pub total_cost_usd: f64,
pub num_turns: u32,
pub is_error: bool,
}
pub struct PrettyStreamHandler {
stdout: io::Stdout,
verbose: bool,
text_buffer: String,
skin: MadSkin,
}
impl PrettyStreamHandler {
pub fn new(verbose: bool) -> Self {
Self {
stdout: io::stdout(),
verbose,
text_buffer: String::new(),
skin: MadSkin::default(),
}
}
fn flush_text_buffer(&mut self) {
if self.text_buffer.is_empty() {
return;
}
let rendered = self.skin.term_text(&self.text_buffer);
let _ = self.stdout.write(rendered.to_string().as_bytes());
let _ = self.stdout.flush();
self.text_buffer.clear();
}
}
impl StreamHandler for PrettyStreamHandler {
fn on_text(&mut self, text: &str) {
self.text_buffer.push_str(text);
}
fn on_tool_result(&mut self, _id: &str, output: &str) {
if self.verbose {
let _ = self
.stdout
.queue(style::SetForegroundColor(Color::DarkGrey));
let _ = self
.stdout
.write(format!(" \u{2713} {}\n", truncate(output, 200)).as_bytes());
let _ = self.stdout.queue(style::ResetColor);
let _ = self.stdout.flush();
}
}
fn on_error(&mut self, error: &str) {
let _ = self.stdout.queue(style::SetForegroundColor(Color::Red));
let _ = self
.stdout
.write(format!("\n\u{2717} Error: {}\n", error).as_bytes());
let _ = self.stdout.queue(style::ResetColor);
let _ = self.stdout.flush();
}
fn on_complete(&mut self, result: &SessionResult) {
self.flush_text_buffer();
let _ = self.stdout.write(b"\n");
let color = if result.is_error {
Color::Red
} else {
Color::Green
};
let _ = self.stdout.queue(style::SetForegroundColor(color));
let _ = self.stdout.write(
format!(
"Duration: {}ms | Cost: ${:.4} | Turns: {}\n",
result.duration_ms, result.total_cost_usd, result.num_turns
)
.as_bytes(),
);
let _ = self.stdout.queue(style::ResetColor);
let _ = self.stdout.flush();
}
fn on_tool_call(&mut self, name: &str, _id: &str, input: &serde_json::Value) {
self.flush_text_buffer();
let _ = self.stdout.queue(style::SetForegroundColor(Color::Blue));
let _ = self.stdout.write(format!("\u{2699} [{}]", name).as_bytes());
if let Some(summary) = format_tool_summary(name, input) {
let _ = self
.stdout
.queue(style::SetForegroundColor(Color::DarkGrey));
let _ = self.stdout.write(format!(" {}\n", summary).as_bytes());
} else {
let _ = self.stdout.write(b"\n");
}
let _ = self.stdout.queue(style::ResetColor);
let _ = self.stdout.flush();
}
}
pub trait StreamHandler: Send {
fn on_text(&mut self, text: &str);
fn on_tool_call(&mut self, name: &str, id: &str, input: &serde_json::Value);
fn on_tool_result(&mut self, id: &str, output: &str);
fn on_error(&mut self, error: &str);
fn on_complete(&mut self, result: &SessionResult);
}
pub struct ConsoleStreamHandler {
verbose: bool,
stdout: io::Stdout,
stderr: io::Stderr,
}
impl ConsoleStreamHandler {
pub fn new(verbose: bool) -> Self {
Self {
verbose,
stdout: io::stdout(),
stderr: io::stderr(),
}
}
}
impl StreamHandler for ConsoleStreamHandler {
fn on_text(&mut self, text: &str) {
let _ = writeln!(self.stdout, "Claude: {}", text);
}
fn on_tool_call(&mut self, name: &str, _id: &str, input: &serde_json::Value) {
match format_tool_summary(name, input) {
Some(summary) => {
let _ = writeln!(self.stdout, "[Tool] {}: {}", name, summary);
}
None => {
let _ = writeln!(self.stdout, "[Tool] {}", name);
}
}
}
fn on_tool_result(&mut self, _id: &str, output: &str) {
if self.verbose {
let _ = writeln!(self.stdout, "[Result] {}", truncate(output, 200));
}
}
fn on_error(&mut self, error: &str) {
let _ = writeln!(self.stdout, "[Error] {}", error);
let _ = writeln!(self.stderr, "[Error] {}", error);
}
fn on_complete(&mut self, result: &SessionResult) {
if self.verbose {
let _ = writeln!(
self.stdout,
"\n--- Session Complete ---\nDuration: {}ms | Cost: ${:.4} | Turns: {}",
result.duration_ms, result.total_cost_usd, result.num_turns
);
}
}
}
pub struct QuietStreamHandler;
impl StreamHandler for QuietStreamHandler {
fn on_text(&mut self, _: &str) {}
fn on_tool_call(&mut self, _: &str, _: &str, _: &serde_json::Value) {}
fn on_tool_result(&mut self, _: &str, _: &str) {}
fn on_error(&mut self, _: &str) {}
fn on_complete(&mut self, _: &SessionResult) {}
}
fn text_to_lines(text: &str) -> Vec<Line<'static>> {
if text.is_empty() {
return Vec::new();
}
let ansi_text = if contains_ansi(text) {
text.to_string()
} else {
let skin = MadSkin::default();
skin.term_text(text).to_string()
};
match ansi_text.as_str().into_text() {
Ok(parsed_text) => {
parsed_text
.lines
.into_iter()
.map(|line| {
let owned_spans: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|span| Span::styled(span.content.into_owned(), span.style))
.collect();
Line::from(owned_spans)
})
.collect()
}
Err(_) => {
text.split('\n')
.map(|line| Line::from(line.to_string()))
.collect()
}
}
}
#[derive(Clone)]
enum ContentBlock {
Text(String),
NonText(Line<'static>),
}
pub struct TuiStreamHandler {
current_text_buffer: String,
blocks: Vec<ContentBlock>,
verbose: bool,
lines: Arc<Mutex<Vec<Line<'static>>>>,
}
impl TuiStreamHandler {
pub fn new(verbose: bool) -> Self {
Self {
current_text_buffer: String::new(),
blocks: Vec::new(),
verbose,
lines: Arc::new(Mutex::new(Vec::new())),
}
}
pub fn with_lines(verbose: bool, lines: Arc<Mutex<Vec<Line<'static>>>>) -> Self {
Self {
current_text_buffer: String::new(),
blocks: Vec::new(),
verbose,
lines,
}
}
pub fn get_lines(&self) -> Vec<Line<'static>> {
self.lines.lock().unwrap().clone()
}
pub fn flush_text_buffer(&mut self) {
self.update_lines();
}
fn freeze_current_text(&mut self) {
if !self.current_text_buffer.is_empty() {
self.blocks
.push(ContentBlock::Text(self.current_text_buffer.clone()));
self.current_text_buffer.clear();
}
}
fn update_lines(&mut self) {
let mut all_lines = Vec::new();
for block in &self.blocks {
match block {
ContentBlock::Text(text) => {
all_lines.extend(text_to_lines(text));
}
ContentBlock::NonText(line) => {
all_lines.push(line.clone());
}
}
}
if !self.current_text_buffer.is_empty() {
all_lines.extend(text_to_lines(&self.current_text_buffer));
}
*self.lines.lock().unwrap() = all_lines;
}
fn add_non_text_line(&mut self, line: Line<'static>) {
self.freeze_current_text();
self.blocks.push(ContentBlock::NonText(line));
self.update_lines();
}
}
impl StreamHandler for TuiStreamHandler {
fn on_text(&mut self, text: &str) {
self.current_text_buffer.push_str(text);
self.update_lines();
}
fn on_tool_call(&mut self, name: &str, _id: &str, input: &serde_json::Value) {
let mut spans = vec![Span::styled(
format!("\u{2699} [{}]", name),
Style::default().fg(RatatuiColor::Blue),
)];
if let Some(summary) = format_tool_summary(name, input) {
spans.push(Span::styled(
format!(" {}", summary),
Style::default().fg(RatatuiColor::DarkGray),
));
}
self.add_non_text_line(Line::from(spans));
}
fn on_tool_result(&mut self, _id: &str, output: &str) {
if self.verbose {
let line = Line::from(Span::styled(
format!(" \u{2713} {}", truncate(output, 200)),
Style::default().fg(RatatuiColor::DarkGray),
));
self.add_non_text_line(line);
}
}
fn on_error(&mut self, error: &str) {
let line = Line::from(Span::styled(
format!("\n\u{2717} Error: {}", error),
Style::default().fg(RatatuiColor::Red),
));
self.add_non_text_line(line);
}
fn on_complete(&mut self, result: &SessionResult) {
self.flush_text_buffer();
self.add_non_text_line(Line::from(""));
let color = if result.is_error {
RatatuiColor::Red
} else {
RatatuiColor::Green
};
let summary = format!(
"Duration: {}ms | Cost: ${:.4} | Turns: {}",
result.duration_ms, result.total_cost_usd, result.num_turns
);
let line = Line::from(Span::styled(summary, Style::default().fg(color)));
self.add_non_text_line(line);
}
}
fn format_tool_summary(name: &str, input: &serde_json::Value) -> Option<String> {
match name {
"Read" | "Edit" | "Write" => input.get("file_path")?.as_str().map(|s| s.to_string()),
"Bash" => {
let cmd = input.get("command")?.as_str()?;
Some(truncate(cmd, 60))
}
"Grep" => input.get("pattern")?.as_str().map(|s| s.to_string()),
"Glob" => input.get("pattern")?.as_str().map(|s| s.to_string()),
"Task" => input.get("description")?.as_str().map(|s| s.to_string()),
"WebFetch" => input.get("url")?.as_str().map(|s| s.to_string()),
"WebSearch" => input.get("query")?.as_str().map(|s| s.to_string()),
"LSP" => {
let op = input.get("operation")?.as_str()?;
let file = input.get("filePath")?.as_str()?;
Some(format!("{} @ {}", op, file))
}
"NotebookEdit" => input.get("notebook_path")?.as_str().map(|s| s.to_string()),
"TodoWrite" => Some("updating todo list".to_string()),
_ => None,
}
}
fn truncate(s: &str, max_len: usize) -> String {
if s.chars().count() <= max_len {
s.to_string()
} else {
let byte_idx = s
.char_indices()
.nth(max_len)
.map(|(idx, _)| idx)
.unwrap_or(s.len());
format!("{}...", &s[..byte_idx])
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_console_handler_verbose_shows_results() {
let mut handler = ConsoleStreamHandler::new(true);
let bash_input = json!({"command": "ls -la"});
handler.on_text("Hello");
handler.on_tool_call("Bash", "tool_1", &bash_input);
handler.on_tool_result("tool_1", "output");
handler.on_complete(&SessionResult {
duration_ms: 1000,
total_cost_usd: 0.01,
num_turns: 1,
is_error: false,
});
}
#[test]
fn test_console_handler_normal_skips_results() {
let mut handler = ConsoleStreamHandler::new(false);
let read_input = json!({"file_path": "src/main.rs"});
handler.on_text("Hello");
handler.on_tool_call("Read", "tool_1", &read_input);
handler.on_tool_result("tool_1", "output"); handler.on_complete(&SessionResult {
duration_ms: 1000,
total_cost_usd: 0.01,
num_turns: 1,
is_error: false,
}); }
#[test]
fn test_quiet_handler_is_silent() {
let mut handler = QuietStreamHandler;
let empty_input = json!({});
handler.on_text("Hello");
handler.on_tool_call("Read", "tool_1", &empty_input);
handler.on_tool_result("tool_1", "output");
handler.on_error("Something went wrong");
handler.on_complete(&SessionResult {
duration_ms: 1000,
total_cost_usd: 0.01,
num_turns: 1,
is_error: false,
});
}
#[test]
fn test_truncate_helper() {
assert_eq!(truncate("short", 10), "short");
assert_eq!(truncate("this is a long string", 10), "this is a ...");
}
#[test]
fn test_truncate_utf8_boundaries() {
let with_arrows = "→→→→→→→→→→";
assert_eq!(truncate(with_arrows, 5), "→→→→→...");
let mixed = "a→b→c→d→e";
assert_eq!(truncate(mixed, 5), "a→b→c...");
let emoji = "🎉🎊🎁🎈🎄";
assert_eq!(truncate(emoji, 3), "🎉🎊🎁...");
}
#[test]
fn test_format_tool_summary_file_tools() {
assert_eq!(
format_tool_summary("Read", &json!({"file_path": "src/main.rs"})),
Some("src/main.rs".to_string())
);
assert_eq!(
format_tool_summary("Edit", &json!({"file_path": "/path/to/file.txt"})),
Some("/path/to/file.txt".to_string())
);
assert_eq!(
format_tool_summary("Write", &json!({"file_path": "output.json"})),
Some("output.json".to_string())
);
}
#[test]
fn test_format_tool_summary_bash_truncates() {
let short_cmd = json!({"command": "ls -la"});
assert_eq!(
format_tool_summary("Bash", &short_cmd),
Some("ls -la".to_string())
);
let long_cmd = json!({"command": "this is a very long command that should be truncated because it exceeds sixty characters"});
let result = format_tool_summary("Bash", &long_cmd).unwrap();
assert!(result.ends_with("..."));
assert!(result.len() <= 70); }
#[test]
fn test_format_tool_summary_search_tools() {
assert_eq!(
format_tool_summary("Grep", &json!({"pattern": "TODO"})),
Some("TODO".to_string())
);
assert_eq!(
format_tool_summary("Glob", &json!({"pattern": "**/*.rs"})),
Some("**/*.rs".to_string())
);
}
#[test]
fn test_format_tool_summary_unknown_tool_returns_none() {
assert_eq!(
format_tool_summary("UnknownTool", &json!({"some_field": "value"})),
None
);
}
#[test]
fn test_format_tool_summary_missing_field_returns_none() {
assert_eq!(
format_tool_summary("Read", &json!({"wrong_field": "value"})),
None
);
assert_eq!(format_tool_summary("Bash", &json!({})), None);
}
mod tui_stream_handler {
use super::*;
use ratatui::style::{Color, Modifier};
fn collect_lines(handler: &TuiStreamHandler) -> Vec<ratatui::text::Line<'static>> {
handler.lines.lock().unwrap().clone()
}
#[test]
fn text_creates_line_on_newline() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("hello\n");
let lines = collect_lines(&handler);
assert_eq!(
lines.len(),
1,
"termimad doesn't create trailing empty line"
);
assert_eq!(lines[0].to_string(), "hello");
}
#[test]
fn partial_text_buffering() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("hel");
handler.on_text("lo\n");
let lines = collect_lines(&handler);
let full_text: String = lines.iter().map(|l| l.to_string()).collect();
assert!(
full_text.contains("hello"),
"Combined text should contain 'hello'. Lines: {:?}",
lines
);
}
#[test]
fn tool_call_produces_formatted_line() {
let mut handler = TuiStreamHandler::new(false);
handler.on_tool_call("Read", "tool_1", &json!({"file_path": "src/main.rs"}));
let lines = collect_lines(&handler);
assert_eq!(lines.len(), 1);
let line_text = lines[0].to_string();
assert!(
line_text.contains('\u{2699}'),
"Should contain gear emoji: {}",
line_text
);
assert!(
line_text.contains("Read"),
"Should contain tool name: {}",
line_text
);
assert!(
line_text.contains("src/main.rs"),
"Should contain file path: {}",
line_text
);
}
#[test]
fn tool_result_verbose_shows_content() {
let mut handler = TuiStreamHandler::new(true);
handler.on_tool_result("tool_1", "file contents here");
let lines = collect_lines(&handler);
assert_eq!(lines.len(), 1);
let line_text = lines[0].to_string();
assert!(
line_text.contains('\u{2713}'),
"Should contain checkmark: {}",
line_text
);
assert!(
line_text.contains("file contents here"),
"Should contain result content: {}",
line_text
);
}
#[test]
fn tool_result_quiet_is_silent() {
let mut handler = TuiStreamHandler::new(false);
handler.on_tool_result("tool_1", "file contents here");
let lines = collect_lines(&handler);
assert!(
lines.is_empty(),
"verbose=false should not produce tool result output"
);
}
#[test]
fn error_produces_red_styled_line() {
let mut handler = TuiStreamHandler::new(false);
handler.on_error("Something went wrong");
let lines = collect_lines(&handler);
assert_eq!(lines.len(), 1);
let line_text = lines[0].to_string();
assert!(
line_text.contains('\u{2717}'),
"Should contain X mark: {}",
line_text
);
assert!(
line_text.contains("Error"),
"Should contain 'Error': {}",
line_text
);
assert!(
line_text.contains("Something went wrong"),
"Should contain error message: {}",
line_text
);
let first_span = &lines[0].spans[0];
assert_eq!(
first_span.style.fg,
Some(Color::Red),
"Error line should have red foreground"
);
}
#[test]
fn long_lines_preserved_without_truncation() {
let mut handler = TuiStreamHandler::new(false);
let long_string: String = "a".repeat(500) + "\n";
handler.on_text(&long_string);
let lines = collect_lines(&handler);
let total_content: String = lines.iter().map(|l| l.to_string()).collect();
let a_count = total_content.chars().filter(|c| *c == 'a').count();
assert_eq!(
a_count, 500,
"All 500 'a' chars should be preserved. Got {}",
a_count
);
assert!(
!total_content.contains("..."),
"Content should not have ellipsis truncation"
);
}
#[test]
fn multiple_lines_in_single_text_call() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("line1\nline2\nline3\n");
let lines = collect_lines(&handler);
let full_text: String = lines
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join(" ");
assert!(
full_text.contains("line1")
&& full_text.contains("line2")
&& full_text.contains("line3"),
"All lines should be present. Lines: {:?}",
lines
);
}
#[test]
fn termimad_parity_with_non_tui_mode() {
let text = "Some text before:★ Insight ─────\nKey point here";
let mut handler = TuiStreamHandler::new(false);
handler.on_text(text);
let lines = collect_lines(&handler);
assert!(
lines.len() >= 2,
"termimad should produce multiple lines. Got: {:?}",
lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
);
let full_text: String = lines.iter().map(|l| l.to_string()).collect();
assert!(
full_text.contains("★ Insight"),
"Content should contain insight marker"
);
}
#[test]
fn tool_call_flushes_text_buffer() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("partial text");
handler.on_tool_call("Read", "id", &json!({}));
let lines = collect_lines(&handler);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].to_string(), "partial text");
assert!(lines[1].to_string().contains('\u{2699}'));
}
#[test]
fn interleaved_text_and_tools_preserves_chronological_order() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("I'll start by reviewing the scratchpad.\n");
handler.on_tool_call("Read", "id1", &json!({"file_path": "scratchpad.md"}));
handler.on_text("I found the task. Now checking the code.\n");
handler.on_tool_call("Read", "id2", &json!({"file_path": "main.rs"}));
handler.on_text("Done reviewing.\n");
let lines = collect_lines(&handler);
let text1_idx = lines
.iter()
.position(|l| l.to_string().contains("reviewing the scratchpad"));
let tool1_idx = lines
.iter()
.position(|l| l.to_string().contains("scratchpad.md"));
let text2_idx = lines
.iter()
.position(|l| l.to_string().contains("checking the code"));
let tool2_idx = lines.iter().position(|l| l.to_string().contains("main.rs"));
let text3_idx = lines
.iter()
.position(|l| l.to_string().contains("Done reviewing"));
assert!(text1_idx.is_some(), "text1 should be present");
assert!(tool1_idx.is_some(), "tool1 should be present");
assert!(text2_idx.is_some(), "text2 should be present");
assert!(tool2_idx.is_some(), "tool2 should be present");
assert!(text3_idx.is_some(), "text3 should be present");
let text1_idx = text1_idx.unwrap();
let tool1_idx = tool1_idx.unwrap();
let text2_idx = text2_idx.unwrap();
let tool2_idx = tool2_idx.unwrap();
let text3_idx = text3_idx.unwrap();
assert!(
text1_idx < tool1_idx,
"text1 ({}) should come before tool1 ({}). Lines: {:?}",
text1_idx,
tool1_idx,
lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
);
assert!(
tool1_idx < text2_idx,
"tool1 ({}) should come before text2 ({}). Lines: {:?}",
tool1_idx,
text2_idx,
lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
);
assert!(
text2_idx < tool2_idx,
"text2 ({}) should come before tool2 ({}). Lines: {:?}",
text2_idx,
tool2_idx,
lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
);
assert!(
tool2_idx < text3_idx,
"tool2 ({}) should come before text3 ({}). Lines: {:?}",
tool2_idx,
text3_idx,
lines.iter().map(|l| l.to_string()).collect::<Vec<_>>()
);
}
#[test]
fn on_complete_flushes_buffer_and_shows_summary() {
let mut handler = TuiStreamHandler::new(true);
handler.on_text("final output");
handler.on_complete(&SessionResult {
duration_ms: 1500,
total_cost_usd: 0.0025,
num_turns: 3,
is_error: false,
});
let lines = collect_lines(&handler);
assert!(lines.len() >= 2, "Should have at least 2 lines");
assert_eq!(lines[0].to_string(), "final output");
let summary = lines.last().unwrap().to_string();
assert!(
summary.contains("1500"),
"Should contain duration: {}",
summary
);
assert!(
summary.contains("0.0025"),
"Should contain cost: {}",
summary
);
assert!(summary.contains('3'), "Should contain turns: {}", summary);
}
#[test]
fn on_complete_error_uses_red_style() {
let mut handler = TuiStreamHandler::new(true);
handler.on_complete(&SessionResult {
duration_ms: 1000,
total_cost_usd: 0.01,
num_turns: 1,
is_error: true,
});
let lines = collect_lines(&handler);
assert!(!lines.is_empty());
let last_line = lines.last().unwrap();
assert_eq!(
last_line.spans[0].style.fg,
Some(Color::Red),
"Error completion should have red foreground"
);
}
#[test]
fn on_complete_success_uses_green_style() {
let mut handler = TuiStreamHandler::new(true);
handler.on_complete(&SessionResult {
duration_ms: 1000,
total_cost_usd: 0.01,
num_turns: 1,
is_error: false,
});
let lines = collect_lines(&handler);
assert!(!lines.is_empty());
let last_line = lines.last().unwrap();
assert_eq!(
last_line.spans[0].style.fg,
Some(Color::Green),
"Success completion should have green foreground"
);
}
#[test]
fn tool_call_with_no_summary_shows_just_name() {
let mut handler = TuiStreamHandler::new(false);
handler.on_tool_call("UnknownTool", "id", &json!({}));
let lines = collect_lines(&handler);
assert_eq!(lines.len(), 1);
let line_text = lines[0].to_string();
assert!(line_text.contains("UnknownTool"));
}
#[test]
fn get_lines_returns_clone_of_internal_lines() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("test\n");
let lines1 = handler.get_lines();
let lines2 = handler.get_lines();
assert_eq!(lines1.len(), lines2.len());
assert_eq!(lines1[0].to_string(), lines2[0].to_string());
}
#[test]
fn markdown_bold_text_renders_with_bold_modifier() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("**important**\n");
let lines = collect_lines(&handler);
assert!(!lines.is_empty(), "Should have at least one line");
let has_bold = lines.iter().any(|line| {
line.spans.iter().any(|span| {
span.content.contains("important")
&& span.style.add_modifier.contains(Modifier::BOLD)
})
});
assert!(
has_bold,
"Should have bold 'important' span. Lines: {:?}",
lines
);
}
#[test]
fn markdown_italic_text_renders_with_italic_modifier() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("*emphasized*\n");
let lines = collect_lines(&handler);
assert!(!lines.is_empty(), "Should have at least one line");
let has_italic = lines.iter().any(|line| {
line.spans.iter().any(|span| {
span.content.contains("emphasized")
&& span.style.add_modifier.contains(Modifier::ITALIC)
})
});
assert!(
has_italic,
"Should have italic 'emphasized' span. Lines: {:?}",
lines
);
}
#[test]
fn markdown_inline_code_renders_with_distinct_style() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("`code`\n");
let lines = collect_lines(&handler);
assert!(!lines.is_empty(), "Should have at least one line");
let has_code_style = lines.iter().any(|line| {
line.spans.iter().any(|span| {
span.content.contains("code")
&& (span.style.fg.is_some() || span.style.bg.is_some())
})
});
assert!(
has_code_style,
"Should have styled 'code' span. Lines: {:?}",
lines
);
}
#[test]
fn markdown_header_renders_content() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("## Section Title\n");
let lines = collect_lines(&handler);
assert!(!lines.is_empty(), "Should have at least one line");
let has_header_content = lines.iter().any(|line| {
line.spans
.iter()
.any(|span| span.content.contains("Section Title"))
});
assert!(
has_header_content,
"Should have header content. Lines: {:?}",
lines
);
}
#[test]
fn markdown_streaming_continuity_handles_split_formatting() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("**bo");
handler.on_text("ld**\n");
let lines = collect_lines(&handler);
let has_bold = lines.iter().any(|line| {
line.spans
.iter()
.any(|span| span.style.add_modifier.contains(Modifier::BOLD))
});
assert!(
has_bold,
"Split markdown should still render bold. Lines: {:?}",
lines
);
}
#[test]
fn markdown_mixed_content_renders_correctly() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("Normal **bold** and *italic* text\n");
let lines = collect_lines(&handler);
assert!(!lines.is_empty(), "Should have at least one line");
let has_bold = lines.iter().any(|line| {
line.spans.iter().any(|span| {
span.content.contains("bold")
&& span.style.add_modifier.contains(Modifier::BOLD)
})
});
let has_italic = lines.iter().any(|line| {
line.spans.iter().any(|span| {
span.content.contains("italic")
&& span.style.add_modifier.contains(Modifier::ITALIC)
})
});
assert!(has_bold, "Should have bold span. Lines: {:?}", lines);
assert!(has_italic, "Should have italic span. Lines: {:?}", lines);
}
#[test]
fn markdown_tool_call_styling_preserved() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("**bold**\n");
handler.on_tool_call("Read", "id", &json!({"file_path": "src/main.rs"}));
let lines = collect_lines(&handler);
assert!(lines.len() >= 2, "Should have at least 2 lines");
let tool_line = lines.last().unwrap();
let has_blue = tool_line
.spans
.iter()
.any(|span| span.style.fg == Some(Color::Blue));
assert!(
has_blue,
"Tool call should preserve blue styling. Line: {:?}",
tool_line
);
}
#[test]
fn markdown_error_styling_preserved() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("**bold**\n");
handler.on_error("Something went wrong");
let lines = collect_lines(&handler);
assert!(lines.len() >= 2, "Should have at least 2 lines");
let error_line = lines.last().unwrap();
let has_red = error_line
.spans
.iter()
.any(|span| span.style.fg == Some(Color::Red));
assert!(
has_red,
"Error should preserve red styling. Line: {:?}",
error_line
);
}
#[test]
fn markdown_partial_formatting_does_not_crash() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("**unclosed bold");
handler.flush_text_buffer();
let lines = collect_lines(&handler);
let _ = lines; }
#[test]
fn ansi_green_text_produces_green_style() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("\x1b[32mgreen text\x1b[0m\n");
let lines = collect_lines(&handler);
assert!(!lines.is_empty(), "Should have at least one line");
let has_green = lines.iter().any(|line| {
line.spans
.iter()
.any(|span| span.style.fg == Some(Color::Green))
});
assert!(
has_green,
"Should have green styled span. Lines: {:?}",
lines
);
}
#[test]
fn ansi_bold_text_produces_bold_modifier() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("\x1b[1mbold text\x1b[0m\n");
let lines = collect_lines(&handler);
assert!(!lines.is_empty(), "Should have at least one line");
let has_bold = lines.iter().any(|line| {
line.spans
.iter()
.any(|span| span.style.add_modifier.contains(Modifier::BOLD))
});
assert!(has_bold, "Should have bold styled span. Lines: {:?}", lines);
}
#[test]
fn ansi_mixed_styles_preserved() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("\x1b[1;32mbold green\x1b[0m normal\n");
let lines = collect_lines(&handler);
assert!(!lines.is_empty(), "Should have at least one line");
let has_styled = lines.iter().any(|line| {
line.spans.iter().any(|span| {
span.style.fg == Some(Color::Green)
|| span.style.add_modifier.contains(Modifier::BOLD)
})
});
assert!(
has_styled,
"Should have styled span with color or bold. Lines: {:?}",
lines
);
}
#[test]
fn ansi_plain_text_renders_without_crash() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("plain text without ansi\n");
let lines = collect_lines(&handler);
assert!(!lines.is_empty(), "Should have at least one line");
let full_text: String = lines.iter().map(|l| l.to_string()).collect();
assert!(
full_text.contains("plain text"),
"Should contain the text. Lines: {:?}",
lines
);
}
#[test]
fn ansi_red_error_text_produces_red_style() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("\x1b[31mError: something failed\x1b[0m\n");
let lines = collect_lines(&handler);
assert!(!lines.is_empty(), "Should have at least one line");
let has_red = lines.iter().any(|line| {
line.spans
.iter()
.any(|span| span.style.fg == Some(Color::Red))
});
assert!(has_red, "Should have red styled span. Lines: {:?}", lines);
}
#[test]
fn ansi_cyan_text_produces_cyan_style() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("\x1b[36mcyan text\x1b[0m\n");
let lines = collect_lines(&handler);
assert!(!lines.is_empty(), "Should have at least one line");
let has_cyan = lines.iter().any(|line| {
line.spans
.iter()
.any(|span| span.style.fg == Some(Color::Cyan))
});
assert!(has_cyan, "Should have cyan styled span. Lines: {:?}", lines);
}
#[test]
fn ansi_underline_produces_underline_modifier() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("\x1b[4munderlined\x1b[0m\n");
let lines = collect_lines(&handler);
assert!(!lines.is_empty(), "Should have at least one line");
let has_underline = lines.iter().any(|line| {
line.spans
.iter()
.any(|span| span.style.add_modifier.contains(Modifier::UNDERLINED))
});
assert!(
has_underline,
"Should have underlined styled span. Lines: {:?}",
lines
);
}
#[test]
fn ansi_multiline_preserves_colors() {
let mut handler = TuiStreamHandler::new(false);
handler.on_text("\x1b[32mline 1 green\x1b[0m\n\x1b[31mline 2 red\x1b[0m\n");
let lines = collect_lines(&handler);
assert!(lines.len() >= 2, "Should have at least two lines");
let has_green = lines.iter().any(|line| {
line.spans
.iter()
.any(|span| span.style.fg == Some(Color::Green))
});
let has_red = lines.iter().any(|line| {
line.spans
.iter()
.any(|span| span.style.fg == Some(Color::Red))
});
assert!(has_green, "Should have green line. Lines: {:?}", lines);
assert!(has_red, "Should have red line. Lines: {:?}", lines);
}
}
}
#[cfg(test)]
mod ansi_detection_tests {
use super::*;
#[test]
fn contains_ansi_with_color_code() {
assert!(contains_ansi("\x1b[32mgreen\x1b[0m"));
}
#[test]
fn contains_ansi_with_bold() {
assert!(contains_ansi("\x1b[1mbold\x1b[0m"));
}
#[test]
fn contains_ansi_plain_text_returns_false() {
assert!(!contains_ansi("hello world"));
}
#[test]
fn contains_ansi_markdown_returns_false() {
assert!(!contains_ansi("**bold** and *italic*"));
}
#[test]
fn contains_ansi_empty_string_returns_false() {
assert!(!contains_ansi(""));
}
#[test]
fn contains_ansi_with_escape_in_middle() {
assert!(contains_ansi("prefix \x1b[31mred\x1b[0m suffix"));
}
}