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