use super::super::app::{ChatApp, ChatMode, MsgLinesCache, PerMsgCache};
use super::super::markdown::markdown_to_lines;
use super::theme::Theme;
use crate::command::chat::constants::{
AGENT_RESULT_MAX_LINES, BASH_OUTPUT_MAX_LINES, CONFIRM_MSG_MAX_LINES, ERROR_RESULT_MAX_LINES,
NORMAL_RESULT_MAX_LINES, THINKING_PULSE_MIN_FACTOR, THINKING_PULSE_PERIOD_MS,
TOOL_ARG_PREVIEW_MAX_CHARS,
};
use crate::command::chat::storage::DisplayType;
use crate::util::safe_lock;
use crate::util::text::{char_width, display_width, wrap_text};
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
use std::io::Write;
use std::sync::Arc;
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 msg_count = app.state.session.messages.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 in 0..msg_count {
let m = &app.state.session.messages[idx];
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 => {
render_user_msg(
&m.content,
is_selected,
inner_width,
bubble_max_width,
&mut tmp_lines,
t,
);
}
DisplayType::AssistantText => {
render_assistant_msg(&m.content, is_selected, bubble_max_width, &mut tmp_lines, t);
}
DisplayType::ToolCallRequest => {
if let Some(ref tool_calls) = m.tool_calls {
render_tool_call_request_msg(
tool_calls,
bubble_max_width,
&mut tmp_lines,
t,
expand,
);
}
}
DisplayType::ToolResult => {
let tool_name = m
.tool_call_id
.as_ref()
.and_then(|tid| {
app.state.session.messages[..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| {
app.state.session.messages[..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.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 md_content_w = bubble_max_width.saturating_sub(pad_left_w + pad_right_w);
let bubble_total_w = bubble_max_width;
streaming_lines.push(Line::from(""));
streaming_lines.push(Line::from(Span::styled(
"Sprite",
Style::default().fg(t.label_ai).add_modifier(Modifier::BOLD),
)));
streaming_lines.push(Line::from(vec![Span::styled(
" ".repeat(bubble_total_w),
Style::default().bg(bubble_bg),
)]));
if streaming_text == "◍" {
let pulse_color = thinking_pulse_color(t);
let indicator_line = Line::from(Span::styled("◍", Style::default().fg(pulse_color)));
let bubble_line = wrap_md_line_in_bubble(
indicator_line,
bubble_bg,
pad_left_w,
pad_right_w,
bubble_total_w,
);
streaming_lines.push(bubble_line);
streaming_lines.push(Line::from(vec![Span::styled(
" ".repeat(bubble_total_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,
bubble_total_w,
);
stable_lines.push(bubble_line);
}
}
final_stable_offset = boundary;
streaming_lines.extend(stable_lines.iter().cloned());
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(
md_line,
bubble_bg,
pad_left_w,
pad_right_w,
bubble_total_w,
);
streaming_lines.push(bubble_line);
}
}
streaming_lines.push(Line::from(vec![Span::styled(
" ".repeat(bubble_total_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,
)
}
pub fn wrap_md_line_in_bubble(
md_line: Line<'static>,
bubble_bg: Color,
pad_left_w: usize,
pad_right_w: usize,
bubble_total_w: usize,
) -> Line<'static> {
for span in &md_line.spans {
if span.content.starts_with("\x00IMG:") {
let marker = span.content.clone();
let spans: Vec<Span> = vec![
Span::styled(" ".repeat(bubble_total_w), Style::default().bg(bubble_bg)),
Span::styled(marker, Style::default()),
];
return Line::from(spans);
}
}
let pad_left = " ".repeat(pad_left_w);
let pad_right = " ".repeat(pad_right_w);
let mut styled_spans: Vec<Span> = Vec::new();
styled_spans.push(Span::styled(pad_left, Style::default().bg(bubble_bg)));
let target_content_w = bubble_total_w.saturating_sub(pad_left_w + pad_right_w);
let mut content_w: usize = 0;
for span in md_line.spans {
let sw = display_width(&span.content);
if content_w + sw > target_content_w {
let remaining = target_content_w.saturating_sub(content_w);
if remaining > 0 {
let mut truncated = String::new();
let mut tw = 0;
for ch in span.content.chars() {
let cw = char_width(ch);
if tw + cw > remaining {
break;
}
truncated.push(ch);
tw += cw;
}
if !truncated.is_empty() {
content_w += tw;
let merged_style = span.style.bg(bubble_bg);
styled_spans.push(Span::styled(truncated, merged_style));
}
}
break;
}
content_w += sw;
let merged_style = span.style.bg(bubble_bg);
styled_spans.push(Span::styled(span.content.to_string(), merged_style));
}
let fill = target_content_w.saturating_sub(content_w);
if fill > 0 {
styled_spans.push(Span::styled(
" ".repeat(fill),
Style::default().bg(bubble_bg),
));
}
styled_spans.push(Span::styled(pad_right, Style::default().bg(bubble_bg)));
Line::from(styled_spans)
}
pub fn render_user_msg(
content: &str,
is_selected: bool,
inner_width: usize,
bubble_max_width: usize,
lines: &mut Vec<Line<'static>>,
theme: &Theme,
) {
lines.push(Line::from(""));
let label = if is_selected { "▶ You " } else { "You " };
let pad = inner_width.saturating_sub(display_width(label) + 2);
lines.push(Line::from(vec![
Span::raw(" ".repeat(pad)),
Span::styled(
label,
Style::default()
.fg(if is_selected {
theme.label_selected
} else {
theme.label_user
})
.add_modifier(Modifier::BOLD),
),
]));
let user_bg = if is_selected {
theme.bubble_user_selected
} else {
theme.bubble_user
};
let user_pad_lr = 3usize;
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 bubble_text = " ".repeat(actual_bubble_w);
let pad = inner_width.saturating_sub(actual_bubble_w);
lines.push(Line::from(vec![
Span::raw(" ".repeat(pad)),
Span::styled(bubble_text, Style::default().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 text = format!(
"{}{}{}{}",
" ".repeat(user_pad_lr),
wl,
" ".repeat(fill),
" ".repeat(user_pad_lr),
);
let text_width = display_width(&text);
let pad = inner_width.saturating_sub(text_width);
lines.push(Line::from(vec![
Span::raw(" ".repeat(pad)),
Span::styled(text, Style::default().fg(theme.text_white).bg(user_bg)),
]));
}
{
let bubble_text = " ".repeat(actual_bubble_w);
let pad = inner_width.saturating_sub(actual_bubble_w);
lines.push(Line::from(vec![
Span::raw(" ".repeat(pad)),
Span::styled(bubble_text, Style::default().bg(user_bg)),
]));
}
}
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() || name.contains(char::is_whitespace) {
return None;
}
let rest = content[end + 1..].trim_start();
Some((name, rest))
}
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 fn render_assistant_msg(
content: &str,
is_selected: bool,
bubble_max_width: usize,
lines: &mut Vec<Line<'static>>,
theme: &Theme,
) {
if content.is_empty() {
return;
}
let (agent_name, bubble_content): (String, &str) =
if let Some((name, rest)) = parse_agent_prefix(content) {
(name.to_string(), rest)
} else {
("Sprite".to_string(), content)
};
let is_teammate = agent_name != "Sprite";
lines.push(Line::from(""));
let label = if is_selected {
format!(" ▶ {}", agent_name)
} else {
format!(" {}", 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,
Style::default()
.fg(label_color)
.add_modifier(Modifier::BOLD),
)));
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 md_content_w = bubble_max_width.saturating_sub(pad_left_w + pad_right_w);
let md_lines = markdown_to_lines(bubble_content, md_content_w + 2, theme);
let bubble_total_w = bubble_max_width;
lines.push(Line::from(vec![Span::styled(
" ".repeat(bubble_total_w),
Style::default().bg(bubble_bg),
)]));
for md_line in md_lines {
let bubble_line =
wrap_md_line_in_bubble(md_line, bubble_bg, pad_left_w, pad_right_w, bubble_total_w);
lines.push(bubble_line);
}
lines.push(Line::from(vec![Span::styled(
" ".repeat(bubble_total_w),
Style::default().bg(bubble_bg),
)]));
}
fn bordered_line(
content_spans: Vec<Span<'static>>,
bubble_max_width: usize,
border_color: Color,
bg: Color,
) -> Line<'static> {
let border_overhead = 4 + 2;
let content_used: usize = content_spans
.iter()
.map(|s| display_width(&s.content))
.sum();
let fill = bubble_max_width.saturating_sub(border_overhead + content_used);
let mut spans = Vec::with_capacity(content_spans.len() + 3);
spans.push(Span::styled(
" │ ",
Style::default().fg(border_color).bg(bg),
));
spans.extend(content_spans);
spans.push(Span::styled(" ".repeat(fill), Style::default().bg(bg)));
spans.push(Span::styled(" │", Style::default().fg(border_color).bg(bg)));
Line::from(spans)
}
fn render_tool_confirm_area(
app: &ChatApp,
bubble_max_width: usize,
lines: &mut Vec<Line<'static>>,
) {
let t = &app.ui.theme;
let confirm_bg = t.tool_confirm_bg;
let border_color = t.tool_confirm_border;
let content_w = bubble_max_width.saturating_sub(6); let is_ask = app.ui.tool_ask_mode;
lines.push(Line::from(""));
let title = if is_ask {
" 🪐 问一下:"
} else {
" 🔧 工具调用确认"
};
lines.push(Line::from(Span::styled(
title,
Style::default()
.fg(t.tool_confirm_title)
.add_modifier(Modifier::BOLD),
)));
let top_border = format!(" ┌{}┐", "─".repeat(bubble_max_width.saturating_sub(4)));
lines.push(Line::from(Span::styled(
top_border,
Style::default().fg(border_color).bg(confirm_bg),
)));
if is_ask {
render_ask_questions(app, bubble_max_width, content_w, lines);
} else if let Some(tc) = app
.tool_executor
.active_tool_calls
.get(app.tool_executor.pending_tool_idx)
{
render_tool_confirm_content(app, tc, bubble_max_width, content_w, lines);
}
let bottom_border = format!(" └{}┘", "─".repeat(bubble_max_width.saturating_sub(4)));
lines.push(Line::from(Span::styled(
bottom_border,
Style::default().fg(border_color).bg(confirm_bg),
)));
}
fn render_ask_questions(
app: &ChatApp,
bubble_max_width: usize,
content_w: usize,
lines: &mut Vec<Line<'static>>,
) {
let t = &app.ui.theme;
let confirm_bg = t.tool_confirm_bg;
let border_color = t.tool_confirm_border;
if let Some(cur_q) = app.ui.tool_ask_questions.get(app.ui.tool_ask_current_idx) {
let total_q = app.ui.tool_ask_questions.len();
let cur_idx = app.ui.tool_ask_current_idx;
let header_text = if total_q > 1 {
format!("[{}/{}] {}", cur_idx + 1, total_q, cur_q.header)
} else {
cur_q.header.clone()
};
{
let header_avail_w = content_w.saturating_sub(2).max(4);
let header_wrapped = wrap_text(&header_text, header_avail_w);
for hl in &header_wrapped {
lines.push(bordered_line(
vec![Span::styled(
format!(" {}", hl),
Style::default().fg(t.tool_confirm_text).bg(confirm_bg),
)],
bubble_max_width,
border_color,
confirm_bg,
));
}
}
{
let max_msg_w = content_w.saturating_sub(2);
let md_lines_rendered = markdown_to_lines(&cur_q.question, max_msg_w, t);
for md_line in md_lines_rendered.iter() {
let is_img_marker = md_line
.spans
.iter()
.any(|s| s.content.starts_with("\x00IMG:"));
let is_placeholder = md_line.spans.is_empty()
|| md_line.spans.iter().all(|s| s.content.trim().is_empty());
if is_img_marker {
let marker = match md_line
.spans
.iter()
.find(|s| s.content.starts_with("\x00IMG:"))
{
Some(s) => s.content.clone(),
None => continue,
};
let inner_w = bubble_max_width.saturating_sub(8);
lines.push(Line::from(vec![
Span::styled(" │ ", Style::default().fg(border_color).bg(confirm_bg)),
Span::styled(" ".repeat(inner_w), Style::default().bg(confirm_bg)),
Span::styled(" │", Style::default().fg(border_color).bg(confirm_bg)),
Span::styled(marker, Style::default()),
]));
} else if is_placeholder {
let inner_w = bubble_max_width.saturating_sub(4);
lines.push(Line::from(vec![
Span::styled(" │", Style::default().fg(border_color).bg(confirm_bg)),
Span::styled(" ".repeat(inner_w), Style::default().bg(confirm_bg)),
Span::styled("│", Style::default().fg(border_color).bg(confirm_bg)),
]));
} else {
let mut content_spans =
vec![Span::styled(" ", Style::default().bg(confirm_bg))];
for span in &md_line.spans {
let mut patched = span.clone();
patched.style = patched.style.bg(confirm_bg);
content_spans.push(patched);
}
lines.push(bordered_line(
content_spans,
bubble_max_width,
border_color,
confirm_bg,
));
}
}
}
{
let inner_w = bubble_max_width.saturating_sub(4);
lines.push(Line::from(vec![
Span::styled(" │", Style::default().fg(border_color).bg(confirm_bg)),
Span::styled(" ".repeat(inner_w), Style::default().bg(confirm_bg)),
Span::styled("│", Style::default().fg(border_color).bg(confirm_bg)),
]));
}
let is_multi = cur_q.multi_select;
for (i, opt) in cur_q.options.iter().enumerate() {
let is_cursor = i == app.ui.tool_ask_cursor;
let is_selected_multi =
i < app.ui.tool_ask_selections.len() && app.ui.tool_ask_selections[i];
let pointer_str = if is_cursor { " ❯ " } else { " " };
let check_str = if is_multi {
if is_selected_multi { "☑ " } else { "☐ " }
} else if is_cursor {
"● "
} else {
"○ "
};
let pointer_style = if is_cursor {
Style::default()
.fg(Color::Cyan)
.bg(confirm_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().bg(confirm_bg)
};
let check_style = if is_cursor || is_selected_multi {
Style::default()
.fg(Color::Green)
.bg(confirm_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(t.tool_confirm_label).bg(confirm_bg)
};
let label_style = if is_cursor {
Style::default()
.fg(Color::Cyan)
.bg(confirm_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(t.tool_confirm_label).bg(confirm_bg)
};
{
let prefix_w = display_width(pointer_str) + display_width(check_str);
let label_avail_w = content_w.saturating_sub(prefix_w + 2).max(4);
let label_wrapped = wrap_text(&opt.label, label_avail_w);
let indent_str = " ".repeat(prefix_w);
for (li, label_line) in label_wrapped.iter().enumerate() {
if li == 0 {
lines.push(bordered_line(
vec![
Span::styled(pointer_str, pointer_style),
Span::styled(check_str, check_style),
Span::styled(label_line.clone(), label_style),
],
bubble_max_width,
border_color,
confirm_bg,
));
} else {
lines.push(bordered_line(
vec![
Span::styled(indent_str.clone(), Style::default().bg(confirm_bg)),
Span::styled(label_line.clone(), label_style),
],
bubble_max_width,
border_color,
confirm_bg,
));
}
}
}
if !opt.description.is_empty() {
let desc_prefix = " ";
let desc_max_w = content_w.saturating_sub(display_width(desc_prefix) + 2);
let desc_wrapped = wrap_text(&opt.description, desc_max_w);
for dl in &desc_wrapped {
let desc_text = format!("{}{}", desc_prefix, dl);
lines.push(bordered_line(
vec![Span::styled(
desc_text,
Style::default().fg(t.text_dim).bg(confirm_bg),
)],
bubble_max_width,
border_color,
confirm_bg,
));
}
}
}
{
let free_idx = cur_q.options.len();
let is_cursor = free_idx == app.ui.tool_ask_cursor;
if app.ui.tool_interact_typing {
let pointer_style = Style::default()
.fg(Color::Cyan)
.bg(confirm_bg)
.add_modifier(Modifier::BOLD);
let input = &app.ui.tool_interact_input;
let cursor_pos = app.ui.tool_interact_cursor;
let chars: Vec<char> = input.chars().collect();
let before: String = chars[..cursor_pos].iter().collect();
let cursor_char = chars.get(cursor_pos).copied().unwrap_or(' ');
let after: String = if cursor_pos < chars.len() {
chars[cursor_pos + 1..].iter().collect()
} else {
String::new()
};
let text_style = Style::default().fg(t.text_white).bg(confirm_bg);
let cursor_style = Style::default().fg(t.cursor_fg).bg(t.cursor_bg);
lines.push(bordered_line(
vec![
Span::styled(" ❯ ✏ ", pointer_style),
Span::styled(before, text_style),
Span::styled(cursor_char.to_string(), cursor_style),
Span::styled(after, text_style),
],
bubble_max_width,
border_color,
confirm_bg,
));
} else {
let pointer_str = if is_cursor { " ❯ " } else { " " };
let pointer_style = if is_cursor {
Style::default()
.fg(Color::Cyan)
.bg(confirm_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().bg(confirm_bg)
};
let text_style = if is_cursor {
Style::default()
.fg(Color::Cyan)
.bg(confirm_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(t.tool_confirm_label).bg(confirm_bg)
};
lines.push(bordered_line(
vec![
Span::styled(pointer_str, pointer_style),
Span::styled("✏ 自由输入...", text_style),
],
bubble_max_width,
border_color,
confirm_bg,
));
}
}
{
let inner_w = bubble_max_width.saturating_sub(4);
lines.push(Line::from(vec![
Span::styled(" │", Style::default().fg(border_color).bg(confirm_bg)),
Span::styled(" ".repeat(inner_w), Style::default().bg(confirm_bg)),
Span::styled("│", Style::default().fg(border_color).bg(confirm_bg)),
]));
}
let hint = if is_multi {
" Up/Down Move | Space Toggle | Enter OK | PgUp/PgDn Scroll | Esc Cancel"
} else {
" Up/Down Move | Enter OK | PgUp/PgDn Scroll | Esc Cancel"
};
lines.push(bordered_line(
vec![Span::styled(
hint,
Style::default().fg(t.text_dim).bg(confirm_bg),
)],
bubble_max_width,
border_color,
confirm_bg,
));
}
}
fn render_tool_confirm_content(
app: &ChatApp,
tc: &super::super::app::ToolCallStatus,
bubble_max_width: usize,
content_w: usize,
lines: &mut Vec<Line<'static>>,
) {
let t = &app.ui.theme;
let confirm_bg = t.tool_confirm_bg;
let border_color = t.tool_confirm_border;
{
let label = "工具: ";
let name = &tc.tool_name;
let text_content = format!("{}{}", label, name);
let fill = content_w.saturating_sub(display_width(&text_content));
lines.push(Line::from(vec![
Span::styled(" │ ", Style::default().fg(border_color).bg(confirm_bg)),
Span::styled(" ".to_string(), Style::default().bg(confirm_bg)),
Span::styled(
label,
Style::default().fg(t.tool_confirm_label).bg(confirm_bg),
),
Span::styled(
name.clone(),
Style::default()
.fg(t.tool_confirm_name)
.bg(confirm_bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" ".repeat(fill.saturating_sub(1)),
Style::default().bg(confirm_bg),
),
Span::styled(" │", Style::default().fg(border_color).bg(confirm_bg)),
]));
}
{
let max_msg_w = content_w.saturating_sub(2);
let wrapped = wrap_text(&tc.confirm_message, max_msg_w);
let max_lines = CONFIRM_MSG_MAX_LINES;
let show_lines = wrapped.len().min(max_lines);
for (i, line_text) in wrapped.iter().enumerate().take(show_lines) {
let display_text = if i == max_lines - 1 && wrapped.len() > max_lines {
format!("{}...", line_text)
} else {
line_text.clone()
};
let msg_w = display_width(&display_text);
let fill = content_w.saturating_sub(msg_w + 2);
lines.push(Line::from(vec![
Span::styled(" │ ", Style::default().fg(border_color).bg(confirm_bg)),
Span::styled(" ".to_string(), Style::default().bg(confirm_bg)),
Span::styled(
display_text,
Style::default().fg(t.tool_confirm_text).bg(confirm_bg),
),
Span::styled(
" ".repeat(fill.saturating_sub(1).saturating_add(2)),
Style::default().bg(confirm_bg),
),
Span::styled(" │", Style::default().fg(border_color).bg(confirm_bg)),
]));
}
}
{
let fill = bubble_max_width.saturating_sub(4);
lines.push(Line::from(vec![
Span::styled(" │", Style::default().fg(border_color).bg(confirm_bg)),
Span::styled(" ".repeat(fill), Style::default().bg(confirm_bg)),
Span::styled("│", Style::default().fg(border_color).bg(confirm_bg)),
]));
}
{
let arrow_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let selected = app.ui.tool_interact_selected;
let countdown_suffix = if app.state.agent_config.tool_confirm_timeout > 0 {
let elapsed = app
.tool_executor
.tool_confirm_entered_at
.elapsed()
.as_secs();
let remaining = app
.state
.agent_config
.tool_confirm_timeout
.saturating_sub(elapsed);
format!(" ({}s)", remaining)
} else {
String::new()
};
let options: Vec<String> = vec![
format!("continue: 确认执行{}", countdown_suffix),
"allow: 允许并记住".to_string(),
"refuse: 拒绝执行".to_string(),
"type something...".to_string(),
];
for (i, option) in options.iter().enumerate() {
let is_selected = i == selected;
let pointer = if is_selected { "❯" } else { " " };
if i == 3 && app.ui.tool_interact_typing {
let input_display = format!("{} type: {}█", pointer, app.ui.tool_interact_input);
let input_w = display_width(&input_display);
let fill = content_w.saturating_sub(input_w + 2);
lines.push(Line::from(vec![
Span::styled(" │ ", Style::default().fg(border_color).bg(confirm_bg)),
Span::styled(" ", Style::default().bg(confirm_bg)),
Span::styled(pointer, arrow_style.bg(confirm_bg)),
Span::styled(
format!(" type: {}█", app.ui.tool_interact_input),
Style::default().fg(t.text_white).bg(confirm_bg),
),
Span::styled(
" ".repeat(fill.saturating_sub(1).saturating_add(2)),
Style::default().bg(confirm_bg),
),
Span::styled(" │", Style::default().fg(border_color).bg(confirm_bg)),
]));
} else {
let full_text = format!("{} {}", pointer, option);
let text_w = display_width(&full_text);
let fill = content_w.saturating_sub(text_w + 2);
let text_style = if is_selected {
arrow_style.bg(confirm_bg)
} else {
Style::default().fg(t.tool_confirm_label).bg(confirm_bg)
};
lines.push(Line::from(vec![
Span::styled(" │ ", Style::default().fg(border_color).bg(confirm_bg)),
Span::styled(" ", Style::default().bg(confirm_bg)),
Span::styled(
pointer,
if is_selected {
arrow_style.bg(confirm_bg)
} else {
Style::default().bg(confirm_bg)
},
),
Span::styled(format!(" {}", option), text_style),
Span::styled(
" ".repeat(fill.saturating_sub(1).saturating_add(2)),
Style::default().bg(confirm_bg),
),
Span::styled(" │", Style::default().fg(border_color).bg(confirm_bg)),
]));
}
}
}
}
fn render_agent_perm_confirm_area(
app: &ChatApp,
bubble_max_width: usize,
lines: &mut Vec<Line<'static>>,
) {
let t = &app.ui.theme;
let confirm_bg = t.tool_confirm_bg;
let border_color = t.tool_confirm_border;
let content_w = bubble_max_width.saturating_sub(6);
let req = match app.ui.pending_agent_perm.as_ref() {
Some(r) => r,
None => return,
};
lines.push(Line::from(Span::styled(
format!(" ╭{}╮", "─".repeat(bubble_max_width.saturating_sub(4))),
Style::default().fg(border_color),
)));
let title = req.title();
lines.push(bordered_line(
vec![Span::styled(
title,
Style::default()
.fg(t.tool_confirm_title)
.add_modifier(Modifier::BOLD)
.bg(confirm_bg),
)],
bubble_max_width,
border_color,
confirm_bg,
));
lines.push(bordered_line(
vec![Span::styled(
format!(" 工具: {}", req.tool_name),
Style::default()
.fg(t.tool_confirm_name)
.add_modifier(Modifier::BOLD)
.bg(confirm_bg),
)],
bubble_max_width,
border_color,
confirm_bg,
));
for wrapped in wrap_text(&req.confirm_msg, content_w) {
lines.push(bordered_line(
vec![Span::styled(
format!(" {}", wrapped),
Style::default().fg(t.tool_confirm_text).bg(confirm_bg),
)],
bubble_max_width,
border_color,
confirm_bg,
));
}
lines.push(bordered_line(
vec![Span::styled(" ", Style::default().bg(confirm_bg))],
bubble_max_width,
border_color,
confirm_bg,
));
lines.push(bordered_line(
vec![Span::styled(
" [Y/Enter] 允许 [N/Esc] 拒绝",
Style::default()
.fg(t.text_dim)
.add_modifier(Modifier::BOLD)
.bg(confirm_bg),
)],
bubble_max_width,
border_color,
confirm_bg,
));
lines.push(Line::from(Span::styled(
format!(" ╰{}╯", "─".repeat(bubble_max_width.saturating_sub(4))),
Style::default().fg(border_color),
)));
}
fn render_plan_approval_confirm_area(
app: &ChatApp,
bubble_max_width: usize,
lines: &mut Vec<Line<'static>>,
) {
let t = &app.ui.theme;
let confirm_bg = t.tool_confirm_bg;
let border_color = t.tool_confirm_border;
let content_w = bubble_max_width.saturating_sub(6);
let req = match app.ui.pending_plan_approval.as_ref() {
Some(r) => r,
None => return,
};
lines.push(Line::from(Span::styled(
format!(" ╭{}╮", "─".repeat(bubble_max_width.saturating_sub(4))),
Style::default().fg(border_color),
)));
let title = format!(" Plan 审批请求 [{}] ", req.agent_name);
lines.push(bordered_line(
vec![Span::styled(
title,
Style::default()
.fg(t.tool_confirm_title)
.add_modifier(Modifier::BOLD)
.bg(confirm_bg),
)],
bubble_max_width,
border_color,
confirm_bg,
));
lines.push(bordered_line(
vec![Span::styled(
format!(" Plan: {}", req.plan_name),
Style::default()
.fg(t.tool_confirm_name)
.add_modifier(Modifier::BOLD)
.bg(confirm_bg),
)],
bubble_max_width,
border_color,
confirm_bg,
));
let plan_lines: Vec<&str> = req.plan_content.lines().take(20).collect();
for line in &plan_lines {
for wrapped in wrap_text(line, content_w) {
lines.push(bordered_line(
vec![Span::styled(
format!(" {}", wrapped),
Style::default().fg(t.tool_confirm_text).bg(confirm_bg),
)],
bubble_max_width,
border_color,
confirm_bg,
));
}
}
if req.plan_content.lines().count() > 20 {
lines.push(bordered_line(
vec![Span::styled(
" ... (内容已截断)".to_string(),
Style::default().fg(t.text_dim).bg(confirm_bg),
)],
bubble_max_width,
border_color,
confirm_bg,
));
}
lines.push(bordered_line(
vec![Span::styled(" ", Style::default().bg(confirm_bg))],
bubble_max_width,
border_color,
confirm_bg,
));
lines.push(bordered_line(
vec![Span::styled(
" [Y/Enter] 批准 [C] 批准并清空 [N/Esc] 拒绝",
Style::default()
.fg(t.text_dim)
.add_modifier(Modifier::BOLD)
.bg(confirm_bg),
)],
bubble_max_width,
border_color,
confirm_bg,
));
lines.push(Line::from(Span::styled(
format!(" ╰{}╯", "─".repeat(bubble_max_width.saturating_sub(4))),
Style::default().fg(border_color),
)));
}
pub fn render_tool_call_request_msg(
tool_calls: &[super::super::storage::ToolCallItem],
bubble_max_width: usize,
lines: &mut Vec<Line<'static>>,
theme: &Theme,
expand: bool,
) {
use super::super::tools::classification::{ToolCategory, ToolStatus};
let content_w = bubble_max_width.saturating_sub(6);
lines.push(Line::from(""));
for (i, tc) in tool_calls.iter().enumerate() {
if i > 0 {
lines.push(Line::from(""));
}
let category = ToolCategory::from_name(&tc.name);
let icon = category.icon();
let tool_color = category.color(theme);
let status = ToolStatus::Pending;
let status_icon = status.icon();
let status_color = status.color(theme);
if expand {
let tool_desc = extract_tool_description_from_args(&tc.name, &tc.arguments);
let display_name = if let Some(ref desc) = tool_desc {
format!("{} - {}", tc.name, desc)
} else {
tc.name.clone()
};
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(icon, Style::default().fg(tool_color)),
Span::styled(" ", Style::default()),
Span::styled(
display_name,
Style::default().fg(tool_color).add_modifier(Modifier::BOLD),
),
Span::styled(" ", Style::default()),
Span::styled(status_icon, Style::default().fg(status_color)),
]));
if !tc.arguments.is_empty() {
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&tc.arguments) {
render_json_params_enhanced(&json_value, content_w, lines, theme);
} else {
for line in wrap_text(&tc.arguments, content_w) {
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(line, Style::default().fg(theme.text_dim)),
]));
}
}
}
} else {
let tool_desc = extract_tool_description_from_args(&tc.name, &tc.arguments);
if let Some(desc) = tool_desc {
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(icon, Style::default().fg(tool_color)),
Span::styled(" ", Style::default()),
Span::styled(
tc.name.clone(),
Style::default().fg(tool_color).add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {}", desc), Style::default().fg(theme.text_dim)),
]));
} else {
let total_len = tc.arguments.chars().count();
let truncated = total_len > TOOL_ARG_PREVIEW_MAX_CHARS;
let closing_bracket = if truncated {
tc.arguments.chars().next().and_then(|c| match c {
'{' => Some('}'),
'[' => Some(']'),
_ => None,
})
} else {
None
};
let max_preview = TOOL_ARG_PREVIEW_MAX_CHARS;
let preview_len = if closing_bracket.is_some() {
max_preview - 4
} else {
max_preview
};
let args_preview: String = tc.arguments.chars().take(preview_len).collect();
let suffix = if truncated {
if let Some(bracket) = closing_bracket {
format!("...{}", bracket)
} else {
"…".to_string()
}
} else {
"".to_string()
};
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(icon, Style::default().fg(tool_color)),
Span::styled(" ", Style::default()),
Span::styled(
tc.name.clone(),
Style::default().fg(tool_color).add_modifier(Modifier::BOLD),
),
if !args_preview.is_empty() {
Span::styled(
format!(" {}{}", args_preview, suffix),
Style::default().fg(theme.text_dim),
)
} else {
Span::raw("")
},
]));
}
}
}
}
fn render_json_params_enhanced(
json: &serde_json::Value,
max_width: usize,
lines: &mut Vec<Line<'static>>,
theme: &Theme,
) {
use super::super::tools::classification::format_json_value;
if let Some(obj) = json.as_object() {
for (key, value) in obj {
let value_str = format_json_value(value);
let max_val_chars = max_width.saturating_sub(key.chars().count() + 7);
let value_display = if value_str.chars().count() > max_val_chars {
let truncated: String = value_str.chars().take(max_val_chars).collect();
format!("{}…", truncated)
} else {
value_str
};
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(format!("{}:", key), Style::default().fg(theme.text_dim)),
Span::styled(" ", Style::default()),
Span::styled(value_display, Style::default().fg(theme.text_normal)),
]));
}
} else {
let value_str = format_json_value(json);
for line in wrap_text(&value_str, max_width) {
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(line, Style::default().fg(theme.text_normal)),
]));
}
}
}
pub fn render_tool_result_msg(
content: &str,
label: &str,
tool_args: Option<&str>,
bubble_max_width: usize,
lines: &mut Vec<Line<'static>>,
theme: &Theme,
expand: bool,
) {
use super::super::tools::classification::{
ToolCategory, ToolStatus, get_result_summary_for_tool,
};
lines.push(Line::from(""));
let (tool_name, is_error) = parse_tool_label(label);
let category = ToolCategory::from_name(&tool_name);
let tool_color = category.color(theme);
let icon = "🔧";
let status = if is_error {
ToolStatus::Failed
} else {
ToolStatus::Success
};
let status_icon = status.icon();
let status_color = status.color(theme);
let summary = get_result_summary_for_tool(content, is_error, &tool_name, tool_args);
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(icon, Style::default().fg(tool_color)),
Span::styled(" ", Style::default()),
Span::styled(
tool_name.clone(),
Style::default().fg(tool_color).add_modifier(Modifier::BOLD),
),
Span::styled(" ", Style::default()),
Span::styled(status_icon, Style::default().fg(status_color)),
Span::styled(" ", Style::default()),
Span::styled(summary, Style::default().fg(theme.text_dim)),
]));
if !expand || content.is_empty() {
return;
}
let clean = crate::util::text::sanitize_tool_output(content);
let content_w = bubble_max_width.saturating_sub(6);
if is_error {
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(
"Error:",
Style::default()
.fg(theme.toast_error_border)
.add_modifier(Modifier::BOLD),
),
]));
let error_lines: Vec<&str> = clean.lines().take(ERROR_RESULT_MAX_LINES).collect();
for line in error_lines {
for wrapped in wrap_text(line, content_w) {
lines.push(Line::from(Span::styled(
format!(" {}", wrapped),
Style::default().fg(theme.toast_error_border),
)));
}
}
let total_lines = clean.lines().count();
let max_err_lines = ERROR_RESULT_MAX_LINES;
if total_lines > max_err_lines {
lines.push(Line::from(Span::styled(
format!(
" ... (共 {} 行,显示前 {} 行)",
total_lines, max_err_lines
),
Style::default().fg(theme.text_dim),
)));
}
} else if clean.contains("```diff\n") {
render_diff_content(&clean, content_w, lines, theme);
} else if tool_name == "Agent" {
render_agent_result_nested(&clean, bubble_max_width, lines, theme);
} else if tool_name == "Compact" {
render_agent_result_nested(&clean, bubble_max_width, lines, theme);
} else if tool_name == "Bash" {
render_bash_result(&clean, tool_args, content_w, lines, theme);
} else if tool_name == "TodoRead" || tool_name == "TodoWrite" {
render_todo_result(content, content_w, lines, theme);
} else {
let all_lines: Vec<&str> = clean.lines().take(NORMAL_RESULT_MAX_LINES).collect();
for line in all_lines {
for wrapped in wrap_text(line, content_w) {
lines.push(Line::from(Span::styled(
format!(" {}", wrapped),
Style::default().fg(theme.text_dim),
)));
}
}
let total_lines = clean.lines().count();
if total_lines > 100 {
lines.push(Line::from(Span::styled(
format!(" ... (共 {} 行,显示前 100 行)", total_lines),
Style::default().fg(theme.text_dim),
)));
}
}
}
fn render_diff_content(
content: &str,
content_w: usize,
lines: &mut Vec<Line<'static>>,
theme: &Theme,
) {
let mut in_diff = false;
for line in content.lines() {
if line.starts_with("```diff") {
in_diff = true;
continue;
}
if in_diff && line.starts_with("```") {
in_diff = false;
continue;
}
if in_diff {
let color = if line.starts_with("- ")
|| line.starts_with('-') && !line.starts_with("---")
{
theme.diff_del
} else if line.starts_with("+ ") || line.starts_with('+') && !line.starts_with("+++") {
theme.diff_add
} else if line.starts_with("@@ ") {
theme.diff_header
} else {
theme.text_dim
};
for wrapped in wrap_text(line, content_w) {
lines.push(Line::from(Span::styled(
format!(" {}", wrapped),
Style::default().fg(color),
)));
}
} else {
for wrapped in wrap_text(line, content_w) {
lines.push(Line::from(Span::styled(
format!(" {}", wrapped),
Style::default().fg(theme.text_dim),
)));
}
}
}
}
fn render_agent_result_nested(
content: &str,
bubble_max_width: usize,
lines: &mut Vec<Line<'static>>,
theme: &Theme,
) {
let all_lines: Vec<&str> = content.lines().collect();
let total = all_lines.len();
let max_display = AGENT_RESULT_MAX_LINES;
let display_lines = &all_lines[..total.min(max_display)];
let border_color = theme.text_dim;
let content_w = bubble_max_width.saturating_sub(6);
let top_border = format!(" ┌{}┐", "─".repeat(bubble_max_width.saturating_sub(4)));
lines.push(Line::from(Span::styled(
top_border,
Style::default().fg(border_color),
)));
for line in display_lines.iter() {
for wrapped in wrap_text(line, content_w) {
lines.push(bordered_line(
vec![Span::styled(wrapped, Style::default().fg(theme.text_dim))],
bubble_max_width,
border_color,
Color::default(),
));
}
}
if total > max_display {
lines.push(bordered_line(
vec![Span::styled(
format!("... (共 {} 行)", total),
Style::default().fg(theme.text_dim),
)],
bubble_max_width,
border_color,
Color::default(),
));
}
let bottom_border = format!(" └{}┘", "─".repeat(bubble_max_width.saturating_sub(4)));
lines.push(Line::from(Span::styled(
bottom_border,
Style::default().fg(border_color),
)));
}
fn render_bash_result(
content: &str,
tool_args: Option<&str>,
content_w: usize,
lines: &mut Vec<Line<'static>>,
theme: &Theme,
) {
let command = tool_args
.and_then(|args| serde_json::from_str::<serde_json::Value>(args).ok())
.and_then(|v| {
v.get("command")
.and_then(|c| c.as_str().map(|s| s.to_string()))
});
if let Some(cmd) = command {
let cmd_w = content_w.saturating_sub(6); for (i, cmd_line) in cmd.lines().enumerate() {
let prefix = if i == 0 { " $ " } else { " " };
for wrapped in wrap_text(cmd_line, cmd_w) {
lines.push(Line::from(vec![
Span::styled(prefix, Style::default().fg(theme.label_ai)),
Span::styled(
wrapped,
Style::default()
.fg(theme.text_white)
.add_modifier(Modifier::BOLD),
),
]));
}
}
}
let output_lines: Vec<&str> = content.lines().take(BASH_OUTPUT_MAX_LINES).collect();
for line in &output_lines {
for wrapped in wrap_text(line, content_w) {
lines.push(Line::from(Span::styled(
format!(" {}", wrapped),
Style::default().fg(theme.text_dim),
)));
}
}
let total_lines = content.lines().count();
if total_lines > 100 {
lines.push(Line::from(Span::styled(
format!(" ... (共 {} 行,显示前 100 行)", total_lines),
Style::default().fg(theme.text_dim),
)));
}
}
fn render_todo_result(
content: &str,
content_w: usize,
lines: &mut Vec<Line<'static>>,
theme: &Theme,
) {
if let Ok(items) = serde_json::from_str::<Vec<serde_json::Value>>(content) {
for item in &items {
let status = item
.get("status")
.and_then(|s| s.as_str())
.unwrap_or("pending");
let text = item
.get("content")
.and_then(|c| c.as_str())
.unwrap_or("(empty)");
let id = item.get("id").and_then(|i| i.as_str()).unwrap_or("");
let (checkbox, color) = match status {
"completed" => ("[x]", theme.label_ai), "in_progress" => ("[~]", theme.title_loading), "cancelled" => ("[-]", theme.text_dim), _ => ("[ ]", Color::Yellow), };
let id_display = if !id.is_empty() {
format!("{} ", id)
} else {
String::new()
};
let item_text = format!("{}{}", id_display, text);
let max_w = content_w.saturating_sub(10); for (i, wrapped) in wrap_text(&item_text, max_w).iter().enumerate() {
if i == 0 {
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(format!("{} ", checkbox), Style::default().fg(color)),
Span::styled(
wrapped.clone(),
if status == "completed" {
Style::default()
.fg(theme.text_dim)
.add_modifier(Modifier::CROSSED_OUT)
} else {
Style::default().fg(theme.text_white)
},
),
]));
} else {
lines.push(Line::from(Span::styled(
format!(" {}", wrapped),
Style::default().fg(theme.text_dim),
)));
}
}
}
} else {
let all_lines: Vec<&str> = content.lines().take(100).collect();
for line in all_lines {
for wrapped in wrap_text(line, content_w) {
lines.push(Line::from(Span::styled(
format!(" {}", wrapped),
Style::default().fg(theme.text_dim),
)));
}
}
}
}
fn parse_tool_label(label: &str) -> (String, bool) {
let is_error = label.contains("错误") || label.contains("失败") || label.contains("error");
let tool_name = if label.starts_with("工具 ") {
label
.chars()
.skip(3)
.collect::<String>()
.split(['.', ' '])
.next()
.unwrap_or(label)
.to_string()
} else {
label.split(['.', ' ']).next().unwrap_or(label).to_string()
};
(tool_name, is_error)
}
fn thinking_pulse_color(theme: &Theme) -> Color {
use std::time::{SystemTime, UNIX_EPOCH};
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let period = THINKING_PULSE_PERIOD_MS as f64;
let phase = (millis % period as u128) as f64 / period;
let t = (phase * std::f64::consts::TAU).sin() * 0.5 + 0.5;
if let Color::Rgb(r, g, b) = theme.label_ai {
let min_factor = THINKING_PULSE_MIN_FACTOR;
let factor = min_factor + (1.0 - min_factor) * t;
let pr = (r as f64 * factor).round().min(255.0) as u8;
let pg = (g as f64 * factor).round().min(255.0) as u8;
let pb = (b as f64 * factor).round().min(255.0) as u8;
Color::Rgb(pr, pg, pb)
} else {
if t > 0.5 {
theme.label_ai
} else {
theme.text_dim
}
}
}
pub fn copy_to_clipboard(content: &str) -> bool {
use std::process::{Command, Stdio};
let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
("pbcopy", vec![])
} else if cfg!(target_os = "linux") {
if Command::new("which")
.arg("xclip")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
("xclip", vec!["-selection", "clipboard"])
} else {
("xsel", vec!["--clipboard", "--input"])
}
} else {
return false;
};
let child = Command::new(cmd).args(&args).stdin(Stdio::piped()).spawn();
match child {
Ok(mut child) => {
if let Some(ref mut stdin) = child.stdin {
let _ = stdin.write_all(content.as_bytes());
}
child.wait().map(|s| s.success()).unwrap_or(false)
}
Err(_) => false,
}
}
fn extract_tool_description_from_args(tool_name: &str, arguments: &str) -> Option<String> {
if tool_name != "Bash" {
return None;
}
serde_json::from_str::<serde_json::Value>(arguments)
.ok()
.and_then(|v| v.get("description")?.as_str().map(|s| s.to_string()))
}