use ratatui::prelude::*;
use ratatui::widgets::{Paragraph, Wrap};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::tui::state::{DiffClickRegion, MessageRole, MessagesRenderCache, UiState};
use crate::tui::theme::Theme;
fn wrap_to_width(text: &str, max_width: usize) -> Vec<String> {
if max_width == 0 {
return vec![text.to_string()];
}
let mut result = Vec::new();
for input_line in text.lines() {
if input_line.is_empty() {
result.push(String::new());
continue;
}
let mut current = String::new();
let mut current_w = 0usize;
for word in input_line.split_inclusive(' ') {
let word_w = UnicodeWidthStr::width(word);
if current_w + word_w > max_width && !current.is_empty() {
result.push(current.trim_end().to_string());
current = String::new();
current_w = 0;
}
if word_w > max_width {
let mut buf = String::new();
let mut buf_w = 0usize;
for ch in word.chars() {
let cw = ch.width().unwrap_or(1);
if buf_w + cw > max_width && !buf.is_empty() {
result.push(buf.clone());
buf.clear();
buf_w = 0;
}
buf.push(ch);
buf_w += cw;
}
if !buf.is_empty() {
current.push_str(&buf);
current_w += buf_w;
}
} else {
current.push_str(word);
current_w += word_w;
}
}
if !current.is_empty() {
result.push(current.trim_end().to_string());
}
}
result
}
fn build_assistant_label(provider_name: &str, model_name: &str, swarm_names: &[String]) -> String {
let model_part = if model_name.is_empty() {
String::new()
} else {
format!(" ({}/{})", provider_name, model_name)
};
let coordinator = format!("Coordinator{}", model_part);
if swarm_names.is_empty() {
coordinator
} else {
let agents: Vec<String> = swarm_names
.iter()
.map(|n| {
let mut chars = n.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
}
})
.collect();
format!("{}, {}{}", coordinator, agents.join("|"), model_part)
}
}
struct RenderSliceParams<'a> {
messages: &'a [crate::tui::state::ChatMessage],
from_idx: usize,
prev_is_user: bool,
inner_width: usize,
theme: &'a Theme,
provider_name: &'a str,
model_name: &'a str,
swarm_names: &'a [String],
collab_mode_label: &'a str,
}
fn render_messages_slice(
p: &RenderSliceParams<'_>,
lines: &mut Vec<Line<'static>>,
user_box_ranges: &mut Vec<(usize, usize)>,
click_regions: &mut Vec<DiffClickRegion>,
) -> bool {
let messages = p.messages;
let from_idx = p.from_idx;
let mut prev_is_user = p.prev_is_user;
let inner_width = p.inner_width;
let theme = p.theme;
let provider_name = p.provider_name;
let model_name = p.model_name;
let swarm_names = p.swarm_names;
let collab_mode_label = p.collab_mode_label;
for (i, msg) in messages.iter().enumerate().skip(from_idx) {
let is_user = matches!(msg.role, MessageRole::User | MessageRole::Queued);
if i > 0 && !is_user && !prev_is_user {
lines.push(Line::from(""));
}
match &msg.role {
MessageRole::Tool {
name,
success,
args_summary,
} => {
let (icon, color) = if *success {
("✓", theme.success)
} else {
("✗", theme.error)
};
let max_summary_w = inner_width.saturating_sub(name.len() + 6);
let summary = tool_one_liner(name, args_summary, &msg.content, max_summary_w);
if name == "bash" && summary.is_empty() {
let has_cmd = extract_json_field(args_summary, "command")
.map(|c| !c.is_empty())
.unwrap_or(false);
if !has_cmd {
if i > 0 && !prev_is_user {
lines.pop();
}
prev_is_user = false;
continue;
}
}
let mut spans = vec![
Span::styled(format!(" {icon} "), Style::default().fg(color)),
Span::styled(name.clone(), Style::default().fg(color).bold()),
];
if !summary.is_empty() {
spans.push(Span::styled(
format!(" {summary}"),
Style::default().fg(theme.text_muted),
));
}
lines.push(Line::from(spans));
if name == "file_edit" {
render_mini_diff(args_summary, inner_width, theme, lines, click_regions);
} else if name == "file_write" {
render_write_preview(args_summary, inner_width, theme, lines, click_regions);
}
}
MessageRole::User => {
lines.push(Line::from(""));
let box_start = lines.len();
lines.push(Line::from(""));
const USER_PREFIX: usize = 4;
let content_w = inner_width.saturating_sub(USER_PREFIX);
let text = msg.content.text_content();
for content_line in text.lines() {
for wrapped in wrap_to_width(content_line, content_w) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(wrapped, Style::default().fg(theme.user)),
]));
}
}
lines.push(Line::from(""));
user_box_ranges.push((box_start, lines.len()));
lines.push(Line::from(""));
}
MessageRole::Queued => {
lines.push(Line::from(""));
let box_start = lines.len();
lines.push(Line::from(""));
const QUEUED_PREFIX: usize = 6;
let content_w = inner_width.saturating_sub(QUEUED_PREFIX);
let text = msg.content.text_content();
for (line_idx, content_line) in text.lines().enumerate() {
for wrapped in wrap_to_width(content_line, content_w) {
if line_idx == 0 {
lines.push(Line::from(vec![
Span::styled(" ⏳ ", Style::default().fg(theme.text_muted)),
Span::styled(wrapped, Style::default().fg(theme.text_dim)),
]));
} else {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(wrapped, Style::default().fg(theme.text_dim)),
]));
}
}
}
lines.push(Line::from(""));
user_box_ranges.push((box_start, lines.len()));
lines.push(Line::from(""));
}
MessageRole::Assistant => {
let label = build_assistant_label(provider_name, model_name, swarm_names);
render_role_header(&label, theme.assistant, theme, lines);
lines.push(Line::from(""));
let text = msg.content.text_content();
render_markdown(&text, lines, theme, inner_width);
}
MessageRole::System => {
let sys_text = msg.content.text_content();
if sys_text.starts_with("🤖 Agent:")
|| sys_text.contains("에이전트 전환")
|| sys_text.starts_with("⚙ Mode:")
|| sys_text.starts_with("🔄")
{
prev_is_user = false;
continue;
}
if sys_text == "##WELCOME##" {
render_welcome_tips(lines, theme, inner_width, collab_mode_label);
prev_is_user = false;
continue;
}
const SYS_PREFIX: usize = 4;
let content_w = inner_width.saturating_sub(SYS_PREFIX);
let wrapped_all: Vec<String> = sys_text
.lines()
.flat_map(|l| wrap_to_width(l, content_w))
.collect();
let pad = " ";
for (idx, wl) in wrapped_all.iter().enumerate() {
if idx == 0 {
lines.push(Line::from(vec![
Span::styled(" ✦ ", Style::default().fg(theme.system).bold()),
Span::styled(wl.clone(), Style::default().fg(theme.text_dim)),
]));
} else {
lines.push(Line::from(Span::styled(
format!("{pad}{wl}"),
Style::default().fg(theme.text_dim),
)));
}
}
}
}
prev_is_user = is_user;
}
prev_is_user
}
fn render_welcome_tips(
lines: &mut Vec<Line<'static>>,
theme: &Theme,
inner_width: usize,
collab_mode: &str,
) {
let accent = theme.system;
let dim = theme.text_dim;
let text = theme.text;
let muted = theme.text_muted;
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled("COLLET", Style::default().fg(accent).bold()),
Span::styled(
" — Relentless agentic coding orchestrator",
Style::default().fg(dim),
),
]));
lines.push(Line::from(""));
let bar = "─".repeat(inner_width.saturating_sub(4).min(48));
lines.push(Line::from(vec![Span::styled(
format!(" {bar}"),
Style::default().fg(muted),
)]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" Arbor ", Style::default().fg(accent).bold()),
Span::styled("(current agent) ", Style::default().fg(muted)),
Span::styled(
"autonomous — selects agents & strategy automatically",
Style::default().fg(dim),
),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
format!(" {bar}"),
Style::default().fg(muted),
)]));
lines.push(Line::from(""));
let (mode_label, mode_style) = match collab_mode {
"fork" => ("fork", Style::default().fg(theme.success).bold()),
"hive" => ("hive", Style::default().fg(theme.warning).bold()),
"flock" => ("flock", Style::default().fg(theme.accent).bold()),
_ => ("none", Style::default().fg(muted)),
};
lines.push(Line::from(vec![
Span::styled(" Swarm ", Style::default().fg(accent).bold()),
Span::styled("current: ", Style::default().fg(dim)),
Span::styled(mode_label, mode_style),
Span::styled(
" (/fork · /hive · /flock to toggle)",
Style::default().fg(muted),
),
]));
for (cmd, desc) in [
("/fork", "parallel — split tasks, merge results"),
("/hive", "consensus — agents review each other"),
("/flock", "realtime — agents coordinate live"),
] {
lines.push(Line::from(vec![
Span::styled(
format!(" {cmd:<10}", cmd = cmd),
Style::default().fg(text),
),
Span::styled(desc, Style::default().fg(muted)),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
format!(" {bar}"),
Style::default().fg(muted),
)]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" Agents", Style::default().fg(accent).bold()),
Span::styled(" Tab / Shift+Tab", Style::default().fg(text)),
]));
for (name, desc) in [
("arbor", "autonomous orchestrator (default)"),
("architect", "planning, design, task breakdown"),
("code", "implementation, tests, debugging"),
("ask", "Q&A — read-only, no code changes"),
] {
lines.push(Line::from(vec![
Span::styled(
format!(" {name:<12}", name = name),
Style::default().fg(text).bold(),
),
Span::styled(desc, Style::default().fg(muted)),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
format!(" {bar}"),
Style::default().fg(muted),
)]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" @", Style::default().fg(accent).bold()),
Span::styled(" files, dirs, agents ", Style::default().fg(text)),
Span::styled("attach context or switch agent", Style::default().fg(muted)),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
format!(" {bar}"),
Style::default().fg(muted),
)]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" /", Style::default().fg(accent).bold()),
Span::styled(" commands & skills ", Style::default().fg(text)),
Span::styled(
"↑↓ to pick, Tab to expand, Enter to run",
Style::default().fg(muted),
),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
format!(" {bar}"),
Style::default().fg(muted),
)]));
lines.push(Line::from(""));
}
#[inline]
fn line_visual_height(line: &Line, render_width: usize) -> u16 {
let span_width: usize = line
.spans
.iter()
.flat_map(|s| s.content.chars())
.map(|c| c.width().unwrap_or(1))
.sum();
if render_width == 0 || span_width == 0 {
1
} else {
span_width.div_ceil(render_width) as u16
}
}
fn compute_scroll_and_heights(
state: &UiState,
lines: &[Line],
render_width: usize,
view_height: u16,
msg_line_count: usize,
) -> (Vec<u16>, u16) {
let cached_msg_heights: Option<Vec<u16>> = {
let c = state.messages_render_cache.borrow();
c.as_ref().and_then(|cache| {
if cache.width == render_width as u16 && cache.line_heights.len() == msg_line_count {
Some(cache.line_heights.clone())
} else {
None
}
})
};
let line_heights: Vec<u16> = if let Some(cached) = cached_msg_heights {
let mut heights = cached;
for line in &lines[msg_line_count..] {
heights.push(line_visual_height(line, render_width));
}
heights
} else {
lines
.iter()
.map(|l| line_visual_height(l, render_width))
.collect()
};
let visual_height_raw: u32 = line_heights.iter().map(|&h| h as u32).sum();
let visual_height_raw_u16 = visual_height_raw.min(u16::MAX as u32) as u16;
let final_line_heights = if state.messages.len() == 1 && visual_height_raw_u16 < view_height {
let pad = (view_height - visual_height_raw_u16) as usize;
std::iter::repeat_n(1u16, pad).chain(line_heights).collect()
} else {
line_heights
};
let visual_height: u32 = final_line_heights
.iter()
.map(|&h| h as u32)
.sum::<u32>()
.saturating_add(2);
let max_scroll = visual_height.saturating_sub(view_height as u32);
let clamped_offset = (state.scroll_offset as u32).min(max_scroll) as u16;
let scroll = (max_scroll - clamped_offset as u32).min(u16::MAX as u32) as u16;
let mut cum: Vec<u16> = Vec::with_capacity(final_line_heights.len() + 1);
let mut acc: u32 = 0;
for &h in &final_line_heights {
cum.push(acc.min(u16::MAX as u32) as u16);
acc = acc.saturating_add(h as u32);
}
cum.push(acc.min(u16::MAX as u32) as u16);
*state.last_line_cum_heights.borrow_mut() = cum;
*state.last_render_scroll.borrow_mut() = scroll;
(final_line_heights, scroll)
}
fn render_user_message_boxes(
user_box_ranges: &[(usize, usize)],
line_heights: &[u16],
scroll: u16,
area: Rect,
theme: &Theme,
buf: &mut Buffer,
) {
let mut cum: Vec<u16> = Vec::with_capacity(line_heights.len() + 1);
let mut acc = 0u16;
for &h in line_heights {
cum.push(acc);
acc = acc.saturating_add(h);
}
cum.push(acc);
let view_height = area.height;
for &(start_idx, end_idx) in user_box_ranges {
let box_top = cum.get(start_idx).copied().unwrap_or(0) as i32 - scroll as i32;
let box_bot = cum.get(end_idx).copied().unwrap_or(0) as i32 - scroll as i32;
let vis_top = box_top.max(0).min(view_height as i32) as u16;
let vis_bot = box_bot.max(0).min(view_height as i32) as u16;
for row in vis_top..vis_bot {
for col in 0..area.width {
buf[(area.x + col, area.y + row)].set_bg(theme.bg_surface);
}
}
}
}
pub fn render(state: &UiState, area: Rect, buf: &mut Buffer) {
if let Some((agent_id, detail)) = state.attached_worker_detail() {
render_worker_view(state, area, buf, agent_id, detail);
return;
}
let theme = &state.theme;
let inner_width = area.width.saturating_sub(2) as usize;
let inner_width_u16 = inner_width as u16;
*state.last_output_area.borrow_mut() = area;
let message_count = state.messages.len();
let theme_bg = theme.bg;
let provider_name = state.provider_name.as_str();
let model_name = state.model_name.as_str();
let swarm_names: Vec<String> = state
.swarm_status
.as_ref()
.map(|s| s.agents.iter().map(|a| a.name.clone()).collect())
.unwrap_or_default();
let (mut lines, user_box_line_ranges, cache_rebuilt) = {
let mut cache_ref = state.messages_render_cache.borrow_mut();
let cache_matches = cache_ref.as_ref().is_some_and(|c| {
c.width == inner_width_u16
&& c.theme_bg == theme_bg
&& c.provider_name == provider_name
&& c.model_name == model_name
&& c.swarm_names == swarm_names
&& c.collab_mode_label == state.collab_mode_label
});
let cached_count = cache_ref.as_ref().map_or(0, |c| c.message_count);
if cache_matches && cached_count == message_count {
let c = cache_ref.as_ref().expect("cache_matches guarantees Some");
(c.lines.clone(), c.user_box_ranges.clone(), false)
} else if cache_matches && cached_count < message_count {
let cached = cache_ref.as_ref().expect("cache_matches guarantees Some");
let prev_was_user = cached.last_was_user;
let mut lines = cached.lines.clone();
let mut user_box_ranges = cached.user_box_ranges.clone();
let mut click_regions = cached.click_regions.clone();
let last_was_user = render_messages_slice(
&RenderSliceParams {
messages: &state.messages,
from_idx: cached_count,
prev_is_user: prev_was_user,
inner_width,
theme,
provider_name,
model_name,
swarm_names: &swarm_names,
collab_mode_label: &state.collab_mode_label,
},
&mut lines,
&mut user_box_ranges,
&mut click_regions,
);
let msg_line_heights: Vec<u16> = lines
.iter()
.map(|l| line_visual_height(l, area.width as usize))
.collect();
*cache_ref = Some(MessagesRenderCache {
width: inner_width_u16,
theme_bg,
provider_name: provider_name.to_string(),
model_name: model_name.to_string(),
swarm_names: swarm_names.clone(),
collab_mode_label: state.collab_mode_label.clone(),
message_count,
lines: lines.clone(),
user_box_ranges: user_box_ranges.clone(),
click_regions: click_regions.clone(),
last_was_user,
line_heights: msg_line_heights,
});
*state.diff_click_regions.borrow_mut() = click_regions;
(lines, user_box_ranges, true)
} else {
let mut lines = Vec::new();
let mut user_box_ranges = Vec::new();
let mut click_regions = Vec::new();
let last_was_user = render_messages_slice(
&RenderSliceParams {
messages: &state.messages,
from_idx: 0,
prev_is_user: false,
inner_width,
theme,
provider_name,
model_name,
swarm_names: &swarm_names,
collab_mode_label: &state.collab_mode_label,
},
&mut lines,
&mut user_box_ranges,
&mut click_regions,
);
let msg_line_heights: Vec<u16> = lines
.iter()
.map(|l| line_visual_height(l, area.width as usize))
.collect();
*cache_ref = Some(MessagesRenderCache {
width: inner_width_u16,
theme_bg,
provider_name: provider_name.to_string(),
model_name: model_name.to_string(),
swarm_names: swarm_names.clone(),
collab_mode_label: state.collab_mode_label.clone(),
message_count,
lines: lines.clone(),
user_box_ranges: user_box_ranges.clone(),
click_regions: click_regions.clone(),
last_was_user,
line_heights: msg_line_heights,
});
*state.diff_click_regions.borrow_mut() = click_regions;
(lines, user_box_ranges, true)
}
};
let _ = cache_rebuilt;
let msg_line_count = lines.len();
if !state.streaming_buffer.is_empty() {
if !state.messages.is_empty() {
lines.push(Line::from(""));
}
let thinking_spans = state.spinner.thinking_label("Generating", theme);
let mut title_spans = vec![Span::styled(
" ✦ ",
Style::default().fg(theme.assistant).bold(),
)];
title_spans.extend(thinking_spans);
title_spans.push(Span::styled(
format!(
" ({}{})",
format_elapsed(state.elapsed_secs),
retry_suffix(state)
),
Style::default().fg(theme.text_muted),
));
lines.push(Line::from(title_spans));
lines.push(Line::from(""));
const REPARSE_THRESHOLD: usize = 200;
let buf_len = state.streaming_buffer.len();
let cached = {
let c = state.streaming_render_cache.borrow();
c.as_ref()
.and_then(|&(cached_len, cached_w, ref cached_lines)| {
if cached_w == inner_width_u16
&& (cached_len == buf_len
|| buf_len.saturating_sub(cached_len) < REPARSE_THRESHOLD)
{
Some(cached_lines.clone())
} else {
None
}
})
};
if let Some(cached_lines) = cached {
lines.extend(cached_lines);
} else {
let start = lines.len();
render_markdown(&state.streaming_buffer, &mut lines, theme, inner_width);
let new_lines = lines[start..].to_vec();
*state.streaming_render_cache.borrow_mut() =
Some((buf_len, inner_width_u16, new_lines));
}
lines.push(Line::from("")); } else if state.agent_busy {
if !state.messages.is_empty() {
lines.push(Line::from(""));
}
let thinking_spans = state.spinner.thinking_label(&state.status_msg, theme);
let mut title_spans = Vec::from(thinking_spans.as_slice());
title_spans.push(Span::styled(
format!(
" ({}{})",
format_elapsed(state.elapsed_secs),
retry_suffix(state)
),
Style::default().fg(if state.stream_retry > 0 {
theme.warning
} else {
theme.text_muted
}),
));
lines.push(Line::from(title_spans));
if let Some(ref hive) = state.swarm_status {
for entry in &hive.agents {
let (status_color, status_icon) = match &entry.status {
crate::tui::state::SwarmAgentStatus::Pending => (theme.text_muted, "·"),
crate::tui::state::SwarmAgentStatus::Running => (theme.accent, "▶"),
crate::tui::state::SwarmAgentStatus::Paused => (theme.warning, "⏸"),
crate::tui::state::SwarmAgentStatus::Completed { success: true } => {
(theme.success, "✓")
}
crate::tui::state::SwarmAgentStatus::Completed { success: false } => {
(theme.error, "✗")
}
};
let preview = if entry.task_preview.is_empty() {
entry.name.as_str()
} else {
entry.task_preview.as_str()
};
let max_w = inner_width.saturating_sub(8);
let truncated = if preview.len() > max_w {
let boundary = preview
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= max_w.saturating_sub(1))
.last()
.unwrap_or(0);
format!("{}…", &preview[..boundary])
} else {
preview.to_string()
};
lines.push(Line::from(vec![
Span::styled(
format!(" {} ", status_icon),
Style::default().fg(status_color),
),
Span::styled(
format!("[{}] ", entry.agent_id),
Style::default().fg(theme.text_muted),
),
Span::styled(truncated, Style::default().fg(theme.text_dim)),
Span::styled(
if entry.tool_calls > 0 {
format!(" ({}t)", entry.tool_calls)
} else {
String::new()
},
Style::default().fg(theme.text_muted),
),
]));
}
}
lines.push(Line::from("")); } else if let Some(ref hive) = state.swarm_status {
if !state.messages.is_empty() {
lines.push(Line::from(""));
}
lines.push(Line::from(vec![
Span::styled(" ⟳ ", Style::default().fg(theme.accent)),
Span::styled(
format!("Workers running ({})", format_elapsed(state.elapsed_secs)),
Style::default().fg(theme.text_muted),
),
]));
for entry in &hive.agents {
let (status_color, status_icon) = match &entry.status {
crate::tui::state::SwarmAgentStatus::Pending => (theme.text_muted, "·"),
crate::tui::state::SwarmAgentStatus::Running => (theme.accent, "▶"),
crate::tui::state::SwarmAgentStatus::Paused => (theme.warning, "⏸"),
crate::tui::state::SwarmAgentStatus::Completed { success: true } => {
(theme.success, "✓")
}
crate::tui::state::SwarmAgentStatus::Completed { success: false } => {
(theme.error, "✗")
}
};
let preview = if entry.task_preview.is_empty() {
entry.agent_id.as_str()
} else {
entry.task_preview.as_str()
};
let max_w = inner_width.saturating_sub(8);
let truncated = if preview.len() > max_w {
let boundary = preview
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= max_w.saturating_sub(1))
.last()
.unwrap_or(0);
format!("{}…", &preview[..boundary])
} else {
preview.to_string()
};
lines.push(Line::from(vec![
Span::styled(
format!(" {} ", status_icon),
Style::default().fg(status_color),
),
Span::styled(
format!("[{}] ", entry.agent_id),
Style::default().fg(theme.text_muted),
),
Span::styled(truncated, Style::default().fg(theme.text_dim)),
]));
}
lines.push(Line::from(""));
}
const BOTTOM_PAD: u16 = 3;
for _ in 0..BOTTOM_PAD {
lines.push(Line::from(""));
}
let render_width = area.width.saturating_sub(1) as usize;
let (line_heights, scroll) =
compute_scroll_and_heights(state, &lines, render_width, area.height, msg_line_count);
for row in area.y..area.y + area.height {
for col in area.x..area.x + area.width {
buf[(col, row)].set_bg(theme.bg);
}
}
let render_area = Rect {
width: area.width.saturating_sub(1),
..area
};
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((scroll, 0))
.render(render_area, buf);
render_user_message_boxes(
&user_box_line_ranges,
&line_heights,
scroll,
area,
theme,
buf,
);
}
fn tool_one_liner(
name: &str,
args_summary: &str,
content: &crate::api::Content,
max_width: usize,
) -> String {
if max_width == 0 {
return String::new();
}
let content_text = content.text_content();
let text = match name {
"bash" => {
let cmd = extract_json_field(args_summary, "command").unwrap_or_default();
if cmd.is_empty() {
first_meaningful_line(&content_text)
} else {
cmd
}
}
"file_read" => extract_json_field(args_summary, "path")
.map(|p| short_path(&p))
.unwrap_or_else(|| first_meaningful_line(&content_text)),
"file_write" => extract_json_field(args_summary, "path")
.map(|p| format!("→ {}", short_path(&p)))
.unwrap_or_else(|| first_meaningful_line(&content_text)),
"file_edit" => extract_json_field(args_summary, "path")
.map(|p| format!("✎ {}", short_path(&p)))
.unwrap_or_else(|| first_meaningful_line(&content_text)),
"search" => extract_json_field(args_summary, "pattern")
.map(|p| format!("/{p}/"))
.unwrap_or_else(|| first_meaningful_line(&content_text)),
_ => {
let from_args = first_meaningful_line(args_summary);
if !from_args.is_empty() && from_args != "{" && from_args != "}" {
from_args
} else {
first_meaningful_line(&content_text)
}
}
};
if text.is_empty() {
return String::new();
}
truncate_to_width(&text, max_width)
}
enum DiffRow<'a> {
Context(&'a str),
Removed(&'a str),
Added(&'a str),
}
fn build_delta_rows<'a>(old: &'a str, new: &'a str) -> Vec<DiffRow<'a>> {
let old_lines: Vec<&str> = old.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
let mut prefix = 0;
while prefix < old_lines.len()
&& prefix < new_lines.len()
&& old_lines[prefix] == new_lines[prefix]
{
prefix += 1;
}
let mut suffix = 0;
while suffix < old_lines.len() - prefix
&& suffix < new_lines.len() - prefix
&& old_lines[old_lines.len() - 1 - suffix] == new_lines[new_lines.len() - 1 - suffix]
{
suffix += 1;
}
let mut rows: Vec<DiffRow<'a>> = Vec::new();
let has_change = prefix < old_lines.len() - suffix || prefix < new_lines.len() - suffix;
if has_change && prefix > 0 {
let ctx_start = prefix.saturating_sub(2);
for line in &old_lines[ctx_start..prefix] {
rows.push(DiffRow::Context(line));
}
}
for line in &old_lines[prefix..old_lines.len() - suffix] {
rows.push(DiffRow::Removed(line));
}
for line in &new_lines[prefix..new_lines.len() - suffix] {
rows.push(DiffRow::Added(line));
}
if has_change && suffix > 0 {
let ctx_len = suffix.min(2);
let start = old_lines.len() - suffix;
for line in &old_lines[start..start + ctx_len] {
rows.push(DiffRow::Context(line));
}
}
rows
}
fn render_mini_diff(
args_json: &str,
max_width: usize,
theme: &crate::tui::theme::Theme,
lines: &mut Vec<Line<'static>>,
click_regions: &mut Vec<DiffClickRegion>,
) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(args_json) else {
return;
};
let old = v.get("old_string").and_then(|s| s.as_str()).unwrap_or("");
let new = v.get("new_string").and_then(|s| s.as_str()).unwrap_or("");
if old.is_empty() && new.is_empty() {
return;
}
let pad = " "; let content_w = max_width.saturating_sub(pad.len() + 2);
let max_visible = 8;
let rows = build_delta_rows(old, new);
if rows.is_empty() {
return;
}
let added = rows
.iter()
.filter(|r| matches!(r, DiffRow::Added(_)))
.count();
let removed = rows
.iter()
.filter(|r| matches!(r, DiffRow::Removed(_)))
.count();
if added + removed > 0 {
lines.push(Line::from(Span::styled(
format!("{pad}+{added} \u{2212}{removed}"),
Style::default().fg(theme.text_muted),
)));
}
let total = rows.len();
let truncated = total > max_visible;
let shown: Vec<&DiffRow<'_>> = if truncated {
pick_visible_rows(&rows, max_visible)
} else {
rows.iter().collect()
};
for row in &shown {
let (symbol, text, fg, bg): (char, &str, Color, Option<Color>) = match row {
DiffRow::Context(t) => (' ', t, theme.text_muted, None),
DiffRow::Removed(t) => (
'\u{2212}',
t,
theme.diff_remove_fg,
Some(theme.diff_remove_bg),
),
DiffRow::Added(t) => ('+', t, theme.diff_add_fg, Some(theme.diff_add_bg)),
};
let display = truncate_to_width(text.trim_end(), content_w);
let text_w = UnicodeWidthStr::width(display.as_str());
let fill = content_w.saturating_sub(text_w + 2); let line_str = format!("{pad}{symbol} {display}{:fill$}", "", fill = fill);
let style = if let Some(bg_color) = bg {
Style::default().fg(fg).bg(bg_color)
} else {
Style::default().fg(fg)
};
lines.push(Line::from(Span::styled(line_str, style)));
}
if truncated {
let remaining = total - shown.len();
let click_line_idx = lines.len();
lines.push(Line::from(Span::styled(
format!("{pad} ... {remaining} more lines (click to expand)"),
Style::default().fg(theme.text_muted).italic(),
)));
let path = v.get("path").and_then(|s| s.as_str()).unwrap_or("file");
let mut full_diff = String::new();
for row in &rows {
match row {
DiffRow::Context(t) => full_diff.push_str(&format!(" {t}\n")),
DiffRow::Removed(t) => full_diff.push_str(&format!("\u{2212} {t}\n")),
DiffRow::Added(t) => full_diff.push_str(&format!("+ {t}\n")),
}
}
click_regions.push(DiffClickRegion {
line_index: click_line_idx,
title: format!("file_edit \u{270E} {}", short_path(path)),
content: full_diff,
});
}
}
fn pick_visible_rows<'a>(rows: &'a [DiffRow<'a>], budget: usize) -> Vec<&'a DiffRow<'a>> {
if rows.len() <= budget {
return rows.iter().collect();
}
let ctx: Vec<usize> = rows
.iter()
.enumerate()
.filter(|(_, r)| matches!(r, DiffRow::Context(_)))
.map(|(i, _)| i)
.collect();
let rem: Vec<usize> = rows
.iter()
.enumerate()
.filter(|(_, r)| matches!(r, DiffRow::Removed(_)))
.map(|(i, _)| i)
.collect();
let add: Vec<usize> = rows
.iter()
.enumerate()
.filter(|(_, r)| matches!(r, DiffRow::Added(_)))
.map(|(i, _)| i)
.collect();
let mut keep_rem = rem.len();
let mut keep_add = add.len();
let min_rem = if rem.is_empty() { 0 } else { 1 };
let min_add = if add.is_empty() { 0 } else { 1 };
while keep_rem + keep_add > budget {
if keep_rem > keep_add && keep_rem > min_rem {
keep_rem -= 1;
} else if keep_add > min_add {
keep_add -= 1;
} else if keep_rem > min_rem {
keep_rem -= 1;
} else {
break;
}
}
let remaining_budget = budget.saturating_sub(keep_rem + keep_add);
let ctx_keep = ctx.len().min(remaining_budget);
let mut keep: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
keep.extend(rem.iter().take(keep_rem));
keep.extend(add.iter().take(keep_add));
keep.extend(ctx.iter().take(ctx_keep));
keep.into_iter().map(|i| &rows[i]).collect()
}
fn render_write_preview(
args_json: &str,
max_width: usize,
theme: &crate::tui::theme::Theme,
lines: &mut Vec<Line<'static>>,
click_regions: &mut Vec<DiffClickRegion>,
) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(args_json) else {
return;
};
let content = v.get("content").and_then(|s| s.as_str()).unwrap_or("");
if content.is_empty() {
return;
}
let total_lines = content.lines().count();
let pad = " ";
let content_w = max_width.saturating_sub(pad.len() + 2);
let max_show = 6;
for (i, line) in content.lines().enumerate() {
if i >= max_show {
break;
}
let display = truncate_to_width(line.trim_end(), content_w);
let text_w = UnicodeWidthStr::width(display.as_str());
let fill = content_w.saturating_sub(text_w + 2);
let line_str = format!("{pad}+ {display}{:fill$}", "", fill = fill);
lines.push(Line::from(Span::styled(
line_str,
Style::default().fg(theme.diff_add_fg).bg(theme.diff_add_bg),
)));
}
if total_lines > max_show {
let remaining = total_lines - max_show;
let click_line_idx = lines.len();
lines.push(Line::from(Span::styled(
format!("{pad} ... {remaining} more lines (click to expand)"),
Style::default().fg(theme.text_muted).italic(),
)));
let path = v.get("path").and_then(|s| s.as_str()).unwrap_or("file");
let mut full_content = String::new();
full_content.push_str("--- /dev/null\n");
full_content.push_str(&format!("+++ b/{path}\n"));
full_content.push_str(&format!("@@ -0,0 +1,{total_lines} @@\n"));
for line in content.lines() {
full_content.push('+');
full_content.push_str(line);
full_content.push('\n');
}
click_regions.push(DiffClickRegion {
line_index: click_line_idx,
title: format!("file_write \u{2192} {}", short_path(path)),
content: full_content,
});
}
}
fn first_meaningful_line(text: &str) -> String {
if text.trim_start().starts_with('{')
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(text)
{
if let Some(stdout) = v.get("stdout").and_then(|s| s.as_str()) {
let line = stdout
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.trim();
if !line.is_empty() {
return line.to_string();
}
}
if let Some(stderr) = v.get("stderr").and_then(|s| s.as_str()) {
let line = stderr
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.trim();
if !line.is_empty() {
return line.to_string();
}
}
for key in &["result", "output", "message", "content", "text"] {
if let Some(val) = v.get(key).and_then(|s| s.as_str()) {
let line = val
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.trim();
if !line.is_empty() {
return line.to_string();
}
}
}
}
text.lines()
.map(|l| l.trim())
.find(|l| {
!l.is_empty()
&& *l != "{"
&& *l != "}"
&& *l != "["
&& *l != "]"
&& *l != "{}"
&& !l.starts_with('"') })
.unwrap_or("")
.to_string()
}
fn extract_json_field(json_str: &str, field: &str) -> Option<String> {
serde_json::from_str::<serde_json::Value>(json_str)
.ok()
.and_then(|v| v.get(field).and_then(|f| f.as_str()).map(|s| s.to_string()))
}
fn short_path(path: &str) -> String {
let parts: Vec<&str> = path.rsplit('/').take(2).collect();
if parts.len() == 2 {
format!("{}/{}", parts[1], parts[0])
} else {
parts.first().unwrap_or(&path).to_string()
}
}
fn truncate_to_width(text: &str, max_width: usize) -> String {
if text.len() <= max_width {
text.to_string()
} else {
let boundary = text
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i < max_width.saturating_sub(1))
.last()
.unwrap_or(0);
format!("{}…", &text[..boundary])
}
}
fn render_role_header(name: &str, color: Color, theme: &Theme, lines: &mut Vec<Line>) {
let badge = format!(" ✦ {name}");
let sep = format!(" {}", "─".repeat(40));
lines.push(Line::from(vec![
Span::styled(badge, Style::default().fg(color).bold()),
Span::styled(sep, Style::default().fg(theme.border).dim()),
]));
}
fn render_markdown(text: &str, lines: &mut Vec<Line>, theme: &Theme, width: usize) {
let mut in_code_block = false;
const BASE_INDENT: usize = 4; const BULLET_INDENT: usize = 6;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("```") {
in_code_block = !in_code_block;
let label = if in_code_block {
let lang = trimmed.strip_prefix("```").unwrap_or("").trim();
if lang.is_empty() {
" ╭── code ──".to_string()
} else {
format!(" ╭── {lang} ──")
}
} else {
" ╰──────────".to_string()
};
lines.push(Line::from(Span::styled(
label,
Style::default().fg(theme.md_code).dim(),
)));
continue;
}
if in_code_block {
lines.push(Line::from(Span::styled(
format!(" │ {line}"),
Style::default().fg(theme.md_code),
)));
continue;
}
if let Some(rest) = trimmed
.strip_prefix("### ")
.or_else(|| trimmed.strip_prefix("## "))
.or_else(|| trimmed.strip_prefix("# "))
{
let content_w = width.saturating_sub(BASE_INDENT);
for wl in wrap_to_width(rest, content_w) {
lines.push(Line::from(Span::styled(
format!(" {wl}"),
Style::default().fg(theme.md_header).bold(),
)));
}
}
else if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
let content = &trimmed[2..];
let content_w = width.saturating_sub(BULLET_INDENT);
let wrapped = wrap_to_width(content, content_w);
let continuation_pad = " ".repeat(BULLET_INDENT);
for (i, wl) in wrapped.iter().enumerate() {
if i == 0 {
let mut spans =
vec![Span::styled(" • ", Style::default().fg(theme.md_bullet))];
spans.extend(parse_inline_markdown(wl, theme));
lines.push(Line::from(spans));
} else {
let mut spans = vec![Span::raw(continuation_pad.clone())];
spans.extend(parse_inline_markdown(wl, theme));
lines.push(Line::from(spans));
}
}
}
else if trimmed.len() > 2
&& trimmed.as_bytes()[0].is_ascii_digit()
&& trimmed.contains(". ")
{
if let Some(dot_pos) = trimmed.find(". ") {
let num = &trimmed[..dot_pos];
if num.chars().all(|c| c.is_ascii_digit()) {
let content = &trimmed[dot_pos + 2..];
let num_prefix = format!(" {num}. ");
let prefix_w = UnicodeWidthStr::width(num_prefix.as_str());
let content_w = width.saturating_sub(prefix_w);
let wrapped = wrap_to_width(content, content_w);
let continuation_pad = " ".repeat(prefix_w);
for (i, wl) in wrapped.iter().enumerate() {
if i == 0 {
let mut spans = vec![Span::styled(
num_prefix.clone(),
Style::default().fg(theme.md_bullet),
)];
spans.extend(parse_inline_markdown(wl, theme));
lines.push(Line::from(spans));
} else {
let mut spans = vec![Span::raw(continuation_pad.clone())];
spans.extend(parse_inline_markdown(wl, theme));
lines.push(Line::from(spans));
}
}
} else {
let content_w = width.saturating_sub(BASE_INDENT);
for wl in wrap_to_width(trimmed, content_w) {
let mut spans = vec![Span::raw(" ")];
spans.extend(parse_inline_markdown(&wl, theme));
lines.push(Line::from(spans));
}
}
}
}
else {
let content_w = width.saturating_sub(BASE_INDENT);
for wl in wrap_to_width(trimmed, content_w) {
let mut spans = vec![Span::raw(" ")];
spans.extend(parse_inline_markdown(&wl, theme));
lines.push(Line::from(spans));
}
}
}
}
fn parse_inline_markdown(text: &str, theme: &Theme) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
let bold_pos = remaining.find("**");
let code_pos = remaining.find('`');
let next = match (bold_pos, code_pos) {
(Some(b), Some(c)) => {
if b <= c {
("**", b)
} else {
("`", c)
}
}
(Some(b), None) => ("**", b),
(None, Some(c)) => ("`", c),
(None, None) => {
spans.push(Span::styled(
remaining.to_string(),
Style::default().fg(theme.text),
));
break;
}
};
let (marker, pos) = next;
if pos > 0 {
spans.push(Span::styled(
remaining[..pos].to_string(),
Style::default().fg(theme.text),
));
}
let after = &remaining[pos + marker.len()..];
if let Some(end) = after.find(marker) {
let inner = &after[..end];
let style = if marker == "**" {
Style::default().fg(theme.text).bold()
} else {
Style::default().fg(theme.md_code).bg(theme.md_code_bg)
};
spans.push(Span::styled(inner.to_string(), style));
remaining = &after[end + marker.len()..];
} else {
spans.push(Span::styled(
remaining[pos..pos + marker.len()].to_string(),
Style::default().fg(theme.text),
));
remaining = after;
}
}
spans
}
fn format_elapsed(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
}
fn retry_suffix(state: &crate::tui::state::UiState) -> String {
if state.stream_retry > 0 {
format!(
" · retry {}/{}",
state.stream_retry, state.stream_max_retries
)
} else {
String::new()
}
}
fn render_worker_view(
state: &UiState,
area: Rect,
buf: &mut Buffer,
agent_id: &str,
detail: &crate::tui::state::WorkerDetailState,
) {
let theme = &state.theme;
let inner_width = area.width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
let agent_info = state
.swarm_status
.as_ref()
.and_then(|h| h.agents.iter().find(|a| a.agent_id == agent_id));
let (agent_name, status_str, status_color) = if let Some(entry) = agent_info {
let (color, label) = match &entry.status {
crate::tui::state::SwarmAgentStatus::Pending => (theme.text_muted, "PENDING"),
crate::tui::state::SwarmAgentStatus::Running => (theme.accent, "RUNNING"),
crate::tui::state::SwarmAgentStatus::Paused => (theme.warning, "PAUSED"),
crate::tui::state::SwarmAgentStatus::Completed { success: true } => {
(theme.success, "COMPLETED")
}
crate::tui::state::SwarmAgentStatus::Completed { success: false } => {
(theme.error, "FAILED")
}
};
(entry.name.as_str(), label, color)
} else {
("unknown", "UNKNOWN", theme.text_muted)
};
lines.push(Line::from(vec![
Span::styled(" ◉ ", Style::default().fg(status_color).bold()),
Span::styled(
format!("[{agent_id}] {agent_name}"),
Style::default().fg(theme.accent).bold(),
),
Span::styled(
format!(" {status_str}"),
Style::default().fg(status_color).bold(),
),
Span::styled(" (Esc to detach)", Style::default().fg(theme.text_muted)),
]));
if let Some(entry) = agent_info {
if !entry.task_preview.is_empty() {
lines.push(Line::from(vec![
Span::styled(" task: ", Style::default().fg(theme.text_muted)),
Span::styled(
entry.task_preview.as_str().to_string(),
Style::default().fg(theme.text_dim),
),
]));
}
let stats = format!(
" iter:{} tools:{} tokens:{}↑/{}↓",
entry.iteration, entry.tool_calls, entry.input_tokens, entry.output_tokens
);
lines.push(Line::from(Span::styled(
stats,
Style::default().fg(theme.text_muted),
)));
}
let sep: String = "─".repeat(inner_width.min(80));
lines.push(Line::from(Span::styled(
format!(" {sep}"),
Style::default().fg(theme.border),
)));
lines.push(Line::from(""));
if !detail.tool_events.is_empty() {
let recent_start = detail.tool_events.len().saturating_sub(10);
for evt in &detail.tool_events[recent_start..] {
let (icon, color) = if evt.result_preview == "running..." {
("⟳", theme.accent)
} else if evt.success {
("✓", theme.success)
} else {
("✗", theme.error)
};
let args_short = if evt.args_preview.len() > 60 {
let boundary = evt
.args_preview
.char_indices()
.take_while(|&(i, _)| i <= 60)
.last()
.map(|(i, _)| i)
.unwrap_or(0);
format!("{}…", &evt.args_preview[..boundary])
} else {
evt.args_preview.clone()
};
lines.push(Line::from(vec![
Span::styled(format!(" {icon} "), Style::default().fg(color)),
Span::styled(evt.name.clone(), Style::default().fg(theme.accent).bold()),
Span::styled(
format!(" {args_short}"),
Style::default().fg(theme.text_dim),
),
]));
if evt.result_preview != "running..." {
let result_short = if evt.result_preview.len() > 80 {
let boundary = evt
.result_preview
.char_indices()
.take_while(|&(i, _)| i <= 80)
.last()
.map(|(i, _)| i)
.unwrap_or(0);
format!("{}…", &evt.result_preview[..boundary])
} else {
evt.result_preview.clone()
};
lines.push(Line::from(Span::styled(
format!(" → {result_short}"),
Style::default().fg(theme.text_muted),
)));
}
}
lines.push(Line::from(""));
}
if !detail.full_stream.is_empty() {
render_markdown(&detail.full_stream, &mut lines, theme, inner_width);
} else {
lines.push(Line::from(Span::styled(
" Waiting for output...",
Style::default().fg(theme.text_muted).italic(),
)));
}
if let Some(entry) = agent_info
&& let Some(ref tool) = entry.current_tool
{
lines.push(Line::from(""));
let thinking_spans = state
.spinner
.thinking_label(&format!("Running {tool}"), theme);
lines.push(Line::from(thinking_spans));
}
for _ in 0..3 {
lines.push(Line::from(""));
}
let render_width = area.width.saturating_sub(1) as usize;
let line_heights: Vec<u16> = lines
.iter()
.map(|l| line_visual_height(l, render_width))
.collect();
let visual_height: u16 = line_heights.iter().sum::<u16>().saturating_add(2);
let view_height = area.height;
let max_scroll = visual_height.saturating_sub(view_height);
let scroll = if detail.auto_scroll {
max_scroll
} else {
max_scroll.saturating_sub(detail.scroll_offset)
};
for row in area.y..area.y + area.height {
for col in area.x..area.x + area.width {
buf[(col, row)].set_bg(theme.bg);
}
}
let render_area = Rect {
width: area.width.saturating_sub(1),
..area
};
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((scroll, 0))
.render(render_area, buf);
}