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, PermissionMode};
use crate::state::{InputMode, LayoutMode, ManagedSession, PreviewEntry, Tab, TabState};
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(200, 200, 200);
const TAB_INACTIVE_COLOR: Color = Color::Rgb(140, 140, 140);
const TAB_ARROW_COLOR: Color = Color::Rgb(140, 140, 140);
const EMPTY_MESSAGE_COLOR: Color = Color::Rgb(180, 180, 180);
const MODEL_COLOR: Color = Color::Rgb(140, 140, 140);
const PLACEHOLDER_COLOR: Color = Color::Rgb(100, 100, 100);
pub fn should_pulse(state: &ClaudeState, elapsed_secs: u64, has_worked: bool) -> bool {
matches!(state, ClaudeState::WaitingForApproval)
|| (matches!(state, ClaudeState::Idle)
&& has_worked
&& (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_border_color(base: Color) -> Color {
let factor = f64::midpoint(pulse_factor(), 1.0); let (r, g, b) = color_to_rgb(base);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
Color::Rgb(
(f64::from(r) * factor) as u8,
(f64::from(g) * factor) as u8,
(f64::from(b) * factor) as u8,
)
}
pub fn format_elapsed(since: Instant) -> String {
let secs = since.elapsed().as_secs();
if secs < 60 {
format!("{secs:2}s")
} else if secs < 3600 {
format!("{:2}m", secs / 60)
} else {
let hours = (secs / 3600).min(99);
format!("{hours:2}h")
}
}
pub const fn state_color(state: &ClaudeState) -> Color {
match state {
ClaudeState::Working => Color::Green,
ClaudeState::WaitingForApproval => Color::Rgb(255, 165, 0),
ClaudeState::Idle => Color::White,
}
}
pub const fn permission_mode_icon(mode: &PermissionMode) -> &'static str {
match mode {
PermissionMode::PlanMode => "⏸ ",
PermissionMode::EditAutomatically => "⏵⏵",
PermissionMode::AskBeforeEdits => "",
}
}
pub const fn state_label(state: &ClaudeState) -> &'static str {
match state {
ClaudeState::Working => "⚡",
ClaudeState::WaitingForApproval => "⚠",
ClaudeState::Idle => "💤",
}
}
fn format_title(name: &str, number: Option<usize>, title: Option<&String>, git_branch: Option<&String>, worktree_name: Option<&String>, base_style: Style) -> Line<'static> {
let is_worktree = worktree_name.is_some();
let style = if is_worktree { Style::default().fg(Color::Cyan) } else { base_style };
let number_part = match number {
Some(idx) if idx < crate::state::MAX_NUMBER_KEYS => format!("{}.", idx + 1),
_ => String::new(),
};
let branch_part = match (git_branch, worktree_name) {
(Some(b), _) => format!("/{b}"),
(None, Some(wt)) => format!("/{wt}"),
(None, None) => String::new(),
};
let suffix_part = match title {
Some(t) if !t.is_empty() => format!(" - {t}"),
_ => String::new(),
};
let text = format!("{number_part}{name}{branch_part}{suffix_part}");
Line::from(Span::styled(text, style))
}
#[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,
tab_state: &TabState,
layout_mode: LayoutMode,
update_available: Option<&str>,
) {
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, tab_state);
let selected_pane_id = sessions
.get(selected_index)
.map(|s| s.pane_id.as_str());
let preview_cursor = draw_preview_panes(f, preview_contents, h_chunks[1], selected_pane_id, preview_scroll, layout_mode);
let footer_line = footer_spans(input_mode, layout_mode, update_available);
let instructions = Paragraph::new(Line::from(footer_line))
.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)
&& let Some((cx, cy)) = preview_cursor
{
f.set_cursor_position((cx, cy));
crossterm::execute!(std::io::stdout(), crossterm::cursor::SetCursorStyle::SteadyBar).ok();
} else {
crossterm::execute!(std::io::stdout(), crossterm::cursor::SetCursorStyle::DefaultUserShape).ok();
}
if show_help {
draw_help_popup(f, size, help_scroll);
}
}
fn footer_spans(input_mode: InputMode, layout_mode: LayoutMode, update_available: Option<&str>) -> Vec<Span<'static>> {
let mut spans = vec![Span::styled(
concat!("crmux v", env!("CARGO_PKG_VERSION")),
Style::default().fg(Color::White),
)];
if let Some(v) = update_available {
spans.push(Span::styled(
format!(" ({v} available! Run: crmux update)"),
Style::default().fg(Color::Yellow),
));
}
let input_keys = "i:Insert I:Broadcast";
match input_mode {
InputMode::Normal => {
let next_label = layout_mode.next().short_label();
let v_label = format!("v:{next_label}");
spans.push(Span::raw(format!(" | hjkl/1-9/t:Nav C-u:ScrollUp s:Switch(leave crmux) Space:Mark {v_label} {input_keys} 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 all visible panes. Esc:Back"));
}
InputMode::Scroll => {
spans.push(Span::raw(" "));
spans.push(Span::styled(
"-- SCROLL --",
Style::default().add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(format!(" | j/k:Line C-u/C-d:HalfPage gg:Top G:Bottom {input_keys} Esc:Back")));
}
}
spans
}
fn compute_cursor_pos(inner: Rect, cursor_pos: Option<(u16, u16)>, scroll_y: u16) -> Option<(u16, u16)> {
let (crow, ccol) = cursor_pos?;
let y = inner.y + crow.saturating_sub(scroll_y);
let x = inner.x + ccol;
Some((
x.min(inner.x + inner.width.saturating_sub(1)),
y.min(inner.y + inner.height.saturating_sub(1)),
))
}
#[allow(clippy::too_many_lines)]
fn git_diff_line(info: &crate::state::GitDiffInfo) -> Line<'static> {
let green = Style::default().fg(Color::Rgb(63, 185, 80));
let yellow = Style::default().fg(Color::Rgb(210, 153, 34));
let red = Style::default().fg(Color::Rgb(248, 81, 73));
let gray = Style::default().fg(Color::Gray);
if info.staged_files == 0 && info.modified_files == 0 {
return Line::from(vec![Span::styled(" no changes ", gray)]).right_aligned();
}
let mut spans = vec![Span::styled(" ", gray)];
if info.staged_files > 0 {
spans.push(Span::styled(format!("+{}", info.staged_files), green));
spans.push(Span::raw(" "));
}
if info.modified_files > 0 {
spans.push(Span::styled(format!("~{}", info.modified_files), yellow));
spans.push(Span::raw(" "));
}
spans.push(Span::styled("(", gray));
spans.push(Span::styled(format!("+{}", info.insertions), green));
spans.push(Span::styled(" ", gray));
spans.push(Span::styled(format!("-{}", info.deletions), red));
spans.push(Span::styled(") ", gray));
Line::from(spans).right_aligned()
}
fn draw_preview_panes(
f: &mut ratatui::Frame,
preview_contents: &[PreviewEntry],
area: ratatui::layout::Rect,
selected_pane_id: Option<&str>,
preview_scroll: u16,
layout_mode: LayoutMode,
) -> 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 color = state_color(&entry.state);
let mut title_line = format_title(&entry.name, Some(entry.index), entry.title.as_ref(), entry.git_branch.as_ref(), entry.worktree_name.as_ref(), Style::default().fg(color));
let mut prefix_spans = vec![Span::styled(SELECTED_ICON.to_string(), Style::default().fg(color))];
prefix_spans.extend(title_line.spans);
if preview_scroll > 0 {
prefix_spans.push(Span::styled(" [SCROLL]", Style::default().fg(color)));
}
title_line = Line::from(prefix_spans);
let mut block = Block::default()
.title(title_line)
.borders(Borders::ALL)
.border_style(Style::default().fg(color));
if let Some(info) = entry.git_diff.as_ref() {
block = block.title_bottom(git_diff_line(info));
}
let preview = Paragraph::new(preview_text)
.block(block)
.scroll((scroll_y, 0));
let inner = Block::default().borders(Borders::ALL).inner(area);
let cursor_pos = compute_cursor_pos(inner, entry.cursor_pos, scroll_y);
f.render_widget(preview, area);
return cursor_pos;
}
let main_content_width = if layout_mode == LayoutMode::MainVertical && !preview_contents.is_empty() {
#[allow(clippy::cast_possible_truncation)]
let max_w = preview_contents[0].content.lines()
.map(|line| {
let stripped = crate::app::strip_ansi_for_prompt(line);
unicode_width::UnicodeWidthStr::width(stripped.as_str()) as u16
})
.max()
.unwrap_or(0);
Some(max_w)
} else {
None
};
let cell_areas = compute_cell_areas(preview_contents.len(), area, layout_mode, main_content_width);
let mut cursor_pos = None;
for (i, entry) in preview_contents.iter().enumerate() {
let cell_area = cell_areas[i];
let pos = render_preview_cell(f, entry, cell_area, selected_pane_id, preview_scroll);
if pos.is_some() {
cursor_pos = pos;
}
}
cursor_pos
}
#[allow(clippy::cast_possible_truncation)]
fn compute_cell_areas(n: usize, area: Rect, layout_mode: LayoutMode, main_content_width: Option<u16>) -> Vec<Rect> {
match layout_mode {
LayoutMode::EvenHorizontal | LayoutMode::EvenVertical => {
let direction = match layout_mode {
LayoutMode::EvenHorizontal => Direction::Horizontal,
_ => Direction::Vertical,
};
let constraints: Vec<Constraint> = (0..n)
.map(|_| Constraint::Ratio(1, n as u32))
.collect();
Layout::default()
.direction(direction)
.constraints(constraints)
.split(area)
.to_vec()
}
LayoutMode::MainVertical | LayoutMode::MainHorizontal => {
if n <= 1 {
return vec![area];
}
let (main_direction, sub_direction, main_pct, sub_pct) = match layout_mode {
LayoutMode::MainVertical => {
let pct = match main_content_width {
Some(w) if area.width > 0 => {
let needed = (u32::from(w) + 2) * 100 / u32::from(area.width);
#[allow(clippy::cast_possible_truncation)]
let needed_u16 = needed as u16;
needed_u16.clamp(60, 80)
}
_ => 60,
};
(Direction::Horizontal, Direction::Vertical, pct, 100 - pct)
}
_ => (Direction::Vertical, Direction::Horizontal, 60, 40),
};
let main_split = Layout::default()
.direction(main_direction)
.constraints([
Constraint::Percentage(main_pct),
Constraint::Percentage(sub_pct),
])
.split(area);
let mut areas = vec![main_split[0]];
let sub_count = n - 1;
let sub_constraints: Vec<Constraint> = (0..sub_count)
.map(|_| Constraint::Ratio(1, sub_count as u32))
.collect();
let sub_areas = Layout::default()
.direction(sub_direction)
.constraints(sub_constraints)
.split(main_split[1]);
areas.extend(sub_areas.iter());
areas
}
LayoutMode::Single | LayoutMode::Grid => {
let (cols, rows) = compute_grid(n, area.width, MIN_PANE_WIDTH);
let row_items = grid_row_items(n, cols);
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 areas = Vec::with_capacity(n);
for (row_idx, &items_in_row) in row_items.iter().enumerate() {
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 {
areas.push(col_chunks[col_idx]);
}
}
areas
}
}
}
fn render_preview_cell(
f: &mut ratatui::Frame,
entry: &PreviewEntry,
cell_area: Rect,
selected_pane_id: Option<&str>,
preview_scroll: u16,
) -> Option<(u16, u16)> {
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 color = state_color(&entry.state);
let title_line = format_title(&entry.name, Some(entry.index), entry.title.as_ref(), entry.git_branch.as_ref(), entry.worktree_name.as_ref(), Style::default().fg(color));
let mut title_spans = vec![Span::styled(title_prefix.to_string(), Style::default().fg(color))];
title_spans.extend(title_line.spans);
let elapsed_secs = entry.state_changed_at.elapsed().as_secs();
let is_pulsing = should_pulse(&entry.state, elapsed_secs, entry.has_worked);
let border_color = if is_pulsing && !is_focused {
pulse_border_color(color)
} else {
color
};
let mut block = Block::default()
.title(Line::from(title_spans))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
if let Some(info) = entry.git_diff.as_ref() {
block = block.title_bottom(git_diff_line(info));
}
let preview = Paragraph::new(preview_text)
.block(block)
.scroll((scroll_y, 0));
let cursor = if is_focused {
let inner = Block::default().borders(Borders::ALL).inner(cell_area);
compute_cursor_pos(inner, entry.cursor_pos, scroll_y)
} else {
None
};
f.render_widget(preview, cell_area);
cursor
}
fn draw_tab_bar(f: &mut ratatui::Frame, tab_state: &TabState, area: Rect) {
let width = area.width as usize;
if width == 0 || tab_state.tabs.is_empty() {
return;
}
let labels: Vec<String> = tab_state
.tabs
.iter()
.enumerate()
.map(|(i, tab)| {
let name = match tab {
Tab::All => "All".to_string(),
Tab::Workspace(w) => format!("@{w}"),
Tab::Marked => "Marked".to_string(),
Tab::Project(p) => format!("+{p}"),
};
if i == tab_state.selected_tab {
format!("[{name}]")
} else {
name
}
})
.collect();
let total_width: usize = labels.iter().map(|l| l.chars().count()).sum::<usize>()
+ labels.len().saturating_sub(1);
let mut spans = Vec::new();
if total_width <= width {
for (i, label) in labels.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
}
let style = if i == tab_state.selected_tab {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default().fg(TAB_INACTIVE_COLOR)
};
spans.push(Span::styled(label.clone(), style));
}
} else {
let mut tab_positions: Vec<(usize, usize)> = Vec::new(); let mut pos = 0;
for (i, label) in labels.iter().enumerate() {
if i > 0 {
pos += 1; }
let len = label.chars().count();
tab_positions.push((pos, pos + len));
pos += len;
}
let sel = tab_state.selected_tab;
let (sel_start, sel_end) = tab_positions[sel];
let sel_mid = usize::midpoint(sel_start, sel_end);
let view_start = sel_mid.saturating_sub(width / 2);
let view_end = view_start + width;
let has_left = view_start > 0;
let has_right = view_end < total_width;
let effective_start = if has_left { view_start + 2 } else { view_start };
let effective_end = if has_right { view_end - 2 } else { view_end };
if has_left {
spans.push(Span::styled("< ", Style::default().fg(TAB_ARROW_COLOR)));
}
for (i, label) in labels.iter().enumerate() {
let (start, end) = tab_positions[i];
if i > 0 {
let sep_pos = start - 1;
if sep_pos >= effective_start && sep_pos < effective_end {
spans.push(Span::raw(" "));
}
}
if end > effective_start && start < effective_end {
let style = if i == tab_state.selected_tab {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default().fg(TAB_INACTIVE_COLOR)
};
let label_start = effective_start.saturating_sub(start);
let label_end = if end > effective_end { effective_end - start } else { label.chars().count() };
if label_start < label_end {
let visible: String = label.chars().skip(label_start).take(label_end - label_start).collect();
spans.push(Span::styled(visible, style));
}
}
}
if has_right {
spans.push(Span::styled(" >", Style::default().fg(TAB_ARROW_COLOR)));
}
}
let line = Line::from(spans);
let paragraph = Paragraph::new(line);
f.render_widget(paragraph, area);
}
pub const HELP_TEXT: &str = "\
Keybindings (Normal mode):
h / ← / l / → Switch project tab
j / ↓ Move cursor down in session list
k / ↑ Move cursor up in session list
Ctrl+u Scroll preview up (enter scroll mode)
1-9 Select session by number
t Switch to previously selected session
s Switch to tmux pane
Space Mark session (for filtering)
v Cycle layout (MainV/Single/Grid/EvenH/EvenV/MainH)
i Enter input mode (send keys to the selected session)
I Enter broadcast mode (send keys to all visible 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)
Esc Reset scroll and return to normal mode
Keybindings (Input mode):
Esc Return to normal mode
Keybindings (Broadcast mode):
Esc Return to normal mode
Keybindings (Title mode):
Esc Save and return to normal mode";
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,
tab_state: &TabState,
) {
draw_sessions_list(f, sessions, area, selected_index, input_mode, input_buffer, tab_state);
}
#[allow(clippy::too_many_lines)]
fn draw_sessions_list(
f: &mut ratatui::Frame,
sessions: &[ManagedSession],
area: ratatui::layout::Rect,
selected_index: usize,
input_mode: InputMode,
input_buffer: &str,
tab_state: &TabState,
) {
let block_title = match tab_state.current_tab() {
Tab::All => format!("All Sessions ({})", sessions.len()),
Tab::Marked => format!("Marked ({})", sessions.len()),
Tab::Workspace(name) => format!("Workspace @{name} ({})", sessions.len()),
Tab::Project(name) => format!("Project {name} ({})", sessions.len()),
};
let block = Block::default()
.title(block_title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::White));
let full_inner = block.inner(area);
f.render_widget(block, area);
let has_tabs = tab_state.tabs.len() > 1;
let (tab_area, inner_area) = if has_tabs {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(full_inner);
draw_tab_bar(f, tab_state, chunks[0]);
(Some(chunks[0]), chunks[1])
} else {
(None, full_inner)
};
let _ = tab_area;
if sessions.is_empty() {
let empty_msg = Paragraph::new("No Claude sessions detected")
.style(Style::default().fg(EMPTY_MESSAGE_COLOR));
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, session.has_worked);
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(
if is_pulsing { pulse_border_color(color) } else { color }
);
let title_prefix = if is_selected { SELECTED_ICON } else { "" };
let base_title_style = Style::default().fg(text_color).add_modifier(Modifier::BOLD);
let card_title = format_title(&session.project_name, Some(idx), None, session.git_branch.as_ref(), session.worktree_name.as_ref(), base_title_style);
let mut title_spans = vec![Span::styled(title_prefix.to_string(), base_title_style)];
title_spans.extend(card_title.spans);
let project_title = Line::from(title_spans);
let mark_span = Span::styled(mark_indicator, Style::default().fg(Color::Green).add_modifier(Modifier::BOLD));
let model_line = session.model.as_ref().map_or_else(
|| Line::from(""),
|model| {
let compact_model = model.replace(' ', "");
let model_text = session.context_percent.map_or_else(
|| compact_model.clone(),
|pct| format!("{compact_model}({pct}%)"),
);
Line::from(Span::styled(model_text, Style::default().fg(MODEL_COLOR)))
},
);
let mut status_spans = Vec::new();
let mode_icon = permission_mode_icon(&session.permission_mode);
if !mode_icon.is_empty() {
status_spans.push(Span::styled(
mode_icon,
Style::default().fg(Color::Cyan),
));
status_spans.push(Span::raw(" "));
}
let status_fg = Style::default().fg(color);
status_spans.push(Span::styled(label, status_fg));
status_spans.push(Span::styled(" ", status_fg));
status_spans.push(Span::styled(elapsed, status_fg));
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 (display_text, text_color) = if input_buffer.is_empty() {
("Type a title".to_string(), PLACEHOLDER_COLOR)
} else {
(input_buffer.to_string(), Color::Yellow)
};
Line::from(vec![
mark_span,
Span::styled(display_text, Style::default().fg(text_color)),
])
} else if let Some(display) = session.display_title() {
Line::from(vec![
mark_span,
Span::styled(display, Style::default().fg(TITLE_COLOR)),
])
} else {
Line::from(vec![
mark_span,
Span::styled("Press e to edit title", Style::default().fg(PLACEHOLDER_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(model_line.left_aligned())
.title_bottom(status_line.right_aligned())
.borders(Borders::ALL)
.border_style(card_border_style);
let paragraph = paragraph.block(block);
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_format_elapsed_capped_at_99h() {
let since = Instant::now() - std::time::Duration::from_secs(100 * 3600);
assert_eq!(format_elapsed(since), "99h");
}
#[test]
fn test_state_color() {
assert_eq!(state_color(&ClaudeState::Working), Color::Green);
assert_eq!(
state_color(&ClaudeState::WaitingForApproval),
Color::Rgb(255, 165, 0)
);
assert_eq!(state_color(&ClaudeState::Idle), Color::White);
}
#[test]
fn test_state_label() {
assert_eq!(state_label(&ClaudeState::Working), "⚡");
assert_eq!(state_label(&ClaudeState::WaitingForApproval), "⚠");
assert_eq!(state_label(&ClaudeState::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, false));
assert!(should_pulse(&ClaudeState::WaitingForApproval, 100, true));
}
#[test]
fn test_should_pulse_idle_stale_after_work() {
assert!(should_pulse(&ClaudeState::Idle, 5, true));
assert!(should_pulse(&ClaudeState::Idle, 10, true));
assert!(should_pulse(&ClaudeState::Idle, 15, true));
}
#[test]
fn test_should_pulse_idle_stale_never_worked() {
assert!(!should_pulse(&ClaudeState::Idle, 5, false));
assert!(!should_pulse(&ClaudeState::Idle, 10, false));
assert!(!should_pulse(&ClaudeState::Idle, 15, false));
}
#[test]
fn test_should_pulse_idle_not_stale() {
assert!(!should_pulse(&ClaudeState::Idle, 4, true));
assert!(!should_pulse(&ClaudeState::Idle, 16, true));
}
#[test]
fn test_should_pulse_working() {
assert!(!should_pulse(&ClaudeState::Working, 0, false));
assert!(!should_pulse(&ClaudeState::Working, 10, true));
}
#[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_compute_cursor_pos_with_position() {
let inner = Rect::new(10, 20, 80, 40);
assert_eq!(compute_cursor_pos(inner, Some((5, 3)), 0), Some((13, 25)));
}
#[test]
fn test_compute_cursor_pos_none_returns_none() {
let inner = Rect::new(10, 20, 80, 40);
assert_eq!(compute_cursor_pos(inner, None, 0), None);
}
#[test]
fn test_compute_cursor_pos_with_scroll() {
let inner = Rect::new(10, 20, 80, 40);
assert_eq!(compute_cursor_pos(inner, Some((5, 3)), 3), Some((13, 22)));
}
#[test]
fn test_selected_icon_is_not_empty() {
assert!(!SELECTED_ICON.is_empty());
}
fn line_text(line: &Line) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn test_format_title_with_title() {
let base = Style::default().fg(Color::White);
let title = "development".to_string();
let line = format_title("crmux", None, Some(&title), None, None, base);
assert_eq!(line_text(&line), "crmux - development");
assert_eq!(line.spans[0].style, base);
}
#[test]
fn test_format_title_without_title() {
let base = Style::default().fg(Color::White);
let line = format_title("crmux", None, None, None, None, base);
assert_eq!(line_text(&line), "crmux");
}
#[test]
fn test_format_title_with_empty_title() {
let base = Style::default().fg(Color::White);
let title = String::new();
let line = format_title("crmux", None, Some(&title), None, None, base);
assert_eq!(line_text(&line), "crmux");
}
#[test]
fn test_format_title_with_branch() {
let base = Style::default().fg(Color::White);
let title = "dev".to_string();
let branch = "main".to_string();
let line = format_title("crmux", None, Some(&title), Some(&branch), None, base);
assert_eq!(line_text(&line), "crmux/main - dev");
assert_eq!(line.spans[0].style, base);
}
#[test]
fn test_format_title_with_branch_and_worktree() {
let base = Style::default().fg(Color::White);
let title = "dev".to_string();
let branch = "feature".to_string();
let worktree = "wt-1".to_string();
let line = format_title("crmux", None, Some(&title), Some(&branch), Some(&worktree), base);
assert_eq!(line_text(&line), "crmux/feature - dev");
let cyan = Style::default().fg(Color::Cyan);
assert_eq!(line.spans[0].style, cyan);
}
#[test]
fn test_format_title_with_worktree_only() {
let base = Style::default().fg(Color::White);
let worktree = "wt-1".to_string();
let line = format_title("crmux", None, None, None, Some(&worktree), base);
assert_eq!(line_text(&line), "crmux/wt-1");
let cyan = Style::default().fg(Color::Cyan);
assert_eq!(line.spans[0].style, cyan);
}
#[test]
fn test_format_title_with_number() {
let base = Style::default().fg(Color::White);
let line = format_title("crmux", Some(0), None, None, None, base);
assert_eq!(line_text(&line), "1.crmux");
}
#[test]
fn test_format_title_with_number_and_branch() {
let base = Style::default().fg(Color::White);
let branch = "main".to_string();
let line = format_title("crmux", Some(2), None, Some(&branch), None, base);
assert_eq!(line_text(&line), "3.crmux/main");
}
#[test]
fn test_format_title_with_number_beyond_max() {
let base = Style::default().fg(Color::White);
let line = format_title("crmux", Some(crate::state::MAX_NUMBER_KEYS), None, None, None, base);
assert_eq!(line_text(&line), "crmux");
}
#[test]
fn test_footer_normal_mode_starts_with_app_name() {
let spans = footer_spans(InputMode::Normal, LayoutMode::Single, None);
assert!(spans[0].content.starts_with("crmux v"));
}
#[test]
fn test_footer_normal_mode_has_no_mode_indicator() {
let spans = footer_spans(InputMode::Normal, LayoutMode::Single, None);
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, LayoutMode::Single, None);
assert!(spans[0].content.starts_with("crmux v"));
}
#[test]
fn test_footer_input_mode_has_insert_indicator() {
let spans = footer_spans(InputMode::Input, LayoutMode::Single, None);
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, LayoutMode::Single, None);
assert!(spans[0].content.starts_with("crmux v"));
}
#[test]
fn test_footer_title_mode_has_title_edit_indicator() {
let spans = footer_spans(InputMode::Title, LayoutMode::Single, None);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("-- TITLE --"));
}
#[test]
fn test_footer_normal_mode_contains_help_key() {
let spans = footer_spans(InputMode::Normal, LayoutMode::Single, None);
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, LayoutMode::Single, None);
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, LayoutMode::Single, None);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("I:Broadcast"), "Normal mode footer should contain 'I:Broadcast', got: {text}");
}
#[test]
fn test_footer_normal_mode_contains_scroll_keys() {
let spans = footer_spans(InputMode::Normal, LayoutMode::Single, None);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("C-u:ScrollUp"), "Normal mode footer should contain 'C-u:ScrollUp', 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_compute_cell_areas_main_vertical_3_panes() {
let area = Rect::new(0, 0, 100, 40);
let areas = compute_cell_areas(3, area, LayoutMode::MainVertical, None);
assert_eq!(areas.len(), 3);
assert!(areas[0].width >= 58 && areas[0].width <= 62, "main width: {}", areas[0].width);
assert_eq!(areas[0].height, 40);
assert!(areas[1].width >= 38 && areas[1].width <= 42, "sub width: {}", areas[1].width);
assert_eq!(areas[1].height, 20);
assert_eq!(areas[2].height, 20);
assert_eq!(areas[1].x, areas[2].x);
}
#[test]
fn test_compute_cell_areas_main_horizontal_3_panes() {
let area = Rect::new(0, 0, 100, 40);
let areas = compute_cell_areas(3, area, LayoutMode::MainHorizontal, None);
assert_eq!(areas.len(), 3);
assert!(areas[0].height >= 23 && areas[0].height <= 25, "main height: {}", areas[0].height);
assert_eq!(areas[0].width, 100);
assert_eq!(areas[1].width, 50);
assert_eq!(areas[2].width, 50);
assert_eq!(areas[1].y, areas[2].y);
}
#[test]
fn test_compute_cell_areas_main_vertical_1_pane() {
let area = Rect::new(0, 0, 100, 40);
let areas = compute_cell_areas(1, area, LayoutMode::MainVertical, None);
assert_eq!(areas.len(), 1);
assert_eq!(areas[0], area);
}
#[test]
fn test_compute_cell_areas_main_horizontal_1_pane() {
let area = Rect::new(0, 0, 100, 40);
let areas = compute_cell_areas(1, area, LayoutMode::MainHorizontal, None);
assert_eq!(areas.len(), 1);
assert_eq!(areas[0], area);
}
#[test]
fn test_compute_cell_areas_main_vertical_auto_width_mid() {
let area = Rect::new(0, 0, 100, 40);
let areas = compute_cell_areas(3, area, LayoutMode::MainVertical, Some(75));
assert_eq!(areas.len(), 3);
assert!(areas[0].width >= 75 && areas[0].width <= 79, "main width: {}", areas[0].width);
}
#[test]
fn test_compute_cell_areas_main_vertical_auto_width_clamp_upper() {
let area = Rect::new(0, 0, 100, 40);
let areas = compute_cell_areas(3, area, LayoutMode::MainVertical, Some(90));
assert_eq!(areas.len(), 3);
assert!(areas[0].width >= 78 && areas[0].width <= 82, "main width: {}", areas[0].width);
}
#[test]
fn test_compute_cell_areas_main_vertical_auto_width_clamp_lower() {
let area = Rect::new(0, 0, 100, 40);
let areas = compute_cell_areas(3, area, LayoutMode::MainVertical, Some(40));
assert_eq!(areas.len(), 3);
assert!(areas[0].width >= 58 && areas[0].width <= 62, "main width: {}", areas[0].width);
}
#[test]
fn test_footer_main_vertical_label_and_next() {
let spans = footer_spans(InputMode::Normal, LayoutMode::MainVertical, None);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("v:Single"), "should contain v:Single, got: {text}");
}
#[test]
fn test_footer_main_horizontal_label_and_next() {
let spans = footer_spans(InputMode::Normal, LayoutMode::MainHorizontal, None);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("v:MainV"), "should contain v:MainV, got: {text}");
}
#[test]
fn test_footer_even_vertical_next_is_main_h() {
let spans = footer_spans(InputMode::Normal, LayoutMode::EvenVertical, None);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("v:MainH"), "should contain v:MainH, got: {text}");
}
#[test]
fn test_footer_normal_mode_contains_claudeye_key() {
let spans = footer_spans(InputMode::Normal, LayoutMode::Single, None);
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, LayoutMode::Single, None);
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, LayoutMode::Single, None);
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_border_color_returns_rgb() {
let result = pulse_border_color(Color::LightRed);
assert!(matches!(result, Color::Rgb(_, _, _)));
}
#[test]
fn test_pulse_border_color_within_intensity_range() {
let base = Color::White; let result = pulse_border_color(base);
if let Color::Rgb(r, g, b) = result {
assert!(r >= 127, "r={r} below min intensity");
assert!(g >= 127, "g={g} below min intensity");
assert!(b >= 127, "b={b} below min intensity");
} else {
panic!("Expected Color::Rgb");
}
}
#[test]
fn test_footer_scroll_mode_has_scroll_indicator() {
let spans = footer_spans(InputMode::Scroll, LayoutMode::Single, None);
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, LayoutMode::Single, None);
assert!(spans[0].content.starts_with("crmux v"));
}
#[test]
fn test_footer_scroll_mode_contains_keybindings() {
let spans = footer_spans(InputMode::Scroll, LayoutMode::Single, None);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("j/k:Line"), "Scroll mode footer should contain 'j/k:Line', got: {text}");
assert!(text.contains("Esc:Back"), "Scroll mode footer should contain 'Esc:Back', got: {text}");
}
#[test]
fn test_footer_no_update_available() {
let spans = footer_spans(InputMode::Normal, LayoutMode::Single, None);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(!text.contains("available!"));
}
#[test]
fn test_footer_update_available() {
let spans = footer_spans(InputMode::Normal, LayoutMode::Single, Some("v0.14.0"));
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("v0.14.0 available! Run: crmux update"), "got: {text}");
}
#[test]
fn test_permission_mode_icon_plan() {
assert_eq!(permission_mode_icon(&PermissionMode::PlanMode), "⏸ ");
}
#[test]
fn test_permission_mode_icon_auto_edit() {
assert_eq!(permission_mode_icon(&PermissionMode::EditAutomatically), "⏵⏵");
}
#[test]
fn test_permission_mode_icon_ask() {
assert_eq!(permission_mode_icon(&PermissionMode::AskBeforeEdits), "");
}
}