use ratatui::{
style::Style,
text::{Line, Span},
};
use rho_tools::tool_card::{
DiffRow, DiffRowKind, ToolBody, ToolCard, ToolFact, ToolHeader, ToolStatus,
};
use unicode_width::UnicodeWidthStr;
use super::{
feed_image::reserve_optional_image_rows,
render::{
display_width, pad_entry_line, padded_inner_width, push_wrapped_text, slice_spans_by_bytes,
spans_display_width, styled_blank_line, wrap_line_at_whitespace_ranges, wrap_line_hard,
wrap_spans_hard, LineFill,
},
theme::Theme,
tool_diff, ToolEntry,
};
const TREE_INDENT: &str = " ";
const TREE_BRANCH_MID: &str = "├ ";
const TREE_BRANCH_END: &str = "â”” ";
const TREE_CONTINUE: &str = " ";
const HEADER_WRAP_STEM: &str = " │ ";
const CHILD_CONTENT_INDENT: &str = " ";
pub(super) fn tool_entry_lines(
tool: &ToolEntry,
width: usize,
max_tool_output_lines: usize,
) -> Vec<Line<'static>> {
let inner_width = padded_inner_width(width);
let mut lines = Vec::new();
push_tool_card(
&mut lines,
&tool.card,
inner_width,
max_tool_output_lines,
tool.expanded,
);
reserve_optional_image_rows(&mut lines, tool.image.as_ref(), width);
let padding_style = Theme::tool_card_padding();
let mut padded = Vec::with_capacity(lines.len() + 1);
padded.extend(lines.into_iter().map(pad_entry_line));
padded.push(styled_blank_line(width, padding_style));
padded
}
pub(super) fn push_tool_card(
lines: &mut Vec<Line<'static>>,
card: &ToolCard,
width: usize,
max_tool_output_lines: usize,
expanded: bool,
) {
push_header_line(lines, card, card.status, width);
let budget = max_tool_output_lines.max(1);
let children = render_child_groups(card, width);
let total_rows: usize = children.iter().map(Vec::len).sum();
let show_collapse_prompt = expanded && total_rows > budget;
let mut remaining = if expanded { usize::MAX } else { budget };
let mut hidden_rows = 0usize;
let mut emitted = Vec::new();
for group in children {
if remaining == 0 {
hidden_rows = hidden_rows.saturating_add(group.len());
continue;
}
if group.len() <= remaining {
remaining = remaining.saturating_sub(group.len());
emitted.push(group);
continue;
}
hidden_rows = hidden_rows.saturating_add(group.len() - remaining);
let mut clipped = group;
clipped.truncate(remaining);
remaining = 0;
if !clipped.is_empty() {
emitted.push(clipped);
}
}
let show_expand_prompt = !expanded && hidden_rows > 0;
let has_prompt = show_expand_prompt || show_collapse_prompt;
let last_child = emitted.len().saturating_sub(1);
for (index, group) in emitted.into_iter().enumerate() {
let is_last_child = index == last_child && !has_prompt;
for (row_index, mut row) in group.into_iter().enumerate() {
if row_index == 0 {
rewrite_fact_branch(&mut row, is_last_child);
}
lines.push(row);
}
}
if show_expand_prompt {
let prompt = format!("... {hidden_rows} more lines, ctrl+o to expand");
push_wrapped_text(lines, &prompt, width, Theme::dim(), LineFill::PadToWidth);
} else if show_collapse_prompt {
push_wrapped_text(
lines,
"ctrl+o to collapse",
width,
Theme::dim(),
LineFill::PadToWidth,
);
}
}
pub(super) fn card_is_toggleable(
card: &ToolCard,
width: usize,
max_tool_output_lines: usize,
_expanded: bool,
) -> bool {
let budget = max_tool_output_lines.max(1);
let total_rows: usize = render_child_groups(card, width).iter().map(Vec::len).sum();
total_rows > budget
}
fn render_child_groups(card: &ToolCard, width: usize) -> Vec<Vec<Line<'static>>> {
let mut groups = Vec::new();
for fact in &card.facts {
let mut lines = Vec::new();
push_fact_line(&mut lines, fact, false, width);
groups.push(lines);
}
match &card.body {
ToolBody::None => {}
ToolBody::Lines(body) => {
for line in tool_diff::logical_lines(body) {
let mut lines = Vec::new();
push_body_line(&mut lines, &line, width, Theme::text());
groups.push(lines);
}
}
ToolBody::Diff(rows) => {
let gutter = tool_diff::gutter_width(rows);
for row in rows {
let mut lines = Vec::new();
push_diff_row(&mut lines, row, gutter, width);
groups.push(lines);
}
}
}
groups
}
fn rewrite_fact_branch(line: &mut Line<'static>, is_last: bool) {
let Some(first) = line.spans.first_mut() else {
return;
};
let content = first.content.as_ref();
let mid = format!("{TREE_INDENT}{TREE_BRANCH_MID}");
let end = format!("{TREE_INDENT}{TREE_BRANCH_END}");
if content.starts_with(&mid) || content.starts_with(&end) {
let suffix = &content[mid.len().min(content.len())..];
first.content = format!(
"{}{suffix}",
if is_last {
format!("{TREE_INDENT}{TREE_BRANCH_END}")
} else {
format!("{TREE_INDENT}{TREE_BRANCH_MID}")
}
)
.into();
}
}
fn push_header_line(
lines: &mut Vec<Line<'static>>,
card: &ToolCard,
status: ToolStatus,
width: usize,
) {
let marker = Span::styled(format!("{} ", status.marker()), Theme::tool_marker(status));
match &card.header {
ToolHeader::Call { verb, primary } => {
let mut prefix = vec![
marker,
Span::styled(verb.clone(), Theme::tool_verb(card.family)),
];
match primary.as_ref().filter(|primary| !primary.is_empty()) {
Some(primary) => {
prefix.push(Span::styled("(", Theme::tool_primary()));
let wrappable = vec![
Span::styled(primary.clone(), Theme::tool_primary()),
Span::styled(")", Theme::tool_primary()),
];
push_wrapped_header(lines, prefix, wrappable, width);
}
None => lines.push(pad_spans_line(prefix, width)),
}
}
ToolHeader::Shell { prompt, command } => {
let mut prefix = vec![
marker,
Span::styled(prompt.clone(), Theme::tool_verb(card.family)),
];
match command.as_ref().filter(|command| !command.is_empty()) {
Some(command) => {
prefix.push(Span::raw(" "));
let wrappable = vec![Span::styled(command.clone(), Theme::tool_primary())];
push_wrapped_header(lines, prefix, wrappable, width);
}
None => lines.push(pad_spans_line(prefix, width)),
}
}
ToolHeader::StatusFirst { identity, detail } => {
let mut prefix = vec![
marker,
Span::styled(identity.clone(), Theme::tool_verb(card.family)),
];
if detail.is_empty() {
lines.push(pad_spans_line(prefix, width));
} else {
prefix.push(Span::raw(" "));
let wrappable = vec![Span::styled(detail.clone(), Theme::text())];
push_wrapped_header(lines, prefix, wrappable, width);
}
}
}
}
fn push_wrapped_header(
lines: &mut Vec<Line<'static>>,
prefix: Vec<Span<'static>>,
wrappable: Vec<Span<'static>>,
width: usize,
) {
let hang = spans_display_width(&prefix);
if hang >= width {
let mut spans = prefix;
spans.extend(wrappable);
lines.push(pad_spans_line(spans, width));
return;
}
let content_width = (width - hang).max(1);
let text: String = wrappable.iter().map(|span| span.content.as_ref()).collect();
if text.is_empty() {
lines.push(pad_spans_line(prefix, width));
return;
}
let mut row_index = 0usize;
for logical_line in text.lines() {
let line_start = subslice_start(&text, logical_line);
for (wrap_index, range) in wrap_line_at_whitespace_ranges(logical_line, content_width)
.into_iter()
.enumerate()
{
let mut start = range.start;
let end = range.end;
if wrap_index > 0 {
while start < end {
let ch = logical_line[start..].chars().next().expect("start < end");
if !ch.is_whitespace() {
break;
}
start += ch.len_utf8();
}
if start >= end {
continue;
}
}
let chunk_spans =
slice_spans_by_bytes(&wrappable, line_start + start, line_start + end);
let mut row = if row_index == 0 {
prefix.clone()
} else {
header_wrap_continuation_prefix(hang)
};
row.extend(chunk_spans);
lines.push(pad_spans_line(row, width));
row_index += 1;
}
}
}
fn subslice_start(parent: &str, child: &str) -> usize {
let start = child.as_ptr() as usize - parent.as_ptr() as usize;
debug_assert!(parent.get(start..start + child.len()) == Some(child));
start
}
fn header_wrap_continuation_prefix(hang: usize) -> Vec<Span<'static>> {
let stem_width = display_width(HEADER_WRAP_STEM);
let mut spans = vec![Span::styled(
HEADER_WRAP_STEM.to_string(),
Theme::tool_tree(),
)];
if hang > stem_width {
spans.push(Span::styled(" ".repeat(hang - stem_width), Theme::text()));
}
spans
}
fn push_fact_line(lines: &mut Vec<Line<'static>>, fact: &ToolFact, is_last: bool, width: usize) {
let branch = if is_last {
TREE_BRANCH_END
} else {
TREE_BRANCH_MID
};
let prefix = format!("{TREE_INDENT}{branch}");
let prefix_width = display_width(&prefix);
let content_width = width.saturating_sub(prefix_width).max(1);
let wrapped = wrap_spans_hard(&fact_spans(fact), content_width);
let mut first_line = vec![Span::styled(prefix, Theme::tool_tree())];
first_line.extend(wrapped[0].clone());
lines.push(pad_spans_line(first_line, width));
for row in wrapped.iter().skip(1) {
let mut continuation = vec![Span::styled(
format!("{TREE_INDENT}{TREE_CONTINUE}"),
Theme::tool_tree(),
)];
continuation.extend(row.clone());
lines.push(pad_spans_line(continuation, width));
}
}
fn fact_spans(fact: &ToolFact) -> Vec<Span<'static>> {
match fact {
ToolFact::DiffStat {
added,
removed,
path,
} => {
let mut spans = vec![
Span::styled(format!("+{added}"), Theme::tool_stat_add()),
Span::raw(" "),
Span::styled(format!("-{removed}"), Theme::tool_stat_del()),
Span::styled(" lines", Theme::tool_meta()),
];
if let Some(path) = path.as_ref().filter(|path| !path.is_empty()) {
spans.push(Span::styled(" | ", Theme::tool_meta()));
spans.push(Span::styled(path.clone(), Theme::tool_path()));
}
spans
}
ToolFact::Exit { code, duration_ms } => {
let status = if *code == 0 {
ToolStatus::Ok
} else {
ToolStatus::Error
};
let mut spans = vec![Span::styled(
format!("exit {code}"),
Theme::tool_exit(status),
)];
if let Some(ms) = duration_ms {
let secs = *ms as f64 / 1000.0;
spans.push(Span::styled(format!(" · {secs:.1}s"), Theme::tool_meta()));
}
spans
}
ToolFact::Count {
label,
value,
detail,
} => {
let mut text = format!("{value} {label}");
if let Some(detail) = detail.as_ref().filter(|detail| !detail.is_empty()) {
text.push(' ');
text.push_str(detail);
}
vec![Span::styled(text, Theme::text())]
}
ToolFact::Meta { text } => vec![Span::styled(text.clone(), Theme::tool_meta())],
ToolFact::Error { text } => vec![Span::styled(text.clone(), Theme::tool_error_text())],
ToolFact::Progress { completed, total } => {
let text = match total {
Some(total) => format!("{completed}/{total}"),
None => format!("{completed}"),
};
vec![Span::styled(text, Theme::tool_meta())]
}
ToolFact::Text { text } => vec![Span::styled(text.clone(), Theme::text())],
}
}
fn push_diff_row(lines: &mut Vec<Line<'static>>, row: &DiffRow, gutter: usize, width: usize) {
if row.kind == DiffRowKind::File {
push_body_line(lines, &row.text, width, Theme::tool_path());
return;
}
let number = match (gutter, row.line) {
(0, _) => String::new(),
(_, Some(line)) => format!("{line:>gutter$} "),
(_, None) => " ".repeat(gutter + 1),
};
let sign = format!("{} ", row.kind.sign());
let prefix_width = display_width(CHILD_CONTENT_INDENT) + display_width(&number) + sign.len();
let content_width = width.saturating_sub(prefix_width).max(1);
let text_style = Theme::tool_diff_text(row.kind);
let mut chunks = wrap_line_hard(&row.text, content_width);
if chunks.is_empty() {
chunks.push(String::new());
}
for (index, chunk) in chunks.into_iter().enumerate() {
let mut spans = if index == 0 {
vec![
Span::styled(
format!("{CHILD_CONTENT_INDENT}{number}"),
Theme::tool_diff_gutter(),
),
Span::styled(sign.clone(), text_style),
]
} else {
vec![Span::styled(" ".repeat(prefix_width), Theme::tool_tree())]
};
spans.push(Span::styled(chunk, text_style));
lines.push(pad_spans_line(spans, width));
}
}
fn push_body_line(lines: &mut Vec<Line<'static>>, line: &str, width: usize, style: Style) {
let prefix = CHILD_CONTENT_INDENT;
let prefix_width = display_width(prefix);
let content_width = width.saturating_sub(prefix_width).max(1);
let chunks = wrap_line_hard(line, content_width);
if chunks.is_empty() {
lines.push(pad_spans_line(
vec![
Span::styled(prefix.to_string(), Theme::tool_tree()),
Span::styled(String::new(), style),
],
width,
));
return;
}
for chunk in chunks {
lines.push(pad_spans_line(
vec![
Span::styled(prefix.to_string(), Theme::tool_tree()),
Span::styled(chunk, style),
],
width,
));
}
}
fn pad_spans_line(mut spans: Vec<Span<'static>>, width: usize) -> Line<'static> {
let used = spans
.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.sum::<usize>();
if used < width {
spans.push(Span::styled(" ".repeat(width - used), Theme::text()));
}
Line::from(spans)
}
#[cfg(test)]
#[path = "tool_card_render_tests.rs"]
mod tests;