use crate::app::{InlinePermission, ToolCallInfo};
use crate::ui::diff::{is_markdown_file, lang_from_title, render_diff, strip_outer_code_fence};
use crate::ui::markdown;
use crate::ui::theme;
use agent_client_protocol::{self as acp, PermissionOptionKind};
use ansi_to_tui::IntoText as _;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Paragraph, Wrap};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const SPINNER_STRS: &[&str] = &[
"\u{280B}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283C}", "\u{2834}", "\u{2826}", "\u{2827}",
"\u{2807}", "\u{280F}",
];
const TERMINAL_MAX_LINES: usize = 12;
pub fn status_icon(status: acp::ToolCallStatus, spinner_frame: usize) -> (&'static str, Color) {
match status {
acp::ToolCallStatus::Pending | acp::ToolCallStatus::InProgress => {
let s = SPINNER_STRS[spinner_frame % SPINNER_STRS.len()];
(s, theme::RUST_ORANGE)
}
acp::ToolCallStatus::Completed => (theme::ICON_COMPLETED, theme::RUST_ORANGE),
acp::ToolCallStatus::Failed => (theme::ICON_FAILED, theme::STATUS_ERROR),
_ => ("?", theme::DIM),
}
}
pub fn render_tool_call_cached(
tc: &mut ToolCallInfo,
width: u16,
spinner_frame: usize,
out: &mut Vec<Line<'static>>,
) {
let is_execute = matches!(tc.kind, acp::ToolKind::Execute);
if is_execute {
if tc.cache.get().is_none() {
crate::perf::mark("tc::cache_miss_execute");
let _t = crate::perf::start("tc::render_exec");
let content = render_execute_content(tc);
tc.cache.store(content);
} else {
crate::perf::mark("tc::cache_hit_execute");
}
if let Some(content) = tc.cache.get() {
let bordered = render_execute_with_borders(tc, content, width, spinner_frame);
let h = {
let _t = crate::perf::start_with("tc::wrap_height_exec", "lines", bordered.len());
Paragraph::new(Text::from(bordered.clone()))
.wrap(Wrap { trim: false })
.line_count(width)
};
tc.cache.set_height(h, width);
out.extend(bordered);
}
return;
}
let is_in_progress =
matches!(tc.status, acp::ToolCallStatus::InProgress | acp::ToolCallStatus::Pending);
if !is_in_progress {
if let Some(cached_lines) = tc.cache.get() {
crate::perf::mark_with("tc::cache_hit", "lines", cached_lines.len());
out.extend_from_slice(cached_lines);
return;
}
crate::perf::mark("tc::cache_miss");
let _t = crate::perf::start("tc::render");
let fresh = render_tool_call(tc, width, spinner_frame);
let h = {
let _t = crate::perf::start_with("tc::wrap_height", "lines", fresh.len());
Paragraph::new(Text::from(fresh.clone())).wrap(Wrap { trim: false }).line_count(width)
};
tc.cache.store(fresh);
tc.cache.set_height(h, width);
if let Some(stored) = tc.cache.get() {
out.extend_from_slice(stored);
}
return;
}
let fresh_title = render_tool_call_title(tc, width, spinner_frame);
out.push(fresh_title);
if let Some(cached_body) = tc.cache.get() {
crate::perf::mark_with("tc::cache_hit_body", "lines", cached_body.len());
out.extend_from_slice(cached_body);
} else {
crate::perf::mark("tc::cache_miss_body");
let _t = crate::perf::start("tc::render_body");
let body = render_tool_call_body(tc);
let h = {
let _t = crate::perf::start_with("tc::wrap_height_body", "lines", body.len());
Paragraph::new(Text::from(body.clone())).wrap(Wrap { trim: false }).line_count(width)
};
tc.cache.store(body);
tc.cache.set_height(h, width);
if let Some(stored) = tc.cache.get() {
out.extend_from_slice(stored);
}
}
}
pub fn measure_tool_call_height_cached(
tc: &mut ToolCallInfo,
width: u16,
spinner_frame: usize,
) -> (usize, usize) {
let is_execute = matches!(tc.kind, acp::ToolKind::Execute);
if is_execute {
if tc.cache.get().is_none() {
let content = render_execute_content(tc);
tc.cache.store(content);
}
if let Some(content) = tc.cache.get() {
let bordered = render_execute_with_borders(tc, content, width, spinner_frame);
let h = Paragraph::new(Text::from(bordered.clone()))
.wrap(Wrap { trim: false })
.line_count(width);
tc.cache.set_height(h, width);
return (h, bordered.len());
}
return (0, 0);
}
let is_in_progress =
matches!(tc.status, acp::ToolCallStatus::InProgress | acp::ToolCallStatus::Pending);
if !is_in_progress {
if let Some(h) = tc.cache.height_at(width) {
return (h, 0);
}
if let Some(cached_lines) = tc.cache.get().cloned() {
let h = Paragraph::new(Text::from(cached_lines.clone()))
.wrap(Wrap { trim: false })
.line_count(width);
tc.cache.set_height(h, width);
return (h, cached_lines.len());
}
let fresh = render_tool_call(tc, width, spinner_frame);
let h =
Paragraph::new(Text::from(fresh.clone())).wrap(Wrap { trim: false }).line_count(width);
tc.cache.store(fresh);
tc.cache.set_height(h, width);
return (h, tc.cache.get().map_or(0, Vec::len));
}
let title = render_tool_call_title(tc, width, spinner_frame);
let title_h =
Paragraph::new(Text::from(vec![title])).wrap(Wrap { trim: false }).line_count(width);
if let Some(body_h) = tc.cache.height_at(width) {
return (title_h + body_h, 1);
}
if let Some(cached_body) = tc.cache.get().cloned() {
let body_h = Paragraph::new(Text::from(cached_body.clone()))
.wrap(Wrap { trim: false })
.line_count(width);
tc.cache.set_height(body_h, width);
return (title_h + body_h, cached_body.len() + 1);
}
let body = render_tool_call_body(tc);
let body_h =
Paragraph::new(Text::from(body.clone())).wrap(Wrap { trim: false }).line_count(width);
tc.cache.store(body);
tc.cache.set_height(body_h, width);
(title_h + body_h, tc.cache.get().map_or(1, |b| b.len() + 1))
}
fn render_tool_call_title(tc: &ToolCallInfo, _width: u16, spinner_frame: usize) -> Line<'static> {
let (icon, icon_color) = status_icon(tc.status, spinner_frame);
let (kind_icon, _kind_name) = theme::tool_kind_label(tc.kind, tc.claude_tool_name.as_deref());
let mut title_spans = vec![
Span::styled(format!(" {icon} "), Style::default().fg(icon_color)),
Span::styled(
format!("{kind_icon} "),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
),
];
title_spans.extend(markdown_inline_spans(&tc.title));
Line::from(title_spans)
}
fn render_tool_call_body(tc: &ToolCallInfo) -> Vec<Line<'static>> {
let mut lines = Vec::new();
render_standard_body(tc, &mut lines);
lines
}
fn render_tool_call(tc: &ToolCallInfo, width: u16, spinner_frame: usize) -> Vec<Line<'static>> {
let title = render_tool_call_title(tc, width, spinner_frame);
let mut lines = vec![title];
render_standard_body(tc, &mut lines);
lines
}
fn render_standard_body(tc: &ToolCallInfo, lines: &mut Vec<Line<'static>>) {
let pipe_style = Style::default().fg(theme::DIM);
let has_permission = tc.pending_permission.is_some();
let has_diff = tc.content.iter().any(|c| matches!(c, acp::ToolCallContent::Diff(_)));
if tc.content.is_empty() && !has_permission {
return;
}
let effectively_collapsed = tc.collapsed && !has_diff && !has_permission;
if effectively_collapsed {
let summary = content_summary(tc);
lines.push(Line::from(vec![
Span::styled(" \u{2514}\u{2500} ", pipe_style),
Span::styled(summary, Style::default().fg(theme::DIM)),
Span::styled(" ctrl+o to expand", Style::default().fg(theme::DIM)),
]));
} else {
let mut content_lines = render_tool_content(tc);
if let Some(ref perm) = tc.pending_permission {
content_lines.extend(render_permission_lines(perm));
}
let last_idx = content_lines.len().saturating_sub(1);
for (i, content_line) in content_lines.into_iter().enumerate() {
let prefix = if i == last_idx {
" \u{2514}\u{2500} " } else {
" \u{2502} " };
let mut spans = vec![Span::styled(prefix.to_owned(), pipe_style)];
spans.extend(content_line.spans);
lines.push(Line::from(spans));
}
}
}
fn render_execute_content(tc: &ToolCallInfo) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
if let Some(ref cmd) = tc.terminal_command {
lines.push(Line::from(vec![
Span::styled(
"$ ",
Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD),
),
Span::styled(cmd.clone(), Style::default().fg(Color::Yellow)),
]));
}
let mut body_lines: Vec<Line<'static>> = Vec::new();
if let Some(ref output) = tc.terminal_output {
let raw_lines: Vec<Line<'static>> = if let Ok(ansi_text) = output.as_bytes().into_text() {
ansi_text
.lines
.into_iter()
.map(|line| {
let owned: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|s| Span::styled(s.content.into_owned(), s.style))
.collect();
Line::from(owned)
})
.collect()
} else {
output.lines().map(|l| Line::from(l.to_owned())).collect()
};
let total = raw_lines.len();
if total > TERMINAL_MAX_LINES {
let skipped = total - TERMINAL_MAX_LINES;
body_lines.push(Line::from(Span::styled(
format!("... {skipped} lines hidden ..."),
Style::default().fg(theme::DIM),
)));
body_lines.extend(raw_lines.into_iter().skip(skipped));
} else {
body_lines = raw_lines;
}
} else if matches!(tc.status, acp::ToolCallStatus::InProgress) {
body_lines.push(Line::from(Span::styled("running...", Style::default().fg(theme::DIM))));
}
lines.extend(body_lines);
if let Some(ref perm) = tc.pending_permission {
lines.extend(render_permission_lines(perm));
}
lines
}
fn render_execute_with_borders(
tc: &ToolCallInfo,
content: &[Line<'static>],
width: u16,
spinner_frame: usize,
) -> Vec<Line<'static>> {
let border = Style::default().fg(theme::DIM);
let inner_w = (width as usize).saturating_sub(2);
let mut out = Vec::with_capacity(content.len() + 2);
let (status_icon_str, icon_color) = status_icon(tc.status, spinner_frame);
let line_budget = width as usize;
let left_prefix = vec![
Span::styled(" \u{256D}\u{2500}", border),
Span::styled(format!(" {status_icon_str} "), Style::default().fg(icon_color)),
Span::styled("Bash ", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
];
let prefix_w = spans_width(&left_prefix);
let right_border_w = 1; let title_max_w = line_budget.saturating_sub(prefix_w + right_border_w + 1);
let title_spans = truncate_spans_to_width(markdown_inline_spans(&tc.title), title_max_w);
let title_w = spans_width(&title_spans);
let fill_w = line_budget.saturating_sub(prefix_w + title_w + right_border_w);
let top_fill: String = "\u{2500}".repeat(fill_w);
let mut top = left_prefix;
top.extend(title_spans);
top.push(Span::styled(format!("{top_fill}\u{256E}"), border));
out.push(Line::from(top));
for line in content {
let mut spans = vec![Span::styled(" \u{2502} ", border)];
spans.extend(line.spans.iter().cloned());
out.push(Line::from(spans));
}
let bottom_fill: String = "\u{2500}".repeat(inner_w.saturating_sub(2));
out.push(Line::from(Span::styled(format!(" \u{2570}{bottom_fill}\u{256F}"), border)));
out
}
fn render_permission_lines(perm: &InlinePermission) -> Vec<Line<'static>> {
if !perm.focused {
return vec![
Line::default(),
Line::from(Span::styled(
" \u{25cb} Waiting for input\u{2026} (\u{2191}\u{2193} to focus)",
Style::default().fg(theme::DIM),
)),
];
}
let mut spans: Vec<Span<'static>> = Vec::new();
let dot = Span::styled(" \u{00b7} ", Style::default().fg(theme::DIM));
for (i, opt) in perm.options.iter().enumerate() {
let is_selected = i == perm.selected_index;
let is_allow =
matches!(opt.kind, PermissionOptionKind::AllowOnce | PermissionOptionKind::AllowAlways);
let (icon, icon_color) = if is_allow {
("\u{2713}", Color::Green) } else {
("\u{2717}", Color::Red) };
if i > 0 {
spans.push(dot.clone());
}
if is_selected {
spans.push(Span::styled(
"\u{25b8} ",
Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD),
));
}
spans.push(Span::styled(format!("{icon} "), Style::default().fg(icon_color)));
let name_style = if is_selected {
Style::default().fg(Color::White).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
let mut name_spans = markdown_inline_spans(&opt.name);
if name_spans.is_empty() {
spans.push(Span::styled(opt.name.clone(), name_style));
} else {
for span in &mut name_spans {
span.style = span.style.patch(name_style);
}
spans.extend(name_spans);
}
let shortcut = match opt.kind {
PermissionOptionKind::AllowOnce => " (Ctrl+y)",
PermissionOptionKind::AllowAlways => " (Ctrl+a)",
PermissionOptionKind::RejectOnce => " (Ctrl+n)",
_ => "",
};
spans.push(Span::styled(shortcut, Style::default().fg(theme::DIM)));
}
vec![
Line::default(),
Line::from(spans),
Line::from(Span::styled(
"\u{2190}\u{2192} select \u{2191}\u{2193} next enter confirm esc reject",
Style::default().fg(theme::DIM),
)),
]
}
fn markdown_inline_spans(input: &str) -> Vec<Span<'static>> {
markdown::render_markdown_safe(input, None).into_iter().next().map_or_else(Vec::new, |line| {
line.spans.into_iter().map(|s| Span::styled(s.content.into_owned(), s.style)).collect()
})
}
fn spans_width(spans: &[Span<'static>]) -> usize {
spans.iter().map(|s| UnicodeWidthStr::width(s.content.as_ref())).sum()
}
fn truncate_spans_to_width(spans: Vec<Span<'static>>, max_width: usize) -> Vec<Span<'static>> {
if max_width == 0 {
return Vec::new();
}
if spans_width(&spans) <= max_width {
return spans;
}
let keep_width = max_width.saturating_sub(1);
let mut used = 0usize;
let mut out: Vec<Span<'static>> = Vec::new();
for span in spans {
if used >= keep_width {
break;
}
let mut chunk = String::new();
for ch in span.content.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + w > keep_width {
break;
}
chunk.push(ch);
used += w;
}
if !chunk.is_empty() {
out.push(Span::styled(chunk, span.style));
}
}
out.push(Span::styled("\u{2026}", Style::default().fg(theme::DIM)));
out
}
fn content_summary(tc: &ToolCallInfo) -> String {
if tc.terminal_id.is_some() {
if let Some(ref output) = tc.terminal_output {
if matches!(tc.status, acp::ToolCallStatus::Failed)
&& let Some(msg) = extract_tool_use_error_message(output)
{
return msg;
}
let last = output.lines().rev().find(|l| !l.trim().is_empty());
if let Some(line) = last {
return if line.chars().count() > 80 {
let truncated: String = line.chars().take(77).collect();
format!("{truncated}...")
} else {
line.to_owned()
};
}
}
return if matches!(tc.status, acp::ToolCallStatus::InProgress) {
"running...".to_owned()
} else {
String::new()
};
}
for content in &tc.content {
match content {
acp::ToolCallContent::Diff(diff) => {
let name = diff.path.file_name().map_or_else(
|| diff.path.to_string_lossy().into_owned(),
|f| f.to_string_lossy().into_owned(),
);
return name;
}
acp::ToolCallContent::Content(c) => {
if let acp::ContentBlock::Text(text) = &c.content {
let stripped = strip_outer_code_fence(&text.text);
if matches!(tc.status, acp::ToolCallStatus::Failed)
&& let Some(msg) = extract_tool_use_error_message(&stripped)
{
return msg;
}
let first = stripped.lines().next().unwrap_or("");
return if first.chars().count() > 60 {
let truncated: String = first.chars().take(57).collect();
format!("{truncated}...")
} else {
first.to_owned()
};
}
}
_ => {}
}
}
String::new()
}
fn render_tool_content(tc: &ToolCallInfo) -> Vec<Line<'static>> {
let is_execute = matches!(tc.kind, acp::ToolKind::Execute);
let mut lines: Vec<Line<'static>> = Vec::new();
if is_execute {
if let Some(ref output) = tc.terminal_output {
if matches!(tc.status, acp::ToolCallStatus::Failed)
&& let Some(msg) = extract_tool_use_error_message(output)
{
lines.extend(render_tool_use_error_content(&msg));
} else if let Ok(ansi_text) = output.as_bytes().into_text() {
for line in ansi_text.lines {
let owned: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|s| Span::styled(s.content.into_owned(), s.style))
.collect();
lines.push(Line::from(owned));
}
} else {
for text_line in output.lines() {
lines.push(Line::from(text_line.to_owned()));
}
}
} else if matches!(tc.status, acp::ToolCallStatus::InProgress) {
lines.push(Line::from(Span::styled("running...", Style::default().fg(theme::DIM))));
}
debug_failed_tool_render(tc);
return lines;
}
for content in &tc.content {
match content {
acp::ToolCallContent::Diff(diff) => {
lines.extend(render_diff(diff));
}
acp::ToolCallContent::Content(c) => {
if let acp::ContentBlock::Text(text) = &c.content {
let stripped = strip_outer_code_fence(&text.text);
if matches!(tc.status, acp::ToolCallStatus::Failed)
&& let Some(msg) = extract_tool_use_error_message(&stripped)
{
lines.extend(render_tool_use_error_content(&msg));
continue;
}
if matches!(tc.status, acp::ToolCallStatus::Failed)
&& looks_like_internal_error(&stripped)
{
lines.extend(render_internal_failure_content(&stripped));
continue;
}
let md_source = if is_markdown_file(&tc.title) {
stripped
} else {
let lang = lang_from_title(&tc.title);
format!("```{lang}\n{stripped}\n```")
};
for line in markdown::render_markdown_safe(&md_source, None) {
let owned: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|s| Span::styled(s.content.into_owned(), s.style))
.collect();
lines.push(Line::from(owned));
}
}
}
_ => {}
}
}
debug_failed_tool_render(tc);
lines
}
fn render_internal_failure_content(payload: &str) -> Vec<Line<'static>> {
let summary = summarize_internal_error(payload);
let mut lines = vec![Line::from(Span::styled(
"Internal ACP/adapter error",
Style::default().fg(theme::STATUS_ERROR).add_modifier(Modifier::BOLD),
))];
if !summary.is_empty() {
lines.push(Line::from(Span::styled(summary, Style::default().fg(theme::STATUS_ERROR))));
}
lines
}
fn render_tool_use_error_content(message: &str) -> Vec<Line<'static>> {
message
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| {
Line::from(Span::styled(line.to_owned(), Style::default().fg(theme::STATUS_ERROR)))
})
.collect()
}
fn debug_failed_tool_render(tc: &ToolCallInfo) {
if !matches!(tc.status, acp::ToolCallStatus::Failed) {
return;
}
let Some(text_payload) = tc.content.iter().find_map(|content| match content {
acp::ToolCallContent::Content(c) => match &c.content {
acp::ContentBlock::Text(t) => Some(t.text.as_str().to_owned()),
_ => None,
},
_ => None,
}) else {
return;
};
if !looks_like_internal_error(&text_payload) {
return;
}
let text_preview = summarize_internal_error(&text_payload);
let terminal_preview = tc
.terminal_output
.as_deref()
.map_or_else(|| "<no terminal output>".to_owned(), preview_for_log);
tracing::debug!(
tool_call_id = %tc.id,
title = %tc.title,
kind = ?tc.kind,
content_blocks = tc.content.len(),
text_preview = %text_preview,
terminal_preview = %terminal_preview,
"Failed tool call render payload"
);
}
fn preview_for_log(input: &str) -> String {
const LIMIT: usize = 240;
let mut out = String::new();
for (i, ch) in input.chars().enumerate() {
if i >= LIMIT {
out.push_str("...");
break;
}
out.push(ch);
}
out.replace('\n', "\\n")
}
fn looks_like_internal_error(input: &str) -> bool {
let lower = input.to_ascii_lowercase();
has_internal_error_keywords(&lower)
|| looks_like_json_rpc_error_shape(&lower)
|| looks_like_xml_error_shape(&lower)
}
fn has_internal_error_keywords(lower: &str) -> bool {
[
"internal error",
"adapter",
"acp",
"json-rpc",
"rpc",
"protocol error",
"transport",
"handshake failed",
"session creation failed",
"connection closed",
"event channel closed",
]
.iter()
.any(|needle| lower.contains(needle))
}
fn looks_like_json_rpc_error_shape(lower: &str) -> bool {
(lower.contains("\"jsonrpc\"") && lower.contains("\"error\""))
|| lower.contains("\"code\":-32603")
|| lower.contains("\"code\": -32603")
}
fn looks_like_xml_error_shape(lower: &str) -> bool {
let has_error_node = lower.contains("<error") || lower.contains("<fault");
let has_detail_node = lower.contains("<message>") || lower.contains("<code>");
has_error_node && has_detail_node
}
fn extract_tool_use_error_message(input: &str) -> Option<String> {
extract_xml_tag_value(input, "tool_use_error")
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
}
fn summarize_internal_error(input: &str) -> String {
if let Some(msg) = extract_xml_tag_value(input, "message") {
return preview_for_log(msg);
}
if let Some(msg) = extract_json_string_field(input, "message") {
return preview_for_log(&msg);
}
let fallback = input.lines().find(|line| !line.trim().is_empty()).unwrap_or(input);
preview_for_log(fallback.trim())
}
fn extract_xml_tag_value<'a>(input: &'a str, tag: &str) -> Option<&'a str> {
let lower = input.to_ascii_lowercase();
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = lower.find(&open)? + open.len();
let end = start + lower[start..].find(&close)?;
let value = input[start..end].trim();
(!value.is_empty()).then_some(value)
}
fn extract_json_string_field(input: &str, field: &str) -> Option<String> {
let needle = format!("\"{field}\"");
let start = input.find(&needle)? + needle.len();
let rest = input[start..].trim_start();
let colon_idx = rest.find(':')?;
let mut chars = rest[colon_idx + 1..].trim_start().chars();
if chars.next()? != '"' {
return None;
}
let mut escaped = false;
let mut out = String::new();
for ch in chars {
if escaped {
let mapped = match ch {
'n' => '\n',
'r' => '\r',
't' => '\t',
'"' => '"',
'\\' => '\\',
_ => ch,
};
out.push(mapped);
escaped = false;
continue;
}
match ch {
'\\' => escaped = true,
'"' => return Some(out),
_ => out.push(ch),
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::BlockCache;
use pretty_assertions::assert_eq;
#[test]
fn status_icon_pending() {
let (icon, color) = status_icon(acp::ToolCallStatus::Pending, 0);
assert!(!icon.is_empty());
assert_eq!(color, theme::RUST_ORANGE);
}
#[test]
fn status_icon_in_progress() {
let (icon, color) = status_icon(acp::ToolCallStatus::InProgress, 3);
assert!(!icon.is_empty());
assert_eq!(color, theme::RUST_ORANGE);
}
#[test]
fn status_icon_completed() {
let (icon, color) = status_icon(acp::ToolCallStatus::Completed, 0);
assert_eq!(icon, theme::ICON_COMPLETED);
assert_eq!(color, theme::RUST_ORANGE);
}
#[test]
fn status_icon_failed() {
let (icon, color) = status_icon(acp::ToolCallStatus::Failed, 0);
assert_eq!(icon, theme::ICON_FAILED);
assert_eq!(color, theme::STATUS_ERROR);
}
#[test]
fn status_icon_spinner_wraps() {
let (icon_a, _) = status_icon(acp::ToolCallStatus::InProgress, 0);
let (icon_b, _) = status_icon(acp::ToolCallStatus::InProgress, SPINNER_STRS.len());
assert_eq!(icon_a, icon_b);
}
#[test]
fn status_icon_all_spinner_frames_valid() {
for i in 0..SPINNER_STRS.len() {
let (icon, _) = status_icon(acp::ToolCallStatus::InProgress, i);
assert!(!icon.is_empty());
}
}
#[test]
fn status_icon_spinner_frames_distinct() {
let frames: Vec<&str> = (0..SPINNER_STRS.len())
.map(|i| status_icon(acp::ToolCallStatus::InProgress, i).0)
.collect();
for i in 0..frames.len() {
for j in (i + 1)..frames.len() {
assert_ne!(frames[i], frames[j], "frames {i} and {j} are identical");
}
}
}
#[test]
fn status_icon_spinner_large_frame() {
let (icon, _) = status_icon(acp::ToolCallStatus::Pending, 999_999);
assert!(!icon.is_empty());
}
#[test]
fn truncate_spans_adds_ellipsis_when_needed() {
let spans = vec![Span::raw("abcdefghijklmnopqrstuvwxyz")];
let out = truncate_spans_to_width(spans, 8);
let rendered: String = out.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(rendered, "abcdefg\u{2026}");
assert!(spans_width(&out) <= 8);
}
#[test]
fn markdown_inline_spans_removes_markdown_syntax() {
let spans = markdown_inline_spans("**Allow** _once_");
let rendered: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(rendered.contains("Allow"));
assert!(rendered.contains("once"));
assert!(!rendered.contains('*'));
assert!(!rendered.contains('_'));
}
#[test]
fn execute_top_border_does_not_wrap_for_long_title() {
use crate::app::BlockCache;
let tc = ToolCallInfo {
id: "tc-1".into(),
title: "echo very long command title with markdown **bold** and path /a/b/c/d/e/f"
.into(),
kind: acp::ToolKind::Execute,
status: acp::ToolCallStatus::Pending,
content: Vec::new(),
collapsed: false,
claude_tool_name: Some("Bash".into()),
hidden: false,
terminal_id: None,
terminal_command: None,
terminal_output: None,
terminal_output_len: 0,
cache: BlockCache::default(),
pending_permission: None,
};
let rendered = render_execute_with_borders(&tc, &[], 80, 0);
let top = rendered.first().expect("top border line");
assert!(spans_width(&top.spans) <= 80);
}
#[test]
fn internal_error_detection_accepts_xml_payload() {
let payload =
"<error><code>-32603</code><message>Adapter process crashed</message></error>";
assert!(looks_like_internal_error(payload));
}
#[test]
fn internal_error_detection_rejects_plain_bash_failure() {
let payload = "bash: unknown_command: command not found";
assert!(!looks_like_internal_error(payload));
}
#[test]
fn summarize_internal_error_prefers_xml_message() {
let payload =
"<error><code>-32603</code><message>Adapter process crashed</message></error>";
assert_eq!(summarize_internal_error(payload), "Adapter process crashed");
}
#[test]
fn summarize_internal_error_reads_json_rpc_message() {
let payload = r#"{"jsonrpc":"2.0","error":{"code":-32603,"message":"internal rpc fault"}}"#;
assert_eq!(summarize_internal_error(payload), "internal rpc fault");
}
#[test]
fn extract_tool_use_error_message_reads_inner_text() {
let payload = "<tool_use_error>Sibling tool call errored</tool_use_error>";
assert_eq!(
extract_tool_use_error_message(payload).as_deref(),
Some("Sibling tool call errored")
);
}
#[test]
fn render_tool_use_error_content_shows_only_inner_text_lines() {
let lines = render_tool_use_error_content("Line A\nLine B");
let rendered: Vec<String> = lines
.iter()
.map(|line| line.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert_eq!(rendered, vec!["Line A", "Line B"]);
}
#[test]
fn content_summary_only_extracts_tool_use_error_for_failed_execute() {
let tc = ToolCallInfo {
id: "tc-1".into(),
title: "Bash".into(),
kind: acp::ToolKind::Execute,
status: acp::ToolCallStatus::Completed,
content: Vec::new(),
collapsed: true,
claude_tool_name: Some("Bash".into()),
hidden: false,
terminal_id: Some("term-1".into()),
terminal_command: Some("echo done".into()),
terminal_output: Some("<tool_use_error>bad</tool_use_error>\ndone".into()),
terminal_output_len: 0,
cache: BlockCache::default(),
pending_permission: None,
};
assert_eq!(content_summary(&tc), "done");
}
#[test]
fn content_summary_extracts_tool_use_error_for_failed_execute() {
let tc = ToolCallInfo {
id: "tc-1".into(),
title: "Bash".into(),
kind: acp::ToolKind::Execute,
status: acp::ToolCallStatus::Failed,
content: Vec::new(),
collapsed: true,
claude_tool_name: Some("Bash".into()),
hidden: false,
terminal_id: Some("term-1".into()),
terminal_command: Some("echo done".into()),
terminal_output: Some("<tool_use_error>bad</tool_use_error>\ndone".into()),
terminal_output_len: 0,
cache: BlockCache::default(),
pending_permission: None,
};
assert_eq!(content_summary(&tc), "bad");
}
}