1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
//! ANSI escape code → ratatui Span conversion.
//!
//! Converts ANSI color/style escape sequences into native ratatui `Span`s
//! so tool output (cargo, git, pytest, etc.) renders with proper styles
//! in the fullscreen TUI instead of showing raw escape codes.
//!
//! Uses `ansi-to-tui` v8 for parsing, with a fast path that skips
//! lines without escape codes entirely.
use ansi_to_tui::IntoText;
use ratatui::text::Span;
/// Parse ANSI escape codes in a single line into ratatui `Span`s.
///
/// Uses `ansi-to-tui` to convert escape sequences (colors, bold, etc.)
/// into native ratatui styles. Plain text without ANSI passes through
/// as a single unstyled span — zero overhead for non-colored output.
pub(crate) fn parse_ansi_spans(line: &str) -> Vec<Span<'static>> {
// Fast path: no escape codes → skip parsing entirely
if !line.contains('\x1b') {
return vec![Span::raw(line.to_string())];
}
// Parse ANSI → ratatui Text (may produce multiple Lines for embedded \n)
match line.as_bytes().into_text() {
Ok(text) => {
// Flatten all lines' spans into a single line
// (we're processing line-by-line, so typically 1 Line)
text.lines
.into_iter()
.flat_map(|l| l.spans)
.map(|s| Span::styled(s.content.into_owned(), s.style))
.collect()
}
Err(_) => {
// Fallback: strip escapes and render plain
let stripped = strip_ansi_escapes(line);
vec![Span::raw(stripped)]
}
}
}
/// Fallback ANSI stripper for malformed escape sequences.
/// Removes all `\x1b[...m` style codes.
pub(crate) fn strip_ansi_escapes(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
// Skip until 'm' or end
while let Some(&next) = chars.peek() {
chars.next();
if next == 'm' {
break;
}
}
} else {
result.push(c);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
#[test]
fn test_parse_ansi_plain_text() {
let spans = parse_ansi_spans("hello world");
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content.as_ref(), "hello world");
}
#[test]
fn test_parse_ansi_colored_text() {
// Red text: \x1b[31mERROR\x1b[0m
let input = "\x1b[31mERROR\x1b[0m: something failed";
let spans = parse_ansi_spans(input);
assert!(spans.len() >= 2, "should produce multiple spans: {spans:?}");
// First span should contain "ERROR" with red styling
assert_eq!(spans[0].content.as_ref(), "ERROR");
assert_eq!(spans[0].style.fg, Some(Color::Red));
}
#[test]
fn test_parse_ansi_no_escape_fast_path() {
// No \x1b → fast path, single raw span
let spans = parse_ansi_spans("just plain text");
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content.as_ref(), "just plain text");
}
#[test]
fn test_strip_ansi_escapes_fallback() {
let input = "\x1b[1;32mOK\x1b[0m done";
let stripped = strip_ansi_escapes(input);
assert_eq!(stripped, "OK done");
}
#[test]
fn test_strip_ansi_empty() {
assert_eq!(strip_ansi_escapes(""), "");
}
}