feat(tui): Fleet manager — list/detail/select/rename/delete, no shadow pile

The primary /fleet surface is now the saved-Fleet list. One row per
named Fleet across both scopes, each showing its display name (never the
file slug), a user/folder scope badge, and its source path; same-name
Fleets in two scopes are two rows, never a silent shadow. Legacy
per-role profiles produce one migration banner instead of a pile of
shadow badges.

- FleetListView: ↑/↓ move, Enter opens detail (legacy files open a
  read-only pager instead of editing), u = select as user-global
  default, w = select for this folder only, d = delete with y/n
  confirm, m = migrate legacy roster into Fleet "Default" with a
  receipt naming every role, pin, winner, and ignored conflict. Every
  receipt names the exact file written.
- FleetDetailView: operator row + member rows; o/e open a route picker
  (inherit + every configured provider/model with readiness labels —
  the same route list the setup wizard's Model step shows), t cycles
  reasoning through the provider's genuinely supported tiers, v toggles
  the vision requirement, a adds the first unused known role, d removes
  with confirm, r renames inline, s saves, c copies to the other scope,
  u/w select. Editing a Fleet edits that Fleet's file — never the live
  session route, never a global role collection.
- store: list_fleets now surfaces each file's display name (from
  content) instead of the slug; load_fleet_in_scope resolves a Fleet in
  one explicit scope so the UI never hits ambiguity.
- /fleet (bare) opens the list; /fleet roster keeps the legacy roster
  one subcommand away; /fleet setup keeps the wizard.

