use ansi_to_tui::IntoText;
use ratatui::{
layout::Rect,
style::{Color, Style},
text::{Line, Span, Text},
widgets::{Block, BorderType, Borders, Paragraph, Wrap},
Frame,
};
use tmai_core::agents::AgentStatus;
use tmai_core::state::AppState;
pub struct PanePreview;
impl PanePreview {
pub fn render(frame: &mut Frame, area: Rect, state: &AppState) {
if state.selection.is_on_create_new {
let block = Block::default()
.title(" Preview ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Gray));
let paragraph = Paragraph::new(Text::from("")).block(block);
frame.render_widget(paragraph, area);
return;
}
let agent = state.selected_agent();
let (title, text) = if let Some(agent) = agent {
if agent.is_virtual {
let member_label = agent
.team_info
.as_ref()
.map(|ti| format!("{}/{}", ti.team_name, ti.member_name))
.unwrap_or_else(|| "Unknown".to_string());
let title = format!(" {} (Offline) ", member_label);
let text = Text::from(vec![
Line::from(""),
Line::from(vec![Span::styled(
" Team member not connected",
Style::default().fg(Color::DarkGray),
)]),
Line::from(""),
Line::from(vec![Span::styled(
" Pane not found — member may not have started yet or has exited.",
Style::default().fg(Color::DarkGray),
)]),
]);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray));
let paragraph = Paragraph::new(text).block(block);
frame.render_widget(paragraph, area);
return;
}
let title = format!(" {} ({}) ", agent.target, agent.agent_type.short_name());
let available_height = area.height.saturating_sub(2) as usize;
let available_width = area.width.saturating_sub(2) as usize;
let content_lines: Vec<&str> = agent.last_content_ansi.lines().collect();
let total_lines = content_lines
.iter()
.rposition(|line| !line.trim().is_empty())
.map(|i| i + 1)
.unwrap_or(0);
let scroll = state.view.preview_scroll as usize;
let line_wrap = state.line_wrap;
if line_wrap {
let trimmed_content: String = content_lines[..total_lines.min(content_lines.len())]
.iter()
.map(|line| {
if Self::is_horizontal_rule(line) {
Self::truncate_line(line, available_width)
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
let styled_text = match trimmed_content.as_str().into_text() {
Ok(text) => text,
Err(_) => Text::raw(trimmed_content),
};
let visual_lines: usize = styled_text
.lines
.iter()
.map(|line| {
let w: usize = line
.spans
.iter()
.map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
.sum();
if available_width > 0 && w > available_width {
w.div_ceil(available_width)
} else {
1
}
})
.sum();
let scroll_offset = visual_lines
.saturating_sub(available_height + scroll)
.min(u16::MAX as usize) as u16;
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Self::border_color(&agent.status)));
let paragraph = Paragraph::new(styled_text)
.block(block)
.wrap(Wrap { trim: false })
.scroll((scroll_offset, 0));
frame.render_widget(paragraph, area);
return;
}
let start = total_lines.saturating_sub(available_height + scroll);
let end = total_lines.saturating_sub(scroll);
let visible_content: String = content_lines[start..end.min(content_lines.len())]
.iter()
.map(|line| Self::truncate_line(line, available_width))
.collect::<Vec<_>>()
.join("\n");
let styled_text = match visible_content.as_str().into_text() {
Ok(text) => text,
Err(_) => Text::raw(visible_content),
};
(title, styled_text)
} else {
(
" Preview ".to_string(),
Text::from(vec![Line::from(vec![Span::styled(
"No agent selected",
Style::default().fg(Color::DarkGray),
)])]),
)
};
let border_color = agent
.map(|a| Self::border_color(&a.status))
.unwrap_or(Color::Gray);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color));
let paragraph = Paragraph::new(text).block(block);
frame.render_widget(paragraph, area);
}
fn border_color(status: &AgentStatus) -> Color {
match status {
AgentStatus::AwaitingApproval { .. } => Color::Magenta,
AgentStatus::Error { .. } => Color::Red,
AgentStatus::Processing { .. } => Color::Yellow,
AgentStatus::Offline => Color::DarkGray,
_ => Color::Gray,
}
}
fn is_horizontal_rule(line: &str) -> bool {
let mut visible_count = 0u32;
let mut rule_count = 0u32;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next();
while let Some(&next) = chars.peek() {
chars.next();
if next.is_ascii_alphabetic() {
break;
}
}
}
continue;
}
if !c.is_whitespace() {
visible_count += 1;
if matches!(
c,
'─' | '━'
| '═'
| '╌'
| '╍'
| '┄'
| '┅'
| '┈'
| '┉'
| '⎯'
| '―'
| '—'
) {
rule_count += 1;
}
}
}
visible_count >= 4 && rule_count * 5 >= visible_count * 4
}
fn truncate_line(line: &str, max_width: usize) -> String {
let mut result = String::new();
let mut current_width = 0;
let mut chars = line.chars().peekable();
let mut truncated = false;
while let Some(c) = chars.next() {
if c == '\x1b' {
result.push(c);
if chars.peek() == Some(&'[') {
result.push(chars.next().unwrap()); while let Some(&next) = chars.peek() {
result.push(chars.next().unwrap());
if next.is_ascii_alphabetic() {
break;
}
}
}
continue;
}
let char_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
if current_width + char_width > max_width.saturating_sub(1) {
result.push('…');
truncated = true;
break;
}
result.push(c);
current_width += char_width;
}
if truncated {
result.push_str("\x1b[0m");
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_truncate_line_plain() {
let long = "a".repeat(100);
let truncated = PanePreview::truncate_line(&long, 50);
assert!(truncated.contains('…'));
}
#[test]
fn test_truncate_line_with_ansi() {
let colored = "\x1b[32mgreen text\x1b[0m and more text here that is long";
let truncated = PanePreview::truncate_line(colored, 20);
assert!(truncated.contains("\x1b[32m"));
assert!(truncated.ends_with("\x1b[0m"));
}
#[test]
fn test_truncate_line_short() {
let short = "short";
let truncated = PanePreview::truncate_line(short, 50);
assert_eq!(truncated, "short");
}
fn effective_line_count(content: &str) -> usize {
let lines: Vec<&str> = content.lines().collect();
lines
.iter()
.rposition(|line| !line.trim().is_empty())
.map(|i| i + 1)
.unwrap_or(0)
}
#[test]
fn test_trailing_empty_lines_trimmed() {
let content = "line1\nline2\n\n\n\n";
assert_eq!(effective_line_count(content), 2);
}
#[test]
fn test_no_trailing_empty_lines() {
let content = "line1\nline2\nline3";
assert_eq!(effective_line_count(content), 3);
}
#[test]
fn test_all_empty_lines() {
let content = "\n\n\n";
assert_eq!(effective_line_count(content), 0);
}
#[test]
fn test_empty_content() {
let content = "";
assert_eq!(effective_line_count(content), 0);
}
#[test]
fn test_is_horizontal_rule_box_drawing() {
assert!(PanePreview::is_horizontal_rule("────────────────────"));
assert!(PanePreview::is_horizontal_rule("━━━━━━━━━━━━━━━━━━━━"));
assert!(PanePreview::is_horizontal_rule("═══════════════════"));
}
#[test]
fn test_is_horizontal_rule_with_ansi() {
assert!(PanePreview::is_horizontal_rule(
"\x1b[90m──────────────\x1b[0m"
));
}
#[test]
fn test_is_horizontal_rule_rejects_text() {
assert!(!PanePreview::is_horizontal_rule("hello world"));
assert!(!PanePreview::is_horizontal_rule("── title ──"));
assert!(!PanePreview::is_horizontal_rule("abc"));
}
#[test]
fn test_is_horizontal_rule_short() {
assert!(!PanePreview::is_horizontal_rule("──"));
}
#[test]
fn test_content_with_middle_empty_lines() {
let content = "header\n\n\n\nfooter";
assert_eq!(effective_line_count(content), 5);
}
}