返回 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 let rows_visible = usize::from(area.height).max(1);
457 let scroll = self.row.saturating_sub(rows_visible.saturating_sub(1));
458
459 let mut lines = Vec::new();
460 let mut hitboxes = Vec::new();
461 for (idx, entry) in self.entries.iter().enumerate() {
462 if idx < scroll || idx >= scroll + rows_visible {
463 continue;
464 }
465 let selected = idx == self.row;
466 let is_selected_fleet = self
467 .selected
468 .as_ref()
469 .is_some_and(|sel| sel.name == entry.name && sel.scope == entry.scope);
470
471 let mut spans = Vec::new();
472 let marker = if is_selected_fleet { "▸ " } else { " " };
473 let base = if selected {
474 Style::default().fg(palette::WHALE_ACTION).bold()
475 } else {
476 Style::default().fg(palette::TEXT_SECONDARY)
477 };
478 spans.push(Span::styled(marker, base));
479 let name = if selected {
480 "» ".to_string()
481 } else {
482 " ".to_string()
483 };
484 spans.push(Span::styled(format!("{name}{}", entry.name), base));
485
486 let scope_badge = match entry.scope {
487 FleetScope::Personal => " [user]",
488 FleetScope::Workspace => " [folder]",
489 };
490 spans.push(Span::styled(
491 scope_badge,
492 Style::default().fg(palette::TEXT_MUTED),
493 ));
494
495 if entry.legacy {
496 spans.push(Span::styled(
497 " legacy format — read-only",
498 Style::default().fg(palette::WHALE_HUMAN),
499 ));
500 } else if let Some(err) = &entry.parse_error {
501 spans.push(Span::styled(
502 format!(" unreadable: {err}"),
503 Style::default().fg(palette::WHALE_HUMAN),
504 ));
505 }
506
507 if is_selected_fleet && !selected {
508 spans.push(Span::styled(
509 " ★",
510 Style::default().fg(palette::WHALE_ACTION),
511 ));
512 }
513
514 let start = lines.len();
515 if self.pending_delete == Some(idx) {
516 lines.push(Line::from(vec![Span::styled(
517 format!(" Delete `{}` ({})? y/n", entry.name, entry.scope.label()),
518 Style::default().fg(palette::WHALE_HUMAN),
519 )]));
520 } else {
521 lines.push(Line::from(spans));
522 // Second line: source + operator summary (compact).
523 let summary = self.entry_summary(entry);
524 lines.push(Line::from(Span::styled(
525 format!(" {summary}"),
526 Style::default().fg(palette::TEXT_DIM),
527 )));
528 }
529 // Hover tints the painted entry but never steals the keyboard
530 // row; the spans keep their ink, so the band cannot recolor
531 // scope badges or warning text.
532 if idx != self.row && self.hovered_row.get() == Some(idx) {
533 for line in &mut lines[start..] {
534 line.style = line.style.patch(menu_style::hovered_row_style());
535 }
536 }
537 hitboxes.push((
538 Rect {
539 x: area.x,
540 y: area.y.saturating_add(start as u16),
541 width: area.width,
542 height: (lines.len().saturating_sub(start) as u16).max(1),
543 },
544 idx,
545 ));
546 }
547 *self.row_hitboxes.borrow_mut() = hitboxes;
548
549 let text = ratatui::text::Text::from(lines);
550 Paragraph::new(text).render(area, buf);
551 }
552
553 fn entry_summary(&self, entry: &FleetEntry) -> String {
554 if entry.legacy {
555 return "legacy format — open for details".to_string();
556 }
557 // Read the file to summarize operator + members. Parsing errors are
558 // already shown on the row. Paths stay out of the primary row — they
559 // belong in receipts after an explicit write/select/delete.
560 let Ok(text) = std::fs::read_to_string(&entry.path) else {
561 return "unreadable".to_string();
562 };
563 match crate::fleet::store::FleetFile::parse(&text) {
564 Ok(fleet) => {
565 let coordinator = match &fleet.operator {
566 Some(op) => format!("{}/{}", op.provider, op.model),
567 None => "uses the session's model".to_string(),
568 };
569 format!(
570 "Coordinator: {coordinator} · members: {}",
571 fleet
572 .members
573 .iter()
574 .filter(|member| !member.shortlist)
575 .count()
576 )
577 }
578 Err(err) => format!("unreadable: {err}"),
579 }
580 }
581 }
582
583 fn outcome_to_action(outcome: Option<FleetListOutcome>) -> ViewAction {
584 match outcome {
585 Some(FleetListOutcome::Done { message }) => {
586 ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { message })
587 }
588 Some(FleetListOutcome::OpenDetail { name, scope }) => {
589 ViewAction::Emit(ViewEvent::FleetListOpenDetailRequested { name, scope })
590 }
591 Some(FleetListOutcome::None) | None => ViewAction::None,
592 }
593 }
594
595 /// How many legacy per-role profile files exist across both scopes.
596 fn legacy_profile_file_count(workspace: &std::path::Path) -> usize {
597 let mut count = 0;
598 if let Ok(dir) = crate::fleet::profile::personal_agent_profile_dir()
599 && let Ok(read) = std::fs::read_dir(dir)
600 {
601 count += read
602 .filter_map(|e| e.ok())
603 .filter(|e| e.path().extension().is_some_and(|x| x == "toml"))
604 .count();
605 }
606 let ws_dir = workspace.join(".codewhale").join("agents");
607 if let Ok(read) = std::fs::read_dir(ws_dir) {
608 count += read
609 .filter_map(|e| e.ok())
610 .filter(|e| e.path().extension().is_some_and(|x| x == "toml"))
611 .count();
612 }
613 count
614 }
615
616 #[cfg(test)]
617 mod tests {
618 use super::*;
619 use crate::config::Config;
620 use crate::fleet::store::{FleetFile, save_fleet};
621 use crate::tui::app::{App, TuiOptions};
622 use std::sync::OnceLock;
623
624 fn sealed_home() -> &'static std::path::Path {
625 static HOME: OnceLock<PathBuf> = OnceLock::new();
626 HOME.get_or_init(|| {
627 let dir = tempfile::TempDir::new().expect("temp dir").keep();
628 std::fs::create_dir_all(dir.join("fleets")).expect("fleets dir");
629 dir
630 })
631 }
632
633 fn app_in(workspace: PathBuf) -> App {
634 let options = TuiOptions {
635 ..crate::test_support::test_tui_options(workspace.clone())
636 };
637 let mut app = App::new(options, &Config::default());
638 app.workspace = workspace;
639 app
640 }
641
642 fn sample_fleet(name: &str) -> FleetFile {
643 let mut fleet = FleetFile::new(name.to_string(), None).unwrap();
644 fleet.operator = Some(crate::fleet::store::FleetOperator {
645 provider: "deepseek".to_string(),
646 model: "deepseek-v4-flash".to_string(),
647 reasoning: None,
648 });
649 fleet
650 }
651
652 fn save_in(workspace: &std::path::Path, scope: FleetScope, name: &str) {
653 let fleet = sample_fleet(name);
654 let _ = save_fleet(&fleet, scope, workspace).expect("save");
655 }
656
657 fn key(code: KeyCode) -> KeyEvent {
658 KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
659 }
660
661 /// #5954: roster → `f` → saved teams → `Esc` returns to the roster, and
662 /// the footer promises `back` rather than `close` while it will.
663 #[test]
664 fn saved_teams_opened_from_the_roster_returns_there_on_esc() {
665 let ws = tempfile::TempDir::new().expect("ws");
666 save_in(ws.path(), FleetScope::Workspace, "alpha");
667 let app = app_in(ws.path().to_path_buf());
668 let config = Config::default();
669
670 let mut stack = crate::tui::views::ViewStack::new();
671 stack.push(crate::tui::views::fleet_roster::FleetRosterView::new(
672 &app, &config,
673 ));
674 let events = stack.handle_key(key(KeyCode::Char('f')));
675 assert!(
676 matches!(
677 events.as_slice(),
678 [ViewEvent::FleetRosterOpenFleetsRequested]
679 ),
680 "{events:?}"
681 );
682 assert_eq!(
683 stack.top_kind(),
684 Some(ModalKind::FleetRoster),
685 "the roster must survive the forward jump"
686 );
687
688 // What ui/handlers.rs does with that event.
689 stack.push(FleetListView::new(&app, &config).over_fleet_roster());
690 assert_eq!(stack.top_kind(), Some(ModalKind::FleetList));
691
692 stack.handle_key(key(KeyCode::Esc));
693 assert_eq!(
694 stack.top_kind(),
695 Some(ModalKind::FleetRoster),
696 "Esc in saved teams must return to the roster, not close the window"
697 );
698 }
699
700 /// #5954: `/fleet list` is the root of its own stack, so `Esc` closes.
701 #[test]
702 fn saved_teams_opened_directly_closes_on_esc() {
703 let ws = tempfile::TempDir::new().expect("ws");
704 let mut stack = crate::tui::views::ViewStack::new();
705 stack.push(FleetListView::new(
706 &app_in(ws.path().to_path_buf()),
707 &Config::default(),
708 ));
709 stack.handle_key(key(KeyCode::Esc));
710 assert!(stack.is_empty(), "direct entry must close on Esc");
711 }
712
713 /// #5954: the hint names what `Esc` actually does in each entry path.
714 #[test]
715 fn saved_teams_esc_hint_matches_the_entry_path() {
716 let ws = tempfile::TempDir::new().expect("ws");
717 save_in(ws.path(), FleetScope::Workspace, "alpha");
718 let app = app_in(ws.path().to_path_buf());
719 let config = Config::default();
720
721 // `footer_hints` is the only source the footer paints from, so
722 // asserting it is asserting what the user reads.
723 let esc_label = |view: &FleetListView| {
724 view.footer_hints()
725 .into_iter()
726 .find(|hint| hint.key == "Esc")
727 .map(|hint| hint.label.into_owned())
728 .expect("Esc hint")
729 };
730
731 assert_eq!(
732 esc_label(&FleetListView::new(&app, &config)),
733 "close",
734 "direct entry must still promise close"
735 );
736 assert_eq!(
737 esc_label(&FleetListView::new(&app, &config).over_fleet_roster()),
738 "back",
739 "over the roster the hint must promise back"
740 );
741 }
742
743 #[test]
744 fn list_renders_both_scopes_and_marks_selection() {
745 let _lock = crate::test_support::lock_test_env();
746 let prev = std::env::var_os("CODEWHALE_HOME");
747 // SAFETY: serialised by lock_test_env.
748 unsafe { std::env::set_var("CODEWHALE_HOME", sealed_home()) };
749 let ws = tempfile::TempDir::new().unwrap();
750
751 save_in(ws.path(), FleetScope::Personal, "DeepSeek Flash");
752 save_in(ws.path(), FleetScope::Workspace, "OpenAI Codex");
753 set_selected("OpenAI Codex", FleetScope::Workspace, ws.path()).unwrap();
754
755 let mut app = app_in(ws.path().to_path_buf());
756 let view = FleetListView::new(&app, &Config::default());
757
758 // Both scopes listed under their display names; the workspace one
759 // selected. The shared sealed home may hold other fleets, so assert
760 // by name+scope, never by total count.
761 assert!(
762 view.entries
763 .iter()
764 .any(|e| e.name == "DeepSeek Flash" && e.scope == FleetScope::Personal),
765 "{:?}",
766 view.entries
767 );
768 assert!(
769 view.entries
770 .iter()
771 .any(|e| e.name == "OpenAI Codex" && e.scope == FleetScope::Workspace),
772 "{:?}",
773 view.entries
774 );
775 let selected = view.selected.as_ref().unwrap();
776 assert_eq!(selected.name, "OpenAI Codex");
777 assert_eq!(selected.scope, FleetScope::Workspace);
778
779 // Restore env.
780 // SAFETY: serialised by lock_test_env.
781 unsafe {
782 match prev {
783 Some(v) => std::env::set_var("CODEWHALE_HOME", v),
784 None => std::env::remove_var("CODEWHALE_HOME"),
785 }
786 }
787 let _ = &mut app;
788 }
789
790 #[test]
791 fn select_user_global_writes_receipt_naming_the_file() {
792 let _lock = crate::test_support::lock_test_env();
793 let prev = std::env::var_os("CODEWHALE_HOME");
794 // SAFETY: serialised by lock_test_env.
795 unsafe { std::env::set_var("CODEWHALE_HOME", sealed_home()) };
796 let ws = tempfile::TempDir::new().unwrap();
797
798 save_in(ws.path(), FleetScope::Personal, "DeepSeek Flash");
799 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
800
801 let action = view.handle_key(key(KeyCode::Char('u')));
802 let ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { message }) = action else {
803 panic!("expected FleetStoreChanged, got {action:?}");
804 };
805 assert!(message.contains("DeepSeek Flash"), "{message}");
806 assert!(message.contains("user-global"), "{message}");
807 assert!(
808 message.to_ascii_lowercase().contains("selected"),
809 "{message}"
810 );
811
812 // The selection really persisted, at the personal scope. The
813 // SelectedFleet path is the fleet file the marker names.
814 let sel = crate::fleet::store::selected_fleet(ws.path()).expect("selection");
815 assert_eq!(sel.name, "DeepSeek Flash");
816 assert_eq!(sel.scope, FleetScope::Personal);
817 assert!(
818 sel.path.ends_with("fleets/deepseek-flash.toml"),
819 "{:?}",
820 sel.path
821 );
822
823 // SAFETY: serialised by lock_test_env.
824 unsafe {
825 match prev {
826 Some(v) => std::env::set_var("CODEWHALE_HOME", v),
827 None => std::env::remove_var("CODEWHALE_HOME"),
828 }
829 }
830 }
831
832 #[test]
833 fn delete_requires_confirmation_and_removes_the_file() {
834 let _lock = crate::test_support::lock_test_env();
835 let home = tempfile::TempDir::new().unwrap();
836 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
837 let ws = tempfile::TempDir::new().unwrap();
838
839 save_in(ws.path(), FleetScope::Workspace, "Temp Fleet");
840 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
841 assert_eq!(view.entries.len(), 1);
842 assert_eq!(view.entries[0].name, "Temp Fleet");
843
844 // 'd' arms the confirmation; the file is still there.
845 assert!(matches!(
846 view.handle_key(key(KeyCode::Char('d'))),
847 ViewAction::None
848 ));
849 assert!(
850 list_fleets(ws.path())
851 .iter()
852 .any(|e| e.name == "Temp Fleet")
853 );
854
855 // 'n' cancels.
856 view.handle_key(key(KeyCode::Char('n')));
857 assert!(
858 list_fleets(ws.path())
859 .iter()
860 .any(|e| e.name == "Temp Fleet")
861 );
862
863 // 'd' then 'y' deletes and emits a receipt naming the removed path.
864 view.handle_key(key(KeyCode::Char('d')));
865 let action = view.handle_key(key(KeyCode::Char('y')));
866 let ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { message }) = action else {
867 panic!("expected FleetStoreChanged, got {action:?}");
868 };
869 assert!(message.contains("Deleted Team `Temp Fleet`"), "{message}");
870 assert!(list_fleets(ws.path()).is_empty());
871 }
872
873 #[test]
874 fn enter_selects_in_the_row_scope_without_opening_detail() {
875 let _lock = crate::test_support::lock_test_env();
876 let prev = std::env::var_os("CODEWHALE_HOME");
877 // SAFETY: serialised by lock_test_env.
878 unsafe { std::env::set_var("CODEWHALE_HOME", sealed_home()) };
879 let ws = tempfile::TempDir::new().unwrap();
880
881 save_in(ws.path(), FleetScope::Workspace, "Folder Fleet");
882 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
883 let idx = view
884 .entries
885 .iter()
886 .position(|e| e.name == "Folder Fleet")
887 .expect("workspace fleet listed");
888 view.row = idx;
889
890 let action = view.handle_key(key(KeyCode::Enter));
891 let ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { message }) = action else {
892 panic!("Enter must select and close, not open detail: {action:?}");
893 };
894 assert!(message.contains("Folder Fleet"), "{message}");
895 assert!(message.contains("folder"), "{message}");
896 let sel = crate::fleet::store::selected_fleet(ws.path()).expect("selection");
897 assert_eq!(sel.name, "Folder Fleet");
898 assert_eq!(sel.scope, FleetScope::Workspace);
899
900 // SAFETY: serialised by lock_test_env.
901 unsafe {
902 match prev {
903 Some(v) => std::env::set_var("CODEWHALE_HOME", v),
904 None => std::env::remove_var("CODEWHALE_HOME"),
905 }
906 }
907 }
908
909 #[test]
910 fn legacy_entry_opens_a_pager_instead_of_editing() {
911 let _lock = crate::test_support::lock_test_env();
912 let home = tempfile::TempDir::new().expect("personal home");
913 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
914 let ws = tempfile::TempDir::new().unwrap();
915
916 // A legacy exact fleet file (workflow schema) in the personal dir.
917 std::fs::create_dir_all(home.path().join("fleets")).unwrap();
918 let legacy_path = home.path().join("fleets/stopship.toml");
919 let legacy_source = r#"schema = "exact"
920 schema_revision = 1
921 name = "stopship"
922 members = []"#;
923 std::fs::write(&legacy_path, legacy_source).unwrap();
924
925 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
926 let idx = view
927 .entries
928 .iter()
929 .position(|e| e.name == "stopship")
930 .expect("legacy entry listed");
931 view.row = idx;
932 let entry = view.selected_entry().expect("legacy entry listed");
933 assert!(entry.legacy);
934 assert_eq!(entry.scope, FleetScope::Personal);
935 assert_eq!(entry.path, legacy_path);
936
937 let action = view.handle_key(key(KeyCode::Enter));
938 let ViewAction::Emit(ViewEvent::OpenTextPager { title, content }) = action else {
939 panic!("legacy entry must open a read-only pager: {action:?}");
940 };
941 assert_eq!(title, "Team `stopship` — legacy format");
942 assert!(content.contains("This team file predates the named-team format"));
943 assert!(content.contains("create a new team"));
944
945 assert_eq!(
946 std::fs::read_to_string(&legacy_path).unwrap(),
947 legacy_source
948 );
949 assert!(crate::fleet::store::selected_fleet(ws.path()).is_none());
950 }
951
952 #[test]
953 fn migration_banner_appears_only_when_needed_and_migrates() {
954 let _lock = crate::test_support::lock_test_env();
955 let prev = std::env::var_os("CODEWHALE_HOME");
956 // SAFETY: serialised by lock_test_env.
957 unsafe { std::env::set_var("CODEWHALE_HOME", sealed_home()) };
958 let ws = tempfile::TempDir::new().unwrap();
959
960 // No legacy profiles: no banner.
961 let view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
962 assert!(!view.banner_visible());
963
964 // A workspace legacy role profile.
965 let agents = ws.path().join(".codewhale/agents");
966 std::fs::create_dir_all(&agents).unwrap();
967 std::fs::write(
968 agents.join("scout.toml"),
969 r#"id = "scout"
970 role_hint = "scout"
971 model = "deepseek-v4-flash"
972 provider = "deepseek"
973 "#,
974 )
975 .unwrap();
976
977 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
978 assert!(
979 view.banner_visible(),
980 "banner must show with legacy profiles"
981 );
982
983 let action = view.handle_key(key(KeyCode::Char('m')));
984 let ViewAction::Emit(ViewEvent::OpenTextPager { title, content }) = action else {
985 panic!("migration must open the receipt pager: {action:?}");
986 };
987 assert_eq!(title, "Legacy migration receipt");
988 assert!(content.contains("Team `Default`"));
989 assert!(content.contains("once a team is selected"));
990
991 // The Default team now exists and is the user-global selection.
992 let entries = list_fleets(ws.path());
993 assert!(
994 entries.iter().any(|e| e.name == "Default" && !e.legacy),
995 "{entries:?}"
996 );
997 let sel =
998 crate::fleet::store::selected_fleet(ws.path()).expect("selection after migration");
999 assert_eq!(sel.name, "Default");
1000
1001 // SAFETY: serialised by lock_test_env.
1002 unsafe {
1003 match prev {
1004 Some(v) => std::env::set_var("CODEWHALE_HOME", v),
1005 None => std::env::remove_var("CODEWHALE_HOME"),
1006 }
1007 }
1008 }
1009
1010 #[test]
1011 fn pointer_click_selects_the_painted_row() {
1012 let _lock = crate::test_support::lock_test_env();
1013 let home = tempfile::TempDir::new().unwrap();
1014 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
1015 let ws = tempfile::TempDir::new().unwrap();
1016
1017 save_in(ws.path(), FleetScope::Workspace, "First Fleet");
1018 save_in(ws.path(), FleetScope::Workspace, "Second Fleet");
1019 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
1020 assert_eq!(view.entries.len(), 2);
1021
1022 let area = Rect::new(0, 0, 100, 30);
1023 let mut buf = Buffer::empty(area);
1024 view.render(area, &mut buf);
1025 let second = view
1026 .row_hitboxes
1027 .borrow()
1028 .iter()
1029 .copied()
1030 .find(|(_, idx)| *idx == 1)
1031 .expect("second entry hitbox")
1032 .0;
1033 view.handle_mouse(MouseEvent {
1034 kind: MouseEventKind::Down(MouseButton::Left),
1035 column: second.x.saturating_add(1),
1036 row: second.y,
1037 modifiers: crossterm::event::KeyModifiers::NONE,
1038 });
1039 assert_eq!(view.row, 1, "click must select the painted row");
1040 }
1041
1042 #[test]
1043 fn hover_tints_list_entry_without_moving_row() {
1044 let _lock = crate::test_support::lock_test_env();
1045 let home = tempfile::TempDir::new().unwrap();
1046 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
1047 let ws = tempfile::TempDir::new().unwrap();
1048
1049 save_in(ws.path(), FleetScope::Workspace, "First Fleet");
1050 save_in(ws.path(), FleetScope::Workspace, "Second Fleet");
1051 let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
1052
1053 let area = Rect::new(0, 0, 100, 30);
1054 let mut buf = Buffer::empty(area);
1055 view.render(area, &mut buf);
1056 let second = view
1057 .row_hitboxes
1058 .borrow()
1059 .iter()
1060 .copied()
1061 .find(|(_, idx)| *idx == 1)
1062 .expect("second entry hitbox")
1063 .0;
1064 assert!(matches!(
1065 view.handle_mouse(MouseEvent {
1066 kind: MouseEventKind::Moved,
1067 column: second.x.saturating_add(1),
1068 row: second.y,
1069 modifiers: crossterm::event::KeyModifiers::NONE,
1070 }),
1071 ViewAction::None
1072 ));
1073 assert_eq!(view.hovered_row.get(), Some(1));
1074 assert_eq!(view.row, 0);
1075
1076 let mut hovered_buf = Buffer::empty(area);
1077 view.render(area, &mut hovered_buf);
1078 assert_eq!(
1079 hovered_buf[(second.x, second.y)].bg,
1080 codewhale_palette::SURFACE_ELEVATED,
1081 "hovered entry must show the shared hover band"
1082 );
1083 }
1084 }
1085
1085 lines RUST