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