use ratatui::prelude::*;
use ratatui::widgets::Paragraph;
use super::theme;
use crate::app::{App, Focus};
use crate::sampler::sessions::ClaudeSession;
pub fn render(f: &mut Frame, area: Rect, app: &App) {
let focused = matches!(app.focus, Focus::Sessions);
let block = theme::panel_block("claude sessions", focused);
let inner = block.inner(area);
f.render_widget(block, area);
let sessions = app.sessions();
if sessions.is_empty() {
f.render_widget(
Paragraph::new("none running").style(Style::default().fg(theme::DIM)),
inner,
);
return;
}
let visible = inner.height as usize;
let cursor = focused.then(|| app.selected());
let offset = super::scroll::offset(visible, cursor);
const TTY_W: usize = 8;
const CPU_W: usize = 6;
let collides = |s: &ClaudeSession| {
sessions.iter().filter(|o| o.project == s.project).count() > 1
};
let any_collision = sessions.iter().any(collides);
let tty_w = if any_collision { TTY_W } else { 0 };
let label_w = (inner.width as usize).saturating_sub(tty_w + CPU_W + 2);
let lines: Vec<Line> = sessions
.iter()
.enumerate()
.skip(offset)
.take(visible)
.map(|(i, s)| {
let selected = cursor == Some(i);
let base = if selected {
Style::default().fg(theme::BG_CELL).bg(theme::ACCENT)
} else {
Style::default().fg(theme::TEXT)
};
let cpu = match app.cpu_of(s.pid) {
Some(c) => format!("{c:>5.1}%"),
None => format!("{:>w$}", "—", w = CPU_W),
};
Line::from(vec![
Span::styled(format!(" {:<w$}", super::text::trunc(&s.project, label_w), w = label_w), base),
Span::styled(
match collides(s) {
true => format!("{:<w$}", s.tty.as_deref().unwrap_or("—"), w = tty_w),
false => " ".repeat(tty_w),
},
if selected { base } else { Style::default().fg(theme::DIM) },
),
Span::styled(
cpu,
if selected { base } else { Style::default().fg(theme::ACCENT) },
),
])
})
.collect();
f.render_widget(Paragraph::new(lines), inner);
}
#[cfg(test)]
mod tests {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use crate::app::App;
use super::ClaudeSession;
fn draw(w: u16, h: u16) -> Vec<String> {
draw_app(&App::demo(), w, h)
}
fn draw_app(app: &App, w: u16, h: u16) -> Vec<String> {
let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
t.draw(|f| super::render(f, f.area(), app)).unwrap();
let b = t.backend().buffer().clone();
(0..h).map(|y| (0..w).map(|x| b[(x, y)].symbol().to_string()).collect()).collect()
}
#[test]
fn two_sessions_in_one_project_are_told_apart_by_tty() {
let out = draw(40, 8).join("\n");
assert!(out.contains("ttys020"), "first axterio session's tty missing:\n{out}");
assert!(out.contains("ttys021"), "second axterio session's tty missing:\n{out}");
}
#[test]
fn a_project_with_only_one_session_shows_no_tty() {
let out = draw(40, 8).join("\n");
assert!(
!out.contains("ttys004"),
"whirr has one session, so its tty should not be shown:\n{out}"
);
}
#[test]
fn with_no_collisions_at_all_the_project_name_takes_the_whole_row() {
let mut app = App::demo();
let slow = app.slow.as_mut().expect("demo() ingests a slow snapshot");
slow.sessions = vec![
ClaudeSession {
pid: 1,
project: "a-project-with-a-long-name".into(),
tty: Some("ttys001".into()),
},
ClaudeSession { pid: 2, project: "other".into(), tty: Some("ttys002".into()) },
];
let out = draw_app(&app, 40, 8).join("\n");
assert!(!out.contains("ttys001"), "no collisions, so no tty column:\n{out}");
assert!(
out.contains("a-project-with-a-long-name"),
"the reclaimed columns should go to the name:\n{out}"
);
}
#[test]
fn a_session_shows_cpu_when_known_and_a_dash_when_not() {
let out = draw(40, 8).join("\n");
assert!(out.contains("8.1"), "known CPU missing:\n{out}");
assert!(out.contains('—'), "unknown CPU should render an em-dash:\n{out}");
assert!(!out.contains("—%"), "em-dash must not be followed by a percent sign");
}
#[test]
fn sessions_have_no_port_column() {
let out = draw(40, 8).join("\n");
assert!(!out.contains(':'), "session rows must not show ports:\n{out}");
}
#[test]
fn nothing_truncates_mid_value_at_forty_columns() {
for line in draw(40, 8) {
let t = line.trim_end();
assert!(!t.ends_with("tty"), "tty name cut short: {t:?}");
assert!(!t.ends_with("ttys"), "tty name cut short: {t:?}");
}
}
}