| 1 | //! `/fleet` roster — the barracks view of the saved agent party. |
| 2 | //! |
| 3 | //! The roster view is the primary `/fleet` face. The first row is the |
| 4 | //! **operator** — the Fleet leader (your live session model). When a user |
| 5 | //! picks a session model they are picking the operator, and every member |
| 6 | //! below is that leader's team. The header names the selected saved Fleet and |
| 7 | //! whether it is user-global or folder-scoped, so scope is never ambiguous. |
| 8 | //! Below the operator sits the merged [`FleetRoster`] (built-in < |
| 9 | //! `[fleet.profiles]` config < `$CODEWHALE_HOME/agents/*.toml` personal < |
| 10 | //! `.codewhale/agents/*.toml` project members) |
| 11 | //! as a scrollable list with a detail pane for the selected row. The view |
| 12 | //! never writes anything; `s` / Enter on a member hands off to the |
| 13 | //! `/fleet setup` wizard for authoring and overrides (the operator row is |
| 14 | //! display-only — its route changes via `/model` or `/provider`). Switch |
| 15 | //! named Fleets with `/fleet fleets`. |
| 16 | //! |
| 17 | //! NOTE: like `fleet_setup.rs`, the copy below is intentionally English for |
| 18 | //! now (#3167 reworks Fleet UI localization); the command entry |
| 19 | //! (`CmdFleetDescription`) is already localized. |
| 20 | |
| 21 | use crossterm::event::{KeyCode, KeyEvent}; |
| 22 | use ratatui::{ |
| 23 | buffer::Buffer, |
| 24 | layout::{Constraint, Direction, Layout, Rect}, |
| 25 | style::{Modifier, Style}, |
| 26 | text::{Line, Span}, |
| 27 | widgets::{Block, Clear, Paragraph, Widget, Wrap}, |
| 28 | }; |
| 29 | |
| 30 | use crate::config::Config; |
| 31 | use crate::fleet::profile::AgentProfile; |
| 32 | use crate::fleet::roster::{FleetRoster, ProfileOrigin}; |
| 33 | use crate::fleet::worker_runtime::roster_member_agent_type; |
| 34 | use crate::localization::{Locale, MessageId, tr}; |
| 35 | use crate::palette; |
| 36 | use crate::tui::app::App; |
| 37 | use crate::tui::menu_style; |
| 38 | use crate::tui::views::{ |
| 39 | ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer, |
| 40 | truncate_view_text, |
| 41 | }; |
| 42 | use crate::worker_profile::{ShellPolicy, WorkerRuntimeProfile}; |
| 43 | |
| 44 | /// The live session route — the operator the roster works for. Read once at |
| 45 | /// open, the same way [`super::fleet_setup::FleetSetupSnapshot`] snapshots it. |
| 46 | #[derive(Debug, Clone)] |
| 47 | struct OperatorInfo { |
| 48 | provider: String, |
| 49 | /// Exact canonical route key, kept separate from the display label so |
| 50 | /// capability lookup can use provider-scoped catalog facts. |
| 51 | provider_id: String, |
| 52 | model: String, |
| 53 | reasoning: String, |
| 54 | } |
| 55 | |
| 56 | impl OperatorInfo { |
| 57 | fn from_app(app: &App) -> Self { |
| 58 | let model = if app.auto_model { |
| 59 | app.last_effective_model |
| 60 | .as_deref() |
| 61 | .map(|effective| format!("auto -> {effective}")) |
| 62 | .unwrap_or_else(|| "auto".to_string()) |
| 63 | } else { |
| 64 | app.model.clone() |
| 65 | }; |
| 66 | let route_provider = if app.auto_model { |
| 67 | app.last_effective_provider.unwrap_or(app.api_provider) |
| 68 | } else { |
| 69 | app.api_provider |
| 70 | }; |
| 71 | let provider_id = if app.auto_model { |
| 72 | app.last_effective_provider_identity |
| 73 | .clone() |
| 74 | .unwrap_or_else(|| { |
| 75 | if route_provider == crate::config::ApiProvider::Custom { |
| 76 | app.provider_identity_for_persistence().to_string() |
| 77 | } else { |
| 78 | route_provider.as_str().to_string() |
| 79 | } |
| 80 | }) |
| 81 | } else { |
| 82 | app.provider_identity_for_persistence().to_string() |
| 83 | }; |
| 84 | let provider = if route_provider == crate::config::ApiProvider::Custom { |
| 85 | provider_id.clone() |
| 86 | } else { |
| 87 | route_provider.display_name().to_string() |
| 88 | }; |
| 89 | Self { |
| 90 | provider, |
| 91 | provider_id, |
| 92 | model, |
| 93 | reasoning: app.reasoning_effort_display_label(), |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | /// Which named Fleet (if any) this session is using, and where that selection |
| 99 | /// is pinned — user-global vs this folder only. |
| 100 | #[derive(Debug, Clone)] |
| 101 | struct SelectedFleetSummary { |
| 102 | name: String, |
| 103 | scope: crate::fleet::store::FleetScope, |
| 104 | } |
| 105 | |
| 106 | pub struct FleetRosterView { |
| 107 | operator: OperatorInfo, |
| 108 | members: Vec<AgentProfile>, |
| 109 | /// Shadow records from the roster load (#5098): which lower-precedence |
| 110 | /// files the displayed members are ignoring. |
| 111 | shadowed: Vec<crate::fleet::roster::ShadowedProfile>, |
| 112 | /// Selected named Fleet + scope, when one is active for this session. |
| 113 | selected_fleet: Option<SelectedFleetSummary>, |
| 114 | /// Selected row: 0 is the pinned operator row, members follow at 1.. |
| 115 | selected: usize, |
| 116 | detail_scroll: usize, |
| 117 | /// UI locale captured from the app at construction (#4057 wave 2). |
| 118 | locale: Locale, |
| 119 | } |
| 120 | |
| 121 | impl FleetRosterView { |
| 122 | #[must_use] |
| 123 | pub fn new(app: &App, config: &Config) -> Self { |
| 124 | let selected_fleet = |
| 125 | crate::fleet::store::selected_fleet(&app.workspace).map(|sel| SelectedFleetSummary { |
| 126 | name: sel.name, |
| 127 | scope: sel.scope, |
| 128 | }); |
| 129 | let mut view = Self::from_parts( |
| 130 | OperatorInfo::from_app(app), |
| 131 | FleetRoster::load(&config.fleet_config(), &app.workspace), |
| 132 | selected_fleet, |
| 133 | ); |
| 134 | view.locale = app.ui_locale; |
| 135 | view |
| 136 | } |
| 137 | |
| 138 | fn from_parts( |
| 139 | operator: OperatorInfo, |
| 140 | roster: FleetRoster, |
| 141 | selected_fleet: Option<SelectedFleetSummary>, |
| 142 | ) -> Self { |
| 143 | Self { |
| 144 | operator, |
| 145 | // The operator is pinned as its own row 0 (the live session route), |
| 146 | // so exclude the built-in "operator" profile from the dispatchable |
| 147 | // member list to avoid rendering it twice (#dogfood 0.8.67). The |
| 148 | // engine's FleetRoster is untouched, so role/dispatch semantics are |
| 149 | // unchanged; only this view drops the duplicate. |
| 150 | members: roster |
| 151 | .members() |
| 152 | .iter() |
| 153 | .filter(|m| !m.id.trim().eq_ignore_ascii_case("operator")) |
| 154 | .cloned() |
| 155 | .collect(), |
| 156 | shadowed: roster.shadowed().to_vec(), |
| 157 | selected_fleet, |
| 158 | selected: 0, |
| 159 | detail_scroll: 0, |
| 160 | locale: Locale::En, |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | /// Total selectable rows: the operator plus every roster member. |
| 165 | fn row_count(&self) -> usize { |
| 166 | 1 + self.members.len() |
| 167 | } |
| 168 | |
| 169 | fn operator_selected(&self) -> bool { |
| 170 | self.selected == 0 |
| 171 | } |
| 172 | |
| 173 | fn selected_member(&self) -> Option<&AgentProfile> { |
| 174 | self.selected.checked_sub(1).and_then(|idx| { |
| 175 | self.members |
| 176 | .get(idx.min(self.members.len().saturating_sub(1))) |
| 177 | }) |
| 178 | } |
| 179 | |
| 180 | fn move_up(&mut self) { |
| 181 | self.selected = crate::tui::list_nav::wrap_index(self.selected, self.row_count(), -1); |
| 182 | self.detail_scroll = 0; |
| 183 | } |
| 184 | |
| 185 | fn move_down(&mut self) { |
| 186 | self.selected = crate::tui::list_nav::wrap_index(self.selected, self.row_count(), 1); |
| 187 | self.detail_scroll = 0; |
| 188 | } |
| 189 | |
| 190 | fn footer_hints(&self) -> Vec<ActionHint> { |
| 191 | vec![ |
| 192 | ActionHint::new("↑/↓", "move"), |
| 193 | ActionHint::new("s/Enter", "setup"), |
| 194 | ActionHint::new("f", "saved Fleets"), |
| 195 | ActionHint::new("w", tr(self.locale, MessageId::FleetRosterWorkers)), |
| 196 | ActionHint::new("PgUp/PgDn", "scroll detail"), |
| 197 | ActionHint::new("Esc", "close"), |
| 198 | ] |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | impl ModalView for FleetRosterView { |
| 203 | fn kind(&self) -> ModalKind { |
| 204 | ModalKind::FleetRoster |
| 205 | } |
| 206 | |
| 207 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 208 | self |
| 209 | } |
| 210 | |
| 211 | fn handle_key(&mut self, key: KeyEvent) -> ViewAction { |
| 212 | match key.code { |
| 213 | KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close, |
| 214 | KeyCode::Up | KeyCode::Char('k') => { |
| 215 | self.move_up(); |
| 216 | ViewAction::None |
| 217 | } |
| 218 | KeyCode::Down | KeyCode::Char('j') => { |
| 219 | self.move_down(); |
| 220 | ViewAction::None |
| 221 | } |
| 222 | KeyCode::Enter | KeyCode::Char('s') => { |
| 223 | if let Some(member) = self.selected_member() { |
| 224 | let role = member.profile.role.name.clone(); |
| 225 | // Carry the role the operator already chose. The setup |
| 226 | // wizard can still step back to Role when they want to |
| 227 | // change it, but does not force a duplicate selection. |
| 228 | ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenSetupRequested { role }) |
| 229 | } else { |
| 230 | // The operator is not a wizard-authored profile; its |
| 231 | // route changes via /model or /provider (the detail pane |
| 232 | // says so). |
| 233 | ViewAction::None |
| 234 | } |
| 235 | } |
| 236 | KeyCode::Char('w') => { |
| 237 | ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenWorkersRequested) |
| 238 | } |
| 239 | KeyCode::Char('f') => { |
| 240 | ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenFleetsRequested) |
| 241 | } |
| 242 | KeyCode::Home => { |
| 243 | self.detail_scroll = 0; |
| 244 | ViewAction::None |
| 245 | } |
| 246 | KeyCode::PageUp => { |
| 247 | self.detail_scroll = self.detail_scroll.saturating_sub(8); |
| 248 | ViewAction::None |
| 249 | } |
| 250 | KeyCode::PageDown => { |
| 251 | self.detail_scroll = self.detail_scroll.saturating_add(8); |
| 252 | ViewAction::None |
| 253 | } |
| 254 | _ => ViewAction::None, |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 259 | Clear.render(area, buf); |
| 260 | Block::default() |
| 261 | .style(Style::default().bg(palette::WHALE_BG)) |
| 262 | .render(area, buf); |
| 263 | |
| 264 | let hints = self.footer_hints(); |
| 265 | let content = render_modal_footer(area, buf, &hints); |
| 266 | |
| 267 | // Hairline shell shared with the HTML route/config/Fleet surfaces. |
| 268 | // This replaces the centered legacy card: Fleet is a product room, |
| 269 | // not a popup floating over an unrelated transcript. |
| 270 | let chunks = Layout::default() |
| 271 | .direction(Direction::Vertical) |
| 272 | .constraints([Constraint::Length(3), Constraint::Min(1)]) |
| 273 | .split(content); |
| 274 | let header = vec![ |
| 275 | Line::from(vec![ |
| 276 | Span::styled( |
| 277 | format!("─ {} ", tr(self.locale, MessageId::FleetRosterHeaderLabel)), |
| 278 | Style::default().fg(palette::WHALE_ACTION).bold(), |
| 279 | ), |
| 280 | Span::styled( |
| 281 | "──────────────────────── ", |
| 282 | Style::default().fg(palette::BORDER_COLOR), |
| 283 | ), |
| 284 | Span::styled( |
| 285 | tr(self.locale, MessageId::FleetRosterTabRoster), |
| 286 | Style::default().fg(palette::WHALE_INFO).bold(), |
| 287 | ), |
| 288 | Span::styled( |
| 289 | format!( |
| 290 | " {} {} ", |
| 291 | tr(self.locale, MessageId::FleetRosterTabSetup), |
| 292 | tr(self.locale, MessageId::FleetRosterWorkers) |
| 293 | ), |
| 294 | Style::default().fg(palette::TEXT_MUTED), |
| 295 | ), |
| 296 | Span::styled("─".repeat(24), Style::default().fg(palette::BORDER_COLOR)), |
| 297 | ]), |
| 298 | Line::from(""), |
| 299 | Line::from(vec![ |
| 300 | Span::styled( |
| 301 | format!(" {}", self.selected_fleet_line()), |
| 302 | Style::default().fg(palette::TEXT_SECONDARY), |
| 303 | ), |
| 304 | Span::styled( |
| 305 | format!( |
| 306 | " · {}", |
| 307 | tr(self.locale, MessageId::FleetRosterMembersCount) |
| 308 | .replace("{count}", &(self.members.len() + 1).to_string()) |
| 309 | ), |
| 310 | Style::default().fg(palette::TEXT_MUTED), |
| 311 | ), |
| 312 | Span::styled( |
| 313 | format!( |
| 314 | " · {}", |
| 315 | tr(self.locale, MessageId::FleetRosterOperatorFirst) |
| 316 | ), |
| 317 | Style::default().fg(palette::TEXT_MUTED), |
| 318 | ), |
| 319 | ]), |
| 320 | ]; |
| 321 | Paragraph::new(header) |
| 322 | .wrap(Wrap { trim: false }) |
| 323 | .render(chunks[0], buf); |
| 324 | |
| 325 | self.render_body(chunks[1], buf); |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | impl FleetRosterView { |
| 330 | /// Scope-explicit selected Fleet line. Paths stay out — receipts name them. |
| 331 | fn selected_fleet_line(&self) -> String { |
| 332 | match &self.selected_fleet { |
| 333 | Some(sel) => format!("Fleet `{}` · {}", sel.name, sel.scope.long_label()), |
| 334 | None => "no saved Fleet selected · legacy roster".to_string(), |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | fn render_body(&self, area: Rect, buf: &mut Buffer) { |
| 339 | if area.width == 0 || area.height == 0 { |
| 340 | return; |
| 341 | } |
| 342 | |
| 343 | // Two columns when there is room, stacked otherwise — same responsive |
| 344 | // shape as the setup wizard's choice step so nothing truncates at |
| 345 | // 80x24. |
| 346 | let (list_area, detail_area) = if area.width >= 56 { |
| 347 | let cols = Layout::default() |
| 348 | .direction(Direction::Horizontal) |
| 349 | .constraints([ |
| 350 | Constraint::Percentage(45), |
| 351 | Constraint::Length(2), |
| 352 | Constraint::Min(20), |
| 353 | ]) |
| 354 | .split(area); |
| 355 | (cols[0], cols[2]) |
| 356 | } else { |
| 357 | let list_height = |
| 358 | (self.row_count() as u16 + 1).min(area.height.saturating_sub(1).max(1)); |
| 359 | let rows = Layout::default() |
| 360 | .direction(Direction::Vertical) |
| 361 | .constraints([Constraint::Length(list_height), Constraint::Min(1)]) |
| 362 | .split(area); |
| 363 | (rows[0], rows[1]) |
| 364 | }; |
| 365 | |
| 366 | // Row list: the pinned operator first, then one row per member, |
| 367 | // scrolled so the selection stays visible when the party outgrows |
| 368 | // the pane. |
| 369 | let visible_rows = usize::from(list_area.height).max(1); |
| 370 | let first = self |
| 371 | .selected |
| 372 | .saturating_sub(visible_rows.saturating_sub(1)) |
| 373 | .min( |
| 374 | self.row_count() |
| 375 | .saturating_sub(visible_rows.min(self.row_count())), |
| 376 | ); |
| 377 | let list_width = usize::from(list_area.width); |
| 378 | let mut list_lines: Vec<Line> = Vec::with_capacity(visible_rows); |
| 379 | for idx in first..(first + visible_rows).min(self.row_count()) { |
| 380 | let is_selected = idx == self.selected; |
| 381 | let pointer = format!("{} ", crate::tui::glyphs::selection_marker(is_selected)); |
| 382 | let (text, base_style) = if idx == 0 { |
| 383 | ( |
| 384 | format!( |
| 385 | "{pointer}@ {} {}", |
| 386 | tr(self.locale, MessageId::FleetRosterOperatorRow), |
| 387 | self.operator.model |
| 388 | ), |
| 389 | Style::default() |
| 390 | .fg(palette::WHALE_ACTION) |
| 391 | .add_modifier(Modifier::BOLD), |
| 392 | ) |
| 393 | } else { |
| 394 | let member = &self.members[idx - 1]; |
| 395 | let mark = member_role_mark(member); |
| 396 | // #5098: badge rows whose winning layer is ignoring a |
| 397 | // lower-precedence file, so a shadowed personal/project edit |
| 398 | // is visible from the list, not just the detail pane. |
| 399 | let shadow_badge = if self |
| 400 | .shadowed |
| 401 | .iter() |
| 402 | .any(|shadow| shadow.id.trim().eq_ignore_ascii_case(member.id.trim())) |
| 403 | { |
| 404 | " ⚠shadows" |
| 405 | } else { |
| 406 | "" |
| 407 | }; |
| 408 | ( |
| 409 | format!( |
| 410 | "{pointer}{mark} {} {}{}", |
| 411 | member.id, |
| 412 | member_routing(member), |
| 413 | shadow_badge |
| 414 | ), |
| 415 | Style::default().fg(palette::TEXT_PRIMARY), |
| 416 | ) |
| 417 | }; |
| 418 | let style = if is_selected { |
| 419 | menu_style::selected_row_style() |
| 420 | } else { |
| 421 | base_style |
| 422 | }; |
| 423 | list_lines.push(Line::from(Span::styled( |
| 424 | truncate_view_text(&text, list_width), |
| 425 | style, |
| 426 | ))); |
| 427 | } |
| 428 | Paragraph::new(list_lines).render(list_area, buf); |
| 429 | |
| 430 | // Detail pane for the selected row. |
| 431 | let lines = if self.operator_selected() { |
| 432 | operator_detail_lines(&self.operator) |
| 433 | } else if let Some(member) = self.selected_member() { |
| 434 | // Session model is the operator route so "fast" loadouts resolve |
| 435 | // to the fast sibling the runtime will actually launch. |
| 436 | member_detail_lines_with_session( |
| 437 | member, |
| 438 | Some(self.operator.model.as_str()), |
| 439 | &self.shadowed, |
| 440 | ) |
| 441 | } else { |
| 442 | vec![Line::from(Span::styled( |
| 443 | "Roster is empty.", |
| 444 | Style::default().fg(palette::TEXT_MUTED), |
| 445 | ))] |
| 446 | }; |
| 447 | |
| 448 | // Same wrapped-row scroll bound as the setup review step: count |
| 449 | // visual rows so the tail stays reachable. |
| 450 | let wrap_width = usize::from(detail_area.width).max(1); |
| 451 | let visual_rows: usize = lines |
| 452 | .iter() |
| 453 | .map(|line| line.width().div_ceil(wrap_width).max(1)) |
| 454 | .sum(); |
| 455 | let max_scroll = visual_rows.saturating_sub(usize::from(detail_area.height).max(1)); |
| 456 | let scroll = self.detail_scroll.min(max_scroll); |
| 457 | Paragraph::new(lines) |
| 458 | .wrap(Wrap { trim: true }) |
| 459 | .scroll((scroll as u16, 0)) |
| 460 | .render(detail_area, buf); |
| 461 | } |
| 462 | } |
| 463 | |
| 464 | fn member_role_mark(member: &AgentProfile) -> &'static str { |
| 465 | match member.id.as_str() { |
| 466 | "manager" | "scout" => crate::tui::glyphs::ROLE_MANAGER, |
| 467 | "builder" => crate::tui::glyphs::ROLE_BUILDER, |
| 468 | "reviewer" => crate::tui::glyphs::ROLE_REVIEWER, |
| 469 | "verifier" => crate::tui::glyphs::ROLE_VERIFIER, |
| 470 | "synthesizer" => crate::tui::glyphs::ROLE_SYNTHESIZER, |
| 471 | _ => match roster_member_agent_type(member).as_str() { |
| 472 | "scout" | "manager" => crate::tui::glyphs::ROLE_MANAGER, |
| 473 | "builder" => crate::tui::glyphs::ROLE_BUILDER, |
| 474 | "reviewer" => crate::tui::glyphs::ROLE_REVIEWER, |
| 475 | "verifier" => crate::tui::glyphs::ROLE_VERIFIER, |
| 476 | "synthesizer" => crate::tui::glyphs::ROLE_SYNTHESIZER, |
| 477 | _ => crate::tui::glyphs::NEUTRAL, |
| 478 | }, |
| 479 | } |
| 480 | } |
| 481 | |
| 482 | /// Shared field renderer for the detail pane. |
| 483 | fn detail_field(lines: &mut Vec<Line<'static>>, label: &str, body: String) { |
| 484 | lines.push(Line::from(Span::styled( |
| 485 | label.to_string(), |
| 486 | Style::default().fg(palette::WHALE_INFO).bold(), |
| 487 | ))); |
| 488 | lines.push(Line::from(Span::styled( |
| 489 | body, |
| 490 | Style::default().fg(palette::TEXT_PRIMARY), |
| 491 | ))); |
| 492 | lines.push(Line::from("")); |
| 493 | } |
| 494 | |
| 495 | /// Detail pane for the pinned operator row: the live session route, plus the |
| 496 | /// product truth that the operator is this Fleet's leader. |
| 497 | fn operator_detail_lines(operator: &OperatorInfo) -> Vec<Line<'static>> { |
| 498 | let mut lines: Vec<Line> = Vec::new(); |
| 499 | detail_field( |
| 500 | &mut lines, |
| 501 | "Role", |
| 502 | "operator · Fleet leader (session route)".to_string(), |
| 503 | ); |
| 504 | detail_field(&mut lines, "Origin", "session".to_string()); |
| 505 | detail_field(&mut lines, "Posture", "full session authority".to_string()); |
| 506 | detail_field(&mut lines, "Provider", operator.provider.clone()); |
| 507 | detail_field(&mut lines, "Model", operator.model.clone()); |
| 508 | // Session-route capability badges (#5038). Use the exact route key rather |
| 509 | // than the display label so built-in routes get provider-scoped catalog |
| 510 | // facts; custom routes still fall back conservatively to registry facts. |
| 511 | if let Some(badges) = crate::fleet::capability_badges::resolve_route_capability_badges( |
| 512 | Some(&operator.provider_id), |
| 513 | &operator.model, |
| 514 | ) { |
| 515 | detail_field(&mut lines, "Capabilities", badges.summary()); |
| 516 | } |
| 517 | detail_field(&mut lines, "Reasoning", operator.reasoning.clone()); |
| 518 | detail_field( |
| 519 | &mut lines, |
| 520 | "Description", |
| 521 | "The operator is the Fleet leader — your main session model. Every member \ |
| 522 | below works for this route. It dispatches workers via `agent` profile \ |
| 523 | spawns and Workflow task({profile}). Change its route with /model or \ |
| 524 | /provider; persist with /fleet save." |
| 525 | .to_string(), |
| 526 | ); |
| 527 | lines |
| 528 | } |
| 529 | |
| 530 | /// The resolved worker posture for a roster member: what the runtime would |
| 531 | /// actually grant when this member is dispatched (role posture, not the |
| 532 | /// profile's requested permissions). |
| 533 | fn member_posture(member: &AgentProfile) -> String { |
| 534 | let agent_type = roster_member_agent_type(member); |
| 535 | let runtime = WorkerRuntimeProfile::for_role(agent_type.clone()); |
| 536 | let write = if runtime.permissions.write { |
| 537 | "write" |
| 538 | } else { |
| 539 | "read-only" |
| 540 | }; |
| 541 | let shell = match runtime.shell { |
| 542 | ShellPolicy::None => "shell none", |
| 543 | ShellPolicy::ReadOnly => "shell read-only", |
| 544 | ShellPolicy::Full => "shell full", |
| 545 | }; |
| 546 | format!("{} worker · {write} · {shell}", agent_type.as_str()) |
| 547 | } |
| 548 | |
| 549 | /// The routing truth for a member: explicit model pin, else route preset, else |
| 550 | /// same-route inheritance. `[subagents]` overrides still win at dispatch. |
| 551 | /// |
| 552 | /// When the loadout is `fast`, show that the runtime resolves the **fast |
| 553 | /// sibling of the active session model** — not a stale on-disk profile name — |
| 554 | /// so the roster matches what Fleet will actually launch. |
| 555 | fn member_routing(member: &AgentProfile) -> String { |
| 556 | member_routing_with_session(member, None) |
| 557 | } |
| 558 | |
| 559 | fn member_routing_with_session(member: &AgentProfile, session_model: Option<&str>) -> String { |
| 560 | if let Some(model) = member |
| 561 | .profile |
| 562 | .model |
| 563 | .as_deref() |
| 564 | .map(str::trim) |
| 565 | .filter(|model| !model.is_empty()) |
| 566 | { |
| 567 | return format!("model {model} (pinned)"); |
| 568 | } |
| 569 | match member.profile.loadout.as_str() { |
| 570 | "inherit" => "inherit session route".to_string(), |
| 571 | "fast" => match session_model.map(str::trim).filter(|m| !m.is_empty()) { |
| 572 | Some(session) => format!("fast sibling of {session} (resolved)"), |
| 573 | None => "route preset fast (resolved at launch)".to_string(), |
| 574 | }, |
| 575 | loadout => format!("route preset {loadout}"), |
| 576 | } |
| 577 | } |
| 578 | |
| 579 | fn member_detail_lines_with_session( |
| 580 | member: &AgentProfile, |
| 581 | session_model: Option<&str>, |
| 582 | shadowed: &[crate::fleet::roster::ShadowedProfile], |
| 583 | ) -> Vec<Line<'static>> { |
| 584 | let mut lines: Vec<Line> = Vec::new(); |
| 585 | |
| 586 | let name = match member.display_name.as_deref().map(str::trim) { |
| 587 | Some(display_name) if !display_name.is_empty() && display_name != member.id => { |
| 588 | format!("{display_name} ({})", member.id) |
| 589 | } |
| 590 | _ => member.id.clone(), |
| 591 | }; |
| 592 | detail_field(&mut lines, "Member", name); |
| 593 | detail_field( |
| 594 | &mut lines, |
| 595 | "Origin", |
| 596 | match member.origin { |
| 597 | ProfileOrigin::BuiltIn => "built-in (default party)".to_string(), |
| 598 | _ => format!("{} · {}", member.origin, member.source.display()), |
| 599 | }, |
| 600 | ); |
| 601 | // #5098: every layer this member is displacing, so a shadowed personal or |
| 602 | // project file is visible instead of silently dropped from the merge. |
| 603 | for shadow in shadowed |
| 604 | .iter() |
| 605 | .filter(|shadow| shadow.id.trim().eq_ignore_ascii_case(member.id.trim())) |
| 606 | { |
| 607 | detail_field( |
| 608 | &mut lines, |
| 609 | "Shadows", |
| 610 | format!( |
| 611 | "{} copy at {} (ignored)", |
| 612 | shadow.shadowed_origin, |
| 613 | shadow.shadowed_source.display() |
| 614 | ), |
| 615 | ); |
| 616 | } |
| 617 | detail_field(&mut lines, "Slot", member.profile.slot.as_str().to_string()); |
| 618 | detail_field(&mut lines, "Posture", member_posture(member)); |
| 619 | detail_field( |
| 620 | &mut lines, |
| 621 | "Routing", |
| 622 | member_routing_with_session(member, session_model), |
| 623 | ); |
| 624 | |
| 625 | // Capability badges for a pinned model, from the shared Fleet resolver |
| 626 | // (#5038). Unknown models omit the field rather than fabricating facts. |
| 627 | if let Some(model) = member |
| 628 | .profile |
| 629 | .model |
| 630 | .as_deref() |
| 631 | .map(str::trim) |
| 632 | .filter(|model| !model.is_empty()) |
| 633 | && let Some(badges) = crate::fleet::capability_badges::resolve_route_capability_badges( |
| 634 | member.profile.provider.as_deref(), |
| 635 | model, |
| 636 | ) |
| 637 | { |
| 638 | detail_field(&mut lines, "Capabilities", badges.summary()); |
| 639 | } |
| 640 | |
| 641 | let delegation = &member.profile.delegation; |
| 642 | if delegation.max_spawn_depth.is_some() || delegation.max_concurrency.is_some() { |
| 643 | let mut bounds: Vec<String> = Vec::new(); |
| 644 | if let Some(depth) = delegation.max_spawn_depth { |
| 645 | bounds.push(format!("spawn depth {depth}")); |
| 646 | } |
| 647 | if let Some(concurrency) = delegation.max_concurrency { |
| 648 | bounds.push(format!("concurrency {concurrency}")); |
| 649 | } |
| 650 | detail_field(&mut lines, "Delegation", bounds.join(" · ")); |
| 651 | } |
| 652 | |
| 653 | detail_field( |
| 654 | &mut lines, |
| 655 | "Instructions", |
| 656 | if member.profile.role.instructions.is_some() { |
| 657 | match member.origin { |
| 658 | ProfileOrigin::Workspace => { |
| 659 | format!("custom overlay ({})", member.source.display()) |
| 660 | } |
| 661 | ProfileOrigin::Personal => { |
| 662 | format!("personal overlay ({})", member.source.display()) |
| 663 | } |
| 664 | _ => "custom overlay".to_string(), |
| 665 | } |
| 666 | } else { |
| 667 | "none (role posture only)".to_string() |
| 668 | }, |
| 669 | ); |
| 670 | |
| 671 | if let Some(description) = member |
| 672 | .description |
| 673 | .as_deref() |
| 674 | .map(str::trim) |
| 675 | .filter(|description| !description.is_empty()) |
| 676 | { |
| 677 | detail_field(&mut lines, "Description", description.to_string()); |
| 678 | } |
| 679 | |
| 680 | lines |
| 681 | } |
| 682 | |
| 683 | #[cfg(test)] |
| 684 | mod tests; |
| 685 |