tazuna 0.1.0

TUI tool for managing multiple Claude Code sessions in parallel
Documentation
//! Status bar widget for TUI.
//!
//! Displays session count, active session name, cost, and notification indicators.

use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::{Color, Style},
    text::{Line, Span},
    widgets::Widget,
};

/// Status bar displaying session information
#[derive(Debug, Clone)]
pub struct StatusBar<'a> {
    /// Current session count
    pub session_count: usize,
    /// Active session name (if any)
    pub active_session: Option<&'a str>,
    /// Active session branch (if any)
    pub active_branch: Option<&'a str>,
    /// Pending notification count
    pub notifications: usize,
    /// Today's usage cost (from ccusage)
    pub today_cost: Option<f64>,
}

impl<'a> StatusBar<'a> {
    /// Create new status bar
    #[must_use]
    pub fn new(session_count: usize) -> Self {
        Self {
            session_count,
            active_session: None,
            active_branch: None,
            notifications: 0,
            today_cost: None,
        }
    }

    /// Set active session name
    #[must_use]
    pub fn active_session(mut self, name: &'a str) -> Self {
        self.active_session = Some(name);
        self
    }

    /// Set active branch name
    #[must_use]
    pub fn active_branch(mut self, branch: &'a str) -> Self {
        self.active_branch = Some(branch);
        self
    }

    /// Set notification count
    #[must_use]
    pub fn notifications(mut self, count: usize) -> Self {
        self.notifications = count;
        self
    }

    /// Set today's cost
    #[must_use]
    pub fn today_cost(mut self, cost: f64) -> Self {
        self.today_cost = Some(cost);
        self
    }

    /// Build left side spans: [N] session-name (branch)
    fn build_left_spans(&self) -> Vec<Span<'_>> {
        let mut spans = vec![
            Span::styled(" [", Style::default().fg(Color::Gray)),
            Span::styled(
                format!("{}", self.session_count),
                Style::default().fg(Color::Cyan),
            ),
            Span::styled("] ", Style::default().fg(Color::Gray)),
        ];

        if let Some(name) = self.active_session {
            spans.push(Span::styled(name, Style::default().fg(Color::Green)));
            if let Some(branch) = self.active_branch {
                spans.push(Span::styled(
                    format!(" ({branch})"),
                    Style::default().fg(Color::Gray),
                ));
            }
        }

        spans
    }

    /// Build right side spans: $X.XX today  N pending
    fn build_right_spans(&self) -> Vec<Span<'static>> {
        let mut spans = Vec::new();

        if let Some(cost) = self.today_cost {
            spans.push(Span::styled(
                format!("${cost:.2}"),
                Style::default().fg(Color::Cyan),
            ));
            spans.push(Span::styled(" today", Style::default().fg(Color::Gray)));
        }

        if self.notifications > 0 {
            if !spans.is_empty() {
                spans.push(Span::styled("  ", Style::default()));
            }
            spans.push(Span::styled(
                format!("{} pending ", self.notifications),
                Style::default().fg(Color::Yellow),
            ));
        }

        spans
    }
}

impl Widget for StatusBar<'_> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        if area.height == 0 || area.width == 0 {
            return;
        }

        let left_spans = self.build_left_spans();
        let right_spans = self.build_right_spans();

        // Calculate widths
        let left_width: usize = left_spans.iter().map(|s| s.content.len()).sum();
        let right_width: usize = right_spans.iter().map(|s| s.content.len()).sum();
        let total_width = area.width as usize;

        // Build final line with gap
        let mut all_spans = left_spans;

        if total_width > left_width + right_width {
            let gap = total_width.saturating_sub(left_width + right_width);
            all_spans.push(Span::raw(" ".repeat(gap)));
        }

        all_spans.extend(right_spans);

        let line = Line::from(all_spans);
        buf.set_line(area.x, area.y, &line, area.width);
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use crate::tui::test_utils::buffer_to_text;

    #[test]
    fn test_status_bar_renders_count() {
        let status = StatusBar::new(3);
        let area = Rect::new(0, 0, 40, 1);
        let mut buf = Buffer::empty(area);

        status.render(area, &mut buf);
        let output = buffer_to_text(&buf);

        assert!(output.contains("[3]"));
    }

    #[test]
    fn test_status_bar_renders_active_name() {
        let status = StatusBar::new(2).active_session("feature-auth");
        let area = Rect::new(0, 0, 60, 1);
        let mut buf = Buffer::empty(area);

        status.render(area, &mut buf);
        let output = buffer_to_text(&buf);

        assert!(output.contains("feature-auth"));
    }

    #[test]
    fn test_status_bar_renders_active_branch() {
        let status = StatusBar::new(2)
            .active_session("my-session")
            .active_branch("main");
        let area = Rect::new(0, 0, 80, 1);
        let mut buf = Buffer::empty(area);

        status.render(area, &mut buf);
        let output = buffer_to_text(&buf);

        assert!(output.contains("my-session"));
        assert!(output.contains("(main)"));
    }

    #[test]
    fn test_status_bar_renders_notifications() {
        let status = StatusBar::new(1).notifications(5);
        let area = Rect::new(0, 0, 50, 1);
        let mut buf = Buffer::empty(area);

        status.render(area, &mut buf);
        let output = buffer_to_text(&buf);

        assert!(output.contains("5 pending"));
    }

    #[test]
    fn test_status_bar_renders_today_cost() {
        let status = StatusBar::new(1).today_cost(7.50);
        let area = Rect::new(0, 0, 50, 1);
        let mut buf = Buffer::empty(area);

        status.render(area, &mut buf);
        let output = buffer_to_text(&buf);

        assert!(output.contains("$7.50"));
        assert!(output.contains("today"));
    }

    #[test]
    fn test_status_bar_empty_area() {
        let status = StatusBar::new(1);
        let area = Rect::new(0, 0, 40, 0);
        let mut buf = Buffer::empty(area);

        // Should not panic
        status.render(area, &mut buf);
    }

    mod snapshots {
        use super::*;
        use crate::tui::test_utils::render_to_snapshot;
        use insta::assert_snapshot;

        #[test]
        fn minimal_count_only() {
            let status = StatusBar::new(3);
            assert_snapshot!(render_to_snapshot(status, 30, 1));
        }

        #[test]
        fn with_active_session() {
            let status = StatusBar::new(2).active_session("feature-auth");
            assert_snapshot!(render_to_snapshot(status, 50, 1));
        }

        #[test]
        fn with_active_branch() {
            let status = StatusBar::new(2)
                .active_session("my-session")
                .active_branch("main");
            assert_snapshot!(render_to_snapshot(status, 60, 1));
        }

        #[test]
        fn with_notifications() {
            let status = StatusBar::new(1).notifications(5);
            assert_snapshot!(render_to_snapshot(status, 50, 1));
        }

        #[test]
        fn with_today_cost() {
            let status = StatusBar::new(1).today_cost(7.50);
            assert_snapshot!(render_to_snapshot(status, 50, 1));
        }

        #[test]
        fn full_combination() {
            let status = StatusBar::new(5)
                .active_session("dev-session")
                .active_branch("feature/login")
                .notifications(3)
                .today_cost(12.34);
            assert_snapshot!(render_to_snapshot(status, 80, 1));
        }
    }
}