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::worker_profile::{ShellPolicy, WorkerRuntimeProfile};
#[derive(Debug, Clone)]
struct OperatorInfo {
provider: 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 provider = if app.api_provider == crate::config::ApiProvider::Custom {
app.provider_identity_for_persistence().to_string()
} else {
app.api_provider.display_name().to_string()
};
Self {
provider,
model,
reasoning: app.reasoning_effort_display_label(),
}
}
}
pub struct FleetRosterView {
operator: OperatorInfo,
members: Vec<AgentProfile>,
selected: usize,
detail_scroll: usize,
locale: Locale,
}
impl FleetRosterView {
#[must_use]
pub fn new(app: &App, config: &Config) -> Self {
let mut view = Self::from_parts(
OperatorInfo::from_app(app),
FleetRoster::load(&config.fleet_config(), &app.workspace),
);
view.locale = app.ui_locale;
view
}
fn from_parts(operator: OperatorInfo, roster: FleetRoster) -> Self {
Self {
operator,
members: roster
.members()
.iter()
.filter(|m| !m.id.trim().eq_ignore_ascii_case("operator"))
.cloned()
.collect(),
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("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::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!(
" {}",
tr(self.locale, MessageId::FleetRosterMembersCount)
.replace("{count}", &(self.members.len() + 1).to_string())
),
Style::default().fg(palette::TEXT_SECONDARY),
),
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 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);
(
format!("{pointer}{mark} {} {}", member.id, member_routing(member)),
Style::default().fg(palette::TEXT_PRIMARY),
)
};
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() {
member_detail_lines_with_session(member, Some(self.operator.model.as_str()))
} 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_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, "Member", "operator (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());
detail_field(&mut lines, "Reasoning", operator.reasoning.clone());
detail_field(
&mut lines,
"Description",
"The operator is your main session model; it dispatches Fleet workers via `agent` \
profile spawns and Workflow task({profile}). Change its route with /model or /provider."
.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>,
) -> 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()),
},
);
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),
);
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 {
use super::*;
use crate::tui::views::ViewStack;
use crossterm::event::KeyModifiers;
use std::collections::BTreeMap;
use std::path::PathBuf;
use unicode_width::UnicodeWidthStr;
const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn operator() -> OperatorInfo {
OperatorInfo {
provider: "DeepSeek".to_string(),
model: "deepseek-v4-pro".to_string(),
reasoning: "Auto".to_string(),
}
}
fn built_in_view() -> FleetRosterView {
FleetRosterView::from_parts(operator(), FleetRoster::built_ins_only())
}
fn view_with_overrides() -> FleetRosterView {
let mut members = FleetRoster::built_ins_only()
.members()
.iter()
.filter(|m| !m.id.trim().eq_ignore_ascii_case("operator"))
.cloned()
.collect::<Vec<_>>();
if let Some(reviewer) = members.iter_mut().find(|m| m.id == "reviewer") {
reviewer.origin = ProfileOrigin::Workspace;
reviewer.source = PathBuf::from(".codewhale/agents/reviewer.toml");
reviewer.profile.model = Some("glm-5.2".to_string());
reviewer.profile.role.instructions = Some("Review hard.".to_string());
reviewer.profile.delegation.max_spawn_depth = Some(1);
}
FleetRosterView {
operator: operator(),
members,
selected: 0,
detail_scroll: 0,
locale: Locale::En,
}
}
fn render_through_stack(make: impl Fn() -> FleetRosterView, w: u16, h: u16) -> Vec<String> {
let area = Rect::new(0, 0, w, h);
let mut buf = Buffer::empty(area);
for y in 0..h {
for x in 0..w {
buf[(x, y)].set_symbol("X");
}
}
let mut stack = ViewStack::new();
stack.push(make());
stack.render(area, &mut buf);
(0..h)
.map(|y| {
(0..w)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect()
}
#[test]
fn fleet_roster_glyphs_all_have_ascii_alternatives() {
let rows = render_through_stack(view_with_overrides, 100, 30);
for ch in rows.join("\n").chars().filter(|ch| !ch.is_ascii()) {
let mut cell = ratatui::buffer::Cell::default();
cell.set_symbol(&ch.to_string());
crate::tui::color_compat::adapt_cell_symbol_for_ascii(&mut cell);
assert!(
cell.symbol().is_ascii(),
"fleet glyph {ch:?} (U+{:04X}) lacks an ASCII-safe alternative",
ch as u32
);
}
}
#[test]
fn operator_row_is_pinned_first_with_the_session_model() {
let rows = render_through_stack(built_in_view, 100, 30);
let text = rows.join("\n");
let operator_row = rows
.iter()
.position(|row| row.contains("operator"))
.expect("operator row rendered");
let first_member_row = rows
.iter()
.position(|row| row.contains("manager"))
.expect("first member rendered");
assert!(
operator_row < first_member_row,
"operator must render above the first member"
);
assert!(text.contains("▸ @ operator"), "operator selected on open");
assert!(text.contains("deepseek-v4-pro"), "session model shown");
assert!(text.contains("full session authority"), "{text}");
}
#[test]
fn arrows_move_selection_and_wrap() {
let mut view = built_in_view();
let last = view.members.len();
assert_eq!(view.selected, 0);
view.handle_key(key(KeyCode::Up));
assert_eq!(
view.selected, last,
"up from the operator wraps to the last member (#4755)"
);
view.handle_key(key(KeyCode::Down));
assert_eq!(
view.selected, 0,
"down from the last member wraps to the operator"
);
view.handle_key(key(KeyCode::Down));
assert_eq!(view.selected, 1, "first member follows the operator");
for _ in 0..=last {
view.handle_key(key(KeyCode::Down));
}
assert_eq!(view.selected, 1, "one full cycle is the identity");
}
#[test]
fn selection_change_resets_detail_scroll() {
let mut view = built_in_view();
view.handle_key(key(KeyCode::PageDown));
assert_eq!(view.detail_scroll, 8);
view.handle_key(key(KeyCode::Down));
assert_eq!(view.detail_scroll, 0);
}
#[test]
fn enter_and_s_open_the_setup_wizard_for_members_only() {
for code in [KeyCode::Enter, KeyCode::Char('s')] {
let mut view = built_in_view();
assert!(view.operator_selected());
assert!(
matches!(view.handle_key(key(code)), ViewAction::None),
"{code:?} must be inert on the operator row"
);
view.handle_key(key(KeyCode::Down));
let action = view.handle_key(key(code));
let ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenSetupRequested { role }) =
action
else {
panic!("{code:?} should hand off to the setup wizard");
};
assert_eq!(role, "manager");
}
}
#[test]
fn w_opens_the_live_workers_tab() {
let mut view = built_in_view();
assert!(matches!(
view.handle_key(key(KeyCode::Char('w'))),
ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenWorkersRequested)
));
}
#[test]
fn esc_closes() {
let mut view = built_in_view();
assert!(matches!(
view.handle_key(key(KeyCode::Esc)),
ViewAction::Close
));
}
#[test]
fn built_in_party_lists_all_members_in_canonical_order() {
let view = built_in_view();
let ids: Vec<&str> = view.members.iter().map(|m| m.id.as_str()).collect();
assert_eq!(
ids,
[
"manager",
"scout",
"builder",
"reviewer",
"verifier",
"consultant",
"synthesizer",
"general"
]
);
}
#[test]
fn detail_shows_posture_routing_and_origin() {
let reviewer = FleetRoster::built_ins_only()
.get("reviewer")
.unwrap()
.clone();
assert_eq!(
member_posture(&reviewer),
"reviewer worker · read-only · shell read-only"
);
assert_eq!(member_routing(&reviewer), "inherit session route");
let scout = FleetRoster::built_ins_only().get("scout").unwrap().clone();
assert_eq!(
member_posture(&scout),
"scout worker · read-only · shell read-only"
);
assert_eq!(member_routing(&scout), "inherit session route");
let builder = FleetRoster::built_ins_only()
.get("builder")
.unwrap()
.clone();
assert_eq!(
member_posture(&builder),
"builder worker · write · shell full"
);
let mut pinned = reviewer.clone();
pinned.profile.model = Some("glm-5.2".to_string());
assert_eq!(member_routing(&pinned), "model glm-5.2 (pinned)");
}
#[test]
fn detail_lines_carry_overlay_source_for_project_members() {
let view = view_with_overrides();
let reviewer = view.members.iter().find(|m| m.id == "reviewer").unwrap();
let text = member_detail_lines_with_session(reviewer, None)
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.clone().into_owned())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("project"), "{text}");
assert!(
text.contains("custom overlay (.codewhale/agents/reviewer.toml)"),
"{text}"
);
assert!(text.contains("model glm-5.2 (pinned)"), "{text}");
assert!(text.contains("spawn depth 1"), "{text}");
}
#[test]
fn roster_loads_config_members_through_the_shared_merge() {
let tmp = tempfile::TempDir::new().unwrap();
let mut profiles = BTreeMap::new();
profiles.insert(
"docs-writer".to_string(),
codewhale_config::FleetProfile {
slot: codewhale_config::FleetSlot::from_name("scout"),
role: codewhale_config::FleetRole {
name: "scout".to_string(),
description: Some("Writes docs.".to_string()),
instructions: None,
},
loadout: codewhale_config::FleetLoadout::Fast,
model: None,
provider: None,
reasoning_effort: None,
permissions: codewhale_config::FleetProfilePermissions::default(),
delegation: codewhale_config::FleetDelegationHints::default(),
},
);
let config = codewhale_config::FleetConfigToml {
profiles,
..codewhale_config::FleetConfigToml::default()
};
let view = FleetRosterView::from_parts(operator(), FleetRoster::load(&config, tmp.path()));
let extra = view.members.iter().find(|m| m.id == "docs-writer").unwrap();
assert_eq!(extra.origin, ProfileOrigin::Config);
assert_eq!(
member_routing(extra),
"route preset fast (resolved at launch)"
);
}
#[test]
fn fleet_roster_is_usable_and_opaque_at_blocker_sizes() {
type Builder = (&'static str, fn() -> FleetRosterView);
let builders: [Builder; 3] = [
("built-ins", built_in_view),
("overrides", view_with_overrides),
("last-selected", || {
let mut v = built_in_view();
v.selected = v.row_count() - 1;
v
}),
];
for (label, make) in builders {
for (w, h) in BLOCKER_SIZES {
let rows = render_through_stack(make, w, h);
let text = rows.join("\n");
assert!(
!text.contains('X'),
"{label} {w}x{h}: background bleed-through"
);
assert!(text.contains("close"), "{label} {w}x{h}: missing footer");
assert!(
text.contains("fleet") && text.contains("workers"),
"{label} {w}x{h}: missing framing"
);
assert!(
text.contains("Posture"),
"{label} {w}x{h}: missing detail pane"
);
for (y, row) in rows.iter().enumerate() {
assert!(
UnicodeWidthStr::width(row.trim_end()) <= w as usize,
"{label} {w}x{h}: row {y} overflows: {row:?}"
);
}
}
}
}
#[test]
fn selection_stays_visible_when_list_scrolls() {
let rows = render_through_stack(
|| {
let mut v = built_in_view();
v.selected = v.row_count() - 1;
v
},
80,
24,
);
let text = rows.join("\n");
assert!(text.contains("▸ · general"), "{text}");
}
}