use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use crate::command::chat::app::{ChatApp, ToolCallStatus};
use crate::command::chat::constants::CONFIRM_MSG_MAX_LINES;
use crate::command::chat::markdown::markdown_to_lines;
use crate::command::chat::render::cache::PLAN_DISPLAY_MAX_LINES;
use crate::command::chat::render::cache::bubble::bordered_line;
use crate::util::text::{display_width, wrap_text};
pub(crate) 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),
)));
}
pub(crate) 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);
let prefix = " ❯ ✏ ";
let prefix_w = display_width(prefix);
let indent_w = prefix_w;
let avail_w = content_w.saturating_sub(prefix_w);
let avail_w = avail_w.max(4);
let full_text = format!("{}{}{}", before, cursor_char, after);
let wrapped = wrap_text(&full_text, avail_w);
let mut char_idx = 0usize;
let mut cursor_line = 0usize;
let mut cursor_offset_in_line = 0usize;
for (li, line_str) in wrapped.iter().enumerate() {
let line_chars: Vec<char> = line_str.chars().collect();
if cursor_pos >= char_idx && cursor_pos < char_idx + line_chars.len() {
cursor_line = li;
cursor_offset_in_line = cursor_pos - char_idx;
break;
}
char_idx += line_chars.len();
if li == wrapped.len() - 1 && cursor_pos == char_idx {
cursor_line = li;
cursor_offset_in_line = line_chars.len();
}
}
for (li, _line_str) in wrapped.iter().enumerate() {
let is_first = li == 0;
let prefix_span = if is_first {
Span::styled(prefix, pointer_style)
} else {
Span::styled(" ".repeat(indent_w), text_style)
};
if li == cursor_line {
let line_str = &wrapped[li];
let line_chars: Vec<char> = line_str.chars().collect();
let line_before: String =
line_chars[..cursor_offset_in_line].iter().collect();
let cc = line_chars
.get(cursor_offset_in_line)
.copied()
.unwrap_or(' ');
let line_after: String =
line_chars[cursor_offset_in_line + 1..].iter().collect();
lines.push(bordered_line(
vec![
prefix_span,
Span::styled(line_before, text_style),
Span::styled(cc.to_string(), cursor_style),
Span::styled(line_after, text_style),
],
bubble_max_width,
border_color,
confirm_bg,
));
} else {
lines.push(bordered_line(
vec![prefix_span, Span::styled(wrapped[li].clone(), 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,
));
}
}
pub(crate) fn render_tool_confirm_content(
app: &ChatApp,
tc: &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 prefix = " ❯ type: ";
let prefix_w = display_width(prefix);
let indent_w = prefix_w;
let avail_w = content_w.saturating_sub(prefix_w);
let avail_w = avail_w.max(4);
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);
let pointer_style = Style::default()
.fg(Color::Cyan)
.bg(confirm_bg)
.add_modifier(Modifier::BOLD);
let full_text = format!("{}{}{}", before, cursor_char, after);
let wrapped = wrap_text(&full_text, avail_w);
let mut char_idx = 0usize;
let mut cursor_line = 0usize;
let mut cursor_offset_in_line = 0usize;
for (li, line_str) in wrapped.iter().enumerate() {
let line_chars: Vec<char> = line_str.chars().collect();
if cursor_pos >= char_idx && cursor_pos < char_idx + line_chars.len() {
cursor_line = li;
cursor_offset_in_line = cursor_pos - char_idx;
break;
}
char_idx += line_chars.len();
if li == wrapped.len() - 1 && cursor_pos == char_idx {
cursor_line = li;
cursor_offset_in_line = line_chars.len();
}
}
for (li, _line_str) in wrapped.iter().enumerate() {
let is_first = li == 0;
let prefix_span = if is_first {
Span::styled(prefix, pointer_style)
} else {
Span::styled(" ".repeat(indent_w), text_style)
};
if li == cursor_line {
let line_str = &wrapped[li];
let line_chars: Vec<char> = line_str.chars().collect();
let line_before: String =
line_chars[..cursor_offset_in_line].iter().collect();
let cc = line_chars
.get(cursor_offset_in_line)
.copied()
.unwrap_or(' ');
let line_after: String =
line_chars[cursor_offset_in_line + 1..].iter().collect();
lines.push(bordered_line(
vec![
prefix_span,
Span::styled(line_before, text_style),
Span::styled(cc.to_string(), cursor_style),
Span::styled(line_after, text_style),
],
bubble_max_width,
border_color,
confirm_bg,
));
} else {
lines.push(bordered_line(
vec![prefix_span, Span::styled(wrapped[li].clone(), text_style)],
bubble_max_width,
border_color,
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)),
]));
}
}
}
}
pub(crate) 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();
let title_style = Style::default()
.fg(t.tool_confirm_title)
.add_modifier(Modifier::BOLD)
.bg(confirm_bg);
let title_wrapped = wrap_text(&title, content_w);
for line_text in title_wrapped {
lines.push(bordered_line(
vec![Span::styled(line_text, title_style)],
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),
)));
}
pub(crate) 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);
let title_style = Style::default()
.fg(t.tool_confirm_title)
.add_modifier(Modifier::BOLD)
.bg(confirm_bg);
let title_wrapped = wrap_text(&title, content_w);
for line_text in title_wrapped {
lines.push(bordered_line(
vec![Span::styled(line_text, title_style)],
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(PLAN_DISPLAY_MAX_LINES)
.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() > PLAN_DISPLAY_MAX_LINES {
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),
)));
}