| 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 |