返回 CodeWhale
fleet_detail.rs
根目录 / crates / tui / src / tui / views / fleet_detail.rs
1 //! Fleet detail — open a saved named Fleet and edit it.
2 //!
3 //! Row 0 is the Fleet's own operator route; 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::path::PathBuf;
10
11 use crossterm::event::{KeyCode, KeyEvent};
12 use ratatui::{
13 buffer::Buffer,
14 layout::{Constraint, Direction, Layout, Rect},
15 style::Style,
16 text::{Line, Span},
17 widgets::{Block, Clear, Paragraph, Widget, Wrap},
18 };
19
20 use crate::config::{ApiProvider, Config};
21 use crate::fleet::store::{
22 FleetFile, FleetMember, FleetOperator, FleetScope, MemberCapability, load_fleet_in_scope,
23 save_fleet, set_selected,
24 };
25 use crate::palette;
26 use crate::tui::app::App;
27 use crate::tui::views::{
28 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
29 };
30
31 /// The built-in role vocabulary offered when adding a member, in a useful
32 /// order. A Fleet member is a role; the user can name anything, these are the
33 /// known postures.
34 const KNOWN_ROLES: [&str; 8] = [
35 "scout",
36 "builder",
37 "reviewer",
38 "verifier",
39 "manager",
40 "consultant",
41 "summarizer",
42 "general",
43 ];
44
45 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
46 enum DetailStep {
47 Overview,
48 PickRoute,
49 }
50
51 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
52 enum PickTarget {
53 Operator,
54 Member(usize),
55 }
56
57 /// One selectable route row in the picker step: inherit or a concrete
58 /// provider/model with its readiness label.
59 #[derive(Debug, Clone)]
60 struct RouteRow {
61 label: String,
62 summary: String,
63 provider: Option<String>,
64 model: Option<String>,
65 }
66
67 pub struct FleetDetailView {
68 fleet: FleetFile,
69 scope: FleetScope,
70 source: PathBuf,
71 workspace: PathBuf,
72 /// 0 = operator row; 1.. = members.
73 selected: usize,
74 step: DetailStep,
75 pick_target: PickTarget,
76 routes: Vec<RouteRow>,
77 pick_row: usize,
78 // Inline rename.
79 rename_mode: bool,
80 rename_input: String,
81 // Delete confirmation.
82 pending_remove: bool,
83 /// The resolved Scout route shown before a run (pinned / verified
84 /// companion / inherited / unavailable), refreshed on route edits.
85 scout_receipt: Option<String>,
86 /// Session route at open, used to resolve the unpinned Scout.
87 session_provider: String,
88 session_model: String,
89 }
90
91 impl FleetDetailView {
92 /// Open a saved Fleet by name and scope. The caller (the list view) names
93 /// the scope explicitly, so ambiguity is impossible here.
94 pub fn open(app: &App, config: &Config, name: &str, scope: FleetScope) -> Option<Self> {
95 let (fleet, source) = load_fleet_in_scope(name, scope, &app.workspace).ok()?;
96 let session_provider = if app.auto_model {
97 app.last_effective_provider_identity
98 .clone()
99 .unwrap_or_else(|| app.provider_identity_for_persistence().to_string())
100 } else {
101 app.provider_identity_for_persistence().to_string()
102 };
103 let session_model = if app.auto_model {
104 app.last_effective_model
105 .clone()
106 .unwrap_or_else(|| "auto".to_string())
107 } else {
108 app.model.clone()
109 };
110 Some(Self::from_parts(
111 fleet,
112 scope,
113 source,
114 app.workspace.clone(),
115 config,
116 &session_provider,
117 &session_model,
118 ))
119 }
120
121 fn from_parts(
122 fleet: FleetFile,
123 scope: FleetScope,
124 source: PathBuf,
125 workspace: PathBuf,
126 config: &Config,
127 session_provider: &str,
128 session_model: &str,
129 ) -> Self {
130 let routes = build_route_rows(config);
131 let mut view = Self {
132 fleet,
133 scope,
134 source,
135 workspace,
136 selected: 0,
137 step: DetailStep::Overview,
138 pick_target: PickTarget::Operator,
139 routes,
140 pick_row: 0,
141 rename_mode: false,
142 rename_input: String::new(),
143 pending_remove: false,
144 scout_receipt: None,
145 session_provider: session_provider.to_string(),
146 session_model: session_model.to_string(),
147 };
148 view.refresh_scout_receipt();
149 view
150 }
151
152 /// Recompute the resolved Scout route from the current fleet draft and
153 /// session route. Called at open and after every route edit.
154 fn refresh_scout_receipt(&mut self) {
155 self.scout_receipt = self.fleet.has_scout().then(|| {
156 crate::fleet::scout::resolve_scout_route(
157 self.fleet.member("scout"),
158 &self.session_provider,
159 &self.session_model,
160 )
161 .receipt_line()
162 });
163 }
164
165 fn row_count(&self) -> usize {
166 1 + self.fleet.members.len()
167 }
168
169 fn selected_member_idx(&self) -> Option<usize> {
170 self.selected.checked_sub(1)
171 }
172
173 fn selected_member(&self) -> Option<&FleetMember> {
174 self.selected_member_idx()
175 .and_then(|idx| self.fleet.members.get(idx))
176 }
177
178 fn move_row(&mut self, delta: isize) {
179 self.selected = crate::tui::list_nav::wrap_index(self.selected, self.row_count(), delta);
180 }
181
182 fn start_rename(&mut self) {
183 self.rename_mode = true;
184 self.rename_input = self.fleet.name.clone();
185 }
186
187 fn commit_rename(&mut self) -> Option<ViewAction> {
188 let new_name = self.rename_input.trim().to_string();
189 if new_name.is_empty() {
190 self.rename_mode = false;
191 return Some(ViewAction::None);
192 }
193 if new_name == self.fleet.name {
194 self.rename_mode = false;
195 return Some(ViewAction::None);
196 }
197 // The rename must not collide with a different Fleet of the same slug
198 // in this scope (the store refuses that at save).
199 let old_name = self.fleet.name.clone();
200 self.fleet.name = new_name.clone();
201 self.rename_mode = false;
202 match save_fleet(&self.fleet, self.scope, &self.workspace) {
203 Ok(path) => Some(ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged {
204 message: format!(
205 "Renamed Fleet `{old_name}` → `{new_name}` ({}) — wrote {}",
206 self.scope.label(),
207 path.display()
208 ),
209 })),
210 Err(err) => {
211 self.fleet.name = old_name;
212 Some(ViewAction::Emit(ViewEvent::OpenTextPager {
213 title: "Rename failed".to_string(),
214 content: format!("{err:#}"),
215 }))
216 }
217 }
218 }
219
220 /// Enter the route-picker step for the target.
221 fn open_route_picker(&mut self, target: PickTarget) {
222 self.step = DetailStep::PickRoute;
223 self.pick_target = target;
224 // Preselect the row matching the current pin (or the inherit row).
225 self.pick_row = 0;
226 let current: Option<(&str, &str)> = match target {
227 PickTarget::Operator => self
228 .fleet
229 .operator
230 .as_ref()
231 .map(|op| (op.provider.as_str(), op.model.as_str())),
232 PickTarget::Member(idx) => self
233 .fleet
234 .members
235 .get(idx)
236 .and_then(|m| m.provider.as_deref().zip(m.model.as_deref())),
237 };
238 if let Some((provider, model)) = current {
239 for (idx, route) in self.routes.iter().enumerate() {
240 if route.provider.as_deref() == Some(provider)
241 && route.model.as_deref() == Some(model)
242 {
243 self.pick_row = idx;
244 break;
245 }
246 }
247 }
248 }
249
250 fn apply_route_pick(&mut self) -> Option<ViewAction> {
251 let route = self.routes.get(self.pick_row)?;
252 match self.pick_target {
253 PickTarget::Operator => {
254 self.fleet.operator = match (&route.provider, &route.model) {
255 (Some(provider), Some(model)) => Some(FleetOperator {
256 provider: provider.clone(),
257 model: model.clone(),
258 reasoning: self
259 .fleet
260 .operator
261 .as_ref()
262 .and_then(|op| op.reasoning.clone()),
263 }),
264 _ => None,
265 };
266 }
267 PickTarget::Member(idx) => {
268 if let Some(member) = self.fleet.members.get_mut(idx) {
269 match (&route.provider, &route.model) {
270 (Some(provider), Some(model)) => {
271 member.provider = Some(provider.clone());
272 member.model = Some(model.clone());
273 }
274 _ => {
275 member.provider = None;
276 member.model = None;
277 }
278 }
279 }
280 }
281 }
282 self.step = DetailStep::Overview;
283 self.rename_mode = false;
284 self.route_edit_needs_refresh();
285 Some(ViewAction::None)
286 }
287
288 /// Cycle the reasoning level of the selected row through the supported
289 /// tiers. The tier list is the provider's documented vocabulary; a tier a
290 /// route cannot genuinely express is never offered.
291 fn cycle_reasoning(&mut self) {
292 let tiers: &[&str] = match self.selected {
293 0 => {
294 if let Some(op) = &self.fleet.operator {
295 reasoning_tiers_for_provider(&op.provider)
296 } else {
297 &[]
298 }
299 }
300 _ => {
301 if let Some(member) = self.selected_member()
302 && let Some(provider) = &member.provider
303 {
304 reasoning_tiers_for_provider(provider)
305 } else {
306 &[]
307 }
308 }
309 };
310 if tiers.is_empty() {
311 return;
312 }
313 let slot = match self.selected {
314 0 => self.fleet.operator.as_mut().map(|op| &mut op.reasoning),
315 _ => self
316 .selected_member_idx()
317 .and_then(|idx| self.fleet.members.get_mut(idx))
318 .map(|m| &mut m.reasoning),
319 };
320 let Some(slot) = slot else { return };
321 let current = slot.as_deref().unwrap_or("inherit");
322 let next = match tiers.iter().position(|t| *t == current) {
323 Some(pos) => tiers[(pos + 1) % tiers.len()],
324 None => tiers[0],
325 };
326 *slot = if next == "inherit" {
327 None
328 } else {
329 Some(next.to_string())
330 };
331 }
332
333 /// The scout receipt depends on the member pin and the session route;
334 /// reasoning edits don't affect it. Pins refresh it at the next open;
335 /// the marker exists so route-edit call sites document that intent.
336 fn route_edit_needs_refresh(&mut self) {}
337
338 fn toggle_vision_requirement(&mut self) {
339 if let Some(member) = self.selected_member_idx()
340 && let Some(member) = self.fleet.members.get_mut(member)
341 {
342 if member.requires.iter().any(|r| r == "vision") {
343 member.requires.retain(|r| r != "vision");
344 } else {
345 member
346 .requires
347 .push(MemberCapability::Vision.wire_name().to_string());
348 }
349 }
350 }
351
352 fn add_member(&mut self) {
353 // First known role not already present.
354 let existing: Vec<&str> = self.fleet.members.iter().map(|m| m.id.as_str()).collect();
355 let Some(role) = KNOWN_ROLES.iter().find(|r| !existing.contains(r)) else {
356 return;
357 };
358 self.fleet.members.push(FleetMember {
359 id: role.to_string(),
360 role: role.to_string(),
361 provider: None,
362 model: None,
363 reasoning: None,
364 instructions: None,
365 requires: Vec::new(),
366 });
367 }
368
369 fn remove_selected_member(&mut self) {
370 if let Some(idx) = self.selected_member_idx() {
371 self.fleet.members.remove(idx);
372 self.pending_remove = false;
373 if self.selected >= self.row_count() {
374 self.selected = self.row_count().saturating_sub(1);
375 }
376 }
377 }
378
379 fn save(&self) -> Option<ViewAction> {
380 match save_fleet(&self.fleet, self.scope, &self.workspace) {
381 Ok(path) => Some(ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged {
382 message: format!(
383 "Saved Fleet `{}` ({}) — wrote {}",
384 self.fleet.name,
385 self.scope.long_label(),
386 path.display()
387 ),
388 })),
389 Err(err) => Some(ViewAction::Emit(ViewEvent::OpenTextPager {
390 title: "Save failed".to_string(),
391 content: format!(
392 "Nothing was written.\n\n{err:#}\n\nFix the issue and save again."
393 ),
394 })),
395 }
396 }
397
398 fn copy_to_other_scope(&self) -> Option<ViewAction> {
399 let target = self.scope.toggled();
400 match save_fleet(&self.fleet, target, &self.workspace) {
401 Ok(path) => Some(ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged {
402 message: format!(
403 "Copied Fleet `{}` to {} scope — wrote {}",
404 self.fleet.name,
405 target.label(),
406 path.display()
407 ),
408 })),
409 Err(err) => Some(ViewAction::Emit(ViewEvent::OpenTextPager {
410 title: "Copy failed".to_string(),
411 content: format!("{err:#}"),
412 })),
413 }
414 }
415
416 fn select_scope(&self, scope: FleetScope) -> Option<ViewAction> {
417 match set_selected(&self.fleet.name, scope, &self.workspace) {
418 Ok(path) => Some(ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged {
419 message: format!(
420 "Selected Fleet `{}` as {} default — wrote {}",
421 self.fleet.name,
422 scope.label(),
423 path.display()
424 ),
425 })),
426 Err(err) => Some(ViewAction::Emit(ViewEvent::OpenTextPager {
427 title: "Selection failed".to_string(),
428 content: format!("{err:#}"),
429 })),
430 }
431 }
432
433 fn footer_hints(&self) -> Vec<ActionHint> {
434 match self.step {
435 DetailStep::PickRoute => vec![
436 ActionHint::new("↑/↓", "move"),
437 ActionHint::new("Enter", "pick"),
438 ActionHint::new("Esc", "back"),
439 ],
440 DetailStep::Overview => {
441 let mut hints = vec![
442 ActionHint::new("↑/↓", "move"),
443 ActionHint::new("o", "operator route"),
444 ActionHint::new("e", "member route"),
445 ActionHint::new("t", "reasoning"),
446 ActionHint::new("r", "rename"),
447 ActionHint::new("s", "save"),
448 ActionHint::new("c", "copy scope"),
449 ActionHint::new("u/w", "select"),
450 ];
451 if self.selected > 0 {
452 hints.push(ActionHint::new("v", "vision"));
453 hints.push(ActionHint::new("a/d", "add/remove"));
454 }
455 hints.push(ActionHint::new("Esc", "back"));
456 hints
457 }
458 }
459 }
460 }
461
462 impl ModalView for FleetDetailView {
463 fn kind(&self) -> ModalKind {
464 ModalKind::FleetDetail
465 }
466
467 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
468 self
469 }
470
471 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
472 match self.step {
473 DetailStep::PickRoute => match key.code {
474 KeyCode::Esc => {
475 self.step = DetailStep::Overview;
476 ViewAction::None
477 }
478 KeyCode::Up | KeyCode::Char('k') => {
479 if !self.routes.is_empty() {
480 self.pick_row =
481 crate::tui::list_nav::wrap_index(self.pick_row, self.routes.len(), -1);
482 }
483 ViewAction::None
484 }
485 KeyCode::Down | KeyCode::Char('j') => {
486 if !self.routes.is_empty() {
487 self.pick_row =
488 crate::tui::list_nav::wrap_index(self.pick_row, self.routes.len(), 1);
489 }
490 ViewAction::None
491 }
492 KeyCode::Enter => self.apply_route_pick().unwrap_or(ViewAction::None),
493 _ => ViewAction::None,
494 },
495 DetailStep::Overview => {
496 if self.rename_mode {
497 return match key.code {
498 KeyCode::Enter => self.commit_rename().unwrap_or(ViewAction::None),
499 KeyCode::Esc => {
500 self.rename_mode = false;
501 ViewAction::None
502 }
503 KeyCode::Char(c) => {
504 self.rename_input.push(c);
505 ViewAction::None
506 }
507 KeyCode::Backspace => {
508 self.rename_input.pop();
509 ViewAction::None
510 }
511 _ => ViewAction::None,
512 };
513 }
514 if self.pending_remove {
515 return match key.code {
516 KeyCode::Char('y') | KeyCode::Enter => {
517 self.remove_selected_member();
518 ViewAction::None
519 }
520 KeyCode::Char('n') | KeyCode::Esc => {
521 self.pending_remove = false;
522 ViewAction::None
523 }
524 _ => ViewAction::None,
525 };
526 }
527 match key.code {
528 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
529 KeyCode::Up | KeyCode::Char('k') => {
530 self.move_row(-1);
531 ViewAction::None
532 }
533 KeyCode::Down | KeyCode::Char('j') => {
534 self.move_row(1);
535 ViewAction::None
536 }
537 KeyCode::Char('o') => {
538 self.open_route_picker(PickTarget::Operator);
539 ViewAction::None
540 }
541 KeyCode::Char('e') => {
542 if let Some(idx) = self.selected_member_idx() {
543 self.open_route_picker(PickTarget::Member(idx));
544 }
545 ViewAction::None
546 }
547 KeyCode::Char('t') => {
548 self.cycle_reasoning();
549 ViewAction::None
550 }
551 KeyCode::Char('v') => {
552 self.toggle_vision_requirement();
553 ViewAction::None
554 }
555 KeyCode::Char('a') => {
556 self.add_member();
557 ViewAction::None
558 }
559 KeyCode::Char('d') if self.selected > 0 => {
560 self.pending_remove = true;
561 ViewAction::None
562 }
563 KeyCode::Char('r') => {
564 self.start_rename();
565 ViewAction::None
566 }
567 KeyCode::Char('s') => self.save().unwrap_or(ViewAction::None),
568 KeyCode::Char('c') => self.copy_to_other_scope().unwrap_or(ViewAction::None),
569 KeyCode::Char('u') => self
570 .select_scope(FleetScope::Personal)
571 .unwrap_or(ViewAction::None),
572 KeyCode::Char('w') => self
573 .select_scope(FleetScope::Workspace)
574 .unwrap_or(ViewAction::None),
575 _ => ViewAction::None,
576 }
577 }
578 }
579 }
580
581 fn render(&self, area: Rect, buf: &mut Buffer) {
582 Clear.render(area, buf);
583 Block::default()
584 .style(Style::default().bg(palette::WHALE_BG))
585 .render(area, buf);
586
587 let hints = self.footer_hints();
588 let content = render_modal_footer(area, buf, &hints);
589 let chunks = Layout::default()
590 .direction(Direction::Vertical)
591 .constraints([Constraint::Length(4), Constraint::Min(1)])
592 .split(content);
593
594 // Header.
595 let title = if self.rename_mode {
596 format!("Renaming: {}▏", self.rename_input)
597 } else {
598 format!(
599 "Fleet `{}` · {} scope · {}",
600 self.fleet.name,
601 self.scope.label(),
602 self.source.display()
603 )
604 };
605 let mut header = vec![
606 Line::from(vec![
607 Span::styled(
608 "─ Fleet ",
609 Style::default().fg(palette::WHALE_ACTION).bold(),
610 ),
611 Span::styled(title, Style::default().fg(palette::TEXT_SECONDARY)),
612 ]),
613 Line::from(""),
614 ];
615 let operator_line = match &self.fleet.operator {
616 Some(op) => format!(" operator: {}/{}", op.provider, op.model),
617 None => " operator: inherits session route".to_string(),
618 };
619 header.push(Line::from(Span::styled(
620 operator_line,
621 Style::default().fg(palette::TEXT_DIM),
622 )));
623 if let Some(scout) = &self.scout_receipt {
624 header.push(Line::from(Span::styled(
625 format!(" scout → {scout}"),
626 Style::default().fg(palette::TEXT_DIM),
627 )));
628 }
629 Paragraph::new(header)
630 .wrap(Wrap { trim: false })
631 .render(chunks[0], buf);
632
633 match self.step {
634 DetailStep::Overview => self.render_overview(chunks[1], buf),
635 DetailStep::PickRoute => self.render_pick_route(chunks[1], buf),
636 }
637 }
638 }
639
640 impl FleetDetailView {
641 fn render_overview(&self, area: Rect, buf: &mut Buffer) {
642 if area.width == 0 || area.height == 0 {
643 return;
644 }
645 let rows_visible = usize::from(area.height).max(1);
646 let scroll = self.selected.saturating_sub(rows_visible.saturating_sub(1));
647 let mut lines: Vec<Line<'static>> = Vec::new();
648
649 // Operator row.
650 if self.selected == 0 {
651 let selected = self.selected == 0;
652 let base = if selected {
653 Style::default().fg(palette::WHALE_ACTION).bold()
654 } else {
655 Style::default().fg(palette::TEXT_SECONDARY)
656 };
657 let operator_text = match &self.fleet.operator {
658 Some(op) => format!("{}/{}", op.provider, op.model),
659 None => "inherits session route".to_string(),
660 };
661 let reasoning = self
662 .fleet
663 .operator
664 .as_ref()
665 .and_then(|op| op.reasoning.as_deref())
666 .unwrap_or("inherit");
667 lines.push(Line::from(vec![
668 Span::styled(if selected { "» " } else { " " }, base),
669 Span::styled("operator", base),
670 Span::styled(" ", Style::default()),
671 Span::styled(operator_text, Style::default().fg(palette::TEXT_MUTED)),
672 Span::styled(
673 format!(" · reasoning: {reasoning}"),
674 Style::default().fg(palette::TEXT_DIM),
675 ),
676 ]));
677 }
678
679 for (idx, member) in self.fleet.members.iter().enumerate() {
680 let row = 1 + idx;
681 if row < scroll || row >= scroll + rows_visible {
682 continue;
683 }
684 let selected = row == self.selected;
685 let base = if selected {
686 Style::default().fg(palette::WHALE_ACTION).bold()
687 } else {
688 Style::default().fg(palette::TEXT_SECONDARY)
689 };
690 let route = match (&member.provider, &member.model) {
691 (Some(p), Some(m)) => format!("{p}/{m} (pinned)"),
692 _ => "inherits session route".to_string(),
693 };
694 let reasoning = member.reasoning.as_deref().unwrap_or("inherit");
695 let vision = if member.requires.iter().any(|r| r == "vision") {
696 " · vision"
697 } else {
698 ""
699 };
700 if self.pending_remove && selected {
701 lines.push(Line::from(vec![Span::styled(
702 format!(" Remove member `{}`? y/n", member.id),
703 Style::default().fg(palette::WHALE_ERROR),
704 )]));
705 } else {
706 lines.push(Line::from(vec![
707 Span::styled(if selected { "» " } else { " " }, base),
708 Span::styled(member.id.clone(), base),
709 Span::styled(" ", Style::default()),
710 Span::styled(route, Style::default().fg(palette::TEXT_MUTED)),
711 Span::styled(
712 format!(" · reasoning: {reasoning}{vision}"),
713 Style::default().fg(palette::TEXT_DIM),
714 ),
715 ]));
716 }
717 }
718 Paragraph::new(ratatui::text::Text::from(lines)).render(area, buf);
719 }
720
721 fn render_pick_route(&self, area: Rect, buf: &mut Buffer) {
722 if area.width == 0 || area.height == 0 {
723 return;
724 }
725 let rows_visible = usize::from(area.height).max(1);
726 let pick_scroll = self.pick_row.saturating_sub(rows_visible.saturating_sub(1));
727 let target_label = match self.pick_target {
728 PickTarget::Operator => "operator",
729 PickTarget::Member(idx) => self
730 .fleet
731 .members
732 .get(idx)
733 .map(|m| m.id.as_str())
734 .unwrap_or("member"),
735 };
736 let mut lines: Vec<Line<'static>> = Vec::new();
737 lines.push(Line::from(Span::styled(
738 format!(" Route for {target_label} — Enter picks, Esc back."),
739 Style::default().fg(palette::TEXT_MUTED),
740 )));
741 lines.push(Line::from(""));
742 for (idx, route) in self.routes.iter().enumerate() {
743 if idx < pick_scroll || idx >= pick_scroll + rows_visible {
744 continue;
745 }
746 let selected = idx == self.pick_row;
747 let base = if selected {
748 Style::default().fg(palette::WHALE_ACTION).bold()
749 } else {
750 Style::default().fg(palette::TEXT_SECONDARY)
751 };
752 lines.push(Line::from(vec![
753 Span::styled(if selected { "» " } else { " " }, base),
754 Span::styled(route.label.clone(), base),
755 Span::styled(" ", Style::default()),
756 Span::styled(
757 route.summary.clone(),
758 Style::default().fg(palette::TEXT_DIM),
759 ),
760 ]));
761 }
762 Paragraph::new(ratatui::text::Text::from(lines)).render(area, buf);
763 }
764 }
765
766 /// Build the route-picker rows: "inherit session route" first, then every
767 /// concrete model across configured providers, with its readiness label —
768 /// the same route list the fleet setup wizard's Model step shows.
769 fn build_route_rows(config: &Config) -> Vec<RouteRow> {
770 let mut rows = vec![RouteRow {
771 label: "inherit session route".to_string(),
772 summary: String::new(),
773 provider: None,
774 model: None,
775 }];
776 let health = crate::provider_readiness::ProviderReadinessSnapshot::default();
777 let active = config
778 .provider
779 .as_deref()
780 .and_then(ApiProvider::parse)
781 .unwrap_or(ApiProvider::Deepseek);
782 let routes = super::fleet_setup::cross_provider_model_routes(config, active, &health);
783 for (provider, model, readiness) in routes {
784 let provider_label = crate::tui::views::fleet_setup::provider_display_label(&provider);
785 let readiness_label = readiness
786 .blocked_reason()
787 .map(|r| r.into_owned())
788 .unwrap_or_else(|| readiness.label().into_owned());
789 rows.push(RouteRow {
790 label: format!("{provider_label}/{model}"),
791 summary: readiness_label,
792 provider: Some(provider),
793 model: Some(model),
794 });
795 }
796 rows
797 }
798
799 /// Reasoning tiers a route may genuinely express, keyed by provider class.
800 /// A tier a provider cannot wire is never offered (no `max` where a route
801 /// has none). `inherit` is always first.
802 fn reasoning_tiers_for_provider(provider: &str) -> &'static [&'static str] {
803 // Tiers are keyed by the provider's exact id from the catalog, never
804 // guessed from a display name. A tier a route cannot genuinely express
805 // is not offered.
806 match provider
807 .to_ascii_lowercase()
808 .replace(['_', '-'], "")
809 .as_str()
810 {
811 "deepseek" | "deepseekcn" | "deepseekanthropic" => {
812 &["inherit", "off", "low", "high", "max"]
813 }
814 "moonshot" | "kimi" | "kimicode" => &["inherit", "off", "low", "medium", "high"],
815 "openaicodex" => &["inherit", "off", "minimal", "high"],
816 _ => &["inherit", "off", "low", "medium", "high"],
817 }
818 }
819
820 #[cfg(test)]
821 mod tests {
822 use super::*;
823 use crate::config::Config;
824 use crate::fleet::store::FleetFile;
825 use crate::tui::app::{App, TuiOptions};
826
827 fn app_in(workspace: PathBuf) -> App {
828 let options = TuiOptions {
829 ..crate::test_support::test_tui_options(workspace.clone())
830 };
831 let mut app = App::new(options, &Config::default());
832 app.workspace = workspace;
833 app
834 }
835
836 fn sample_fleet(name: &str) -> FleetFile {
837 let mut fleet = FleetFile::new(name.to_string(), None).unwrap();
838 fleet.operator = Some(FleetOperator {
839 provider: "deepseek".to_string(),
840 model: "deepseek-v4-flash".to_string(),
841 reasoning: None,
842 });
843 fleet.members.push(FleetMember {
844 id: "scout".to_string(),
845 role: "scout".to_string(),
846 provider: None,
847 model: None,
848 reasoning: None,
849 instructions: None,
850 requires: Vec::new(),
851 });
852 fleet
853 }
854
855 fn key(code: KeyCode) -> KeyEvent {
856 KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
857 }
858
859 #[test]
860 fn open_loads_the_fleet_by_name_and_scope() {
861 let ws = tempfile::TempDir::new().unwrap();
862 let fleet = sample_fleet("DeepSeek Flash");
863 let path = save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
864
865 let mut app = app_in(ws.path().to_path_buf());
866 let view = FleetDetailView::open(
867 &app,
868 &Config::default(),
869 "DeepSeek Flash",
870 FleetScope::Workspace,
871 )
872 .expect("open");
873 assert_eq!(view.fleet.name, "DeepSeek Flash");
874 assert_eq!(view.scope, FleetScope::Workspace);
875 assert_eq!(view.source, path);
876 assert_eq!(view.row_count(), 2); // operator + scout
877
878 // A missing fleet fails to open (the host shows the error receipt).
879 app.workspace = ws.path().to_path_buf();
880 assert!(
881 FleetDetailView::open(&app, &Config::default(), "Nope", FleetScope::Workspace)
882 .is_none()
883 );
884 }
885
886 #[test]
887 fn rename_commits_and_names_the_receipt() {
888 let ws = tempfile::TempDir::new().unwrap();
889 let fleet = sample_fleet("Old Name");
890 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
891
892 let mut view = FleetDetailView::open(
893 &app_in(ws.path().to_path_buf()),
894 &Config::default(),
895 "Old Name",
896 FleetScope::Workspace,
897 )
898 .expect("open");
899
900 view.handle_key(key(KeyCode::Char('r')));
901 assert!(view.rename_mode);
902 // The input starts filled with the current name; clear it, then type.
903 for _ in "Old Name".chars() {
904 view.handle_key(key(KeyCode::Backspace));
905 }
906 for ch in "New Name".chars() {
907 view.handle_key(key(KeyCode::Char(ch)));
908 }
909 let action = view.handle_key(key(KeyCode::Enter));
910 let ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { message }) = action else {
911 panic!("expected FleetStoreChanged, got {action:?}");
912 };
913 assert!(
914 message.contains("Renamed Fleet `Old Name` → `New Name`"),
915 "{message}"
916 );
917 // The on-disk file now carries the new name.
918 let (loaded, _) =
919 crate::fleet::store::load_fleet_in_scope("New Name", FleetScope::Workspace, ws.path())
920 .expect("reload");
921 assert_eq!(loaded.name, "New Name");
922 }
923
924 #[test]
925 fn operator_route_pick_pins_and_inherit_clears() {
926 let ws = tempfile::TempDir::new().unwrap();
927 let fleet = sample_fleet("Fleet A");
928 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
929
930 let mut view = FleetDetailView::open(
931 &app_in(ws.path().to_path_buf()),
932 &Config::default(),
933 "Fleet A",
934 FleetScope::Workspace,
935 )
936 .expect("open");
937 assert!(view.fleet.operator.is_some());
938
939 // Enter the operator picker, choose the inherit row.
940 view.handle_key(key(KeyCode::Char('o')));
941 assert_eq!(view.step, DetailStep::PickRoute);
942 view.pick_row = 0;
943 view.handle_key(key(KeyCode::Enter));
944 assert_eq!(view.step, DetailStep::Overview);
945 assert!(view.fleet.operator.is_none(), "inherit row clears the pin");
946
947 // Re-enter and pick the first concrete route row.
948 view.handle_key(key(KeyCode::Char('o')));
949 view.pick_row = 1;
950 view.handle_key(key(KeyCode::Enter));
951 let op = view.fleet.operator.as_ref().expect("pinned operator");
952 assert!(!op.provider.is_empty() && !op.model.is_empty());
953 }
954
955 #[test]
956 fn member_edit_pins_route_and_toggles_vision() {
957 let ws = tempfile::TempDir::new().unwrap();
958 let fleet = sample_fleet("Fleet B");
959 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
960
961 let mut view = FleetDetailView::open(
962 &app_in(ws.path().to_path_buf()),
963 &Config::default(),
964 "Fleet B",
965 FleetScope::Workspace,
966 )
967 .expect("open");
968
969 // Select the scout member (row 1) and pin the first concrete route.
970 view.selected = 1;
971 view.handle_key(key(KeyCode::Char('e')));
972 assert_eq!(view.step, DetailStep::PickRoute);
973 view.pick_row = 1;
974 view.handle_key(key(KeyCode::Enter));
975 let member = view.fleet.member("scout").expect("scout");
976 assert!(
977 member.provider.is_some() && member.model.is_some(),
978 "scout must be pinned: {member:?}"
979 );
980
981 // Vision requirement toggles on and off.
982 view.handle_key(key(KeyCode::Char('v')));
983 assert!(
984 view.fleet
985 .member("scout")
986 .unwrap()
987 .requires
988 .contains(&"vision".to_string())
989 );
990 view.handle_key(key(KeyCode::Char('v')));
991 assert!(view.fleet.member("scout").unwrap().requires.is_empty());
992 }
993
994 #[test]
995 fn save_writes_the_file_and_receipt_names_the_path() {
996 let ws = tempfile::TempDir::new().unwrap();
997 let fleet = sample_fleet("Fleet C");
998 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
999
1000 let mut view = FleetDetailView::open(
1001 &app_in(ws.path().to_path_buf()),
1002 &Config::default(),
1003 "Fleet C",
1004 FleetScope::Workspace,
1005 )
1006 .expect("open");
1007
1008 // Change the operator model, then save.
1009 view.fleet.operator = Some(FleetOperator {
1010 provider: "deepseek".to_string(),
1011 model: "deepseek-v4-pro".to_string(),
1012 reasoning: Some("high".to_string()),
1013 });
1014 let action = view.handle_key(key(KeyCode::Char('s')));
1015 let ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { message }) = action else {
1016 panic!("expected FleetStoreChanged, got {action:?}");
1017 };
1018 assert!(message.contains("Saved Fleet `Fleet C`"), "{message}");
1019 // The receipt names the path as this platform writes it, so build the
1020 // expected tail the same way instead of hard-coding `/` — on Windows
1021 // `Path::display` renders the separators as `\`.
1022 let expected_tail = std::path::Path::new(".codewhale")
1023 .join("fleets")
1024 .join("fleet-c.toml")
1025 .display()
1026 .to_string();
1027 assert!(message.contains(&expected_tail), "{message}");
1028
1029 let (loaded, _) =
1030 crate::fleet::store::load_fleet_in_scope("Fleet C", FleetScope::Workspace, ws.path())
1031 .expect("reload");
1032 let op = loaded.operator.expect("operator");
1033 assert_eq!(op.model, "deepseek-v4-pro");
1034 assert_eq!(op.reasoning.as_deref(), Some("high"));
1035 }
1036
1037 #[test]
1038 fn add_member_uses_an_unused_known_role() {
1039 let ws = tempfile::TempDir::new().unwrap();
1040 let fleet = sample_fleet("Fleet D");
1041 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1042
1043 let mut view = FleetDetailView::open(
1044 &app_in(ws.path().to_path_buf()),
1045 &Config::default(),
1046 "Fleet D",
1047 FleetScope::Workspace,
1048 )
1049 .expect("open");
1050
1051 view.handle_key(key(KeyCode::Char('a')));
1052 let ids: Vec<&str> = view.fleet.members.iter().map(|m| m.id.as_str()).collect();
1053 assert_eq!(ids, vec!["scout", "builder"]);
1054
1055 // Remove the new member with the confirmed delete flow.
1056 view.selected = 2;
1057 view.handle_key(key(KeyCode::Char('d')));
1058 view.handle_key(key(KeyCode::Char('y')));
1059 assert_eq!(view.fleet.members.len(), 1);
1060 }
1061 }
1062
1062 lines RUST