use std::collections::{HashMap, HashSet};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::symbols;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Widget};
use unicode_width::UnicodeWidthStr;
use super::flat::FlatLine;
use super::format::{
format_collapsed_styled, format_message_styled, format_pair_styled, format_scope_styled,
};
use super::icons::IconSet;
use super::selection::VisualSelection;
use super::theme::Theme;
use crate::session::SessionMessage;
pub struct PanelState<'a> {
pub session_id: String,
pub messages: Vec<SessionMessage>,
pub cursor: usize,
pub scroll_offset: usize,
pub tail_attached: bool,
pub horizontal_scroll: usize,
pub pinned: bool,
pub language_servers: Vec<String>,
pub expanded: HashSet<usize>,
pub visual_selection: Option<VisualSelection>,
pub viewport_height: usize,
pub filter_pattern: Option<String>,
pub theme: &'a Theme,
pub icons: &'a IconSet,
}
impl<'a> PanelState<'a> {
#[must_use]
pub fn new(session_id: String, theme: &'a Theme, icons: &'a IconSet) -> Self {
Self {
session_id,
messages: Vec::new(),
cursor: 0,
scroll_offset: 0,
tail_attached: true,
horizontal_scroll: 0,
pinned: false,
language_servers: Vec::new(),
expanded: HashSet::new(),
visual_selection: None,
viewport_height: 0,
filter_pattern: None,
theme,
icons,
}
}
fn total_lines(&self) -> usize {
self.flat_lines().len()
}
pub fn load_messages(&mut self, messages: Vec<SessionMessage>) {
self.messages = messages;
self.expanded.clear();
let total = self.total_lines();
self.cursor = total.saturating_sub(1);
self.tail_attached = true;
self.snap_viewport(0);
}
pub fn push_message(&mut self, msg: SessionMessage) {
self.messages.push(msg);
if self.tail_attached {
let total = self.total_lines();
self.cursor = total.saturating_sub(1);
self.snap_viewport(0);
}
}
#[allow(
clippy::cast_possible_wrap,
clippy::cast_sign_loss,
reason = "terminal item counts never overflow isize"
)]
pub fn navigate(&mut self, delta: isize) {
let total = self.total_lines();
if total == 0 {
self.cursor = 0;
return;
}
let max = (total - 1) as isize;
let new_pos = self.cursor as isize + delta;
if delta < 0 {
self.tail_attached = false;
}
if new_pos > max {
self.cursor = total - 1;
self.tail_attached = true;
} else {
self.cursor = new_pos.max(0) as usize;
}
self.snap_viewport(0);
}
#[allow(
clippy::cast_possible_wrap,
clippy::cast_sign_loss,
reason = "terminal item counts never overflow isize"
)]
pub fn scroll_viewport(&mut self, delta: isize) {
let total = self.total_lines();
if total == 0 {
return;
}
if delta < 0 {
self.tail_attached = false;
}
let new_offset = (self.scroll_offset as isize + delta)
.max(0)
.min(total.saturating_sub(1) as isize);
#[allow(clippy::cast_sign_loss, reason = "clamped to >= 0")]
{
self.scroll_offset = new_offset as usize;
}
let vh = if self.viewport_height > 0 {
self.viewport_height
} else {
20
};
if self.scroll_offset + vh >= total {
self.tail_attached = true;
}
}
pub const fn scroll_to_top(&mut self) {
self.cursor = 0;
self.scroll_offset = 0;
self.tail_attached = false;
}
pub fn scroll_to_bottom(&mut self) {
let total = self.total_lines();
self.cursor = total.saturating_sub(1);
self.tail_attached = true;
self.snap_viewport(0);
}
pub fn page_up(&mut self, height: usize) {
let half = (height / 2).max(1);
#[allow(
clippy::cast_possible_wrap,
reason = "terminal heights never overflow isize"
)]
self.navigate(-(half as isize));
}
#[allow(
clippy::cast_sign_loss,
reason = "delta is checked positive before cast"
)]
pub const fn scroll_horizontal(&mut self, delta: isize) {
if delta < 0 {
self.horizontal_scroll = self.horizontal_scroll.saturating_sub(delta.unsigned_abs());
} else {
self.horizontal_scroll = self.horizontal_scroll.saturating_add(delta as usize);
}
}
pub fn page_down(&mut self, height: usize) {
let half = (height / 2).max(1);
#[allow(
clippy::cast_possible_wrap,
reason = "terminal heights never overflow isize"
)]
self.navigate(half as isize);
}
#[must_use]
pub fn visible_range(&self, height: usize) -> (usize, usize) {
let total = self.total_lines();
let start = self.scroll_offset.min(total);
let end = (start + height).min(total);
(start, end)
}
pub fn update_language_servers(&mut self) {
let mut seen: HashSet<String> = HashSet::new();
let mut order: Vec<String> = Vec::new();
for msg in &self.messages {
if msg.r#type == "lsp" && !msg.server.is_empty() && seen.insert(msg.server.clone()) {
order.push(msg.server.clone());
}
}
self.language_servers = order;
}
fn snap_viewport(&mut self, height: usize) {
let h = if height > 0 {
height
} else if self.viewport_height > 0 {
self.viewport_height
} else {
20
};
let total = self.total_lines();
if total <= h {
self.scroll_offset = 0;
return;
}
let target = self.cursor.saturating_sub(h / 2);
self.scroll_offset = target.min(total.saturating_sub(h));
}
pub fn toggle_expansion(&mut self) {
let flat = self.flat_lines();
let Some(current) = flat.get(self.cursor) else {
return;
};
match *current {
FlatLine::MessageHeader { message_index, .. } => {
if self.expanded.contains(&message_index) {
self.expanded.remove(&message_index);
} else {
self.expanded.insert(message_index);
}
}
FlatLine::Detail { message_index, .. } => {
self.expanded.remove(&message_index);
let new_flat = self.flat_lines();
if let Some(pos) = new_flat.iter().position(|fl| {
matches!(fl, FlatLine::MessageHeader { message_index: mi, .. } if *mi == message_index)
}) {
self.cursor = pos;
}
}
FlatLine::CollapsedHeader { start_index, .. } => {
if self.expanded.contains(&start_index) {
self.expanded.remove(&start_index);
} else {
self.expanded.insert(start_index);
}
}
FlatLine::ScopeHeader { expansion_key, .. } => {
if self.expanded.contains(&expansion_key) {
self.expanded.remove(&expansion_key);
} else {
self.expanded.insert(expansion_key);
}
}
FlatLine::Separator => {
}
FlatLine::ScopeChild {
scope_parent_index,
ref inner,
..
} => match inner.as_ref() {
FlatLine::CollapsedHeader { start_index, .. } => {
if self.expanded.contains(start_index) {
self.expanded.remove(start_index);
} else {
self.expanded.insert(*start_index);
}
}
FlatLine::ScopeHeader { expansion_key, .. } => {
if self.expanded.contains(expansion_key) {
self.expanded.remove(expansion_key);
} else {
self.expanded.insert(*expansion_key);
}
}
_ => {
self.expanded.remove(&scope_parent_index);
let new_flat = self.flat_lines();
if let Some(pos) = new_flat.iter().position(|fl| {
matches!(fl, FlatLine::ScopeHeader { expansion_key, .. } if *expansion_key == scope_parent_index)
}) {
self.cursor = pos;
}
}
},
}
self.snap_viewport(0);
}
}
#[must_use]
pub fn frontmatter_lines(msg: &SessionMessage, theme: &Theme) -> Vec<Line<'static>> {
let payload = &msg.payload;
if payload.as_object().is_none_or(serde_json::Map::is_empty) {
return Vec::new();
}
let indent = " ";
let mut lines = Vec::new();
if let Ok(pretty) = serde_json::to_string_pretty(payload) {
for line in pretty.lines() {
lines.push(Line::from(vec![
Span::raw(indent.to_string()),
Span::styled(line.to_string(), theme.muted),
]));
}
}
lines
}
fn build_title<'a>(state: &'a PanelState<'a>) -> Line<'a> {
let id_short = if state.session_id.len() > 8 {
&state.session_id[..8]
} else {
&state.session_id
};
let mut spans = vec![Span::raw(format!(" Events [{id_short}]"))];
if state.language_servers.is_empty() {
spans.push(Span::styled(" no ls", Style::default().fg(Color::DarkGray)));
} else {
let style = Style::default().fg(Color::Green);
spans.push(Span::raw(" "));
for (i, name) in state.language_servers.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" \u{2571} ")); }
spans.push(Span::styled(state.icons.ls_active.as_str(), style));
spans.push(Span::styled(name.as_str(), style));
}
}
spans.push(Span::raw(" "));
Line::from(spans)
}
#[must_use]
pub fn to_owned_line(line: &Line<'_>) -> Line<'static> {
Line::from(
line.spans
.iter()
.map(|s| Span::styled(s.content.to_string(), s.style))
.collect::<Vec<_>>(),
)
}
fn clip_line_horizontal(line: &Line<'_>, h_scroll: usize, width: usize) -> Line<'static> {
if width < 4 {
return Line::default();
}
let full_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let full_width = UnicodeWidthStr::width(full_text.as_str());
if full_width == 0 {
return Line::default();
}
if h_scroll == 0 && full_width <= width {
return to_owned_line(line);
}
let clipped_left = h_scroll > 0;
let clipped_right = full_width > h_scroll + width;
let left_reserve = if clipped_left { 2 } else { 0 };
let right_reserve = if clipped_right { 2 } else { 0 };
let content_width = width.saturating_sub(left_reserve + right_reserve);
if content_width == 0 {
let mut spans = Vec::new();
if clipped_left {
spans.push(Span::styled(
"\u{25C0}\u{2026}",
Style::default().fg(Color::DarkGray),
));
}
if clipped_right {
spans.push(Span::styled(
"\u{2026}\u{25B6}",
Style::default().fg(Color::DarkGray),
));
}
return Line::from(spans);
}
let vis_start = h_scroll;
let vis_end = h_scroll + content_width;
let mut result_spans: Vec<Span<'static>> = Vec::new();
if clipped_left {
result_spans.push(Span::styled(
"\u{25C0}\u{2026}",
Style::default().fg(Color::DarkGray),
)); }
let mut col = 0usize;
for span in &line.spans {
let span_text = span.content.as_ref();
let span_width = UnicodeWidthStr::width(span_text);
let span_end = col + span_width;
if span_end <= vis_start || col >= vis_end {
col = span_end;
continue;
}
let mut visible = String::new();
let mut char_col = col;
for ch in span_text.chars() {
let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
let ch_end = char_col + ch_width;
if char_col >= vis_end {
break;
}
if ch_end > vis_start {
visible.push(ch);
}
char_col = ch_end;
}
if !visible.is_empty() {
result_spans.push(Span::styled(visible, span.style));
}
col = span_end;
}
if clipped_right {
result_spans.push(Span::styled(
"\u{2026}\u{25B6}",
Style::default().fg(Color::DarkGray),
)); }
Line::from(result_spans)
}
fn render_flat_line_styled(
fl: &FlatLine,
state: &PanelState<'_>,
detail_cache: &mut HashMap<usize, Vec<Line<'static>>>,
) -> Line<'static> {
match fl {
FlatLine::MessageHeader {
message_index,
paired_response,
} => paired_response.map_or_else(
|| format_message_styled(&state.messages[*message_index], state.icons, state.theme),
|resp_idx| {
format_pair_styled(
&state.messages[*message_index],
&state.messages[resp_idx],
state.icons,
state.theme,
)
},
),
FlatLine::Detail {
message_index,
detail_index,
} => detail_cache
.entry(*message_index)
.or_insert_with(|| frontmatter_lines(&state.messages[*message_index], state.theme))
.get(*detail_index)
.cloned()
.unwrap_or_default(),
FlatLine::CollapsedHeader {
start_index,
end_index,
count,
} => format_collapsed_styled(
&state.messages,
*start_index,
*end_index,
*count,
state.icons,
state.theme,
),
FlatLine::ScopeHeader {
parent,
child_count,
position,
..
} => format_scope_styled(
parent,
*child_count,
*position,
&state.messages,
state.icons,
state.theme,
),
FlatLine::Separator => {
let indent = " "; Line::from(vec![
Span::raw(indent.to_string()),
Span::styled("---".to_string(), state.theme.muted),
])
}
FlatLine::ScopeChild { depth, inner, .. } => {
let indent = " ".repeat(depth * 4);
let inner_line = render_flat_line_styled(inner, state, detail_cache);
let mut spans = vec![Span::raw(indent)];
spans.extend(
inner_line
.spans
.into_iter()
.map(|s| Span::styled(s.content.into_owned(), s.style)),
);
Line::from(spans)
}
}
}
#[allow(
clippy::cast_possible_truncation,
clippy::too_many_lines,
reason = "terminal coordinates are always small; pair merge adds detail lookup logic"
)]
pub fn render_panel(state: &PanelState<'_>, area: Rect, buf: &mut Buffer, focused: bool) {
if area.width < 4 || area.height < 2 {
return;
}
let border_style = if focused {
state.theme.border_focused
} else {
state.theme.border_unfocused
};
let title_style = if focused {
state.theme.title
} else {
state.theme.border_unfocused
};
let border_set = if focused {
symbols::border::THICK
} else {
symbols::border::PLAIN
};
let title = build_title(state);
let block = Block::default()
.borders(Borders::TOP | Borders::RIGHT)
.border_set(border_set)
.border_style(border_style)
.title(title)
.title_style(title_style);
let inner = block.inner(area);
block.render(area, buf);
if inner.width < 2 || inner.height < 1 {
return;
}
let flat = state.flat_lines();
let height = inner.height as usize;
let total = flat.len();
let start = state.scroll_offset.min(total);
let end = (start + height).min(total);
let mut detail_cache: HashMap<usize, Vec<Line<'static>>> = HashMap::new();
let content_width = inner.width as usize;
for (i, fl) in flat[start..end].iter().enumerate() {
let y = inner.y + i as u16;
if y >= inner.y + inner.height {
break;
}
let line = render_flat_line_styled(fl, state, &mut detail_cache);
let display_line = if state.horizontal_scroll > 0
|| UnicodeWidthStr::width(
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
.as_str(),
) > content_width
{
clip_line_horizontal(&line, state.horizontal_scroll, content_width)
} else {
to_owned_line(&line)
};
let line_index = start + i;
if line_index == state.cursor {
for x in inner.x..inner.x + inner.width {
buf[(x, y)].set_style(state.theme.selection);
}
}
buf.set_line(inner.x, y, &display_line, inner.width);
if line_index == state.cursor {
for x in inner.x..inner.x + inner.width {
buf[(x, y)].set_style(state.theme.selection);
}
}
}
}
#[cfg(test)]
#[allow(
clippy::expect_used,
clippy::panic,
reason = "tests use expect/panic for readable assertions"
)]
mod tests {
use std::rc::Rc;
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use crate::config::IconConfig;
use crate::session::SessionMessage;
use crate::tui::format::format_scope_plain;
use crate::tui::pipeline::{DisplayEntry, SegmentPosition};
fn test_theme() -> Theme {
Theme::new()
}
fn test_icons() -> IconSet {
IconSet::from_config(IconConfig::default())
}
fn make_message(r#type: &str, method: &str, server: &str) -> SessionMessage {
SessionMessage {
id: 0,
r#type: r#type.to_string(),
method: method.to_string(),
server: server.to_string(),
client: "catenary".to_string(),
request_id: None,
parent_id: None,
timestamp: chrono::Utc::now(),
payload: serde_json::json!({}),
}
}
fn make_non_collapsing_messages(n: usize) -> Vec<SessionMessage> {
(0..n)
.map(|i| make_message("hook", &format!("test-{i}"), "catenary"))
.collect()
}
fn make_message_with_payload(
r#type: &str,
method: &str,
server: &str,
payload: serde_json::Value,
) -> SessionMessage {
SessionMessage {
id: 0,
r#type: r#type.to_string(),
method: method.to_string(),
server: server.to_string(),
client: "catenary".to_string(),
request_id: None,
parent_id: None,
timestamp: chrono::Utc::now(),
payload,
}
}
fn make_lsp_message() -> SessionMessage {
make_message_with_payload(
"lsp",
"textDocument/hover",
"rust-analyzer",
serde_json::json!({"id": 1, "method": "textDocument/hover", "params": {}}),
)
}
fn make_hook_diag_message(file: &str, count: u64) -> SessionMessage {
make_message_with_payload(
"hook",
"post-tool",
"catenary",
serde_json::json!({
"file": file,
"count": count,
"preview": "\t:12:1 [error] rustc: something"
}),
)
}
fn buffer_to_string(buf: &Buffer) -> String {
let mut s = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
let cell = &buf[(x, y)];
s.push_str(cell.symbol());
}
s.push('\n');
}
s
}
#[test]
fn test_panel_new_tail_attached() {
let theme = test_theme();
let icons = test_icons();
let panel = PanelState::new("abc123".to_string(), &theme, &icons);
assert!(panel.tail_attached);
assert_eq!(panel.cursor, 0);
assert_eq!(panel.scroll_offset, 0);
assert_eq!(panel.horizontal_scroll, 0);
assert!(!panel.pinned);
assert!(panel.messages.is_empty());
}
#[test]
fn test_panel_load_messages() {
let theme = test_theme();
let icons = test_icons();
let mut panel = PanelState::new("abc123".to_string(), &theme, &icons);
let messages = make_non_collapsing_messages(10);
panel.load_messages(messages);
assert_eq!(panel.messages.len(), 10);
assert_eq!(panel.cursor, 9);
assert!(panel.tail_attached);
}
#[test]
fn test_panel_push_message_attached() {
let theme = test_theme();
let icons = test_icons();
let mut panel = PanelState::new("abc123".to_string(), &theme, &icons);
let messages = make_non_collapsing_messages(5);
panel.load_messages(messages);
assert_eq!(panel.cursor, 4);
panel.push_message(make_message("mcp", "tools/list", "catenary"));
assert_eq!(panel.messages.len(), 6);
assert_eq!(panel.cursor, 5);
assert!(panel.tail_attached);
}
#[test]
fn test_panel_push_message_detached() {
let theme = test_theme();
let icons = test_icons();
let mut panel = PanelState::new("abc123".to_string(), &theme, &icons);
let messages = make_non_collapsing_messages(5);
panel.load_messages(messages);
panel.navigate(-1);
assert!(!panel.tail_attached);
let cursor_before = panel.cursor;
panel.push_message(make_message("mcp", "tools/list", "catenary"));
assert_eq!(panel.messages.len(), 6);
assert_eq!(panel.cursor, cursor_before);
assert!(!panel.tail_attached);
}
#[test]
fn test_panel_navigate_up_detaches() {
let theme = test_theme();
let icons = test_icons();
let mut panel = PanelState::new("abc123".to_string(), &theme, &icons);
let messages = make_non_collapsing_messages(10);
panel.load_messages(messages);
assert_eq!(panel.cursor, 9);
assert!(panel.tail_attached);
panel.navigate(-1);
assert_eq!(panel.cursor, 8);
assert!(!panel.tail_attached);
}
#[test]
fn test_panel_navigate_down_past_end_reattaches() {
let theme = test_theme();
let icons = test_icons();
let mut panel = PanelState::new("abc123".to_string(), &theme, &icons);
let messages = make_non_collapsing_messages(5);
panel.load_messages(messages);
panel.navigate(-2);
assert_eq!(panel.cursor, 2);
assert!(!panel.tail_attached);
panel.navigate(1);
assert_eq!(panel.cursor, 3);
panel.navigate(1);
assert_eq!(panel.cursor, 4);
panel.navigate(1);
assert_eq!(panel.cursor, 4);
assert!(panel.tail_attached);
}
#[test]
fn test_panel_scroll_to_top() {
let theme = test_theme();
let icons = test_icons();
let mut panel = PanelState::new("abc123".to_string(), &theme, &icons);
let messages = make_non_collapsing_messages(20);
panel.load_messages(messages);
panel.scroll_to_top();
assert_eq!(panel.cursor, 0);
assert_eq!(panel.scroll_offset, 0);
assert!(!panel.tail_attached);
}
#[test]
fn test_panel_scroll_to_bottom() {
let theme = test_theme();
let icons = test_icons();
let mut panel = PanelState::new("abc123".to_string(), &theme, &icons);
let messages = make_non_collapsing_messages(20);
panel.load_messages(messages);
panel.scroll_to_top();
assert!(!panel.tail_attached);
panel.scroll_to_bottom();
assert_eq!(panel.cursor, 19);
assert!(panel.tail_attached);
}
#[test]
fn test_panel_visible_range() {
let theme = test_theme();
let icons = test_icons();
let mut panel = PanelState::new("abc123".to_string(), &theme, &icons);
let messages = make_non_collapsing_messages(100);
panel.load_messages(messages);
panel.cursor = 50;
panel.snap_viewport(20);
let (start, end) = panel.visible_range(20);
assert_eq!(start, 40);
assert_eq!(end, 60);
}
#[test]
fn test_panel_visible_range_at_top() {
let theme = test_theme();
let icons = test_icons();
let mut panel = PanelState::new("abc123".to_string(), &theme, &icons);
let messages = make_non_collapsing_messages(100);
panel.load_messages(messages);
panel.cursor = 3;
panel.snap_viewport(20);
let (start, end) = panel.visible_range(20);
assert_eq!(start, 0);
assert_eq!(end, 20);
}
#[test]
fn test_panel_visible_range_at_bottom() {
let theme = test_theme();
let icons = test_icons();
let mut panel = PanelState::new("abc123".to_string(), &theme, &icons);
let messages = make_non_collapsing_messages(100);
panel.load_messages(messages);
panel.cursor = 97;
panel.snap_viewport(20);
let (start, end) = panel.visible_range(20);
assert_eq!(end, 100);
assert_eq!(start, 80);
}
#[test]
fn test_panel_render_messages() {
let theme = test_theme();
let icons = test_icons();
let messages: Vec<SessionMessage> = vec![
make_message_with_payload(
"mcp",
"tools/call",
"catenary",
serde_json::json!({"params": {"name": "grep"}}),
),
make_message("hook", "break", "catenary"),
make_message_with_payload(
"mcp",
"tools/call",
"catenary",
serde_json::json!({"params": {"name": "glob"}}),
),
];
let mut panel = PanelState::new("test1234".to_string(), &theme, &icons);
panel.load_messages(messages);
let backend = TestBackend::new(60, 10);
let mut terminal = Terminal::new(backend).expect("terminal creation");
terminal
.draw(|f| {
let area = f.area();
render_panel(&panel, area, f.buffer_mut(), true);
})
.expect("draw");
let buf = terminal.backend().buffer().clone();
let content = buffer_to_string(&buf);
assert!(content.contains("grep"), "expected grep tool name");
assert!(content.contains("glob"), "expected glob tool name");
}
#[test]
fn test_panel_render_empty() {
let theme = test_theme();
let icons = test_icons();
let panel = PanelState::new("empty123".to_string(), &theme, &icons);
let backend = TestBackend::new(60, 10);
let mut terminal = Terminal::new(backend).expect("terminal creation");
terminal
.draw(|f| {
let area = f.area();
render_panel(&panel, area, f.buffer_mut(), true);
})
.expect("draw");
let buf = terminal.backend().buffer().clone();
let content = buffer_to_string(&buf);
assert!(content.contains("Events"), "expected title in empty panel");
}
#[test]
fn test_panel_render_cursor_highlight() {
let theme = test_theme();
let icons = test_icons();
let messages = make_non_collapsing_messages(5);
let mut panel = PanelState::new("test1234".to_string(), &theme, &icons);
panel.load_messages(messages);
panel.cursor = 1;
panel.snap_viewport(8);
let backend = TestBackend::new(60, 10);
let mut terminal = Terminal::new(backend).expect("terminal creation");
terminal
.draw(|f| {
let area = f.area();
render_panel(&panel, area, f.buffer_mut(), true);
})
.expect("draw");
let buf = terminal.backend().buffer().clone();
let cursor_y = 2u16;
let inner_x = 1u16; let cell = &buf[(inner_x, cursor_y)];
assert!(
cell.modifier.contains(ratatui::style::Modifier::REVERSED),
"expected REVERSED modifier on cursor row"
);
}
#[test]
fn test_panel_language_server_status() {
let theme = test_theme();
let icons = test_icons();
let mut panel = PanelState::new("abc123".to_string(), &theme, &icons);
panel.messages = vec![
make_message("lsp", "textDocument/hover", "rust-analyzer"),
make_message(
"lsp",
"textDocument/completion",
"typescript-language-server",
),
];
panel.update_language_servers();
assert_eq!(panel.language_servers.len(), 2);
assert_eq!(panel.language_servers[0], "rust-analyzer");
assert_eq!(panel.language_servers[1], "typescript-language-server");
}
#[test]
fn test_toggle_expansion_header() {
let theme = test_theme();
let icons = test_icons();
let mut panel = PanelState::new("test".to_string(), &theme, &icons);
let messages = vec![
make_message("lsp", "initialized", "rust-analyzer"),
make_lsp_message(),
];
panel.load_messages(messages);
panel.cursor = 1;
panel.toggle_expansion();
assert!(panel.expanded.contains(&1));
panel.toggle_expansion();
assert!(!panel.expanded.contains(&1));
}
#[test]
fn test_toggle_expansion_detail() {
let theme = test_theme();
let icons = test_icons();
let mut panel = PanelState::new("test".to_string(), &theme, &icons);
let messages = vec![
make_message("lsp", "initialized", "rust-analyzer"),
make_lsp_message(),
make_message("mcp", "tools/list", "catenary"),
];
panel.load_messages(messages);
panel.expanded.insert(1);
let flat = panel.flat_lines();
let detail_pos = flat
.iter()
.position(|fl| {
matches!(
fl,
FlatLine::Detail {
message_index: 1,
..
}
)
})
.expect("should have detail lines");
panel.cursor = detail_pos;
panel.toggle_expansion();
assert!(!panel.expanded.contains(&1));
assert_eq!(panel.cursor, 1);
}
#[test]
fn test_toggle_expansion_empty_payload() {
let theme = test_theme();
let icons = test_icons();
let mut panel = PanelState::new("test".to_string(), &theme, &icons);
let messages = vec![make_message("lsp", "initialized", "rust-analyzer")];
panel.load_messages(messages);
panel.cursor = 0;
panel.toggle_expansion();
assert!(panel.expanded.contains(&0));
let flat = panel.flat_lines();
assert_eq!(flat.len(), 1);
}
#[test]
fn test_cursor_walks_detail_lines() {
let theme = test_theme();
let icons = test_icons();
let mut panel = PanelState::new("test".to_string(), &theme, &icons);
let messages = vec![
make_message("lsp", "initialized", "rust-analyzer"),
make_lsp_message(),
make_message("mcp", "tools/list", "catenary"),
];
panel.load_messages(messages);
panel.expanded.insert(1);
let flat = panel.flat_lines();
panel.cursor = 0;
panel.tail_attached = false;
for expected in flat.iter().skip(1) {
panel.navigate(1);
let current_flat = panel.flat_lines();
assert_eq!(current_flat[panel.cursor], *expected);
}
}
#[test]
fn test_frontmatter_lines_non_empty_payload() {
let msg = make_lsp_message();
let theme = test_theme();
let lines = frontmatter_lines(&msg, &theme);
assert!(!lines.is_empty(), "non-empty payload should have lines");
let all_text: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
all_text.contains("textDocument/hover"),
"should contain payload content"
);
assert!(
!all_text.contains("textDocument/hover [lsp]"),
"should not contain method [type] header"
);
assert!(
!all_text.contains('\u{2500}'),
"should not contain box-drawing separator"
);
for line in &lines {
for span in &line.spans {
if !span.content.trim().is_empty() {
assert_eq!(
span.style, theme.muted,
"content spans should use muted style"
);
}
}
}
}
#[test]
fn test_frontmatter_lines_empty_payload() {
let msg = make_message("lsp", "initialized", "rust-analyzer");
let theme = test_theme();
let lines = frontmatter_lines(&msg, &theme);
assert!(
lines.is_empty(),
"empty payload should have no frontmatter lines"
);
}
#[test]
fn test_frontmatter_lines_format() {
let theme = test_theme();
let msg = make_message_with_payload(
"lsp",
"textDocument/hover",
"rust-analyzer",
serde_json::json!({"id": 1, "method": "textDocument/hover"}),
);
let lines = frontmatter_lines(&msg, &theme);
let pretty = serde_json::to_string_pretty(&msg.payload).expect("serialize");
assert_eq!(
lines.len(),
pretty.lines().count(),
"line count should match pretty-printed JSON"
);
for line in &lines {
assert_eq!(
line.spans.len(),
2,
"each line should have indent + content"
);
let indent_text: &str = &line.spans[0].content;
assert_eq!(indent_text, " ", "indent should be 10 spaces");
assert_eq!(
line.spans[1].style, theme.muted,
"content should use muted style"
);
}
let all_text: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
!all_text.contains("[lsp]"),
"should not contain [type] header"
);
assert!(
!all_text.contains('\u{2500}'),
"should not contain box-drawing separator"
);
assert!(
!all_text.contains("---"),
"should not contain --- separator"
);
}
#[test]
fn test_visible_range_with_expansion() {
let theme = test_theme();
let icons = test_icons();
let mut panel = PanelState::new("test".to_string(), &theme, &icons);
let mut messages = make_non_collapsing_messages(10);
messages[5] = make_lsp_message();
panel.load_messages(messages);
panel.expanded.insert(5);
let flat = panel.flat_lines();
let detail_count = frontmatter_lines(&panel.messages[5], &theme).len();
assert_eq!(flat.len(), 10 + detail_count);
panel.cursor = 0;
panel.snap_viewport(10);
let (start, end) = panel.visible_range(10);
assert_eq!(start, 0);
assert_eq!(end, 10);
}
#[test]
fn test_render_expanded_message() {
let theme = test_theme();
let icons = test_icons();
let messages = vec![make_hook_diag_message("/src/lib.rs", 2)];
let mut panel = PanelState::new("test1234".to_string(), &theme, &icons);
panel.load_messages(messages);
panel.expanded.insert(0);
panel.cursor = 0;
panel.snap_viewport(8);
let backend = TestBackend::new(60, 10);
let mut terminal = Terminal::new(backend).expect("terminal creation");
terminal
.draw(|f| {
let area = f.area();
render_panel(&panel, area, f.buffer_mut(), true);
})
.expect("draw");
let buf = terminal.backend().buffer().clone();
let content = buffer_to_string(&buf);
assert!(content.contains("lib.rs"), "expected file name in header");
assert!(
content.contains("count"),
"expected payload content in detail"
);
}
fn make_message_with_id(
id: i64,
r#type: &str,
method: &str,
server: &str,
request_id: Option<i64>,
) -> SessionMessage {
SessionMessage {
id,
r#type: r#type.to_string(),
method: method.to_string(),
server: server.to_string(),
client: "catenary".to_string(),
request_id,
parent_id: None,
timestamp: chrono::Utc::now(),
payload: serde_json::json!({}),
}
}
fn make_message_with_id_ts(
id: i64,
r#type: &str,
method: &str,
server: &str,
request_id: Option<i64>,
timestamp: chrono::DateTime<chrono::Utc>,
payload: serde_json::Value,
) -> SessionMessage {
SessionMessage {
id,
r#type: r#type.to_string(),
method: method.to_string(),
server: server.to_string(),
client: "catenary".to_string(),
request_id,
parent_id: None,
timestamp,
payload,
}
}
#[test]
fn test_pair_merge_cancellation() {
use super::super::pipeline::DisplayEntry;
use super::super::pipeline::pair_merge;
let messages = vec![
make_message_with_id(1, "mcp", "tools/call", "catenary", None),
make_message_with_id(2, "mcp", "notifications/cancelled", "catenary", Some(1)),
];
let entries = pair_merge(&messages);
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0],
DisplayEntry::Paired {
request_index: 0,
response_index: 1,
parent_id: None,
}
);
let theme = test_theme();
let icons = test_icons();
let line =
super::super::format::format_pair_styled(&messages[0], &messages[1], &icons, &theme);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("\u{2501}"),
"cancellation should show ━ icon: {text}"
);
assert!(
text.contains("cancelled"),
"cancellation should show cancelled text: {text}"
);
}
#[test]
fn test_format_pair_styled_timing() {
use chrono::{TimeDelta, Utc};
let now = Utc::now();
let later = now + TimeDelta::milliseconds(1500);
let theme = test_theme();
let icons = test_icons();
let request = make_message_with_id_ts(
1,
"lsp",
"textDocument/hover",
"rust-analyzer",
None,
now,
serde_json::json!({}),
);
let response = make_message_with_id_ts(
2,
"lsp",
"textDocument/hover",
"rust-analyzer",
Some(1),
later,
serde_json::json!({"result": null}),
);
let line = super::super::format::format_pair_styled(&request, &response, &icons, &theme);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("1.5s"), "should contain timing delta: {text}");
}
#[test]
fn test_format_pair_styled_lsp() {
let theme = test_theme();
let icons = test_icons();
let request = make_message_with_id(1, "lsp", "textDocument/hover", "rust-analyzer", None);
let mut response =
make_message_with_id(2, "lsp", "textDocument/hover", "rust-analyzer", Some(1));
response.payload = serde_json::json!({"result": {"contents": "fn main()"}});
let line = super::super::format::format_pair_styled(&request, &response, &icons, &theme);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("[rust-analyzer]"),
"should contain server name: {text}"
);
assert!(
text.contains("\u{2714}"),
"LSP success should show ✔ icon: {text}"
);
assert!(
text.contains("textDocument/hover"),
"should contain method: {text}"
);
assert!(!text.contains("<->"), "should not contain arrow: {text}");
}
#[test]
fn test_format_pair_styled_mcp() {
let theme = test_theme();
let icons = test_icons();
let request = make_message_with_id_ts(
1,
"mcp",
"tools/call",
"catenary",
None,
chrono::Utc::now(),
serde_json::json!({"params": {"name": "grep"}}),
);
let response = make_message_with_id_ts(
2,
"mcp",
"tools/call",
"catenary",
Some(1),
chrono::Utc::now(),
serde_json::json!({"result": {"content": [{"type": "text", "text": "results"}]}}),
);
let line = super::super::format::format_pair_styled(&request, &response, &icons, &theme);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("grep"), "should contain tool name: {text}");
assert!(
text.contains("\u{2B9E}"),
"MCP tool success should show tool icon ⮞: {text}"
);
assert!(!text.contains("<->"), "should not contain arrow: {text}");
}
fn make_message_with_id_parent(
id: i64,
r#type: &str,
method: &str,
server: &str,
request_id: Option<i64>,
parent_id: Option<i64>,
) -> SessionMessage {
SessionMessage {
id,
r#type: r#type.to_string(),
method: method.to_string(),
server: server.to_string(),
client: "catenary".to_string(),
request_id,
parent_id,
timestamp: chrono::Utc::now(),
payload: serde_json::json!({}),
}
}
#[test]
fn test_scope_toggle_expansion() {
let theme = test_theme();
let icons = test_icons();
let messages = vec![
make_message_with_id_parent(1, "mcp", "tools/call", "catenary", None, None),
make_message_with_id_parent(
2,
"lsp",
"workspace/symbol",
"rust-analyzer",
None,
Some(1),
),
make_message_with_id_parent(3, "lsp", "workspace/symbol", "taplo", None, Some(1)),
];
let mut panel = PanelState::new("test".to_string(), &theme, &icons);
panel.load_messages(messages);
panel.cursor = 0;
panel.toggle_expansion();
assert!(
panel.expanded.contains(&0),
"scope should be expanded after toggle"
);
let flat = panel.flat_lines();
assert_eq!(flat.len(), 3, "expanded should show 3 lines");
panel.cursor = 0;
panel.toggle_expansion();
assert!(
!panel.expanded.contains(&0),
"scope should be collapsed after second toggle"
);
panel.cursor = 0;
panel.toggle_expansion();
assert!(panel.expanded.contains(&0));
panel.cursor = 1;
panel.toggle_expansion();
assert!(
!panel.expanded.contains(&0),
"toggling on ScopeChild should collapse parent"
);
assert_eq!(
panel.cursor, 0,
"cursor should move to ScopeHeader after child toggle"
);
}
#[test]
fn test_scope_render_basic() {
let theme = test_theme();
let icons = test_icons();
let messages = vec![
{
let mut m =
make_message_with_id_parent(1, "mcp", "tools/call", "catenary", None, None);
m.payload = serde_json::json!({"params": {"name": "grep"}});
m
},
make_message_with_id_parent(
2,
"lsp",
"workspace/symbol",
"rust-analyzer",
None,
Some(1),
),
make_message_with_id_parent(3, "lsp", "workspace/symbol", "taplo", None, Some(1)),
];
let mut panel = PanelState::new("test1234".to_string(), &theme, &icons);
panel.load_messages(messages);
let backend = TestBackend::new(60, 10);
let mut terminal = Terminal::new(backend).expect("terminal creation");
terminal
.draw(|f| {
let area = f.area();
render_panel(&panel, area, f.buffer_mut(), true);
})
.expect("draw");
let buf = terminal.backend().buffer().clone();
let content = buffer_to_string(&buf);
assert!(
content.contains("grep"),
"expected grep tool name in scope header: {content}"
);
assert!(
content.contains("2 children"),
"expected child count in scope header: {content}"
);
}
#[test]
fn test_segmented_scope_rendering() {
let theme = test_theme();
let icons = test_icons();
let make_tool_call = || {
let mut m = make_message_with_id_parent(1, "mcp", "tools/call", "catenary", None, None);
m.payload = serde_json::json!({"params": {"name": "grep"}});
m
};
let parent_entry = DisplayEntry::Single {
index: 0,
parent_id: None,
};
let parent_rc = Rc::new(parent_entry);
let messages = vec![make_tool_call()];
let line = format_scope_styled(
&parent_rc,
3,
SegmentPosition::First,
&messages,
&icons,
&theme,
);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("grep\u{2026}"),
"First should render grep…: {text}"
);
assert!(
!text.contains("grep\u{2026}\u{2026}"),
"First should not have double ellipsis: {text}"
);
let line = format_scope_styled(
&parent_rc,
2,
SegmentPosition::Middle,
&messages,
&icons,
&theme,
);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("\u{2026}grep\u{2026}"),
"Middle should render …grep…: {text}"
);
let line = format_scope_styled(
&parent_rc,
1,
SegmentPosition::Last,
&messages,
&icons,
&theme,
);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("\u{2026}grep"),
"Last should render …grep: {text}"
);
let line = format_scope_styled(
&parent_rc,
5,
SegmentPosition::Only,
&messages,
&icons,
&theme,
);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("grep"), "Only should contain grep: {text}");
assert!(
!text.contains('\u{2026}'),
"Only should not contain ellipsis: {text}"
);
}
#[test]
fn test_segmented_scope_independent_expansion() {
let theme = test_theme();
let icons = test_icons();
let messages = vec![
make_message_with_id_parent(1, "mcp", "tools/call", "catenary", None, None),
make_message_with_id_parent(
2,
"lsp",
"workspace/symbol",
"rust-analyzer",
None,
Some(1),
),
make_message_with_id_parent(3, "lsp", "workspace/symbol", "taplo", None, Some(1)),
make_message_with_id_parent(4, "lsp", "$/progress", "rust-analyzer", None, None),
make_message_with_id_parent(
5,
"lsp",
"textDocument/references",
"rust-analyzer",
None,
Some(1),
),
];
let mut panel = PanelState::new("test".to_string(), &theme, &icons);
panel.load_messages(messages);
panel.expanded.insert(0);
let flat = panel.flat_lines();
assert_eq!(
flat.len(),
5,
"segment 1 expanded, segment 2 collapsed: {flat:?}"
);
assert!(
matches!(flat[0], FlatLine::ScopeHeader { .. }),
"first should be segment 1 ScopeHeader"
);
assert!(
matches!(flat[1], FlatLine::ScopeChild { .. }),
"second should be ScopeChild"
);
assert!(
matches!(flat[2], FlatLine::ScopeChild { .. }),
"third should be ScopeChild"
);
assert!(
matches!(flat[4], FlatLine::ScopeHeader { .. }),
"fifth should be segment 2 ScopeHeader (collapsed)"
);
}
#[test]
fn test_segmented_scope_filter_hides_interruption() {
let theme = test_theme();
let icons = test_icons();
let messages = vec![
{
let mut m =
make_message_with_id_parent(1, "mcp", "tools/call", "catenary", None, None);
m.payload = serde_json::json!({"params": {"name": "grep"}});
m
},
make_message_with_id_parent(
2,
"lsp",
"workspace/symbol",
"rust-analyzer",
None,
Some(1),
),
make_message_with_id_parent(3, "lsp", "$/progress", "rust-analyzer", None, None),
make_message_with_id_parent(
4,
"lsp",
"textDocument/references",
"rust-analyzer",
None,
Some(1),
),
];
let mut panel = PanelState::new("test".to_string(), &theme, &icons);
panel.load_messages(messages);
let flat = panel.flat_lines();
assert_eq!(
flat.len(),
3,
"unfiltered: 2 segments + 1 interruption: {flat:?}"
);
panel.filter_pattern = Some("grep".to_string());
let flat = panel.flat_lines();
assert_eq!(
flat.len(),
2,
"filtered: 2 segments, interruption hidden: {flat:?}"
);
}
#[test]
fn test_segmented_scope_plain_format() {
let make_tool_call = || {
let mut m = make_message_with_id_parent(1, "mcp", "tools/call", "catenary", None, None);
m.payload = serde_json::json!({"params": {"name": "grep"}});
m
};
let parent_entry = DisplayEntry::Single {
index: 0,
parent_id: None,
};
let messages = vec![make_tool_call()];
let plain_first = format_scope_plain(&parent_entry, 3, SegmentPosition::First, &messages);
assert!(
plain_first.contains("grep\u{2026}"),
"First plain should contain grep…: {plain_first}"
);
let plain_middle = format_scope_plain(&parent_entry, 2, SegmentPosition::Middle, &messages);
assert!(
plain_middle.contains("\u{2026}grep\u{2026}"),
"Middle plain should contain …grep…: {plain_middle}"
);
let plain_last = format_scope_plain(&parent_entry, 1, SegmentPosition::Last, &messages);
assert!(
plain_last.contains("\u{2026}grep"),
"Last plain should contain …grep: {plain_last}"
);
let plain_only = format_scope_plain(&parent_entry, 5, SegmentPosition::Only, &messages);
assert!(
!plain_only.contains('\u{2026}'),
"Only plain should not contain ellipsis: {plain_only}"
);
}
fn make_message_with_id_parent_payload(
id: i64,
r#type: &str,
method: &str,
server: &str,
request_id: Option<i64>,
parent_id: Option<i64>,
payload: serde_json::Value,
) -> SessionMessage {
SessionMessage {
id,
r#type: r#type.to_string(),
method: method.to_string(),
server: server.to_string(),
client: "catenary".to_string(),
request_id,
parent_id,
timestamp: chrono::Utc::now(),
payload,
}
}
#[test]
fn test_separator_toggle_collapses_parent() {
let theme = test_theme();
let icons = test_icons();
let messages = vec![
make_message_with_id_parent_payload(
1,
"mcp",
"tools/call",
"catenary",
None,
None,
serde_json::json!({"params": {"name": "grep"}}),
),
make_message_with_id_parent(
2,
"lsp",
"workspace/symbol",
"rust-analyzer",
None,
Some(1),
),
];
let fm_count = frontmatter_lines(&messages[0], &theme).len();
assert!(fm_count > 0, "parent should have frontmatter");
let mut panel = PanelState::new("test".to_string(), &theme, &icons);
panel.load_messages(messages);
panel.expanded.insert(0);
let flat = panel.flat_lines();
let sep_idx = flat
.iter()
.position(|fl| {
matches!(
fl,
FlatLine::ScopeChild { inner, .. }
if matches!(inner.as_ref(), FlatLine::Separator)
)
})
.expect("should have separator");
panel.cursor = sep_idx;
panel.toggle_expansion();
assert!(
!panel.expanded.contains(&0),
"scope should be collapsed after toggling on separator"
);
assert_eq!(
panel.cursor, 0,
"cursor should move to ScopeHeader after separator toggle"
);
}
}