use ansi_to_tui::IntoText as _;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
};
use std::time::{Instant, SystemTime};
use tmux_claude_state::claude_state::ClaudeState;
use crate::state::{InputMode, ManagedSession, PreviewEntry};
const STALE_MIN_SECS: u64 = 5;
const STALE_MAX_SECS: u64 = 15;
pub const MIN_PANE_WIDTH: u16 = 80;
pub fn compute_grid(n: usize, available_width: u16, min_col_width: u16) -> (usize, usize) {
if n == 0 {
return (1, 0);
}
let cols = (available_width / min_col_width).max(1) as usize;
let cols = cols.min(n); let rows = n.div_ceil(cols);
(cols, rows)
}
fn grid_row_items(n: usize, cols: usize) -> Vec<usize> {
if cols == 0 || n == 0 {
return vec![];
}
let rows = n.div_ceil(cols);
(0..rows)
.map(|r| {
if r < rows - 1 {
cols
} else {
let rem = n % cols;
if rem == 0 { cols } else { rem }
}
})
.collect()
}
const SELECTED_ICON: &str = "> ";
const TITLE_COLOR: Color = Color::Rgb(180, 180, 180);
pub fn should_pulse(state: &ClaudeState, elapsed_secs: u64) -> bool {
matches!(state, ClaudeState::WaitingForApproval)
|| (matches!(state, ClaudeState::Idle)
&& (STALE_MIN_SECS..=STALE_MAX_SECS).contains(&elapsed_secs))
}
const fn color_to_rgb(color: Color) -> (u8, u8, u8) {
match color {
Color::Blue => (0, 0, 255),
Color::LightRed => (255, 100, 100),
Color::White => (255, 255, 255),
Color::Rgb(r, g, b) => (r, g, b),
_ => (200, 200, 200),
}
}
fn pulse_factor() -> f64 {
let t = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64();
f64::midpoint((t * 16.0).sin(), 1.0) }
fn pulse_bg_color(base: Color) -> Color {
let intensity = pulse_factor() * 0.25; let (r, g, b) = color_to_rgb(base);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
Color::Rgb(
(f64::from(r) * intensity) as u8,
(f64::from(g) * intensity) as u8,
(f64::from(b) * intensity) as u8,
)
}
pub fn format_elapsed(since: Instant) -> String {
let secs = since.elapsed().as_secs();
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m", secs / 60)
} else {
format!("{}h", secs / 3600)
}
}
pub const fn state_color(state: &ClaudeState) -> Color {
match state {
ClaudeState::Working => Color::Blue,
ClaudeState::WaitingForApproval => Color::LightRed,
ClaudeState::Idle => Color::White,
}
}
pub const fn state_label(state: &ClaudeState) -> &'static str {
match state {
ClaudeState::Working => "Running",
ClaudeState::WaitingForApproval => "Approval",
ClaudeState::Idle => "Idle",
}
}
fn truncate_title(s: &str, max_chars: usize) -> String {
let char_count = s.chars().count();
if char_count <= max_chars {
s.to_string()
} else {
let truncated: String = s.chars().take(max_chars - 1).collect();
format!("{truncated}…")
}
}
fn preview_title(name: &str, pane_id: &str, title: &Option<String>) -> String {
match title {
Some(t) if !t.is_empty() => format!("{name} - {t}"),
_ => format!("{name} - {pane_id}"),
}
}
#[allow(clippy::too_many_arguments)]
pub fn draw(
f: &mut ratatui::Frame,
sessions: &[ManagedSession],
selected_index: usize,
preview_contents: &[PreviewEntry],
input_mode: InputMode,
input_buffer: &str,
show_help: bool,
help_scroll: u16,
preview_scroll: u16,
) {
let size = f.area();
let v_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(3)])
.split(size);
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(30), Constraint::Min(0)])
.split(v_chunks[0]);
draw_left_panel(f, sessions, h_chunks[0], selected_index, input_mode, input_buffer);
let selected_pane_id = sessions
.get(selected_index)
.map(|s| s.pane_id.as_str());
let preview_cursor = draw_right_panel(f, preview_contents, input_mode, input_buffer, h_chunks[1], selected_pane_id, preview_scroll);
let footer_line = footer_spans(input_mode);
let instructions = Paragraph::new(Line::from(footer_line.clone()))
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::Gray));
f.render_widget(instructions, v_chunks[1]);
if matches!(input_mode, InputMode::Input | InputMode::Broadcast) {
if let Some((cx, cy)) = preview_cursor {
f.set_cursor_position((cx, cy));
}
}
if show_help {
draw_help_popup(f, size, help_scroll);
}
}
fn footer_spans(input_mode: InputMode) -> Vec<Span<'static>> {
let mut spans = vec![Span::styled(
concat!("crmux v", env!("CARGO_PKG_VERSION")),
Style::default().fg(Color::White),
)];
match input_mode {
InputMode::Normal => {
spans.push(Span::raw(" | j/k:Nav C-u/C-d:Scroll gg:Top G:Bottom Space:Multi-preview s:Switch i:Input(selected) I:Input(marked) e:Title o:Claudeye ?:Help q:Quit"));
}
InputMode::Input => {
spans.push(Span::raw(" "));
spans.push(Span::styled(
"-- INSERT --",
Style::default().add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" | Keys sent to selected pane via send-keys. Esc:Back"));
}
InputMode::Title => {
spans.push(Span::raw(" "));
spans.push(Span::styled(
"-- TITLE --",
Style::default().add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" | Edit session title. Esc:Save&Exit"));
}
InputMode::Broadcast => {
spans.push(Span::raw(" "));
spans.push(Span::styled(
"-- BROADCAST --",
Style::default().add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" | Keys sent to marked panes. Esc:Back"));
}
InputMode::Scroll => {
spans.push(Span::raw(" "));
spans.push(Span::styled(
"-- SCROLL --",
Style::default().add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" | j/k:Scroll C-u/C-d:Page gg:Top G:Bottom i:Input I:Broadcast Esc:Back"));
}
}
spans
}
fn draw_right_panel(
f: &mut ratatui::Frame,
preview_contents: &[PreviewEntry],
_input_mode: InputMode,
_input_buffer: &str,
area: ratatui::layout::Rect,
selected_pane_id: Option<&str>,
preview_scroll: u16,
) -> Option<(u16, u16)> {
draw_preview_panes(f, preview_contents, area, selected_pane_id, preview_scroll)
}
fn draw_preview_panes(
f: &mut ratatui::Frame,
preview_contents: &[PreviewEntry],
area: ratatui::layout::Rect,
selected_pane_id: Option<&str>,
preview_scroll: u16,
) -> Option<(u16, u16)> {
if preview_contents.is_empty() {
let preview = Paragraph::new("No session selected").block(
Block::default()
.title("Preview")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Gray)),
);
f.render_widget(preview, area);
return None;
}
if preview_contents.len() == 1 {
let entry = &preview_contents[0];
let preview_text = entry
.content
.as_str()
.into_text()
.unwrap_or_else(|_| Text::raw(entry.content.as_str()));
#[allow(clippy::cast_possible_truncation)]
let text_lines = preview_text.lines.len() as u16;
let inner_height = area.height.saturating_sub(2);
let max_scroll = text_lines.saturating_sub(inner_height);
let effective_scroll = preview_scroll.min(max_scroll);
let scroll_y = max_scroll.saturating_sub(effective_scroll);
let mut title = preview_title(&entry.name, &entry.pane_id, &entry.title);
if preview_scroll > 0 {
title.push_str(" [SCROLL]");
}
let preview = Paragraph::new(preview_text)
.block(
Block::default()
.title(format!("{SELECTED_ICON}{title}"))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Gray)),
)
.scroll((scroll_y, 0));
let inner = Block::default().borders(Borders::ALL).inner(area);
let cursor_pos = (inner.x, inner.y + inner.height.saturating_sub(1));
f.render_widget(preview, area);
return Some(cursor_pos);
}
let n = preview_contents.len();
let (cols, rows) = compute_grid(n, area.width, MIN_PANE_WIDTH);
let row_items = grid_row_items(n, cols);
#[allow(clippy::cast_possible_truncation)]
let row_constraints: Vec<Constraint> = row_items
.iter()
.map(|_| Constraint::Ratio(1, rows as u32))
.collect();
let row_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(row_constraints)
.split(area);
let mut idx = 0;
let mut cursor_pos = None;
for (row_idx, &items_in_row) in row_items.iter().enumerate() {
#[allow(clippy::cast_possible_truncation)]
let col_constraints: Vec<Constraint> = (0..items_in_row)
.map(|_| Constraint::Ratio(1, items_in_row as u32))
.collect();
let col_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(col_constraints)
.split(row_chunks[row_idx]);
for col_idx in 0..items_in_row {
let entry = &preview_contents[idx];
let cell_area = col_chunks[col_idx];
let preview_text = entry
.content
.as_str()
.into_text()
.unwrap_or_else(|_| Text::raw(entry.content.as_str()));
#[allow(clippy::cast_possible_truncation)]
let text_lines = preview_text.lines.len() as u16;
let inner_height = cell_area.height.saturating_sub(2);
let is_focused = selected_pane_id == Some(entry.pane_id.as_str());
let scroll_y = if is_focused {
let max_scroll = text_lines.saturating_sub(inner_height);
let effective_scroll = preview_scroll.min(max_scroll);
max_scroll.saturating_sub(effective_scroll)
} else {
text_lines.saturating_sub(inner_height)
};
let title_prefix = if is_focused { SELECTED_ICON } else { "" };
let title = preview_title(&entry.name, &entry.pane_id, &entry.title);
let preview = Paragraph::new(preview_text)
.block(
Block::default()
.title(format!("{title_prefix}{title}"))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Gray)),
)
.scroll((scroll_y, 0));
if is_focused {
let inner = Block::default().borders(Borders::ALL).inner(cell_area);
cursor_pos = Some((inner.x, inner.y + inner.height.saturating_sub(1)));
}
f.render_widget(preview, cell_area);
idx += 1;
}
}
cursor_pos
}
pub const HELP_TEXT: &str = "\
Keybindings (Normal mode):
j / ↓ Move cursor down in session list
k / ↑ Move cursor up in session list
Ctrl+u Scroll preview up (half page)
Ctrl+d Scroll preview down (half page)
gg Scroll preview to top
G Scroll preview to bottom
Space Mark for preview multiple tmux panes
s Switch to tmux pane
i Enter input mode (send keys to the selected session)
I Enter input mode (send keys to all marked sessions)
e Enter title mode (set a title for the session)
o Toggle claudeye overlay (requires claudeye >= 0.7.0)
? Show this help
q Quit crmux
Keybindings (Scroll mode):
j / ↓ Scroll preview down (1 line)
k / ↑ Scroll preview up (1 line)
Ctrl+u Scroll preview up (half page)
Ctrl+d Scroll preview down (half page)
gg Scroll preview to top
G Scroll preview to bottom (exit scroll mode)
i Enter input mode (reset scroll)
I Enter broadcast mode (reset scroll)
Esc Reset scroll and return to normal mode
Keybindings (Input mode):
Esc Return to normal mode
Any other key Forwarded to the tmux pane via send-keys
Keybindings (Broadcast mode):
Esc Return to normal mode
Any other key Forwarded to all marked panes via send-keys
Keybindings (Title mode):
Esc Save and return to normal mode
Backspace Delete the last character";
fn draw_help_popup(f: &mut ratatui::Frame, area: Rect, help_scroll: u16) {
let popup_width = area.width.min(65);
let popup_height = area.height.saturating_sub(4).min(40);
let x = (area.width.saturating_sub(popup_width)) / 2;
let y = (area.height.saturating_sub(popup_height)) / 2;
let popup_area = Rect::new(x, y, popup_width, popup_height);
f.render_widget(Clear, popup_area);
let block = Block::default()
.title(" Help (? to close, j/k to scroll) ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let paragraph = Paragraph::new(HELP_TEXT)
.block(block)
.wrap(Wrap { trim: false })
.scroll((help_scroll, 0));
f.render_widget(paragraph, popup_area);
}
fn draw_left_panel(
f: &mut ratatui::Frame,
sessions: &[ManagedSession],
area: ratatui::layout::Rect,
selected_index: usize,
input_mode: InputMode,
input_buffer: &str,
) {
draw_sessions_list(f, sessions, area, selected_index, input_mode, input_buffer);
}
fn draw_sessions_list(
f: &mut ratatui::Frame,
sessions: &[ManagedSession],
area: ratatui::layout::Rect,
selected_index: usize,
input_mode: InputMode,
input_buffer: &str,
) {
let block = Block::default()
.title(format!("Sessions ({})", sessions.len()))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::White));
let inner_area = block.inner(area);
f.render_widget(block, area);
if sessions.is_empty() {
let empty_msg = Paragraph::new("No Claude sessions detected")
.style(Style::default().fg(Color::DarkGray));
f.render_widget(empty_msg, inner_area);
return;
}
let constraints: Vec<Constraint> = sessions.iter().map(|_| Constraint::Length(3)).collect();
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(inner_area);
for (idx, session) in sessions.iter().enumerate() {
if idx >= layout.len() {
break;
}
let is_selected = idx == selected_index;
let elapsed_secs = session.state_changed_at.elapsed().as_secs();
let is_pulsing = should_pulse(&session.state, elapsed_secs);
let color = state_color(&session.state);
let elapsed = format_elapsed(session.state_changed_at);
let label = state_label(&session.state);
let text_color = color;
let mark_indicator = if session.marked { "* " } else { " " };
let border_style = Style::default().fg(color);
let bg_style = if is_pulsing {
Style::default().bg(pulse_bg_color(state_color(&session.state)))
} else {
Style::default()
};
let title_prefix = if is_selected { SELECTED_ICON } else { "" };
let mut title_spans = vec![
Span::styled(
format!("{title_prefix}{}", &session.project_name),
Style::default().fg(text_color).add_modifier(Modifier::BOLD),
),
];
if let Some(ref branch) = session.git_branch {
title_spans.push(Span::styled(
format!(" ({branch})"),
Style::default().fg(Color::DarkGray),
));
}
let project_title = Line::from(title_spans);
let mark_span = Span::styled(mark_indicator, Style::default().fg(Color::Green).add_modifier(Modifier::BOLD));
let mut status_spans = Vec::new();
if let Some(ref model) = session.model {
let model_text = session.context_percent.map_or_else(
|| model.clone(),
|pct| format!("{model} ({pct}%)"),
);
status_spans.push(Span::styled(
model_text,
Style::default().fg(Color::DarkGray),
));
status_spans.push(Span::raw(" "));
}
status_spans.push(Span::styled(label, Style::default().fg(text_color)));
status_spans.push(Span::raw(" "));
status_spans.push(Span::styled(elapsed, Style::default().fg(text_color)));
let status_line = Line::from(status_spans);
let is_editing_title = is_selected && input_mode == InputMode::Title;
let combined_line = if is_editing_title {
let max_width = layout[idx].width.saturating_sub(4) as usize; let (display_text, text_color) = if input_buffer.is_empty() {
("Type a title".to_string(), Color::DarkGray)
} else {
(truncate_title(input_buffer, max_width), Color::Yellow)
};
Line::from(vec![
mark_span,
Span::styled(display_text, Style::default().fg(text_color)),
])
} else if let Some(display) = session.display_title() {
let max_width = layout[idx].width.saturating_sub(4) as usize; let truncated = truncate_title(display, max_width);
Line::from(vec![
mark_span,
Span::styled(truncated, Style::default().fg(TITLE_COLOR)),
])
} else {
Line::from(vec![
mark_span,
Span::styled("Press e to edit title", Style::default().fg(TITLE_COLOR)),
])
};
let paragraph = Paragraph::new(vec![combined_line]);
let card_border_style = if is_editing_title {
Style::default().fg(Color::Yellow)
} else {
border_style
};
let block = Block::default()
.title(project_title)
.title_bottom(status_line.right_aligned())
.borders(Borders::ALL)
.border_style(card_border_style);
let paragraph = paragraph.block(block).style(bg_style);
f.render_widget(paragraph, layout[idx]);
if is_editing_title {
let inner = Block::default().borders(Borders::ALL).inner(layout[idx]);
#[allow(clippy::cast_possible_truncation)]
let cursor_x = inner.x + 2 + input_buffer.chars().count().min((inner.width.saturating_sub(2)) as usize) as u16;
let cursor_y = inner.y;
f.set_cursor_position((cursor_x, cursor_y));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_elapsed_seconds() {
let now = Instant::now();
let result = format_elapsed(now);
assert_eq!(result, "0s");
}
#[test]
fn test_format_elapsed_minutes() {
let since = Instant::now() - std::time::Duration::from_secs(120);
assert_eq!(format_elapsed(since), "2m");
}
#[test]
fn test_format_elapsed_hours() {
let since = Instant::now() - std::time::Duration::from_secs(7200);
assert_eq!(format_elapsed(since), "2h");
}
#[test]
fn test_format_elapsed_boundary_59s() {
let since = Instant::now() - std::time::Duration::from_secs(59);
assert_eq!(format_elapsed(since), "59s");
}
#[test]
fn test_format_elapsed_boundary_60s() {
let since = Instant::now() - std::time::Duration::from_secs(60);
assert_eq!(format_elapsed(since), "1m");
}
#[test]
fn test_format_elapsed_boundary_3599s() {
let since = Instant::now() - std::time::Duration::from_secs(3599);
assert_eq!(format_elapsed(since), "59m");
}
#[test]
fn test_format_elapsed_boundary_3600s() {
let since = Instant::now() - std::time::Duration::from_secs(3600);
assert_eq!(format_elapsed(since), "1h");
}
#[test]
fn test_state_color() {
assert_eq!(state_color(&ClaudeState::Working), Color::Blue);
assert_eq!(
state_color(&ClaudeState::WaitingForApproval),
Color::LightRed
);
assert_eq!(state_color(&ClaudeState::Idle), Color::White);
}
#[test]
fn test_state_label() {
assert_eq!(state_label(&ClaudeState::Working), "Running");
assert_eq!(state_label(&ClaudeState::WaitingForApproval), "Approval");
assert_eq!(state_label(&ClaudeState::Idle), "Idle");
}
#[test]
fn test_ansi_to_text_plain() {
let text = "hello world".into_text().unwrap();
assert_eq!(text.lines.len(), 1);
}
#[test]
fn test_ansi_to_text_with_colors() {
let ansi = "\x1b[31mred\x1b[0m normal";
let text = ansi.into_text().unwrap();
assert!(!text.lines.is_empty());
}
#[test]
fn test_should_pulse_approval() {
assert!(should_pulse(&ClaudeState::WaitingForApproval, 0));
assert!(should_pulse(&ClaudeState::WaitingForApproval, 100));
}
#[test]
fn test_should_pulse_idle_stale() {
assert!(should_pulse(&ClaudeState::Idle, 5));
assert!(should_pulse(&ClaudeState::Idle, 10));
assert!(should_pulse(&ClaudeState::Idle, 15));
}
#[test]
fn test_should_pulse_idle_not_stale() {
assert!(!should_pulse(&ClaudeState::Idle, 4));
assert!(!should_pulse(&ClaudeState::Idle, 16));
}
#[test]
fn test_should_pulse_working() {
assert!(!should_pulse(&ClaudeState::Working, 0));
assert!(!should_pulse(&ClaudeState::Working, 10));
}
#[test]
fn test_color_to_rgb() {
assert_eq!(color_to_rgb(Color::Blue), (0, 0, 255));
assert_eq!(color_to_rgb(Color::LightRed), (255, 100, 100));
assert_eq!(color_to_rgb(Color::White), (255, 255, 255));
}
#[test]
fn test_pulse_bg_color_returns_rgb() {
let result = pulse_bg_color(Color::LightRed);
assert!(matches!(result, Color::Rgb(_, _, _)));
}
#[test]
fn test_selected_icon_is_not_empty() {
assert!(!SELECTED_ICON.is_empty());
}
#[test]
fn test_preview_title_with_title() {
assert_eq!(preview_title("crmux", "%1", &Some("development".to_string())), "crmux - development");
}
#[test]
fn test_preview_title_without_title() {
assert_eq!(preview_title("crmux", "%1", &None), "crmux - %1");
}
#[test]
fn test_preview_title_with_empty_title() {
assert_eq!(preview_title("crmux", "%1", &Some("".to_string())), "crmux - %1");
}
#[test]
fn test_footer_normal_mode_starts_with_app_name() {
let spans = footer_spans(InputMode::Normal);
assert!(spans[0].content.starts_with("crmux v"));
}
#[test]
fn test_footer_normal_mode_has_no_mode_indicator() {
let spans = footer_spans(InputMode::Normal);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(!text.contains("--"));
}
#[test]
fn test_footer_input_mode_starts_with_app_name() {
let spans = footer_spans(InputMode::Input);
assert!(spans[0].content.starts_with("crmux v"));
}
#[test]
fn test_footer_input_mode_has_insert_indicator() {
let spans = footer_spans(InputMode::Input);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("-- INSERT --"));
}
#[test]
fn test_footer_title_mode_starts_with_app_name() {
let spans = footer_spans(InputMode::Title);
assert!(spans[0].content.starts_with("crmux v"));
}
#[test]
fn test_footer_title_mode_has_title_edit_indicator() {
let spans = footer_spans(InputMode::Title);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("-- TITLE --"));
}
#[test]
fn test_truncate_short_title() {
assert_eq!(truncate_title("short", 20), "short");
}
#[test]
fn test_truncate_exact_length() {
assert_eq!(truncate_title("abcde", 5), "abcde");
}
#[test]
fn test_truncate_long_title() {
assert_eq!(truncate_title("abcdef", 5), "abcd…");
}
#[test]
fn test_truncate_multibyte() {
assert_eq!(truncate_title("あいうえお", 4), "あいう…");
}
#[test]
fn test_truncate_empty() {
assert_eq!(truncate_title("", 10), "");
}
#[test]
fn test_footer_normal_mode_contains_help_key() {
let spans = footer_spans(InputMode::Normal);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("?:Help"), "Normal mode footer should contain '?:Help', got: {text}");
}
#[test]
fn test_footer_broadcast_mode_has_broadcast_indicator() {
let spans = footer_spans(InputMode::Broadcast);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("-- BROADCAST --"), "Broadcast mode footer should contain '-- BROADCAST --', got: {text}");
}
#[test]
fn test_footer_normal_mode_contains_broadcast_key() {
let spans = footer_spans(InputMode::Normal);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("I:Input(marked)"), "Normal mode footer should contain 'I:Input(marked)', got: {text}");
}
#[test]
fn test_footer_normal_mode_contains_scroll_keys() {
let spans = footer_spans(InputMode::Normal);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("C-u/C-d:Scroll"), "Normal mode footer should contain 'C-u/C-d:Scroll', got: {text}");
}
#[test]
fn test_footer_normal_mode_contains_g_bottom() {
let spans = footer_spans(InputMode::Normal);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("G:Bottom"), "Normal mode footer should contain 'G:Bottom', got: {text}");
}
#[test]
fn test_compute_grid_single_pane() {
assert_eq!(compute_grid(1, 200, MIN_PANE_WIDTH), (1, 1));
}
#[test]
fn test_compute_grid_horizontal_fit() {
assert_eq!(compute_grid(2, 160, MIN_PANE_WIDTH), (2, 1));
}
#[test]
fn test_compute_grid_grid_layout() {
assert_eq!(compute_grid(4, 160, MIN_PANE_WIDTH), (2, 2));
}
#[test]
fn test_compute_grid_wide_screen() {
assert_eq!(compute_grid(4, 320, MIN_PANE_WIDTH), (4, 1));
}
#[test]
fn test_compute_grid_narrow_screen() {
assert_eq!(compute_grid(3, 79, MIN_PANE_WIDTH), (1, 3));
}
#[test]
fn test_compute_grid_boundary_exact() {
assert_eq!(compute_grid(1, 80, MIN_PANE_WIDTH), (1, 1));
}
#[test]
fn test_compute_grid_boundary_two_panes() {
assert_eq!(compute_grid(3, 160, MIN_PANE_WIDTH), (2, 2));
}
#[test]
fn test_compute_grid_zero_panes() {
assert_eq!(compute_grid(0, 200, MIN_PANE_WIDTH), (1, 0));
}
#[test]
fn test_grid_row_items_even_split() {
assert_eq!(grid_row_items(4, 2), vec![2, 2]);
}
#[test]
fn test_grid_row_items_remainder() {
assert_eq!(grid_row_items(3, 2), vec![2, 1]);
}
#[test]
fn test_grid_row_items_single_column() {
assert_eq!(grid_row_items(3, 1), vec![1, 1, 1]);
}
#[test]
fn test_grid_row_items_all_in_one_row() {
assert_eq!(grid_row_items(3, 3), vec![3]);
}
#[test]
fn test_grid_row_items_5_panes_3_cols() {
assert_eq!(grid_row_items(5, 3), vec![3, 2]);
}
#[test]
fn test_footer_normal_mode_contains_claudeye_key() {
let spans = footer_spans(InputMode::Normal);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("o:Claudeye"), "Normal mode footer should contain 'o:Claudeye', got: {text}");
}
#[test]
fn test_footer_spans_input_mode_not_empty() {
let spans = footer_spans(InputMode::Input);
let text_len: usize = spans.iter().map(|s| s.content.chars().count()).sum();
assert!(text_len > 0, "Input mode footer should produce non-empty text");
}
#[test]
fn test_footer_spans_broadcast_mode_not_empty() {
let spans = footer_spans(InputMode::Broadcast);
let text_len: usize = spans.iter().map(|s| s.content.chars().count()).sum();
assert!(text_len > 0, "Broadcast mode footer should produce non-empty text");
}
#[test]
fn test_pulse_bg_color_within_intensity_range() {
let base = Color::White; let result = pulse_bg_color(base);
if let Color::Rgb(r, g, b) = result {
assert!(r <= 63, "r={r} exceeds max intensity");
assert!(g <= 63, "g={g} exceeds max intensity");
assert!(b <= 63, "b={b} exceeds max intensity");
} else {
panic!("Expected Color::Rgb");
}
}
#[test]
fn test_footer_scroll_mode_has_scroll_indicator() {
let spans = footer_spans(InputMode::Scroll);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("-- SCROLL --"), "Scroll mode footer should contain '-- SCROLL --', got: {text}");
}
#[test]
fn test_footer_scroll_mode_starts_with_app_name() {
let spans = footer_spans(InputMode::Scroll);
assert!(spans[0].content.starts_with("crmux v"));
}
#[test]
fn test_footer_scroll_mode_contains_keybindings() {
let spans = footer_spans(InputMode::Scroll);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("j/k:Scroll"), "Scroll mode footer should contain 'j/k:Scroll', got: {text}");
assert!(text.contains("Esc:Back"), "Scroll mode footer should contain 'Esc:Back', got: {text}");
}
}