返回 CodeWhale
fleet_list.rs
根目录 / crates / tui / src / tui / views / fleet_list.rs
1 //! `/fleet fleets` — named saved-Fleet picker (secondary surface; `/fleet fleets`
2 //! remains a compatibility alias).
3 //!
4 //! Bare `/fleet` opens the roster/setup face for the selected Fleet. This view
5 //! is only for switching between named configurations. One row per saved Fleet
6 //! across both scopes: user-global (`$CODEWHALE_HOME/fleets/`) and folder
7 //! (`.codewhale/fleets/`). Rows show name, scope badge, and operator summary —
8 //! not filesystem paths (paths belong in receipts). Same-name Fleets in both
9 //! scopes are two rows, never a silent shadow. Legacy per-role profiles get
10 //! one migration banner, not a pile of shadow badges.
11 //!
12 //! The view reads and writes the Fleet store directly (local, atomic file
13 //! operations); it never touches the live session route.
14
15 use std::cell::{Cell, RefCell};
16 use std::path::PathBuf;
17
18 use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
19 use ratatui::{
20 buffer::Buffer,
21 layout::{Constraint, Direction, Layout, Rect},
22 style::Style,
23 text::{Line, Span},
24 widgets::{Block, Clear, Paragraph, Widget, Wrap},
25 };
26
27 use crate::config::Config;
28 use crate::fleet::store::{
29 FleetEntry, FleetScope, SelectedFleet, delete_fleet, list_fleets, migrate_legacy_roster,
30 selected_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 /// Rows one PageUp/PageDown travels. Pages clamp at the ends per the shared
41 /// vocabulary instead of wrapping (#6290).
42 const FLEET_LIST_PAGE: usize = 10;
43
44 /// What the host should do after this view acted on the store.
45 #[derive(Debug, Clone, PartialEq, Eq)]
46 #[allow(dead_code)] // OpenDetail/None are reserved for the qualified-name flow
47 pub enum FleetListOutcome {
48 /// A store mutation happened; show `message`, refresh, and pop the stack.
49 Done { message: String },
50 /// Open the detail view for the given Fleet.
51 OpenDetail { name: String, scope: FleetScope },
52 /// Nothing to do.
53 None,
54 }
55
56 pub struct FleetListView {
57 entries: Vec<FleetEntry>,
58 /// The active selection snapshot (workspace then personal).
59 selected: Option<SelectedFleet>,
60 /// Legacy profile count (how many per-role files exist), for the banner.
61 legacy_profile_count: usize,
62 /// Whether a v2 Fleet named "Default" already exists (so migration is
63 /// offered only when it would not clobber).
64 default_fleet_exists: bool,
65 row: usize,
66 /// Saved scope of the row being acted on (delete/select flow through
67 /// confirmation state).
68 pending_delete: Option<usize>,
69 /// Exact painted cells of each entry row. Clicks and hover hit-test
70 /// these painted rects — never a hardcoded row offset — so pointer
71 /// targets stay truthful when the list scrolls or the footer wraps.
72 row_hitboxes: RefCell<Vec<(Rect, usize)>>,
73 /// Entry under the pointer, tinted with the shared hover style. Hover
74 /// never moves the keyboard row.
75 hovered_row: Cell<Option<usize>>,
76 fleet_config: codewhale_config::FleetConfigToml,
77 workspace: PathBuf,
78 /// UI locale, for the one hint whose wording changes with the entry path.
79 locale: Locale,
80 /// True when the Fleet roster is parked directly underneath on the view
81 /// stack (#5954), i.e. this list was opened with `f` from the roster.
82 /// `Esc` pops one view either way — the flag only decides whether the
83 /// footer promises `back` or `close`. Direct entry (`/fleet list`)
84 /// leaves it false, so `Esc` closes as before.
85 back_to_fleet_roster: bool,
86 }
87
88 impl FleetListView {
89 #[must_use]
90 pub fn new(app: &App, config: &Config) -> Self {
91 let workspace = app.workspace.clone();
92 let entries = list_fleets(&workspace);
93 let selected = selected_fleet(&workspace);
94 let legacy_profile_count = legacy_profile_file_count(&workspace);
95 let default_fleet_exists = entries
96 .iter()
97 .any(|e| e.name.eq_ignore_ascii_case("default") && !e.legacy);
98 Self {
99 entries,
100 selected,
101 legacy_profile_count,
102 default_fleet_exists,
103 row: 0,
104 pending_delete: None,
105 row_hitboxes: RefCell::new(Vec::new()),
106 hovered_row: Cell::new(None),
107 fleet_config: config.fleet_config(),
108 workspace,
109 locale: app.ui_locale,
110 back_to_fleet_roster: false,
111 }
112 }
113
114 /// Mark this list as pushed on top of the Fleet roster (#5954), so the
115 /// footer says `back` rather than `close`.
116 #[must_use]
117 pub fn over_fleet_roster(mut self) -> Self {
118 self.back_to_fleet_roster = true;
119 self
120 }
121
122 fn selected_entry(&self) -> Option<&FleetEntry> {
123 self.entries.get(self.row)
124 }
125
126 fn banner_visible(&self) -> bool {
127 self.legacy_profile_count > 0 && !self.default_fleet_exists
128 }
129
130 fn move_row(&mut self, delta: isize) {
131 let rows = self.entries.len();
132 if rows == 0 {
133 return;
134 }
135 self.row = crate::tui::list_nav::wrap_index(self.row, rows, delta);
136 self.hovered_row.set(None);
137 }
138
139 /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning
140 /// whether it was consumed. Steps wrap; pages travel [`FLEET_LIST_PAGE`]
141 /// rows and clamp. The list is single-column, so the region axis is a
142 /// no-op.
143 fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool {
144 let len = self.entries.len();
145 if len == 0 {
146 return false;
147 }
148 let Some(next) = crate::tui::list_nav::apply(self.row, len, FLEET_LIST_PAGE, motion) else {
149 return false;
150 };
151 self.row = next;
152 self.hovered_row.set(None);
153 true
154 }
155
156 fn hit_row(&self, mouse: MouseEvent) -> Option<usize> {
157 let position = ratatui::layout::Position::new(mouse.column, mouse.row);
158 self.row_hitboxes
159 .borrow()
160 .iter()
161 .find_map(|(rect, idx)| rect.contains(position).then_some(*idx))
162 }
163
164 fn footer_hints(&self) -> Vec<ActionHint> {
165 let mut hints = vec![
166 ActionHint::new("↑/↓", "move"),
167 ActionHint::new("Enter", "select"),
168 ];
169 if !self.entries.is_empty() {
170 // Explicit scope overrides; Enter selects in the row's own scope.
171 hints.push(ActionHint::new("u", "as user default"));
172 hints.push(ActionHint::new("w", "for this folder"));
173 hints.push(ActionHint::new("d", "delete"));
174 }
175 if self.banner_visible() {
176 hints.push(ActionHint::new("m", "migrate"));
177 }
178 // The hint has to name what `Esc` actually does: pop back to the
179 // parked roster, or close the window at the root (#5954).
180 hints.push(if self.back_to_fleet_roster {
181 ActionHint::new("Esc", tr(self.locale, MessageId::SetupActionBack))
182 } else {
183 ActionHint::new("Esc", "close")
184 });
185 hints
186 }
187
188 /// Select the highlighted Fleet in `scope` and close with a receipt that
189 /// names the exact file written. Editing stays on `/fleet setup` / roster —
190 /// this surface is a switcher, not a file manager.
191 fn select_highlighted(&self, scope: FleetScope) -> Option<FleetListOutcome> {
192 let entry = self.selected_entry()?;
193 if entry.legacy {
194 return None;
195 }
196 match set_selected(&entry.name, scope, &self.workspace) {
197 Ok(path) => Some(FleetListOutcome::Done {
198 message: format!(
199 "Selected Team `{}` ({}) — wrote {}",
200 entry.name,
201 scope.long_label(),
202 path.display()
203 ),
204 }),
205 Err(err) => Some(FleetListOutcome::Done {
206 message: format!("Selection failed: {err}"),
207 }),
208 }
209 }
210
211 fn confirm_delete(&mut self) -> Option<FleetListOutcome> {
212 let idx = self.pending_delete?;
213 let entry = self.entries.get(idx)?;
214 let name = entry.name.clone();
215 let scope = entry.scope;
216 match delete_fleet(&name, scope, &self.workspace) {
217 Ok(path) => Some(FleetListOutcome::Done {
218 message: format!(
219 "Deleted Team `{name}` ({}) — removed {}",
220 scope.label(),
221 path.display()
222 ),
223 }),
224 Err(err) => Some(FleetListOutcome::Done {
225 message: format!("Delete failed: {err}"),
226 }),
227 }
228 }
229 }
230
231 impl ModalView for FleetListView {
232 fn kind(&self) -> ModalKind {
233 ModalKind::FleetList
234 }
235
236 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
237 self
238 }
239
240 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
241 if self.pending_delete.is_some() {
242 match key.code {
243 KeyCode::Char('y') | KeyCode::Enter => {
244 let outcome = self.confirm_delete();
245 self.pending_delete = None;
246 return outcome_to_action(outcome);
247 }
248 KeyCode::Char('n') | KeyCode::Esc => {
249 self.pending_delete = None;
250 return ViewAction::None;
251 }
252 _ => return ViewAction::None,
253 }
254 }
255 match key.code {
256 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
257 // Movement keys come from the shared vocabulary (#6290), j/k
258 // aliases included — this surface captures no text. Pages are new
259 // here; they used to do nothing.
260 _ if crate::tui::list_nav::motion(&key).is_some_and(|m| self.apply_motion(m)) => {
261 ViewAction::None
262 }
263 KeyCode::Enter => {
264 let Some(entry) = self.selected_entry() else {
265 return ViewAction::None;
266 };
267 if entry.legacy {
268 return ViewAction::Emit(ViewEvent::OpenTextPager {
269 title: format!("Team `{}` — legacy format", entry.name),
270 content: format!(
271 "This team file predates the named-team format ({}).\n\n\
272 It is listed so nothing you saved disappears, but it is \
273 read-only here. To edit it, create a new team and copy \
274 the settings you want; legacy files are never migrated \
275 silently.\n\nParse error: {}",
276 entry.path.display(),
277 entry.parse_error.as_deref().unwrap_or("unknown")
278 ),
279 });
280 }
281 // Enter selects in the row's own scope and returns to the
282 // roster/setup face. The list/detail editor is not the product.
283 outcome_to_action(self.select_highlighted(entry.scope))
284 }
285 KeyCode::Char('u') => outcome_to_action(self.select_highlighted(FleetScope::Personal)),
286 KeyCode::Char('w') => outcome_to_action(self.select_highlighted(FleetScope::Workspace)),
287 KeyCode::Char('d') => {
288 let Some(entry) = self.selected_entry() else {
289 return ViewAction::None;
290 };
291 if entry.legacy {
292 return ViewAction::None;
293 }
294 self.pending_delete = Some(self.row);
295 ViewAction::None
296 }
297 KeyCode::Char('m') if self.banner_visible() => {
298 match migrate_legacy_roster(
299 &self.fleet_config,
300 &self.workspace,
301 true,
302 FleetScope::Personal,
303 ) {
304 Ok(receipt) => {
305 let mut content = format!(
306 "Migrated {} legacy role profiles into Team `Default` \
307 (user-global) — wrote {}\n\n",
308 receipt.rows.len(),
309 receipt.saved_to.display()
310 );
311 for row in &receipt.rows {
312 let pin = match &row.pin {
313 Some((model, provider)) => {
314 format!("{provider}/{model}")
315 }
316 None => "same model as this session".to_string(),
317 };
318 content.push_str(&format!("- {} → {pin}", row.id));
319 if let Some(shadow) = &row.conflicting_shadow {
320 content.push_str(&format!(" [conflict: {shadow}]"));
321 }
322 content.push('\n');
323 }
324 content.push_str(
325 "\nLegacy profile files were left untouched — they are no \
326 longer live configuration once a team is selected.",
327 );
328 if let Ok(path) =
329 set_selected("Default", FleetScope::Personal, &self.workspace)
330 {
331 content.push_str(&format!(
332 "\n\nTeam `Default` is now your user-global default — wrote {}.",
333 path.display()
334 ));
335 }
336 ViewAction::Emit(ViewEvent::OpenTextPager {
337 title: "Legacy migration receipt".to_string(),
338 content,
339 })
340 }
341 Err(err) => ViewAction::Emit(ViewEvent::OpenTextPager {
342 title: "Legacy migration failed".to_string(),
343 content: format!("{err:#}"),
344 }),
345 }
346 }
347 _ => ViewAction::None,
348 }
349 }
350
351 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
352 match mouse.kind {
353 MouseEventKind::Moved => {
354 self.hovered_row.set(self.hit_row(mouse));
355 ViewAction::None
356 }
357 // The wheel moves this list, not the transcript behind it.
358 MouseEventKind::ScrollUp => {
359 self.move_row(-1);
360 ViewAction::None
361 }
362 MouseEventKind::ScrollDown => {
363 self.move_row(1);
364 ViewAction::None
365 }
366 MouseEventKind::Down(MouseButton::Left) => {
367 if self.pending_delete.is_some() {
368 self.pending_delete = None;
369 return ViewAction::None;
370 }
371 if let Some(idx) = self.hit_row(mouse) {
372 self.row = idx;
373 }
374 ViewAction::None
375 }
376 _ => ViewAction::None,
377 }
378 }
379
380 fn render(&self, area: Rect, buf: &mut Buffer) {
381 Clear.render(area, buf);
382 Block::default()
383 .style(Style::default().bg(palette::WHALE_BG))
384 .render(area, buf);
385
386 let hints = self.footer_hints();
387 let content = render_modal_footer(area, buf, &hints);
388
389 let chunks = Layout::default()
390 .direction(Direction::Vertical)
391 .constraints([Constraint::Length(4), Constraint::Min(1)])
392 .split(content);
393
394 // Header: name + selected summary.
395 let selected_line = match &self.selected {
396 Some(sel) => format!("Selected: `{}` ({})", sel.name, sel.scope.label()),
397 None => "No team selected — built-in team".to_string(),
398 };
399 let mut header = vec![
400 Line::from(vec![
401 Span::styled(
402 "─ Saved Teams ",
403 Style::default().fg(palette::WHALE_ACTION).bold(),
404 ),
405 Span::styled(
406 "· pick which named team the session uses",
407 Style::default().fg(palette::TEXT_MUTED),
408 ),
409 ]),
410 Line::from(""),
411 Line::from(Span::styled(
412 format!(" {selected_line}"),
413 Style::default().fg(palette::TEXT_SECONDARY),
414 )),
415 ];
416 if self.banner_visible() {
417 header.push(Line::from(vec![Span::styled(
418 format!(
419 " ⚠ {} legacy role profile(s) found — press m to migrate them into a \
420 Fleet (nothing is changed until you do)",
421 self.legacy_profile_count
422 ),
423 Style::default().fg(palette::WHALE_HUMAN),
424 )]));
425 }
426 Paragraph::new(header)
427 .wrap(Wrap { trim: false })
428 .render(chunks[0], buf);
429
430 self.render_rows(chunks[1], buf);
431 }
432 }
433
434 impl FleetListView {
435 fn render_rows(&self, area: Rect, buf: &mut Buffer) {
436 self.row_hitboxes.borrow_mut().clear();
437 if area.width == 0 || area.height == 0 {
438 return;
439 }
440 if self.entries.is_empty() {
441 Paragraph::new(Line::from(vec![
442 Span::styled(
443 " No saved teams yet.",
444 Style::default().fg(palette::TEXT_MUTED),
445 ),
446 Span::styled(
447 " Select a model with /model and /provider, then /fleet save or \
448 /fleet save-as. Editing stays on /fleet setup.",
449 Style::default().fg(palette::TEXT_DIM),
450 ),
451 ]))
452 .render(area, buf);
453 return;
454 }
455
456 // Entries paint two rows (name + summary), one while a delete is
457 // armed. Scroll and fit by painted rows, not entry count, so the
458 // selected entry is always fully on screen.
459 let height = usize::from(area.height).max(1);
460 let entry_height = |idx: usize| {
461 if self.pending_delete == Some(idx) {
462 1
463 } else {
464 2
465 }
466 };
467 let row = self.row.min(self.entries.len() - 1);
468 let mut scroll = row;
469 let mut used = entry_height(row);
470 while scroll > 0 && used + entry_height(scroll - 1) <= height {
471 scroll -= 1;
472 used += entry_height(scroll);
473 }
474
475 let mut lines = Vec::new();
476 let mut hitboxes = Vec::new();
477 for (idx, entry) in self.entries.iter().enumerate().skip(scroll) {
478 if !lines.is_empty() && lines.len() + entry_height(idx) > height {
479 break;
480 }
481 let selected = idx == self.row;
482 let is_selected_fleet = self
483 .selected
484 .as_ref()
485 .is_some_and(|sel| sel.name == entry.name && sel.scope == entry.scope);
486
487 let mut spans = Vec::new();
488 let marker = if is_selected_fleet { "▸ " } else { " " };
489 let base = if selected {
490 Style::default().fg(palette::WHALE_ACTION).bold()
491 } else {
492 Style::default().fg(palette::TEXT_SECONDARY)
493 };
494 spans.push(Span::styled(marker, base));
495 let name = if selected {
496 "» ".to_string()
497 } else {
498 " ".to_string()
499 };
500 spans.push(Span::styled(format!("{name}{}", entry.name), base));
501
502 let scope_badge = match entry.scope {
503 FleetScope::Personal => " [user]",
504 FleetScope::Workspace => " [folder]",
505 };
506 spans.push(Span::styled(
507 scope_badge,
508 Style::default().fg(palette::TEXT_MUTED),
509 ));
510
511 if entry.legacy {
512 spans.push(Span::styled(
513 " legacy format — read-only",
514 Style::default().fg(palette::WHALE_HUMAN),
515 ));
516 } else if let Some(err) = &entry.parse_error {
517 spans.push(Span::styled(
518 format!(" unreadable: {err}"),
519 Style::default().fg(palette::WHALE_HUMAN),
520 ));
521 }
522
523 if is_selected_fleet && !selected {
524 spans.push(Span::styled(
525 " ★",
526 Style::default().fg(palette::WHALE_ACTION),
527 ));
528 }
529
530 let start = lines.len();
531 if self.pending_delete == Some(idx) {
532 lines.push(Line::from(vec![Span::styled(
533 format!(" Delete `{}` ({})? y/n", entry.name, entry.scope.label()),
534 Style::default().fg(palette::WHALE_HUMAN),
535 )]));
536 } else {
537 lines.push(Line::from(spans));
538 // Second line: source + operator summary (compact).
539 let summary = self.entry_summary(entry);
540 lines.push(Line::from(Span::styled(
541 format!(" {summary}"),
542 Style::default().fg(palette::TEXT_DIM),
543 )));
544 }
545 // Hover tints the painted entry but never steals the keyboard
546 // row; the spans keep their ink, so the band cannot recolor
547 // scope badges or warning text.
548 if idx != self.row && self.hovered_row.get() == Some(idx) {
549 for line in &mut lines[start..] {
550 line.style = line.style.patch(menu_style::hovered_row_style());
551 }
552 }
553 hitboxes.push((
554 Rect {
555 x: area.x,
556 y: area.y.saturating_add(start as u16),
557 width: area.width,
558 height: (lines.len().saturating_sub(start) as u16).max(1),
559 },
560 idx,
561 ));
562 }
563 *self.row_hitboxes.borrow_mut() = hitboxes;
564
565 let text = ratatui::text::Text::from(lines);
566 Paragraph::new(text).render(area, buf);
567 }
568
569 fn entry_summary(&self, entry: &FleetEntry) -> String {
570 if entry.legacy {
571 return "legacy format — open for details".to_string();
572 }
573 // Read the file to summarize operator + members. Parsing errors are
574 // already shown on the row. Paths stay out of the primary row — they
575 // belong in receipts after an explicit write/select/delete.
576 let Ok(text) = std::fs::read_to_string(&entry.path) else {
577 return "unreadable".to_string();
578 };
579 match crate::fleet::store::FleetFile::parse(&text) {
580 Ok(fleet) => {
581 let coordinator = match &fleet.operator {
582 Some(op) => format!("{}/{}", op.provider, op.model),
583 None => "uses the session's model".to_string(),
584 };
585 format!(
586 "Coordinator: {coordinator} · members: {}",
587 fleet
588 .members
589 .iter()
590 .filter(|member| !member.shortlist)
591 .count()
592 )
593 }
594 Err(err) => format!("unreadable: {err}"),
595 }
596 }
597 }
598
599 fn outcome_to_action(outcome: Option<FleetListOutcome>) -> ViewAction {
600 match outcome {
601 Some(FleetListOutcome::Done { message }) => {
602 ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { message })
603 }
604 Some(FleetListOutcome::OpenDetail { name, scope }) => {
605 ViewAction::Emit(ViewEvent::FleetListOpenDetailRequested { name, scope })
606 }
607 Some(FleetListOutcome::None) | None => ViewAction::None,
608 }
609 }
610
611 /// How many legacy per-role profile files exist across both scopes.
612 fn legacy_profile_file_count(workspace: &std::path::Path) -> usize {
613 let mut count = 0;
614 if let Ok(dir) = crate::fleet::profile::personal_agent_profile_dir()
615 && let Ok(read) = std::fs::read_dir(dir)
616 {
617 count += read
618 .filter_map(|e| e.ok())
619 .filter(|e| e.path().extension().is_some_and(|x| x == "toml"))
620 .count();
621 }
622 let ws_dir = workspace.join(".codewhale").join("agents");
623 if let Ok(read) = std::fs::read_dir(ws_dir) {
624 count += read
625 .filter_map(|e| e.ok())
626 .filter(|e| e.path().extension().is_some_and(|x| x == "toml"))
627 .count();
628 }
629 count
630 }
631
632 #[cfg(test)]
633 mod tests {
634 use super::*;
635 use crate::config::Config;
636 use crate::fleet::store::{FleetFile, save_fleet};
637 use crate::tui::app::{App, TuiOptions};
638 use std::sync::OnceLock;
639
640 fn sealed_home() -> &'static std::path::Path {
641 static HOME: OnceLock<PathBuf> = OnceLock::new();
642 HOME.get_or_init(|| {
643 let dir = tempfile::TempDir::new().expect("temp dir").keep();
644 std::fs::create_dir_all(dir.join("fleets")).expect("fleets dir");
645 dir
646 })
647 }
648
649 fn app_in(workspace: PathBuf) -> App {
650 let options = TuiOptions {
651 ..crate::test_support::test_tui_options(workspace.clone())
652 };
653 let mut app = App::new(options, &Config::default());
654 app.workspace = workspace;
655 app
656 }
657
658 fn sample_fleet(name: &str) -> FleetFile {
659 let mut fleet = FleetFile::new(name.to_string(), None).unwrap();
660 fleet.operator = Some(crate::fleet::store::FleetOperator {
661 provider: "deepseek".to_string(),
662 model: "deepseek-v4-flash".to_string(),
663 reasoning: None,
664 });
665 fleet
666 }
667
668 fn save_in(workspace: &std::path::Path, scope: FleetScope, name: &str) {
669 let fleet = sample_fleet(name);
670 let _ = save_fleet(&fleet, scope, workspace).expect("save");
671 }
672
673 fn key(code: KeyCode) -> KeyEvent {
674 KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
675 }
676
677 /// #5954: roster → `f` → saved teams → `Esc` returns to the roster, and
678 /// the footer promises `back` rather than `close` while it will.
679 #[test]
680 fn saved_teams_opened_from_the_roster_returns_there_on_esc() {
681 let ws = tempfile::TempDir::new().expect("ws");
682 save_in(ws.path(), FleetScope::Workspace, "alpha");
683 let app = app_in(ws.path().to_path_buf());
684 let config = Config::default();
685
686 let mut stack = crate::tui::views::ViewStack::new();
687 stack.push(crate::tui::views::fleet_roster::FleetRosterView::new(
688 &app, &config,
689 ));
690 let events = stack.handle_key(key(KeyCode::Char('f')));
691 assert!(
692 matches!(
693 events.as_slice(),
694 [ViewEvent::FleetRosterOpenFleetsRequested]
695 ),
696 "{events:?}"
697 );
698 assert_eq!(
699 stack.top_kind(),
700 Some(ModalKind::FleetRoster),
701 "the roster must survive the forward jump"
702 );
703
704 // What ui/handlers.rs does with that event.
705 stack.push(FleetListView::new(&app, &config).over_fleet_roster());
706 assert_eq!(stack.top_kind(), Some(ModalKind::FleetList));
707
708 stack.handle_key(key(KeyCode::Esc));
709 assert_eq!(
710 stack.top_kind(),
711 Some(ModalKind::FleetRoster),
712 "Esc in saved teams must return to the roster, not close the window"
713 );
714 }
715
716 /// #5954: `/fleet list` is the root of its own stack, so `Esc` closes.
717 #[test]
718 fn saved_teams_opened_directly_closes_on_esc() {
719 let ws = tempfile::TempDir::new().expect("ws");
720 let mut stack = crate::tui::views::ViewStack::new();
721 stack.push(FleetListView::new(
722 &app_in(ws.path().to_path_buf()),
723 &Config::default(),
724 ));
725 stack.handle_key(key(KeyCode::Esc));
726 assert!(stack.is_empty(), "direct entry must close on Esc");
727 }
728
729 /// #5954: the hint names what `Esc` actually does in each entry path.
730 #[test]
731 fn saved_teams_esc_hint_matches_the_entry_path() {
732 let ws = tempfile::TempDir::new().expect("ws");
733 save_in(ws.path(), FleetScope::Workspace, "alpha");
734 let app = app_in(ws.path().to_path_buf());
735 let config = Config::default();
736
737 // `footer_hints` is the only source the footer paints from, so
738 // asserting it is asserting what the user reads.
739 let esc_label = |view: &FleetListView| {
740 view.footer_hints()
741 .into_iter()
742 .find(|hint| hint.key == "Esc")
743 .map(|hint| hint.label.into_owned())
744 .expect("Esc hint")
745 };
746
747 assert_eq!(
748 esc_label(&FleetListView::new(&app, &config)),
749 "close",
750 "direct entry must still promise close"
751 );
752 assert_eq!(
753 esc_label(&FleetListView::new(&app, &config).over_fleet_roster()),
754 "back",
755 "over the roster the hint must promise back"
756 );
757 }
758
759 #[test]
760 fn list_renders_both_scopes_and_marks_selection() {
761 let _lock = crate::test_support::lock_test_env();
762 let prev = std::env::var_os("CODEWHALE_HOME");
763 // SAFETY: serialised by lock_test_env.
764 unsafe { std::env::set_var("CODEWHALE_HOME", sealed_home()) };
765 let ws = tempfile::TempDir::new().unwrap();
766
767 save_in(ws.path(), FleetScope::Personal, "DeepSeek Flash");
768 save_in(ws.path(), FleetScope::Workspace, "OpenAI Codex");
769 set_selected("OpenAI Codex", FleetScope::Workspace, ws.path()).unwrap();
770
771 let mut app = app_in(ws.path().to_path_buf());
772 let view = FleetListView::new(&app, &Config::default());
773
774 // Both scopes listed under their display names; the workspace one
775 // selected. The shared sealed home may hold other fleets, so assert
776 // by name+scope, never by total count.
777 assert!(
778 view.entries
779 .iter()
780 .any(|e| e.name == "DeepSeek Flash" && e.scope == FleetScope::Personal),
781 "{:?}",
782 view.entries
783 );
784 assert!(
785 view.entries
786 .iter()
787 .any(|e| e.name == "OpenAI Codex" && e.scope == FleetScope::Workspace),
788 "{:?}",
789 view.entries
790 );
791 let selected = view.selected.as_ref().unwrap();
792 assert_eq!(selected.name, "OpenAI Codex");
793 assert_eq!(selected.scope, FleetScope::Workspace);
794
795 // Restore env.
796 // SAFETY: serialised by lock_test_env.
797 unsafe {
798 match prev {
799 Some(v) => std::env::set_var("CODEWHALE_HOME", v),
800 None => std::env::remove_var("CODEWHALE_HOME"),
801 }
802 }
803 let _ = &mut app;
804 }
805
806 #[test]
807 fn select_user_global_writes_receipt_naming_the_file() {
808 let _lock = crate::test_support::lock_test_env();
809 let prev = std::env::var_os("CODEWHALE_HOME");
810 // SAFETY: serialised by lock_test_env.
811 unsafe { std::env::set_var("CODEWHALE_HOME", sealed_home()) };
812 let ws = tempfile::TempDir::new().unwrap();
813
814 save_in(ws.path(), FleetScope::Personal, "DeepSeek Flash");
815 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
816
817 let action = view.handle_key(key(KeyCode::Char('u')));
818 let ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { message }) = action else {
819 panic!("expected FleetStoreChanged, got {action:?}");
820 };
821 assert!(message.contains("DeepSeek Flash"), "{message}");
822 assert!(message.contains("user-global"), "{message}");
823 assert!(
824 message.to_ascii_lowercase().contains("selected"),
825 "{message}"
826 );
827
828 // The selection really persisted, at the personal scope. The
829 // SelectedFleet path is the fleet file the marker names.
830 let sel = crate::fleet::store::selected_fleet(ws.path()).expect("selection");
831 assert_eq!(sel.name, "DeepSeek Flash");
832 assert_eq!(sel.scope, FleetScope::Personal);
833 assert!(
834 sel.path.ends_with("fleets/deepseek-flash.toml"),
835 "{:?}",
836 sel.path
837 );
838
839 // SAFETY: serialised by lock_test_env.
840 unsafe {
841 match prev {
842 Some(v) => std::env::set_var("CODEWHALE_HOME", v),
843 None => std::env::remove_var("CODEWHALE_HOME"),
844 }
845 }
846 }
847
848 #[test]
849 fn delete_requires_confirmation_and_removes_the_file() {
850 let _lock = crate::test_support::lock_test_env();
851 let home = tempfile::TempDir::new().unwrap();
852 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
853 let ws = tempfile::TempDir::new().unwrap();
854
855 save_in(ws.path(), FleetScope::Workspace, "Temp Fleet");
856 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
857 assert_eq!(view.entries.len(), 1);
858 assert_eq!(view.entries[0].name, "Temp Fleet");
859
860 // 'd' arms the confirmation; the file is still there.
861 assert!(matches!(
862 view.handle_key(key(KeyCode::Char('d'))),
863 ViewAction::None
864 ));
865 assert!(
866 list_fleets(ws.path())
867 .iter()
868 .any(|e| e.name == "Temp Fleet")
869 );
870
871 // 'n' cancels.
872 view.handle_key(key(KeyCode::Char('n')));
873 assert!(
874 list_fleets(ws.path())
875 .iter()
876 .any(|e| e.name == "Temp Fleet")
877 );
878
879 // 'd' then 'y' deletes and emits a receipt naming the removed path.
880 view.handle_key(key(KeyCode::Char('d')));
881 let action = view.handle_key(key(KeyCode::Char('y')));
882 let ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { message }) = action else {
883 panic!("expected FleetStoreChanged, got {action:?}");
884 };
885 assert!(message.contains("Deleted Team `Temp Fleet`"), "{message}");
886 assert!(list_fleets(ws.path()).is_empty());
887 }
888
889 #[test]
890 fn enter_selects_in_the_row_scope_without_opening_detail() {
891 let _lock = crate::test_support::lock_test_env();
892 let prev = std::env::var_os("CODEWHALE_HOME");
893 // SAFETY: serialised by lock_test_env.
894 unsafe { std::env::set_var("CODEWHALE_HOME", sealed_home()) };
895 let ws = tempfile::TempDir::new().unwrap();
896
897 save_in(ws.path(), FleetScope::Workspace, "Folder Fleet");
898 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
899 let idx = view
900 .entries
901 .iter()
902 .position(|e| e.name == "Folder Fleet")
903 .expect("workspace fleet listed");
904 view.row = idx;
905
906 let action = view.handle_key(key(KeyCode::Enter));
907 let ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { message }) = action else {
908 panic!("Enter must select and close, not open detail: {action:?}");
909 };
910 assert!(message.contains("Folder Fleet"), "{message}");
911 assert!(message.contains("folder"), "{message}");
912 let sel = crate::fleet::store::selected_fleet(ws.path()).expect("selection");
913 assert_eq!(sel.name, "Folder Fleet");
914 assert_eq!(sel.scope, FleetScope::Workspace);
915
916 // SAFETY: serialised by lock_test_env.
917 unsafe {
918 match prev {
919 Some(v) => std::env::set_var("CODEWHALE_HOME", v),
920 None => std::env::remove_var("CODEWHALE_HOME"),
921 }
922 }
923 }
924
925 #[test]
926 fn legacy_entry_opens_a_pager_instead_of_editing() {
927 let _lock = crate::test_support::lock_test_env();
928 let home = tempfile::TempDir::new().expect("personal home");
929 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
930 let ws = tempfile::TempDir::new().unwrap();
931
932 // A legacy exact fleet file (workflow schema) in the personal dir.
933 std::fs::create_dir_all(home.path().join("fleets")).unwrap();
934 let legacy_path = home.path().join("fleets/stopship.toml");
935 let legacy_source = r#"schema = "exact"
936 schema_revision = 1
937 name = "stopship"
938 members = []"#;
939 std::fs::write(&legacy_path, legacy_source).unwrap();
940
941 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
942 let idx = view
943 .entries
944 .iter()
945 .position(|e| e.name == "stopship")
946 .expect("legacy entry listed");
947 view.row = idx;
948 let entry = view.selected_entry().expect("legacy entry listed");
949 assert!(entry.legacy);
950 assert_eq!(entry.scope, FleetScope::Personal);
951 assert_eq!(entry.path, legacy_path);
952
953 let action = view.handle_key(key(KeyCode::Enter));
954 let ViewAction::Emit(ViewEvent::OpenTextPager { title, content }) = action else {
955 panic!("legacy entry must open a read-only pager: {action:?}");
956 };
957 assert_eq!(title, "Team `stopship` — legacy format");
958 assert!(content.contains("This team file predates the named-team format"));
959 assert!(content.contains("create a new team"));
960
961 assert_eq!(
962 std::fs::read_to_string(&legacy_path).unwrap(),
963 legacy_source
964 );
965 assert!(crate::fleet::store::selected_fleet(ws.path()).is_none());
966 }
967
968 #[test]
969 fn migration_banner_appears_only_when_needed_and_migrates() {
970 let _lock = crate::test_support::lock_test_env();
971 let prev = std::env::var_os("CODEWHALE_HOME");
972 // SAFETY: serialised by lock_test_env.
973 unsafe { std::env::set_var("CODEWHALE_HOME", sealed_home()) };
974 let ws = tempfile::TempDir::new().unwrap();
975
976 // No legacy profiles: no banner.
977 let view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
978 assert!(!view.banner_visible());
979
980 // A workspace legacy role profile.
981 let agents = ws.path().join(".codewhale/agents");
982 std::fs::create_dir_all(&agents).unwrap();
983 std::fs::write(
984 agents.join("scout.toml"),
985 r#"id = "scout"
986 role_hint = "scout"
987 model = "deepseek-v4-flash"
988 provider = "deepseek"
989 "#,
990 )
991 .unwrap();
992
993 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
994 assert!(
995 view.banner_visible(),
996 "banner must show with legacy profiles"
997 );
998
999 let action = view.handle_key(key(KeyCode::Char('m')));
1000 let ViewAction::Emit(ViewEvent::OpenTextPager { title, content }) = action else {
1001 panic!("migration must open the receipt pager: {action:?}");
1002 };
1003 assert_eq!(title, "Legacy migration receipt");
1004 assert!(content.contains("Team `Default`"));
1005 assert!(content.contains("once a team is selected"));
1006
1007 // The Default team now exists and is the user-global selection.
1008 let entries = list_fleets(ws.path());
1009 assert!(
1010 entries.iter().any(|e| e.name == "Default" && !e.legacy),
1011 "{entries:?}"
1012 );
1013 let sel =
1014 crate::fleet::store::selected_fleet(ws.path()).expect("selection after migration");
1015 assert_eq!(sel.name, "Default");
1016
1017 // SAFETY: serialised by lock_test_env.
1018 unsafe {
1019 match prev {
1020 Some(v) => std::env::set_var("CODEWHALE_HOME", v),
1021 None => std::env::remove_var("CODEWHALE_HOME"),
1022 }
1023 }
1024 }
1025
1026 #[test]
1027 fn pointer_click_selects_the_painted_row() {
1028 let _lock = crate::test_support::lock_test_env();
1029 let home = tempfile::TempDir::new().unwrap();
1030 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
1031 let ws = tempfile::TempDir::new().unwrap();
1032
1033 save_in(ws.path(), FleetScope::Workspace, "First Fleet");
1034 save_in(ws.path(), FleetScope::Workspace, "Second Fleet");
1035 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
1036 assert_eq!(view.entries.len(), 2);
1037
1038 let area = Rect::new(0, 0, 100, 30);
1039 let mut buf = Buffer::empty(area);
1040 view.render(area, &mut buf);
1041 let second = view
1042 .row_hitboxes
1043 .borrow()
1044 .iter()
1045 .copied()
1046 .find(|(_, idx)| *idx == 1)
1047 .expect("second entry hitbox")
1048 .0;
1049 view.handle_mouse(MouseEvent {
1050 kind: MouseEventKind::Down(MouseButton::Left),
1051 column: second.x.saturating_add(1),
1052 row: second.y,
1053 modifiers: crossterm::event::KeyModifiers::NONE,
1054 });
1055 assert_eq!(view.row, 1, "click must select the painted row");
1056 }
1057
1058 #[test]
1059 fn hover_tints_list_entry_without_moving_row() {
1060 let _lock = crate::test_support::lock_test_env();
1061 let home = tempfile::TempDir::new().unwrap();
1062 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
1063 let ws = tempfile::TempDir::new().unwrap();
1064
1065 save_in(ws.path(), FleetScope::Workspace, "First Fleet");
1066 save_in(ws.path(), FleetScope::Workspace, "Second Fleet");
1067 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
1068
1069 let area = Rect::new(0, 0, 100, 30);
1070 let mut buf = Buffer::empty(area);
1071 view.render(area, &mut buf);
1072 let second = view
1073 .row_hitboxes
1074 .borrow()
1075 .iter()
1076 .copied()
1077 .find(|(_, idx)| *idx == 1)
1078 .expect("second entry hitbox")
1079 .0;
1080 assert!(matches!(
1081 view.handle_mouse(MouseEvent {
1082 kind: MouseEventKind::Moved,
1083 column: second.x.saturating_add(1),
1084 row: second.y,
1085 modifiers: crossterm::event::KeyModifiers::NONE,
1086 }),
1087 ViewAction::None
1088 ));
1089 assert_eq!(view.hovered_row.get(), Some(1));
1090 assert_eq!(view.row, 0);
1091
1092 let mut hovered_buf = Buffer::empty(area);
1093 view.render(area, &mut hovered_buf);
1094 assert_eq!(
1095 hovered_buf[(second.x, second.y)].bg,
1096 codewhale_palette::SURFACE_ELEVATED,
1097 "hovered entry must show the shared hover band"
1098 );
1099 }
1100
1101 /// U09-m5: entries paint two rows (name + summary). Scrolling by entry
1102 /// count pushed the selected bottom entry below the list; every painted
1103 /// entry, the selected one included, must fit inside the list area.
1104 #[test]
1105 fn selected_entry_stays_on_screen_when_entries_are_two_rows() {
1106 let _lock = crate::test_support::lock_test_env();
1107 let home = tempfile::TempDir::new().unwrap();
1108 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
1109 let ws = tempfile::TempDir::new().unwrap();
1110 for name in ["Alpha", "Bravo", "Charlie", "Delta", "Echo"] {
1111 save_in(ws.path(), FleetScope::Workspace, name);
1112 }
1113 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
1114 assert_eq!(view.entries.len(), 5);
1115 let area = Rect::new(0, 0, 80, 4);
1116 for row in 0..view.entries.len() {
1117 view.row = row;
1118 let mut buf = Buffer::empty(area);
1119 view.render_rows(area, &mut buf);
1120 let hitboxes = view.row_hitboxes.borrow();
1121 assert!(
1122 hitboxes.iter().any(|(_, idx)| *idx == row),
1123 "row {row} not painted: {hitboxes:?}"
1124 );
1125 assert!(
1126 hitboxes
1127 .iter()
1128 .all(|(rect, _)| rect.bottom() <= area.bottom()),
1129 "row {row}: {hitboxes:?}"
1130 );
1131 }
1132 }
1133 }
1134
1134 lines RUST