use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{
Block, BorderType, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
},
Frame,
};
use tmai_core::state::AppState;
use tmai_core::teams::TaskStatus;
pub struct TeamOverview;
impl TeamOverview {
pub fn render(frame: &mut Frame, area: Rect, state: &AppState) {
let content_lines = Self::build_content(state);
let total_lines = content_lines.len();
let visible_height = area.height.saturating_sub(2) as usize;
let max_scroll = total_lines.saturating_sub(visible_height);
let scroll = (state.view.team_overview_scroll as usize).min(max_scroll);
let block = Block::default()
.title(" Team Overview (j/k to scroll, T or Esc to close) ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Cyan));
let paragraph = Paragraph::new(content_lines)
.block(block)
.scroll((scroll as u16, 0));
frame.render_widget(paragraph, area);
if total_lines > visible_height {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("\u{2191}"))
.end_symbol(Some("\u{2193}"));
let mut scrollbar_state = ScrollbarState::new(max_scroll).position(scroll);
frame.render_stateful_widget(
scrollbar,
area.inner(ratatui::layout::Margin {
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
}
}
fn build_content(state: &AppState) -> Vec<Line<'static>> {
let mut lines = Vec::new();
lines.push(Self::title_line("Team Overview"));
lines.push(Line::from(""));
if state.teams.is_empty() {
lines.push(Line::from(Span::styled(
" No teams found.",
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Teams are detected from ~/.claude/teams/*/config.json",
Style::default().fg(Color::DarkGray),
)));
return lines;
}
let mut team_names: Vec<&String> = state.teams.keys().collect();
team_names.sort();
for team_name in team_names {
let snapshot = &state.teams[team_name];
let config = &snapshot.config;
let tasks = &snapshot.tasks;
let member_count = config.members.len();
lines.push(Self::section_header(&format!(
"{} ({} members)",
team_name, member_count
)));
if let Some(ref desc) = config.description {
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(desc.clone(), Style::default().fg(Color::DarkGray)),
]));
}
if !snapshot.worktree_names.is_empty() {
lines.push(Line::from(vec![
Span::styled(" Worktrees: ", Style::default().fg(Color::DarkGray)),
Span::styled(
snapshot.worktree_names.join(", "),
Style::default().fg(Color::Magenta),
),
]));
}
let total_tasks = tasks.len();
if total_tasks > 0 {
let done = tasks
.iter()
.filter(|t| t.status == TaskStatus::Completed)
.count();
let in_progress = tasks
.iter()
.filter(|t| t.status == TaskStatus::InProgress)
.count();
let percent = if total_tasks > 0 {
(done * 100) / total_tasks
} else {
0
};
let bar = Self::build_progress_bar(done, in_progress, total_tasks, 20);
lines.push(Line::from(vec![
Span::styled(" Tasks: ", Style::default().fg(Color::DarkGray)),
bar,
Span::styled(
format!(" {}/{} ({}%)", done, total_tasks, percent),
Style::default().fg(Color::White),
),
]));
} else {
lines.push(Line::from(Span::styled(
" Tasks: none",
Style::default().fg(Color::DarkGray),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Members:",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)));
for member in &config.members {
let mut spans = vec![Span::styled(" ", Style::default())];
let has_pane = snapshot.member_panes.contains_key(&member.name);
let is_idle = has_pane
&& snapshot
.member_panes
.get(&member.name)
.and_then(|target| state.agents.get(target))
.is_some_and(|a| matches!(a.status, tmai_core::agents::AgentStatus::Idle));
let status_icon = if has_pane { "\u{25CF}" } else { "\u{25CB}" };
let status_color = if is_idle {
Color::Yellow
} else if has_pane {
Color::Green
} else {
Color::DarkGray
};
spans.push(Span::styled(
format!("{} ", status_icon),
Style::default().fg(status_color),
));
spans.push(Span::styled(
member.name.clone(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));
if let Some(ref agent_type) = member.agent_type {
spans.push(Span::styled(
format!(" ({})", agent_type),
Style::default().fg(Color::DarkGray),
));
}
if is_idle {
spans.push(Span::styled(
" idle",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::ITALIC),
));
}
if let Some(pane_target) = snapshot.member_panes.get(&member.name) {
if let Some(agent) = state.agents.get(pane_target) {
if let Some(ref wt_name) = agent.worktree_name {
let branch = agent.git_branch.as_deref().unwrap_or("?");
spans.push(Span::styled(
format!(" [WT: {} @ {}]", wt_name, branch),
Style::default().fg(Color::Magenta),
));
} else if agent.is_worktree == Some(true) {
if let Some(ref branch) = agent.git_branch {
spans.push(Span::styled(
format!(" [WT: {}]", branch),
Style::default().fg(Color::Magenta),
));
}
}
}
}
let current_task = tasks.iter().find(|t| {
t.owner.as_deref() == Some(&member.name) && t.status == TaskStatus::InProgress
});
if let Some(task) = current_task {
spans.push(Span::styled(
format!(" -> #{} {}", task.id, task.subject),
Style::default().fg(Color::Yellow),
));
}
lines.push(Line::from(spans));
}
lines.push(Line::from(""));
if !tasks.is_empty() {
lines.push(Line::from(Span::styled(
" Tasks:",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)));
for task in tasks {
let status_icon = match task.status {
TaskStatus::Completed => Span::styled(
"\u{2713} ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
TaskStatus::InProgress => Span::styled(
format!("{} ", state.spinner_char()),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
TaskStatus::Pending => {
Span::styled("\u{25CB} ", Style::default().fg(Color::DarkGray))
}
};
let subject_color = match task.status {
TaskStatus::Completed => Color::Green,
TaskStatus::InProgress => Color::Yellow,
TaskStatus::Pending => Color::DarkGray,
};
let mut spans = vec![
Span::styled(" ", Style::default()),
status_icon,
Span::styled(format!("#{} ", task.id), Style::default().fg(Color::Cyan)),
Span::styled(task.subject.clone(), Style::default().fg(subject_color)),
];
if let Some(ref owner) = task.owner {
spans.push(Span::styled(
format!(" [{}]", owner),
Style::default().fg(Color::Magenta),
));
}
if !task.blocked_by.is_empty() {
spans.push(Span::styled(
format!(" blocked by #{}", task.blocked_by.join(", #")),
Style::default().fg(Color::Red),
));
}
if !task.blocks.is_empty() {
spans.push(Span::styled(
format!(" blocks #{}", task.blocks.join(", #")),
Style::default().fg(Color::Blue),
));
}
lines.push(Line::from(spans));
}
}
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled(
"Press T or Esc to close",
Style::default().fg(Color::DarkGray),
)));
lines
}
fn build_progress_bar(
done: usize,
in_progress: usize,
total: usize,
bar_width: usize,
) -> Span<'static> {
if total == 0 {
return Span::styled(
"\u{2591}".repeat(bar_width),
Style::default().fg(Color::DarkGray),
);
}
let done_width = (done * bar_width) / total;
let in_progress_width = (in_progress * bar_width) / total;
let remaining_width = bar_width.saturating_sub(done_width + in_progress_width);
let mut bar = String::new();
bar.push_str(&"\u{2588}".repeat(done_width));
bar.push_str(&"\u{2593}".repeat(in_progress_width));
bar.push_str(&"\u{2591}".repeat(remaining_width));
let color = if done == total {
Color::Green
} else if done + in_progress > 0 {
Color::Yellow
} else {
Color::DarkGray
};
Span::styled(bar, Style::default().fg(color))
}
fn title_line(text: &str) -> Line<'static> {
Line::from(vec![Span::styled(
text.to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)])
}
fn section_header(text: &str) -> Line<'static> {
Line::from(vec![Span::styled(
format!("\u{2500}\u{2500}\u{2500} {} \u{2500}\u{2500}\u{2500}", text),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_progress_bar_empty() {
let bar = TeamOverview::build_progress_bar(0, 0, 0, 10);
assert_eq!(bar.content.len(), 30); }
#[test]
fn test_progress_bar_full() {
let bar = TeamOverview::build_progress_bar(5, 0, 5, 10);
assert!(bar.content.contains('\u{2588}'));
}
#[test]
fn test_progress_bar_partial() {
let bar = TeamOverview::build_progress_bar(2, 1, 5, 10);
let content = bar.content.to_string();
assert!(content.contains('\u{2588}') || content.contains('\u{2593}'));
}
}