pub(crate) use super::markdown::LinkSpan;
use super::markdown::{collect_link_spans, render_markdown, render_markdown_styled};
use crate::{
core::{
extensions,
provider::{AgentMessage, ContentBlock},
skills::{self, SkillBlock},
},
modes::interactive::{
app::{MsgLevel, SysMsg, SysSpan, sys_spans_text},
theme::Theme,
},
utils::{
display::{
char_width, display_width, format_cost_usd, grapheme_width, sanitize_output,
truncate_display, wrap_line,
},
glyphs::{DEF_DONE, DEF_ELLIPSIS, DEF_FAILED, DEF_NESTED},
terminal_image,
time::now_ms,
},
};
use ratatui::{
style::{Modifier, Style},
text::{Line, Span},
};
use serde_json::Value;
use std::{collections::HashMap, sync::Arc, time::Duration};
pub type HistorySegment = (usize, Arc<Vec<Line<'static>>>, Arc<Vec<PendingTool>>);
const IMAGE_MAX_COLS: usize = 60;
pub(crate) const SYSTEM_SEQ_BASE: usize = 100_000_000;
const USER_MSG_BG: (&str, &str) = ("userMessageBg", "#343541");
const USER_MSG_TEXT: (&str, &str) = ("userMessageText", "#d4d4d4");
const THINKING_TEXT: (&str, &str) = ("thinkingText", "#808080");
const CUSTOM_MSG_BG: (&str, &str) = ("customMessageBg", "#2d2838");
const CUSTOM_MSG_TEXT: (&str, &str) = ("customMessageText", "#d4d4d4");
const CUSTOM_MSG_LABEL: (&str, &str) = ("customMessageLabel", "#9575cd");
const TOOL_PENDING_BG: (&str, &str) = ("toolPendingBg", "#282832");
const TOOL_SUCCESS_BG: (&str, &str) = ("toolSuccessBg", "#283228");
const TOOL_ERROR_BG: (&str, &str) = ("toolErrorBg", "#3c2828");
const TOOL_TITLE: (&str, &str) = ("toolTitle", "#d4d4d4");
const TOOL_OUTPUT: (&str, &str) = ("toolOutput", "#808080");
const DIFF_ADDED: (&str, &str) = ("toolDiffAdded", "#98c379");
const DIFF_REMOVED: (&str, &str) = ("toolDiffRemoved", "#e06c75");
const DIFF_CONTEXT: (&str, &str) = ("toolDiffContext", "#6a737d");
const ERROR: (&str, &str) = ("error", "#cc6666");
const SYS_LEVEL_KEYS: [(&str, &str, bool); 4] = [
("dim", "#666666", false),
("success", "#b5bd68", false),
("warning", "#d8a657", true),
("error", "#cc6666", true),
];
const BASH_PREVIEW_LINES: usize = 5;
const TOOL_PREVIEW_LINES: usize = 20;
const MCP_PREVIEW_LINES: usize = 5;
const WRITE_PREVIEW_LINES: usize = 10;
const COLLAPSED_ARGS_CHARS: usize = 100;
const SUB_CALL_PREVIEW_COUNT: usize = 8;
const ARG_INDENT: &str = " ";
const ARG_CONTINUATION_INDENT: &str = " ";
const CUSTOM_CALL_HEADER_TOOLS: &[&str] = &["read", "write", "edit", "ls", "grep", "find"];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BlockKind {
Summary,
Skill,
Thinking,
Tool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct BlockId {
pub ts: u64,
pub seq: usize,
pub block: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BlockSpan {
pub index: usize,
pub start: usize,
pub end: usize,
}
pub fn collapsible_blocks(msg: &AgentMessage) -> Vec<BlockKind> {
match msg.role.as_str() {
"compactionSummary" | "branchSummary" => vec![BlockKind::Summary],
"user" => {
if skills::parse_skill_block(&msg.text()).is_some() {
vec![BlockKind::Skill]
} else {
Vec::new()
}
}
"assistant" => {
let mut out = Vec::new();
let content = &msg.content;
let mut i = 0;
while i < content.len() {
match &content[i] {
ContentBlock::Thinking { .. } => {
let mut non_empty = false;
while i < content.len() {
if let ContentBlock::Thinking { thinking, .. } = &content[i] {
if !thinking.trim().is_empty() {
non_empty = true;
}
i += 1;
} else {
break;
}
}
if non_empty {
out.push(BlockKind::Thinking);
}
}
ContentBlock::ToolCall { .. } => {
out.push(BlockKind::Tool);
i += 1;
}
_ => i += 1,
}
}
out
}
_ => Vec::new(),
}
}
pub fn default_block_expanded(kind: BlockKind, expand_all: bool, show_thinking: bool) -> bool {
match kind {
BlockKind::Thinking => show_thinking,
BlockKind::Summary | BlockKind::Skill | BlockKind::Tool => expand_all,
}
}
pub fn resolve_block_expansions(
msg: &AgentMessage,
ts: u64,
seq: usize,
overrides: &HashMap<BlockId, bool>,
expand_all: bool,
show_thinking: bool,
) -> Vec<bool> {
collapsible_blocks(msg)
.into_iter()
.enumerate()
.map(|(i, kind)| {
overrides
.get(&BlockId { ts, seq, block: i })
.copied()
.unwrap_or(default_block_expanded(kind, expand_all, show_thinking))
})
.collect()
}
#[derive(Debug, Clone)]
pub struct PendingTool {
pub id: String,
pub is_error: Option<bool>,
pub result: Option<String>,
pub duration_ms: Option<u64>,
pub started_at_ms: Option<u64>,
}
impl PendingTool {
fn from_call(id: &str, _name: &str, _args: Value, started_at_ms: Option<u64>) -> Self {
PendingTool {
id: id.to_string(),
is_error: None,
result: None,
duration_ms: None,
started_at_ms,
}
}
}
fn format_tool_duration(ms: u64) -> String {
let seconds = ms as f64 / 1000.0;
if seconds < 60.0 {
return format!("{seconds:.1}s");
}
let total = seconds as u64;
let minutes = total / 60;
let remainder = total % 60;
if minutes < 60 {
return format!("{minutes}m {remainder}s");
}
format!("{}h {}m {remainder}s", minutes / 60, minutes % 60)
}
pub(crate) fn render_sys_message(
spans: &[SysSpan],
level: MsgLevel,
width: usize,
theme: &Theme,
) -> Vec<Line<'static>> {
if sys_spans_text(spans).trim().is_empty() {
return Vec::new();
}
let out_w = width.saturating_sub(2);
if out_w == 0 {
return Vec::new();
}
let level_style = sys_level_style(level, theme);
let lines = sys_lines_from_spans(spans, level_style, theme);
let mut out: Vec<Line<'static>> = Vec::new();
for row in &lines {
if row.is_empty() {
out.push(Line::from(""));
continue;
}
out.extend(render_sys_row(row, out_w));
}
push_blank_gap(&mut out);
out
}
fn sys_level_style(level: MsgLevel, theme: &Theme) -> Style {
let (key, default, bold) = match level {
MsgLevel::Info => SYS_LEVEL_KEYS[0],
MsgLevel::Success => SYS_LEVEL_KEYS[1],
MsgLevel::Warning => SYS_LEVEL_KEYS[2],
MsgLevel::Error => SYS_LEVEL_KEYS[3],
};
let style = theme.style(key, default);
if bold {
style.add_modifier(Modifier::BOLD)
} else {
style
}
}
fn sys_lines_from_spans(
spans: &[SysSpan],
level_style: Style,
theme: &Theme,
) -> Vec<Vec<(Style, String)>> {
let mut lines: Vec<Vec<(Style, String)>> = vec![vec![]];
for span in spans {
let style = match &span.fg {
Some(spec) => {
let base = Style::default().fg(theme.resolve_color(spec));
if span.bold {
base.add_modifier(Modifier::BOLD)
} else {
base
}
}
None => {
if span.bold {
level_style.add_modifier(Modifier::BOLD)
} else {
level_style
}
}
};
for (i, piece) in span.text.split('\n').enumerate() {
if i > 0 {
lines.push(vec![]);
}
if !piece.is_empty() {
lines
.last_mut()
.unwrap()
.push((style, piece.replace('\t', " ")));
}
}
}
while lines.last().map(|l| l.is_empty()).unwrap_or(false) {
lines.pop();
}
lines
}
fn render_sys_row(row: &[(Style, String)], out_w: usize) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
let mut cur: Vec<Span<'static>> = Vec::new();
let mut cur_w = 0usize;
for (style, text) in row {
let mut byte_start = 0usize;
let mut w = 0usize;
for (bi, ch) in text.char_indices() {
let cw = char_width(ch);
if cw == 0 {
continue;
}
if cur_w + w + cw > out_w {
if w > 0 {
cur.push(Span::styled(text[byte_start..bi].to_string(), *style));
}
if !cur.is_empty() {
out.push(Line::from(std::mem::take(&mut cur)));
}
cur_w = 0;
byte_start = bi;
w = 0;
}
w += cw;
}
if w > 0 {
cur.push(Span::styled(text[byte_start..].to_string(), *style));
}
cur_w += w;
}
if !cur.is_empty() {
out.push(Line::from(cur));
}
out
}
#[allow(clippy::too_many_arguments)]
pub fn render_message(
msg: &AgentMessage,
width: usize,
theme: &Theme,
expand_all: bool,
show_thinking: bool,
pending: &mut Vec<PendingTool>,
tool_results: &HashMap<String, ToolResultView>,
tool_started_at: &HashMap<String, u64>,
nested_calls: &HashMap<String, Vec<NestedCallView>>,
edit_map: &HashMap<String, EditRenderData>,
) -> Vec<Line<'static>> {
let expansions: Vec<bool> = collapsible_blocks(msg)
.into_iter()
.map(|k| default_block_expanded(k, expand_all, show_thinking))
.collect();
let mut blocks = Vec::new();
render_message_blocks(
msg,
width,
theme,
&expansions,
&mut blocks,
pending,
tool_results,
tool_started_at,
nested_calls,
edit_map,
&mut Vec::new(),
ImageLayout::disabled(),
)
}
#[allow(clippy::too_many_arguments)]
pub fn render_message_blocks(
msg: &AgentMessage,
width: usize,
theme: &Theme,
expansions: &[bool],
blocks: &mut Vec<BlockSpan>,
pending: &mut Vec<PendingTool>,
tool_results: &HashMap<String, ToolResultView>,
tool_started_at: &HashMap<String, u64>,
nested_calls: &HashMap<String, Vec<NestedCallView>>,
edit_map: &HashMap<String, EditRenderData>,
images: &mut Vec<ImageSlot>,
layout: ImageLayout,
) -> Vec<Line<'static>> {
let mut out = match msg.role.as_str() {
"user" => {
let expand = expansions.first().copied().unwrap_or(false);
let mut lines = render_user(&msg.text(), width, theme, expand, blocks);
let bg = theme.style_bg(USER_MSG_BG.0, USER_MSG_BG.1);
for block in &msg.content {
if let ContentBlock::Image { data, .. } = block {
push_image_block(&mut lines, data, width, theme, layout, images, Some(bg));
}
}
lines
}
"assistant" => {
let mut cursor = 0usize;
render_assistant(
msg,
width,
theme,
expansions,
&mut cursor,
blocks,
pending,
tool_results,
tool_started_at,
nested_calls,
edit_map,
images,
layout,
)
}
"toolResult" => render_tool_result(msg, width, theme, false, pending),
"compactionSummary" => {
let expand = expansions.first().copied().unwrap_or(false);
render_summary(&msg.text(), true, width, theme, expand, blocks)
}
"branchSummary" => {
let expand = expansions.first().copied().unwrap_or(false);
render_summary(&msg.text(), false, width, theme, expand, blocks)
}
_ => Vec::new(),
};
push_blank_gap(&mut out);
out
}
fn render_user(
text: &str,
width: usize,
theme: &Theme,
expand: bool,
blocks: &mut Vec<BlockSpan>,
) -> Vec<Line<'static>> {
if let Some(block) = skills::parse_skill_block(text) {
let mut out = render_skill_invocation(&block, width, theme, expand, blocks);
if let Some(user_message) = block.user_message {
out.push(Line::from(""));
out.extend(render_user(
&user_message,
width,
theme,
expand,
&mut Vec::new(),
));
}
return out;
}
let inner = width.saturating_sub(2).max(1);
let lines = render_markdown_styled(
text,
inner,
theme,
theme.style(USER_MSG_TEXT.0, USER_MSG_TEXT.1),
);
let bg = theme.style_bg(USER_MSG_BG.0, USER_MSG_BG.1);
let mut out = Vec::new();
out.push(Line::from(Span::styled(" ".repeat(width), bg)));
if lines.is_empty() {
out.push(bg_line(Line::from(""), width, bg, 1));
} else {
for line in lines {
out.push(bg_line(line, width, bg, 1));
}
}
out.push(Line::from(Span::styled(" ".repeat(width), bg)));
out
}
fn render_skill_invocation(
block: &SkillBlock,
width: usize,
theme: &Theme,
expand: bool,
blocks: &mut Vec<BlockSpan>,
) -> Vec<Line<'static>> {
let label_style = theme
.style(CUSTOM_MSG_LABEL.0, CUSTOM_MSG_LABEL.1)
.add_modifier(Modifier::BOLD);
let text_style = theme.style(CUSTOM_MSG_TEXT.0, CUSTOM_MSG_TEXT.1);
let muted = theme.style("muted", "#808080");
let bg = theme.style_bg(CUSTOM_MSG_BG.0, CUSTOM_MSG_BG.1);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled(" ".repeat(width), bg)));
if expand {
lines.push(bg_line(
Line::from(vec![Span::styled("[skill]", label_style)]),
width,
bg,
1,
));
lines.push(Line::from(Span::styled(" ".repeat(width), bg))); let inner = width.saturating_sub(2).max(1);
let md = format!("**{}**\n\n{}", block.name, block.content);
for l in render_markdown_styled(&md, inner, theme, text_style) {
lines.push(bg_line(l, width, bg, 1));
}
} else {
let line = Line::from(vec![
Span::styled("[skill] ", label_style),
Span::styled(block.name.clone(), text_style),
Span::styled(" (Ctrl+O/Alt+click to expand)", muted),
]);
lines.push(bg_line(line, width, bg, 1));
}
lines.push(Line::from(Span::styled(" ".repeat(width), bg))); blocks.push(BlockSpan {
index: 0,
start: 0,
end: lines.len(),
});
lines
}
#[allow(clippy::too_many_arguments)]
fn render_extension_tool_block(
name: &str,
args: &Value,
result: Option<(&str, bool)>,
duration_ms: Option<u64>,
started_at_ms: Option<u64>,
theme: &Theme,
bg: Style,
width: usize,
inner: usize,
expand_all: bool,
) -> Option<Vec<Line<'static>>> {
let duration_ms = duration_ms.or_else(|| {
result
.is_none()
.then_some(started_at_ms)
.flatten()
.map(|started| now_ms().saturating_sub(started))
});
let ctx = extensions::ToolRenderCtx {
tool: name,
args,
result,
duration_ms,
expanded: expand_all,
width,
inner_width: inner,
};
let rendered = extensions::render_tool_block(name, &ctx)?;
let muted = theme.style("muted", "#808080");
let default_style = theme.style(TOOL_OUTPUT.0, TOOL_OUTPUT.1);
let mut visual: Vec<Line<'static>> = Vec::new();
for rich in rendered.lines {
let spans: Vec<Span<'static>> = rich
.into_iter()
.map(|s| {
let style = match s.fg.as_deref() {
Some(fg) => theme.style(fg, fg),
None => default_style,
};
Span::styled(s.text, style)
})
.collect();
visual.extend(wrap_spans(spans, inner));
}
let mut collapsed_hint: Option<String> = None;
if !expand_all
&& let Some(preview_lines) = rendered.preview_lines
&& preview_lines > 0
&& visual.len() > preview_lines
{
collapsed_hint = Some(format!(
"... ({} more lines, ctrl+o/alt+click to expand)",
visual.len() - preview_lines
));
visual.truncate(preview_lines);
}
let mut lines: Vec<Line<'static>> = visual
.into_iter()
.map(|l| bg_line(l, width, bg, 1))
.collect();
if let Some(hint) = collapsed_hint {
lines.push(bg_line(Line::from(Span::styled(hint, muted)), width, bg, 1));
}
Some(lines)
}
fn bg_line(line: Line<'static>, width: usize, bg: Style, pad: usize) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
if pad > 0 {
spans.push(Span::styled(" ".repeat(pad), bg));
}
let mut content_w = pad;
for sp in line.spans {
content_w += grapheme_width(&sp.content);
spans.push(Span::styled(sp.content, sp.style.patch(bg)));
}
if content_w < width {
spans.push(Span::styled(" ".repeat(width - content_w), bg));
}
Line::from(spans)
}
pub(crate) fn is_pure_gap(line: &Line<'static>) -> bool {
line.spans.iter().all(|s| s.content.trim().is_empty())
&& !line.spans.iter().any(|s| s.style.bg.is_some())
}
fn push_blank_gap(out: &mut Vec<Line<'static>>) {
let ends_blank = out.last().map(is_pure_gap).unwrap_or(true);
if !ends_blank {
out.push(Line::from(""));
}
}
fn push_image_gap(out: &mut Vec<Line<'static>>, width: usize, bg: Option<Style>) {
let ends_blank = out
.last()
.map(|line| line.spans.iter().all(|s| s.content.trim().is_empty()))
.unwrap_or(true);
if ends_blank {
return;
}
out.push(match bg {
Some(bg) => bg_line(Line::from(""), width, bg, 1),
None => Line::from(""),
});
}
#[allow(clippy::too_many_arguments)]
fn render_assistant(
msg: &AgentMessage,
width: usize,
theme: &Theme,
expansions: &[bool],
cursor: &mut usize,
blocks: &mut Vec<BlockSpan>,
pending: &mut Vec<PendingTool>,
tool_results: &HashMap<String, ToolResultView>,
tool_started_at: &HashMap<String, u64>,
nested_calls: &HashMap<String, Vec<NestedCallView>>,
edit_map: &HashMap<String, EditRenderData>,
images: &mut Vec<ImageSlot>,
layout: ImageLayout,
) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
let mut i = 0;
let content = &msg.content;
while i < content.len() {
match &content[i] {
ContentBlock::Text { text, .. } => {
let t = text.trim();
if !t.is_empty() {
out.extend(pad_markdown(t, width, theme, 1));
}
i += 1;
push_blank_gap(&mut out);
}
ContentBlock::Thinking { .. } => {
let mut run = String::new();
while i < content.len() {
if let ContentBlock::Thinking { thinking, .. } = &content[i] {
let t = thinking.trim();
if !t.is_empty() {
if !run.is_empty() {
run.push_str("\n\n");
}
run.push_str(t);
}
i += 1;
} else {
break;
}
}
if run.is_empty() {
continue;
}
let index = *cursor;
*cursor += 1;
let start = out.len();
out.extend(render_thinking_run(
&run,
expansions.get(index).copied().unwrap_or(false),
width,
theme,
));
blocks.push(BlockSpan {
index,
start,
end: out.len(),
});
push_blank_gap(&mut out);
}
ContentBlock::ToolCall {
id,
name,
arguments,
..
} => {
let view = tool_results.get(id.as_str());
let result = view.map(|v| (v.text.as_str(), v.is_error, v.duration_ms));
let nested = view
.map(|v| sub_calls_from_details(v.details.as_ref()))
.filter(|calls| !calls.is_empty())
.or_else(|| nested_calls.get(id.as_str()).cloned())
.unwrap_or_default();
let started_at = tool_started_at.get(id.as_str()).copied();
let edit = edit_map.get(id.as_str());
let index = *cursor;
*cursor += 1;
let start = out.len();
let images_before = images.len();
out.extend(render_tool_block_inner(
name,
arguments,
result.map(|(t, e, _)| (t, e)),
result.and_then(|(_, _, d)| d),
started_at,
edit,
&nested,
width,
theme,
expansions.get(index).copied().unwrap_or(false),
view.map(|v| v.images.as_slice()).unwrap_or(&[]),
images,
layout,
));
for slot in images[images_before..].iter_mut() {
slot.line += start;
}
blocks.push(BlockSpan {
index,
start,
end: out.len(),
});
pending.push(PendingTool::from_call(
id,
name,
arguments.clone(),
started_at,
));
i += 1;
push_blank_gap(&mut out);
}
ContentBlock::Image { data, .. } => {
push_image_block(&mut out, data, width, theme, layout, images, None);
i += 1;
push_blank_gap(&mut out);
}
}
}
append_stop_reason_hint(msg, theme, width, &mut out);
push_blank_gap(&mut out);
out
}
fn render_thinking_run(
run: &str,
visible: bool,
width: usize,
theme: &Theme,
) -> Vec<Line<'static>> {
let style = theme
.style(THINKING_TEXT.0, THINKING_TEXT.1)
.add_modifier(Modifier::ITALIC);
if visible {
let inner = width.saturating_sub(2).max(1);
render_markdown_styled(run, inner, theme, style)
.into_iter()
.map(|l| pad_line(l, 1))
.collect()
} else {
vec![Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled("Thinking...", style),
])]
}
}
pub(super) fn render_image_placeholder(theme: &Theme) -> Line<'static> {
Line::from(vec![Span::styled(
" [image]",
theme.style("muted", "#808080"),
)])
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ImageLayout {
pub enabled: bool,
pub font: (u16, u16),
}
impl ImageLayout {
pub fn disabled() -> Self {
Self {
enabled: false,
font: (1, 1),
}
}
}
#[derive(Debug, Clone)]
pub struct ImageSlot {
pub line: usize,
pub cols: u16,
pub rows: u16,
pub key: u64,
pub data: Arc<str>,
pub pad: terminal_image::PadRgb,
}
fn image_cell_size(img_w: u32, img_h: u32, width: usize, font: (u16, u16)) -> Option<(u16, u16)> {
let max_cols = width.saturating_sub(2).min(IMAGE_MAX_COLS) as u16;
let max_rows = ((max_cols as f32 * font.0 as f32 / font.1.max(1) as f32).ceil() as u16).max(1);
let (cols, rows) = terminal_image::fit_cell_size(img_w, img_h, font, max_cols, max_rows);
(cols > 0 && rows > 0).then_some((cols, rows))
}
fn push_image_block(
out: &mut Vec<Line<'static>>,
data: &str,
width: usize,
theme: &Theme,
layout: ImageLayout,
images: &mut Vec<ImageSlot>,
bg: Option<Style>,
) {
if !layout.enabled {
return;
}
push_image_gap(out, width, bg);
if let Some((img_w, img_h)) = terminal_image::image_dimensions(data)
&& let Some((cols, rows)) = image_cell_size(img_w, img_h, width, layout.font)
{
let line = out.len();
for _ in 0..rows {
out.push(match bg {
Some(bg) => bg_line(Line::from(""), width, bg, 1),
None => Line::from(Span::styled(" ".repeat(width), Style::default())),
});
}
let pad = terminal_image::image_bottom_pad(img_w, img_h, cols, rows, layout.font);
if pad * 2 < layout.font.1.max(1) as u32 {
out.push(match bg {
Some(bg) => bg_line(Line::from(""), width, bg, 1),
None => Line::from(""),
});
}
images.push(ImageSlot {
line,
cols,
rows,
key: terminal_image::image_key(data),
data: Arc::from(data),
pad: bg
.and_then(|style| style.bg)
.and_then(terminal_image::style_bg_rgb),
});
return;
}
out.push(render_image_placeholder(theme));
}
fn append_stop_reason_hint(
msg: &AgentMessage,
theme: &Theme,
width: usize,
out: &mut Vec<Line<'static>>,
) {
let aborted = msg.stop_reason.as_deref() == Some("aborted");
let errored = msg.stop_reason.as_deref() == Some("error");
let length = msg.stop_reason.as_deref() == Some("length");
let hint: Option<String> = if length {
Some("Response was truncated before completion.".to_string())
} else if aborted {
Some("Operation aborted".to_string())
} else if errored {
msg.error_message.clone().map(|e| format!("Error: {}", e))
} else {
None
};
if let Some(h) = hint {
if !out.is_empty() {
push_blank_gap(out);
}
let style = theme.style(ERROR.0, ERROR.1);
let inner = width.saturating_sub(2).max(1);
for wrapped in wrap_hint_line(&h, inner) {
out.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(wrapped, style),
]));
}
}
}
fn pad_markdown(text: &str, width: usize, theme: &Theme, pad: usize) -> Vec<Line<'static>> {
let inner = width.saturating_sub(pad).max(1);
render_markdown(text, inner, theme)
.into_iter()
.map(|l| pad_line(l, pad))
.collect()
}
fn pad_line(line: Line<'static>, pad: usize) -> Line<'static> {
if pad == 0 {
return line;
}
let mut spans = vec![Span::raw(" ".repeat(pad))];
spans.extend(line.spans);
Line::from(spans)
}
#[allow(clippy::too_many_arguments)]
pub fn render_tool_block(
name: &str,
args: &Value,
result: Option<(&str, bool)>,
duration_ms: Option<u64>,
started_at_ms: Option<u64>,
edit: Option<&EditRenderData>,
nested: &[NestedCallView],
width: usize,
theme: &Theme,
expand_all: bool,
) -> Vec<Line<'static>> {
render_tool_block_inner(
name,
args,
result,
duration_ms,
started_at_ms,
edit,
nested,
width,
theme,
expand_all,
&[],
&mut Vec::new(),
ImageLayout::disabled(),
)
}
#[allow(clippy::too_many_arguments)]
fn render_tool_block_inner(
name: &str,
args: &Value,
result: Option<(&str, bool)>,
duration_ms: Option<u64>,
started_at_ms: Option<u64>,
edit: Option<&EditRenderData>,
nested: &[NestedCallView],
width: usize,
theme: &Theme,
expand_all: bool,
result_images: &[Arc<str>],
images: &mut Vec<ImageSlot>,
layout: ImageLayout,
) -> Vec<Line<'static>> {
let bg_key = match result {
None => TOOL_PENDING_BG,
Some((_, true)) => TOOL_ERROR_BG,
Some((_, false)) => TOOL_SUCCESS_BG,
};
let bg = theme.style_bg(bg_key.0, bg_key.1);
let title = theme
.style(TOOL_TITLE.0, TOOL_TITLE.1)
.add_modifier(Modifier::BOLD);
let muted = theme.style("muted", "#808080");
let inner = width.saturating_sub(2).max(1);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled(" ".repeat(width), bg)));
if name != "edit"
&& let Some(rendered) = render_extension_tool_block(
name,
args,
result,
duration_ms,
started_at_ms,
theme,
bg,
width,
inner,
expand_all,
)
{
lines.extend(rendered);
lines.push(Line::from(Span::styled(" ".repeat(width), bg))); return lines;
}
for hl in tool_call_header(name, args, &title, &muted, inner, expand_all) {
lines.push(bg_line(hl, width, bg, 1));
}
if name == "edit"
&& let Some(edit) = edit
{
lines.extend(render_edit_body(
edit,
result,
duration_ms,
theme,
bg,
width,
inner,
));
} else {
lines.extend(render_tool_write_content(
name, args, theme, bg, width, inner, expand_all,
));
lines.extend(render_tool_output(
name,
result,
duration_ms,
started_at_ms,
nested,
theme,
bg,
width,
inner,
expand_all,
));
}
for data in result_images {
push_image_block(&mut lines, data, width, theme, layout, images, Some(bg));
}
lines.push(Line::from(Span::styled(" ".repeat(width), bg))); lines
}
fn render_edit_body(
edit: &EditRenderData,
result: Option<(&str, bool)>,
duration_ms: Option<u64>,
theme: &Theme,
bg: Style,
width: usize,
inner: usize,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
match result {
Some((text, true)) => {
let err_style = theme.style(ERROR.0, ERROR.1);
lines.push(bg_line(Line::from(""), width, bg, 1));
for l in wrap_text_lines(text.trim(), inner) {
lines.push(bg_line(
Line::from(Span::styled(l, err_style)),
width,
bg,
1,
));
}
}
_ => {
let diff = edit.result_diff.clone().or_else(|| edit.preview.clone());
if let Some(d) = diff {
lines.push(bg_line(Line::from(""), width, bg, 1));
for l in render_diff(&d, theme, inner) {
lines.push(bg_line(l, width, bg, 1));
}
} else if let Some((text, false)) = result
&& !text.trim().is_empty()
{
let output = theme.style(TOOL_OUTPUT.0, TOOL_OUTPUT.1);
lines.push(bg_line(Line::from(""), width, bg, 1));
for l in wrap_text_lines(text.trim(), inner) {
lines.push(bg_line(Line::from(Span::styled(l, output)), width, bg, 1));
}
}
}
}
if let Some(dur) = duration_ms {
let muted = theme.style("muted", "#808080");
lines.push(bg_line(Line::from(""), width, bg, 1));
let took = format!("Took {}", format_tool_duration(dur));
lines.push(bg_line(Line::from(Span::styled(took, muted)), width, bg, 1));
}
lines
}
fn render_diff(diff_text: &str, theme: &Theme, inner: usize) -> Vec<Line<'static>> {
let added = theme.style(DIFF_ADDED.0, DIFF_ADDED.1);
let removed = theme.style(DIFF_REMOVED.0, DIFF_REMOVED.1);
let context = theme.style(DIFF_CONTEXT.0, DIFF_CONTEXT.1);
let added_inv = added.add_modifier(Modifier::REVERSED);
let removed_inv = removed.add_modifier(Modifier::REVERSED);
let raw: Vec<&str> = diff_text.split('\n').collect();
let mut out: Vec<Line<'static>> = Vec::new();
let mut i = 0usize;
while i < raw.len() {
let prefix = diff_prefix(raw[i]);
if prefix == Some('-') {
let mut removed_lines: Vec<(&str, &str)> = Vec::new();
while i < raw.len() && diff_prefix(raw[i]) == Some('-') {
removed_lines.push(split_diff_line(raw[i]));
i += 1;
}
let mut added_lines: Vec<(&str, &str)> = Vec::new();
while i < raw.len() && diff_prefix(raw[i]) == Some('+') {
added_lines.push(split_diff_line(raw[i]));
i += 1;
}
if removed_lines.len() == 1 && added_lines.len() == 1 {
let (num, content) = removed_lines[0];
let (anum, acontent) = added_lines[0];
let (rspans, aspans) = intra_line_spans(
content,
acontent,
&removed,
&added,
&removed_inv,
&added_inv,
);
let mut r = vec![Span::styled(format!("-{} ", num), removed)];
r.extend(rspans);
let mut a = vec![Span::styled(format!("+{} ", anum), added)];
a.extend(aspans);
out.extend(wrap_spans(r, inner));
out.extend(wrap_spans(a, inner));
} else {
for (num, content) in removed_lines {
out.extend(wrap_styled(
format!("-{} {}", num, content),
&removed,
inner,
));
}
for (num, content) in added_lines {
out.extend(wrap_styled(format!("+{} {}", num, content), &added, inner));
}
}
} else if prefix == Some('+') {
let (num, content) = split_diff_line(raw[i]);
out.extend(wrap_styled(format!("+{} {}", num, content), &added, inner));
i += 1;
} else {
out.extend(wrap_styled(raw[i].to_string(), &context, inner));
i += 1;
}
}
out
}
fn diff_prefix(line: &str) -> Option<char> {
line.chars()
.next()
.filter(|c| *c == ' ' || *c == '-' || *c == '+')
}
fn split_diff_line(line: &str) -> (&str, &str) {
let rest = line.get(1..).unwrap_or("");
let bytes = rest.as_bytes();
let mut idx = 0usize;
while idx < bytes.len() && bytes[idx] == b' ' {
idx += 1;
}
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
let num = &rest[..idx];
let mut content = &rest[idx..];
content = content.strip_prefix(' ').unwrap_or(content);
(num, content)
}
#[allow(clippy::too_many_arguments)]
fn intra_line_spans(
old: &str,
new: &str,
removed: &Style,
added: &Style,
removed_inv: &Style,
added_inv: &Style,
) -> (Vec<Span<'static>>, Vec<Span<'static>>) {
let old_toks = word_tokens(old);
let new_toks = word_tokens(new);
let diff = similar::TextDiff::from_slices(&old_toks, &new_toks);
let mut rspans: Vec<Span<'static>> = Vec::new();
let mut aspans: Vec<Span<'static>> = Vec::new();
let mut first_removed = true;
let mut first_added = true;
for change in diff.iter_all_changes() {
let val: &str = change.value();
let is_ws = val.chars().all(char::is_whitespace);
match change.tag() {
similar::ChangeTag::Delete => {
let v = if first_removed {
let s = val.trim_start_matches(char::is_whitespace);
first_removed = false;
s
} else {
val
};
if !v.is_empty() {
rspans.push(Span::styled(
v.to_string(),
if is_ws { *removed } else { *removed_inv },
));
}
}
similar::ChangeTag::Insert => {
let v = if first_added {
let s = val.trim_start_matches(char::is_whitespace);
first_added = false;
s
} else {
val
};
if !v.is_empty() {
aspans.push(Span::styled(
v.to_string(),
if is_ws { *added } else { *added_inv },
));
}
}
similar::ChangeTag::Equal => {
rspans.push(Span::styled(val.to_string(), *removed));
aspans.push(Span::styled(val.to_string(), *added));
}
}
}
(rspans, aspans)
}
fn word_tokens(s: &str) -> Vec<&str> {
let mut toks: Vec<&str> = Vec::new();
let mut start = 0usize;
let mut last_ws: Option<bool> = None;
for (idx, c) in s.char_indices() {
let ws = c.is_whitespace();
if let Some(lw) = last_ws
&& lw != ws
{
toks.push(&s[start..idx]);
start = idx;
}
last_ws = Some(ws);
}
if start < s.len() {
toks.push(&s[start..]);
}
toks
}
fn wrap_styled(text: String, style: &Style, width: usize) -> Vec<Line<'static>> {
wrap_text_lines(&text, width)
.into_iter()
.map(|l| Line::from(Span::styled(l, *style)))
.collect()
}
fn wrap_spans(spans: Vec<Span<'static>>, width: usize) -> Vec<Line<'static>> {
if width == 0 {
return vec![Line::from(spans)];
}
let mut out: Vec<Line<'static>> = Vec::new();
let mut cur: Vec<Span<'static>> = Vec::new();
let mut cur_w = 0usize;
for sp in spans {
let mut byte_start = 0usize;
let mut w = 0usize;
for (bi, ch) in sp.content.char_indices() {
let cw = char_width(ch);
if cw == 0 {
continue;
}
if cur_w + w + cw > width {
if w > 0 {
cur.push(Span::styled(
sp.content[byte_start..bi].to_string(),
sp.style,
));
}
if !cur.is_empty() {
out.push(Line::from(std::mem::take(&mut cur)));
}
cur_w = 0;
byte_start = bi;
w = 0;
}
w += cw;
}
if w > 0 {
cur.push(Span::styled(sp.content[byte_start..].to_string(), sp.style));
}
cur_w += w;
}
if !cur.is_empty() {
out.push(Line::from(cur));
}
out
}
fn render_tool_write_content(
name: &str,
args: &serde_json::Value,
theme: &Theme,
bg: Style,
width: usize,
inner: usize,
expand_all: bool,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
if name == "write"
&& let Some(content) = args.get("content").and_then(|v| v.as_str())
{
let sanitized = sanitize_output(content);
let content = sanitized.trim();
if !content.is_empty() {
let output = theme.style(TOOL_OUTPUT.0, TOOL_OUTPUT.1);
let muted = theme.style("muted", "#808080");
lines.push(bg_line(Line::from(""), width, bg, 1));
let content_lines: Vec<&str> = content.lines().collect();
let total = content_lines.len();
let preview: Vec<&str> = if expand_all {
content_lines.clone()
} else {
content_lines[..content_lines.len().min(WRITE_PREVIEW_LINES)].to_vec()
};
for l in preview {
for wrapped in wrap_text_lines(l, inner) {
lines.push(bg_line(
Line::from(Span::styled(wrapped, output)),
width,
bg,
1,
));
}
}
if !expand_all && total > WRITE_PREVIEW_LINES {
let more = total - WRITE_PREVIEW_LINES;
let hint = format!(
"... ({} more lines, {} total, ctrl+o/alt+click to expand)",
more, total
);
lines.push(bg_line(Line::from(Span::styled(hint, muted)), width, bg, 1));
}
}
}
lines
}
#[allow(clippy::too_many_arguments)]
fn render_tool_output(
name: &str,
result: Option<(&str, bool)>,
duration_ms: Option<u64>,
started_at_ms: Option<u64>,
nested: &[NestedCallView],
theme: &Theme,
bg: Style,
width: usize,
inner: usize,
expand_all: bool,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
if result.is_none()
&& let Some(started) = started_at_ms
{
let muted = theme.style("muted", "#808080");
let elapsed = now_ms().saturating_sub(started);
lines.push(bg_line(Line::from(""), width, bg, 1));
let took = format!("Elapsed {}", format_tool_duration(elapsed));
lines.push(bg_line(Line::from(Span::styled(took, muted)), width, bg, 1));
lines.extend(render_sub_calls(
nested, expand_all, theme, bg, width, inner,
));
return lines;
}
if let Some((out_text, is_err)) = result {
let output = theme.style(TOOL_OUTPUT.0, TOOL_OUTPUT.1);
let muted = theme.style("muted", "#808080");
let sanitized = sanitize_output(out_text);
let text = sanitized.trim();
if !text.is_empty() {
lines.push(bg_line(Line::from(""), width, bg, 1));
let out_lines: Vec<&str> = text.lines().collect();
let is_shell = name == "bash" || name == "powershell";
let style = if is_err && !expand_all {
theme.style(ERROR.0, ERROR.1)
} else {
output
};
let (preview, hint_before, hint_after): (Vec<String>, Option<String>, Option<String>) =
if expand_all {
(
out_lines
.iter()
.flat_map(|l| wrap_text_lines(l, inner))
.collect(),
None,
None,
)
} else if is_shell {
let visual: Vec<String> = out_lines
.iter()
.flat_map(|l| wrap_text_lines(l, inner))
.collect();
let skipped = visual.len().saturating_sub(BASH_PREVIEW_LINES);
if skipped > 0 {
(
visual[skipped..].to_vec(),
Some(format!(
"... ({} earlier lines, ctrl+o/alt+click to expand)",
skipped
)),
None,
)
} else {
(visual, None, None)
}
} else {
let preview_lines = if name == "mcp" {
MCP_PREVIEW_LINES
} else {
TOOL_PREVIEW_LINES
};
let visual: Vec<String> = out_lines
.iter()
.flat_map(|l| wrap_text_lines(l, inner))
.collect();
if visual.len() > preview_lines {
(
visual[..preview_lines].to_vec(),
None,
Some(format!(
"... ({} more lines, ctrl+o/alt+click to expand)",
visual.len() - preview_lines
)),
)
} else {
(visual, None, None)
}
};
if let Some(h) = hint_before {
lines.push(bg_line(Line::from(Span::styled(h, muted)), width, bg, 1));
}
for l in preview {
lines.push(bg_line(Line::from(Span::styled(l, style)), width, bg, 1));
}
if let Some(h) = hint_after {
lines.push(bg_line(Line::from(Span::styled(h, muted)), width, bg, 1));
}
}
lines.extend(render_sub_calls(
nested, expand_all, theme, bg, width, inner,
));
if let Some(dur) = duration_ms {
lines.push(bg_line(Line::from(""), width, bg, 1));
let took = format!("Took {}", format_tool_duration(dur));
lines.push(bg_line(Line::from(Span::styled(took, muted)), width, bg, 1));
}
}
lines
}
fn render_sub_calls(
calls: &[NestedCallView],
expanded: bool,
theme: &Theme,
bg: Style,
width: usize,
inner: usize,
) -> Vec<Line<'static>> {
if calls.is_empty() {
return Vec::new();
}
let muted = theme.style("muted", "#808080");
let dim = theme.style("dim", "#808080");
let running = theme.style("warning", "#e5c07b");
let success = theme.style("success", "#98c379");
let error = theme.style("error", "#e06c75");
let shown = if expanded || calls.len() <= SUB_CALL_PREVIEW_COUNT {
calls
} else {
&calls[calls.len() - SUB_CALL_PREVIEW_COUNT..]
};
let mut lines = Vec::new();
let hidden = calls.len() - shown.len();
if hidden > 0 {
lines.push(bg_line(
Line::from(Span::styled(
format!("... ({hidden} earlier calls, ctrl+o/alt+click to expand)"),
muted,
)),
width,
bg,
1,
));
}
for call in shown {
let duration = match (call.duration_ms, call.started_at_ms) {
(Some(ms), _) => Some(format_tool_duration(ms)),
(None, Some(started)) => Some(format_tool_duration(now_ms().saturating_sub(started))),
(None, None) => None,
};
let (mark, mark_style) = match call.is_error {
None => (DEF_ELLIPSIS, running),
Some(true) => (DEF_FAILED, error),
Some(false) => (DEF_DONE, success),
};
let mut spans = vec![
Span::styled(format!("{mark} "), mark_style),
Span::styled(call.name.clone(), muted),
];
if !call.summary.is_empty() {
spans.push(Span::styled(format!(" {}", call.summary), muted));
}
if let Some(duration) = duration {
spans.push(Span::styled(format!(" {duration}"), dim));
}
if let Some(cost) = call.cost.filter(|cost| *cost != 0.0) {
spans.push(Span::styled(format!(" {}", format_cost_usd(cost)), dim));
}
for line in soft_wrap_spans(spans, inner) {
lines.push(bg_line(line, width, bg, 1));
}
}
let priced: Vec<f64> = calls
.iter()
.filter_map(|call| call.cost)
.filter(|cost| *cost != 0.0)
.collect();
if priced.len() > 1 {
let total: f64 = priced.iter().sum();
lines.push(bg_line(
Line::from(Span::styled(
format!("Model calls: {}", format_cost_usd(total)),
muted,
)),
width,
bg,
1,
));
}
lines
}
fn tool_call_header(
name: &str,
args: &Value,
title: &Style,
muted: &Style,
inner: usize,
expanded: bool,
) -> Vec<Line<'static>> {
if name == "bash" || name == "powershell" {
let cmd = args
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("...");
let prompt = if name == "bash" { "$" } else { "PS>" };
let mut text = format!("{} {}", prompt, sanitize_output(cmd).trim_end());
if let Some(t) = args.get("timeout").and_then(|v| v.as_f64()) {
text.push_str(&format!(" (timeout {}s)", t));
}
return soft_wrap_spans(vec![Span::styled(text, *title)], inner);
}
if name == "mcp" {
return mcp_call_header(args, title, muted, inner, expanded);
}
if !CUSTOM_CALL_HEADER_TOOLS.contains(&name) {
return generic_call_header(name, Some(args), title, muted, inner, expanded);
}
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled(name.to_string(), *title));
let path = args
.get("path")
.or_else(|| args.get("file_path"))
.and_then(|v| v.as_str());
let pattern = args.get("pattern").and_then(|v| v.as_str());
if let Some(p) = path {
spans.push(Span::raw(format!(" {}", p)));
} else if name == "ls" {
spans.push(Span::raw(" .")); } else if let Some(p) = pattern {
spans.push(Span::raw(format!(" {}", p)));
}
if let Some(lim) = args.get("limit").and_then(|v| v.as_i64()) {
spans.push(Span::styled(format!(" (limit {})", lim), *muted));
}
soft_wrap_spans(spans, inner)
}
fn mcp_call_header(
args: &Value,
title: &Style,
muted: &Style,
inner: usize,
expanded: bool,
) -> Vec<Line<'static>> {
let action = args.get("action").and_then(Value::as_str).unwrap_or("");
if action == "call" {
let server = args.get("server").and_then(Value::as_str).unwrap_or("");
let tool = args.get("name").and_then(Value::as_str).unwrap_or("");
let label = if server.is_empty() || tool.is_empty() {
"mcp".to_string()
} else {
format!("{server}/{tool}")
};
return generic_call_header(&label, args.get("arguments"), title, muted, inner, expanded);
}
let label = if action.is_empty() {
"mcp".to_string()
} else {
format!("mcp {action}")
};
let rest = args.as_object().map(|map| {
Value::Object(
map.iter()
.filter(|(key, _)| key.as_str() != "action")
.map(|(key, value)| (key.clone(), value.clone()))
.collect(),
)
});
generic_call_header(&label, rest.as_ref(), title, muted, inner, expanded)
}
fn generic_call_header(
label: &str,
args: Option<&Value>,
title: &Style,
muted: &Style,
inner: usize,
expanded: bool,
) -> Vec<Line<'static>> {
let entries: Vec<(&str, &Value)> = match args {
None => Vec::new(),
Some(Value::Object(map)) => map
.iter()
.map(|(key, value)| (key.as_str(), value))
.collect(),
Some(other) => vec![("args", other)],
};
let head = Span::styled(label.to_string(), *title);
if entries.is_empty() {
return soft_wrap_spans(vec![head], inner);
}
if !expanded {
let pairs = entries
.iter()
.map(|(key, value)| format!("{key}={}", compact_json(value)))
.collect::<Vec<_>>()
.join(" ");
let preview = if pairs.chars().count() > COLLAPSED_ARGS_CHARS {
format!(
"{}...",
pairs
.chars()
.take(COLLAPSED_ARGS_CHARS - 3)
.collect::<String>()
)
} else {
pairs
};
return soft_wrap_spans(
vec![head, Span::raw(" "), Span::styled(preview, *muted)],
inner,
);
}
let mut lines = soft_wrap_spans(vec![head], inner);
for (key, value) in entries {
for (index, segment) in expanded_arg_segments(key, value).into_iter().enumerate() {
let indent = if index == 0 {
ARG_INDENT
} else {
ARG_CONTINUATION_INDENT
};
lines.extend(soft_wrap_spans(
vec![Span::styled(format!("{indent}{segment}"), *muted)],
inner,
));
}
}
lines
}
pub fn compact_json(value: &Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
}
fn expanded_arg_segments(key: &str, value: &Value) -> Vec<String> {
let raw = match value {
Value::String(text) => text.clone(),
other => serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string()),
};
let normalized = raw.replace('\t', " ").replace('\r', "");
let mut segments: Vec<String> = normalized.split('\n').map(String::from).collect();
if let Some(first) = segments.first_mut() {
*first = format!("{key}: {first}");
}
segments
}
fn soft_wrap_spans(spans: Vec<Span<'static>>, width: usize) -> Vec<Line<'static>> {
if width == 0 {
return vec![Line::from(spans)];
}
let mut toks: Vec<(Style, String)> = Vec::new();
for sp in spans {
for w in word_tokens(&sp.content) {
toks.push((sp.style, w.to_string()));
}
}
let mut out: Vec<Line<'static>> = Vec::new();
let mut cur: Vec<Span<'static>> = Vec::new();
let mut cur_w = 0usize;
for (style, tok) in toks {
let tw = display_width(&tok);
if tw > width {
if !cur.is_empty() {
out.push(Line::from(std::mem::take(&mut cur)));
cur_w = 0;
}
for piece in wrap_line(&tok, width) {
out.push(Line::from(Span::styled(piece, style)));
}
continue;
}
if cur_w + tw > width && !cur.is_empty() {
out.push(Line::from(std::mem::take(&mut cur)));
cur_w = 0;
}
cur.push(Span::styled(tok, style));
cur_w += tw;
}
if !cur.is_empty() {
out.push(Line::from(cur));
}
out
}
fn render_tool_result(
msg: &AgentMessage,
_width: usize,
theme: &Theme,
_expand_all: bool,
pending: &mut [PendingTool],
) -> Vec<Line<'static>> {
let id = msg.tool_call_id.as_deref().unwrap_or("");
let text = msg.text();
let is_err = msg.is_error;
if let Some(tool) = pending.iter_mut().find(|t| t.id == id) {
tool.result = Some(text);
tool.is_error = Some(is_err);
tool.duration_ms = msg.duration_ms;
tool.started_at_ms = None;
return Vec::new();
}
let (summary, _) = truncate_display(&text, 120);
let style = theme.style("dim", "#666666");
vec![Line::from(vec![
Span::styled(format!("{DEF_NESTED} "), style),
Span::styled(summary, style),
])]
}
fn render_summary(
text: &str,
is_compaction: bool,
width: usize,
theme: &Theme,
expand: bool,
blocks: &mut Vec<BlockSpan>,
) -> Vec<Line<'static>> {
let label = if is_compaction {
"[compaction]"
} else {
"[branch]"
};
let label_style = theme
.style(CUSTOM_MSG_LABEL.0, CUSTOM_MSG_LABEL.1)
.add_modifier(Modifier::BOLD);
let text_style = theme.style(CUSTOM_MSG_TEXT.0, CUSTOM_MSG_TEXT.1);
let bg = theme.style_bg(CUSTOM_MSG_BG.0, CUSTOM_MSG_BG.1);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled(" ".repeat(width), bg)));
lines.push(bg_line(
Line::from(vec![Span::styled(label.to_string(), label_style)]),
width,
bg,
1,
));
lines.push(Line::from(Span::styled(" ".repeat(width), bg))); if expand {
let inner = width.saturating_sub(2).max(1);
let md = if is_compaction {
format!("**Compacted context**\n\n{}", text)
} else {
format!("**Branch Summary**\n\n{}", text)
};
for l in render_markdown_styled(&md, inner, theme, text_style) {
lines.push(bg_line(l, width, bg, 1));
}
} else {
let hint = if is_compaction {
"Compacted context (Ctrl+O/Alt+click to expand)"
} else {
"Branch summary (Ctrl+O/Alt+click to expand)"
};
lines.push(bg_line(
Line::from(vec![Span::styled(hint, text_style)]),
width,
bg,
1,
));
}
lines.push(Line::from(Span::styled(" ".repeat(width), bg))); blocks.push(BlockSpan {
index: 0,
start: 0,
end: lines.len(),
});
lines
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn render_streaming(
thinking: &str,
tools: &[String],
text: &str,
width: usize,
theme: &Theme,
expand_all: bool,
show_thinking: bool,
pending: &mut Vec<PendingTool>,
_tool_started_at: &HashMap<String, u64>,
links: &mut Vec<LinkSpan>,
) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
if !thinking.trim().is_empty() {
if show_thinking {
let style = theme
.style(THINKING_TEXT.0, THINKING_TEXT.1)
.add_modifier(Modifier::ITALIC);
let inner = width.saturating_sub(2).max(1);
out.extend(
render_markdown_styled(thinking, inner, theme, style)
.into_iter()
.map(|l| pad_line(l, 1)),
);
} else {
let style = theme
.style(THINKING_TEXT.0, THINKING_TEXT.1)
.add_modifier(Modifier::ITALIC);
out.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled("Thinking...", style),
]));
}
if !tools.is_empty() || !text.trim().is_empty() {
push_blank_gap(&mut out);
}
}
for (ti, tool) in tools.iter().enumerate() {
let (name, args) = tool
.split_once(' ')
.map(|(n, a)| (n.to_string(), a.to_string()))
.unwrap_or_else(|| (tool.clone(), String::new()));
let args_val = serde_json::from_str(&args).unwrap_or_else(|_| serde_json::json!(&args));
out.extend(render_tool_block(
&name,
&args_val,
None,
None,
None,
None,
&[],
width,
theme,
expand_all,
));
pending.push(PendingTool {
id: format!("streaming-{}", pending.len()),
is_error: None,
result: None,
duration_ms: None,
started_at_ms: None,
});
if ti + 1 < tools.len() || !text.trim().is_empty() {
push_blank_gap(&mut out);
}
}
if !text.trim().is_empty() {
let inner = width.saturating_sub(2).max(1);
out.extend(
render_markdown(text, inner, theme)
.into_iter()
.map(|l| pad_line(l, 1)),
);
}
push_blank_gap(&mut out);
links.extend(collect_link_spans(&out, theme));
out
}
fn wrap_text_lines(text: &str, width: usize) -> Vec<String> {
let mut out = Vec::new();
for raw in text.split('\n') {
for l in wrap_line(raw, width) {
out.push(l);
}
}
out
}
fn wrap_hint_line(text: &str, width: usize) -> Vec<String> {
let mut out = Vec::new();
for raw in text.split('\n') {
if display_width(raw) <= width {
out.push(raw.to_string());
continue;
}
let mut tokens: Vec<String> = Vec::new();
let mut cur = String::new();
for c in raw.chars() {
cur.push(c);
if matches!(c, ' ' | ',' | ':' | '{' | '}' | '[' | ']' | '"' | ';') {
tokens.push(std::mem::take(&mut cur));
}
}
if !cur.is_empty() {
tokens.push(cur);
}
let mut line = String::new();
let mut line_w = 0usize;
for tok in tokens {
let tw = display_width(&tok);
if !line.is_empty() && line_w + tw > width {
out.push(line.trim_end().to_string());
line = String::new();
line_w = 0;
}
if line.is_empty() && tw > width {
for piece in wrap_line(&tok, width) {
out.push(piece);
}
continue;
}
line.push_str(&tok);
line_w += tw;
}
if !line.is_empty() {
out.push(line.trim_end().to_string());
}
}
out
}
#[derive(Clone, Copy)]
pub(crate) enum TimelineRef<'a> {
Msg(&'a AgentMessage),
Sys(&'a SysMsg),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ClickTarget {
pub start: usize,
pub end: usize,
pub id: BlockId,
pub default_expanded: bool,
}
pub(crate) fn timeline_items<'a>(
messages: &'a [AgentMessage],
system: &'a [(u64, SysMsg)],
) -> Vec<(u64, usize, TimelineRef<'a>)> {
let mut items: Vec<(u64, usize, TimelineRef<'a>)> = Vec::new();
let mut si = 0usize;
for (i, m) in messages.iter().enumerate() {
while si < system.len() && system[si].0 <= m.timestamp {
items.push((
system[si].0,
SYSTEM_SEQ_BASE + si,
TimelineRef::Sys(&system[si].1),
));
si += 1;
}
items.push((m.timestamp, i, TimelineRef::Msg(m)));
}
while si < system.len() {
items.push((
system[si].0,
SYSTEM_SEQ_BASE + si,
TimelineRef::Sys(&system[si].1),
));
si += 1;
}
items
}
#[derive(Clone, Default)]
pub struct ToolResultView {
pub text: String,
pub images: Vec<Arc<str>>,
pub is_error: bool,
pub duration_ms: Option<u64>,
pub details: Option<Value>,
}
#[derive(Clone, Default)]
pub struct NestedCallView {
pub name: String,
pub summary: String,
pub is_error: Option<bool>,
pub duration_ms: Option<u64>,
pub started_at_ms: Option<u64>,
pub cost: Option<f64>,
}
pub fn nested_arg_summary(args: &Value) -> String {
let text = args
.get("command")
.and_then(|v| v.as_str())
.map(|command| command.to_string())
.unwrap_or_else(|| compact_json(args));
let max = 80;
if text.chars().count() <= max {
return text;
}
let head: String = text.chars().take(max).collect();
format!("{head}...")
}
fn sub_calls_from_details(details: Option<&Value>) -> Vec<NestedCallView> {
let mut calls = Vec::new();
if let Some(entries) = details
.and_then(|d| d.get("nestedCalls"))
.and_then(|v| v.as_array())
{
calls.extend(entries.iter().map(|call| {
NestedCallView {
name: call
.get("toolName")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
summary: call.get("args").map(nested_arg_summary).unwrap_or_default(),
is_error: Some(
call.get("isError")
.and_then(|v| v.as_bool())
.unwrap_or(false),
),
duration_ms: call.get("durationMs").and_then(|v| v.as_u64()),
started_at_ms: None,
cost: None,
}
}));
}
if let Some(entries) = details
.and_then(|d| d.get("modelCalls"))
.and_then(|v| v.as_array())
{
calls.extend(entries.iter().map(model_call_view));
}
calls
}
fn model_call_view(call: &Value) -> NestedCallView {
let status = call.get("status").and_then(|v| v.as_str()).unwrap_or("ok");
let running = status == "running";
NestedCallView {
name: call
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
summary: match call.get("args") {
Some(Value::String(text)) => text.clone(),
Some(args) => nested_arg_summary(args),
None => String::new(),
},
is_error: if running {
None
} else {
Some(status == "error")
},
duration_ms: if running {
None
} else {
call.get("durationMs").and_then(|v| v.as_u64())
},
started_at_ms: None,
cost: call.get("cost").and_then(|v| v.as_f64()),
}
}
pub fn scan_tool_results(messages: &[AgentMessage]) -> HashMap<String, ToolResultView> {
let mut map = HashMap::new();
for m in messages {
if m.role == "toolResult"
&& let Some(id) = m.tool_call_id.as_deref()
{
map.insert(
id.to_string(),
ToolResultView {
text: m.text(),
images: m
.content
.iter()
.filter_map(|b| match b {
ContentBlock::Image { data, .. } => Some(Arc::from(data.as_str())),
_ => None,
})
.collect(),
is_error: m.is_error,
duration_ms: m.duration_ms,
details: m.details.clone(),
},
);
}
}
map
}
#[derive(Clone, Default)]
pub struct EditRenderData {
pub preview: Option<String>,
pub result_diff: Option<String>,
}
pub(crate) fn edit_map_from_results(messages: &[AgentMessage]) -> HashMap<String, EditRenderData> {
let mut map: HashMap<String, EditRenderData> = HashMap::new();
for m in messages {
if m.role == "toolResult"
&& let Some(id) = m.tool_call_id.as_deref()
&& let Some(diff) = m
.details
.as_ref()
.and_then(|d| d.get("diff"))
.and_then(|v| v.as_str())
{
map.entry(id.to_string()).or_default().result_diff = Some(diff.to_string());
}
}
map
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn render_timeline_item(
iref: TimelineRef<'_>,
width: usize,
theme: &Theme,
expansions: &[bool],
blocks: &mut Vec<BlockSpan>,
tool_results: &HashMap<String, ToolResultView>,
tool_started_at: &HashMap<String, u64>,
nested_calls: &HashMap<String, Vec<NestedCallView>>,
edit_map: &HashMap<String, EditRenderData>,
accum_pending: &mut Vec<PendingTool>,
links: &mut Vec<LinkSpan>,
images: &mut Vec<ImageSlot>,
layout: ImageLayout,
) -> Vec<Line<'static>> {
let lines = match iref {
TimelineRef::Msg(m) => render_message_blocks(
m,
width,
theme,
expansions,
blocks,
accum_pending,
tool_results,
tool_started_at,
nested_calls,
edit_map,
images,
layout,
),
TimelineRef::Sys(s) => render_sys_message(&s.spans, s.level, width, theme),
};
links.extend(collect_link_spans(&lines, theme));
lines
}
#[derive(Clone)]
pub struct CachedMsg {
pub lines: Arc<Vec<Line<'static>>>,
pub pending: Arc<Vec<PendingTool>>, pub blocks: Arc<Vec<BlockSpan>>,
pub links: Arc<Vec<LinkSpan>>,
pub images: Arc<Vec<ImageSlot>>,
}
pub struct HistoryRender {
pub segments: Vec<HistorySegment>,
pub total: usize,
pub pending: Vec<PendingTool>,
pub anchor_end: Option<usize>,
pub click_targets: Vec<ClickTarget>,
pub links: Vec<LinkSpan>,
pub images: Vec<ImageSlot>,
}
#[derive(Clone)]
pub struct HighlightSnapshot {
pub msgs: Vec<AgentMessage>,
pub sys: Vec<(u64, SysMsg)>,
pub width: usize,
pub expand_all: bool,
pub show_thinking: bool,
pub images: ImageLayout,
pub overrides: HashMap<BlockId, bool>,
pub theme: Theme,
}
pub(crate) fn render_full_history(snap: &HighlightSnapshot) -> HashMap<(u64, usize), CachedMsg> {
let tool_results = scan_tool_results(&snap.msgs);
let edit_map = edit_map_from_results(&snap.msgs);
let tool_started_at: HashMap<String, u64> = HashMap::new();
let nested_calls: HashMap<String, Vec<NestedCallView>> = HashMap::new();
let items = timeline_items(&snap.msgs, &snap.sys);
let mut accum_pending: Vec<PendingTool> = Vec::new();
let mut out = HashMap::new();
for (idx, (ts, seq, iref)) in items.into_iter().enumerate() {
let base = accum_pending.len();
let expansions: Vec<bool> = match iref {
TimelineRef::Msg(m) => resolve_block_expansions(
m,
ts,
seq,
&snap.overrides,
snap.expand_all,
snap.show_thinking,
),
TimelineRef::Sys(_) => Vec::new(),
};
let mut blocks: Vec<BlockSpan> = Vec::new();
let mut links: Vec<LinkSpan> = Vec::new();
let mut images: Vec<ImageSlot> = Vec::new();
let lines = render_timeline_item(
iref,
snap.width,
&snap.theme,
&expansions,
&mut blocks,
&tool_results,
&tool_started_at,
&nested_calls,
&edit_map,
&mut accum_pending,
&mut links,
&mut images,
snap.images,
);
let delta: Vec<PendingTool> = accum_pending[base..].to_vec();
out.insert(
(ts, seq),
CachedMsg {
lines: Arc::new(lines),
pending: Arc::new(delta),
blocks: Arc::new(blocks),
links: Arc::new(links),
images: Arc::new(images),
},
);
if idx % 32 == 31 {
std::thread::sleep(Duration::from_millis(1));
}
}
out
}
#[derive(Default)]
pub struct MessageCache {
entries: Vec<(CachedMsg, u64)>,
width: usize,
expand_all: bool,
show_thinking: bool,
theme_fp: String,
renderers_fp: String,
}
impl MessageCache {
pub fn render(
&mut self,
messages: &[AgentMessage],
width: usize,
theme: &Theme,
expand_all: bool,
show_thinking: bool,
) -> Vec<Line<'static>> {
let theme_fp = theme.render_fingerprint();
let renderers_fp = extensions::tool_renderers_fingerprint();
if self.width != width
|| self.expand_all != expand_all
|| self.show_thinking != show_thinking
|| self.theme_fp != theme_fp
|| self.renderers_fp != renderers_fp
{
self.entries.clear();
self.width = width;
self.expand_all = expand_all;
self.show_thinking = show_thinking;
self.theme_fp = theme_fp;
self.renderers_fp = renderers_fp;
}
self.entries.truncate(messages.len());
let tool_results = scan_tool_results(messages);
let edit_map = edit_map_from_results(messages);
let tool_started_at: HashMap<String, u64> = HashMap::new();
let nested_calls: HashMap<String, Vec<NestedCallView>> = HashMap::new();
let mut out: Vec<Line<'static>> = Vec::new();
let mut pending: Vec<PendingTool> = Vec::new();
for (i, m) in messages.iter().enumerate() {
let fp = result_fp(m, &tool_results);
let base = pending.len();
let cached = match self.entries.get(i) {
Some((c, cached_fp)) if *cached_fp == fp => {
pending.extend(c.pending.iter().cloned());
c.clone()
}
_ => {
let lines = render_message(
m,
width,
theme,
expand_all,
show_thinking,
&mut pending,
&tool_results,
&tool_started_at,
&nested_calls,
&edit_map,
);
let delta: Vec<PendingTool> = pending[base..].to_vec();
let links = collect_link_spans(&lines, theme);
let c = CachedMsg {
lines: Arc::new(lines),
pending: Arc::new(delta),
blocks: Arc::new(Vec::new()),
links: Arc::new(links),
images: Arc::new(Vec::new()),
};
if i < self.entries.len() {
self.entries[i] = (c.clone(), fp);
} else {
self.entries.push((c.clone(), fp));
}
c
}
};
if cached.lines.is_empty() {
continue;
}
out.extend(cached.lines.iter().cloned());
if !cached.lines.last().map(is_pure_gap).unwrap_or(true) {
out.push(Line::from(""));
}
}
out
}
}
fn result_fp(msg: &AgentMessage, tool_results: &HashMap<String, ToolResultView>) -> u64 {
let mut n = 0u64;
for b in &msg.content {
if let ContentBlock::ToolCall { id, .. } = b
&& tool_results.contains_key(id.as_str())
{
n += 1;
}
}
n
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::provider::AgentMessage;
use ratatui::style::Color;
#[test]
fn format_tool_duration_matches_pi_thresholds() {
assert_eq!(format_tool_duration(0), "0.0s");
assert_eq!(format_tool_duration(3_200), "3.2s");
assert_eq!(format_tool_duration(59_900), "59.9s");
assert_eq!(format_tool_duration(60_000), "1m 0s");
assert_eq!(format_tool_duration(90_500), "1m 30s");
assert_eq!(format_tool_duration(3_599_000), "59m 59s");
assert_eq!(format_tool_duration(3_600_000), "1h 0m 0s");
assert_eq!(format_tool_duration(3_930_000), "1h 5m 30s");
}
fn render_with_images(
msg: &AgentMessage,
width: usize,
layout: ImageLayout,
tool_results: &HashMap<String, ToolResultView>,
) -> (Vec<Line<'static>>, Vec<ImageSlot>) {
let expansions: Vec<bool> = collapsible_blocks(msg)
.into_iter()
.map(|k| default_block_expanded(k, true, true))
.collect();
let mut images: Vec<ImageSlot> = Vec::new();
let lines = render_message_blocks(
msg,
width,
&theme(),
&expansions,
&mut Vec::new(),
&mut Vec::new(),
tool_results,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&mut images,
layout,
);
(lines, images)
}
#[test]
fn image_block_reserves_rows_and_reports_slot() {
let mut msg = AgentMessage::user_text("");
msg.role = "assistant".to_string();
msg.content = vec![ContentBlock::Image {
data: crate::test_support::png_base64(600, 200),
mime_type: "image/png".to_string(),
}];
let (lines, images) = render_with_images(
&msg,
80,
ImageLayout {
enabled: true,
font: (10, 20),
},
&HashMap::new(),
);
assert_eq!(images.len(), 1);
assert_eq!((images[0].cols, images[0].rows), (60, 10));
assert_eq!(images[0].key, terminal_image::image_key(&images[0].data));
assert_eq!(images[0].pad, None);
let rows = t(&lines);
assert_eq!(rows[images[0].line].trim(), "");
assert_eq!(rows.len(), images[0].line + images[0].rows as usize + 1);
assert!(is_pure_gap(
&lines[images[0].line + images[0].rows as usize]
));
}
#[test]
fn consecutive_images_keep_a_blank_gap_between_them() {
let data = crate::test_support::png_base64(600, 200);
let mut msg = AgentMessage::user_text("");
msg.role = "assistant".to_string();
msg.content = vec![
ContentBlock::Text {
text: "before".to_string(),
text_signature: None,
},
ContentBlock::Image {
data: data.clone(),
mime_type: "image/png".to_string(),
},
ContentBlock::Image {
data,
mime_type: "image/png".to_string(),
},
];
let (lines, images) = render_with_images(
&msg,
80,
ImageLayout {
enabled: true,
font: (10, 20),
},
&HashMap::new(),
);
assert_eq!(images.len(), 2);
let rows = t(&lines);
let first_end = images[0].line + images[0].rows as usize;
let second_end = images[1].line + images[1].rows as usize;
assert_eq!(images[1].line, first_end + 1);
assert!(is_pure_gap(&lines[first_end]), "{:?}", rows[first_end]);
assert!(is_pure_gap(&lines[second_end]), "{:?}", rows[second_end]);
}
#[test]
fn large_bottom_pad_replaces_the_blank_gap_line() {
let data = crate::test_support::png_base64(333, 299);
let mut msg = AgentMessage::user_text("");
msg.role = "assistant".to_string();
msg.content = vec![
ContentBlock::Image {
data: data.clone(),
mime_type: "image/png".to_string(),
},
ContentBlock::Image {
data,
mime_type: "image/png".to_string(),
},
];
let (lines, images) = render_with_images(
&msg,
80,
ImageLayout {
enabled: true,
font: (22, 47),
},
&HashMap::new(),
);
assert_eq!(images.len(), 2);
assert_eq!((images[0].cols, images[0].rows), (16, 7));
assert_eq!(
images[1].line,
images[0].line + images[0].rows as usize,
"补边够一行时第二张图应紧跟预留行: {:?}",
t(&lines)
);
}
#[test]
fn user_message_image_padding_keeps_the_message_background() {
let mut msg = AgentMessage::user_text("look");
msg.content.push(ContentBlock::Image {
data: crate::test_support::png_base64(600, 200),
mime_type: "image/png".to_string(),
});
let (lines, images) = render_with_images(
&msg,
80,
ImageLayout {
enabled: true,
font: (10, 20),
},
&HashMap::new(),
);
assert_eq!(images.len(), 1);
let padding = images[0].line + images[0].rows as usize;
assert!(
lines[padding].spans.iter().any(|s| s.style.bg.is_some()),
"padding 行应带消息背景: {:?}",
lines[padding]
);
assert_eq!(
images[0].pad, None,
"默认主题的消息底色是终端默认色,无可信 RGB"
);
}
#[test]
fn block_background_color_becomes_the_halfblocks_pad() {
let mut msg = AgentMessage::user_text("");
msg.role = "assistant".to_string();
msg.content = vec![ContentBlock::ToolCall {
id: "r1".to_string(),
name: "read".to_string(),
arguments: serde_json::json!({ "path": "shot.png" }),
thought_signature: None,
namespace: None,
}];
let mut tool_results: HashMap<String, ToolResultView> = HashMap::new();
tool_results.insert(
"r1".to_string(),
ToolResultView {
text: "read shot.png".to_string(),
images: vec![Arc::from(
crate::test_support::png_base64(600, 200).as_str(),
)],
is_error: false,
duration_ms: Some(12),
details: None,
},
);
let slots = |theme: &Theme| {
let expansions: Vec<bool> = collapsible_blocks(&msg)
.into_iter()
.map(|k| default_block_expanded(k, true, true))
.collect();
let mut images: Vec<ImageSlot> = Vec::new();
render_message_blocks(
&msg,
80,
theme,
&expansions,
&mut Vec::new(),
&mut Vec::new(),
&tool_results,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&mut images,
ImageLayout {
enabled: true,
font: (10, 20),
},
);
images
};
assert_eq!(
slots(&theme())[0].pad,
None,
"system 主题的块底色是终端默认色,没有可信 RGB"
);
let synthwave = Theme::load("synthwave");
let expected = synthwave
.style_bg(TOOL_SUCCESS_BG.0, TOOL_SUCCESS_BG.1)
.bg
.and_then(terminal_image::style_bg_rgb);
assert!(
expected.is_some(),
"synthwave 的 toolSuccessBg 应是具体颜色"
);
assert_eq!(slots(&synthwave)[0].pad, expected);
}
#[test]
fn image_block_is_not_rendered_when_disabled() {
let mut msg = AgentMessage::user_text("");
msg.role = "assistant".to_string();
msg.content = vec![ContentBlock::Image {
data: crate::test_support::png_base64(600, 200),
mime_type: "image/png".to_string(),
}];
let (lines, images) =
render_with_images(&msg, 80, ImageLayout::disabled(), &HashMap::new());
assert!(images.is_empty());
let rows = t(&lines);
assert!(
!rows.iter().any(|r| r.contains("[image]")),
"关闭时不应出现占位行: {rows:?}"
);
}
#[test]
fn image_block_falls_back_when_data_is_not_an_image() {
let mut msg = AgentMessage::user_text("");
msg.role = "assistant".to_string();
msg.content = vec![ContentBlock::Image {
data: "bm90LWFuLWltYWdl".to_string(),
mime_type: "image/png".to_string(),
}];
let (lines, images) = render_with_images(
&msg,
80,
ImageLayout {
enabled: true,
font: (10, 20),
},
&HashMap::new(),
);
assert!(images.is_empty());
assert!(t(&lines).iter().any(|r| r.contains("[image]")));
}
#[test]
fn user_message_images_are_reserved_after_the_text() {
let mut msg = AgentMessage::user_text("look at this");
msg.content.push(ContentBlock::Image {
data: crate::test_support::png_base64(100, 100),
mime_type: "image/png".to_string(),
});
let (lines, images) = render_with_images(
&msg,
80,
ImageLayout {
enabled: true,
font: (10, 20),
},
&HashMap::new(),
);
assert_eq!(images.len(), 1, "用户消息的图片也要收集");
assert_eq!((images[0].cols, images[0].rows), (10, 5));
assert!(images[0].line > 0);
assert_eq!(t(&lines)[images[0].line].trim(), "");
}
#[test]
fn tool_result_images_are_reserved_inside_the_tool_block() {
let mut msg = AgentMessage::user_text("");
msg.role = "assistant".to_string();
msg.content = vec![ContentBlock::ToolCall {
id: "r1".to_string(),
name: "read".to_string(),
arguments: serde_json::json!({ "path": "shot.png" }),
thought_signature: None,
namespace: None,
}];
let mut tool_results: HashMap<String, ToolResultView> = HashMap::new();
tool_results.insert(
"r1".to_string(),
ToolResultView {
text: "read shot.png".to_string(),
images: vec![Arc::from(
crate::test_support::png_base64(600, 200).as_str(),
)],
is_error: false,
duration_ms: Some(12),
details: None,
},
);
let (lines, images) = render_with_images(
&msg,
80,
ImageLayout {
enabled: true,
font: (10, 20),
},
&tool_results,
);
assert_eq!(images.len(), 1, "工具结果的图片也要收集");
assert_eq!((images[0].cols, images[0].rows), (60, 10));
assert_eq!(t(&lines)[images[0].line].trim(), "");
}
#[test]
fn tool_result_image_slot_is_entry_relative() {
let mut msg = AgentMessage::user_text("");
msg.role = "assistant".to_string();
msg.content = vec![
ContentBlock::Thinking {
thinking: "先看看这张图".to_string(),
thinking_signature: None,
redacted: None,
},
ContentBlock::Text {
text: "我看一下这张截图。".to_string(),
text_signature: None,
},
ContentBlock::ToolCall {
id: "r1".to_string(),
name: "read".to_string(),
arguments: serde_json::json!({ "path": "shot.png" }),
thought_signature: None,
namespace: None,
},
];
let mut tool_results: HashMap<String, ToolResultView> = HashMap::new();
tool_results.insert(
"r1".to_string(),
ToolResultView {
text: "read shot.png".to_string(),
images: vec![Arc::from(
crate::test_support::png_base64(600, 200).as_str(),
)],
is_error: false,
duration_ms: Some(1200),
details: None,
},
);
let (lines, images) = render_with_images(
&msg,
80,
ImageLayout {
enabled: true,
font: (10, 20),
},
&tool_results,
);
let rows = t(&lines);
assert_eq!(images.len(), 1);
let slot = &images[0];
let end = slot.line + slot.rows as usize;
for (row, line) in rows.iter().enumerate().take(end).skip(slot.line) {
assert!(
line.trim().is_empty(),
"第 {row} 行落在图片预留区 {}..{end} 里: {line:?}",
slot.line
);
}
assert!(rows[end].trim().is_empty(), "预留区后应是盒底 padding");
assert!(rows[slot.line - 1].trim().is_empty(), "图片上方应空一行");
assert!(
lines[slot.line - 1]
.spans
.iter()
.all(|s| s.style.bg.is_some()),
"间隔行要沿用工具块背景"
);
assert!(
!rows[slot.line - 2].trim().is_empty(),
"间隔行再往上应是工具块的正文/耗时行"
);
let took = rows
.iter()
.position(|r| r.contains("Took"))
.expect("工具块应有耗时行");
assert!(
took < slot.line,
"`Took` 行({took}) 应在图片预留区({}..{end})之上",
slot.line
);
}
fn t(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
}
fn theme() -> Theme {
Theme::default()
}
#[test]
fn nested_calls_from_details_render_inside_tool_block() {
let mut ai = AgentMessage::user_text("");
ai.role = "assistant".to_string();
ai.content = vec![ContentBlock::ToolCall {
id: "cm1".into(),
name: "codemode".into(),
arguments: serde_json::json!({ "code": "return 1;" }),
thought_signature: None,
namespace: None,
}];
let mut res = AgentMessage::user_text("Script completed");
res.role = "toolResult".to_string();
res.tool_call_id = Some("cm1".into());
res.duration_ms = Some(1500);
res.details = Some(serde_json::json!({
"nestedCalls": [
{"toolName": "bash", "args": {"command": "ls -la"}, "isError": false, "durationMs": 400},
{"toolName": "read", "args": {"path": "a.txt"}, "isError": true, "durationMs": 12},
]
}));
let tool_results = scan_tool_results(&[ai.clone(), res]);
let lines = render_message(
&ai,
80,
&theme(),
true,
false,
&mut Vec::new(),
&tool_results,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
);
let rows = t(&lines).join("\n");
assert!(rows.contains("✓ bash ls -la 0.4s"), "嵌套行: {rows}");
assert!(rows.contains("✗ read"), "失败的嵌套调用应标 ✗: {rows}");
assert!(
rows.contains("0.0s") || rows.contains("12"),
"失败行也带耗时: {rows}"
);
}
#[test]
fn live_nested_calls_render_while_tool_runs() {
let mut ai = AgentMessage::user_text("");
ai.role = "assistant".to_string();
ai.content = vec![ContentBlock::ToolCall {
id: "cm2".into(),
name: "codemode".into(),
arguments: serde_json::json!({ "code": "await tools.bash('ls')" }),
thought_signature: None,
namespace: None,
}];
let started = now_ms();
let mut started_map = HashMap::new();
started_map.insert("cm2".to_string(), started);
let mut nested_map: HashMap<String, Vec<NestedCallView>> = HashMap::new();
nested_map.insert(
"cm2".to_string(),
vec![NestedCallView {
name: "bash".to_string(),
summary: "ls".to_string(),
is_error: None,
duration_ms: None,
started_at_ms: Some(started),
cost: None,
}],
);
let lines = render_message(
&ai,
80,
&theme(),
true,
false,
&mut Vec::new(),
&HashMap::new(),
&started_map,
&nested_map,
&HashMap::new(),
);
let rows = t(&lines).join("\n");
assert!(rows.contains("Elapsed"), "进行中应显示 Elapsed: {rows}");
assert!(rows.contains("… bash ls"), "进行中应显示嵌套行: {rows}");
}
#[test]
fn model_calls_from_details_render_with_cost_and_total() {
let mut ai = AgentMessage::user_text("");
ai.role = "assistant".to_string();
ai.content = vec![ContentBlock::ToolCall {
id: "cm3".into(),
name: "codemode".into(),
arguments: serde_json::json!({ "code": "await models.classify(m, ctx)" }),
thought_signature: None,
namespace: None,
}];
let mut res = AgentMessage::user_text("Script completed");
res.role = "toolResult".to_string();
res.tool_call_id = Some("cm3".into());
res.duration_ms = Some(900);
res.details = Some(serde_json::json!({
"modelCalls": [
{"name": "models.classify", "args": "openrouter/~typesafe/jev-latest", "status": "ok", "durationMs": 300, "cost": 0.0012},
{"name": "models.classify", "args": "openrouter/~typesafe/jev-latest", "status": "error", "durationMs": 120, "cost": 0.0008},
]
}));
let tool_results = scan_tool_results(&[ai.clone(), res]);
let lines = render_message(
&ai,
100,
&theme(),
true,
false,
&mut Vec::new(),
&tool_results,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
);
let rows = t(&lines).join("\n");
assert!(
rows.contains("✓ models.classify openrouter/~typesafe/jev-latest 0.3s $0.0012"),
"成功的分类器调用行: {rows}"
);
assert!(rows.contains("✗ models.classify"), "失败调用应标 ✗: {rows}");
assert!(rows.contains("Model calls: $0.0020"), "合计行: {rows}");
}
#[test]
fn model_calls_fold_to_preview_and_total_covers_all() {
let calls: Vec<serde_json::Value> = (0..10)
.map(|i| {
serde_json::json!({
"name": "models.classify",
"args": format!("openrouter/jev-{i}"),
"status": "ok",
"durationMs": 100,
"cost": 0.001,
})
})
.collect();
let details = serde_json::json!({ "modelCalls": calls });
let view = sub_calls_from_details(Some(&details));
assert_eq!(view.len(), 10);
let lines = render_sub_calls(&view, false, &theme(), Style::default(), 80, 78);
let rows = t(&lines);
assert!(
rows[0].contains("... (2 earlier calls, ctrl+o/alt+click to expand)"),
"省略提示: {rows:?}"
);
assert!(
!rows.iter().any(|r| r.contains("jev-0")),
"更早的调用被折叠: {rows:?}"
);
assert!(
rows.iter().any(|r| r.contains("jev-9")),
"末尾调用可见: {rows:?}"
);
assert!(
rows.iter().any(|r| r.contains("Model calls: $0.01")),
"合计覆盖全部 10 条: {rows:?}"
);
}
pub fn render_all_messages(
messages: &[AgentMessage],
system: &[(u64, SysMsg)],
streaming: Option<(&str, &[String], &str)>,
width: usize,
theme: &Theme,
expand_all: bool,
show_thinking: bool,
) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
let mut pending: Vec<PendingTool> = Vec::new();
let mut tool_results: HashMap<String, ToolResultView> = HashMap::new();
let tool_started_at: HashMap<String, u64> = HashMap::new();
for m in messages {
if m.role == "toolResult"
&& let Some(id) = m.tool_call_id.as_deref()
{
tool_results.insert(
id.to_string(),
ToolResultView {
text: m.text(),
images: Vec::new(),
is_error: m.is_error,
duration_ms: m.duration_ms,
details: m.details.clone(),
},
);
}
}
enum ItemRef<'a> {
Msg(&'a AgentMessage),
Sys(&'a SysMsg),
}
let mut items: Vec<(u64, usize, ItemRef<'_>)> = Vec::new();
for (i, m) in messages.iter().enumerate() {
items.push((m.timestamp, i, ItemRef::Msg(m)));
}
for (i, (ts, s)) in system.iter().enumerate() {
items.push((*ts, SYSTEM_SEQ_BASE + i, ItemRef::Sys(s)));
}
items.sort_by_key(|(ts, seq, _)| (*ts, *seq));
let edit_map = edit_map_from_results(messages);
for (_, _, item) in items {
let lines: Vec<Line<'static>> = match item {
ItemRef::Msg(m) => render_message(
m,
width,
theme,
expand_all,
show_thinking,
&mut pending,
&tool_results,
&tool_started_at,
&std::collections::HashMap::new(),
&edit_map,
),
ItemRef::Sys(s) => render_sys_message(&s.spans, s.level, width, theme),
};
if lines.is_empty() {
continue;
}
out.extend(lines);
push_blank_gap(&mut out);
}
if let Some((thinking, tools, text)) = streaming {
out.extend(render_streaming(
thinking,
tools,
text,
width,
theme,
expand_all,
show_thinking,
&mut pending,
&tool_started_at,
&mut Vec::new(),
));
}
out
}
#[test]
fn user_message_has_background_block_with_padding() {
let msg = AgentMessage::user_text("hello user");
let lines = render_message(
&msg,
40,
&theme(),
false,
true,
&mut Vec::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
);
let t = t(&lines);
assert_eq!(display_width(&t[0]), 40, "top padding row full width");
assert!(lines[0].spans[0].style.bg.is_some(), "padding row has bg");
let bottom_pad = &lines[lines.len() - 2];
assert!(
bottom_pad.spans[0].style.bg.is_some(),
"bottom padding row has bg"
);
assert!(is_pure_gap(lines.last().unwrap()), "块尾应有渲染间隔空行");
let content = t.iter().find(|l| l.contains("hello user")).unwrap();
assert!(content.starts_with(' '), "left pad: {:?}", content);
assert_eq!(
display_width(content),
40,
"content row padded to full width"
);
}
#[test]
fn assistant_text_renders_markdown() {
let mut msg = AgentMessage::user_text("");
msg.role = "assistant".to_string();
msg.content = vec![ContentBlock::Text {
text: "# Hi\n\nSome **bold**".into(),
text_signature: None,
}];
let lines = render_message(
&msg,
60,
&theme(),
false,
true,
&mut Vec::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
);
let t = t(&lines);
assert!(t.iter().any(|l| l.contains("# Hi")));
assert!(t.iter().any(|l| l.contains("bold")));
}
#[test]
fn thinking_folded_label_and_expanded() {
let mut msg = AgentMessage::user_text("");
msg.role = "assistant".to_string();
msg.content = vec![ContentBlock::Thinking {
thinking: "deep thoughts".into(),
thinking_signature: None,
redacted: None,
}];
let lines = render_message(
&msg,
60,
&theme(),
true,
false,
&mut Vec::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
);
let t1 = t(&lines);
assert!(t1.iter().any(|l| l.contains("Thinking...")));
let lines = render_message(
&msg,
60,
&theme(),
true,
true,
&mut Vec::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
);
let t2 = t(&lines);
assert!(t2.iter().any(|l| l.contains("deep thoughts")));
}
#[test]
fn tool_call_block_and_result_update() {
let mut msg = AgentMessage::user_text("");
msg.role = "assistant".to_string();
msg.content = vec![ContentBlock::ToolCall {
id: "t1".into(),
name: "read".into(),
arguments: serde_json::json!({ "path": "a.rs" }),
thought_signature: None,
namespace: None,
}];
let mut pending = Vec::new();
let lines = render_message(
&msg,
60,
&theme(),
true,
true,
&mut pending,
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
);
let t = t(&lines);
assert!(
t.iter().any(|l| l.trim().starts_with("read")),
"tool title: {:?}",
t
);
assert!(t.iter().any(|l| l.contains("a.rs")));
assert_eq!(pending.len(), 1);
let mut res = AgentMessage::user_text("file content");
res.role = "toolResult".to_string();
res.tool_call_id = Some("t1".into());
let lines = render_message(
&res,
60,
&theme(),
true,
true,
&mut pending,
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
);
assert!(lines.is_empty(), "toolResult must not render its own line");
assert_eq!(pending[0].result.as_deref(), Some("file content"));
}
#[test]
fn tool_block_shows_result_via_prescan() {
let mut ai = AgentMessage::user_text("");
ai.role = "assistant".to_string();
ai.timestamp = 100;
ai.content = vec![ContentBlock::ToolCall {
id: "t2".into(),
name: "bash".into(),
arguments: serde_json::json!({ "command": "ls -la" }),
thought_signature: None,
namespace: None,
}];
let mut res =
AgentMessage::user_text("total 8\ndrwxr-xr-x .\n-rw-r--r-- a.txt\n-rw-r--r-- b.txt");
res.role = "toolResult".to_string();
res.tool_call_id = Some("t2".into());
res.timestamp = 200;
res.duration_ms = Some(500);
let lines = render_all_messages(&[ai, res], &[], None, 60, &theme(), true, true);
let t = t(&lines);
let joined = t.join("\n");
assert!(joined.contains("$ ls -la"), "bash 头部: {:?}", t);
assert!(joined.contains("a.txt"), "工具输出必须在块内: {:?}", t);
assert!(joined.contains("Took 0.5s"), "耗时: {:?}", t);
}
#[test]
fn tool_block_lines_fill_full_width() {
let width = 40usize;
let out = concat!(
"a\tb\n",
"\u{200b}zero-width\n",
"\u{4e2d}\u{6587} cjk\n",
"\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467} emoji\n",
"\u{1b}[31mred\u{1b}[0m ansi\n",
"exactly-width-ascii-line-padded-out-xx\n",
"plain ascii line that is definitely long enough to wrap around",
);
let args = serde_json::json!({ "command": "printf x" });
let lines = render_tool_block(
"bash",
&args,
Some((out, false)),
Some(1200),
None,
None,
&[],
width,
&theme(),
false,
);
assert!(!lines.is_empty());
for l in &lines {
let w: usize = l.spans.iter().map(|s| grapheme_width(&s.content)).sum();
let text: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(w >= width, "行未铺满背景(渲染宽 {w} < {width}): {text:?}");
}
}
#[test]
fn tool_block_collapsed_shows_tail_with_hint() {
let mut ai = AgentMessage::user_text("");
ai.role = "assistant".to_string();
ai.timestamp = 100;
ai.content = vec![ContentBlock::ToolCall {
id: "t3".into(),
name: "bash".into(),
arguments: serde_json::json!({ "command": "seq 10" }),
thought_signature: None,
namespace: None,
}];
let mut res = AgentMessage::user_text("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
res.role = "toolResult".to_string();
res.tool_call_id = Some("t3".into());
res.timestamp = 200;
let lines = render_all_messages(&[ai, res], &[], None, 60, &theme(), false, true);
let tt = t(&lines);
let joined = tt.join("\n");
assert!(
joined.contains("(5 earlier lines, ctrl+o/alt+click to expand)"),
"earlier 提示: {:?}",
tt
);
assert!(joined.contains("10"), "显示末尾行: {:?}", tt);
assert!(!joined.contains("\n1\n"), "不显示开头行: {:?}", tt);
let mut ai2 = AgentMessage::user_text("");
ai2.role = "assistant".to_string();
ai2.timestamp = 100;
ai2.content = vec![ContentBlock::ToolCall {
id: "t4".into(),
name: "ls".into(),
arguments: serde_json::json!({ "path": "." }),
thought_signature: None,
namespace: None,
}];
let mut res2 = AgentMessage::user_text(
&(1..=25)
.map(|i| format!("f{}", i))
.collect::<Vec<_>>()
.join("\n"),
);
res2.role = "toolResult".to_string();
res2.tool_call_id = Some("t4".into());
res2.timestamp = 200;
let lines = render_all_messages(&[ai2, res2], &[], None, 60, &theme(), false, true);
let tt = t(&lines);
let joined = tt.join("\n");
assert!(joined.contains("ls ."), "ls 头部(空路径回退 .): {:?}", tt);
assert!(joined.contains("f1"), "显示开头行: {:?}", tt);
assert!(
joined.contains("(5 more lines, ctrl+o/alt+click to expand)"),
"more 提示: {:?}",
tt
);
}
#[test]
fn write_call_renders_content_with_total_hint() {
let mut ai = AgentMessage::user_text("");
ai.role = "assistant".to_string();
ai.timestamp = 100;
ai.content = vec![ContentBlock::ToolCall {
id: "w1".into(),
name: "write".into(),
arguments: serde_json::json!({
"path": "a.txt",
"content": (1..=40).map(|i| format!("line{}", i)).collect::<Vec<_>>().join("\n")
}),
thought_signature: None,
namespace: None,
}];
let lines = render_all_messages(&[ai], &[], None, 60, &theme(), false, true);
let tt = t(&lines);
let joined = tt.join("\n");
assert!(joined.contains("write a.txt"), "write 头部: {:?}", tt);
assert!(joined.contains("line1"), "显示 content 开头: {:?}", tt);
assert!(
joined.contains("(30 more lines, 40 total, ctrl+o/alt+click to expand)"),
"total 折叠提示: {:?}",
tt
);
assert!(!joined.contains("line40"), "折叠不显示第 40 行: {:?}", tt);
}
#[test]
fn write_call_expanded_renders_all_content() {
let mut ai = AgentMessage::user_text("");
ai.role = "assistant".to_string();
ai.timestamp = 100;
ai.content = vec![ContentBlock::ToolCall {
id: "w2".into(),
name: "write".into(),
arguments: serde_json::json!({
"path": "a.txt",
"content": (1..=25).map(|i| format!("line{}", i)).collect::<Vec<_>>().join("\n")
}),
thought_signature: None,
namespace: None,
}];
let lines = render_all_messages(&[ai], &[], None, 60, &theme(), true, true);
let joined = t(&lines).join("\n");
assert!(
joined.contains("line25"),
"展开显示全部 content: {:?}",
joined
);
assert!(
!joined.contains("more lines"),
"展开无折叠提示: {:?}",
joined
);
}
#[test]
fn bash_fold_limits_visual_lines_with_wrapped_tail() {
let mut ai = AgentMessage::user_text("");
ai.role = "assistant".to_string();
ai.timestamp = 100;
ai.content = vec![ContentBlock::ToolCall {
id: "b1".into(),
name: "bash".into(),
arguments: serde_json::json!({ "command": "make noise" }),
thought_signature: None,
namespace: None,
}];
let mut res = AgentMessage::user_text(&{
let mut v: Vec<String> = (1..=20).map(|i| format!("line{}", i)).collect();
v.push("x".repeat(80));
v.join("\n")
});
res.role = "toolResult".to_string();
res.tool_call_id = Some("b1".into());
res.timestamp = 200;
let lines = render_all_messages(&[ai, res], &[], None, 20, &theme(), false, true);
let tt = t(&lines);
let content_rows = tt
.iter()
.filter(|l| {
let s = l.trim();
!s.is_empty() && !s.starts_with('$') && !s.starts_with("... (")
})
.count();
assert!(content_rows <= 5, "bash 折叠视觉行超限: {:?}", tt);
assert!(
tt.iter()
.any(|l| l.contains("earlier lines, ctrl+o/alt+click to expand)")),
"缺 earlier 提示: {:?}",
tt
);
}
#[test]
fn extension_renderer_takes_over_tool_block_and_falls_back() {
use crate::core::extensions::{
Extension, ExtensionMode, ExtensionTool, RichSpan, ToolRender, ToolRenderCtx,
ToolRenderer, register_extension, set_extension_mode, unregister_extension,
};
use std::sync::Arc;
struct Fake;
impl ToolRenderer for Fake {
fn name(&self) -> &'static str {
"fake-mcp-renderer"
}
fn tools(&self) -> &[&'static str] {
&["fake-mcp"]
}
fn render(&self, ctx: &ToolRenderCtx<'_>) -> Option<ToolRender> {
let head = format!("RENDERED {} ({})", ctx.tool, ctx.tool);
Some(ToolRender {
lines: vec![
vec![RichSpan::plain(head)],
vec![RichSpan::plain("row-2")],
vec![RichSpan::plain("row-3")],
vec![RichSpan::plain("row-4")],
],
preview_lines: Some(2),
})
}
}
struct Faker;
impl Extension for Faker {
fn name(&self) -> &str {
"fake-renderer-ext"
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn tool_renderers(&self) -> Vec<crate::core::extensions::RegisteredToolRenderer> {
vec![crate::core::extensions::RegisteredToolRenderer::new(
Arc::new(Fake),
)]
}
}
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ad = crate::test_support::AgentDirGuard::temp();
crate::core::settings_manager::write_disabled_extensions(&[]).ok();
set_extension_mode(ExtensionMode::All);
let built = || {
let mut ai = AgentMessage::user_text("");
ai.role = "assistant".to_string();
ai.timestamp = 100;
ai.content = vec![ContentBlock::ToolCall {
id: "m1".into(),
name: "fake-mcp".into(),
arguments: serde_json::json!({ "server": "docs", "tool": "lookup" }),
thought_signature: None,
namespace: None,
}];
let mut res = AgentMessage::user_text("native output\nsecond line");
res.role = "toolResult".to_string();
res.tool_call_id = Some("m1".into());
res.timestamp = 200;
(ai, res)
};
let (ai, res) = built();
let plain = t(&render_all_messages(
&[ai, res],
&[],
None,
40,
&theme(),
false,
false,
));
assert!(
plain.iter().any(|l| l.contains("native output")),
"{plain:?}"
);
register_extension(Faker);
let (ai, res) = built();
let rendered = t(&render_all_messages(
&[ai, res],
&[],
None,
40,
&theme(),
false,
false,
));
let joined = rendered.join("\n");
assert!(joined.contains("RENDERED fake-mcp (fake-mcp)"), "{joined}");
assert!(
!joined.contains("native output"),
"内置渲染应被接管: {joined}"
);
assert!(joined.contains("row-2"), "{joined}");
assert!(!joined.contains("row-3"), "折叠应截掉第 3 行: {joined}");
assert!(
joined.contains("2 more lines, ctrl+o/alt+click to expand"),
"{joined}"
);
let (ai, res) = built();
let expanded = t(&render_all_messages(
&[ai, res],
&[],
None,
40,
&theme(),
true,
false,
))
.join("\n");
assert!(expanded.contains("row-4"), "{expanded}");
crate::core::extensions::set_extension_enabled("fake-renderer-ext", false);
let (ai, res) = built();
let disabled = t(&render_all_messages(
&[ai, res],
&[],
None,
40,
&theme(),
false,
false,
))
.join("\n");
assert!(disabled.contains("native output"), "{disabled}");
assert!(!disabled.contains("RENDERED fake-mcp"), "{disabled}");
crate::core::extensions::set_extension_enabled("fake-renderer-ext", true);
assert!(unregister_extension("fake-renderer-ext"));
}
#[test]
fn non_bash_fold_limits_visual_lines_for_long_single_line() {
let mut ai = AgentMessage::user_text("");
ai.role = "assistant".to_string();
ai.timestamp = 100;
ai.content = vec![ContentBlock::ToolCall {
id: "m1".into(),
name: "mcp".into(),
arguments: serde_json::json!({ "action": "call", "server": "fs", "name": "read", "arguments": {} }),
thought_signature: None,
namespace: None,
}];
let mut res = AgentMessage::user_text(&"x".repeat(400));
res.role = "toolResult".to_string();
res.tool_call_id = Some("m1".into());
res.timestamp = 200;
let lines = render_all_messages(&[ai, res], &[], None, 20, &theme(), false, true);
let tt = t(&lines);
let x_rows = tt
.iter()
.filter(|l| {
let s = l.trim();
!s.is_empty() && s.chars().all(|c| c == 'x')
})
.count();
assert_eq!(
x_rows, MCP_PREVIEW_LINES,
"mcp 折叠视觉行应严格等于 {MCP_PREVIEW_LINES}(实际 {x_rows}): {tt:?}"
);
assert!(
tt.iter()
.any(|l| l.contains("more lines, ctrl+o/alt+click to expand)")),
"缺折叠提示: {tt:?}"
);
}
#[test]
fn tool_block_pending_shows_elapsed_timer() {
let mut ai = AgentMessage::user_text("");
ai.role = "assistant".to_string();
ai.timestamp = 100;
ai.content = vec![ContentBlock::ToolCall {
id: "e1".into(),
name: "bash".into(),
arguments: serde_json::json!({ "command": "sleep 5" }),
thought_signature: None,
namespace: None,
}];
let started = crate::utils::time::now_ms().saturating_sub(1200);
let mut started_map = std::collections::HashMap::new();
started_map.insert("e1".to_string(), started);
let mut pending = Vec::new();
let lines = render_message(
&ai,
60,
&theme(),
true,
true,
&mut pending,
&std::collections::HashMap::new(),
&started_map,
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
);
let joined = t(&lines).join("\n");
assert!(
joined.contains("Elapsed 1."),
"进行中应显示 Elapsed: {:?}",
joined
);
assert!(
!joined.contains("Took"),
"进行中不应显示 Took: {:?}",
joined
);
assert_eq!(
pending[0].started_at_ms,
Some(started),
"pending 应携带 started_at"
);
let mut res = AgentMessage::user_text("done");
res.role = "toolResult".to_string();
res.tool_call_id = Some("e1".into());
res.duration_ms = Some(3200);
let mut done_map = std::collections::HashMap::new();
done_map.insert(
"e1".to_string(),
ToolResultView {
text: res.text(),
duration_ms: Some(3200),
..Default::default()
},
);
let mut pending2 = Vec::new();
let lines2 = render_message(
&ai,
60,
&theme(),
true,
true,
&mut pending2,
&done_map,
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
);
let joined2 = t(&lines2).join("\n");
assert!(
joined2.contains("Took 3.2s"),
"完成后应显示 Took: {:?}",
joined2
);
assert!(!joined2.contains("Elapsed"), "完成后不应显示 Elapsed");
}
#[test]
fn summary_blocks_have_labels() {
let msg = AgentMessage::user_text("everything summarized");
let lines = render_summary(&msg.text(), true, 60, &theme(), false, &mut Vec::new());
let t = t(&lines);
assert!(t.iter().any(|l| l.contains("[compaction]")));
assert!(t.iter().any(|l| l.contains("Compacted context")));
}
#[test]
fn edit_result_renders_gutter_diff_with_colors() {
let mut ai = AgentMessage::user_text("");
ai.role = "assistant".to_string();
ai.content = vec![ContentBlock::ToolCall {
id: "e1".into(),
name: "edit".into(),
arguments: serde_json::json!({
"path": "a.txt",
"edits": [{"oldText": "old a\nold b", "newText": "new a\nnew b"}]
}),
thought_signature: None,
namespace: None,
}];
let mut res = AgentMessage::user_text("Successfully replaced 1 block(s) in a.txt.");
res.role = "toolResult".to_string();
res.tool_call_id = Some("e1".into());
res.duration_ms = Some(700);
res.details = Some(serde_json::json!({
"diff": " 1 # title\n-2 old a\n-3 old b\n+2 new a\n+3 new b\n 4 tail",
}));
let mut edit_map = HashMap::new();
edit_map.insert(
"e1".to_string(),
EditRenderData {
preview: None,
result_diff: Some(
" 1 # title\n-2 old a\n-3 old b\n+2 new a\n+3 new b\n 4 tail".to_string(),
),
},
);
let lines = render_message(
&ai,
60,
&theme(),
true,
true,
&mut Vec::new(),
&scan_tool_results(&[ai.clone(), res]),
&HashMap::new(),
&std::collections::HashMap::new(),
&edit_map,
);
let joined = t(&lines).join("\n");
assert!(joined.contains("edit a.txt"), "头部: {:?}", joined);
assert!(joined.contains("-2 old a"), "删除行: {:?}", joined);
assert!(joined.contains("+3 new b"), "新增行: {:?}", joined);
assert!(
!joined.contains("Successfully replaced"),
"成功文本不应显示: {:?}",
joined
);
assert!(!joined.contains("PATCH"), "PATCH 不应显示: {:?}", joined);
assert!(joined.contains("Took 0.7s"), "保留耗时: {:?}", joined);
let removed_fg = lines
.iter()
.flat_map(|l| &l.spans)
.find(|s| s.content.contains("-2 old a"))
.map(|s| s.style.fg);
let added_fg = lines
.iter()
.flat_map(|l| &l.spans)
.find(|s| s.content.contains("+3 new b"))
.map(|s| s.style.fg);
let context_fg = lines
.iter()
.flat_map(|l| &l.spans)
.find(|s| s.content.contains("1 # title"))
.map(|s| s.style.fg);
assert!(
removed_fg.flatten().is_some(),
"删除行应有前景色: {:?}",
joined
);
assert!(
added_fg.flatten().is_some(),
"新增行应有前景色: {:?}",
joined
);
assert_ne!(removed_fg, added_fg, "删除/新增颜色应不同: {:?}", joined);
assert_ne!(context_fg, added_fg, "上下文与新增颜色应不同: {:?}", joined);
}
#[test]
fn edit_pending_preview_renders_diff() {
let mut ai = AgentMessage::user_text("");
ai.role = "assistant".to_string();
ai.content = vec![ContentBlock::ToolCall {
id: "p1".into(),
name: "edit".into(),
arguments: serde_json::json!({
"path": "a.txt",
"edits": [{"oldText": "old line", "newText": "new line"}]
}),
thought_signature: None,
namespace: None,
}];
let mut edit_map = HashMap::new();
edit_map.insert(
"p1".to_string(),
EditRenderData {
preview: Some(" 1 # title\n-2 old line\n+2 new line\n 3 tail".to_string()),
result_diff: None,
},
);
let lines = render_message(
&ai,
60,
&theme(),
true,
true,
&mut Vec::new(),
&HashMap::new(),
&HashMap::new(),
&std::collections::HashMap::new(),
&edit_map,
);
let joined = t(&lines).join("\n");
assert!(joined.contains("-2 old line"), "预览删除行: {:?}", joined);
assert!(joined.contains("+2 new line"), "预览新增行: {:?}", joined);
}
#[test]
fn edit_error_shows_error_text() {
let mut ai = AgentMessage::user_text("");
ai.role = "assistant".to_string();
ai.content = vec![ContentBlock::ToolCall {
id: "e2".into(),
name: "edit".into(),
arguments: serde_json::json!({
"path": "a.txt",
"edits": [{"oldText": "old", "newText": "new"}]
}),
thought_signature: None,
namespace: None,
}];
let mut res = AgentMessage::user_text("Could not find the exact text in a.txt.");
res.role = "toolResult".to_string();
res.tool_call_id = Some("e2".into());
res.is_error = true;
let lines = render_message(
&ai,
60,
&theme(),
true,
true,
&mut Vec::new(),
&scan_tool_results(&[ai.clone(), res]),
&HashMap::new(),
&HashMap::new(),
&std::collections::HashMap::new(),
);
let joined = t(&lines).join("\n");
assert!(
joined.contains("Could not find the exact text"),
"错误文本: {:?}",
joined
);
}
#[test]
fn aborted_stop_reason_hint() {
let mut msg = AgentMessage::user_text("");
msg.role = "assistant".to_string();
msg.stop_reason = Some("aborted".into());
msg.content = vec![ContentBlock::Text {
text: "partial".into(),
text_signature: None,
}];
let lines = render_message(
&msg,
60,
&theme(),
false,
true,
&mut Vec::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
);
let t = t(&lines);
assert!(t.iter().any(|l| l.contains("Operation aborted")));
}
#[test]
fn system_prompts_interleave_by_timestamp() {
let mut user = AgentMessage::user_text("user msg");
user.timestamp = 1000;
let mut ai = AgentMessage::user_text("assistant reply");
ai.role = "assistant".to_string();
ai.timestamp = 2000;
let sys_after = (
3000u64,
SysMsg::plain(MsgLevel::Success, "✓ New session started"),
);
let sys_before = (500u64, SysMsg::plain(MsgLevel::Info, "old note"));
let lines = render_all_messages(
&[user, ai],
&[sys_after, sys_before],
None,
60,
&theme(),
false,
true,
);
let t = t(&lines);
let p_old = t.iter().position(|l| l.contains("old note")).unwrap();
let p_user = t.iter().position(|l| l.contains("user msg")).unwrap();
let p_ai = t
.iter()
.position(|l| l.contains("assistant reply"))
.unwrap();
let p_new = t
.iter()
.position(|l| l.contains("✓ New session started"))
.unwrap();
assert!(
p_old < p_user && p_user < p_ai && p_ai < p_new,
"时间线顺序错误: old={} user={} ai={} new={}",
p_old,
p_user,
p_ai,
p_new
);
}
#[test]
fn long_error_message_wraps_instead_of_truncating() {
let msg = "Error: provider returned 503 Service Unavailable: {\"error\":{\"type\":\"server_error\",\"message\":\"Error from provider (Console Go): Upstream request failed: Endpoint is unavailable.\"}}";
let lines = render_sys_message(&[SysSpan::plain(msg)], MsgLevel::Error, 60, &theme());
let t = t(&lines);
assert!(t.len() > 1, "超宽错误应折行: {:?}", t);
for l in &t {
assert!(
display_width(l) <= 60,
"折行后仍超宽: {:?} ({})",
l,
display_width(l)
);
}
let joined: String = t.concat();
assert!(
joined.starts_with("Error: provider returned 503"),
"头部缺失"
);
assert!(joined.ends_with("unavailable.\"}}"), "尾部缺失: {joined:?}");
}
#[test]
fn long_error_hint_wraps_instead_of_truncating() {
let msg = AgentMessage {
role: "assistant".into(),
thinking_level: None,
content: vec![crate::core::provider::ContentBlock::Text {
text: "I hit an error.".into(),
text_signature: None,
}],
stop_reason: Some("error".into()),
error_message: Some("provider returned 503 Service Unavailable: {\"error\":{\"type\":\"server_error\",\"message\":\"Error from provider (Console Go): Upstream request failed: Endpoint is unavailable.\"}}".into()),
..AgentMessage::user_text("")
};
let mut pending = Vec::new();
let lines = render_message(
&msg,
60,
&theme(),
false,
true,
&mut pending,
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
);
let t = t(&lines);
let joined_all: String = t.concat();
assert!(
joined_all.contains("Endpoint is unavailable."),
"错误尾部缺失: {joined_all:?}"
);
assert!(
joined_all.contains("I hit an error."),
"正文缺失: {joined_all:?}"
);
for l in &t {
assert!(
display_width(l) <= 61,
"折行后仍超宽: {:?} ({})",
l,
display_width(l)
);
}
let first_err = t
.iter()
.position(|l| l.contains("provider returned 503"))
.expect("错误行应存在");
assert!(t.len() - first_err > 2, "超宽错误应折成多行: {:?}", t);
}
#[test]
fn system_prompt_styled_and_wrapped() {
let lines = render_all_messages(
&[],
&[(
1u64,
SysMsg::plain(MsgLevel::Info, &"long system note ".repeat(10)),
)],
None,
20,
&theme(),
false,
true,
);
let t = t(&lines);
assert!(t.iter().any(|l| l.contains("system note")), "{:?}", t);
assert!(t.len() > 2, "超宽提示按消息宽度折行: {:?}", t);
}
#[test]
fn system_message_keeps_line_breaks_and_multicolor() {
let spans = vec![
SysSpan {
fg: Some("success".to_string()),
bold: true,
text: "Title\n".to_string(),
},
SysSpan::plain("\n"),
SysSpan::fg("dim", "Label:"),
SysSpan::fg("text", " value\n"),
];
let lines = render_sys_message(&spans, MsgLevel::Info, 60, &theme());
let t = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>();
assert_eq!(t.len(), 4, "\n 应拆成三行+块尾间隔空行: {:?}", t);
assert_eq!(t[0], "Title", "标题行: {:?}", t);
assert_eq!(t[1], "", "块间应留一空行: {:?}", t);
assert_eq!(t[2], "Label: value", "标签+值同行: {:?}", t);
assert_eq!(t[3], "", "块尾应有渲染间隔空行: {:?}", t);
let th = theme();
let title = &lines[0].spans[0];
assert!(
title
.style
.add_modifier
.contains(ratatui::style::Modifier::BOLD)
);
assert_eq!(title.style.fg, Some(th.resolve_color("success")));
assert_eq!(lines[2].spans[0].style.fg, Some(th.resolve_color("dim")));
assert_eq!(
lines[2].spans[1].style.fg,
Some(Color::Reset),
"text 值色为终端默认"
);
}
#[test]
fn system_message_expands_tabs() {
let spans = vec![SysSpan::plain("local\tenabled\thttp://x/mcp\ttools=0")];
let lines = render_sys_message(&spans, MsgLevel::Info, 80, &theme());
let text = lines[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
assert_eq!(text, "local enabled http://x/mcp tools=0");
assert_eq!(
crate::utils::display::display_width(&text),
crate::utils::display::grapheme_width(&text),
"展开后的显示宽度必须与 ratatui 落格宽度一致"
);
}
#[test]
fn system_message_collapses_trailing_blank() {
let spans = vec![SysSpan::plain("A\nB\n\n")];
let lines = render_sys_message(&spans, MsgLevel::Info, 60, &theme());
let t = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>();
assert_eq!(
t,
vec!["A".to_string(), "B".to_string(), "".to_string()],
"内容尾部折叠+块尾间隔空行: {:?}",
t
);
}
#[test]
fn session_info_blocks_separated_by_blank_and_cost_renders() {
let spans = vec![
SysSpan {
fg: Some("text".to_string()),
bold: true,
text: "Session Info\n".to_string(),
},
SysSpan::plain("\n"),
SysSpan::fg("dim", "File:"),
SysSpan::fg("text", " /path\n"),
SysSpan::fg("dim", "ID:"),
SysSpan::fg("text", " abc\n\n"),
SysSpan {
fg: Some("text".to_string()),
bold: true,
text: "Messages\n".to_string(),
},
SysSpan::fg("dim", "Tools:"),
SysSpan::fg("text", " 1 calls, 0 results\n\n"),
SysSpan {
fg: Some("text".to_string()),
bold: true,
text: "Tokens\n".to_string(),
},
SysSpan::fg("dim", "Input:"),
SysSpan::fg("text", " 1,000\n"),
SysSpan::fg("dim", "Total:"),
SysSpan::fg("text", " 1,062\n"),
SysSpan {
fg: Some("text".to_string()),
bold: true,
text: "\nCost\n".to_string(),
},
SysSpan::fg("dim", "Total:"),
SysSpan::fg("text", " $0.002\n"),
];
let lines = render_sys_message(&spans, MsgLevel::Info, 80, &theme());
let t = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>();
assert!(t.contains(&"Session Info".to_string()), "{:?}", t);
assert!(t.contains(&"Messages".to_string()), "{:?}", t);
assert!(t.contains(&"Tokens".to_string()), "{:?}", t);
assert!(t.contains(&"Cost".to_string()), "Cost 块必须渲染: {:?}", t);
assert!(
t.contains(&"Total: $0.002".to_string()),
"Cost total: {:?}",
t
);
let ci = t.iter().position(|l| l == "Cost").unwrap();
assert_eq!(t[ci - 1], "", "Cost 前应空行: {:?}", t);
assert_eq!(
t.last().map(|s| s.as_str()),
Some(""),
"块尾应有间隔空行: {:?}",
t
);
}
#[test]
fn sys_message_level_selects_color_style() {
let lines = render_all_messages(
&[],
&[
(
1u64,
SysMsg::plain(MsgLevel::Success, "✓ New session started"),
),
(2u64, SysMsg::plain(MsgLevel::Info, "plain note")),
(3u64, SysMsg::plain(MsgLevel::Warning, "not supported")),
(4u64, SysMsg::plain(MsgLevel::Error, "failed to load")),
],
None,
60,
&theme(),
false,
true,
);
let mk = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains("✓ New")))
.unwrap();
let plain = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains("plain")))
.unwrap();
let warn = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains("not supported")))
.unwrap();
let err = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains("failed to load")))
.unwrap();
let theme = theme();
assert_eq!(
mk.spans[0].style.fg,
theme.style("success", "#b5bd68").fg,
"success 消息使用主题 success 键"
);
assert_eq!(
plain.spans[0].style.fg,
theme.style("dim", "#666666").fg,
"info 使用主题 dim 键"
);
assert_ne!(
mk.spans[0].style.fg, plain.spans[0].style.fg,
"success 与 info 颜色不同"
);
assert_eq!(
warn.spans[0].style.fg,
theme.style("warning", "#d8a657").fg,
"warning 使用主题 warning 键"
);
assert!(
warn.spans[0].style.add_modifier.contains(Modifier::BOLD),
"warning 加粗"
);
assert_eq!(
err.spans[0].style.fg,
theme.style("error", "#cc6666").fg,
"error 使用主题 error 键"
);
assert!(
err.spans[0].style.add_modifier.contains(Modifier::BOLD),
"error 加粗"
);
assert!(
!plain.spans[0].style.add_modifier.contains(Modifier::BOLD),
"info 不加粗"
);
}
#[test]
fn sys_level_uses_canonical_state_keys() {
let t = theme();
for (level, key) in [
(MsgLevel::Info, "dim"),
(MsgLevel::Success, "success"),
(MsgLevel::Warning, "warning"),
(MsgLevel::Error, "error"),
] {
assert_eq!(
sys_level_style(level, &t).fg,
Some(t.resolve_color(key)),
"{level:?} 应取主题 {key} 键"
);
}
use crate::modes::interactive::theme::color;
let bare = Theme {
name: "bare".to_string(),
vars: HashMap::new(),
syntax_highlight: true,
mermaid: true,
latex: true,
appearance: None,
dim_keys: std::collections::HashSet::new(),
};
assert_eq!(
sys_level_style(MsgLevel::Info, &bare).fg,
Some(color("#666666")),
"info 回退到兜底色"
);
assert_eq!(
sys_level_style(MsgLevel::Error, &bare).fg,
Some(color("#cc6666")),
"error 回退到兜底色"
);
}
#[test]
fn user_multiline_message_keeps_line_breaks() {
let mut m = AgentMessage::user_text("line1\nline2\nline3\nline4");
m.role = "user".to_string();
let lines = render_all_messages(&[m], &[], None, 60, &theme(), false, true);
let t = t(&lines);
for (i, l) in ["line1", "line2", "line3", "line4"].iter().enumerate() {
assert!(t.iter().any(|x| x.contains(l)), "第 {} 行丢失: {:?}", i, t);
}
let joined: String = t.join("\n");
let p1 = joined.find("line1").unwrap();
let p2 = joined.find("line2").unwrap();
let p3 = joined.find("line3").unwrap();
let p4 = joined.find("line4").unwrap();
assert!(p1 < p2 && p2 < p3 && p3 < p4, "行序保持: {:?}", t);
}
#[test]
fn messages_separated_by_blank_line() {
let a = AgentMessage::user_text("first");
let mut b = AgentMessage::user_text("");
b.role = "assistant".to_string();
b.content = vec![ContentBlock::Text {
text: "second".into(),
text_signature: None,
}];
let lines = render_all_messages(&[a, b], &[], None, 60, &theme(), false, true);
let t = t(&lines);
let ia = t.iter().position(|l| l.contains("first")).unwrap();
let ib = t.iter().position(|l| l.contains("second")).unwrap();
assert!(ia + 1 < ib, "expected gap: {:?}", t);
assert!(
t[ia + 1..ib].iter().any(|l| l.trim().is_empty()),
"no blank line between messages: {:?}",
t
);
}
fn is_pure_gap_line(line: &Line<'static>) -> bool {
line.spans.iter().all(|s| s.content.trim().is_empty())
&& !line.spans.iter().any(|s| s.style.bg.is_some())
}
#[test]
fn assistant_blocks_have_blank_gaps() {
let mut msg = AgentMessage::user_text("");
msg.role = "assistant".to_string();
msg.content = vec![
ContentBlock::Thinking {
thinking: "think".into(),
thinking_signature: None,
redacted: None,
},
ContentBlock::ToolCall {
id: "t1".into(),
name: "ls".into(),
arguments: serde_json::json!({}),
thought_signature: None,
namespace: None,
},
ContentBlock::Text {
text: "result text".into(),
text_signature: None,
},
];
let mut tool_map = std::collections::HashMap::new();
let mut res = AgentMessage::user_text("a\nb");
res.role = "toolResult".to_string();
res.tool_call_id = Some("t1".into());
res.duration_ms = Some(10);
tool_map.insert(
"t1".to_string(),
ToolResultView {
text: res.text(),
duration_ms: Some(10),
..Default::default()
},
);
let lines = render_message(
&msg,
60,
&theme(),
true,
true,
&mut Vec::new(),
&tool_map,
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
);
let texts = t(&lines);
let p_thinking = texts.iter().position(|l| l.contains("think")).unwrap();
let p_tool = texts
.iter()
.position(|l| l.trim().starts_with("ls ."))
.unwrap();
let p_took = texts.iter().position(|l| l.contains("Took")).unwrap();
let p_text = texts
.iter()
.position(|l| l.contains("result text"))
.unwrap();
assert!(
lines[p_thinking + 1..p_tool].iter().any(is_pure_gap_line),
"thinking→工具块缺纯空行: {:?}",
texts
);
assert!(
lines[p_took + 1..p_text].iter().any(is_pure_gap_line),
"Took→文本缺纯空行: {:?}",
texts
);
}
#[test]
fn background_blocks_separated_by_pure_gap() {
let user = AgentMessage::user_text("ls");
let mut ai = AgentMessage::user_text("");
ai.role = "assistant".to_string();
ai.content = vec![ContentBlock::Thinking {
thinking: "think".into(),
thinking_signature: None,
redacted: None,
}];
let lines = render_all_messages(&[user, ai], &[], None, 60, &theme(), true, true);
let p_user = lines
.iter()
.position(|l| l.spans.iter().any(|s| s.content.contains("ls")))
.unwrap();
let p_thinking = lines
.iter()
.position(|l| l.spans.iter().any(|s| s.content.contains("think")))
.unwrap();
assert!(
lines[p_user + 1..p_thinking].iter().any(is_pure_gap_line),
"用户块→thinking 缺纯空行: {:?}",
t(&lines)
);
assert!(
lines[p_user + 1..p_thinking]
.iter()
.any(|l| !is_pure_gap_line(l) && l.spans.iter().any(|s| s.style.bg.is_some())),
"应存在带背景的 padding 行: {:?}",
t(&lines)
);
}
#[test]
fn skill_invocation_collapses_by_default() {
let text = "<skill name=\"html-ppt\" location=\"/x/skills/html-ppt/SKILL.md\">\nReferences are relative to /x/skills/html-ppt.\n\n# Build slides\n</skill>\n\nhttps://besok.github.io/posts/what-zig-felt-like-coming-from-rust/\n";
let msg = AgentMessage::user_text(text);
let lines = render_message(
&msg,
60,
&theme(),
false,
true,
&mut Vec::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
);
let joined: Vec<String> = t(&lines);
let all = joined.join("\n");
assert!(
all.contains("[skill] html-ppt (Ctrl+O/Alt+click to expand)"),
"got: {:?}",
joined
);
assert!(
!all.contains("# Build slides"),
"body 不应输出: {:?}",
joined
);
assert!(
!all.contains("References are relative to"),
"location 不应输出: {:?}",
joined
);
assert!(
all.contains("besok.github.io/posts/what-zig-felt-like-coming"),
"userMessage 应单独渲染: {:?}",
joined
);
}
#[test]
fn skill_invocation_expands_with_markdown_body() {
let text = "<skill name=\"html-ppt\" location=\"/x/SKILL.md\">\n# Build slides\n\n- step1\n</skill>";
let msg = AgentMessage::user_text(text);
let lines = render_message(
&msg,
60,
&theme(),
true,
true,
&mut Vec::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
);
let all = t(&lines).join("\n");
assert!(all.contains("[skill]"), "展开含 label: {:?}", all);
assert!(all.contains("html-ppt"), "展开含技能名标题: {:?}", all);
assert!(all.contains("Build slides"), "展开含 body: {:?}", all);
assert!(all.contains("step1"), "展开含 body 内容: {:?}", all);
}
#[test]
fn plain_user_message_untouched() {
let msg = AgentMessage::user_text("just a normal message");
let lines = render_message(
&msg,
60,
&theme(),
true,
true,
&mut Vec::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
);
let all = t(&lines).join("\n");
assert!(all.contains("just a normal message"));
assert!(!all.contains("[skill]"));
}
#[test]
fn fast_mode_skips_syntax_highlight() {
let md = "```rust\nlet x = 1;\n```";
let mut th = theme();
assert!(th.syntax_highlight, "默认主题应开启高亮");
th.syntax_highlight = false;
let fast = render_markdown_styled(md, 60, &th, ratatui::style::Style::default());
let fast_code_line = fast
.iter()
.find(|l| l.spans.iter().any(|s| s.content == "let"))
.expect("fast 渲染应含代码行");
let fast_fgs: std::collections::HashSet<_> = fast_code_line
.spans
.iter()
.filter_map(|s| s.style.fg)
.collect();
assert_eq!(
fast_fgs.len(),
1,
"fast 代码行应单色(纯 codeBlock 色): {:?}",
fast_fgs
);
let full = render_markdown_styled(md, 60, &theme(), ratatui::style::Style::default());
let full_code_line = full
.iter()
.find(|l| l.spans.iter().any(|s| s.content == "let"))
.expect("full 渲染应含代码行");
let full_fgs: std::collections::HashSet<_> = full_code_line
.spans
.iter()
.filter_map(|s| s.style.fg)
.collect();
assert!(
full_fgs.len() > fast_fgs.len(),
"syntect 应产生多种 token 颜色: fast={:?} full={:?}",
fast_fgs,
full_fgs
);
}
#[test]
fn full_history_matches_render_all_messages() {
let mut ai = AgentMessage::user_text("");
ai.role = "assistant".to_string();
ai.content = vec![ContentBlock::ToolCall {
id: "c1".into(),
name: "bash".into(),
arguments: serde_json::json!({ "command": "ls" }),
thought_signature: None,
namespace: None,
}];
ai.timestamp = 10;
let mut res = AgentMessage::user_text("");
res.role = "toolResult".to_string();
res.content = vec![ContentBlock::Text {
text: "file1\nfile2".into(),
text_signature: None,
}];
res.tool_call_id = Some("c1".into());
res.timestamp = 20;
let user = AgentMessage::user_text("hello *world*");
let mut user2 = user.clone();
user2.timestamp = 30;
let msgs = vec![ai, user, user2, res];
let sys: Vec<(u64, SysMsg)> = vec![(15, SysMsg::plain(MsgLevel::Success, "✓ sys note"))];
let snap = HighlightSnapshot {
msgs: msgs.clone(),
sys: sys.clone(),
width: 60,
expand_all: false,
show_thinking: true,
images: ImageLayout::disabled(),
overrides: HashMap::new(),
theme: theme(),
};
let cached = render_full_history(&snap);
let items = timeline_items(&msgs, &sys);
let mut actual: Vec<Line<'static>> = Vec::new();
for (ts, seq, _) in items {
let Some(c) = cached.get(&(ts, seq)) else {
panic!("缓存缺失 ({}, {})", ts, seq);
};
if c.lines.is_empty() {
continue;
}
actual.extend(c.lines.iter().cloned());
let ends_blank = c.lines.last().map(is_pure_gap).unwrap_or(true);
if !ends_blank {
actual.push(Line::from(""));
}
}
let expected = render_all_messages(&msgs, &sys, None, 60, &theme(), false, true);
assert_eq!(t(&actual), t(&expected), "缓存管线输出与全量渲染不一致");
}
#[test]
fn timeline_keeps_message_list_order_despite_timestamps() {
let mut m1 = AgentMessage::user_text("msg1");
m1.timestamp = 100;
let mut a1 = AgentMessage::user_text("");
a1.role = "assistant".to_string();
a1.content = vec![ContentBlock::Text {
text: "assistant1".into(),
text_signature: None,
}];
a1.timestamp = 500;
let mut m2 = AgentMessage::user_text("msg2 steer");
m2.timestamp = 200; let mut a2 = AgentMessage::user_text("");
a2.role = "assistant".to_string();
a2.content = vec![ContentBlock::Text {
text: "assistant2".into(),
text_signature: None,
}];
a2.timestamp = 600;
let msgs = vec![m1, a1, m2, a2];
let items = timeline_items(&msgs, &[]);
let texts: Vec<String> = items
.iter()
.map(|(_, _, r)| match r {
TimelineRef::Msg(m) => m.text(),
TimelineRef::Sys(_) => String::new(),
})
.collect();
assert_eq!(
texts,
vec!["msg1", "assistant1", "msg2 steer", "assistant2"],
"消息应按列表顺序渲染,而非按时间戳"
);
}
#[test]
fn bash_long_command_header_wraps_not_truncates() {
let long = format!("echo {}", "x".repeat(200));
let args = serde_json::json!({ "command": long });
let lines = render_tool_block(
"bash",
&args,
None,
None,
None,
None,
&[],
30,
&theme(),
false,
);
let rows = t(&lines);
let compact: String = rows
.iter()
.flat_map(|l| l.chars().filter(|c| !c.is_whitespace()))
.collect();
assert!(rows.len() > 3, "超长命令应折成多行: {rows:?}");
assert!(compact.contains(&"x".repeat(200)), "命令尾部应完整保留");
assert!(
rows.iter().any(|l| l.contains('$')),
"应保留命令前缀: {rows:?}"
);
for l in &rows {
assert!(display_width(l) <= 30, "行超宽: {l:?}");
}
}
#[test]
fn tool_header_long_path_wraps_not_truncates() {
let long = format!("/very/long/{}", "segment/".repeat(20));
let args = serde_json::json!({ "path": long });
let lines = render_tool_block(
"read",
&args,
None,
None,
None,
None,
&[],
20,
&theme(),
false,
);
let rows = t(&lines);
let compact: String = rows
.iter()
.flat_map(|l| l.chars().filter(|c| !c.is_whitespace()))
.collect();
assert!(rows.len() > 2, "长路径应折成多行: {rows:?}");
assert!(compact.contains(&long), "路径应完整保留: {compact}");
for l in &rows {
assert!(display_width(l) <= 20, "行超宽: {l:?}");
}
}
#[test]
fn mcp_call_header_shows_server_slash_tool_with_args() {
let args = serde_json::json!({
"action": "call",
"server": "fs",
"name": "read_file",
"arguments": { "path": "a.txt", "limit": 5 }
});
let rows = t(&render_tool_block(
"mcp",
&args,
None,
None,
None,
None,
&[],
80,
&theme(),
false,
));
let joined = rows.join("\n");
assert!(joined.contains("fs/read_file"), "标题: {rows:?}");
assert!(joined.contains("path=\"a.txt\""), "参数对: {rows:?}");
assert!(joined.contains("limit=5"), "参数对: {rows:?}");
assert!(!joined.contains("call"), "action 不应出现: {rows:?}");
}
#[test]
fn mcp_call_header_expanded_lists_one_arg_per_line() {
let args = serde_json::json!({
"action": "call",
"server": "fs",
"name": "read_file",
"arguments": { "path": "a.txt", "limit": 5 }
});
let rows = t(&render_tool_block(
"mcp",
&args,
None,
None,
None,
None,
&[],
80,
&theme(),
true,
));
assert!(rows.iter().any(|l| l.trim() == "fs/read_file"), "{rows:?}");
assert!(rows.iter().any(|l| l.trim() == "path: a.txt"), "{rows:?}");
assert!(rows.iter().any(|l| l.trim() == "limit: 5"), "{rows:?}");
}
#[test]
fn mcp_non_call_action_shows_action_and_key_value_args() {
let args = serde_json::json!({ "action": "search", "query": "weather" });
let rows = t(&render_tool_block(
"mcp",
&args,
None,
None,
None,
None,
&[],
80,
&theme(),
false,
));
let joined = rows.join("\n");
assert!(joined.contains("mcp search"), "标题: {rows:?}");
assert!(joined.contains("query=\"weather\""), "参数对: {rows:?}");
}
#[test]
fn mcp_result_preview_folds_to_five_lines() {
let args = serde_json::json!({
"action": "call", "server": "fs", "name": "read_file", "arguments": {}
});
let output = (1..=8)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
let rows = t(&render_tool_block(
"mcp",
&args,
Some((&output, false)),
None,
None,
None,
&[],
80,
&theme(),
false,
));
let joined = rows.join("\n");
assert!(joined.contains("line5"), "前 5 行应显示: {rows:?}");
assert!(!joined.contains("line6"), "第 6 行应折叠: {rows:?}");
assert!(
joined.contains("... (3 more lines, ctrl+o/alt+click to expand)"),
"折叠提示: {rows:?}"
);
}
#[test]
fn generic_tool_header_shows_key_value_args() {
let args = serde_json::json!({ "objective": "ship it", "count": 2 });
let collapsed = t(&render_tool_block(
"goal",
&args,
None,
None,
None,
None,
&[],
80,
&theme(),
false,
))
.join("\n");
assert!(collapsed.contains("goal"), "标题: {collapsed}");
assert!(collapsed.contains("objective=\"ship it\""), "{collapsed}");
assert!(collapsed.contains("count=2"), "{collapsed}");
let expanded = t(&render_tool_block(
"goal",
&args,
None,
None,
None,
None,
&[],
80,
&theme(),
true,
));
assert!(expanded.iter().any(|l| l.trim() == "objective: ship it"));
assert!(expanded.iter().any(|l| l.trim() == "count: 2"));
}
#[test]
fn collapsed_args_cut_at_hundred_chars() {
let args = serde_json::json!({ "objective": "x".repeat(300) });
let rows = t(&render_tool_block(
"goal",
&args,
None,
None,
None,
None,
&[],
400,
&theme(),
false,
));
let joined = rows.join("");
let joined = joined.trim();
let pairs = joined.split("objective=").nth(1).expect("参数对");
assert!(pairs.ends_with("..."), "应截断: {pairs}");
assert_eq!(
pairs.chars().count(),
COLLAPSED_ARGS_CHARS - "objective=".len(),
"参数对(含键前缀)截断到 COLLAPSED_ARGS_CHARS 字符"
);
}
fn assistant_with_tool_call(id: &str) -> AgentMessage {
let mut m = AgentMessage::user_text("");
m.role = "assistant".to_string();
m.content = vec![ContentBlock::ToolCall {
id: id.to_string(),
name: "read".to_string(),
arguments: serde_json::json!({ "path": "a.rs" }),
thought_signature: None,
namespace: None,
}];
m
}
fn tool_result(id: &str, text: &str) -> AgentMessage {
let mut m = AgentMessage::user_text(text);
m.role = "toolResult".to_string();
m.tool_call_id = Some(id.to_string());
m.tool_name = Some("read".to_string());
m
}
#[test]
fn message_cache_reuses_entries_and_invalidates_on_tool_result() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let theme = theme();
let mut cache = MessageCache::default();
let msgs = vec![assistant_with_tool_call("t1")];
let first = cache.render(&msgs, 60, &theme, false, true);
assert!(
first
.iter()
.any(|l| t(std::slice::from_ref(l))[0].contains("read"))
);
let ptr1 = Arc::as_ptr(&cache.entries[0].0.lines);
let again = cache.render(&msgs, 60, &theme, false, true);
assert_eq!(Arc::as_ptr(&cache.entries[0].0.lines), ptr1, "应命中缓存");
assert_eq!(first.len(), again.len());
let msgs = vec![
assistant_with_tool_call("t1"),
tool_result("t1", "file contents here"),
];
let after = cache.render(&msgs, 60, &theme, false, true);
assert_ne!(
Arc::as_ptr(&cache.entries[0].0.lines),
ptr1,
"toolResult 到达后 assistant 块应重渲染"
);
let joined: String = after
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
joined.contains("file contents here"),
"结果应内联显示: {joined}"
);
}
#[test]
fn message_cache_clears_when_width_changes() {
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let theme = theme();
let mut cache = MessageCache::default();
let msgs = vec![AgentMessage::user_text("hello world")];
let _ = cache.render(&msgs, 60, &theme, false, true);
assert_eq!(cache.entries.len(), 1);
let _ = cache.render(&msgs, 40, &theme, false, true);
assert_eq!(cache.width, 40, "宽度变化后缓存参数应更新");
assert_eq!(cache.entries.len(), 1, "重建后仍有 1 条");
}
}