macot 0.1.11

Multi Agent Control Tower - CLI for orchestrating Claude CLI instances
Documentation
use ratatui::{
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
    Frame,
};

use crate::context::RoleInfo;
use crate::utils::truncate_str;

pub struct RoleSelector {
    visible: bool,
    expert_id: Option<u32>,
    current_role: String,
    available_roles: Vec<RoleInfo>,
    state: ListState,
}

impl RoleSelector {
    pub fn new() -> Self {
        Self {
            visible: false,
            expert_id: None,
            current_role: String::new(),
            available_roles: Vec::new(),
            state: ListState::default(),
        }
    }

    pub fn show(&mut self, expert_id: u32, current_role: &str, roles: Vec<RoleInfo>) {
        self.visible = true;
        self.expert_id = Some(expert_id);
        self.current_role = current_role.to_string();
        self.available_roles = roles;

        let current_index = self
            .available_roles
            .iter()
            .position(|r| r.name == current_role)
            .unwrap_or(0);
        self.state.select(Some(current_index));
    }

    pub fn hide(&mut self) {
        self.visible = false;
        self.expert_id = None;
        self.current_role.clear();
        self.state.select(None);
    }

    pub fn is_visible(&self) -> bool {
        self.visible
    }

    pub fn expert_id(&self) -> Option<u32> {
        self.expert_id
    }

    pub fn selected_role(&self) -> Option<&str> {
        self.state
            .selected()
            .and_then(|i| self.available_roles.get(i))
            .map(|r| r.name.as_str())
    }

    pub fn next(&mut self) {
        super::select_next(&mut self.state, self.available_roles.len());
    }

    pub fn prev(&mut self) {
        super::select_prev(&mut self.state, self.available_roles.len());
    }

    pub fn render(&mut self, frame: &mut Frame, area: Rect) {
        if !self.visible {
            return;
        }

        let popup_width = 50.min(area.width.saturating_sub(4));
        let popup_height =
            (self.available_roles.len() as u16 + 6).min(area.height.saturating_sub(4));

        let popup_area = centered_rect(popup_width, popup_height, area);

        frame.render_widget(Clear, popup_area);

        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Length(3),
                Constraint::Min(1),
                Constraint::Length(2),
            ])
            .split(popup_area);

        let title = format!("Select Role for Expert {}", self.expert_id.unwrap_or(0));
        let header = Paragraph::new(Line::from(vec![Span::styled(
            format!("Current: {}", self.current_role),
            Style::default().fg(Color::Yellow),
        )]))
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::Cyan))
                .title(title),
        );
        frame.render_widget(header, chunks[0]);

        let items: Vec<ListItem> = self
            .available_roles
            .iter()
            .enumerate()
            .map(|(idx, role)| {
                let is_current = role.name == self.current_role;
                let marker = if is_current { "" } else { "" };

                let style = if is_current {
                    Style::default()
                        .fg(Color::Green)
                        .add_modifier(Modifier::BOLD)
                } else {
                    Style::default().fg(Color::White)
                };

                let spans = vec![
                    Span::styled(
                        format!("[{}] ", idx + 1),
                        Style::default().fg(Color::DarkGray),
                    ),
                    Span::styled(format!("{marker} "), style),
                    Span::styled(format!("{:<12}", role.display_name), style),
                    Span::styled(
                        format!(" - {}", truncate_str(&role.description, 25)),
                        Style::default().fg(Color::Gray),
                    ),
                ];

                ListItem::new(Line::from(spans))
            })
            .collect();

        let list = List::new(items)
            .block(Block::default().borders(Borders::LEFT | Borders::RIGHT))
            .highlight_style(
                Style::default()
                    .add_modifier(Modifier::REVERSED)
                    .add_modifier(Modifier::BOLD),
            )
            .highlight_symbol("> ");

        frame.render_stateful_widget(list, chunks[1], &mut self.state);

        let footer = Paragraph::new(Line::from(vec![
            Span::styled("Enter", Style::default().fg(Color::Cyan)),
            Span::raw(": Select  |  "),
            Span::styled("Esc/q/Ctrl+O", Style::default().fg(Color::Cyan)),
            Span::raw(": Cancel  |  "),
            Span::styled("j/k", Style::default().fg(Color::Cyan)),
            Span::raw(": Navigate"),
        ]))
        .block(Block::default().borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM));
        frame.render_widget(footer, chunks[2]);
    }
}

impl Default for RoleSelector {
    fn default() -> Self {
        Self::new()
    }
}

fn centered_rect(width: u16, height: u16, r: Rect) -> Rect {
    let x = r.x + (r.width.saturating_sub(width)) / 2;
    let y = r.y + (r.height.saturating_sub(height)) / 2;
    Rect::new(x, y, width, height)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn create_test_roles() -> Vec<RoleInfo> {
        vec![
            RoleInfo {
                name: "architect".to_string(),
                display_name: "Architect".to_string(),
                description: "System design".to_string(),
            },
            RoleInfo {
                name: "backend".to_string(),
                display_name: "Backend".to_string(),
                description: "Server logic".to_string(),
            },
            RoleInfo {
                name: "frontend".to_string(),
                display_name: "Frontend".to_string(),
                description: "UI development".to_string(),
            },
        ]
    }

    #[test]
    fn role_selector_initially_hidden() {
        let selector = RoleSelector::new();
        assert!(!selector.is_visible());
        assert!(selector.expert_id().is_none());
    }

    #[test]
    fn role_selector_show_makes_visible() {
        let mut selector = RoleSelector::new();
        selector.show(0, "architect", create_test_roles());

        assert!(selector.is_visible());
        assert_eq!(selector.expert_id(), Some(0));
        assert_eq!(selector.selected_role(), Some("architect"));
    }

    #[test]
    fn role_selector_hide_resets_state() {
        let mut selector = RoleSelector::new();
        selector.show(0, "architect", create_test_roles());
        selector.hide();

        assert!(!selector.is_visible());
        assert!(selector.expert_id().is_none());
    }

    #[test]
    fn role_selector_navigation() {
        let mut selector = RoleSelector::new();
        selector.show(0, "architect", create_test_roles());

        assert_eq!(selector.selected_role(), Some("architect"));

        selector.next();
        assert_eq!(selector.selected_role(), Some("backend"));

        selector.next();
        assert_eq!(selector.selected_role(), Some("frontend"));

        selector.next();
        assert_eq!(selector.selected_role(), Some("architect"));
    }

    #[test]
    fn role_selector_prev_navigation() {
        let mut selector = RoleSelector::new();
        selector.show(0, "architect", create_test_roles());

        selector.prev();
        assert_eq!(selector.selected_role(), Some("frontend"));

        selector.prev();
        assert_eq!(selector.selected_role(), Some("backend"));
    }
}