whirr 0.3.8

A whirring macOS system dashboard for your terminal
//! The Claude sessions card. Sourced from processes, so it lists every running
//! session — including the ones holding no listening socket, which a
//! port-sourced list cannot see.

use ratatui::prelude::*;
use ratatui::widgets::Paragraph;


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 = app.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(app.theme.dim)),
            inner,
        );
        return;
    }

    let visible = inner.height as usize;
    let cursor = focused.then(|| app.selected());
    let offset = super::scroll::offset(visible, cursor);

    // tty and CPU are fixed-width; the project takes what is left. Reserve them
    // first so a long project name cannot push them off the edge.
    const TTY_W: usize = 8;
    const CPU_W: usize = 6;

    // The tty is only ever an answer to "which of these two identical rows is
    // which". On a project with a single session it is 8 columns telling you
    // something you cannot act on — you cannot map `ttys004` back to a window
    // without running `tty` in every terminal you have open.
    //
    // So it appears per row, and the column is reserved for the whole card
    // only when some row needs it: reserving per row instead would let the
    // CPU figures jump left and right down the list.
    // A host title already identifies the session, so a tty next to it would
    // be answering a question nobody has. Collision is judged on what the row
    // actually shows, not on the project underneath it.
    // The project stays even when the host supplies a title. A title says
    // what the session is *doing*; the project says which codebase it is
    // doing it in, and two sessions can easily be doing similar things in
    // different repos.
    fn shown(s: &ClaudeSession) -> &str {
        s.title.as_deref().unwrap_or(&s.project)
    }
    let collides = |s: &ClaudeSession| {
        s.title.is_none() && sessions.iter().filter(|o| shown(o) == shown(s)).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);
    // The project gets what it needs up to a ceiling; the title takes the
    // rest, and gets nothing when there is nothing to spare.
    let widest_project = sessions.iter().map(|s| s.project.chars().count()).max().unwrap_or(0);
    let any_title = sessions.iter().any(|s| s.title.is_some());
    let proj_w = if any_title { widest_project.min(label_w.saturating_sub(8)).max(1) } else { label_w };
    let title_w = label_w.saturating_sub(proj_w);

    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(app.theme.bg_cell).bg(app.theme.accent)
            } else {
                Style::default().fg(app.theme.text)
            };
            let cpu = match app.cpu_of(s.pid) {
                Some(c) => format!("{c:>5.1}%"),
                // An em-dash with no percent sign: unknown, not idle.
                None => format!("{:>w$}", "", w = CPU_W),
            };
            Line::from(vec![
                // The host's own title when it has one — cmux names a
                // workspace after the task the session is doing, which beats
                // a project directory. Display only: the sort stays on
                // project/tty/pid, because a title changes every few seconds
                // and rows must not reorder under the cursor.
                // Project first — it is what you scan for — then the host's
                // own title in the space that is left. The title is truncated
                // rather than the project: losing the end of "…pull latest
                // changes" costs less than losing which repo it is.
                Span::styled(format!(" {:<w$}", super::text::trunc(&s.project, proj_w), w = proj_w), base),
                Span::styled(
                    match &s.title {
                        Some(t) if title_w > 1 => {
                            format!(" {:<w$}", super::text::trunc(t, title_w - 1), w = title_w - 1)
                        }
                        _ => " ".repeat(title_w),
                    },
                    if selected { base } else { Style::default().fg(app.theme.dim) },
                ),
                Span::styled(
                    // Blank, not an em-dash, on a row that doesn't collide:
                    // a dash would read as "unknown tty" when the truth is
                    // "you don't need one".
                    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(app.theme.dim) },
                ),
                Span::styled(
                    cpu,
                    if selected { base } else { Style::default().fg(app.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");
        // This is the bug that motivated the card: both rows say "axterio",
        // so the tty is the only thing distinguishing them.
        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() {
        // The tty exists to tell two rows apart. On a row that is already
        // unique it is 8 columns of noise you cannot act on — you cannot map
        // ttys004 back to a window without running `tty` in each terminal.
        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() {
        // Nothing to disambiguate anywhere, so the column itself goes and the
        // name gets the width back. The name below is 26 characters: it fits
        // the 30 columns available once the tty is gone, and would not have
        // fit the 22 it left behind.
        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(),
                title: None,
                jumpable: false,
                tty: Some("ttys001".into()),
            },
            ClaudeSession { pid: 2, project: "other".into(), title: None, jumpable: false, 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:?}");
        }
    }
}