pub mod animation;
pub mod bubble;
pub mod clipboard;
pub mod confirm_render;
pub mod msg_render;
pub mod tool_call_render;
pub mod tool_result_render;
pub use clipboard::copy_to_clipboard;
pub use msg_render::{render_assistant_msg, render_user_msg};
pub use tool_call_render::render_tool_call_request_msg;
pub use tool_result_render::render_tool_result_msg;
use super::theme::Theme;
use crate::command::chat::app::{ChatApp, ChatMode, MsgLinesCache, PerMsgCache};
use crate::command::chat::markdown::markdown_to_lines;
use crate::command::chat::storage::DisplayType;
use crate::command::chat::storage::config::ThinkingStyle;
use crate::util::safe_lock;
use crate::util::text::wrap_text;
use ratatui::{
style::{Modifier, Style},
text::{Line, Span},
};
use std::sync::Arc;
use animation::{comet_gradient_line, current_tick, thinking_pulse_color};
use bubble::{wrap_md_line_in_bubble, wrap_md_line_in_bubble_with_margin};
use confirm_render::{
render_agent_perm_confirm_area, render_plan_approval_confirm_area, render_tool_confirm_area,
};
use msg_render::render_thinking_block;
pub(crate) const THINKING_FOLDED_MAX_LINES: usize = 5;
pub(crate) const BUBBLE_MIN_WIDTH: usize = 20;
pub(crate) const ASSISTANT_BUBBLE_LEFT_MARGIN: usize = 2;
pub(crate) const USER_BUBBLE_PAD_LR: usize = 3;
pub(crate) const TOOL_RESULT_DISPLAY_MAX_LINES: usize = 100;
pub(crate) const PLAN_DISPLAY_MAX_LINES: usize = 20;
pub struct RenderContext<'a> {
pub bubble_max_width: usize,
pub lines: &'a mut Vec<Line<'static>>,
pub theme: &'a Theme,
pub expand: bool,
}
pub(crate) struct ContentContext<'a> {
pub content_w: usize,
pub lines: &'a mut Vec<Line<'static>>,
pub theme: &'a Theme,
pub expand: bool,
}
pub fn find_stable_boundary(content: &str) -> usize {
let mut fence_count = 0usize;
let mut last_safe_boundary = 0usize;
let mut i = 0;
let bytes = content.as_bytes();
while i < bytes.len() {
if i + 2 < bytes.len() && bytes[i] == b'`' && bytes[i + 1] == b'`' && bytes[i + 2] == b'`' {
fence_count += 1;
i += 3;
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
continue;
}
if i + 1 < bytes.len() && bytes[i] == b'\n' && bytes[i + 1] == b'\n' {
if fence_count.is_multiple_of(2) {
last_safe_boundary = i + 2; }
i += 2;
continue;
}
i += 1;
}
last_safe_boundary
}
#[allow(clippy::type_complexity)]
pub fn build_message_lines_incremental(
app: &ChatApp,
inner_width: usize,
bubble_max_width: usize,
old_cache: Option<&MsgLinesCache>,
) -> (
Vec<(usize, usize)>,
Vec<PerMsgCache>,
Vec<Line<'static>>,
Arc<Vec<Line<'static>>>,
usize,
) {
let streaming_content_str = if app.state.is_loading {
let streaming: String = safe_lock(
&app.state.streaming_content,
"render_cache::streaming_content",
)
.clone();
if !streaming.is_empty() {
Some(streaming)
} else {
None
}
} else {
None
};
let t = &app.ui.theme;
let is_browse_mode = app.ui.mode == ChatMode::Browse;
let display_msgs = safe_lock(&app.display_messages, "render_cache::display_msgs");
let msg_count = display_msgs.len();
let mut current_line_offset: usize = 0;
let mut msg_start_lines: Vec<(usize, usize)> = Vec::with_capacity(msg_count);
let mut per_msg_cache: Vec<PerMsgCache> = Vec::with_capacity(msg_count);
let expand = app.ui.expand_tools;
let can_reuse_per_msg = old_cache
.map(|c| c.bubble_max_width == bubble_max_width && c.expand_tools == expand)
.unwrap_or(false);
for (idx, m) in display_msgs.iter().enumerate() {
let is_selected = is_browse_mode && idx == app.ui.browse_msg_index;
msg_start_lines.push((idx, current_line_offset));
if can_reuse_per_msg
&& let Some(old_c) = old_cache
&& let Some(old_per) = old_c.per_msg_lines.get(idx)
&& old_per.msg_index == idx
&& old_per.content_len == m.content.len()
&& old_per.is_selected == is_selected
{
current_line_offset += old_per.lines.len();
per_msg_cache.push(PerMsgCache {
content_len: old_per.content_len,
lines: old_per.lines.clone(),
msg_index: idx,
is_selected,
});
continue;
}
let mut tmp_lines: Vec<Line<'static>> = Vec::new();
match m.display_type() {
DisplayType::User => {
let mut ctx = RenderContext {
bubble_max_width,
lines: &mut tmp_lines,
theme: t,
expand,
};
render_user_msg(&m.content, is_selected, inner_width, &mut ctx);
}
DisplayType::AssistantText => {
let mut ctx = RenderContext {
bubble_max_width,
lines: &mut tmp_lines,
theme: t,
expand,
};
if let Some(ref reasoning) = m.reasoning_content {
render_thinking_block(reasoning, &mut ctx);
}
render_assistant_msg(m.sender_name.as_deref(), &m.content, is_selected, &mut ctx);
}
DisplayType::ToolCallRequest => {
let mut ctx = RenderContext {
bubble_max_width,
lines: &mut tmp_lines,
theme: t,
expand,
};
if let Some(ref reasoning) = m.reasoning_content {
render_thinking_block(reasoning, &mut ctx);
}
if !m.content.is_empty() {
render_assistant_msg(
m.sender_name.as_deref(),
&m.content,
is_selected,
&mut ctx,
);
}
if let Some(ref tool_calls) = m.tool_calls {
render_tool_call_request_msg(m.sender_name.as_deref(), tool_calls, &mut ctx);
}
}
DisplayType::ToolResult => {
let tool_name = m
.tool_call_id
.as_ref()
.and_then(|tid| {
display_msgs[..idx].iter().rev().find_map(|prev| {
prev.tool_calls.as_ref().and_then(|tcs| {
tcs.iter()
.find(|tc| tc.id == *tid)
.map(|tc| tc.name.clone())
})
})
})
.unwrap_or_default();
let tool_args = m.tool_call_id.as_ref().and_then(|tid| {
display_msgs[..idx].iter().rev().find_map(|prev| {
prev.tool_calls.as_ref().and_then(|tcs| {
tcs.iter()
.find(|tc| tc.id == *tid)
.map(|tc| tc.arguments.clone())
})
})
});
let label = if tool_name.is_empty() {
"工具结果".to_string()
} else {
tool_name
};
render_tool_result_msg(
m.sender_name.as_deref(),
&m.content,
&label,
tool_args.as_deref(),
bubble_max_width,
&mut tmp_lines,
t,
expand,
);
}
DisplayType::System => {
tmp_lines.push(Line::from(""));
let wrapped = wrap_text(&m.content, inner_width.saturating_sub(8));
for wl in wrapped {
tmp_lines.push(Line::from(Span::styled(
format!(" {} {}", "sys", wl),
Style::default().fg(t.text_system),
)));
}
}
}
current_line_offset += tmp_lines.len();
per_msg_cache.push(PerMsgCache {
content_len: m.content.len(),
lines: tmp_lines,
msg_index: idx,
is_selected,
});
}
let mut streaming_lines: Vec<Line<'static>> = Vec::new();
let (mut stable_lines, old_stable_offset) = if let Some(old_c) = old_cache {
if old_c.bubble_max_width == bubble_max_width {
(
(*old_c.streaming_stable_lines).clone(),
old_c.streaming_stable_offset,
)
} else {
(Vec::<Line<'static>>::new(), 0)
}
} else {
(Vec::<Line<'static>>::new(), 0)
};
let has_streaming_msg = app.state.is_loading;
let mut final_stable_offset = old_stable_offset;
if has_streaming_msg {
let streaming_text = streaming_content_str.as_deref().unwrap_or("◍");
let bubble_bg = t.bubble_ai;
let pad_left_w = 3usize;
let pad_right_w = 3usize;
let margin_str = " ".repeat(ASSISTANT_BUBBLE_LEFT_MARGIN);
let md_content_w = bubble_max_width
.saturating_sub(pad_left_w + pad_right_w + ASSISTANT_BUBBLE_LEFT_MARGIN);
let inner_bubble_w = bubble_max_width.saturating_sub(ASSISTANT_BUBBLE_LEFT_MARGIN);
streaming_lines.push(Line::from(""));
streaming_lines.push(Line::from(Span::styled(
format!("{}Sprite", margin_str),
Style::default().fg(t.label_ai).add_modifier(Modifier::BOLD),
)));
streaming_lines.push(Line::from(vec![
Span::styled(margin_str.clone(), Style::default()),
Span::styled(" ".repeat(inner_bubble_w), Style::default().bg(bubble_bg)),
]));
if streaming_text == "◍" {
let tick = current_tick();
let thinking_style = app.state.agent_config.thinking_style;
let indicator_line = if thinking_style == ThinkingStyle::Comet {
comet_gradient_line(tick, t.welcome_palette, t.label_ai)
} else {
let pulse_color = thinking_pulse_color(t);
let frame = thinking_style.frame(tick);
Line::from(Span::styled(frame, Style::default().fg(pulse_color)))
};
let bubble_line = wrap_md_line_in_bubble_with_margin(
indicator_line,
bubble_bg,
pad_left_w,
pad_right_w,
inner_bubble_w,
&margin_str,
);
streaming_lines.push(bubble_line);
let reasoning_str = safe_lock(
&app.state.streaming_reasoning_content,
"render::streaming_reasoning",
)
.clone();
if !reasoning_str.is_empty() {
let thinking_label = Line::from(Span::styled(
" Thinking...",
Style::default()
.fg(t.text_dim)
.add_modifier(Modifier::ITALIC),
));
let label_bubble = wrap_md_line_in_bubble_with_margin(
thinking_label,
bubble_bg,
pad_left_w,
pad_right_w,
inner_bubble_w,
&margin_str,
);
streaming_lines.push(label_bubble);
let reason_content_w = md_content_w.saturating_sub(2);
for wrapped_line in wrap_text(&reasoning_str, reason_content_w) {
let line = Line::from(Span::styled(
format!(" {}", wrapped_line),
Style::default().fg(t.text_dim),
));
let bubble_line = wrap_md_line_in_bubble_with_margin(
line,
bubble_bg,
pad_left_w,
pad_right_w,
inner_bubble_w,
&margin_str,
);
streaming_lines.push(bubble_line);
}
}
streaming_lines.push(Line::from(vec![
Span::styled(margin_str.clone(), Style::default()),
Span::styled(" ".repeat(inner_bubble_w), Style::default().bg(bubble_bg)),
]));
} else {
let content = streaming_text;
let boundary = find_stable_boundary(content);
if boundary > old_stable_offset {
let new_stable_text = &content[old_stable_offset..boundary];
let new_md_lines = markdown_to_lines(new_stable_text, md_content_w + 2, t);
for md_line in new_md_lines {
let bubble_line = wrap_md_line_in_bubble(
md_line,
bubble_bg,
pad_left_w,
pad_right_w,
inner_bubble_w,
);
stable_lines.push(bubble_line);
}
}
final_stable_offset = boundary;
for sl in stable_lines.iter() {
let mut line = sl.clone();
line.spans
.insert(0, Span::styled(margin_str.clone(), Style::default()));
streaming_lines.push(line);
}
let tail = &content[boundary..];
if !tail.is_empty() {
let tail_md_lines = markdown_to_lines(tail, md_content_w + 2, t);
for md_line in tail_md_lines {
let bubble_line = wrap_md_line_in_bubble_with_margin(
md_line,
bubble_bg,
pad_left_w,
pad_right_w,
inner_bubble_w,
&margin_str,
);
streaming_lines.push(bubble_line);
}
}
streaming_lines.push(Line::from(vec![
Span::styled(margin_str.clone(), Style::default()),
Span::styled(" ".repeat(inner_bubble_w), Style::default().bg(bubble_bg)),
]));
}
} else {
stable_lines = Vec::new();
final_stable_offset = 0;
}
if app.ui.mode == ChatMode::ToolConfirm {
render_tool_confirm_area(app, bubble_max_width, &mut streaming_lines);
}
if app.ui.mode == ChatMode::AgentPermConfirm {
render_agent_perm_confirm_area(app, bubble_max_width, &mut streaming_lines);
}
if app.ui.mode == ChatMode::PlanApprovalConfirm {
render_plan_approval_confirm_area(app, bubble_max_width, &mut streaming_lines);
}
streaming_lines.push(Line::from(""));
(
msg_start_lines,
per_msg_cache,
streaming_lines,
Arc::new(stable_lines),
final_stable_offset,
)
}