use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use crate::command::chat::markdown::markdown_to_lines;
use crate::command::chat::render::cache::bubble::wrap_md_line_in_bubble;
use crate::command::chat::render::cache::{
ASSISTANT_BUBBLE_LEFT_MARGIN, BUBBLE_MIN_WIDTH, RenderContext, THINKING_FOLDED_MAX_LINES,
USER_BUBBLE_PAD_LR,
};
use crate::util::text::{display_width, wrap_text};
pub(crate) fn parse_agent_prefix(content: &str) -> Option<(&str, &str)> {
if !content.starts_with('<') {
return None;
}
let end = content.find('>')?;
let name = &content[1..end];
if name.is_empty() {
return None;
}
let rest = content[end + 1..].trim_start();
Some((name, rest))
}
pub(crate) fn agent_name_color(name: &str) -> Color {
const PALETTE: &[Color] = &[
Color::Rgb(255, 160, 100), Color::Rgb(100, 200, 255), Color::Rgb(255, 110, 180), Color::Rgb(160, 255, 110), Color::Rgb(200, 150, 255), Color::Rgb(255, 220, 80), Color::Rgb(80, 220, 200), Color::Rgb(255, 140, 140), ];
let hash = name
.bytes()
.fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32));
PALETTE[hash as usize % PALETTE.len()]
}
pub(crate) fn render_thinking_block(reasoning: &str, ctx: &mut RenderContext<'_>) {
let lines = &mut *ctx.lines;
let theme = ctx.theme;
let bubble_max_width = ctx.bubble_max_width;
let expand = ctx.expand;
if reasoning.is_empty() {
return;
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" >> Thinking...",
Style::default()
.fg(theme.text_dim)
.add_modifier(Modifier::ITALIC),
)));
let content_w = bubble_max_width.saturating_sub(6);
let wrapped = wrap_text(reasoning, content_w);
let total = wrapped.len();
let (shown, truncated) = if !expand && total > THINKING_FOLDED_MAX_LINES {
(&wrapped[..THINKING_FOLDED_MAX_LINES], true)
} else {
(&wrapped[..], false)
};
for wrapped_line in shown {
lines.push(Line::from(Span::styled(
format!(" {}", wrapped_line),
Style::default().fg(theme.text_dim),
)));
}
if truncated {
lines.push(Line::from(Span::styled(
format!(
" … (+{} 行, Ctrl+O 展开)",
total - THINKING_FOLDED_MAX_LINES
),
Style::default()
.fg(theme.text_dim)
.add_modifier(Modifier::ITALIC),
)));
}
}
pub fn render_user_msg(
content: &str,
is_selected: bool,
actual_inner_content_width: usize,
ctx: &mut RenderContext<'_>,
) {
let lines = &mut *ctx.lines;
let theme = ctx.theme;
let bubble_max_width = ctx.bubble_max_width;
lines.push(Line::from(""));
let user_bg = if is_selected {
theme.bubble_user_selected
} else {
theme.bubble_user
};
let user_pad_lr = USER_BUBBLE_PAD_LR;
let user_content_w = bubble_max_width.saturating_sub(user_pad_lr * 2);
let mut all_wrapped_lines: Vec<String> = Vec::new();
for content_line in content.lines() {
let wrapped = wrap_text(content_line, user_content_w);
all_wrapped_lines.extend(wrapped);
}
if all_wrapped_lines.is_empty() {
all_wrapped_lines.push(String::new());
}
let actual_content_w = all_wrapped_lines
.iter()
.map(|l| display_width(l))
.max()
.unwrap_or(0);
let actual_bubble_w = (actual_content_w + user_pad_lr * 2)
.min(bubble_max_width)
.max(user_pad_lr * 2 + 1);
let actual_inner_content_w = actual_bubble_w.saturating_sub(user_pad_lr * 2);
let label_color = if is_selected {
theme.label_selected
} else {
theme.label_user
};
let label = if is_selected { "▶ You " } else { "You " };
let left_pad = actual_inner_content_width.saturating_sub(actual_bubble_w);
lines.push(Line::from(vec![
Span::raw(" ".repeat(left_pad)),
Span::styled(
label.to_string(),
Style::default()
.fg(label_color)
.add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(""));
let border_color = if is_selected {
theme.label_selected
} else {
theme.border_message
};
{
let dash_w = actual_inner_content_w + user_pad_lr * 2;
let pad = actual_inner_content_width.saturating_sub(dash_w + 2);
lines.push(Line::from(vec![
Span::raw(" ".repeat(pad)),
Span::styled("╭", Style::default().fg(border_color).bg(user_bg)),
Span::styled(
"─".repeat(dash_w),
Style::default().fg(border_color).bg(user_bg),
),
Span::styled("╮", Style::default().fg(border_color).bg(user_bg)),
]));
}
for wl in &all_wrapped_lines {
let wl_width = display_width(wl);
let fill = actual_inner_content_w.saturating_sub(wl_width);
let pad =
actual_inner_content_width.saturating_sub(actual_inner_content_w + user_pad_lr * 2 + 2);
lines.push(Line::from(vec![
Span::raw(" ".repeat(pad)),
Span::styled("│", Style::default().fg(border_color).bg(user_bg)),
Span::styled(" ".repeat(user_pad_lr), Style::default().bg(user_bg)),
Span::styled(
wl.clone(),
Style::default().fg(theme.text_white).bg(user_bg),
),
Span::styled(" ".repeat(fill), Style::default().bg(user_bg)),
Span::styled(" ".repeat(user_pad_lr), Style::default().bg(user_bg)),
Span::styled("│", Style::default().fg(border_color).bg(user_bg)),
]));
}
{
let dash_w = actual_inner_content_w + user_pad_lr * 2;
let pad = actual_inner_content_width.saturating_sub(dash_w + 2);
lines.push(Line::from(vec![
Span::raw(" ".repeat(pad)),
Span::styled("╰", Style::default().fg(border_color).bg(user_bg)),
Span::styled(
"─".repeat(dash_w),
Style::default().fg(border_color).bg(user_bg),
),
Span::styled("╯", Style::default().fg(border_color).bg(user_bg)),
]));
}
}
pub fn render_assistant_msg(
sender_name: Option<&str>,
content: &str,
is_selected: bool,
ctx: &mut RenderContext<'_>,
) {
let lines = &mut *ctx.lines;
let theme = ctx.theme;
let bubble_max_width = ctx.bubble_max_width;
if content.is_empty() {
return;
}
let (agent_name, bubble_content): (String, &str) = if let Some(name) = sender_name {
(name.to_string(), content)
} else if let Some((name, rest)) = parse_agent_prefix(content) {
(name.to_string(), rest)
} else {
("Sprite".to_string(), content)
};
let is_teammate = agent_name != "Sprite";
let bubble_bg = if is_selected {
theme.bubble_ai_selected
} else {
theme.bubble_ai
};
let pad_left_w = 3usize;
let pad_right_w = 3usize;
let margin = " ".repeat(ASSISTANT_BUBBLE_LEFT_MARGIN);
lines.push(Line::from(""));
let label_text = if is_selected {
format!("{}▶ {}", margin, agent_name)
} else {
format!("{}{}", margin, agent_name)
};
let label_color = if is_selected {
theme.label_selected
} else if is_teammate {
agent_name_color(&agent_name)
} else {
theme.label_ai
};
lines.push(Line::from(Span::styled(
label_text,
Style::default()
.fg(label_color)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
let border_color = if is_selected {
theme.label_selected
} else {
theme.border_message
};
let md_content_w = bubble_max_width
.saturating_sub(pad_left_w + pad_right_w + ASSISTANT_BUBBLE_LEFT_MARGIN + 2);
let md_lines = markdown_to_lines(bubble_content, md_content_w + 2, theme);
let actual_content_max_w = md_lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| display_width(&span.content))
.sum::<usize>()
})
.max()
.unwrap_or(0);
let bubble_actual_inner_content_w = (actual_content_max_w + pad_left_w + pad_right_w)
.max(BUBBLE_MIN_WIDTH)
.min(bubble_max_width.saturating_sub(ASSISTANT_BUBBLE_LEFT_MARGIN + 2));
let dash_w = bubble_actual_inner_content_w;
lines.push(Line::from(vec![
Span::styled(margin.clone(), Style::default()),
Span::styled("╭", Style::default().fg(border_color).bg(bubble_bg)),
Span::styled(
"─".repeat(dash_w),
Style::default().fg(border_color).bg(bubble_bg),
),
Span::styled("╮", Style::default().fg(border_color).bg(bubble_bg)),
]));
for md_line in md_lines {
let mut bubble_line = wrap_md_line_in_bubble(
md_line,
bubble_bg,
pad_left_w,
pad_right_w,
bubble_actual_inner_content_w,
);
let last = bubble_line.spans.pop();
if let Some(pad_span) = last {
bubble_line.spans.push(pad_span);
}
let mut spans = vec![Span::styled(margin.clone(), Style::default())];
spans.push(Span::styled(
"│",
Style::default().fg(border_color).bg(bubble_bg),
));
spans.extend(bubble_line.spans);
spans.push(Span::styled(
"│",
Style::default().fg(border_color).bg(bubble_bg),
));
lines.push(Line::from(spans));
}
lines.push(Line::from(vec![
Span::styled(margin.clone(), Style::default()),
Span::styled("╰", Style::default().fg(border_color).bg(bubble_bg)),
Span::styled(
"─".repeat(dash_w),
Style::default().fg(border_color).bg(bubble_bg),
),
Span::styled("╯", Style::default().fg(border_color).bg(bubble_bg)),
]));
}