返回 CodeWhale
fleet_roster.rs
根目录 / crates / tui / src / tui / views / fleet_roster.rs
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; Enter opens the shared model/thinking picker for
13 //! the selected role, retaining this roster underneath. The existing saved
14 //! team/profile owner validates and persists assignments. Switch named
15 //! saved Fleets with `/fleet fleets` (`/fleet fleets` remains compatible).
16 //!
17 //! #5888: the default lineup folds the built-in `general` alias out of
18 //! presentation — it is the same posture as `worker` and stays dispatchable
19 //! (roster lookup and the identity selector both still resolve it) — so the
20 //! default surface is 11 rows: the live operator plus ten members.
21 //!
22 //! NOTE: like `fleet_setup.rs`, the copy below is intentionally English for
23 //! now (#3167 reworks Fleet UI localization); the command entry
24 //! (`CmdFleetDescription`) is already localized.
25
26 use std::cell::{Cell, RefCell};
27
28 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
29 use ratatui::{
30 buffer::Buffer,
31 layout::{Constraint, Direction, Layout, Rect},
32 style::{Color, Modifier, Style},
33 text::{Line, Span},
34 widgets::{Block, Clear, Paragraph, Widget, Wrap},
35 };
36
37 use crate::config::Config;
38 use crate::fleet::profile::AgentProfile;
39 use crate::fleet::role::public_role_label;
40 use crate::fleet::roster::{FleetRoster, ProfileLayer, ProfileOrigin, layers_from_parts};
41 use crate::fleet::worker_runtime::roster_member_agent_type;
42 use crate::tui::app::App;
43 use crate::tui::menu_style;
44
45 /// Rows one PageUp/PageDown travels. Pages clamp at the ends per the shared
46 /// vocabulary instead of wrapping (#6290).
47 const FLEET_ROSTER_PAGE: usize = 10;
48 use crate::tui::views::{
49 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
50 truncate_view_text,
51 };
52 use crate::tui::whales;
53 use crate::worker_profile::{ShellPolicy, WorkerRuntimeProfile};
54 use codewhale_localization::{Locale, MessageId, tr};
55 use codewhale_palette as palette;
56
57 /// The live session route — the operator the roster works for. Read once at
58 /// open, the same way [`super::fleet_setup::FleetSetupSnapshot`] snapshots it.
59 #[derive(Debug, Clone)]
60 struct OperatorInfo {
61 provider: String,
62 /// Exact canonical route key, kept separate from the display label so
63 /// capability lookup can use provider-scoped catalog facts.
64 provider_id: String,
65 model: String,
66 reasoning: String,
67 }
68
69 impl OperatorInfo {
70 fn from_app(app: &App) -> Self {
71 let model = if app.auto_model {
72 app.last_effective_model
73 .as_deref()
74 .map(|effective| format!("auto -> {effective}"))
75 .unwrap_or_else(|| "auto".to_string())
76 } else {
77 app.model.clone()
78 };
79 let route_provider = if app.auto_model {
80 app.last_effective_provider.unwrap_or(app.api_provider)
81 } else {
82 app.api_provider
83 };
84 let provider_id = if app.auto_model {
85 app.last_effective_provider_identity
86 .clone()
87 .unwrap_or_else(|| {
88 if route_provider == crate::config::ApiProvider::Custom {
89 app.provider_identity_for_persistence().to_string()
90 } else {
91 route_provider.as_str().to_string()
92 }
93 })
94 } else {
95 app.provider_identity_for_persistence().to_string()
96 };
97 let provider = if route_provider == crate::config::ApiProvider::Custom {
98 provider_id.clone()
99 } else {
100 route_provider.display_name().to_string()
101 };
102 Self {
103 provider,
104 provider_id,
105 model,
106 reasoning: app.reasoning_effort_display_label(),
107 }
108 }
109 }
110
111 /// Which named Fleet (if any) this session is using, and where that selection
112 /// is pinned — user-global vs this folder only.
113 #[derive(Debug, Clone)]
114 struct SelectedFleetSummary {
115 name: String,
116 scope: crate::fleet::store::FleetScope,
117 }
118
119 /// View-owned action attached to a painted saved-profile row.
120 ///
121 /// This stays deliberately separate from Tideline's live-worker targets,
122 /// which are backed by `SubAgentStatus`, not editable profiles in this roster.
123 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
124 enum FleetRosterRowAction {
125 SelectOrActivate { row: usize },
126 }
127
128 impl FleetRosterRowAction {
129 const fn row(self) -> usize {
130 match self {
131 Self::SelectOrActivate { row } => row,
132 }
133 }
134 }
135
136 pub struct FleetRosterView {
137 operator: OperatorInfo,
138 members: Vec<AgentProfile>,
139 /// Shadow records from the roster load (#5098): which lower-precedence
140 /// files the displayed members are ignoring.
141 shadowed: Vec<crate::fleet::roster::ShadowedProfile>,
142 /// Selected named Fleet + scope, when one is active for this session.
143 selected_fleet: Option<SelectedFleetSummary>,
144 /// A selected Fleet existed but could not become the runtime roster.
145 load_error: Option<String>,
146 /// Selected row: 0 is the pinned operator row, members follow at 1..
147 selected: usize,
148 detail_scroll: usize,
149 /// Exact visible row geometry from the latest render. This is a
150 /// frame-scoped projection, not a second roster or navigation owner.
151 row_hitboxes: RefCell<Vec<(Rect, FleetRosterRowAction)>>,
152 /// A first click selects/reveals details; a consecutive click on the same
153 /// row activates the exact same handoff as Enter.
154 last_mouse_selected: Option<usize>,
155 /// Row under the pointer, tinted with the shared hover style. Hover
156 /// never moves the keyboard selection; only painted rows answer.
157 hovered_row: Cell<Option<usize>>,
158 workers_hitbox: Cell<Option<Rect>>,
159 hovered_workers: Cell<bool>,
160 /// Canonical active-theme surface captured from `App`; Terminal owns
161 /// `Color::Reset`, while explicit themes retain their resolved surface.
162 surface_bg: Color,
163 /// UI locale captured from the app at construction (#4057 wave 2).
164 locale: Locale,
165 }
166
167 impl FleetRosterView {
168 #[must_use]
169 pub fn new(app: &App, config: &Config) -> Self {
170 let selected_fleet =
171 crate::fleet::store::selected_fleet(&app.workspace).map(|sel| SelectedFleetSummary {
172 name: sel.name,
173 scope: sel.scope,
174 });
175 let mut view = Self::from_parts(
176 OperatorInfo::from_app(app),
177 crate::fleet::identity::load_effective_roster(
178 &config.fleet_config(),
179 &app.workspace,
180 Some(app.plugin_registry.as_ref()),
181 ),
182 selected_fleet,
183 );
184 view.locale = app.ui_locale;
185 view.surface_bg = app.ui_theme.surface_bg;
186 view
187 }
188
189 fn from_parts(
190 operator: OperatorInfo,
191 roster: FleetRoster,
192 selected_fleet: Option<SelectedFleetSummary>,
193 ) -> Self {
194 let load_error = roster.load_error().map(str::to_string);
195 Self {
196 operator,
197 // The operator is pinned as its own row 0 (the live session route),
198 // so exclude the built-in "operator" profile from the dispatchable
199 // member list to avoid rendering it twice (#dogfood 0.8.67). The
200 // engine's FleetRoster is untouched, so role/dispatch semantics are
201 // unchanged; only this view drops the duplicate.
202 members: roster
203 .members()
204 .iter()
205 .filter(|m| !m.id.trim().eq_ignore_ascii_case("operator"))
206 .cloned()
207 .collect(),
208 shadowed: roster.shadowed().to_vec(),
209 selected_fleet,
210 load_error,
211 selected: 0,
212 detail_scroll: 0,
213 row_hitboxes: RefCell::new(Vec::new()),
214 last_mouse_selected: None,
215 hovered_row: Cell::new(None),
216 workers_hitbox: Cell::new(None),
217 hovered_workers: Cell::new(false),
218 surface_bg: palette::UI_THEME.surface_bg,
219 locale: Locale::En,
220 }
221 }
222
223 /// Rebuild this roster from the current workspace, keeping the cursor.
224 ///
225 /// #5954: the roster now stays parked under the saved-teams list, so a
226 /// team switch or delete has to refresh the parked view in place — the
227 /// user pops back to it, and it must not keep painting the pre-change
228 /// selection. Cursor and detail scroll survive because losing them is
229 /// exactly the disruption the back path exists to avoid.
230 pub fn reload(&mut self, app: &App, config: &Config) {
231 let selected = self.selected;
232 let detail_scroll = self.detail_scroll;
233 *self = Self::new(app, config);
234 self.selected = selected.min(self.row_count().saturating_sub(1));
235 self.detail_scroll = detail_scroll;
236 }
237
238 /// Total selectable rows: the operator plus every roster member.
239 fn row_count(&self) -> usize {
240 1 + self.members.len()
241 }
242
243 fn operator_selected(&self) -> bool {
244 self.selected == 0
245 }
246
247 fn selected_member(&self) -> Option<&AgentProfile> {
248 self.selected.checked_sub(1).and_then(|idx| {
249 self.members
250 .get(idx.min(self.members.len().saturating_sub(1)))
251 })
252 }
253
254 fn move_up(&mut self) {
255 self.selected = crate::tui::list_nav::wrap_index(self.selected, self.row_count(), -1);
256 self.detail_scroll = 0;
257 self.last_mouse_selected = None;
258 self.hovered_row.set(None);
259 }
260
261 fn move_down(&mut self) {
262 self.selected = crate::tui::list_nav::wrap_index(self.selected, self.row_count(), 1);
263 self.detail_scroll = 0;
264 self.last_mouse_selected = None;
265 self.hovered_row.set(None);
266 }
267
268 /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning
269 /// whether it was consumed. Steps wrap; pages travel [`FLEET_ROSTER_PAGE`]
270 /// rows and clamp. The region axis is declined so Tab keeps opening the
271 /// workers view through the explicit arm below.
272 fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool {
273 use crate::tui::list_nav::Motion;
274 match motion {
275 Motion::Prev => {
276 self.move_up();
277 true
278 }
279 Motion::Next => {
280 self.move_down();
281 true
282 }
283 Motion::RegionPrev | Motion::RegionNext => false,
284 _ => {
285 let count = self.row_count();
286 if count == 0 {
287 return false;
288 }
289 let Some(next) =
290 crate::tui::list_nav::apply(self.selected, count, FLEET_ROSTER_PAGE, motion)
291 else {
292 return false;
293 };
294 self.selected = next;
295 self.detail_scroll = 0;
296 self.last_mouse_selected = None;
297 self.hovered_row.set(None);
298 true
299 }
300 }
301 }
302
303 fn select_row(&mut self, row: usize) {
304 self.selected = row.min(self.row_count().saturating_sub(1));
305 self.detail_scroll = 0;
306 }
307
308 fn activate_selected(&self) -> ViewAction {
309 if let Some(member) = self.selected_member() {
310 let member_id = member.id.clone();
311 // Carry the exact member the operator already chose. The host
312 // focuses it in the selected v2 Fleet editor, or starts legacy
313 // setup from its member id when no Fleet is selected.
314 ViewAction::Emit(ViewEvent::FleetRosterOpenSetupRequested { member_id })
315 } else {
316 ViewAction::Emit(ViewEvent::FleetRosterOpenCoordinatorRequested)
317 }
318 }
319
320 fn select_or_activate_mouse_row(&mut self, row: usize) -> ViewAction {
321 let activate = self.last_mouse_selected == Some(row) && self.selected == row;
322 self.select_row(row);
323 self.last_mouse_selected = Some(row);
324 if activate {
325 self.activate_selected()
326 } else {
327 ViewAction::None
328 }
329 }
330
331 /// One navigation grammar (grokbuild): arrows move, Enter acts, Tab
332 /// moves across the header tabs, Esc closes. `f` is the one named
333 /// destination this room has that is not a tab. Detail scrolling
334 /// (PgUp/PgDn) works but is not advertised — the pane is short now.
335 fn footer_hints(&self) -> Vec<ActionHint> {
336 let mut hints = vec![
337 ActionHint::new("↑↓", "move"),
338 ActionHint::new("Enter", "model & thinking"),
339 ];
340 hints.extend([
341 ActionHint::new("Tab", tr(self.locale, MessageId::FleetRosterWorkers)),
342 ActionHint::new("f", "saved teams"),
343 ActionHint::new("Esc", "close"),
344 ]);
345 hints
346 }
347 }
348
349 impl ModalView for FleetRosterView {
350 fn kind(&self) -> ModalKind {
351 ModalKind::FleetRoster
352 }
353
354 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
355 self
356 }
357
358 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
359 // A keyboard gesture ends any pending mouse double-click sequence so
360 // a later single click can never activate a stale row.
361 self.last_mouse_selected = None;
362 self.hovered_workers.set(false);
363 self.hovered_row.set(None);
364 // Shift-modified paging scrolls the detail pane; bare keys drive the
365 // row list through the shared vocabulary (#6290, #6014-style split).
366 if key.modifiers.contains(KeyModifiers::SHIFT) {
367 match key.code {
368 KeyCode::PageUp => {
369 self.detail_scroll = self.detail_scroll.saturating_sub(8);
370 return ViewAction::None;
371 }
372 KeyCode::PageDown => {
373 self.detail_scroll = self.detail_scroll.saturating_add(8);
374 return ViewAction::None;
375 }
376 KeyCode::Home => {
377 self.detail_scroll = 0;
378 return ViewAction::None;
379 }
380 _ => {}
381 }
382 }
383 // Movement keys come from the shared vocabulary (#6290), j/k aliases
384 // included — this surface captures no text.
385 if let Some(motion) = crate::tui::list_nav::motion(&key)
386 && self.apply_motion(motion)
387 {
388 return ViewAction::None;
389 }
390 match key.code {
391 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
392 KeyCode::Enter => self.activate_selected(),
393 // #5954: the roster stays on the stack under the view it opens,
394 // so `Esc` in workers / saved teams pops back here instead of
395 // closing the window. `Emit` (not `EmitAndClose`) is what makes
396 // the three Fleet views one stack.
397 KeyCode::Tab | KeyCode::BackTab | KeyCode::Char('w') => {
398 ViewAction::Emit(ViewEvent::FleetRosterOpenWorkersRequested)
399 }
400 KeyCode::Char('f') => ViewAction::Emit(ViewEvent::FleetRosterOpenFleetsRequested),
401 _ => ViewAction::None,
402 }
403 }
404
405 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
406 match mouse.kind {
407 MouseEventKind::Moved => {
408 self.hovered_workers.set(
409 self.workers_hitbox
410 .get()
411 .is_some_and(|rect| rect.contains((mouse.column, mouse.row).into())),
412 );
413 let hovered = self
414 .row_hitboxes
415 .borrow()
416 .iter()
417 .find_map(|(rect, action)| {
418 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
419 .then_some(action.row())
420 });
421 self.hovered_row.set(hovered);
422 ViewAction::None
423 }
424 MouseEventKind::ScrollUp => {
425 self.move_up();
426 ViewAction::None
427 }
428 MouseEventKind::ScrollDown => {
429 self.move_down();
430 ViewAction::None
431 }
432 MouseEventKind::Down(MouseButton::Left) => {
433 if self
434 .workers_hitbox
435 .get()
436 .is_some_and(|rect| rect.contains((mouse.column, mouse.row).into()))
437 {
438 self.last_mouse_selected = None;
439 return ViewAction::Emit(ViewEvent::FleetRosterOpenWorkersRequested);
440 }
441 let action = self
442 .row_hitboxes
443 .borrow()
444 .iter()
445 .find_map(|(rect, action)| {
446 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
447 .then_some(*action)
448 });
449 action.map_or(ViewAction::None, |action| {
450 self.select_or_activate_mouse_row(action.row())
451 })
452 }
453 _ => ViewAction::None,
454 }
455 }
456
457 fn render(&self, area: Rect, buf: &mut Buffer) {
458 Clear.render(area, buf);
459 Block::default()
460 .style(Style::default().bg(self.surface_bg))
461 .render(area, buf);
462
463 let hints = self.footer_hints();
464 let content = render_modal_footer(area, buf, &hints);
465
466 // A compact, honest header: the roster is the current room, Workers
467 // is a real destination. Editing belongs to the selected member,
468 // rather than a decorative Setup tab that never handled clicks.
469 let chunks = Layout::default()
470 .direction(Direction::Vertical)
471 .constraints([Constraint::Length(2), Constraint::Min(1)])
472 .split(content);
473 let roster_label = format!(
474 " {} · {} ",
475 tr(self.locale, MessageId::FleetRosterHeaderLabel),
476 tr(self.locale, MessageId::FleetRosterTabRoster)
477 );
478 let workers_label = format!(" {} ", tr(self.locale, MessageId::FleetRosterWorkers));
479 let roster_width = unicode_width::UnicodeWidthStr::width(roster_label.as_str()) as u16;
480 let workers_width = unicode_width::UnicodeWidthStr::width(workers_label.as_str()) as u16;
481 self.workers_hitbox.set(None);
482 // Place Workers at the right edge so even a compact screen retains
483 // its full action target; the room label yields first.
484 let workers_width = workers_width.min(chunks[0].width);
485 let workers = Rect::new(
486 chunks[0].right().saturating_sub(workers_width),
487 chunks[0].y,
488 workers_width,
489 u16::from(chunks[0].height > 0),
490 );
491 if workers.width > 0 && workers.height > 0 {
492 self.workers_hitbox.set(Some(workers));
493 }
494 let title = Rect::new(
495 chunks[0].x,
496 chunks[0].y,
497 roster_width.min(chunks[0].width.saturating_sub(workers_width)),
498 workers.height,
499 );
500 Paragraph::new(Line::from(Span::styled(
501 roster_label,
502 Style::default().fg(palette::TEXT_PRIMARY).bold(),
503 )))
504 .render(title, buf);
505 let workers_style = if self.hovered_workers.get() {
506 menu_style::hovered_row_style().fg(palette::WHALE_ACTION)
507 } else {
508 Style::default()
509 .fg(palette::WHALE_ACTION)
510 .add_modifier(Modifier::UNDERLINED)
511 };
512 Paragraph::new(Line::from(Span::styled(workers_label, workers_style))).render(workers, buf);
513 if chunks[0].height > 1 {
514 let summary = format!(
515 " {} · {}",
516 self.selected_fleet_line(),
517 tr(self.locale, MessageId::FleetRosterMembersCount)
518 .replace("{count}", &(self.members.len() + 1).to_string())
519 );
520 Paragraph::new(Line::from(Span::styled(
521 truncate_view_text(&summary, usize::from(chunks[0].width)),
522 Style::default().fg(palette::TEXT_MUTED),
523 )))
524 .render(
525 Rect::new(chunks[0].x, chunks[0].y + 1, chunks[0].width, 1),
526 buf,
527 );
528 }
529
530 self.render_body(chunks[1], buf);
531 }
532 }
533
534 impl FleetRosterView {
535 /// Scope-explicit selected Fleet line. Paths stay out — receipts name them.
536 fn selected_fleet_line(&self) -> String {
537 if let Some(error) = &self.load_error {
538 return format!("Team selection error — {error}");
539 }
540 match &self.selected_fleet {
541 Some(sel) => format!("Team `{}` · {}", sel.name, sel.scope.long_label()),
542 None => "No team selected — built-in team".to_string(),
543 }
544 }
545
546 fn render_body(&self, area: Rect, buf: &mut Buffer) {
547 self.row_hitboxes.borrow_mut().clear();
548 if area.width == 0 || area.height == 0 {
549 return;
550 }
551
552 // Two columns when there is room, stacked otherwise — same responsive
553 // shape as the setup wizard's choice step so nothing truncates at
554 // 80x24.
555 let (list_area, detail_area) = if area.width >= 56 {
556 let cols = Layout::default()
557 .direction(Direction::Horizontal)
558 .constraints([
559 Constraint::Percentage(45),
560 Constraint::Length(2),
561 Constraint::Min(20),
562 ])
563 .split(area);
564 (cols[0], cols[2])
565 } else {
566 let list_height =
567 (self.row_count() as u16 + 1).min(area.height.saturating_sub(1).max(1));
568 let rows = Layout::default()
569 .direction(Direction::Vertical)
570 .constraints([Constraint::Length(list_height), Constraint::Min(1)])
571 .split(area);
572 (rows[0], rows[1])
573 };
574
575 // Row list: the pinned operator first, then one row per member,
576 // scrolled so the selection stays visible when the party outgrows
577 // the pane.
578 let visible_rows = usize::from(list_area.height).max(1);
579 let first = self
580 .selected
581 .saturating_sub(visible_rows.saturating_sub(1))
582 .min(
583 self.row_count()
584 .saturating_sub(visible_rows.min(self.row_count())),
585 );
586 let list_width = usize::from(list_area.width);
587 let mut list_lines: Vec<Line> = Vec::with_capacity(visible_rows);
588 for (line_offset, idx) in (first..(first + visible_rows).min(self.row_count())).enumerate()
589 {
590 self.row_hitboxes.borrow_mut().push((
591 Rect::new(
592 list_area.x,
593 list_area
594 .y
595 .saturating_add(u16::try_from(line_offset).unwrap_or(u16::MAX)),
596 list_area.width,
597 1,
598 ),
599 FleetRosterRowAction::SelectOrActivate { row: idx },
600 ));
601 let is_selected = idx == self.selected;
602 // Hover tints but never steals the keyboard selection.
603 let hovered = !is_selected && self.hovered_row.get() == Some(idx);
604 let hover_tint = || menu_style::hovered_row_style();
605 let pointer = format!("{} ", crate::tui::glyphs::selection_marker(is_selected));
606 let shadow_badge = idx.checked_sub(1).and_then(|index| {
607 member_shadow_badge(self.locale, &self.members[index], &self.shadowed)
608 });
609 let (text, base_style) = if idx == 0 {
610 (
611 format!(
612 "{pointer}@ {}",
613 tr(self.locale, MessageId::FleetRosterOperatorRow)
614 ),
615 Style::default().fg(palette::TEXT_PRIMARY).bold(),
616 )
617 } else {
618 let member = &self.members[idx - 1];
619 let mark = member_role_mark(member);
620 let member_name = member
621 .display_name
622 .as_deref()
623 .map(str::trim)
624 .filter(|name| !name.is_empty() && !name.eq_ignore_ascii_case(&member.id))
625 .map_or_else(
626 || member.id.clone(),
627 |name| format!("{name} ({})", member.id),
628 );
629 (
630 format!(
631 "{pointer}{mark} {member_name}{}",
632 shadow_badge.as_deref().unwrap_or("")
633 ),
634 Style::default().fg(palette::TEXT_PRIMARY),
635 )
636 };
637 let text = if list_width >= 28 && shadow_badge.is_none() {
638 let route = if idx == 0 {
639 self.operator.model.as_str()
640 } else {
641 let profile = &self.members[idx - 1].profile;
642 profile
643 .model
644 .as_deref()
645 .filter(|model| !model.trim().is_empty())
646 .unwrap_or_else(|| {
647 if profile.loadout.as_str() == "inherit" {
648 "follow Coordinator"
649 } else {
650 profile.loadout.as_str()
651 }
652 })
653 };
654 let role_width = (list_width / 2).clamp(14, 24);
655 let label = truncate_view_text(&text, role_width);
656 let pad = role_width
657 .saturating_sub(unicode_width::UnicodeWidthStr::width(label.as_str()));
658 format!(
659 "{label}{} {}",
660 " ".repeat(pad),
661 truncate_view_text(route, list_width.saturating_sub(role_width + 2))
662 )
663 } else {
664 text
665 };
666 let style = if is_selected {
667 menu_style::selected_row_style()
668 } else if hovered {
669 base_style.patch(hover_tint())
670 } else {
671 base_style
672 };
673 if is_selected || hovered {
674 buf.set_style(
675 Rect::new(
676 list_area.x,
677 list_area.y + line_offset as u16,
678 list_area.width,
679 1,
680 ),
681 style,
682 );
683 }
684 list_lines.push(Line::from(Span::styled(
685 truncate_view_text(&text, list_width),
686 style,
687 )));
688 }
689 Paragraph::new(list_lines).render(list_area, buf);
690
691 // Detail pane for the selected row.
692 let lines = if self.operator_selected() {
693 operator_detail_lines(&self.operator)
694 } else if let Some(member) = self.selected_member() {
695 // Whale Teams identity first: the species badge plus species and
696 // job. Rendered without a state — a roster member is a profile,
697 // not a runtime, so this claims nothing about whether anyone is
698 // working. (The hand-drawn portrait that used to open this pane
699 // was deleted per the 2026-08-29 founder directive.)
700 let mut lines = whale_identity_lines(member, self.locale);
701 // Session model is the operator route so "fast" loadouts resolve
702 // to the fast sibling the runtime will actually launch.
703 lines.extend(member_detail_lines_with_session(
704 member,
705 Some(self.operator.model.as_str()),
706 &self.shadowed,
707 self.locale,
708 ));
709 lines
710 } else {
711 vec![Line::from(Span::styled(
712 "Roster is empty.",
713 Style::default().fg(palette::TEXT_MUTED),
714 ))]
715 };
716
717 // Same wrapped-row scroll bound as the setup review step: count
718 // visual rows so the tail stays reachable.
719 let wrap_width = usize::from(detail_area.width).max(1);
720 let visual_rows: usize = lines
721 .iter()
722 .map(|line| line.width().div_ceil(wrap_width).max(1))
723 .sum();
724 let max_scroll = visual_rows.saturating_sub(usize::from(detail_area.height).max(1));
725 let scroll = self.detail_scroll.min(max_scroll);
726 Paragraph::new(lines)
727 .wrap(Wrap { trim: true })
728 .scroll((scroll as u16, 0))
729 .render(detail_area, buf);
730 }
731 }
732
733 /// Species for a roster member: the profile id first (built-in ids are role
734 /// names), then the resolved worker agent type. Unknown → the plain whale.
735 fn member_species(member: &AgentProfile) -> whales::WhaleSpecies {
736 match whales::WhaleSpecies::for_role_id(&member.id) {
737 whales::WhaleSpecies::Plain => {
738 whales::WhaleSpecies::for_fleet_role(&roster_member_agent_type(member))
739 }
740 species => species,
741 }
742 }
743
744 /// Identity block for the detail pane: the species badge, then
745 /// `Name · species · job`. No state is drawn or claimed — a roster member is
746 /// a profile, not a runtime.
747 fn whale_identity_lines(member: &AgentProfile, locale: Locale) -> Vec<Line<'static>> {
748 let species = member_species(member);
749 let theme = &palette::UI_THEME;
750 let mut lines: Vec<Line> = Vec::new();
751 let mut caption = whales::badge(species, theme);
752 caption.push(Span::styled(
753 format!(
754 " {} · {} · {}",
755 species.name(),
756 species.animal(locale),
757 species.job(locale)
758 ),
759 Style::default().fg(palette::TEXT_PRIMARY),
760 ));
761 lines.push(Line::from(caption));
762 lines.push(Line::from(""));
763 lines
764 }
765
766 fn member_role_mark(member: &AgentProfile) -> &'static str {
767 let role = public_role_label(&member.id);
768 match role.as_str() {
769 "manager" | "explore" => crate::tui::glyphs::ROLE_MANAGER,
770 "implement" => crate::tui::glyphs::ROLE_BUILDER,
771 "reviewer" => crate::tui::glyphs::ROLE_REVIEWER,
772 "test" => crate::tui::glyphs::ROLE_VERIFIER,
773 "synthesizer" => crate::tui::glyphs::ROLE_SYNTHESIZER,
774 _ => match roster_member_agent_type(member).as_str() {
775 "explore" | "manager" => crate::tui::glyphs::ROLE_MANAGER,
776 "implement" => crate::tui::glyphs::ROLE_BUILDER,
777 "reviewer" => crate::tui::glyphs::ROLE_REVIEWER,
778 "test" => crate::tui::glyphs::ROLE_VERIFIER,
779 "synthesizer" => crate::tui::glyphs::ROLE_SYNTHESIZER,
780 _ => crate::tui::glyphs::NEUTRAL,
781 },
782 }
783 }
784
785 /// Shared field renderer for the detail pane.
786 fn detail_field(lines: &mut Vec<Line<'static>>, label: &str, body: String) {
787 lines.push(Line::from(vec![
788 Span::styled(
789 format!("{label} "),
790 Style::default().fg(palette::TEXT_MUTED).bold(),
791 ),
792 Span::styled(body, Style::default().fg(palette::TEXT_PRIMARY)),
793 ]));
794 lines.push(Line::from(""));
795 }
796
797 /// Detail pane for the pinned operator row: the live session route, plus the
798 /// product truth that the operator is this Fleet's leader.
799 fn operator_detail_lines(operator: &OperatorInfo) -> Vec<Line<'static>> {
800 let mut lines: Vec<Line> = Vec::new();
801 detail_field(
802 &mut lines,
803 "Role",
804 "Coordinator — this session's model leads the Fleet".to_string(),
805 );
806 // Model, provider, and reasoning are one route: one line, same shape
807 // as a member's.
808 let mut route = format!("{} · {}", operator.model, operator.provider);
809 if !operator.reasoning.trim().is_empty() {
810 route.push_str(" · ");
811 route.push_str(&operator.reasoning);
812 }
813 detail_field(&mut lines, "Model", route);
814 detail_field(&mut lines, "Access", "full session access".to_string());
815 // Session-route capability badges (#5038). Use the exact route key rather
816 // than the display label so built-in routes get provider-scoped catalog
817 // facts; custom routes still fall back conservatively to registry facts.
818 if let Some(badges) = crate::fleet::capability_badges::resolve_route_capability_badges(
819 Some(&operator.provider_id),
820 &operator.model,
821 ) {
822 detail_field(&mut lines, "Capabilities", badges.summary());
823 }
824 detail_field(
825 &mut lines,
826 "Description",
827 "The Coordinator leads this session. Press Enter to change its model and thinking. \
828 Roles set to follow the Coordinator use this route; pinned roles keep their own models."
829 .to_string(),
830 );
831 lines.push(Line::from(Span::styled(
832 "saved for this session only",
833 Style::default().fg(palette::TEXT_MUTED),
834 )));
835 lines
836 }
837
838 /// The resolved worker posture for a roster member: what the runtime would
839 /// actually grant when this member is dispatched (role posture, not the
840 /// profile's requested permissions).
841 /// Plain-Access summary for a roster member: what it may do, derived from the
842 /// same runtime profile dispatch would grant. No internal role/posture words.
843 fn member_access_summary(member: &AgentProfile) -> String {
844 let agent_type = roster_member_agent_type(member);
845 let runtime = WorkerRuntimeProfile::for_role(agent_type.clone());
846 let write = if runtime.permissions.write {
847 "can edit files"
848 } else {
849 "read-only files"
850 };
851 let shell = match runtime.shell {
852 ShellPolicy::None => "cannot run commands",
853 ShellPolicy::ReadOnly => "read-only commands",
854 ShellPolicy::Full => "can run commands",
855 };
856 let network = if runtime.permissions.network {
857 "network"
858 } else {
859 "no network"
860 };
861 format!("{write} · {shell} · {network}")
862 }
863
864 /// The model truth for a member: explicit model choice, else saved model set,
865 /// else the session's model. `[subagents]` overrides still win at dispatch.
866 ///
867 /// When the loadout is `fast`, show that the runtime picks the **fast sibling
868 /// of the active session model** — not a stale on-disk profile name — so the
869 /// roster matches what Fleet will actually launch.
870 fn member_routing_with_session(member: &AgentProfile, session_model: Option<&str>) -> String {
871 if let Some(model) = member
872 .profile
873 .model
874 .as_deref()
875 .map(str::trim)
876 .filter(|model| !model.is_empty())
877 {
878 if let Some(provider) = member
879 .profile
880 .provider
881 .as_deref()
882 .map(str::trim)
883 .filter(|provider| !provider.is_empty())
884 {
885 return format!("model {provider}/{model}");
886 }
887 return format!("model {model}");
888 }
889 match member.profile.loadout.as_str() {
890 "inherit" => "same model as this session".to_string(),
891 "fast" => match session_model.map(str::trim).filter(|m| !m.is_empty()) {
892 Some(session) => format!("fast model for {session}"),
893 None => "fast model, picked at launch".to_string(),
894 },
895 loadout => format!("saved model set {loadout}"),
896 }
897 }
898
899 fn member_shadow_badge(
900 locale: Locale,
901 member: &AgentProfile,
902 shadowed: &[crate::fleet::roster::ShadowedProfile],
903 ) -> Option<String> {
904 let layers = layers_from_parts(member, shadowed);
905 if layers.len() < 2 {
906 return None;
907 }
908 let personal_ignored = layers
909 .iter()
910 .any(|layer| !layer.wins && layer.origin == ProfileOrigin::Personal);
911 let id = if personal_ignored {
912 MessageId::FleetRosterShadowBadgePersonalIgnored
913 } else {
914 match member.origin {
915 ProfileOrigin::Workspace => MessageId::FleetRosterShadowBadgeProjectOverride,
916 ProfileOrigin::Personal => MessageId::FleetRosterShadowBadgePersonalOverride,
917 ProfileOrigin::Config => MessageId::FleetRosterShadowBadgeConfigOverride,
918 ProfileOrigin::Plugin | ProfileOrigin::BuiltIn => return None,
919 }
920 };
921 Some(format!(" {}", tr(locale, id)))
922 }
923
924 fn format_profile_layer(layer: &ProfileLayer, locale: Locale) -> String {
925 let mark = if layer.wins {
926 tr(locale, MessageId::FleetRosterLayerWins)
927 } else {
928 tr(locale, MessageId::FleetRosterLayerIgnored)
929 };
930 format!("{} · {} ({mark})", layer.origin, layer.source.display())
931 }
932
933 fn member_detail_lines_with_session(
934 member: &AgentProfile,
935 session_model: Option<&str>,
936 shadowed: &[crate::fleet::roster::ShadowedProfile],
937 locale: Locale,
938 ) -> Vec<Line<'static>> {
939 let mut lines: Vec<Line> = Vec::new();
940
941 // Role is the member's primary identity; the id/display name only
942 // appears when it says something the role does not.
943 let role = member.profile.role.name.trim().to_string();
944 let display_name = member
945 .display_name
946 .as_deref()
947 .map(str::trim)
948 .filter(|name| !name.is_empty() && !name.eq_ignore_ascii_case(&member.id));
949 let role_line = match display_name {
950 Some(name) => format!("{role} — {name} ({})", member.id),
951 None if member.id.trim().eq_ignore_ascii_case(&role) => role.clone(),
952 None => format!("{role} ({})", member.id),
953 };
954 detail_field(&mut lines, "Role", role_line);
955 // #5098: every layer found for this id, with the winner named. The
956 // Origin field still shows the effective copy; this list is the full
957 // stack so a personal/config edit is visible when project wins.
958 let layers = layers_from_parts(member, shadowed);
959 if layers.len() > 1 {
960 let body = layers
961 .iter()
962 .map(|layer| format_profile_layer(layer, locale))
963 .collect::<Vec<_>>()
964 .join("\n");
965 detail_field(
966 &mut lines,
967 &tr(locale, MessageId::FleetRosterLayersLabel),
968 body,
969 );
970 }
971 // Model and provider are attributes of the role: one line, together.
972 let model = match (
973 member.profile.model.as_deref(),
974 crate::fleet::identity::friendly_model_name(member),
975 ) {
976 (Some(model), Some(name)) if !name.eq_ignore_ascii_case(model.trim()) => {
977 format!("{name} ({})", model.trim())
978 }
979 _ => member_routing_with_session(member, session_model),
980 };
981 let route = match member
982 .profile
983 .provider
984 .as_deref()
985 .map(str::trim)
986 .filter(|provider| !provider.is_empty())
987 {
988 Some(provider) => format!("{model} · {provider}"),
989 None => model,
990 };
991 detail_field(&mut lines, "Model", route);
992 detail_field(
993 &mut lines,
994 "Thinking",
995 member
996 .profile
997 .reasoning_effort
998 .clone()
999 .unwrap_or_else(|| "Follow Coordinator".to_string()),
1000 );
1001 // Slot is internal dispatch vocabulary and duplicates Role — never shown.
1002 detail_field(&mut lines, "Access", member_access_summary(member));
1003
1004 // Capability badges for a pinned model, from the shared Fleet resolver
1005 // (#5038). Unknown models omit the field rather than fabricating facts.
1006 if let Some(model) = member
1007 .profile
1008 .model
1009 .as_deref()
1010 .map(str::trim)
1011 .filter(|model| !model.is_empty())
1012 && let Some(badges) = crate::fleet::capability_badges::resolve_route_capability_badges(
1013 member.profile.provider.as_deref(),
1014 model,
1015 )
1016 {
1017 detail_field(&mut lines, "Capabilities", badges.summary());
1018 }
1019
1020 let delegation = &member.profile.delegation;
1021 if delegation.max_spawn_depth.is_some() || delegation.max_concurrency.is_some() {
1022 let mut bounds: Vec<String> = Vec::new();
1023 if let Some(depth) = delegation.max_spawn_depth {
1024 bounds.push(format!("spawn depth {depth}"));
1025 }
1026 if let Some(concurrency) = delegation.max_concurrency {
1027 bounds.push(format!("concurrency {concurrency}"));
1028 }
1029 detail_field(&mut lines, "Delegation", bounds.join(" · "));
1030 }
1031
1032 // Only a real overlay earns a field; "none" is the default and says
1033 // nothing.
1034 if member.profile.role.instructions.is_some() {
1035 detail_field(
1036 &mut lines,
1037 "Instructions",
1038 match member.origin {
1039 ProfileOrigin::Workspace => {
1040 format!("custom overlay ({})", member.source.display())
1041 }
1042 ProfileOrigin::Personal => {
1043 format!("personal overlay ({})", member.source.display())
1044 }
1045 _ => "custom overlay".to_string(),
1046 },
1047 );
1048 }
1049
1050 if let Some(description) = member
1051 .description
1052 .as_deref()
1053 .map(str::trim)
1054 .filter(|description| !description.is_empty())
1055 {
1056 detail_field(&mut lines, "Description", description.to_string());
1057 }
1058
1059 // Where the member is saved, last and muted: provenance, not identity.
1060 lines.push(Line::from(Span::styled(
1061 match member.origin {
1062 ProfileOrigin::BuiltIn => "saved for all projects (built-in team)".to_string(),
1063 ProfileOrigin::Workspace => "saved for this project".to_string(),
1064 _ => format!("saved: {} · {}", member.origin, member.source.display()),
1065 },
1066 Style::default().fg(palette::TEXT_MUTED),
1067 )));
1068
1069 lines
1070 }
1071
1072 #[cfg(test)]
1073 mod tests;
1074
1074 lines RUST