Tests: 62 view tests (list rendering + scope badges + selection
receipts + delete confirm + legacy pager + migration banner/flow; detail
open/rename/route-pin/inherit-clear/vision toggle/save receipt/add +
remove) and 10 fleet-command tests. Store tests updated for display
names. cargo fmt clean.
This commit is contained in:
Hmbown
2026-08-04 18:57:39 -07:00
parent 7396dc4066
commit 371846f5c9
8 changed files with 1943 additions and 10 deletions
+6 -3
View File
@@ -78,10 +78,13 @@ impl RegisterCommand for FleetCmd {
fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
let Some((verb, target)) = split_verb(arg) else {
return CommandResult::action(AppAction::OpenFleetRoster);
// The primary Fleet surface is the saved-Fleet list. The legacy
// roster remains one subcommand away.
return CommandResult::action(AppAction::OpenFleetList);
};
match verb {
"roster" | "party" | "loadout" | "roles" | "role" | "profiles" | "profile" => {
// The old per-role roster; the saved-Fleet list is /fleet.
CommandResult::action(AppAction::OpenFleetRoster)
}
"setup" | "edit" | "new" => CommandResult::action(AppAction::OpenFleetSetup),
@@ -123,12 +126,12 @@ mod tests {
}
#[test]
fn fleet_command_opens_roster_view() {
fn fleet_command_opens_fleet_list_view() {
let mut app = test_app();
let result = FleetCmd::execute(&mut app, None);
assert_eq!(result.action, Some(AppAction::OpenFleetRoster));
assert_eq!(result.action, Some(AppAction::OpenFleetList));
assert!(result.message.is_none());
}
+51 -5
View File
@@ -412,17 +412,32 @@ fn collect_entries(dir: &Path, scope: FleetScope, out: &mut Vec<FleetEntry>) {
.collect();
files.sort();
for path in files {
let name = path
let stem = path
.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let parse_error = fs::read_to_string(&path)
.ok()
.and_then(|text| FleetFile::parse(&text).err())
let text = fs::read_to_string(&path).ok();
let parse_error = text
.as_deref()
.and_then(|text| FleetFile::parse(text).err())
.map(|e| e.to_string());
let legacy = parse_error.as_deref().is_some_and(|err| {
err.contains("unknown schema") || err.contains("invalid fleet TOML")
});
// The row shows the Fleet's own display name, never the file slug —
// a file saved as `Temp Fleet` must not appear as `temp-fleet`.
let name = text
.as_deref()
.and_then(|text| toml::from_str::<toml::Value>(text).ok())
.and_then(|value| {
value
.get("name")
.and_then(|n| n.as_str())
.map(str::trim)
.filter(|n| !n.is_empty())
.map(str::to_string)
})
.unwrap_or(stem);
out.push(FleetEntry {
name,
scope,
@@ -475,6 +490,37 @@ pub fn load_fleet(
Ok((fleet, scope, path))
}
/// Load a v2 Fleet by name in one explicit scope. Unlike [`load_fleet`],
/// this never resolves ambiguity — the caller already knows where the Fleet
/// lives (e.g. the row the user just picked).
pub fn load_fleet_in_scope(
name: &str,
scope: FleetScope,
workspace: &Path,
) -> Result<(FleetFile, PathBuf), FleetStoreError> {
let dir = match scope {
FleetScope::Personal => personal_fleets_dir()?,
FleetScope::Workspace => workspace_fleets_dir(workspace),
};
let path = dir.join(format!("{}.toml", slugify(name)));
if !path.is_file() {
return Err(FleetStoreError::NotFound(format!(
"{} ({})",
name,
scope.label()
)));
}
let text = fs::read_to_string(&path).map_err(|e| FleetStoreError::Io {
path: path.display().to_string(),
message: e.to_string(),
})?;
let fleet = FleetFile::parse(&text).map_err(|e| FleetStoreError::Parse {
path: path.display().to_string(),
message: e.to_string(),
})?;
Ok((fleet, path))
}
/// Load a v2 Fleet from a specific path (used by the editor on the currently
/// open entry, so the saved scope is exact).
pub fn load_fleet_at(path: &Path) -> Result<(FleetFile, FleetScope), FleetStoreError> {
+2
View File
@@ -878,6 +878,8 @@ pub enum AppAction {
OpenThemePicker,
/// Open the `/skills` manager — audit inventory + owned mutations.
OpenSkillsManager,
/// Open `/fleet` — the saved named-Fleet list (the primary Fleet surface).
OpenFleetList,
/// Open the `/fleet` roster — the saved-party view of the agent team.
OpenFleetRoster,
/// Open the `/fleet` profile authoring wizard.
+36
View File
@@ -12392,6 +12392,14 @@ async fn apply_command_result(
));
}
}
AppAction::OpenFleetList => {
if app.view_stack.top_kind() != Some(ModalKind::FleetList) {
app.view_stack
.push(crate::tui::views::fleet_list::FleetListView::new(
app, config,
));
}
}
AppAction::OpenFleetRoster => {
if app.view_stack.top_kind() != Some(ModalKind::FleetRoster) {
app.view_stack
@@ -15005,6 +15013,34 @@ async fn handle_view_events(
);
}
}
ViewEvent::FleetListOpenDetailRequested { name, scope } => {
if app.view_stack.top_kind() != Some(ModalKind::FleetDetail) {
if let Some(view) = crate::tui::views::fleet_detail::FleetDetailView::open(
app, config, &name, scope,
) {
app.view_stack.push(view);
} else {
app.set_sticky_status(
format!(
"Could not open Fleet `{name}` ({}) — the file may have moved or become unreadable.",
scope.label()
),
crate::tui::app::StatusToastLevel::Error,
None,
);
}
}
}
ViewEvent::FleetStoreChanged { message } => {
app.status_message = Some(message);
// Refresh the dispatch roster from the fleet-aware source so
// selection changes take effect for the next spawn.
let roster =
crate::fleet::roster::FleetRoster::load(&config.fleet_config(), &app.workspace);
let _ = engine_handle.try_send(Op::SetFleetRoster {
roster: std::sync::Arc::new(roster),
});
}
ViewEvent::FleetRosterOpenWorkersRequested => {
if app.view_stack.top_kind() != Some(ModalKind::SubAgents) {
let agents = subagent_view_agents(app, &app.subagent_cache);
File diff suppressed because it is too large Load Diff
+824
View File
@@ -0,0 +1,824 @@
//! `/fleet` list — the primary Fleet surface.
//!
//! One row per saved named Fleet, across both scopes: user-global
//! (`$CODEWHALE_HOME/fleets/`) and folder (`.codewhale/fleets/`). Every row
//! shows its scope and exact source; same-name Fleets in both scopes are two
//! rows, never a silent shadow. The selected Fleet is marked and its operator
//! route summarized. Legacy per-role profiles get one migration banner, not a
//! pile of shadow badges.
//!
//! The view reads and writes the Fleet store directly (local, atomic file
//! operations); it never touches the live session route.
use std::path::PathBuf;
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect},
style::Style,
text::{Line, Span},
widgets::{Block, Clear, Paragraph, Widget, Wrap},
};
use crate::config::Config;
use crate::fleet::store::{
FleetEntry, FleetScope, SelectedFleet, delete_fleet, list_fleets, migrate_legacy_roster,
selected_fleet, set_selected,
};
use crate::palette;
use crate::tui::app::App;
use crate::tui::views::{
ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
};
/// What the host should do after this view acted on the store.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FleetListOutcome {
/// A store mutation happened; show `message`, refresh, and pop the stack.
Done { message: String },
/// Open the detail view for the given Fleet.
OpenDetail { name: String, scope: FleetScope },
/// Nothing to do.
None,
}
pub struct FleetListView {
entries: Vec<FleetEntry>,
/// The active selection snapshot (workspace then personal).
selected: Option<SelectedFleet>,
/// Legacy profile count (how many per-role files exist), for the banner.
legacy_profile_count: usize,
/// Whether a v2 Fleet named "Default" already exists (so migration is
/// offered only when it would not clobber).
default_fleet_exists: bool,
row: usize,
/// Saved scope of the row being acted on (delete/select flow through
/// confirmation state).
pending_delete: Option<usize>,
fleet_config: codewhale_config::FleetConfigToml,
locale: crate::localization::Locale,
workspace: PathBuf,
}
impl FleetListView {
#[must_use]
pub fn new(app: &App, config: &Config) -> Self {
let workspace = app.workspace.clone();
let entries = list_fleets(&workspace);
let selected = selected_fleet(&workspace);
let legacy_profile_count = legacy_profile_file_count(&workspace);
let default_fleet_exists = entries
.iter()
.any(|e| e.name.eq_ignore_ascii_case("default") && !e.legacy);
Self {
entries,
selected,
legacy_profile_count,
default_fleet_exists,
row: 0,
pending_delete: None,
fleet_config: config.fleet_config(),
locale: app.ui_locale,
workspace,
}
}
fn selected_entry(&self) -> Option<&FleetEntry> {
self.entries.get(self.row)
}
fn visible_entries(&self) -> Vec<usize> {
// Rows: one per entry; the migration banner is rendered separately.
(0..self.entries.len()).collect()
}
fn banner_visible(&self) -> bool {
self.legacy_profile_count > 0 && !self.default_fleet_exists
}
fn move_row(&mut self, delta: isize) {
let rows = self.entries.len();
if rows == 0 {
return;
}
self.row = crate::tui::list_nav::wrap_index(self.row, rows, delta);
}
fn footer_hints(&self) -> Vec<ActionHint> {
let mut hints = vec![
ActionHint::new("↑/↓", "move"),
ActionHint::new("Enter", "open"),
];
if !self.entries.is_empty() {
hints.push(ActionHint::new("u", "select for user"));
hints.push(ActionHint::new("w", "select for folder"));
hints.push(ActionHint::new("d", "delete"));
}
if self.banner_visible() {
hints.push(ActionHint::new("m", "migrate"));
}
hints.push(ActionHint::new("Esc", "close"));
hints
}
fn confirm_delete(&mut self) -> Option<FleetListOutcome> {
let idx = self.pending_delete?;
let entry = self.entries.get(idx)?;
let name = entry.name.clone();
let scope = entry.scope;
match delete_fleet(&name, scope, &self.workspace) {
Ok(path) => Some(FleetListOutcome::Done {
message: format!(
"Deleted Fleet `{name}` ({}) — removed {}",
scope.label(),
path.display()
),
}),
Err(err) => Some(FleetListOutcome::Done {
message: format!("Delete failed: {err}"),
}),
}
}
}
impl ModalView for FleetListView {
fn kind(&self) -> ModalKind {
ModalKind::FleetList
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
if self.pending_delete.is_some() {
match key.code {
KeyCode::Char('y') | KeyCode::Enter => {
let outcome = self.confirm_delete();
self.pending_delete = None;
return outcome_to_action(outcome);
}
KeyCode::Char('n') | KeyCode::Esc => {
self.pending_delete = None;
return ViewAction::None;
}
_ => return ViewAction::None,
}
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
KeyCode::Up | KeyCode::Char('k') => {
self.move_row(-1);
ViewAction::None
}
KeyCode::Down | KeyCode::Char('j') => {
self.move_row(1);
ViewAction::None
}
KeyCode::Enter => {
let Some(entry) = self.selected_entry() else {
return ViewAction::None;
};
if entry.legacy {
return ViewAction::Emit(ViewEvent::OpenTextPager {
title: format!("Fleet `{}` — legacy format", entry.name),
content: format!(
"This Fleet file predates the named-Fleet format ({}).\n\n\
It is listed so nothing you saved disappears, but it is \
read-only here. To edit it, create a new Fleet and copy \
the settings you want; legacy files are never migrated \
silently.\n\nParse error: {}",
entry.path.display(),
entry.parse_error.as_deref().unwrap_or("unknown")
),
});
}
ViewAction::Emit(ViewEvent::FleetListOpenDetailRequested {
name: entry.name.clone(),
scope: entry.scope,
})
}
KeyCode::Char('u') => {
let Some(entry) = self.selected_entry() else {
return ViewAction::None;
};
if entry.legacy {
return ViewAction::None;
}
match set_selected(&entry.name, FleetScope::Personal, &self.workspace) {
Ok(path) => outcome_to_action(Some(FleetListOutcome::Done {
message: format!(
"Selected Fleet `{}` as your user-global default — wrote {}",
entry.name,
path.display()
),
})),
Err(err) => outcome_to_action(Some(FleetListOutcome::Done {
message: format!("Selection failed: {err}"),
})),
}
}
KeyCode::Char('w') => {
let Some(entry) = self.selected_entry() else {
return ViewAction::None;
};
if entry.legacy {
return ViewAction::None;
}
match set_selected(&entry.name, FleetScope::Workspace, &self.workspace) {
Ok(path) => outcome_to_action(Some(FleetListOutcome::Done {
message: format!(
"Selected Fleet `{}` for this folder only — wrote {}",
entry.name,
path.display()
),
})),
Err(err) => outcome_to_action(Some(FleetListOutcome::Done {
message: format!("Selection failed: {err}"),
})),
}
}
KeyCode::Char('d') => {
let Some(entry) = self.selected_entry() else {
return ViewAction::None;
};
if entry.legacy {
return ViewAction::None;
}
self.pending_delete = Some(self.row);
ViewAction::None
}
KeyCode::Char('m') if self.banner_visible() => {
match migrate_legacy_roster(
&self.fleet_config,
&self.workspace,
true,
FleetScope::Personal,
) {
Ok(receipt) => {
let mut content = format!(
"Migrated {} legacy role profiles into Fleet `Default` \
(user-global) — wrote {}\n\n",
receipt.rows.len(),
receipt.saved_to.display()
);
for row in &receipt.rows {
let pin = match &row.pin {
Some((model, provider)) => {
format!("{provider}/{model}")
}
None => "inherit session route".to_string(),
};
content.push_str(&format!("- {}{pin}", row.id));
if let Some(shadow) = &row.conflicting_shadow {
content.push_str(&format!(" [conflict: {shadow}]"));
}
content.push('\n');
}
content.push_str(
"\nLegacy profile files were left untouched — they are no \
longer live configuration once a Fleet is selected.",
);
if let Ok(path) =
set_selected("Default", FleetScope::Personal, &self.workspace)
{
content.push_str(&format!(
"\n\nFleet `Default` is now your user-global default — wrote {}.",
path.display()
));
}
ViewAction::Emit(ViewEvent::OpenTextPager {
title: "Legacy migration receipt".to_string(),
content,
})
}
Err(err) => ViewAction::Emit(ViewEvent::OpenTextPager {
title: "Legacy migration failed".to_string(),
content: format!("{err:#}"),
}),
}
}
KeyCode::Home => {
self.row = 0;
ViewAction::None
}
KeyCode::End => {
self.row = self.entries.len().saturating_sub(1);
ViewAction::None
}
_ => ViewAction::None,
}
}
fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
if self.pending_delete.is_some() {
self.pending_delete = None;
return ViewAction::None;
}
let (rows_top, _) = (5u16, 0u16);
if mouse.row >= rows_top {
let idx = usize::from(mouse.row - rows_top) + self.row.saturating_sub(0);
if idx < self.entries.len() {
self.row = idx;
return ViewAction::None;
}
}
}
ViewAction::None
}
fn render(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
Block::default()
.style(Style::default().bg(palette::WHALE_BG))
.render(area, buf);
let hints = self.footer_hints();
let content = render_modal_footer(area, buf, &hints);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(4), Constraint::Min(1)])
.split(content);
// Header: name + selected summary.
let selected_line = match &self.selected {
Some(sel) => format!(
"Selected: `{}` ({}) — {}",
sel.name,
sel.scope.label(),
sel.path.display()
),
None => "No Fleet selected — the session uses its own route and the legacy roster."
.to_string(),
};
let mut header = vec![
Line::from(vec![
Span::styled(
"─ Fleet ",
Style::default().fg(palette::WHALE_ACTION).bold(),
),
Span::styled(
"saved configurations · one operator, one roster, one scope",
Style::default().fg(palette::TEXT_MUTED),
),
]),
Line::from(""),
Line::from(Span::styled(
format!(" {selected_line}"),
Style::default().fg(palette::TEXT_SECONDARY),
)),
];
if self.banner_visible() {
header.push(Line::from(vec![Span::styled(
format!(
"{} legacy role profile(s) found — press m to migrate them into a \
Fleet (nothing is changed until you do)",
self.legacy_profile_count
),
Style::default().fg(palette::WHALE_HUMAN),
)]));
}
Paragraph::new(header)
.wrap(Wrap { trim: false })
.render(chunks[0], buf);
self.render_rows(chunks[1], buf);
}
}
impl FleetListView {
fn render_rows(&self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
if self.entries.is_empty() {
Paragraph::new(Line::from(vec![
Span::styled(
" No saved Fleets yet.",
Style::default().fg(palette::TEXT_MUTED),
),
Span::styled(
" Select a route with /model and /provider, then save it as a Fleet from \
the receipt — or press u/w to select one once it exists.",
Style::default().fg(palette::TEXT_DIM),
),
]))
.render(area, buf);
return;
}
let rows_visible = usize::from(area.height).max(1);
let scroll = self.row.saturating_sub(rows_visible.saturating_sub(1));
let mut lines = Vec::new();
for (idx, entry) in self.entries.iter().enumerate() {
if idx < scroll || idx >= scroll + rows_visible {
continue;
}
let selected = idx == self.row;
let is_selected_fleet = self
.selected
.as_ref()
.is_some_and(|sel| sel.name == entry.name && sel.scope == entry.scope);
let mut spans = Vec::new();
let marker = if is_selected_fleet { "" } else { " " };
let base = if selected {
Style::default().fg(palette::WHALE_ACTION).bold()
} else {
Style::default().fg(palette::TEXT_SECONDARY)
};
spans.push(Span::styled(marker, base));
let name = if selected {
"» ".to_string()
} else {
" ".to_string()
};
spans.push(Span::styled(format!("{name}{}", entry.name), base));
let scope_badge = match entry.scope {
FleetScope::Personal => " [user]",
FleetScope::Workspace => " [folder]",
};
spans.push(Span::styled(
scope_badge,
Style::default().fg(palette::TEXT_MUTED),
));
if entry.legacy {
spans.push(Span::styled(
" legacy format — read-only",
Style::default().fg(palette::WHALE_HUMAN),
));
} else if let Some(err) = &entry.parse_error {
spans.push(Span::styled(
format!(" unreadable: {err}"),
Style::default().fg(palette::WHALE_HUMAN),
));
}
if is_selected_fleet && !selected {
spans.push(Span::styled(
"",
Style::default().fg(palette::WHALE_ACTION),
));
}
if self.pending_delete == Some(idx) {
lines.push(Line::from(vec![Span::styled(
format!(" Delete `{}` ({})? y/n", entry.name, entry.scope.label()),
Style::default().fg(palette::WHALE_HUMAN),
)]));
} else {
lines.push(Line::from(spans));
// Second line: source + operator summary (compact).
let summary = self.entry_summary(entry);
lines.push(Line::from(Span::styled(
format!(" {summary}"),
Style::default().fg(palette::TEXT_DIM),
)));
}
}
let text = ratatui::text::Text::from(lines);
Paragraph::new(text).render(area, buf);
}
fn entry_summary(&self, entry: &FleetEntry) -> String {
if entry.legacy {
return entry.path.display().to_string();
}
// Read the file to summarize operator + members. Parsing errors are
// already shown on the row.
let Ok(text) = std::fs::read_to_string(&entry.path) else {
return entry.path.display().to_string();
};
match crate::fleet::store::FleetFile::parse(&text) {
Ok(fleet) => {
let operator = match &fleet.operator {
Some(op) => format!("{}/{}", op.provider, op.model),
None => "inherits session route".to_string(),
};
format!(
"operator: {operator} · members: {} · {}",
fleet.members.len(),
entry.path.display()
)
}
Err(err) => format!("{}{err}", entry.path.display()),
}
}
}
fn outcome_to_action(outcome: Option<FleetListOutcome>) -> ViewAction {
match outcome {
Some(FleetListOutcome::Done { message }) => {
ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { message })
}
Some(FleetListOutcome::OpenDetail { name, scope }) => {
ViewAction::Emit(ViewEvent::FleetListOpenDetailRequested { name, scope })
}
Some(FleetListOutcome::None) | None => ViewAction::None,
}
}
/// How many legacy per-role profile files exist across both scopes.
fn legacy_profile_file_count(workspace: &std::path::Path) -> usize {
let mut count = 0;
if let Ok(dir) = crate::fleet::profile::personal_agent_profile_dir() {
if let Ok(read) = std::fs::read_dir(dir) {
count += read
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "toml"))
.count();
}
}
let ws_dir = workspace.join(".codewhale").join("agents");
if let Ok(read) = std::fs::read_dir(ws_dir) {
count += read
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "toml"))
.count();
}
count
}
/// Read the `[fleet]` table for migration input. The view holds `config` only
/// at construction; the migration path re-reads via the standard loader so it
/// never works from a stale snapshot.
fn config_fleet_table() -> codewhale_config::FleetConfigToml {
crate::config::Config::default().fleet_config()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::fleet::store::{FleetFile, save_fleet};
use crate::tui::app::{App, TuiOptions};
use std::sync::OnceLock;
fn sealed_home() -> &'static std::path::Path {
static HOME: OnceLock<PathBuf> = OnceLock::new();
HOME.get_or_init(|| {
let dir = tempfile::TempDir::new().expect("temp dir").into_path();
std::fs::create_dir_all(dir.join("fleets")).expect("fleets dir");
dir
})
}
fn app_in(workspace: PathBuf) -> App {
let options = TuiOptions {
..crate::test_support::test_tui_options(workspace.clone())
};
let mut app = App::new(options, &Config::default());
app.workspace = workspace;
app
}
fn sample_fleet(name: &str) -> FleetFile {
let mut fleet = FleetFile::new(name.to_string(), None).unwrap();
fleet.operator = Some(crate::fleet::store::FleetOperator {
provider: "deepseek".to_string(),
model: "deepseek-v4-flash".to_string(),
reasoning: None,
});
fleet
}
fn save_in(workspace: &std::path::Path, scope: FleetScope, name: &str) {
let fleet = sample_fleet(name);
let _ = save_fleet(&fleet, scope, workspace).expect("save");
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
}
#[test]
fn list_renders_both_scopes_and_marks_selection() {
let _lock = crate::test_support::lock_test_env();
let prev = std::env::var_os("CODEWHALE_HOME");
// SAFETY: serialised by lock_test_env.
unsafe { std::env::set_var("CODEWHALE_HOME", sealed_home()) };
let ws = tempfile::TempDir::new().unwrap();
save_in(ws.path(), FleetScope::Personal, "DeepSeek Flash");
save_in(ws.path(), FleetScope::Workspace, "OpenAI Codex");
set_selected("OpenAI Codex", FleetScope::Workspace, ws.path()).unwrap();
let mut app = app_in(ws.path().to_path_buf());
let view = FleetListView::new(&app, &Config::default());
// Both scopes listed under their display names; the workspace one
// selected. The shared sealed home may hold other fleets, so assert
// by name+scope, never by total count.
assert!(
view.entries
.iter()
.any(|e| e.name == "DeepSeek Flash" && e.scope == FleetScope::Personal),
"{:?}",
view.entries
);
assert!(
view.entries
.iter()
.any(|e| e.name == "OpenAI Codex" && e.scope == FleetScope::Workspace),
"{:?}",
view.entries
);
let selected = view.selected.as_ref().unwrap();
assert_eq!(selected.name, "OpenAI Codex");
assert_eq!(selected.scope, FleetScope::Workspace);
// Restore env.
// SAFETY: serialised by lock_test_env.
unsafe {
match prev {
Some(v) => std::env::set_var("CODEWHALE_HOME", v),
None => std::env::remove_var("CODEWHALE_HOME"),
}
}
let _ = &mut app;
}
#[test]
fn select_user_global_writes_receipt_naming_the_file() {
let _lock = crate::test_support::lock_test_env();
let prev = std::env::var_os("CODEWHALE_HOME");
// SAFETY: serialised by lock_test_env.
unsafe { std::env::set_var("CODEWHALE_HOME", sealed_home()) };
let ws = tempfile::TempDir::new().unwrap();
save_in(ws.path(), FleetScope::Personal, "DeepSeek Flash");
let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
let action = view.handle_key(key(KeyCode::Char('u')));
let ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { message }) = action else {
panic!("expected FleetStoreChanged, got {action:?}");
};
assert!(message.contains("DeepSeek Flash"), "{message}");
assert!(message.contains("user-global"), "{message}");
assert!(message.contains("selected"), "{message}");
// The selection really persisted, at the personal scope. The
// SelectedFleet path is the fleet file the marker names.
let sel = selected_fleet(ws.path()).expect("selection");
assert_eq!(sel.name, "DeepSeek Flash");
assert_eq!(sel.scope, FleetScope::Personal);
assert!(
sel.path.ends_with("fleets/deepseek-flash.toml"),
"{:?}",
sel.path
);
// SAFETY: serialised by lock_test_env.
unsafe {
match prev {
Some(v) => std::env::set_var("CODEWHALE_HOME", v),
None => std::env::remove_var("CODEWHALE_HOME"),
}
}
}
#[test]
fn delete_requires_confirmation_and_removes_the_file() {
let _lock = crate::test_support::lock_test_env();
let ws = tempfile::TempDir::new().unwrap();
save_in(ws.path(), FleetScope::Workspace, "Temp Fleet");
let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
assert_eq!(view.entries.len(), 1);
assert_eq!(view.entries[0].name, "Temp Fleet");
// 'd' arms the confirmation; the file is still there.
assert!(matches!(
view.handle_key(key(KeyCode::Char('d'))),
ViewAction::None
));
assert!(
list_fleets(ws.path())
.iter()
.any(|e| e.name == "Temp Fleet")
);
// 'n' cancels.
view.handle_key(key(KeyCode::Char('n')));
assert!(
list_fleets(ws.path())
.iter()
.any(|e| e.name == "Temp Fleet")
);
// 'd' then 'y' deletes and emits a receipt naming the removed path.
view.handle_key(key(KeyCode::Char('d')));
let action = view.handle_key(key(KeyCode::Char('y')));
let ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { message }) = action else {
panic!("expected FleetStoreChanged, got {action:?}");
};
assert!(message.contains("Deleted Fleet `Temp Fleet`"), "{message}");
assert!(list_fleets(ws.path()).is_empty());
}
#[test]
fn legacy_entry_opens_a_pager_instead_of_editing() {
let _lock = crate::test_support::lock_test_env();
let prev = std::env::var_os("CODEWHALE_HOME");
// SAFETY: serialised by lock_test_env.
unsafe { std::env::set_var("CODEWHALE_HOME", sealed_home()) };
let ws = tempfile::TempDir::new().unwrap();
// A legacy exact fleet file (workflow schema) in the personal dir.
std::fs::create_dir_all(sealed_home().join("fleets")).unwrap();
std::fs::write(
sealed_home().join("fleets/stopship.toml"),
r#"schema = "exact"
schema_revision = 1
name = "stopship"
members = []"#,
)
.unwrap();
let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
let idx = view
.entries
.iter()
.position(|e| e.name == "stopship")
.expect("legacy entry listed");
view.row = idx;
let entry = view.selected_entry().expect("legacy entry listed");
assert!(entry.legacy);
let action = view.handle_key(key(KeyCode::Enter));
assert!(
matches!(action, ViewAction::Emit(ViewEvent::OpenTextPager { .. })),
"legacy entry must open a read-only pager: {action:?}"
);
// SAFETY: serialised by lock_test_env.
unsafe {
match prev {
Some(v) => std::env::set_var("CODEWHALE_HOME", v),
None => std::env::remove_var("CODEWHALE_HOME"),
}
}
}
#[test]
fn migration_banner_appears_only_when_needed_and_migrates() {
let _lock = crate::test_support::lock_test_env();
let prev = std::env::var_os("CODEWHALE_HOME");
// SAFETY: serialised by lock_test_env.
unsafe { std::env::set_var("CODEWHALE_HOME", sealed_home()) };
let ws = tempfile::TempDir::new().unwrap();
// No legacy profiles: no banner.
let view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
assert!(!view.banner_visible());
// A workspace legacy role profile.
let agents = ws.path().join(".codewhale/agents");
std::fs::create_dir_all(&agents).unwrap();
std::fs::write(
agents.join("scout.toml"),
r#"id = "scout"
role_hint = "scout"
model = "deepseek-v4-flash"
provider = "deepseek"
"#,
)
.unwrap();
let mut view = FleetListView::new(&app_in(ws.path().to_path_buf()), &Config::default());
assert!(
view.banner_visible(),
"banner must show with legacy profiles"
);
let action = view.handle_key(key(KeyCode::Char('m')));
assert!(
matches!(action, ViewAction::Emit(ViewEvent::OpenTextPager { .. })),
"migration must open the receipt pager: {action:?}"
);
// The Default fleet now exists and is the user-global selection.
let entries = list_fleets(ws.path());
assert!(
entries.iter().any(|e| e.name == "Default" && !e.legacy),
"{entries:?}"
);
let sel = selected_fleet(ws.path()).expect("selection after migration");
assert_eq!(sel.name, "Default");
// SAFETY: serialised by lock_test_env.
unsafe {
match prev {
Some(v) => std::env::set_var("CODEWHALE_HOME", v),
None => std::env::remove_var("CODEWHALE_HOME"),
}
}
}
}
+2 -2
View File
@@ -277,7 +277,7 @@ impl FleetSetupSnapshot {
/// use their canonical id; named custom routes keep their table key so saved
/// Fleet profiles can rebuild the same child client.
/// Callers derive a human-readable label from it for UI text.
fn cross_provider_model_routes(
pub(super) fn cross_provider_model_routes(
config: &Config,
active: crate::config::ApiProvider,
health: &crate::provider_readiness::ProviderReadinessSnapshot,
@@ -388,7 +388,7 @@ fn push_unique_model(models: &mut Vec<String>, model: &str) {
/// Human-readable label for a built-in provider id, falling back to an exact
/// named custom id verbatim.
fn provider_display_label(provider_id: &str) -> String {
pub(super) fn provider_display_label(provider_id: &str) -> String {
crate::config::ApiProvider::parse(provider_id)
.filter(|provider| provider.as_str() == provider_id)
.map(|provider| provider.display_name().to_string())
+16
View File
@@ -29,6 +29,8 @@ use crate::tui::history::{HistoryCell, SubAgentCell, summarize_tool_output};
use crate::tui::menu_style;
use crate::tui::widgets::agent_card::AgentLifecycle;
pub mod fleet_detail;
pub mod fleet_list;
pub mod fleet_roster;
pub mod fleet_setup;
pub mod mode_picker;
@@ -52,6 +54,8 @@ pub enum ModalKind {
ModePicker,
FleetRoster,
FleetSetup,
FleetList,
FleetDetail,
HotbarSetup,
SetupWizard,
FilePicker,
@@ -898,6 +902,17 @@ pub enum ViewEvent {
},
/// Open the live workers tab from the unified Fleet surface.
FleetRosterOpenWorkersRequested,
/// The Fleet list view asks the host to open a saved Fleet's detail view.
FleetListOpenDetailRequested {
name: String,
scope: crate::fleet::store::FleetScope,
},
/// A Fleet store mutation happened (select/save/delete/rename/copy).
/// The message is the exact receipt; the host refreshes roster state.
FleetStoreChanged {
message: String,
},
/// Emitted by the fleet setup Review step after the user previewed a
/// model-drafted profile and pressed the explicit ratify key. The host
/// renders TOML deterministically from the validated draft and persists it
@@ -1002,6 +1017,7 @@ pub trait ModalView: std::any::Any {
fn handle_paste(&mut self, _text: &str) -> bool {
false
}
fn handle_mouse(&mut self, _mouse: MouseEvent) -> ViewAction {
ViewAction::None
}