use ratatui::prelude::*;
use ratatui::widgets::Paragraph;
use crate::tui::state::{SwarmAgentStatus, UiState};
use crate::tui::theme::Theme;
pub const SIDEBAR_MIN_WIDTH: u16 = 120;
pub const SIDEBAR_WIDTH: u16 = 36;
pub fn render(state: &UiState, area: Rect, buf: &mut Buffer, working_dir: &str) {
let theme = &state.theme;
ratatui::widgets::Block::default()
.style(Style::default().bg(theme.bg_surface))
.render(area, buf);
let pad = 1u16;
let content_x = area.x + pad;
let content_w = area.width.saturating_sub(pad * 2);
if content_w == 0 {
return;
}
const FOOTER_HEIGHT: u16 = 3;
let debug_height: u16 = if state.debug_mode {
let base = 9u16;
let has_perf = state.debug_monitor.tool_latency_avg_ms > 0.0
|| state.debug_monitor.api_latency_avg_ms > 0.0;
if has_perf {
base + 1 + 6 + state.debug_monitor.top_tools.len().min(3) as u16
} else {
base
}
} else {
0
};
let bottom = area.y
+ area
.height
.saturating_sub(pad + FOOTER_HEIGHT + debug_height);
let mut y = area.y + pad;
y = render_section_header("Context", y, content_x, content_w, buf, theme);
let ctx_used = state.context_used_tokens;
let ctx_max = state.context_max_tokens;
let pct = if ctx_max > 0 {
((ctx_used as f64 / ctx_max as f64) * 100.0).min(100.0) as u32
} else {
0
};
let token_line = if ctx_used > 0 {
format!(" {} tokens", ctx_used)
} else {
" 0 tokens".to_string()
};
y = render_dim_line(&token_line, y, content_x, content_w, buf, theme);
y = render_dim_line(
&format!(" {pct}% used"),
y,
content_x,
content_w,
buf,
theme,
);
y = render_blank(y, content_x, content_w, buf, theme);
if let Some(ref hive) = state.swarm_status {
y = render_section_header(&hive.mode_label, y, content_x, content_w, buf, theme);
y = render_dim_line(
&format!(" Phase: {:?}", hive.phase),
y,
content_x,
content_w,
buf,
theme,
);
state.sidebar_swarm_agents_start_row.set(y);
for entry in &hive.agents {
if y >= bottom {
break;
}
let (dot_color, status_label) = match &entry.status {
SwarmAgentStatus::Pending => (theme.text_muted, "..."),
SwarmAgentStatus::Running => (theme.accent, "run"),
SwarmAgentStatus::Paused => (theme.warning, "PSE"),
SwarmAgentStatus::Completed { success: true } => (theme.success, "OK"),
SwarmAgentStatus::Completed { success: false } => (theme.error, "ERR"),
};
let is_attached = matches!(
&state.view_mode,
crate::tui::state::ViewMode::WorkerAttached { agent_id } if *agent_id == entry.agent_id
);
let dot_area = Rect::new(content_x, y, 2, 1);
let dot_icon = if is_attached { " ◉" } else { " •" };
Paragraph::new(Span::styled(dot_icon, Style::default().fg(dot_color)))
.render(dot_area, buf);
let name_x = content_x + 2;
let name_trunc = truncate(&entry.agent_id, 4); let clickable = matches!(&entry.status, SwarmAgentStatus::Completed { .. })
&& !entry.output.is_empty();
let name_style = if is_attached {
Style::default().fg(theme.accent).bold()
} else {
Style::default().fg(theme.text_muted)
};
let label_area = Rect::new(name_x, y, content_w.saturating_sub(2), 1);
Paragraph::new(vec![Line::from(vec![
Span::styled(format!(" [{name_trunc}]"), name_style),
Span::styled(
format!(" {status_label}"),
Style::default().fg(dot_color).add_modifier(Modifier::DIM),
),
Span::styled(
if clickable {
" ▸"
} else if is_attached {
" ◀"
} else {
""
},
Style::default()
.fg(if is_attached {
theme.accent
} else {
theme.text_muted
})
.add_modifier(Modifier::DIM),
),
])])
.render(label_area, buf);
y += 1;
if !entry.task_preview.is_empty() && y < bottom {
let preview_trunc =
truncate(&entry.task_preview, (content_w as usize).saturating_sub(4));
y = render_dim_line(
&format!(" ↳ {preview_trunc}"),
y,
content_x,
content_w,
buf,
theme,
);
}
if state.debug_mode && (entry.input_tokens > 0 || entry.output_tokens > 0) && y < bottom
{
let tok_line = format!(
" in:{} out:{} tools:{}",
format_number(entry.input_tokens),
format_number(entry.output_tokens),
entry.tool_calls,
);
y = render_dim_line(&tok_line, y, content_x, content_w, buf, theme);
}
}
if !hive.pending_conflicts.is_empty() {
y = render_dim_line(
&format!(" {} conflicts", hive.pending_conflicts.len()),
y,
content_x,
content_w,
buf,
theme,
);
}
y = render_blank(y, content_x, content_w, buf, theme);
}
if y >= bottom {
} else if state.debug_mode && state.debug_monitor.mcp_memory_bytes > 0 {
y = render_section_header_with_suffix(
"MCP",
&format_bytes(state.debug_monitor.mcp_memory_bytes),
y,
content_x,
content_w,
buf,
theme,
);
} else {
y = render_section_header("MCP", y, content_x, content_w, buf, theme);
}
if state.mcp_servers.is_empty() {
y = render_dim_line(" No MCP configured", y, content_x, content_w, buf, theme);
} else {
for entry in &state.mcp_servers {
if y >= bottom {
break;
}
let is_active = state
.active_mcp_server
.as_deref()
.is_some_and(|s| s == entry.name);
let (dot_color, name_color, status_label) = if is_active {
(theme.accent, theme.accent, "●")
} else {
match entry.status {
crate::mcp::config::McpStatus::Available => {
(theme.success, theme.text_muted, "")
}
crate::mcp::config::McpStatus::Unavailable => {
(theme.error, theme.text_muted, "n/a")
}
}
};
let dot_area = Rect::new(content_x, y, 2, 1);
Paragraph::new(Span::styled(" •", Style::default().fg(dot_color)))
.render(dot_area, buf);
let name_x = content_x + 2;
let avail_w = content_w.saturating_sub(2) as usize;
let name_trunc = if status_label.is_empty() {
truncate(&entry.name, avail_w)
} else {
truncate(&entry.name, avail_w.saturating_sub(status_label.len() + 2))
};
let label_area = Rect::new(name_x, y, content_w.saturating_sub(2), 1);
let mut spans = vec![Span::styled(
format!(" {name_trunc}"),
Style::default().fg(name_color).add_modifier(Modifier::DIM),
)];
if !status_label.is_empty() {
spans.push(Span::styled(
format!(" {status_label}"),
Style::default().fg(theme.error).add_modifier(Modifier::DIM),
));
}
Paragraph::new(vec![Line::from(spans)]).render(label_area, buf);
y += 1;
}
}
y = render_blank(y, content_x, content_w, buf, theme);
if y >= bottom {
} else if state.debug_mode && state.debug_monitor.lsp_memory_bytes > 0 {
y = render_section_header_with_suffix(
"LSP",
&format_bytes(state.debug_monitor.lsp_memory_bytes),
y,
content_x,
content_w,
buf,
theme,
);
} else {
y = render_section_header("LSP", y, content_x, content_w, buf, theme);
}
if state.installed_lsp.is_empty() {
y = render_dim_line(
" No LSP servers detected",
y,
content_x,
content_w,
buf,
theme,
);
} else {
for lsp in &state.installed_lsp {
if y >= bottom {
break;
}
let is_running = state.running_lsp.contains(lsp);
let dot_color = if is_running {
theme.success
} else {
theme.text_muted
};
let text_color = if is_running {
theme.text
} else {
theme.text_muted
};
let dot_area = Rect::new(content_x, y, 2, 1);
Paragraph::new(Span::styled(" •", Style::default().fg(dot_color)))
.render(dot_area, buf);
let name_area = Rect::new(content_x + 2, y, content_w.saturating_sub(2), 1);
Paragraph::new(Span::styled(
format!(" {lsp}"),
Style::default().fg(text_color),
))
.render(name_area, buf);
y += 1;
}
}
y = render_blank(y, content_x, content_w, buf, theme);
if y < bottom {
y = render_section_header("Changed Files", y, content_x, content_w, buf, theme);
state.sidebar_files_start_row.set(y);
*state.last_sidebar_area.borrow_mut() = area;
if state.changed_files.is_empty() {
render_dim_line(" No changes yet", y, content_x, content_w, buf, theme);
} else {
let all_lines: Vec<Line> = state
.changed_files
.iter()
.map(|entry| {
let filename = std::path::Path::new(&entry.path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&entry.path);
let file_trunc = truncate(filename, (content_w as usize).saturating_sub(12));
let adds_text = format!("+{}", entry.additions);
let dels_text = format!("-{}", entry.deletions);
Line::from(vec![
Span::styled(format!(" {file_trunc}"), Style::default().fg(theme.text)),
Span::styled(" ".to_string(), Style::default()),
Span::styled(adds_text, Style::default().fg(theme.success)),
Span::styled(" ", Style::default()),
Span::styled(dels_text, Style::default().fg(theme.error)),
])
})
.collect();
let available_h = bottom.saturating_sub(y);
let scroll = state
.sidebar_scroll
.min((all_lines.len() as u16).saturating_sub(available_h));
let files_area = Rect::new(content_x, y, content_w, available_h);
Paragraph::new(all_lines)
.scroll((scroll, 0))
.render(files_area, buf);
}
}
if state.debug_mode {
let mut dy = bottom;
let dbg = &state.debug_monitor;
let tgt = &state.debug_targets;
dy = render_blank(dy, content_x, content_w, buf, theme);
dy = render_section_header("Debug", dy, content_x, content_w, buf, theme);
let mem_str = if dbg.memory_bytes >= 1_073_741_824 {
format!(" mem {:.1} GB", dbg.memory_bytes as f64 / 1_073_741_824.0)
} else {
format!(" mem {:.1} MB", dbg.memory_bytes as f64 / 1_048_576.0)
};
dy = render_dim_line(&mem_str, dy, content_x, content_w, buf, theme);
let cpu_color = if dbg.cpu_percent > 80.0 {
theme.error
} else if dbg.cpu_percent > 50.0 {
theme.warning
} else {
theme.text_muted
};
dy = render_colored_line(
&format!(" cpu {:.1}%", dbg.cpu_percent),
dy,
content_x,
content_w,
buf,
cpu_color,
);
dy = render_dim_line(
&format!(" in {} tok", format_number(dbg.input_tokens)),
dy,
content_x,
content_w,
buf,
theme,
);
dy = render_dim_line(
&format!(" out {} tok", format_number(dbg.output_tokens)),
dy,
content_x,
content_w,
buf,
theme,
);
dy = render_dim_line(
&format!(" tool {} calls", dbg.total_tool_calls),
dy,
content_x,
content_w,
buf,
theme,
);
dy = render_dim_line(
&format!(" api {} calls", dbg.total_api_calls),
dy,
content_x,
content_w,
buf,
theme,
);
let agent_color = if dbg.active_agents > 1 {
theme.accent
} else {
theme.text_muted
};
dy = render_colored_line(
&format!(" agents {}", dbg.active_agents),
dy,
content_x,
content_w,
buf,
agent_color,
);
let has_perf = dbg.tool_latency_avg_ms > 0.0 || dbg.api_latency_avg_ms > 0.0;
if has_perf {
dy = render_blank(dy, content_x, content_w, buf, theme);
let tl_color = if dbg.tool_latency_avg_ms > tgt.tool_latency_avg_ms as f64 {
theme.warning
} else {
theme.text_muted
};
dy = render_colored_line(
&format!(
" t.avg {} t.max {}",
format_duration_ms(dbg.tool_latency_avg_ms),
format_duration_ms(dbg.tool_latency_max_ms as f64)
),
dy,
content_x,
content_w,
buf,
tl_color,
);
let al_color = if dbg.api_latency_avg_ms > tgt.api_latency_avg_ms as f64 {
theme.warning
} else {
theme.text_muted
};
dy = render_colored_line(
&format!(
" a.avg {} a.max {}",
format_duration_ms(dbg.api_latency_avg_ms),
format_duration_ms(dbg.api_latency_max_ms as f64)
),
dy,
content_x,
content_w,
buf,
al_color,
);
let sr_color = if dbg.tool_success_rate < tgt.tool_success_rate {
theme.warning
} else {
theme.text_muted
};
dy = render_colored_line(
&format!(" ok {:.0}%", dbg.tool_success_rate),
dy,
content_x,
content_w,
buf,
sr_color,
);
let tpi_color = if dbg.tokens_per_iteration > tgt.tokens_per_iteration as f64 {
theme.warning
} else {
theme.text_muted
};
dy = render_colored_line(
&format!(" tok/i {}", format_number(dbg.tokens_per_iteration as u64)),
dy,
content_x,
content_w,
buf,
tpi_color,
);
let ti_color = if dbg.tools_per_iteration > tgt.tools_per_iteration as f64 {
theme.warning
} else {
theme.text_muted
};
dy = render_colored_line(
&format!(" t/i {:.1}", dbg.tools_per_iteration),
dy,
content_x,
content_w,
buf,
ti_color,
);
for (name, count) in dbg.top_tools.iter().take(3) {
let name_trunc = truncate(name, (content_w as usize).saturating_sub(8));
dy = render_dim_line(
&format!(" {name_trunc} {count}"),
dy,
content_x,
content_w,
buf,
theme,
);
}
}
}
let footer_top = area.y + area.height.saturating_sub(FOOTER_HEIGHT);
let short_dir = {
let path = std::path::Path::new(working_dir);
let parts: Vec<&str> = path
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
match parts.len() {
0 => working_dir.to_string(),
1 => parts[0].to_string(),
n => format!("{}/{}", parts[n - 2], parts[n - 1]),
}
};
let proj_area = Rect::new(content_x, footer_top + 1, content_w, 1); Paragraph::new(Span::styled(
truncate(&format!(" {short_dir}"), content_w as usize),
Style::default().fg(theme.text_muted),
))
.render(proj_area, buf);
let ver_area = Rect::new(content_x, footer_top + 2, content_w, 1);
Paragraph::new(Span::styled(
format!(" collet v{}", env!("CARGO_PKG_VERSION")),
Style::default()
.fg(theme.border)
.add_modifier(Modifier::DIM),
))
.render(ver_area, buf);
}
fn render_section_header(
label: &str,
y: u16,
x: u16,
w: u16,
buf: &mut Buffer,
theme: &Theme,
) -> u16 {
let area = Rect::new(x, y, w, 1);
Paragraph::new(Span::styled(
format!(" {label}"),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
))
.render(area, buf);
y + 1
}
fn render_section_header_with_suffix(
label: &str,
suffix: &str,
y: u16,
x: u16,
w: u16,
buf: &mut Buffer,
theme: &Theme,
) -> u16 {
let area = Rect::new(x, y, w, 1);
Paragraph::new(Line::from(vec![
Span::styled(
format!(" {label}"),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {suffix}"),
Style::default()
.fg(theme.text_muted)
.add_modifier(Modifier::DIM),
),
]))
.render(area, buf);
y + 1
}
fn render_dim_line(text: &str, y: u16, x: u16, w: u16, buf: &mut Buffer, theme: &Theme) -> u16 {
let area = Rect::new(x, y, w, 1);
Paragraph::new(Span::styled(
truncate(text, w as usize),
Style::default().fg(theme.text_muted),
))
.render(area, buf);
y + 1
}
fn render_colored_line(text: &str, y: u16, x: u16, w: u16, buf: &mut Buffer, color: Color) -> u16 {
let area = Rect::new(x, y, w, 1);
Paragraph::new(Span::styled(
truncate(text, w as usize),
Style::default().fg(color),
))
.render(area, buf);
y + 1
}
fn format_duration_ms(ms: f64) -> String {
if ms >= 1000.0 {
format!("{:.1}s", ms / 1000.0)
} else {
format!("{:.0}ms", ms)
}
}
fn render_blank(y: u16, _x: u16, _w: u16, _buf: &mut Buffer, _theme: &Theme) -> u16 {
y + 1
}
fn format_bytes(bytes: u64) -> String {
if bytes >= 1_073_741_824 {
format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
} else {
format!("{:.1} MB", bytes as f64 / 1_048_576.0)
}
}
fn format_number(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}K", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
fn truncate(s: &str, max: usize) -> String {
use unicode_width::UnicodeWidthChar;
if max == 0 {
return String::new();
}
let mut width = 0usize;
let mut end = s.len();
let mut cut = false;
for (i, c) in s.char_indices() {
let cw = c.width().unwrap_or(1);
if width + cw > max.saturating_sub(1) {
end = i;
cut = true;
break;
}
width += cw;
}
if cut {
format!("{}…", &s[..end])
} else {
s.to_string()
}
}