返回 CodeWhale
fleet_detail.rs
根目录 / crates / tui / src / tui / views / fleet_detail.rs
1 //! Fleet detail — open a saved named team and edit it.
2 //!
3 //! Row 0 is the Coordinator's own model; below it one row per member.
4 //! Editing a Fleet edits that Fleet's file — never the live session route and
5 //! never a global collection of role profiles. Every write goes through
6 //! [`crate::fleet::store`] with an atomic save and a receipt naming the exact
7 //! file and scope.
8
9 use std::cell::RefCell;
10 use std::path::PathBuf;
11
12 /// Rows a PageUp/PageDown travels. Both lists here are modal and short, so a
13 /// page is a readable jump rather than a screenful measured at paint time.
14 const DETAIL_PAGE: usize = 10;
15 const ROUTE_PICK_PAGE: usize = 10;
16
17 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
18 use ratatui::{
19 buffer::Buffer,
20 layout::{Constraint, Direction, Layout, Rect},
21 style::Style,
22 text::{Line, Span},
23 widgets::{Block, Clear, Paragraph, Widget, Wrap},
24 };
25
26 use crate::config::{ApiProvider, Config};
27 use crate::fleet::role::public_role_label;
28 use crate::fleet::store::{
29 FleetFile, FleetMember, FleetOperator, FleetScope, MemberCapability, load_fleet_in_scope,
30 save_fleet, set_selected,
31 };
32 use crate::tui::app::App;
33 use crate::tui::menu_style;
34 use crate::tui::views::{
35 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
36 };
37 use codewhale_localization::{Locale, MessageId, tr};
38 use codewhale_palette as palette;
39
40 /// The built-in role vocabulary offered when adding a member, in a useful
41 /// order. A Fleet member is a role; the user can name anything, these are the
42 /// known postures.
43 const KNOWN_ROLES: [&str; 8] = [
44 "explore",
45 "implement",
46 "reviewer",
47 "test",
48 "manager",
49 "advisor",
50 // "synthesizer", not "summarizer": the setup wizard's ROLES table owns
51 // this token's spelling (profile role_hint + file stem contract), and
52 // the whale table + docs agree. Both resolve to Planner posture, but
53 // the picker must offer the same word setup does (#6087 item 3).
54 "synthesizer",
55 "general",
56 ];
57
58 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
59 enum DetailStep {
60 Overview,
61 PickRoute,
62 }
63
64 /// The Fleet editor row a route applies to: the Coordinator (row 0) or one
65 /// member by roster index. Shared with the `/model` picker, which hands a pick
66 /// back to the editor addressed by this.
67 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
68 pub enum FleetRouteTarget {
69 Operator,
70 Member(usize),
71 }
72
73 /// The selected row's saved route, independent of the session's picker memory.
74 pub struct FleetRouteSelection {
75 pub provider: Option<String>,
76 pub model: Option<String>,
77 pub reasoning: Option<crate::reasoning_preference::ReasoningEffort>,
78 pub allow_inherit: bool,
79 }
80
81 /// One selectable route row in the picker step: inherit or a concrete
82 /// provider/model with its readiness label.
83 #[derive(Debug, Clone)]
84 struct RouteRow {
85 label: String,
86 summary: String,
87 provider: Option<String>,
88 model: Option<String>,
89 }
90
91 pub struct FleetDetailView {
92 fleet: FleetFile,
93 editor_id: uuid::Uuid,
94 pub(crate) return_to_roster: bool,
95 saved_source: Option<String>,
96 locale: Locale,
97 scope: FleetScope,
98 source: PathBuf,
99 workspace: PathBuf,
100 /// 0 = operator row; 1.. = members.
101 selected: usize,
102 row_hitboxes: RefCell<Vec<(Rect, usize)>>,
103 last_mouse_selected: Option<usize>,
104 hovered_row: Option<usize>,
105 step: DetailStep,
106 pick_target: FleetRouteTarget,
107 routes: Vec<RouteRow>,
108 /// Highlight position *within the filtered list*, not into `routes`.
109 pick_row: usize,
110 /// Typed filter for the route picker. Letters filter directly — no mode
111 /// to discover — because the list is every configured provider/model
112 /// route and arrowing through it was the whole complaint.
113 pick_query: String,
114 // Inline rename.
115 rename_mode: bool,
116 rename_input: String,
117 // Delete confirmation.
118 pending_remove: bool,
119 /// The resolved Scout route shown before a run (pinned / verified
120 /// companion / inherited / unavailable), refreshed on route edits.
121 scout_receipt: Option<String>,
122 /// Session route at open, used to resolve the unpinned Scout.
123 session_provider: String,
124 session_model: String,
125 }
126
127 impl FleetDetailView {
128 /// Open a saved Fleet by name and scope. The caller (the list view) names
129 /// the scope explicitly, so ambiguity is impossible here.
130 pub fn open(app: &App, config: &Config, name: &str, scope: FleetScope) -> Option<Self> {
131 Self::open_for_member(app, config, name, scope, None)
132 }
133
134 /// Open the exact named team and, when the request came from a roster
135 /// member, focus that member in the v2 editor.
136 pub(crate) fn open_for_member(
137 app: &App,
138 config: &Config,
139 name: &str,
140 scope: FleetScope,
141 member_id: Option<&str>,
142 ) -> Option<Self> {
143 let (fleet, source) = load_fleet_in_scope(name, scope, &app.workspace).ok()?;
144 let session_provider = if app.auto_model {
145 app.last_effective_provider_identity
146 .clone()
147 .unwrap_or_else(|| app.provider_identity_for_persistence().to_string())
148 } else {
149 app.provider_identity_for_persistence().to_string()
150 };
151 let session_model = if app.auto_model {
152 app.last_effective_model
153 .clone()
154 .unwrap_or_else(|| "auto".to_string())
155 } else {
156 app.model.clone()
157 };
158 let mut view = Self::from_parts(
159 fleet,
160 app.ui_locale,
161 scope,
162 source,
163 app.workspace.clone(),
164 config,
165 &session_provider,
166 &session_model,
167 );
168 if let Some(member_id) = member_id.map(str::trim).filter(|id| !id.is_empty()) {
169 let index = view
170 .fleet
171 .members
172 .iter()
173 .position(|member| member.id.eq_ignore_ascii_case(member_id))?;
174 view.selected = index + 1;
175 }
176 Some(view)
177 }
178
179 fn from_parts(
180 fleet: FleetFile,
181 locale: Locale,
182 scope: FleetScope,
183 source: PathBuf,
184 workspace: PathBuf,
185 config: &Config,
186 session_provider: &str,
187 session_model: &str,
188 ) -> Self {
189 let routes = build_route_rows(config);
190 let saved_source = std::fs::read_to_string(&source)
191 .ok()
192 .filter(|text| FleetFile::parse(text).ok().as_ref() == Some(&fleet));
193 let mut view = Self {
194 fleet,
195 editor_id: uuid::Uuid::new_v4(),
196 return_to_roster: false,
197 saved_source,
198 locale,
199 scope,
200 source,
201 workspace,
202 selected: 0,
203 row_hitboxes: RefCell::new(Vec::new()),
204 last_mouse_selected: None,
205 hovered_row: None,
206 step: DetailStep::Overview,
207 pick_target: FleetRouteTarget::Operator,
208 routes,
209 pick_row: 0,
210 pick_query: String::new(),
211 rename_mode: false,
212 rename_input: String::new(),
213 pending_remove: false,
214 scout_receipt: None,
215 session_provider: session_provider.to_string(),
216 session_model: session_model.to_string(),
217 };
218 view.refresh_scout_receipt();
219 view
220 }
221
222 /// Recompute the resolved Scout route from the current fleet draft and
223 /// session route. Called at open and after every route edit.
224 fn refresh_scout_receipt(&mut self) {
225 self.scout_receipt = self.fleet.has_scout().then(|| {
226 crate::fleet::scout::resolve_scout_route(
227 self.fleet.member("scout"),
228 &self.session_provider,
229 &self.session_model,
230 )
231 .receipt_line()
232 });
233 }
234
235 fn row_count(&self) -> usize {
236 1 + self.fleet.members.len()
237 }
238
239 fn selected_member_idx(&self) -> Option<usize> {
240 self.selected.checked_sub(1)
241 }
242
243 fn selected_member(&self) -> Option<&FleetMember> {
244 self.selected_member_idx()
245 .and_then(|idx| self.fleet.members.get(idx))
246 }
247
248 fn start_rename(&mut self) {
249 self.rename_mode = true;
250 self.rename_input = self.fleet.name.clone();
251 }
252
253 fn commit_rename(&mut self) -> Option<ViewAction> {
254 let new_name = self.rename_input.trim().to_string();
255 if new_name.is_empty() {
256 self.rename_mode = false;
257 return Some(ViewAction::None);
258 }
259 if new_name == self.fleet.name {
260 self.rename_mode = false;
261 return Some(ViewAction::None);
262 }
263 // The rename must not collide with a different Fleet of the same slug
264 // in this scope (the store refuses that at save).
265 let old_name = self.fleet.name.clone();
266 self.fleet.name = new_name.clone();
267 self.rename_mode = false;
268 match save_fleet(&self.fleet, self.scope, &self.workspace) {
269 Ok(path) => Some(ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged {
270 message: format!(
271 "Renamed Team `{old_name}` → `{new_name}` ({}) — wrote {}",
272 self.scope.label(),
273 path.display()
274 ),
275 })),
276 Err(err) => {
277 self.fleet.name = old_name;
278 Some(ViewAction::Emit(ViewEvent::OpenTextPager {
279 title: "Rename failed".to_string(),
280 content: format!("{err:#}"),
281 }))
282 }
283 }
284 }
285
286 /// Rows passing the typed filter, as indices into `routes`.
287 fn filtered_routes(&self) -> Vec<usize> {
288 let shortlist = matches!(self.pick_target, FleetRouteTarget::Member(idx)
289 if self.fleet.members.get(idx).is_some_and(|member| member.shortlist));
290 (0..self.routes.len())
291 .filter(|idx| {
292 let route = &self.routes[*idx];
293 if shortlist && (route.provider.is_none() || route.model.is_none()) {
294 return false;
295 }
296 crate::tui::views::fleet_setup::route_matches_query(
297 &self.pick_query,
298 route.provider.as_deref().unwrap_or(""),
299 route.model.as_deref().unwrap_or(""),
300 *idx == 0,
301 )
302 })
303 .collect()
304 }
305
306 /// The `routes` index currently highlighted, or `None` when the filter
307 /// excludes everything.
308 fn picked_route_index(&self) -> Option<usize> {
309 let filtered = self.filtered_routes();
310 filtered
311 .get(self.pick_row.min(filtered.len().saturating_sub(1)))
312 .copied()
313 }
314
315 /// Enter the route-picker step for the target.
316 fn open_route_picker(&mut self, target: FleetRouteTarget) {
317 self.step = DetailStep::PickRoute;
318 self.pick_target = target;
319 // Preselect the row matching the current pin (or the inherit row).
320 self.pick_row = 0;
321 self.pick_query.clear();
322 let current: Option<(&str, &str)> = match target {
323 FleetRouteTarget::Operator => self
324 .fleet
325 .operator
326 .as_ref()
327 .map(|op| (op.provider.as_str(), op.model.as_str())),
328 FleetRouteTarget::Member(idx) => self
329 .fleet
330 .members
331 .get(idx)
332 .and_then(|m| m.provider.as_deref().zip(m.model.as_deref())),
333 };
334 if let Some((provider, model)) = current {
335 for (idx, route_idx) in self.filtered_routes().into_iter().enumerate() {
336 let route = &self.routes[route_idx];
337 if route.provider.as_deref() == Some(provider)
338 && route.model.as_deref() == Some(model)
339 {
340 self.pick_row = idx;
341 break;
342 }
343 }
344 }
345 }
346
347 fn apply_route_pick(&mut self) -> Option<ViewAction> {
348 let route = self.routes.get(self.picked_route_index()?)?;
349 let (provider, model) = (route.provider.clone(), route.model.clone());
350 self.set_route(self.pick_target, provider, model);
351 self.step = DetailStep::Overview;
352 self.rename_mode = false;
353 self.route_edit_needs_refresh();
354 Some(ViewAction::None)
355 }
356
357 /// Pin `target` to `provider`/`model`, or clear its pin when either is
358 /// absent so the row inherits the session route again. The Coordinator
359 /// keeps its reasoning tier across a route change.
360 fn set_route(
361 &mut self,
362 target: FleetRouteTarget,
363 provider: Option<String>,
364 model: Option<String>,
365 ) {
366 match target {
367 FleetRouteTarget::Operator => {
368 self.fleet.operator = match (provider, model) {
369 (Some(provider), Some(model)) => Some(FleetOperator {
370 provider,
371 model,
372 reasoning: self
373 .fleet
374 .operator
375 .as_ref()
376 .and_then(|op| op.reasoning.clone()),
377 }),
378 _ => None,
379 };
380 }
381 FleetRouteTarget::Member(idx) => {
382 if let Some(member) = self.fleet.members.get_mut(idx) {
383 match (provider, model) {
384 (Some(provider), Some(model)) => {
385 member.provider = Some(provider);
386 member.model = Some(model);
387 }
388 _ => {
389 member.provider = None;
390 member.model = None;
391 }
392 }
393 }
394 }
395 }
396 }
397
398 /// The row Enter acts on: the Coordinator on row 0, else that member.
399 fn selected_route_target(&self) -> FleetRouteTarget {
400 match self.selected_member_idx() {
401 Some(idx) => FleetRouteTarget::Member(idx),
402 None => FleetRouteTarget::Operator,
403 }
404 }
405
406 pub(crate) fn direct_assignment(&mut self) -> (uuid::Uuid, FleetRouteTarget) {
407 self.return_to_roster = true;
408 (self.editor_id, self.selected_route_target())
409 }
410
411 pub(crate) fn assignment_context(&self) -> (String, String) {
412 let role = self
413 .selected_member()
414 .map(|member| member.id.clone())
415 .unwrap_or_else(|| "Coordinator".into());
416 (
417 role,
418 format!("{} · {}", self.fleet.name, self.scope.label()),
419 )
420 }
421
422 pub(crate) fn is_direct_assignment(&self, editor_id: uuid::Uuid) -> bool {
423 self.return_to_roster && self.editor_id == editor_id
424 }
425
426 pub(crate) fn route_selection(
427 &self,
428 editor_id: uuid::Uuid,
429 target: FleetRouteTarget,
430 ) -> Option<FleetRouteSelection> {
431 if editor_id != self.editor_id {
432 return None;
433 }
434 let (provider, model, reasoning, allow_inherit) = match target {
435 FleetRouteTarget::Operator => (
436 self.fleet.operator.as_ref().map(|op| op.provider.clone()),
437 self.fleet.operator.as_ref().map(|op| op.model.clone()),
438 self.fleet
439 .operator
440 .as_ref()
441 .and_then(|op| op.reasoning.as_deref()),
442 true,
443 ),
444 FleetRouteTarget::Member(idx) => {
445 let member = self.fleet.members.get(idx)?;
446 (
447 member.provider.clone(),
448 member.model.clone(),
449 member.reasoning.as_deref(),
450 !member.shortlist,
451 )
452 }
453 };
454 Some(FleetRouteSelection {
455 provider,
456 model,
457 reasoning: reasoning.and_then(|value| {
458 crate::reasoning_preference::ReasoningEffort::parse_strict(value).ok()
459 }),
460 allow_inherit,
461 })
462 }
463
464 /// Apply a route the standard `/model` picker resolved for `target` and
465 /// write the Fleet file at once, so an Enter-pick is one gesture: pick,
466 /// saved. `None`/`None` clears the pin. Returns the receipt to show, or
467 /// the reason nothing was written.
468 pub(crate) fn apply_picked_route(
469 &mut self,
470 editor_id: uuid::Uuid,
471 target: FleetRouteTarget,
472 provider: Option<String>,
473 model: Option<String>,
474 reasoning: Option<crate::reasoning_preference::ReasoningEffort>,
475 ) -> Result<String, String> {
476 // A picker belongs to one editor instance and the exact saved file
477 // it opened. Never recreate a removed file or overwrite newer edits.
478 if editor_id != self.editor_id
479 || self.saved_source.is_none()
480 || std::fs::read_to_string(&self.source).ok() != self.saved_source
481 {
482 return Err(tr(self.locale, MessageId::FleetRoutePickUnavailable).into_owned());
483 }
484 if let FleetRouteTarget::Member(idx) = target
485 && idx >= self.fleet.members.len()
486 {
487 return Err(tr(self.locale, MessageId::FleetRoutePickUnavailable).into_owned());
488 }
489 let previous = self.fleet.clone();
490 self.set_route(target, provider, model);
491 let reasoning = reasoning.map(|effort| effort.as_setting().to_string());
492 match target {
493 FleetRouteTarget::Operator => {
494 if let Some(operator) = self.fleet.operator.as_mut() {
495 operator.reasoning = reasoning;
496 }
497 }
498 FleetRouteTarget::Member(idx) => self.fleet.members[idx].reasoning = reasoning,
499 }
500 self.route_edit_needs_refresh();
501 let route = match target {
502 FleetRouteTarget::Operator => self
503 .fleet
504 .operator
505 .as_ref()
506 .map(|op| format!("{}/{}", op.provider, op.model)),
507 FleetRouteTarget::Member(idx) => {
508 let member = &self.fleet.members[idx];
509 member
510 .provider
511 .as_deref()
512 .zip(member.model.as_deref())
513 .map(|(provider, model)| format!("{provider}/{model}"))
514 }
515 };
516 match save_fleet(&self.fleet, self.scope, &self.workspace) {
517 Ok(path) => {
518 self.saved_source = self.fleet.render_toml().ok();
519 Ok(tr(self.locale, MessageId::FleetRouteSaved)
520 .replace("{fleet}", &self.fleet.name)
521 .replace(
522 "{route}",
523 &route.unwrap_or_else(|| {
524 tr(self.locale, MessageId::FleetRouteInherited).into_owned()
525 }),
526 )
527 .replace("{path}", &path.display().to_string()))
528 }
529 Err(err) => {
530 self.fleet = previous;
531 self.route_edit_needs_refresh();
532 Err(tr(self.locale, MessageId::FleetToggleFailed)
533 .replace("{error}", &err.to_string()))
534 }
535 }
536 }
537
538 /// Cycle the reasoning level of the selected row through the supported
539 /// tiers. The tier list is the provider's documented vocabulary; a tier a
540 /// route cannot genuinely express is never offered.
541 fn cycle_reasoning(&mut self) {
542 let tiers: &[&str] = match self.selected {
543 0 => {
544 if let Some(op) = &self.fleet.operator {
545 reasoning_tiers_for_provider(&op.provider)
546 } else {
547 &[]
548 }
549 }
550 _ => {
551 if let Some(member) = self.selected_member()
552 && !member.shortlist
553 && let Some(provider) = &member.provider
554 {
555 reasoning_tiers_for_provider(provider)
556 } else {
557 &[]
558 }
559 }
560 };
561 if tiers.is_empty() {
562 return;
563 }
564 let slot = match self.selected {
565 0 => self.fleet.operator.as_mut().map(|op| &mut op.reasoning),
566 _ => self
567 .selected_member_idx()
568 .and_then(|idx| self.fleet.members.get_mut(idx))
569 .map(|m| &mut m.reasoning),
570 };
571 let Some(slot) = slot else { return };
572 let current = slot.as_deref().unwrap_or("inherit");
573 let next = match tiers.iter().position(|t| *t == current) {
574 Some(pos) => tiers[(pos + 1) % tiers.len()],
575 None => tiers[0],
576 };
577 *slot = if next == "inherit" {
578 None
579 } else {
580 Some(next.to_string())
581 };
582 }
583
584 /// The scout receipt depends on the member pin and the session route;
585 /// reasoning edits don't affect it. Pins refresh it at the next open;
586 /// the marker exists so route-edit call sites document that intent.
587 fn route_edit_needs_refresh(&mut self) {
588 self.refresh_scout_receipt();
589 }
590
591 fn toggle_vision_requirement(&mut self) {
592 if let Some(member) = self.selected_member_idx()
593 && let Some(member) = self.fleet.members.get_mut(member)
594 && !member.shortlist
595 {
596 if member.requires.iter().any(|r| r == "vision") {
597 member.requires.retain(|r| r != "vision");
598 } else {
599 member
600 .requires
601 .push(MemberCapability::Vision.wire_name().to_string());
602 }
603 }
604 }
605
606 fn add_member(&mut self) {
607 // First known role not already present.
608 let Some(role) = KNOWN_ROLES.iter().find(|role| {
609 !self.fleet.members.iter().any(|member| {
610 !member.shortlist
611 && public_role_label(member.role_label())
612 .eq_ignore_ascii_case(&public_role_label(role))
613 })
614 }) else {
615 return;
616 };
617 let id = crate::fleet::members::unique_member_id(&self.fleet, role, "role");
618 self.fleet.members.push(FleetMember {
619 id,
620 display_name: None,
621 shortlist: false,
622 role: role.to_string(),
623 provider: None,
624 model: None,
625 reasoning: None,
626 instructions: None,
627 requires: Vec::new(),
628 });
629 }
630
631 fn remove_selected_member(&mut self) {
632 if let Some(idx) = self.selected_member_idx() {
633 self.fleet.members.remove(idx);
634 self.pending_remove = false;
635 if self.selected >= self.row_count() {
636 self.selected = self.row_count().saturating_sub(1);
637 }
638 }
639 }
640
641 fn save(&self) -> Option<ViewAction> {
642 match save_fleet(&self.fleet, self.scope, &self.workspace) {
643 Ok(path) => Some(ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged {
644 message: format!(
645 "Saved Team `{}` ({}) — wrote {}",
646 self.fleet.name,
647 self.scope.long_label(),
648 path.display()
649 ),
650 })),
651 Err(err) => Some(ViewAction::Emit(ViewEvent::OpenTextPager {
652 title: "Save failed".to_string(),
653 content: format!(
654 "Nothing was written.\n\n{err:#}\n\nFix the issue and save again."
655 ),
656 })),
657 }
658 }
659
660 fn copy_to_other_scope(&self) -> Option<ViewAction> {
661 let target = self.scope.toggled();
662 match save_fleet(&self.fleet, target, &self.workspace) {
663 Ok(path) => Some(ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged {
664 message: format!(
665 "Copied Team `{}` to {} scope — wrote {}",
666 self.fleet.name,
667 target.label(),
668 path.display()
669 ),
670 })),
671 Err(err) => Some(ViewAction::Emit(ViewEvent::OpenTextPager {
672 title: "Copy failed".to_string(),
673 content: format!("{err:#}"),
674 })),
675 }
676 }
677
678 fn select_scope(&self, scope: FleetScope) -> Option<ViewAction> {
679 match set_selected(&self.fleet.name, scope, &self.workspace) {
680 Ok(path) => Some(ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged {
681 message: format!(
682 "Selected Team `{}` as {} default — wrote {}",
683 self.fleet.name,
684 scope.label(),
685 path.display()
686 ),
687 })),
688 Err(err) => Some(ViewAction::Emit(ViewEvent::OpenTextPager {
689 title: "Selection failed".to_string(),
690 content: format!("{err:#}"),
691 })),
692 }
693 }
694
695 fn footer_hints(&self) -> Vec<ActionHint> {
696 match self.step {
697 DetailStep::PickRoute => vec![
698 ActionHint::new("type", "filter"),
699 ActionHint::new("↑/↓", "move"),
700 ActionHint::new("Enter", "pick"),
701 ActionHint::new(
702 "Esc",
703 if self.pick_query.is_empty() {
704 "back"
705 } else {
706 "clear filter"
707 },
708 ),
709 ],
710 DetailStep::Overview => {
711 let shortlist = self
712 .selected_member()
713 .is_some_and(|member| member.shortlist);
714 let mut hints = vec![
715 ActionHint::new("↑/↓", "move"),
716 ActionHint::new("Enter", tr(self.locale, MessageId::PickerActionModels)),
717 ActionHint::new("r", "rename"),
718 ActionHint::new("s", "save"),
719 ActionHint::new("c", "copy destination"),
720 ActionHint::new("u/w", "select"),
721 ];
722 if !shortlist {
723 hints.push(ActionHint::new("t", "reasoning"));
724 }
725 if self.selected > 0 {
726 if !shortlist {
727 hints.push(ActionHint::new("v", "vision"));
728 }
729 hints.push(ActionHint::new("a/d", "add/remove"));
730 }
731 hints.push(ActionHint::new("Esc", "back"));
732 hints
733 }
734 }
735 }
736 }
737
738 impl ModalView for FleetDetailView {
739 fn kind(&self) -> ModalKind {
740 ModalKind::FleetDetail
741 }
742
743 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
744 self
745 }
746
747 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
748 self.last_mouse_selected = None;
749 self.hovered_row = None;
750 match self.step {
751 DetailStep::PickRoute => match key.code {
752 // Esc clears a filter before it leaves, so a mistyped query
753 // does not cost the step.
754 KeyCode::Esc if !self.pick_query.is_empty() => {
755 self.pick_query.clear();
756 self.pick_row = 0;
757 ViewAction::None
758 }
759 KeyCode::Esc => {
760 self.step = DetailStep::Overview;
761 ViewAction::None
762 }
763 // Typing-safe set only: this step feeds `Char(c)` into
764 // `pick_query`, so a letter alias would eat the query (#6290).
765 _ if crate::tui::list_nav::motion_while_typing(&key)
766 .and_then(|m| {
767 crate::tui::list_nav::apply(
768 self.pick_row,
769 self.filtered_routes().len(),
770 ROUTE_PICK_PAGE,
771 m,
772 )
773 })
774 .is_some_and(|row| {
775 self.pick_row = row;
776 true
777 }) =>
778 {
779 ViewAction::None
780 }
781 KeyCode::Enter => self.apply_route_pick().unwrap_or(ViewAction::None),
782 KeyCode::Backspace => {
783 self.pick_query.pop();
784 self.pick_row = 0;
785 ViewAction::None
786 }
787 // Letters filter. `j`/`k` used to navigate here, which is why
788 // typing a model name did nothing useful.
789 KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
790 self.pick_query.push(c);
791 self.pick_row = 0;
792 ViewAction::None
793 }
794 _ => ViewAction::None,
795 },
796 DetailStep::Overview => {
797 if self.rename_mode {
798 return match key.code {
799 KeyCode::Enter => self.commit_rename().unwrap_or(ViewAction::None),
800 KeyCode::Esc => {
801 self.rename_mode = false;
802 ViewAction::None
803 }
804 KeyCode::Char(c) => {
805 self.rename_input.push(c);
806 ViewAction::None
807 }
808 KeyCode::Backspace => {
809 self.rename_input.pop();
810 ViewAction::None
811 }
812 _ => ViewAction::None,
813 };
814 }
815 if self.pending_remove {
816 return match key.code {
817 KeyCode::Char('y') | KeyCode::Enter => {
818 self.remove_selected_member();
819 ViewAction::None
820 }
821 KeyCode::Char('n') | KeyCode::Esc => {
822 self.pending_remove = false;
823 ViewAction::None
824 }
825 _ => ViewAction::None,
826 };
827 }
828 match key.code {
829 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
830 _ if crate::tui::list_nav::motion(&key)
831 .and_then(|m| {
832 crate::tui::list_nav::apply(
833 self.selected,
834 self.row_count(),
835 DETAIL_PAGE,
836 m,
837 )
838 })
839 .is_some_and(|row| {
840 self.selected = row;
841 true
842 }) =>
843 {
844 ViewAction::None
845 }
846 // Enter opens the standard `/model` picker for the row —
847 // catalog, search, readiness and all — and the pick comes
848 // back through `FleetRoutePicked` already saved. `o`/`e`
849 // keep the inline route list for hands that know it.
850 KeyCode::Enter => ViewAction::Emit(ViewEvent::FleetDetailRoutePickRequested {
851 target: self.selected_route_target(),
852 editor_id: self.editor_id,
853 }),
854 KeyCode::Char('o') => {
855 self.open_route_picker(FleetRouteTarget::Operator);
856 ViewAction::None
857 }
858 KeyCode::Char('e') => {
859 if let Some(idx) = self.selected_member_idx() {
860 self.open_route_picker(FleetRouteTarget::Member(idx));
861 }
862 ViewAction::None
863 }
864 KeyCode::Char('t') => {
865 self.cycle_reasoning();
866 ViewAction::None
867 }
868 KeyCode::Char('v') => {
869 self.toggle_vision_requirement();
870 ViewAction::None
871 }
872 KeyCode::Char('a') => {
873 self.add_member();
874 ViewAction::None
875 }
876 KeyCode::Char('d') if self.selected > 0 => {
877 self.pending_remove = true;
878 ViewAction::None
879 }
880 KeyCode::Char('r') => {
881 self.start_rename();
882 ViewAction::None
883 }
884 KeyCode::Char('s') => self.save().unwrap_or(ViewAction::None),
885 KeyCode::Char('c') => self.copy_to_other_scope().unwrap_or(ViewAction::None),
886 KeyCode::Char('u') => self
887 .select_scope(FleetScope::Personal)
888 .unwrap_or(ViewAction::None),
889 KeyCode::Char('w') => self
890 .select_scope(FleetScope::Workspace)
891 .unwrap_or(ViewAction::None),
892 _ => ViewAction::None,
893 }
894 }
895 }
896 }
897
898 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
899 // Mouse browsing cannot bypass a rename or removal confirmation.
900 if self.step != DetailStep::Overview || self.rename_mode || self.pending_remove {
901 return ViewAction::None;
902 }
903 let hit = self.row_hitboxes.borrow().iter().find_map(|(rect, row)| {
904 rect.contains((mouse.column, mouse.row).into())
905 .then_some(*row)
906 });
907 match mouse.kind {
908 MouseEventKind::Moved => self.hovered_row = hit,
909 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
910 return self.handle_key(KeyEvent::new(
911 if mouse.kind == MouseEventKind::ScrollUp {
912 KeyCode::Up
913 } else {
914 KeyCode::Down
915 },
916 KeyModifiers::NONE,
917 ));
918 }
919 MouseEventKind::Down(MouseButton::Left) => {
920 if let Some(row) = hit {
921 let open = self.selected == row && self.last_mouse_selected == Some(row);
922 self.selected = row;
923 self.last_mouse_selected = Some(row);
924 if open {
925 return self.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
926 }
927 } else {
928 self.last_mouse_selected = None;
929 }
930 }
931 _ => {}
932 }
933 ViewAction::None
934 }
935
936 fn render(&self, area: Rect, buf: &mut Buffer) {
937 self.row_hitboxes.borrow_mut().clear();
938 Clear.render(area, buf);
939 Block::default()
940 .style(Style::default().bg(palette::WHALE_BG))
941 .render(area, buf);
942
943 let hints = self.footer_hints();
944 let content = render_modal_footer(area, buf, &hints);
945 let chunks = Layout::default()
946 .direction(Direction::Vertical)
947 .constraints([Constraint::Length(4), Constraint::Min(1)])
948 .split(content);
949
950 // Header.
951 let title = if self.rename_mode {
952 format!("Renaming: {}▏", self.rename_input)
953 } else {
954 format!(
955 "Team `{}` · {} scope · {}",
956 self.fleet.name,
957 self.scope.label(),
958 self.source.display()
959 )
960 };
961 let mut header = vec![
962 Line::from(vec![
963 Span::styled("─ Team ", Style::default().fg(palette::WHALE_ACTION).bold()),
964 Span::styled(title, Style::default().fg(palette::TEXT_SECONDARY)),
965 ]),
966 Line::from(""),
967 ];
968 let operator_line = match &self.fleet.operator {
969 Some(op) => format!(" Coordinator: {}/{}", op.provider, op.model),
970 None => " Coordinator: uses the session's model".to_string(),
971 };
972 header.push(Line::from(Span::styled(
973 operator_line,
974 Style::default().fg(palette::TEXT_DIM),
975 )));
976 if let Some(scout) = &self.scout_receipt {
977 header.push(Line::from(Span::styled(
978 format!(" scout → {scout}"),
979 Style::default().fg(palette::TEXT_DIM),
980 )));
981 }
982 Paragraph::new(header)
983 .wrap(Wrap { trim: false })
984 .render(chunks[0], buf);
985
986 match self.step {
987 DetailStep::Overview => self.render_overview(chunks[1], buf),
988 DetailStep::PickRoute => self.render_pick_route(chunks[1], buf),
989 }
990 }
991 }
992
993 impl FleetDetailView {
994 /// A saved route pin is drifted when the `(provider, model)` pair is not
995 /// among the routes the picker can currently offer — the provider table
996 /// was removed, or the model dropped out of the provider's roster. The
997 /// pin may still serve upstream, so this only flags; it never rewrites.
998 fn pin_drifted(&self, provider: &str, model: &str) -> bool {
999 !self.routes.iter().any(|row| {
1000 row.provider
1001 .as_deref()
1002 .is_some_and(|p| p.eq_ignore_ascii_case(provider))
1003 && row
1004 .model
1005 .as_deref()
1006 .is_some_and(|m| m.eq_ignore_ascii_case(model))
1007 })
1008 }
1009
1010 fn render_overview(&self, area: Rect, buf: &mut Buffer) {
1011 self.row_hitboxes.borrow_mut().clear();
1012 if area.width == 0 || area.height == 0 {
1013 return;
1014 }
1015 let rows_visible = usize::from(area.height).max(1);
1016 let scroll = self.selected.saturating_sub(rows_visible.saturating_sub(1));
1017 let mut lines: Vec<Line<'static>> = Vec::new();
1018
1019 // Keep the Coordinator in place until it actually scrolls offscreen.
1020 if scroll == 0 {
1021 let selected = self.selected == 0;
1022 let base = if selected {
1023 Style::default().fg(palette::WHALE_ACTION).bold()
1024 } else {
1025 Style::default().fg(palette::TEXT_SECONDARY)
1026 };
1027 let operator_text = match &self.fleet.operator {
1028 Some(op) => format!("{}/{}", op.provider, op.model),
1029 None => "inherits session route".to_string(),
1030 };
1031 let reasoning = self
1032 .fleet
1033 .operator
1034 .as_ref()
1035 .and_then(|op| op.reasoning.as_deref())
1036 .unwrap_or("inherit");
1037 let drifted = self
1038 .fleet
1039 .operator
1040 .as_ref()
1041 .is_some_and(|op| self.pin_drifted(&op.provider, &op.model));
1042 let mut spans = vec![
1043 Span::styled(if selected { "» " } else { " " }, base),
1044 Span::styled("operator", base),
1045 Span::styled(" ", Style::default()),
1046 Span::styled(operator_text, Style::default().fg(palette::TEXT_MUTED)),
1047 Span::styled(
1048 format!(" · reasoning: {reasoning}"),
1049 Style::default().fg(palette::TEXT_DIM),
1050 ),
1051 ];
1052 if drifted {
1053 spans.push(Span::styled(
1054 tr(self.locale, MessageId::FleetRouteNotInCatalog),
1055 Style::default().fg(palette::STATUS_WARNING),
1056 ));
1057 }
1058 self.row_hitboxes
1059 .borrow_mut()
1060 .push((Rect::new(area.x, area.y, area.width, 1), 0));
1061 lines.push(Line::from(spans));
1062 }
1063
1064 for (idx, member) in self.fleet.members.iter().enumerate() {
1065 let row = 1 + idx;
1066 if row < scroll || row >= scroll + rows_visible {
1067 continue;
1068 }
1069 self.row_hitboxes.borrow_mut().push((
1070 Rect::new(area.x, area.y + lines.len() as u16, area.width, 1),
1071 row,
1072 ));
1073 let selected = row == self.selected;
1074 let base = if selected {
1075 Style::default().fg(palette::WHALE_ACTION).bold()
1076 } else {
1077 Style::default().fg(palette::TEXT_SECONDARY)
1078 };
1079 let route = match (&member.provider, &member.model) {
1080 (Some(p), Some(m)) => format!("model {p}/{m}"),
1081 _ => "same model as this session".to_string(),
1082 };
1083 let reasoning = member.reasoning.as_deref().unwrap_or("inherit");
1084 let vision = if member.requires.iter().any(|r| r == "vision") {
1085 " · vision"
1086 } else {
1087 ""
1088 };
1089 if self.pending_remove && selected {
1090 lines.push(Line::from(vec![Span::styled(
1091 format!(" Remove member `{}`? y/n", member.id),
1092 Style::default().fg(palette::WHALE_ERROR),
1093 )]));
1094 } else {
1095 let role = if member.shortlist {
1096 String::new()
1097 } else {
1098 format!(" · role {}", public_role_label(member.role_label()))
1099 };
1100 let member_label = member
1101 .display_name
1102 .as_deref()
1103 .map(str::trim)
1104 .filter(|name| !name.is_empty() && !name.eq_ignore_ascii_case(&member.id))
1105 .map_or_else(
1106 || member.id.clone(),
1107 |name| format!("{name} ({})", member.id),
1108 );
1109 let drifted = matches!(
1110 (&member.provider, &member.model),
1111 (Some(provider), Some(model)) if self.pin_drifted(provider, model)
1112 );
1113 let mut spans = vec![
1114 Span::styled(if selected { "» " } else { " " }, base),
1115 Span::styled(member_label, base),
1116 Span::styled(role, Style::default().fg(palette::TEXT_SECONDARY)),
1117 Span::styled(" ", Style::default()),
1118 Span::styled(route, Style::default().fg(palette::TEXT_MUTED)),
1119 Span::styled(
1120 if member.shortlist {
1121 String::new()
1122 } else {
1123 format!(" · reasoning: {reasoning}{vision}")
1124 },
1125 Style::default().fg(palette::TEXT_DIM),
1126 ),
1127 ];
1128 if drifted {
1129 spans.push(Span::styled(
1130 tr(self.locale, MessageId::FleetRouteNotInCatalog),
1131 Style::default().fg(palette::STATUS_WARNING),
1132 ));
1133 }
1134 lines.push(Line::from(spans));
1135 }
1136 }
1137 for ((rect, row), line) in self.row_hitboxes.borrow().iter().zip(lines.iter_mut()) {
1138 let style = if *row == self.selected && !self.pending_remove {
1139 Some(menu_style::selected_row_style())
1140 } else if self.hovered_row == Some(*row) {
1141 Some(menu_style::hovered_row_style())
1142 } else {
1143 None
1144 };
1145 if let Some(style) = style {
1146 buf.set_style(*rect, style);
1147 line.style = style;
1148 for span in &mut line.spans {
1149 // Focus owns the fill, while warnings retain their
1150 // semantic ink and explicit diagnostic label.
1151 span.style = match span.style.fg {
1152 Some(ink)
1153 if ink == palette::STATUS_WARNING || ink == palette::WHALE_ERROR =>
1154 {
1155 style.fg(ink)
1156 }
1157 _ => style,
1158 };
1159 }
1160 }
1161 }
1162 Paragraph::new(ratatui::text::Text::from(lines)).render(area, buf);
1163 }
1164
1165 fn render_pick_route(&self, area: Rect, buf: &mut Buffer) {
1166 if area.width == 0 || area.height == 0 {
1167 return;
1168 }
1169 let filtered = self.filtered_routes();
1170 let rows_visible = usize::from(area.height).max(1);
1171 let pick_scroll = self.pick_row.saturating_sub(rows_visible.saturating_sub(1));
1172 let target_label = match self.pick_target {
1173 FleetRouteTarget::Operator => "operator",
1174 FleetRouteTarget::Member(idx) => self
1175 .fleet
1176 .members
1177 .get(idx)
1178 .map(|m| m.id.as_str())
1179 .unwrap_or("member"),
1180 };
1181 let mut lines: Vec<Line<'static>> = Vec::new();
1182 lines.push(Line::from(Span::styled(
1183 if self.pick_query.is_empty() {
1184 format!(" Model for {target_label} — type to filter, Enter picks.")
1185 } else {
1186 format!(
1187 " Model for {target_label} — filter: {} ({} of {})",
1188 self.pick_query,
1189 filtered.len(),
1190 self.routes.len()
1191 )
1192 },
1193 Style::default().fg(palette::TEXT_MUTED),
1194 )));
1195 lines.push(Line::from(""));
1196 if filtered.is_empty() {
1197 lines.push(Line::from(Span::styled(
1198 " No route matches. Backspace to widen the filter.",
1199 Style::default().fg(palette::TEXT_DIM),
1200 )));
1201 }
1202 for (position, route_idx) in filtered.iter().enumerate() {
1203 if position < pick_scroll || position >= pick_scroll + rows_visible {
1204 continue;
1205 }
1206 let route = &self.routes[*route_idx];
1207 let selected = position == self.pick_row.min(filtered.len().saturating_sub(1));
1208 let base = if selected {
1209 Style::default().fg(palette::WHALE_ACTION).bold()
1210 } else {
1211 Style::default().fg(palette::TEXT_SECONDARY)
1212 };
1213 lines.push(Line::from(vec![
1214 Span::styled(if selected { "» " } else { " " }, base),
1215 Span::styled(route.label.clone(), base),
1216 Span::styled(" ", Style::default()),
1217 Span::styled(
1218 route.summary.clone(),
1219 Style::default().fg(palette::TEXT_DIM),
1220 ),
1221 ]));
1222 }
1223 Paragraph::new(ratatui::text::Text::from(lines)).render(area, buf);
1224 }
1225 }
1226
1227 /// Build the model-picker rows: "same as session" first, then every
1228 /// concrete model across configured providers, with its readiness label —
1229 /// the same list the fleet setup wizard's Model step shows.
1230 fn build_route_rows(config: &Config) -> Vec<RouteRow> {
1231 let mut rows = vec![RouteRow {
1232 label: "same as session".to_string(),
1233 summary: String::new(),
1234 provider: None,
1235 model: None,
1236 }];
1237 let health = crate::provider_readiness::ProviderReadinessSnapshot::default();
1238 let active = config
1239 .provider
1240 .as_deref()
1241 .and_then(ApiProvider::parse)
1242 .unwrap_or(ApiProvider::Deepseek);
1243 let routes = super::fleet_setup::cross_provider_model_routes(config, active, &health);
1244 for (provider, model, readiness) in routes {
1245 let provider_label = crate::tui::views::fleet_setup::provider_display_label(&provider);
1246 let readiness_label = readiness
1247 .blocked_reason()
1248 .map(|r| r.into_owned())
1249 .unwrap_or_else(|| readiness.label().into_owned());
1250 rows.push(RouteRow {
1251 label: format!("{provider_label}/{model}"),
1252 summary: readiness_label,
1253 provider: Some(provider),
1254 model: Some(model),
1255 });
1256 }
1257 rows
1258 }
1259
1260 /// Reasoning tiers a route may genuinely express, keyed by provider class.
1261 /// A tier a provider cannot wire is never offered (no `max` where a route
1262 /// has none). `inherit` is always first.
1263 fn reasoning_tiers_for_provider(provider: &str) -> &'static [&'static str] {
1264 // Tiers are keyed by the provider's exact id from the catalog, never
1265 // guessed from a display name. A tier a route cannot genuinely express
1266 // is not offered.
1267 match provider
1268 .to_ascii_lowercase()
1269 .replace(['_', '-'], "")
1270 .as_str()
1271 {
1272 "deepseek" | "deepseekcn" | "deepseekanthropic" => {
1273 &["inherit", "off", "low", "high", "max"]
1274 }
1275 "moonshot" | "kimi" | "kimicode" => &["inherit", "off", "low", "medium", "high"],
1276 "openaicodex" => &["inherit", "off", "minimal", "high"],
1277 _ => &["inherit", "off", "low", "medium", "high"],
1278 }
1279 }
1280
1281 #[cfg(test)]
1282 mod tests {
1283 use super::*;
1284 use crate::config::Config;
1285 use crate::fleet::store::FleetFile;
1286 use crate::tui::app::{App, TuiOptions};
1287
1288 fn app_in(workspace: PathBuf) -> App {
1289 let options = TuiOptions {
1290 ..crate::test_support::test_tui_options(workspace.clone())
1291 };
1292 let mut app = App::new(options, &Config::default());
1293 app.workspace = workspace;
1294 app
1295 }
1296
1297 fn sample_fleet(name: &str) -> FleetFile {
1298 let mut fleet = FleetFile::new(name.to_string(), None).unwrap();
1299 fleet.operator = Some(FleetOperator {
1300 provider: "deepseek".to_string(),
1301 model: "deepseek-v4-flash".to_string(),
1302 reasoning: None,
1303 });
1304 fleet.members.push(FleetMember {
1305 id: "scout".to_string(),
1306 display_name: Some("Flash Scout".to_string()),
1307 shortlist: false,
1308 role: "scout".to_string(),
1309 provider: None,
1310 model: None,
1311 reasoning: None,
1312 instructions: None,
1313 requires: Vec::new(),
1314 });
1315 fleet
1316 }
1317
1318 fn key(code: KeyCode) -> KeyEvent {
1319 KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
1320 }
1321
1322 #[test]
1323 fn open_loads_the_fleet_by_name_and_scope() {
1324 let ws = tempfile::TempDir::new().unwrap();
1325 let fleet = sample_fleet("DeepSeek Flash");
1326 let path = save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1327
1328 let mut app = app_in(ws.path().to_path_buf());
1329 let view = FleetDetailView::open(
1330 &app,
1331 &Config::default(),
1332 "DeepSeek Flash",
1333 FleetScope::Workspace,
1334 )
1335 .expect("open");
1336 assert_eq!(view.fleet.name, "DeepSeek Flash");
1337 assert_eq!(view.scope, FleetScope::Workspace);
1338 assert_eq!(view.source, path);
1339 assert_eq!(view.row_count(), 2); // operator + scout
1340
1341 let mut duplicate_roles = sample_fleet("Duplicate Roles");
1342 duplicate_roles.members.push(FleetMember {
1343 id: "fast-scout".to_string(),
1344 display_name: Some("Fast Scout".to_string()),
1345 shortlist: false,
1346 role: "scout".to_string(),
1347 provider: None,
1348 model: None,
1349 reasoning: None,
1350 instructions: None,
1351 requires: Vec::new(),
1352 });
1353 save_fleet(&duplicate_roles, FleetScope::Workspace, ws.path())
1354 .expect("save duplicate-role Fleet");
1355 let focused = FleetDetailView::open_for_member(
1356 &app,
1357 &Config::default(),
1358 "Duplicate Roles",
1359 FleetScope::Workspace,
1360 Some("fast-scout"),
1361 )
1362 .expect("open focused member");
1363 assert_eq!(focused.selected, 2);
1364 assert_eq!(
1365 focused.selected_member().map(|member| member.id.as_str()),
1366 Some("fast-scout")
1367 );
1368
1369 // A missing fleet fails to open (the host shows the error receipt).
1370 app.workspace = ws.path().to_path_buf();
1371 assert!(
1372 FleetDetailView::open(&app, &Config::default(), "Nope", FleetScope::Workspace)
1373 .is_none()
1374 );
1375 }
1376
1377 #[test]
1378 fn fleet_role_mouse_selection_opens_the_same_model_picker_as_enter() {
1379 let ws = tempfile::TempDir::new().unwrap();
1380 save_fleet(&sample_fleet("Team"), FleetScope::Workspace, ws.path()).unwrap();
1381 let mut view = FleetDetailView::open(
1382 &app_in(ws.path().to_path_buf()),
1383 &Config::default(),
1384 "Team",
1385 FleetScope::Workspace,
1386 )
1387 .expect("open");
1388 for (width, height) in [(40, 12), (80, 24), (140, 40)] {
1389 view.selected = 0;
1390 view.last_mouse_selected = None;
1391 let area = Rect::new(0, 0, width, height);
1392 view.render(area, &mut Buffer::empty(area));
1393 let hit = view
1394 .row_hitboxes
1395 .borrow()
1396 .iter()
1397 .find_map(|(rect, row)| (*row == 1).then_some(*rect))
1398 .expect("member row");
1399 let click = MouseEvent {
1400 kind: MouseEventKind::Down(MouseButton::Left),
1401 column: hit.x,
1402 row: hit.y,
1403 modifiers: KeyModifiers::NONE,
1404 };
1405 assert!(matches!(view.handle_mouse(click), ViewAction::None));
1406 assert_eq!(view.selected, 1);
1407 // Selection must not move the Coordinator out of the viewport.
1408 view.render(area, &mut Buffer::empty(area));
1409 assert_eq!(view.row_hitboxes.borrow()[0].1, 0);
1410 assert_eq!(view.row_hitboxes.borrow()[1].0, hit);
1411 assert!(matches!(view.handle_mouse(click),
1412 ViewAction::Emit(ViewEvent::FleetDetailRoutePickRequested {
1413 target: FleetRouteTarget::Member(0), editor_id,
1414 }) if editor_id == view.editor_id));
1415 assert!(matches!(view.handle_key(key(KeyCode::Enter)),
1416 ViewAction::Emit(ViewEvent::FleetDetailRoutePickRequested {
1417 target: FleetRouteTarget::Member(0), editor_id,
1418 }) if editor_id == view.editor_id));
1419 // Clicking never skips an active destructive confirmation.
1420 view.pending_remove = true;
1421 assert!(matches!(view.handle_mouse(click), ViewAction::None));
1422 view.pending_remove = false;
1423 let empty = Rect::new(0, 0, 0, 0);
1424 view.render(empty, &mut Buffer::empty(empty));
1425 assert!(view.row_hitboxes.borrow().is_empty());
1426 }
1427 }
1428
1429 /// Enter on a row asks the host for the standard `/model` picker, and the
1430 /// route it hands back is applied and written in one step.
1431 #[test]
1432 fn enter_asks_for_the_model_picker_and_a_pick_saves_the_row() {
1433 let ws = tempfile::TempDir::new().unwrap();
1434 let fleet = sample_fleet("Team");
1435 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1436 let mut view = FleetDetailView::open(
1437 &app_in(ws.path().to_path_buf()),
1438 &Config::default(),
1439 "Team",
1440 FleetScope::Workspace,
1441 )
1442 .expect("open");
1443
1444 // Row 0 is the Coordinator; the first member sits under it.
1445 assert!(matches!(
1446 view.handle_key(key(KeyCode::Enter)),
1447 ViewAction::Emit(ViewEvent::FleetDetailRoutePickRequested {
1448 target: FleetRouteTarget::Operator, editor_id
1449 }) if editor_id == view.editor_id
1450 ));
1451 view.handle_key(key(KeyCode::Down));
1452 assert!(matches!(
1453 view.handle_key(key(KeyCode::Enter)),
1454 ViewAction::Emit(ViewEvent::FleetDetailRoutePickRequested {
1455 target: FleetRouteTarget::Member(0), editor_id
1456 }) if editor_id == view.editor_id
1457 ));
1458
1459 let receipt = view
1460 .apply_picked_route(
1461 view.editor_id,
1462 FleetRouteTarget::Member(0),
1463 Some("openai".to_string()),
1464 Some("gpt-5.6".to_string()),
1465 Some(crate::reasoning_preference::ReasoningEffort::High),
1466 )
1467 .expect("saved");
1468 assert!(receipt.contains("openai/gpt-5.6"), "{receipt}");
1469 let (saved, _) = load_fleet_in_scope("Team", FleetScope::Workspace, ws.path()).unwrap();
1470 assert_eq!(saved.members[0].provider.as_deref(), Some("openai"));
1471 assert_eq!(saved.members[0].model.as_deref(), Some("gpt-5.6"));
1472 assert_eq!(saved.members[0].reasoning.as_deref(), Some("high"));
1473 assert!(
1474 view.scout_receipt
1475 .as_deref()
1476 .unwrap()
1477 .contains("openai/gpt-5.6")
1478 );
1479
1480 // Clearing the pin returns the member to the session route.
1481 view.apply_picked_route(
1482 view.editor_id,
1483 FleetRouteTarget::Member(0),
1484 None,
1485 None,
1486 None,
1487 )
1488 .expect("saved");
1489 let (saved, _) = load_fleet_in_scope("Team", FleetScope::Workspace, ws.path()).unwrap();
1490 assert_eq!(saved.members[0].provider, None);
1491 assert_eq!(saved.members[0].model, None);
1492
1493 // A row that no longer exists writes nothing.
1494 assert!(
1495 view.apply_picked_route(
1496 view.editor_id,
1497 FleetRouteTarget::Member(99),
1498 Some("openai".to_string()),
1499 Some("gpt-5.6".to_string()),
1500 None,
1501 )
1502 .is_err()
1503 );
1504 }
1505
1506 #[test]
1507 fn route_pick_refuses_changed_missing_or_different_editor_without_overwriting() {
1508 for change in ["replace", "reorder", "edit", "remove", "different-editor"] {
1509 let ws = tempfile::TempDir::new().unwrap();
1510 let mut fleet = sample_fleet("Team");
1511 let mut second = fleet.members[0].clone();
1512 second.id = "reviewer".into();
1513 second.role = "reviewer".into();
1514 fleet.members.push(second);
1515 let path = save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1516 let mut view = FleetDetailView::open(
1517 &app_in(ws.path().to_path_buf()),
1518 &Config::default(),
1519 "Team",
1520 FleetScope::Workspace,
1521 )
1522 .unwrap();
1523 match change {
1524 "replace" => {
1525 fleet.members[0].id = "replacement".into();
1526 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1527 }
1528 "reorder" => {
1529 fleet.members.swap(0, 1);
1530 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1531 }
1532 "edit" => {
1533 let bytes = std::fs::read_to_string(&path).unwrap();
1534 std::fs::write(&path, format!("{bytes}\n# new user note\n")).unwrap();
1535 }
1536 "remove" => std::fs::remove_file(&path).unwrap(),
1537 _ => {}
1538 }
1539 let before = std::fs::read(&path).ok();
1540 let draft = view.fleet.clone();
1541 let editor_id = if change == "different-editor" {
1542 uuid::Uuid::new_v4()
1543 } else {
1544 view.editor_id
1545 };
1546 assert!(
1547 view.apply_picked_route(
1548 editor_id,
1549 FleetRouteTarget::Member(0),
1550 Some("openai".into()),
1551 Some("gpt-5.6".into()),
1552 None,
1553 )
1554 .is_err(),
1555 "{change}"
1556 );
1557 assert_eq!(std::fs::read(&path).ok(), before, "{change}");
1558 assert_eq!(view.fleet, draft, "{change}");
1559 }
1560 }
1561
1562 #[test]
1563 fn failed_route_pick_preserves_the_editor_and_saved_team() {
1564 let ws = tempfile::TempDir::new().unwrap();
1565 let mut fleet = sample_fleet("Shortlist");
1566 let member = &mut fleet.members[0];
1567 member.shortlist = true;
1568 member.role.clear();
1569 member.provider = Some("openai".to_string());
1570 member.model = Some("gpt-5.6".to_string());
1571 let path = save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1572 let before = std::fs::read(&path).unwrap();
1573 let mut view = FleetDetailView::open(
1574 &app_in(ws.path().to_path_buf()),
1575 &Config::default(),
1576 "Shortlist",
1577 FleetScope::Workspace,
1578 )
1579 .unwrap();
1580 // A shortlisted row must have an explicit route. Failed validation
1581 // must not leave the editor displaying a change that never saved.
1582 assert!(
1583 view.apply_picked_route(
1584 view.editor_id,
1585 FleetRouteTarget::Member(0),
1586 None,
1587 None,
1588 None
1589 )
1590 .is_err()
1591 );
1592 assert_eq!(view.fleet.members[0].provider.as_deref(), Some("openai"));
1593 assert_eq!(view.fleet.members[0].model.as_deref(), Some("gpt-5.6"));
1594 assert_eq!(std::fs::read(&path).unwrap(), before);
1595 }
1596
1597 #[test]
1598 fn rename_commits_and_names_the_receipt() {
1599 let ws = tempfile::TempDir::new().unwrap();
1600 let fleet = sample_fleet("Old Name");
1601 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1602
1603 let mut view = FleetDetailView::open(
1604 &app_in(ws.path().to_path_buf()),
1605 &Config::default(),
1606 "Old Name",
1607 FleetScope::Workspace,
1608 )
1609 .expect("open");
1610
1611 view.handle_key(key(KeyCode::Char('r')));
1612 assert!(view.rename_mode);
1613 // The input starts filled with the current name; clear it, then type.
1614 for _ in "Old Name".chars() {
1615 view.handle_key(key(KeyCode::Backspace));
1616 }
1617 for ch in "New Name".chars() {
1618 view.handle_key(key(KeyCode::Char(ch)));
1619 }
1620 let action = view.handle_key(key(KeyCode::Enter));
1621 let ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { message }) = action else {
1622 panic!("expected FleetStoreChanged, got {action:?}");
1623 };
1624 assert!(
1625 message.contains("Renamed Team `Old Name` → `New Name`"),
1626 "{message}"
1627 );
1628 // The on-disk file now carries the new name.
1629 let (loaded, _) =
1630 crate::fleet::store::load_fleet_in_scope("New Name", FleetScope::Workspace, ws.path())
1631 .expect("reload");
1632 assert_eq!(loaded.name, "New Name");
1633 }
1634
1635 #[test]
1636 fn operator_route_pick_pins_and_inherit_clears() {
1637 let ws = tempfile::TempDir::new().unwrap();
1638 let fleet = sample_fleet("Fleet A");
1639 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1640
1641 let mut view = FleetDetailView::open(
1642 &app_in(ws.path().to_path_buf()),
1643 &Config::default(),
1644 "Fleet A",
1645 FleetScope::Workspace,
1646 )
1647 .expect("open");
1648 assert!(view.fleet.operator.is_some());
1649
1650 // Enter the operator picker, choose the inherit row.
1651 view.handle_key(key(KeyCode::Char('o')));
1652 assert_eq!(view.step, DetailStep::PickRoute);
1653 view.pick_row = 0;
1654 view.handle_key(key(KeyCode::Enter));
1655 assert_eq!(view.step, DetailStep::Overview);
1656 assert!(view.fleet.operator.is_none(), "inherit row clears the pin");
1657
1658 // Re-enter and pick the first concrete route row.
1659 view.handle_key(key(KeyCode::Char('o')));
1660 view.pick_row = 1;
1661 view.handle_key(key(KeyCode::Enter));
1662 let op = view.fleet.operator.as_ref().expect("pinned operator");
1663 assert!(!op.provider.is_empty() && !op.model.is_empty());
1664 }
1665
1666 /// "It is too hard to assign a model from a specific provider to a
1667 /// specific fleet role." The picker listed every configured
1668 /// provider/model route and offered only arrow keys; `j` and `k` moved
1669 /// the highlight instead of typing. Letters now narrow the list.
1670 #[test]
1671 fn typing_narrows_the_route_picker_and_enter_picks_from_the_narrowed_list() {
1672 let ws = tempfile::TempDir::new().unwrap();
1673 let fleet = sample_fleet("Fleet Filter");
1674 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1675
1676 let mut view = FleetDetailView::open(
1677 &app_in(ws.path().to_path_buf()),
1678 &Config::default(),
1679 "Fleet Filter",
1680 FleetScope::Workspace,
1681 )
1682 .expect("open");
1683
1684 view.handle_key(key(KeyCode::Char('o')));
1685 assert_eq!(view.step, DetailStep::PickRoute);
1686 let unfiltered = view.filtered_routes().len();
1687 assert!(unfiltered > 1, "the picker needs rows to narrow");
1688
1689 // Type the provider of a concrete row and confirm the list shrinks to
1690 // rows that actually mention it.
1691 let target = view.routes[1..]
1692 .iter()
1693 .find_map(|route| route.provider.clone())
1694 .expect("a concrete route row");
1695 for ch in target.chars() {
1696 view.handle_key(key(KeyCode::Char(ch)));
1697 }
1698 let filtered = view.filtered_routes();
1699 assert!(!filtered.is_empty(), "the typed provider must match itself");
1700 assert!(
1701 filtered.len() < unfiltered || unfiltered == filtered.len(),
1702 "filtering must never grow the list"
1703 );
1704 for idx in &filtered {
1705 let route = &view.routes[*idx];
1706 assert!(
1707 crate::tui::views::fleet_setup::route_matches_query(
1708 &target,
1709 route.provider.as_deref().unwrap_or(""),
1710 route.model.as_deref().unwrap_or(""),
1711 *idx == 0,
1712 ),
1713 "row {:?} survived a filter it does not match",
1714 route.label
1715 );
1716 }
1717
1718 // Enter picks from the narrowed list, not from the raw index.
1719 let expected = view.routes[filtered[0]].clone();
1720 view.handle_key(key(KeyCode::Enter));
1721 assert_eq!(view.step, DetailStep::Overview);
1722 match (expected.provider, expected.model) {
1723 (Some(provider), Some(model)) => {
1724 let op = view.fleet.operator.as_ref().expect("pinned operator");
1725 assert_eq!(op.provider, provider);
1726 assert_eq!(op.model, model);
1727 }
1728 _ => assert!(view.fleet.operator.is_none(), "inherit row clears the pin"),
1729 }
1730
1731 // Backspace widens again, and Esc clears a filter before it leaves.
1732 view.handle_key(key(KeyCode::Char('o')));
1733 view.handle_key(key(KeyCode::Char('z')));
1734 view.handle_key(key(KeyCode::Char('z')));
1735 view.handle_key(key(KeyCode::Backspace));
1736 assert_eq!(view.pick_query, "z");
1737 view.handle_key(key(KeyCode::Esc));
1738 assert!(view.pick_query.is_empty());
1739 assert_eq!(
1740 view.step,
1741 DetailStep::PickRoute,
1742 "the first Esc spends itself on the filter"
1743 );
1744 view.handle_key(key(KeyCode::Esc));
1745 assert_eq!(view.step, DetailStep::Overview);
1746 }
1747
1748 #[test]
1749 fn member_edit_pins_route_and_toggles_vision() {
1750 let ws = tempfile::TempDir::new().unwrap();
1751 let fleet = sample_fleet("Fleet B");
1752 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1753
1754 let mut view = FleetDetailView::open(
1755 &app_in(ws.path().to_path_buf()),
1756 &Config::default(),
1757 "Fleet B",
1758 FleetScope::Workspace,
1759 )
1760 .expect("open");
1761
1762 // Select the scout member (row 1) and pin the first concrete route.
1763 view.selected = 1;
1764 view.handle_key(key(KeyCode::Char('e')));
1765 assert_eq!(view.step, DetailStep::PickRoute);
1766 view.pick_row = 1;
1767 view.handle_key(key(KeyCode::Enter));
1768 let member = view.fleet.member("scout").expect("scout");
1769 assert!(
1770 member.provider.is_some() && member.model.is_some(),
1771 "scout must be pinned: {member:?}"
1772 );
1773
1774 // Vision requirement toggles on and off.
1775 view.handle_key(key(KeyCode::Char('v')));
1776 assert!(
1777 view.fleet
1778 .member("scout")
1779 .unwrap()
1780 .requires
1781 .contains(&"vision".to_string())
1782 );
1783 view.handle_key(key(KeyCode::Char('v')));
1784 assert!(view.fleet.member("scout").unwrap().requires.is_empty());
1785 }
1786
1787 #[test]
1788 fn shortlist_editor_preserves_exact_route_and_cannot_select_inherit() {
1789 let _lock = crate::test_support::lock_test_env();
1790 let ws = tempfile::tempdir().unwrap();
1791 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", ws.path().join("home"));
1792 let mut fleet = FleetFile::new("Shortlist editor".into(), None).unwrap();
1793 fleet.members.push(
1794 serde_json::from_value(serde_json::json!({
1795 "id": "choice", "shortlist": true,
1796 "provider": "deepseek", "model": "deepseek-v4-pro",
1797 }))
1798 .unwrap(),
1799 );
1800 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1801 let config = Config {
1802 provider: Some("deepseek".into()),
1803 api_key: Some("test-key".into()),
1804 ..Default::default()
1805 };
1806 let mut view = FleetDetailView::open_for_member(
1807 &app_in(ws.path().to_path_buf()),
1808 &config,
1809 &fleet.name,
1810 FleetScope::Workspace,
1811 Some("choice"),
1812 )
1813 .unwrap();
1814
1815 let before = view.fleet.members[0].clone();
1816 for code in [KeyCode::Char('t'), KeyCode::Char('v')] {
1817 view.handle_key(key(code));
1818 }
1819 assert_eq!(
1820 view.fleet.members[0], before,
1821 "role-only keys cannot alter a shortlist choice"
1822 );
1823 assert!(
1824 view.footer_hints()
1825 .iter()
1826 .all(|hint| !matches!(hint.key.as_ref(), "t" | "v"))
1827 );
1828 let area = Rect::new(0, 0, 160, 8);
1829 let mut buf = Buffer::empty(area);
1830 view.render_overview(area, &mut buf);
1831 let rows: Vec<String> = (0..area.height)
1832 .map(|y| (0..area.width).map(|x| buf[(x, y)].symbol()).collect())
1833 .collect();
1834 let choice_row = rows
1835 .iter()
1836 .find(|row| row.contains("choice"))
1837 .expect("shortlist row rendered");
1838 assert!(
1839 !choice_row.contains("role ")
1840 && !choice_row.contains("reasoning:")
1841 && !choice_row.contains("vision"),
1842 "{choice_row}"
1843 );
1844
1845 view.handle_key(key(KeyCode::Char('e')));
1846 assert_eq!(view.step, DetailStep::PickRoute);
1847 let filtered = view.filtered_routes();
1848 assert!(!filtered.is_empty());
1849 assert!(
1850 filtered.iter().all(|idx| {
1851 view.routes[*idx].provider.is_some() && view.routes[*idx].model.is_some()
1852 }),
1853 "shortlist entries must not offer inherited routes"
1854 );
1855 let selected = &view.routes[view.picked_route_index().expect("current route selected")];
1856 assert_eq!(selected.provider.as_deref(), Some("deepseek"));
1857 assert_eq!(selected.model.as_deref(), Some("deepseek-v4-pro"));
1858 view.handle_key(key(KeyCode::Enter));
1859 assert!(matches!(
1860 view.handle_key(key(KeyCode::Char('s'))),
1861 ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { .. })
1862 ));
1863 let (reloaded, _) =
1864 load_fleet_in_scope(&fleet.name, FleetScope::Workspace, ws.path()).unwrap();
1865 assert_eq!(
1866 reloaded, fleet,
1867 "editing a shortlist preserves the complete route and marker"
1868 );
1869 }
1870
1871 #[test]
1872 fn overview_flags_a_saved_route_the_picker_can_no_longer_offer() {
1873 let ws = tempfile::TempDir::new().unwrap();
1874 let mut fleet = sample_fleet("Drifted");
1875 fleet.members[0].provider = Some("gone-provider".to_string());
1876 fleet.members[0].model = Some("gone-model".to_string());
1877 fleet.members.push(FleetMember {
1878 id: "kept".to_string(),
1879 display_name: None,
1880 shortlist: false,
1881 role: "reviewer".to_string(),
1882 provider: Some("deepseek".to_string()),
1883 model: Some("deepseek-v4-pro".to_string()),
1884 reasoning: None,
1885 instructions: None,
1886 requires: Vec::new(),
1887 });
1888 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1889
1890 let view = FleetDetailView::open(
1891 &app_in(ws.path().to_path_buf()),
1892 &Config::default(),
1893 "Drifted",
1894 FleetScope::Workspace,
1895 )
1896 .expect("open");
1897
1898 assert!(view.pin_drifted("gone-provider", "gone-model"));
1899 assert!(!view.pin_drifted("deepseek", "deepseek-v4-pro"));
1900
1901 let area = Rect::new(0, 0, 160, 8);
1902 let mut buf = Buffer::empty(area);
1903 view.render_overview(area, &mut buf);
1904 let rows: Vec<String> = (0..area.height)
1905 .map(|y| (0..area.width).map(|x| buf[(x, y)].symbol()).collect())
1906 .collect();
1907 let drifted_row = rows
1908 .iter()
1909 .find(|row| row.contains("gone-model"))
1910 .expect("drifted member row rendered");
1911 assert!(
1912 drifted_row.contains("not in current catalog"),
1913 "{drifted_row}"
1914 );
1915 let offered_row = rows
1916 .iter()
1917 .find(|row| row.contains("deepseek-v4-pro"))
1918 .expect("offerable member row rendered");
1919 assert!(
1920 !offered_row.contains("not in current catalog"),
1921 "{offered_row}"
1922 );
1923 }
1924
1925 #[test]
1926 fn save_writes_the_file_and_receipt_names_the_path() {
1927 let ws = tempfile::TempDir::new().unwrap();
1928 let fleet = sample_fleet("Fleet C");
1929 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1930
1931 let mut view = FleetDetailView::open(
1932 &app_in(ws.path().to_path_buf()),
1933 &Config::default(),
1934 "Fleet C",
1935 FleetScope::Workspace,
1936 )
1937 .expect("open");
1938
1939 // Change the operator model, then save.
1940 view.fleet.operator = Some(FleetOperator {
1941 provider: "deepseek".to_string(),
1942 model: "deepseek-v4-pro".to_string(),
1943 reasoning: Some("high".to_string()),
1944 });
1945 let action = view.handle_key(key(KeyCode::Char('s')));
1946 let ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { message }) = action else {
1947 panic!("expected FleetStoreChanged, got {action:?}");
1948 };
1949 assert!(message.contains("Saved Team `Fleet C`"), "{message}");
1950 // The receipt names the path as this platform writes it, so build the
1951 // expected tail the same way instead of hard-coding `/` — on Windows
1952 // `Path::display` renders the separators as `\`.
1953 let expected_tail = std::path::Path::new(".codewhale")
1954 .join("fleets")
1955 .join("fleet-c.toml")
1956 .display()
1957 .to_string();
1958 assert!(message.contains(&expected_tail), "{message}");
1959
1960 let (loaded, _) =
1961 crate::fleet::store::load_fleet_in_scope("Fleet C", FleetScope::Workspace, ws.path())
1962 .expect("reload");
1963 let op = loaded.operator.expect("operator");
1964 assert_eq!(op.model, "deepseek-v4-pro");
1965 assert_eq!(op.reasoning.as_deref(), Some("high"));
1966 }
1967
1968 #[test]
1969 fn roster_picker_spells_roles_like_setup() {
1970 // The setup wizard's ROLES table owns the "synthesizer" spelling
1971 // (profile role_hint + file stem contract); the roster add-member
1972 // picker must offer the same word, not the "summarizer" near-twin
1973 // (#6087 item 3). Both resolve to Planner posture, so this pins
1974 // the presented vocabulary, not behavior.
1975 assert!(KNOWN_ROLES.contains(&"synthesizer"));
1976 assert!(!KNOWN_ROLES.contains(&"summarizer"));
1977 }
1978
1979 #[test]
1980 fn add_member_uses_role_occupancy_and_preserves_colliding_shortlist() {
1981 let ws = tempfile::TempDir::new().unwrap();
1982 let mut fleet = sample_fleet("Fleet D");
1983 fleet.members.push(
1984 serde_json::from_value(serde_json::json!({
1985 "id": "implement", "shortlist": true,
1986 "provider": "custom-a", "model": "implement",
1987 }))
1988 .unwrap(),
1989 );
1990 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1991
1992 let mut view = FleetDetailView::open(
1993 &app_in(ws.path().to_path_buf()),
1994 &Config::default(),
1995 "Fleet D",
1996 FleetScope::Workspace,
1997 )
1998 .expect("open");
1999
2000 view.handle_key(key(KeyCode::Char('a')));
2001 let ids: Vec<&str> = view.fleet.members.iter().map(|m| m.id.as_str()).collect();
2002 assert_eq!(ids, vec!["scout", "implement", "implement-role"]);
2003 assert_eq!(view.fleet.members[2].role, "implement");
2004 assert!(!view.fleet.members[2].shortlist);
2005 assert!(matches!(
2006 view.handle_key(key(KeyCode::Char('s'))),
2007 ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { .. })
2008 ));
2009 let (reloaded, _) =
2010 load_fleet_in_scope("Fleet D", FleetScope::Workspace, ws.path()).unwrap();
2011 assert_eq!(reloaded.members[..2], fleet.members);
2012 let roster = crate::fleet::identity::roster_from_fleet(
2013 &reloaded,
2014 FleetScope::Workspace,
2015 PathBuf::from("fleet-d.toml").as_path(),
2016 );
2017 assert_eq!(roster.members().len(), 2);
2018 assert!(roster.get("implement").is_none());
2019 assert_eq!(
2020 roster.get("implement-role").unwrap().profile.role.name,
2021 "implement"
2022 );
2023
2024 // Remove the new member with the confirmed delete flow.
2025 view.selected = 3;
2026 view.handle_key(key(KeyCode::Char('d')));
2027 view.handle_key(key(KeyCode::Char('y')));
2028 assert_eq!(view.fleet.members, fleet.members);
2029 }
2030 }
2031
2031 lines RUST