use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color as RColor, Style};
use ratatui::widgets::Widget;
use crate::ui::renderer::{LeftPanelInfo, PanelData, SubagentStatusRow};
use super::chat::crossterm_to_ratatui;
#[derive(Clone)]
pub struct SubPanel<'a> {
title: &'a str,
lines: Vec<(String, RColor)>,
border_style: Style,
}
impl<'a> SubPanel<'a> {
pub fn new(title: &'a str) -> Self {
Self {
title,
lines: Vec::new(),
border_style: Style::default().fg(RColor::Green),
}
}
pub fn line(mut self, text: impl Into<String>, color: RColor) -> Self {
self.lines.push((text.into(), color));
self
}
#[allow(dead_code)]
pub fn border_style(mut self, style: Style) -> Self {
self.border_style = style;
self
}
pub fn height(&self) -> u16 {
2 + self.lines.len() as u16
}
}
impl<'a> Widget for SubPanel<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width < 4 || area.height < 2 {
return;
}
let bs = self.border_style;
let inner_w = area.width as usize - 2;
let label = format!("[{}]", self.title);
let lw = label.chars().count();
let (lpad, rpad) = if lw >= inner_w {
(0, 0)
} else {
let pad = inner_w - lw;
(pad / 2, pad - pad / 2)
};
buf[(area.x, area.y)].set_char('╭').set_style(bs);
for i in 0..lpad as u16 {
buf[(area.x + 1 + i, area.y)].set_char('─').set_style(bs);
}
if lw <= inner_w {
for (i, ch) in label.chars().enumerate() {
buf[(area.x + 1 + lpad as u16 + i as u16, area.y)]
.set_char(ch)
.set_style(bs);
}
let after = 1 + lpad + lw;
for i in 0..rpad {
buf[(area.x + (after + i) as u16, area.y)]
.set_char('─')
.set_style(bs);
}
} else {
for i in 0..inner_w as u16 {
buf[(area.x + 1 + i, area.y)].set_char('─').set_style(bs);
}
}
buf[(area.x + area.width - 1, area.y)]
.set_char('╮')
.set_style(bs);
let body_rows = area.height.saturating_sub(2);
for (i, slot) in (0..body_rows).enumerate() {
let y = area.y + 1 + slot;
buf[(area.x, y)].set_char('│').set_style(bs);
buf[(area.x + area.width - 1, y)]
.set_char('│')
.set_style(bs);
if let Some((text, color)) = self.lines.get(i) {
let text_style = Style::default().fg(*color);
buf.set_stringn(area.x + 1, y, format!(" {}", text), inner_w, text_style);
}
}
let by = area.y + area.height - 1;
buf[(area.x, by)].set_char('╰').set_style(bs);
for i in 0..inner_w as u16 {
buf[(area.x + 1 + i, by)].set_char('─').set_style(bs);
}
buf[(area.x + area.width - 1, by)]
.set_char('╯')
.set_style(bs);
}
}
pub struct LeftPanel<'a> {
info: &'a LeftPanelInfo,
subagents: &'a [SubagentStatusRow],
style: Style,
}
impl<'a> LeftPanel<'a> {
pub fn new(info: &'a LeftPanelInfo, subagents: &'a [SubagentStatusRow]) -> Self {
Self {
info,
subagents,
style: Style::default().fg(RColor::Green),
}
}
pub fn border_style(mut self, style: Style) -> Self {
self.style = style;
self
}
}
impl<'a> Widget for LeftPanel<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
if self.subagents.is_empty() {
paint_idle_card(buf, area, self.info, self.style);
return;
}
let natural_sub: u16 = LEFT_PANEL_TOP_PAD
+ self
.subagents
.iter()
.map(|r| 2 + r.files.len() as u16)
.sum::<u16>();
let sub_h = natural_sub.min(area.height / 2);
if sub_h == 0 {
paint_idle_card(buf, area, self.info, self.style);
return;
}
let vitals_h = area.height - sub_h;
paint_idle_card(
buf,
Rect::new(area.x, area.y, area.width, vitals_h),
self.info,
self.style,
);
let sub_area = Rect::new(area.x, area.y + vitals_h, area.width, sub_h);
buf.set_stringn(
sub_area.x,
sub_area.y,
"── agents ──",
sub_area.width as usize,
Style::default().fg(RColor::DarkGray),
);
paint_subagent_list(buf, sub_area, self.subagents);
}
}
const LEFT_PANEL_TOP_PAD: u16 = 1;
fn kfmt(n: u64) -> String {
if n >= 1000 {
format!("{:.1}k", n as f64 / 1000.0)
} else {
n.to_string()
}
}
fn paint_idle_card(buf: &mut Buffer, area: Rect, info: &LeftPanelInfo, style: Style) {
let dim = RColor::DarkGray;
let warn = RColor::Yellow;
let green = RColor::Green;
let panel_w = area.width as usize;
let box_w = area.width.saturating_sub(1);
let bs = style;
let mut dy = LEFT_PANEL_TOP_PAD;
let banner = "D I R G E";
if dy < area.height {
let bw = banner.chars().count();
let bpad = panel_w.saturating_sub(bw) / 2;
buf.set_stringn(
area.x + bpad as u16,
area.y + dy,
banner,
panel_w.saturating_sub(bpad),
style,
);
}
dy += 2;
let place = |buf: &mut Buffer, dy: &mut u16, title: &str, lines: Vec<(String, RColor)>| {
let h = 2 + lines.len() as u16;
if box_w < 4 || area.y + *dy + h > area.y + area.height {
return;
}
let mut sp = SubPanel::new(title).border_style(bs);
for (t, c) in lines {
sp = sp.line(t, c);
}
sp.render(Rect::new(area.x, area.y + *dy, box_w, h), buf);
*dy += h + 1;
};
let g = &info.context;
let mut ctx_lines = vec![
(
format_bar("ctx", g.pct as f32),
if g.fold_soon { warn } else { green },
),
(
format!("{}/{} cmp:{}", kfmt(g.used), kfmt(g.window), g.compactions),
dim,
),
];
if g.fold_soon {
ctx_lines.push(("⚠ compaction soon".to_string(), warn));
}
place(buf, &mut dy, "CONTEXT", ctx_lines);
let git_lines: Option<Vec<(String, RColor)>> = info.git.as_ref().map(|gs| {
let mut v = vec![
(
format!(
"⎇ {}",
if gs.branch.is_empty() {
"?"
} else {
&gs.branch
}
),
green,
),
(
format!("+{} ~{} ?{}", gs.staged, gs.unstaged, gs.untracked),
if gs.staged + gs.unstaged + gs.untracked == 0 {
dim
} else {
warn
},
),
];
if !gs.last_commit.is_empty() {
v.push((gs.last_commit.clone(), dim));
}
v
});
let git_reserve = git_lines
.as_ref()
.map(|v| 2 + v.len() as u16 + 1)
.unwrap_or(0);
let avail = (area.y + area.height)
.saturating_sub(area.y + dy)
.saturating_sub(git_reserve);
let max_act = avail.saturating_sub(2) as usize; if max_act >= 1 {
let act_lines: Vec<(String, RColor)> = if info.activity.is_empty() {
vec![("· idle".to_string(), dim)]
} else {
info.activity
.iter()
.rev()
.take(max_act)
.rev()
.map(|a| (a.clone(), dim))
.collect()
};
place(buf, &mut dy, "ACTIVITY", act_lines);
}
if let Some(lines) = git_lines {
place(buf, &mut dy, "GIT", lines);
}
}
fn paint_subagent_list(buf: &mut Buffer, area: Rect, rows: &[SubagentStatusRow]) {
let dim = Style::default().fg(RColor::DarkGray);
let agent = Style::default().fg(RColor::Green);
let err = Style::default().fg(RColor::Red);
let file_style = Style::default().fg(RColor::DarkGray);
let id_indent = 3_u16; let file_indent = 5_u16; let trailing_pad = 1_usize;
let cap_rows = area.height.saturating_sub(LEFT_PANEL_TOP_PAD) as usize;
let mut dy: u16 = LEFT_PANEL_TOP_PAD;
for row in rows {
let file_lines = &row.files;
let row_height = 2_u16 + file_lines.len() as u16;
if (dy + row_height - LEFT_PANEL_TOP_PAD) as usize > cap_rows {
break;
}
let (glyph, style) = match row.state.as_str() {
"running" => ("⋯", agent),
"completed" => ("✓", agent),
"failed" => ("✗", err),
_ => ("·", dim),
};
let id_tail: String = row
.id_short
.chars()
.rev()
.take(6)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let hash_line = format!("{} ...{}", glyph, id_tail);
let hash_w = (area.width as usize).saturating_sub(trailing_pad);
buf.set_stringn(area.x, area.y + dy, hash_line, hash_w, style);
let prompt_avail = (area.width as usize)
.saturating_sub(id_indent as usize)
.saturating_sub(trailing_pad);
let prompt_field: String = row.prompt_short.chars().take(prompt_avail).collect();
buf.set_stringn(
area.x + id_indent,
area.y + dy + 1,
prompt_field,
prompt_avail,
dim,
);
dy += 2;
for file in file_lines {
let file_avail = (area.width as usize)
.saturating_sub(file_indent as usize)
.saturating_sub(trailing_pad);
let file_field: String = if file.len() <= file_avail {
file.clone()
} else {
format!("…{}", crate::text::tail(file, file_avail.saturating_sub(1)))
};
buf.set_stringn(
area.x + file_indent,
area.y + dy,
file_field,
file_avail,
file_style,
);
dy += 1;
}
}
}
pub struct RightPanel<'a> {
data: &'a PanelData,
style: Style,
modified_offset: usize,
}
impl<'a> RightPanel<'a> {
pub fn new(data: &'a PanelData) -> Self {
Self {
data,
style: Style::default().fg(RColor::Green),
modified_offset: 0,
}
}
#[allow(dead_code)]
pub fn border_style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn modified_offset(mut self, offset: usize) -> Self {
self.modified_offset = offset;
self
}
}
pub fn compute_modified_rect(data: &PanelData, area: Rect) -> Option<Rect> {
if area.width == 0 || area.height == 0 {
return None;
}
let sysload_lines = if data.sysload.is_some() { 2 } else { 1 };
let mcp_lines = data.mcp.len().max(1);
let lsp_lines = data.lsp.len().max(1);
let todos_lines = data.todos.len().max(1);
let mut y = area.y + RIGHT_PANEL_TOP_PAD;
for body_lines in [sysload_lines, mcp_lines, lsp_lines, todos_lines] {
let h = 2 + body_lines as u16;
if y + h > area.y + area.height {
return None;
}
y += h + 1; }
let remaining = (area.y + area.height).saturating_sub(y);
if remaining < 3 {
return None;
}
let natural = (2 + data.modified.len().max(1)) as u16;
let height = natural.min(remaining);
let inner_w = area.width.saturating_sub(RIGHT_PANEL_TRAILING_PAD);
Some(Rect::new(
area.x + RIGHT_PANEL_TRAILING_PAD,
y,
inner_w,
height,
))
}
const RIGHT_PANEL_TOP_PAD: u16 = 1;
const RIGHT_PANEL_TRAILING_PAD: u16 = 1;
const AMBER: RColor = RColor::Rgb(255, 191, 0);
impl<'a> Widget for RightPanel<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let dim = RColor::DarkGray;
let body = AMBER;
let sysload_panel = match self.data.sysload.as_ref() {
Some(s) => SubPanel::new("SYSTEM LOAD")
.line(format_bar("CPU", s.cpu_pct), body)
.line(format_bar("MEM", s.mem_pct), body)
.border_style(self.style),
None => SubPanel::new("SYSTEM LOAD")
.line("(pending)", dim)
.border_style(self.style),
};
let mcp_panel = {
let mut p = SubPanel::new("MCP").border_style(self.style);
if self.data.mcp.is_empty() {
p = p.line("· (none)", dim);
} else {
for (name, ok) in &self.data.mcp {
let glyph = if *ok { "●" } else { "○" };
p = p.line(format!("{} {}", glyph, name), body);
}
}
p
};
let lsp_panel = {
let mut p = SubPanel::new("LSP").border_style(self.style);
if self.data.lsp.is_empty() {
p = p.line("· (none)", dim);
} else {
for (id, root, ok) in &self.data.lsp {
let glyph = if *ok { "●" } else { "○" };
p = p.line(format!("{} {} {}", glyph, id, root), body);
}
}
p
};
let todos_panel = {
let mut p = SubPanel::new("TODOS").border_style(self.style);
if self.data.todos.is_empty() {
p = p.line("· (none)", dim);
} else {
for (status, text) in &self.data.todos {
p = p.line(format!("{} {}", status, text), body);
}
}
p
};
let mut y = area.y + RIGHT_PANEL_TOP_PAD;
let box_x = area.x + RIGHT_PANEL_TRAILING_PAD;
let inner_w = area.width.saturating_sub(RIGHT_PANEL_TRAILING_PAD);
let fixed = [sysload_panel, mcp_panel, lsp_panel, todos_panel];
for panel in fixed {
let h = panel.height();
if y + h > area.y + area.height {
break;
}
let rect = Rect::new(box_x, y, inner_w, h);
panel.render(rect, buf);
y += h + 1; }
let modified_top = y;
let remaining = (area.y + area.height).saturating_sub(modified_top);
if remaining >= 3 {
let total = self.data.modified.len();
let height = ((2 + total.max(1)) as u16).min(remaining);
let rect = Rect::new(box_x, modified_top, inner_w, height);
let inner_rows = (height as usize).saturating_sub(2);
let mut p = SubPanel::new("MODIFIED").border_style(self.style);
if total == 0 {
p = p.line("· (none)", dim);
} else if total <= inner_rows {
for f in &self.data.modified {
p = p.line(f.clone(), body);
}
} else {
let head_rows = inner_rows.saturating_sub(1);
let max_off = total.saturating_sub(head_rows);
let offset = self.modified_offset.min(max_off);
let end = (offset + head_rows).min(total);
for f in self.data.modified.iter().take(end).skip(offset) {
p = p.line(f.clone(), body);
}
let newer = offset;
let older = total.saturating_sub(end);
let footer = if newer > 0 {
format!("↑ {} newer / ↓ {} older", newer, older)
} else {
format!("+{} older", older)
};
p = p.line(footer, dim);
}
p.render(rect, buf);
}
}
}
#[cfg(feature = "dap")]
pub mod debug {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color as RColor, Style};
use ratatui::widgets::Widget;
use crate::dap::types::*;
use super::SubPanel;
const AMBER: RColor = RColor::Rgb(255, 191, 0);
const RIGHT_PANEL_TOP_PAD: u16 = 1;
const RIGHT_PANEL_TRAILING_PAD: u16 = 1;
pub struct DebugRightPanel<'a> {
data: &'a DebugPanelData,
style: Style,
}
impl<'a> DebugRightPanel<'a> {
pub fn new(data: &'a DebugPanelData) -> Self {
Self {
data,
style: Style::default().fg(RColor::Green),
}
}
}
impl<'a> Widget for DebugRightPanel<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let dim = RColor::DarkGray;
let body_color = AMBER;
let mut y = area.y + RIGHT_PANEL_TOP_PAD;
let box_x = area.x + RIGHT_PANEL_TRAILING_PAD;
let inner_w = area.width.saturating_sub(RIGHT_PANEL_TRAILING_PAD);
let summary = self.data.session_summary.as_ref();
let debug_panel = {
let mut p = SubPanel::new("DEBUG").border_style(self.style);
if let Some(s) = summary {
p = p.line(format!("status: {:?}", s.status), body_color);
p = p.line(format!("adapter: {}", s.adapter_name), body_color);
if let Some(ref reason) = s.stop_reason {
p = p.line(format!("reason: {}", reason), body_color);
}
if let Some(tid) = s.thread_id {
p = p.line(format!("thread: {}", tid), body_color);
}
} else {
p = p.line("· (no session)", dim);
}
p
};
let h = debug_panel.height();
if y + h <= area.y + area.height {
debug_panel.render(Rect::new(box_x, y, inner_w, h), buf);
y += h + 1;
}
let threads_panel = {
let mut p = SubPanel::new("THREADS").border_style(self.style);
if self.data.threads.is_empty() {
p = p.line("· (pending)", dim);
} else {
for t in &self.data.threads {
p = p.line(format!("{} {}", t.id, t.name), body_color);
}
}
p
};
let h = threads_panel.height();
if y + h <= area.y + area.height {
threads_panel.render(Rect::new(box_x, y, inner_w, h), buf);
y += h + 1;
}
let frames_panel = {
let mut p = SubPanel::new("FRAMES").border_style(self.style);
if self.data.frames.is_empty() {
p = p.line("· (pending)", dim);
} else {
for f in &self.data.frames {
let src = f
.source
.as_ref()
.and_then(|s| s.name.as_deref())
.unwrap_or("??");
p = p.line(
format!("{} {}:{} {}", f.id, src, f.line, f.name),
body_color,
);
}
}
p
};
let h = frames_panel.height();
if y + h <= area.y + area.height {
frames_panel.render(Rect::new(box_x, y, inner_w, h), buf);
y += h + 1;
}
let variables_panel = {
let mut p = SubPanel::new("VARIABLES").border_style(self.style);
if self.data.variables.is_empty() {
p = p.line("· (pending)", dim);
} else {
for v in &self.data.variables {
let val = &v.value;
let type_hint = v
.type_field
.as_deref()
.map(|t| format!(": {t}"))
.unwrap_or_default();
p = p.line(format!("{} = {}{}", v.name, val, type_hint), body_color);
}
}
p
};
let h = variables_panel.height();
if y + h <= area.y + area.height {
variables_panel.render(Rect::new(box_x, y, inner_w, h), buf);
y += h + 1;
}
let bp_count = summary.map(|s| s.breakpoint_count).unwrap_or(0);
let fbp_count = summary.map(|s| s.function_breakpoint_count).unwrap_or(0);
let bp_panel = SubPanel::new("BREAKPOINTS")
.line(
format!("source: {} func: {}", bp_count, fbp_count),
body_color,
)
.border_style(self.style);
let h = bp_panel.height();
if y + h <= area.y + area.height {
bp_panel.render(Rect::new(box_x, y, inner_w, h), buf);
y += h + 1;
}
let remaining = (area.y + area.height).saturating_sub(y);
if remaining >= 3 {
let rect = Rect::new(box_x, y, inner_w, remaining);
let inner_rows = (remaining as usize).saturating_sub(2);
let mut p = SubPanel::new("OUTPUT").border_style(self.style);
let output = &self.data.output;
if output.is_empty() {
p = p.line("· (none)", dim);
} else {
let lines: Vec<&str> = output.lines().collect();
let total = lines.len();
if total <= inner_rows {
for line in &lines {
p = p.line(*line, body_color);
}
} else {
for line in lines.iter().take(inner_rows.saturating_sub(1)) {
p = p.line(*line, body_color);
}
let footer = if self.data.output_truncated {
format!("+{} more (truncated)", total.saturating_sub(inner_rows - 1))
} else {
format!("+{} more", total.saturating_sub(inner_rows - 1))
};
p = p.line(footer, dim);
}
}
p.render(rect, buf);
}
}
}
}
fn format_bar(label: &str, pct: f32) -> String {
let bar_w = 10;
let filled = ((pct / 100.0) * bar_w as f32)
.round()
.clamp(0.0, bar_w as f32) as usize;
let empty = bar_w - filled;
format!(
"{}: [{}{}] {:>3}%",
label,
"#".repeat(filled),
".".repeat(empty),
pct.round() as i32
)
}
const _: fn(crossterm::style::Color) -> RColor = crossterm_to_ratatui;
#[cfg(test)]
mod tests {
use super::super::layout::Layout;
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
#[test]
fn subpanel_frame_and_title() {
let mut backend = TestBackend::new(20, 5);
let mut terminal = Terminal::new(backend.clone()).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 20, 4);
f.render_widget(SubPanel::new("MCP").line("a", RColor::Green), area);
})
.unwrap();
backend = terminal.backend().clone();
let row = |y: u16| -> String {
(0..20)
.map(|x| backend.buffer().cell((x, y)).unwrap().symbol().to_string())
.collect()
};
let expected_top = format!("╭{}[MCP]{}╮", "─".repeat(6), "─".repeat(7));
assert_eq!(row(0), expected_top, "got {:?}", row(0));
let body_chars: Vec<char> = row(1).chars().collect();
assert_eq!(body_chars[0], '│', "got first char {:?}", body_chars[0]);
assert_eq!(body_chars[1], ' ');
assert_eq!(body_chars[2], 'a');
assert_eq!(body_chars[19], '│', "row(1) = {:?}", row(1));
let expected_bot = format!("╰{}╯", "─".repeat(18));
assert_eq!(row(3), expected_bot);
}
#[test]
fn side_panels_borders_follow_caller_style() {
let magenta = RColor::Magenta;
let style = Style::default().fg(magenta);
let border_fg = |backend: &TestBackend, area: Rect| -> Option<RColor> {
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
let cell = backend.buffer().cell((x, y)).unwrap();
if matches!(cell.symbol(), "╭" | "╮" | "╰" | "╯" | "│") {
return Some(cell.fg);
}
}
}
None
};
let info = LeftPanelInfo::default();
let subs: Vec<SubagentStatusRow> = Vec::new();
let mut backend = TestBackend::new(24, 20);
let mut terminal = Terminal::new(backend.clone()).unwrap();
let area = Rect::new(0, 0, 24, 20);
terminal
.draw(|f| {
f.render_widget(LeftPanel::new(&info, &subs).border_style(style), area);
})
.unwrap();
backend = terminal.backend().clone();
assert_eq!(
border_fg(&backend, area),
Some(magenta),
"left panel border should follow caller style"
);
let pd = PanelData::default();
let mut backend = TestBackend::new(24, 24);
let mut terminal = Terminal::new(backend.clone()).unwrap();
let area = Rect::new(0, 0, 24, 24);
terminal
.draw(|f| {
f.render_widget(RightPanel::new(&pd).border_style(style), area);
})
.unwrap();
backend = terminal.backend().clone();
assert_eq!(
border_fg(&backend, area),
Some(magenta),
"right panel border should follow caller style"
);
}
#[test]
fn right_panel_boxes_hug_outer_edge() {
let pd = PanelData::default();
let area = Rect::new(10, 0, 14, 24);
let mut terminal = Terminal::new(TestBackend::new(24, 24)).unwrap();
terminal
.draw(|f| f.render_widget(RightPanel::new(&pd), area))
.unwrap();
let backend = terminal.backend();
let has = |x: u16, y: u16| {
matches!(
backend.buffer().cell((x, y)).unwrap().symbol(),
"╭" | "╮" | "╰" | "╯" | "│"
)
};
let mut found = false;
for y in area.y..area.y + area.height {
if has(area.x + 1, y) {
found = true;
assert!(
!has(area.x, y),
"divider-side col {} must be the gap (row {y})",
area.x
);
assert!(
has(area.x + area.width - 1, y),
"box should reach the outer edge col {} (row {y})",
area.x + area.width - 1
);
}
}
assert!(found, "expected at least one right-panel box");
}
#[test]
fn subpanel_content_is_left_aligned() {
let mut backend = TestBackend::new(20, 4);
let mut terminal = Terminal::new(backend.clone()).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 20, 3);
f.render_widget(SubPanel::new("X").line("hi", RColor::Green), area);
})
.unwrap();
backend = terminal.backend().clone();
let body: String = (0..20)
.map(|x| backend.buffer().cell((x, 1)).unwrap().symbol().to_string())
.collect();
let body_chars: Vec<char> = body.chars().collect();
assert_eq!(body_chars[0], '│');
assert_eq!(body_chars[1], ' ');
assert_eq!(body_chars[2], 'h');
assert_eq!(body_chars[3], 'i');
for c in &body_chars[4..19] {
assert_eq!(*c, ' ');
}
assert_eq!(body_chars[19], '│');
}
#[test]
fn left_panel_idle_paints_vitals() {
use crate::ui::panel_data::{ContextGauge, GitSnapshot};
let info = LeftPanelInfo {
context: ContextGauge {
used: 12_300,
window: 128_000,
pct: 80,
compactions: 2,
fold_soon: true,
},
activity: vec!["read run.rs".into(), "bash cargo test".into()],
git: Some(GitSnapshot {
branch: "main".into(),
staged: 1,
unstaged: 2,
untracked: 0,
last_commit: "wip".into(),
}),
};
let backend = TestBackend::new(30, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
f.render_widget(LeftPanel::new(&info, &[]), Rect::new(0, 0, 30, 30));
})
.unwrap();
let backend = terminal.backend().clone();
let dump: String = (0..30)
.map(|y| {
(0..30)
.map(|x| backend.buffer().cell((x, y)).unwrap().symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(dump.contains("D I R G E"), "banner missing:\n{dump}");
assert!(dump.contains("CONTEXT"), "context section missing:\n{dump}");
assert!(dump.contains("80%"), "context pct missing:\n{dump}");
assert!(
dump.contains("compaction soon"),
"fold warning missing:\n{dump}"
);
assert!(
dump.contains("ACTIVITY"),
"activity section missing:\n{dump}"
);
assert!(
dump.contains("cargo test"),
"activity entry missing:\n{dump}"
);
assert!(dump.contains("GIT"), "git section missing:\n{dump}");
assert!(dump.contains("main"), "git branch missing:\n{dump}");
assert!(dump.contains("+1 ~2 ?0"), "git counts missing:\n{dump}");
}
#[test]
fn left_panel_short_keeps_git() {
use crate::ui::panel_data::{ContextGauge, GitSnapshot};
let info = LeftPanelInfo {
context: ContextGauge {
used: 1000,
window: 128_000,
pct: 10,
compactions: 0,
fold_soon: false,
},
activity: vec!["read a.rs".into(), "edit b.rs".into()],
git: Some(GitSnapshot {
branch: "main".into(),
staged: 0,
unstaged: 1,
untracked: 0,
last_commit: "x".into(),
}),
};
let backend = TestBackend::new(28, 15);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| f.render_widget(LeftPanel::new(&info, &[]), Rect::new(0, 0, 28, 15)))
.unwrap();
let backend = terminal.backend().clone();
let dump: String = (0..15)
.map(|y| {
(0..28)
.map(|x| backend.buffer().cell((x, y)).unwrap().symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(dump.contains("CONTEXT"), "context missing:\n{dump}");
assert!(
dump.contains("GIT") && dump.contains("main"),
"GIT dropped on a short panel:\n{dump}"
);
}
#[test]
fn left_panel_subagents_render_below_vitals() {
use crate::ui::panel_data::ContextGauge;
let info = LeftPanelInfo {
context: ContextGauge {
used: 5000,
window: 128_000,
pct: 4,
compactions: 0,
fold_soon: false,
},
activity: vec!["read x.rs".into()],
git: None,
};
let subs = vec![
SubagentStatusRow {
id_short: "abc123".into(),
state: "running".into(),
prompt_short: "do thing".into(),
files: vec!["src/main.rs".into()],
},
SubagentStatusRow {
id_short: "def456".into(),
state: "completed".into(),
prompt_short: "done".into(),
files: vec![],
},
];
let backend = TestBackend::new(30, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| f.render_widget(LeftPanel::new(&info, &subs), Rect::new(0, 0, 30, 24)))
.unwrap();
let backend = terminal.backend().clone();
let rows: Vec<String> = (0..24)
.map(|y| {
(0..30)
.map(|x| backend.buffer().cell((x, y)).unwrap().symbol().to_string())
.collect()
})
.collect();
let dump = rows.join("\n");
assert!(dump.contains("CONTEXT"), "vitals dropped:\n{dump}");
assert!(dump.contains("agents"), "agents header missing:\n{dump}");
assert!(dump.contains("...abc123"), "subagent row missing:\n{dump}");
assert!(
dump.contains("do thing"),
"subagent prompt missing:\n{dump}"
);
let agents_y = rows.iter().position(|r| r.contains("agents")).unwrap();
let context_y = rows.iter().position(|r| r.contains("CONTEXT")).unwrap();
assert!(
context_y < agents_y,
"CONTEXT ({context_y}) must sit above agents ({agents_y})"
);
}
#[test]
fn right_panel_stacks_sub_panels() {
let mut data = PanelData::default();
data.mcp = vec![("server1".into(), true)];
let layout = Layout::new(160, 30, 1);
let mut backend = TestBackend::new(160, 30);
let mut terminal = Terminal::new(backend.clone()).unwrap();
terminal
.draw(|f| {
f.render_widget(RightPanel::new(&data), layout.right_panel);
})
.unwrap();
backend = terminal.backend().clone();
let mut titles_found: Vec<&str> = Vec::new();
for y in layout.right_panel.y..(layout.right_panel.y + layout.right_panel.height) {
let row: String = (layout.right_panel.x
..layout.right_panel.x + layout.right_panel.width)
.map(|x| backend.buffer().cell((x, y)).unwrap().symbol().to_string())
.collect();
for t in ["[SYSTEM LOAD]", "[MCP]", "[LSP]", "[TODOS]", "[MODIFIED]"] {
if row.contains(t) && !titles_found.contains(&t) {
titles_found.push(t);
}
}
}
assert_eq!(
titles_found,
vec!["[SYSTEM LOAD]", "[MCP]", "[LSP]", "[TODOS]", "[MODIFIED]"],
);
let mut found_server = false;
for y in layout.right_panel.y..(layout.right_panel.y + layout.right_panel.height) {
let row: String = (layout.right_panel.x
..layout.right_panel.x + layout.right_panel.width)
.map(|x| backend.buffer().cell((x, y)).unwrap().symbol().to_string())
.collect();
if row.contains("server1") {
found_server = true;
break;
}
}
assert!(found_server, "expected MCP server name in right panel");
}
#[test]
fn modified_rect_collapses_to_one_line_when_empty() {
let data = PanelData::default(); let area = Rect::new(0, 0, 40, 30);
let rect = compute_modified_rect(&data, area).expect("rect");
assert_eq!(
rect.height, 3,
"empty MODIFIED should be 3 rows (1 content line), got {}",
rect.height
);
}
#[test]
fn modified_rect_grows_with_file_count() {
let mut data = PanelData::default();
data.modified = vec!["a.rs".into(), "b.rs".into(), "c.rs".into()];
let area = Rect::new(0, 0, 40, 30);
let rect = compute_modified_rect(&data, area).expect("rect");
assert_eq!(rect.height, 5, "3 files → 2 borders + 3 rows");
}
#[test]
fn modified_rect_caps_at_remaining_space() {
let mut data = PanelData::default();
data.modified = (0..100).map(|i| format!("f{i}.rs")).collect();
let area = Rect::new(0, 0, 40, 30);
let rect = compute_modified_rect(&data, area).expect("rect");
assert!(
rect.y + rect.height <= area.y + area.height,
"must stay within the panel"
);
assert!(
rect.height < (2 + 100),
"must cap below natural height when overflowing, got {}",
rect.height
);
assert!(rect.height >= 3);
}
#[test]
fn modified_box_paints_three_rows_when_empty() {
let data = PanelData::default(); let layout = Layout::new(160, 40, 1);
let backend = TestBackend::new(160, 40);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| f.render_widget(RightPanel::new(&data), layout.right_panel))
.unwrap();
let backend = terminal.backend().clone();
let row = |y: u16| -> String {
(layout.right_panel.x..layout.right_panel.x + layout.right_panel.width)
.map(|x| backend.buffer().cell((x, y)).unwrap().symbol().to_string())
.collect()
};
let y_range = layout.right_panel.y..(layout.right_panel.y + layout.right_panel.height);
let title_y = y_range
.clone()
.find(|&y| row(y).contains("[MODIFIED]"))
.expect("MODIFIED title should render");
let bottom_y = (title_y + 1..layout.right_panel.y + layout.right_panel.height)
.find(|&y| row(y).contains('╰'))
.expect("MODIFIED box should have a bottom border");
assert_eq!(
bottom_y - title_y,
2,
"empty MODIFIED box should be 3 rows (top, (none), bottom)"
);
}
#[test]
fn modified_box_grows_to_content_height_with_files() {
let mut data = PanelData::default();
data.modified = vec![
"src/verify.ts".into(),
"test/todatests.test.ts".into(),
"src/graph.ts".into(),
"src/interpreter.ts".into(),
];
let layout = Layout::new(160, 40, 1);
let backend = TestBackend::new(160, 40);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| f.render_widget(RightPanel::new(&data), layout.right_panel))
.unwrap();
let backend = terminal.backend().clone();
let row = |y: u16| -> String {
(layout.right_panel.x..layout.right_panel.x + layout.right_panel.width)
.map(|x| backend.buffer().cell((x, y)).unwrap().symbol().to_string())
.collect()
};
let title_y = (layout.right_panel.y..layout.right_panel.y + layout.right_panel.height)
.find(|&y| row(y).contains("[MODIFIED]"))
.expect("MODIFIED title");
let bottom_y = (title_y + 1..layout.right_panel.y + layout.right_panel.height)
.find(|&y| row(y).contains('╰'))
.expect("MODIFIED bottom border");
assert_eq!(
bottom_y - title_y,
5,
"4 files → 6-row box (2 borders + 4 rows), not pane-filling"
);
}
#[test]
fn bar_formatting() {
assert_eq!(format_bar("CPU", 0.0), "CPU: [..........] 0%");
assert_eq!(format_bar("MEM", 50.0), "MEM: [#####.....] 50%");
assert_eq!(format_bar("CPU", 100.0), "CPU: [##########] 100%");
}
#[cfg(feature = "dap")]
#[test]
fn debug_panel_renders_variables() {
use crate::dap::types::{DebugPanelData, SessionStatus, Variable};
let data = DebugPanelData {
adapter: "test".into(),
status: SessionStatus::Stopped,
session_summary: Some(crate::dap::types::SessionSummary {
id: "s1".into(),
adapter_name: "test".into(),
program: None,
status: SessionStatus::Stopped,
breakpoint_count: 1,
function_breakpoint_count: 2,
stop_reason: Some("breakpoint".into()),
thread_id: Some(1),
output: String::new(),
output_truncated: false,
exit_code: None,
capabilities: None,
languages: vec![],
}),
threads: vec![],
frames: vec![],
scopes: vec![],
breakpoints: vec![],
variables: vec![
Variable {
name: "x".into(),
value: "42".into(),
type_field: Some("i32".into()),
presentation_hint: None,
evaluate_name: None,
variables_reference: 0,
named_variables: None,
indexed_variables: None,
memory_reference: None,
},
Variable {
name: "msg".into(),
value: "\"hello\"".into(),
type_field: Some("String".into()),
presentation_hint: None,
evaluate_name: None,
variables_reference: 0,
named_variables: None,
indexed_variables: None,
memory_reference: None,
},
],
output: "hello\nworld\n".into(),
output_truncated: false,
exit_code: None,
};
let backend = TestBackend::new(30, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 30, 30);
f.render_widget(debug::DebugRightPanel::new(&data), area);
})
.unwrap();
let backend = terminal.backend().clone();
let dump: String = (0..30)
.map(|y| {
(0..30)
.map(|x| backend.buffer().cell((x, y)).unwrap().symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(dump.contains("[VARIABLES]"), "VARIABLES title:\n{dump}");
assert!(dump.contains("msg = \"hello\""), "variable value:\n{dump}");
assert!(dump.contains(": String"), "variable type:\n{dump}");
assert!(dump.contains("x = 42"), "simple variable:\n{dump}");
assert!(dump.contains("source: 1 func: 2"), "bp counts:\n{dump}");
assert!(dump.contains("[DEBUG]"), "DEBUG title:\n{dump}");
assert!(dump.contains("[BREAKPOINTS]"), "BREAKPOINTS title:\n{dump}");
assert!(dump.contains("[OUTPUT]"), "OUTPUT title:\n{dump}");
assert!(dump.contains("hello"), "output:\n{dump}");
}
}