use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Clear, Paragraph, Widget, Wrap},
};
use crate::config::Config;
use crate::fleet::profile::AgentProfile;
use crate::fleet::roster::{FleetRoster, ProfileOrigin};
use crate::fleet::worker_runtime::roster_member_agent_type;
use crate::localization::{Locale, MessageId, tr};
use crate::palette;
use crate::tui::app::App;
use crate::tui::menu_style;
use crate::tui::views::{
ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
truncate_view_text,
};
use crate::tui::whales;
use crate::worker_profile::{ShellPolicy, WorkerRuntimeProfile};
#[derive(Debug, Clone)]
struct OperatorInfo {
provider: String,
provider_id: String,
model: String,
reasoning: String,
}
impl OperatorInfo {
fn from_app(app: &App) -> Self {
let model = if app.auto_model {
app.last_effective_model
.as_deref()
.map(|effective| format!("auto -> {effective}"))
.unwrap_or_else(|| "auto".to_string())
} else {
app.model.clone()
};
let route_provider = if app.auto_model {
app.last_effective_provider.unwrap_or(app.api_provider)
} else {
app.api_provider
};
let provider_id = if app.auto_model {
app.last_effective_provider_identity
.clone()
.unwrap_or_else(|| {
if route_provider == crate::config::ApiProvider::Custom {
app.provider_identity_for_persistence().to_string()
} else {
route_provider.as_str().to_string()
}
})
} else {
app.provider_identity_for_persistence().to_string()
};
let provider = if route_provider == crate::config::ApiProvider::Custom {
provider_id.clone()
} else {
route_provider.display_name().to_string()
};
Self {
provider,
provider_id,
model,
reasoning: app.reasoning_effort_display_label(),
}
}
}
#[derive(Debug, Clone)]
struct SelectedFleetSummary {
name: String,
scope: crate::fleet::store::FleetScope,
}
pub struct FleetRosterView {
operator: OperatorInfo,
members: Vec<AgentProfile>,
shadowed: Vec<crate::fleet::roster::ShadowedProfile>,
selected_fleet: Option<SelectedFleetSummary>,
selected: usize,
detail_scroll: usize,
locale: Locale,
}
impl FleetRosterView {
#[must_use]
pub fn new(app: &App, config: &Config) -> Self {
let selected_fleet =
crate::fleet::store::selected_fleet(&app.workspace).map(|sel| SelectedFleetSummary {
name: sel.name,
scope: sel.scope,
});
let mut view = Self::from_parts(
OperatorInfo::from_app(app),
FleetRoster::load(&config.fleet_config(), &app.workspace),
selected_fleet,
);
view.locale = app.ui_locale;
view
}
fn from_parts(
operator: OperatorInfo,
roster: FleetRoster,
selected_fleet: Option<SelectedFleetSummary>,
) -> Self {
Self {
operator,
members: roster
.members()
.iter()
.filter(|m| !m.id.trim().eq_ignore_ascii_case("operator"))
.cloned()
.collect(),
shadowed: roster.shadowed().to_vec(),
selected_fleet,
selected: 0,
detail_scroll: 0,
locale: Locale::En,
}
}
fn row_count(&self) -> usize {
1 + self.members.len()
}
fn operator_selected(&self) -> bool {
self.selected == 0
}
fn selected_member(&self) -> Option<&AgentProfile> {
self.selected.checked_sub(1).and_then(|idx| {
self.members
.get(idx.min(self.members.len().saturating_sub(1)))
})
}
fn move_up(&mut self) {
self.selected = crate::tui::list_nav::wrap_index(self.selected, self.row_count(), -1);
self.detail_scroll = 0;
}
fn move_down(&mut self) {
self.selected = crate::tui::list_nav::wrap_index(self.selected, self.row_count(), 1);
self.detail_scroll = 0;
}
fn footer_hints(&self) -> Vec<ActionHint> {
vec![
ActionHint::new("↑/↓", "move"),
ActionHint::new("s/Enter", "setup"),
ActionHint::new("f", "saved Fleets"),
ActionHint::new("w", tr(self.locale, MessageId::FleetRosterWorkers)),
ActionHint::new("PgUp/PgDn", "scroll detail"),
ActionHint::new("Esc", "close"),
]
}
}
impl ModalView for FleetRosterView {
fn kind(&self) -> ModalKind {
ModalKind::FleetRoster
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
KeyCode::Up | KeyCode::Char('k') => {
self.move_up();
ViewAction::None
}
KeyCode::Down | KeyCode::Char('j') => {
self.move_down();
ViewAction::None
}
KeyCode::Enter | KeyCode::Char('s') => {
if let Some(member) = self.selected_member() {
let role = member.profile.role.name.clone();
ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenSetupRequested { role })
} else {
ViewAction::None
}
}
KeyCode::Char('w') => {
ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenWorkersRequested)
}
KeyCode::Char('f') => {
ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenFleetsRequested)
}
KeyCode::Home => {
self.detail_scroll = 0;
ViewAction::None
}
KeyCode::PageUp => {
self.detail_scroll = self.detail_scroll.saturating_sub(8);
ViewAction::None
}
KeyCode::PageDown => {
self.detail_scroll = self.detail_scroll.saturating_add(8);
ViewAction::None
}
_ => ViewAction::None,
}
}
fn render(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
Block::default()
.style(Style::default().bg(palette::WHALE_BG))
.render(area, buf);
let hints = self.footer_hints();
let content = render_modal_footer(area, buf, &hints);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(content);
let header = vec![
Line::from(vec![
Span::styled(
format!("─ {} ", tr(self.locale, MessageId::FleetRosterHeaderLabel)),
Style::default().fg(palette::WHALE_ACTION).bold(),
),
Span::styled(
"──────────────────────── ",
Style::default().fg(palette::BORDER_COLOR),
),
Span::styled(
tr(self.locale, MessageId::FleetRosterTabRoster),
Style::default().fg(palette::WHALE_INFO).bold(),
),
Span::styled(
format!(
" {} {} ",
tr(self.locale, MessageId::FleetRosterTabSetup),
tr(self.locale, MessageId::FleetRosterWorkers)
),
Style::default().fg(palette::TEXT_MUTED),
),
Span::styled("─".repeat(24), Style::default().fg(palette::BORDER_COLOR)),
]),
Line::from(""),
Line::from(vec![
Span::styled(
format!(" {}", self.selected_fleet_line()),
Style::default().fg(palette::TEXT_SECONDARY),
),
Span::styled(
format!(
" · {}",
tr(self.locale, MessageId::FleetRosterMembersCount)
.replace("{count}", &(self.members.len() + 1).to_string())
),
Style::default().fg(palette::TEXT_MUTED),
),
Span::styled(
format!(
" · {}",
tr(self.locale, MessageId::FleetRosterOperatorFirst)
),
Style::default().fg(palette::TEXT_MUTED),
),
]),
];
Paragraph::new(header)
.wrap(Wrap { trim: false })
.render(chunks[0], buf);
self.render_body(chunks[1], buf);
}
}
impl FleetRosterView {
fn selected_fleet_line(&self) -> String {
match &self.selected_fleet {
Some(sel) => format!("Fleet `{}` · {}", sel.name, sel.scope.long_label()),
None => "no saved Fleet selected · legacy roster".to_string(),
}
}
fn render_body(&self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let (list_area, detail_area) = if area.width >= 56 {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(45),
Constraint::Length(2),
Constraint::Min(20),
])
.split(area);
(cols[0], cols[2])
} else {
let list_height =
(self.row_count() as u16 + 1).min(area.height.saturating_sub(1).max(1));
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(list_height), Constraint::Min(1)])
.split(area);
(rows[0], rows[1])
};
let visible_rows = usize::from(list_area.height).max(1);
let first = self
.selected
.saturating_sub(visible_rows.saturating_sub(1))
.min(
self.row_count()
.saturating_sub(visible_rows.min(self.row_count())),
);
let list_width = usize::from(list_area.width);
let mut list_lines: Vec<Line> = Vec::with_capacity(visible_rows);
for idx in first..(first + visible_rows).min(self.row_count()) {
let is_selected = idx == self.selected;
let pointer = format!("{} ", crate::tui::glyphs::selection_marker(is_selected));
let (text, base_style) = if idx == 0 {
(
format!(
"{pointer}@ {} {}",
tr(self.locale, MessageId::FleetRosterOperatorRow),
self.operator.model
),
Style::default()
.fg(palette::WHALE_ACTION)
.add_modifier(Modifier::BOLD),
)
} else {
let member = &self.members[idx - 1];
let mark = member_role_mark(member);
let shadow_badge = if self
.shadowed
.iter()
.any(|shadow| shadow.id.trim().eq_ignore_ascii_case(member.id.trim()))
{
" ⚠shadows"
} else {
""
};
let species = member_species(member);
let badge_cells = whales::BADGE_WIDTH + 1;
let text = format!(
"{pointer}{mark} {} {}{}",
member.id,
member_routing(member),
shadow_badge
);
let text = truncate_view_text(&text, list_width.saturating_sub(badge_cells));
let base_style = if is_selected {
menu_style::selected_row_style()
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
let split = pointer.len() + mark.len() + 1;
let (head, tail) = if text.len() >= split && text.is_char_boundary(split) {
text.split_at(split)
} else {
(text.as_str(), "")
};
let mut spans = vec![Span::styled(head.to_string(), base_style)];
for span in whales::badge(species, &palette::UI_THEME) {
spans.push(if is_selected {
Span::styled(
span.content,
span.style
.bg(palette::SELECTION_BG)
.add_modifier(Modifier::BOLD),
)
} else {
span
});
}
spans.push(Span::styled(format!(" {tail}"), base_style));
list_lines.push(Line::from(spans));
continue;
};
let style = if is_selected {
menu_style::selected_row_style()
} else {
base_style
};
list_lines.push(Line::from(Span::styled(
truncate_view_text(&text, list_width),
style,
)));
}
Paragraph::new(list_lines).render(list_area, buf);
let lines = if self.operator_selected() {
operator_detail_lines(&self.operator)
} else if let Some(member) = self.selected_member() {
let mut lines = whale_identity_lines(member, self.locale, area.width);
lines.extend(member_detail_lines_with_session(
member,
Some(self.operator.model.as_str()),
&self.shadowed,
));
lines
} else {
vec![Line::from(Span::styled(
"Roster is empty.",
Style::default().fg(palette::TEXT_MUTED),
))]
};
let wrap_width = usize::from(detail_area.width).max(1);
let visual_rows: usize = lines
.iter()
.map(|line| line.width().div_ceil(wrap_width).max(1))
.sum();
let max_scroll = visual_rows.saturating_sub(usize::from(detail_area.height).max(1));
let scroll = self.detail_scroll.min(max_scroll);
Paragraph::new(lines)
.wrap(Wrap { trim: true })
.scroll((scroll as u16, 0))
.render(detail_area, buf);
}
}
fn member_species(member: &AgentProfile) -> whales::WhaleSpecies {
match whales::WhaleSpecies::for_role_id(&member.id) {
whales::WhaleSpecies::Plain => {
whales::WhaleSpecies::for_fleet_role(&roster_member_agent_type(member))
}
species => species,
}
}
fn whale_identity_lines(
member: &AgentProfile,
locale: Locale,
view_width: u16,
) -> Vec<Line<'static>> {
let species = member_species(member);
let theme = &palette::UI_THEME;
let mut lines: Vec<Line> = Vec::new();
if whales::portrait_fits(view_width) {
lines.extend(whales::portrait(species, None, 0, theme));
}
let mut caption = whales::badge(species, theme);
caption.push(Span::styled(
format!(
" {} · {} · {}",
species.name(),
species.animal(locale),
species.job(locale)
),
Style::default().fg(palette::TEXT_PRIMARY),
));
lines.push(Line::from(caption));
lines.push(Line::from(""));
lines
}
fn member_role_mark(member: &AgentProfile) -> &'static str {
match member.id.as_str() {
"manager" | "scout" => crate::tui::glyphs::ROLE_MANAGER,
"builder" => crate::tui::glyphs::ROLE_BUILDER,
"reviewer" => crate::tui::glyphs::ROLE_REVIEWER,
"verifier" => crate::tui::glyphs::ROLE_VERIFIER,
"synthesizer" => crate::tui::glyphs::ROLE_SYNTHESIZER,
_ => match roster_member_agent_type(member).as_str() {
"scout" | "manager" => crate::tui::glyphs::ROLE_MANAGER,
"builder" => crate::tui::glyphs::ROLE_BUILDER,
"reviewer" => crate::tui::glyphs::ROLE_REVIEWER,
"verifier" => crate::tui::glyphs::ROLE_VERIFIER,
"synthesizer" => crate::tui::glyphs::ROLE_SYNTHESIZER,
_ => crate::tui::glyphs::NEUTRAL,
},
}
}
fn detail_field(lines: &mut Vec<Line<'static>>, label: &str, body: String) {
lines.push(Line::from(Span::styled(
label.to_string(),
Style::default().fg(palette::WHALE_INFO).bold(),
)));
lines.push(Line::from(Span::styled(
body,
Style::default().fg(palette::TEXT_PRIMARY),
)));
lines.push(Line::from(""));
}
fn operator_detail_lines(operator: &OperatorInfo) -> Vec<Line<'static>> {
let mut lines: Vec<Line> = Vec::new();
detail_field(
&mut lines,
"Role",
"operator · Fleet leader (session route)".to_string(),
);
detail_field(&mut lines, "Origin", "session".to_string());
detail_field(&mut lines, "Posture", "full session authority".to_string());
detail_field(&mut lines, "Provider", operator.provider.clone());
detail_field(&mut lines, "Model", operator.model.clone());
if let Some(badges) = crate::fleet::capability_badges::resolve_route_capability_badges(
Some(&operator.provider_id),
&operator.model,
) {
detail_field(&mut lines, "Capabilities", badges.summary());
}
detail_field(&mut lines, "Reasoning", operator.reasoning.clone());
detail_field(
&mut lines,
"Description",
"The operator is the Fleet leader — your main session model. Every member \
below works for this route. It dispatches workers via `agent` profile \
spawns and Workflow task({profile}). Change its route with /model or \
/provider; persist with /fleet save."
.to_string(),
);
lines
}
fn member_posture(member: &AgentProfile) -> String {
let agent_type = roster_member_agent_type(member);
let runtime = WorkerRuntimeProfile::for_role(agent_type.clone());
let write = if runtime.permissions.write {
"write"
} else {
"read-only"
};
let shell = match runtime.shell {
ShellPolicy::None => "shell none",
ShellPolicy::ReadOnly => "shell read-only",
ShellPolicy::Full => "shell full",
};
format!("{} worker · {write} · {shell}", agent_type.as_str())
}
fn member_routing(member: &AgentProfile) -> String {
member_routing_with_session(member, None)
}
fn member_routing_with_session(member: &AgentProfile, session_model: Option<&str>) -> String {
if let Some(model) = member
.profile
.model
.as_deref()
.map(str::trim)
.filter(|model| !model.is_empty())
{
return format!("model {model} (pinned)");
}
match member.profile.loadout.as_str() {
"inherit" => "inherit session route".to_string(),
"fast" => match session_model.map(str::trim).filter(|m| !m.is_empty()) {
Some(session) => format!("fast sibling of {session} (resolved)"),
None => "route preset fast (resolved at launch)".to_string(),
},
loadout => format!("route preset {loadout}"),
}
}
fn member_detail_lines_with_session(
member: &AgentProfile,
session_model: Option<&str>,
shadowed: &[crate::fleet::roster::ShadowedProfile],
) -> Vec<Line<'static>> {
let mut lines: Vec<Line> = Vec::new();
let name = match member.display_name.as_deref().map(str::trim) {
Some(display_name) if !display_name.is_empty() && display_name != member.id => {
format!("{display_name} ({})", member.id)
}
_ => member.id.clone(),
};
detail_field(&mut lines, "Member", name);
detail_field(
&mut lines,
"Origin",
match member.origin {
ProfileOrigin::BuiltIn => "built-in (default party)".to_string(),
_ => format!("{} · {}", member.origin, member.source.display()),
},
);
for shadow in shadowed
.iter()
.filter(|shadow| shadow.id.trim().eq_ignore_ascii_case(member.id.trim()))
{
detail_field(
&mut lines,
"Shadows",
format!(
"{} copy at {} (ignored)",
shadow.shadowed_origin,
shadow.shadowed_source.display()
),
);
}
detail_field(&mut lines, "Slot", member.profile.slot.as_str().to_string());
detail_field(&mut lines, "Posture", member_posture(member));
detail_field(
&mut lines,
"Routing",
member_routing_with_session(member, session_model),
);
if let Some(model) = member
.profile
.model
.as_deref()
.map(str::trim)
.filter(|model| !model.is_empty())
&& let Some(badges) = crate::fleet::capability_badges::resolve_route_capability_badges(
member.profile.provider.as_deref(),
model,
)
{
detail_field(&mut lines, "Capabilities", badges.summary());
}
let delegation = &member.profile.delegation;
if delegation.max_spawn_depth.is_some() || delegation.max_concurrency.is_some() {
let mut bounds: Vec<String> = Vec::new();
if let Some(depth) = delegation.max_spawn_depth {
bounds.push(format!("spawn depth {depth}"));
}
if let Some(concurrency) = delegation.max_concurrency {
bounds.push(format!("concurrency {concurrency}"));
}
detail_field(&mut lines, "Delegation", bounds.join(" · "));
}
detail_field(
&mut lines,
"Instructions",
if member.profile.role.instructions.is_some() {
match member.origin {
ProfileOrigin::Workspace => {
format!("custom overlay ({})", member.source.display())
}
ProfileOrigin::Personal => {
format!("personal overlay ({})", member.source.display())
}
_ => "custom overlay".to_string(),
}
} else {
"none (role posture only)".to_string()
},
);
if let Some(description) = member
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
{
detail_field(&mut lines, "Description", description.to_string());
}
lines
}
#[cfg(test)]
mod tests;