use std::path::{Path, PathBuf};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap},
};
use crate::config::Config;
use crate::palette;
use crate::tui::app::App;
use crate::tui::views::{
ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, centered_modal_area,
render_modal_footer, render_modal_surface, truncate_view_text,
};
const PROFILE_DIR: &str = ".codewhale/agents";
struct Choice {
label: &'static str,
summary: &'static str,
description: &'static str,
}
const CHOICE_LIST_WIDTH: u16 = 22;
const CHOICE_DETAIL_MIN_WIDTH: u16 = 58;
const CHOICE_TWO_COLUMN_MIN_WIDTH: u16 = CHOICE_LIST_WIDTH + CHOICE_DETAIL_MIN_WIDTH;
const ROLES: [Choice; 8] = [
Choice {
label: "manager",
summary: "Plan & split queued work",
description: "Coordinates the Fleet run: plans the work, splits it into bounded tasks, and dispatches workers.",
},
Choice {
label: "scout",
summary: "Read-first research",
description: "Research and repo reconnaissance. Reads and summarizes before anything is written.",
},
Choice {
label: "builder",
summary: "Implements bounded changes",
description: "Implements changes strictly inside its assigned task scope; writes only what the slice needs.",
},
Choice {
label: "reviewer",
summary: "Read-only review",
description: "Checks regressions, tests, and diffs. Read-only — it never writes.",
},
Choice {
label: "verifier",
summary: "Runs focused validation",
description: "Runs targeted validation and reports receipts back to the orchestrator.",
},
Choice {
label: "synthesizer",
summary: "Reduce receipts to handoff",
description: "Turns worker receipts into bounded handoff state instead of raw transcript replay.",
},
Choice {
label: "general",
summary: "General-purpose worker",
description: "A flexible worker with no specialized posture — use it when the task doesn't fit a named role.",
},
Choice {
label: "custom",
summary: "Author a profile by hand",
description: "Define the posture yourself in a workspace agent TOML profile under .codewhale/agents/.",
},
];
const MODEL_INHERIT: Choice = Choice {
label: "inherit",
summary: "Same model as now",
description: "Reuse the active provider, model, and reasoning for this worker — the operator's route. Recommended default.",
};
#[derive(Debug, Clone)]
pub struct FleetSetupSnapshot {
workspace: PathBuf,
locale: crate::localization::Locale,
provider_ready: bool,
provider: String,
model: String,
reasoning: String,
subagents_enabled: bool,
max_subagents: usize,
launch_concurrency: usize,
max_admitted: usize,
subagent_spawn_depth: u32,
fleet_spawn_depth: u32,
token_budget: Option<u64>,
api_timeout_secs: u64,
heartbeat_timeout_secs: u64,
roster_members: Vec<(String, String)>,
available_models: Vec<&'static str>,
}
impl FleetSetupSnapshot {
#[must_use]
pub fn from_app(app: &App, config: &Config) -> Self {
let provider = app.api_provider.display_name().to_string();
let model = if app.auto_model {
app.last_effective_model
.as_deref()
.map(|effective| format!("auto -> {effective}"))
.unwrap_or_else(|| "auto".to_string())
} else {
app.model.clone()
};
let fleet_spawn_depth = config
.fleet
.as_ref()
.map(|fleet| fleet.exec.max_spawn_depth)
.unwrap_or_else(|| codewhale_config::FleetExecConfig::default().max_spawn_depth)
.min(codewhale_config::MAX_SPAWN_DEPTH_CEILING);
let roster_members =
crate::fleet::roster::FleetRoster::load(&config.fleet_config(), &app.workspace)
.members()
.iter()
.map(|member| (member.id.to_lowercase(), member.origin.to_string()))
.collect();
Self {
workspace: app.workspace.clone(),
locale: app.ui_locale,
provider_ready: crate::config::has_api_key_for(config, app.api_provider),
provider,
model,
reasoning: app.reasoning_effort_display_label(),
subagents_enabled: config.subagents_enabled_for_provider(app.api_provider),
max_subagents: config.max_subagents_for_provider(app.api_provider),
launch_concurrency: config.launch_concurrency_for_provider(app.api_provider),
max_admitted: config.max_admitted_subagents_for_provider(app.api_provider),
subagent_spawn_depth: config.subagent_max_spawn_depth_for_provider(app.api_provider),
fleet_spawn_depth,
token_budget: config.subagent_token_budget_for_provider(app.api_provider),
api_timeout_secs: config.subagent_api_timeout_secs_for_provider(app.api_provider),
heartbeat_timeout_secs: config
.subagent_heartbeat_timeout_secs_for_provider(app.api_provider),
roster_members,
available_models: crate::config::model_completion_names_for_provider(app.api_provider),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Step {
Role,
Model,
Review,
}
pub struct FleetSetupView {
snapshot: FleetSetupSnapshot,
step: Step,
role_idx: usize,
model_idx: usize,
review_scroll: usize,
model_draft: Option<Box<crate::fleet::profile::FleetProfileDraft>>,
model_draft_label: Option<String>,
model_choices: Vec<Choice>,
}
impl FleetSetupView {
#[must_use]
pub fn new(app: &App, config: &Config) -> Self {
Self::from_snapshot(FleetSetupSnapshot::from_app(app, config))
}
fn from_snapshot(snapshot: FleetSetupSnapshot) -> Self {
let mut model_choices = vec![MODEL_INHERIT];
for &name in &snapshot.available_models {
model_choices.push(Choice {
label: name,
summary: "Pin this model",
description: "Route this worker to this specific model on the active provider instead of inheriting the session route.",
});
}
Self {
snapshot,
step: Step::Role,
role_idx: 0,
model_idx: 0,
review_scroll: 0,
model_draft: None,
model_draft_label: None,
model_choices,
}
}
pub fn install_model_draft(
&mut self,
draft: Box<crate::fleet::profile::FleetProfileDraft>,
model_label: String,
) -> (String, String) {
let (title, header) = match self.snapshot.locale {
crate::localization::Locale::ZhHans => (
format!("Fleet 配置 — 由 {model_label} 起草(按 g 批准)"),
format!(
"# .codewhale/agents/{}\n# 由 {model_label} 起草,并由 CodeWhale 校验与限界。\n# 权限保持在 Fleet 底线:无 shell、无 trust、需审批。\n# 在向导中按 g 之前不会保存任何内容。\n\n",
draft.file_name()
),
),
_ => (
format!("Fleet profile — draft by {model_label} (g ratifies)"),
format!(
"# .codewhale/agents/{}\n# Drafted by {model_label}, validated and bounded by CodeWhale.\n# Permissions stay at the fleet floor: no shell, no trust, approval required.\n# Nothing is saved until you press g in the wizard.\n\n",
draft.file_name()
),
),
};
let content = format!("{header}{}", draft.render_toml());
self.model_draft = Some(draft);
self.model_draft_label = Some(model_label);
(title, content)
}
fn selected_role(&self) -> &'static str {
ROLES[self.role_idx.min(ROLES.len() - 1)].label
}
fn roster_override_note(&self) -> Option<String> {
let role = self.selected_role().to_lowercase();
self.snapshot
.roster_members
.iter()
.find(|(id, _)| *id == role)
.map(|(id, origin)| format!("Overrides the {origin} '{id}' roster member."))
}
fn selected_model(&self) -> Option<String> {
match self.model_choices.get(self.model_idx) {
Some(choice) if choice.label != "inherit" => Some(choice.label.to_string()),
_ => None,
}
}
fn step_len(&self) -> usize {
match self.step {
Step::Role => ROLES.len(),
Step::Model => self.model_choices.len(),
Step::Review => 0,
}
}
fn move_up(&mut self) {
match self.step {
Step::Role => {
self.role_idx = self.role_idx.saturating_sub(1);
self.discard_model_draft();
}
Step::Model => {
self.model_idx = self.model_idx.saturating_sub(1);
self.discard_model_draft();
}
Step::Review => self.review_scroll = self.review_scroll.saturating_sub(1),
}
}
fn discard_model_draft(&mut self) {
self.model_draft = None;
self.model_draft_label = None;
}
fn move_down(&mut self) {
match self.step {
Step::Role => {
self.role_idx = (self.role_idx + 1).min(self.step_len().saturating_sub(1));
self.discard_model_draft();
}
Step::Model => {
self.model_idx = (self.model_idx + 1).min(self.step_len().saturating_sub(1));
self.discard_model_draft();
}
Step::Review => self.review_scroll = self.review_scroll.saturating_add(1),
}
}
fn advance(&mut self) -> ViewAction {
match self.step {
Step::Role => {
self.step = Step::Model;
ViewAction::None
}
Step::Model => {
self.step = Step::Review;
self.review_scroll = 0;
ViewAction::None
}
Step::Review => self.preview_starter_profile_action(),
}
}
fn back(&mut self) -> ViewAction {
match self.step {
Step::Role => ViewAction::None,
Step::Model => {
self.step = Step::Role;
ViewAction::None
}
Step::Review => {
self.step = Step::Model;
ViewAction::None
}
}
}
fn preview_starter_profile_action(&mut self) -> ViewAction {
let draft = self.starter_profile_draft();
let (title, header) = match self.snapshot.locale {
crate::localization::Locale::ZhHans => (
"Fleet 配置 — 起始草案(Enter/g 批准)".to_string(),
format!(
"# .codewhale/agents/{}\n# CodeWhale 根据当前角色和模型选择确定性渲染。\n# 权限保持在 Fleet 底线:无 shell、无 trust、需审批。\n# 在向导中按 Enter 或 g 之前不会保存任何内容。\n\n",
draft.file_name()
),
),
_ => (
"Fleet profile — starter draft (Enter/g ratifies)".to_string(),
format!(
"# .codewhale/agents/{}\n# Deterministic starter profile rendered by CodeWhale from your role/model choices.\n# Permissions stay at the fleet floor: no shell, no trust, approval required.\n# Nothing is saved until you press Enter or g in the wizard.\n\n",
draft.file_name()
),
),
};
let content = format!("{header}{}", draft.render_toml());
self.model_draft = Some(draft);
self.model_draft_label = Some("CodeWhale starter".to_string());
ViewAction::Emit(ViewEvent::OpenTextPager { title, content })
}
fn starter_profile_draft(&self) -> Box<crate::fleet::profile::FleetProfileDraft> {
let role = &ROLES[self.role_idx.min(ROLES.len() - 1)];
Box::new(crate::fleet::profile::FleetProfileDraft {
id: profile_file_stem(role.label),
display_name: Some(role.label.to_string()),
description: Some(format!("{} - {}", role.summary, role.description)),
role_hint: role.label.to_string(),
model_class_hint: None,
model: self.selected_model(),
instructions: Some(format!(
"Role: {}. Work only within the assigned Fleet slice. Report concise evidence and stop when the assignment is complete. Do not widen permissions, trust, route configuration, or topology.",
role.label
)),
})
}
fn footer_hints(&self) -> Vec<ActionHint> {
let mut hints = Vec::new();
match self.step {
Step::Role => {
hints.push(ActionHint::new("↑/↓", "choose"));
hints.push(ActionHint::new("Enter", "next"));
}
Step::Model => {
hints.push(ActionHint::new("↑/↓", "choose"));
hints.push(ActionHint::new("Enter", "next"));
hints.push(ActionHint::new("←", "back"));
}
Step::Review => {
hints.push(ActionHint::new("↑/↓", "scroll"));
if self.model_draft.is_some() {
hints.push(ActionHint::new("Enter", "ratify"));
hints.push(ActionHint::new("g", "ratify draft"));
hints.push(ActionHint::new("m", "redraft"));
} else if self.snapshot.provider_ready {
hints.push(ActionHint::new("Enter", "preview"));
hints.push(ActionHint::new("m", "model draft"));
} else {
hints.push(ActionHint::new("Enter", "preview"));
}
hints.push(ActionHint::new("←", "back"));
}
}
hints.push(ActionHint::new("Esc", "cancel"));
hints
}
}
impl ModalView for FleetSetupView {
fn kind(&self) -> ModalKind {
ModalKind::FleetSetup
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
KeyCode::Up | KeyCode::Char('k') => {
self.move_up();
ViewAction::None
}
KeyCode::Down | KeyCode::Char('j') => {
self.move_down();
ViewAction::None
}
KeyCode::Char('m') if self.step == Step::Review && self.snapshot.provider_ready => {
ViewAction::Emit(ViewEvent::FleetProfileModelDraftRequested {
role: self.selected_role().to_string(),
model: self
.selected_model()
.unwrap_or_else(|| "inherit".to_string()),
locale: self.snapshot.locale,
})
}
KeyCode::Char('g') if self.step == Step::Review => match self.model_draft.clone() {
Some(draft) => {
ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft })
}
None => ViewAction::None,
},
KeyCode::Enter | KeyCode::Right | KeyCode::Char('l')
if self.step == Step::Review && self.model_draft.is_some() =>
{
match self.model_draft.clone() {
Some(draft) => {
ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested {
draft,
})
}
None => ViewAction::None,
}
}
KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => self.advance(),
KeyCode::Left | KeyCode::Char('h') => self.back(),
KeyCode::Home => {
self.review_scroll = 0;
ViewAction::None
}
KeyCode::PageUp => {
self.review_scroll = self.review_scroll.saturating_sub(8);
ViewAction::None
}
KeyCode::PageDown => {
self.review_scroll = self.review_scroll.saturating_add(8);
ViewAction::None
}
_ => ViewAction::None,
}
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let popup_area = centered_modal_area(area, 96, 30, 60, 16);
render_modal_surface(area, popup_area, buf);
let step_no = match self.step {
Step::Role => 1,
Step::Model => 2,
Step::Review => 3,
};
let block = Block::default()
.title(Line::from(Span::styled(
" Fleet setup — your agent team ",
Style::default()
.fg(palette::WHALE_ACCENT_PRIMARY)
.add_modifier(Modifier::BOLD),
)))
.title_bottom(
Line::from(Span::styled(
format!(" Step {step_no}/3 "),
Style::default().fg(palette::TEXT_MUTED),
))
.alignment(ratatui::layout::Alignment::Right),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::DEEPSEEK_INK))
.padding(Padding::uniform(1));
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let hints = self.footer_hints();
let content = render_modal_footer(inner, buf, &hints);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(content);
self.render_header(chunks[0], buf);
match self.step {
Step::Role => {
let mut context = vec![
"Fleet runs sub-agents that delegate work. Pick the role this".to_string(),
"team member should play. It becomes the profile role_hint.".to_string(),
];
if let Some(note) = self.roster_override_note() {
context.push(note);
}
render_choice_step(chunks[1], buf, &ROLES, self.role_idx, &context)
}
Step::Model => render_choice_step(
chunks[1],
buf,
&self.model_choices,
self.model_idx,
&[
format!(
"Current route: {} / {} · reasoning {}",
self.snapshot.provider, self.snapshot.model, self.snapshot.reasoning
),
match self.selected_model() {
Some(model) => format!("This worker will run on {model}."),
None => "This worker inherits your current route.".to_string(),
},
],
),
Step::Review => self.render_review(chunks[1], buf),
}
}
}
impl FleetSetupView {
fn render_header(&self, area: Rect, buf: &mut Buffer) {
let (title, subtitle) = match self.step {
Step::Role => (
"Choose a team role",
"Each Fleet member plays one role in the delegation.",
),
Step::Model => (
"Choose a model",
"Pick this worker's model, or inherit your current route.",
),
Step::Review => (
"Review & start",
"Confirm the posture below, preview exact TOML, then ratify to save.",
),
};
let lines = vec![
Line::from(Span::styled(
title,
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
)),
Line::from(Span::styled(
subtitle,
Style::default().fg(palette::TEXT_MUTED),
)),
];
Paragraph::new(lines)
.wrap(Wrap { trim: true })
.render(area, buf);
}
fn render_review(&self, area: Rect, buf: &mut Buffer) {
let role = &ROLES[self.role_idx.min(ROLES.len() - 1)];
let (profile_value, _) = profile_file_status(&self.snapshot.workspace);
let file_stem = profile_file_stem(role.label);
let token_budget = self
.snapshot
.token_budget
.map(|budget| format!("{budget} tokens"))
.unwrap_or_else(|| "unbounded".to_string());
let mut lines: Vec<Line> = Vec::new();
let section = |lines: &mut Vec<Line>, label: &str, body: String| {
lines.push(Line::from(Span::styled(
label.to_string(),
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
)));
lines.push(Line::from(Span::styled(
body,
Style::default().fg(palette::TEXT_PRIMARY),
)));
lines.push(Line::from(""));
};
section(
&mut lines,
"Role",
match self.roster_override_note() {
Some(note) => format!("{} — {} · {note}", role.label, role.summary),
None => format!("{} — {}", role.label, role.summary),
},
);
section(
&mut lines,
"Model",
match self.selected_model() {
Some(model) => format!("{model} · provider {}", self.snapshot.provider),
None => format!(
"inherit · route {} / {}, reasoning {}",
self.snapshot.provider, self.snapshot.model, self.snapshot.reasoning
),
},
);
section(
&mut lines,
"Permissions",
"Inherit the parent envelope and narrow only. Children cannot widen approval, trust, or secrets, and required approvals stay on.".to_string(),
);
section(
&mut lines,
"Tools",
"Read tools by default; write tools for builders within scope; shell stays policy-gated; artifacts and receipts stay inspectable.".to_string(),
);
section(
&mut lines,
"Workspace & org",
format!(
"{} · sub-agents {} ({} concurrent, {} launch slots, {} admitted) · recursion agent {} / fleet {} (ceiling {})",
self.snapshot.workspace.display(),
if self.snapshot.subagents_enabled {
"enabled"
} else {
"disabled"
},
self.snapshot.max_subagents,
self.snapshot.launch_concurrency,
self.snapshot.max_admitted,
self.snapshot.subagent_spawn_depth,
self.snapshot.fleet_spawn_depth,
codewhale_config::MAX_SPAWN_DEPTH_CEILING,
),
);
section(
&mut lines,
"Review policy",
format!(
"Budget {token_budget} · {}s api, {}s heartbeat. Fleet -> exec runs the workers; /fleet status (or /subagents) inspects the ledger.",
self.snapshot.api_timeout_secs, self.snapshot.heartbeat_timeout_secs
),
);
section(
&mut lines,
"Profile",
format!(
"{PROFILE_DIR}/{file_stem}.toml · {profile_value} present. Start previews a deterministic starter profile; nothing is written to disk until ratification.",
),
);
let wrap_width = usize::from(area.width).max(1);
let visual_rows: usize = lines
.iter()
.map(|line| line.width().div_ceil(wrap_width).max(1))
.sum();
let max_scroll = visual_rows.saturating_sub(usize::from(area.height).max(1));
let scroll = self.review_scroll.min(max_scroll);
Paragraph::new(lines)
.wrap(Wrap { trim: true })
.scroll((scroll as u16, 0))
.render(area, buf);
}
}
fn render_choice_step(
area: Rect,
buf: &mut Buffer,
choices: &[Choice],
selected: usize,
context: &[String],
) {
if area.width == 0 || area.height == 0 {
return;
}
let (list_area, detail_area) = if area.width >= CHOICE_TWO_COLUMN_MIN_WIDTH {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(CHOICE_LIST_WIDTH),
Constraint::Min(CHOICE_DETAIL_MIN_WIDTH),
])
.split(area);
(cols[0], cols[1])
} else {
let list_height = (choices.len() as u16 + 1).min(area.height.saturating_sub(1).max(1));
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(list_height), Constraint::Min(1)])
.split(area);
(rows[0], rows[1])
};
let list_width = usize::from(list_area.width);
let mut list_lines: Vec<Line> = Vec::with_capacity(choices.len());
for (idx, choice) in choices.iter().enumerate() {
let is_selected = idx == selected;
let pointer = if is_selected { "> " } else { " " };
let style = if is_selected {
Style::default()
.fg(palette::SELECTION_TEXT)
.bg(palette::SELECTION_BG)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
list_lines.push(Line::from(Span::styled(
truncate_view_text(&format!("{pointer}{}", choice.label), list_width),
style,
)));
}
Paragraph::new(list_lines).render(list_area, buf);
let choice = &choices[selected.min(choices.len().saturating_sub(1))];
let mut detail_lines: Vec<Line> = vec![
Line::from(Span::styled(
choice.summary,
Style::default().fg(palette::WHALE_ACCENT_PRIMARY).bold(),
)),
Line::from(""),
Line::from(Span::styled(
choice.description,
Style::default().fg(palette::TEXT_PRIMARY),
)),
];
if !context.is_empty() {
detail_lines.push(Line::from(""));
for entry in context {
detail_lines.push(Line::from(Span::styled(
entry.clone(),
Style::default().fg(palette::TEXT_MUTED),
)));
}
}
Paragraph::new(detail_lines)
.wrap(Wrap { trim: true })
.render(detail_area, buf);
}
fn profile_file_status(workspace: &Path) -> (String, String) {
let dir = workspace.join(PROFILE_DIR);
if !dir.exists() {
return (
"0 files".to_string(),
format!("create {PROFILE_DIR}/*.toml"),
);
}
if !dir.is_dir() {
return (
"blocked".to_string(),
format!("{} is not a dir", dir.display()),
);
}
let count = std::fs::read_dir(&dir)
.ok()
.into_iter()
.flat_map(|entries| entries.flatten())
.filter(|entry| entry.path().extension().and_then(|value| value.to_str()) == Some("toml"))
.count();
if count == 1 {
("1 file".to_string(), PROFILE_DIR.to_string())
} else {
(format!("{count} files"), PROFILE_DIR.to_string())
}
}
fn profile_file_stem(role: &str) -> String {
let stem: String = role
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let stem = stem.trim_matches('-').to_ascii_lowercase();
if stem.is_empty() {
"custom".to_string()
} else {
stem
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::views::ViewStack;
use crossterm::event::KeyModifiers;
use unicode_width::UnicodeWidthStr;
const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
fn snapshot() -> FleetSetupSnapshot {
FleetSetupSnapshot {
workspace: PathBuf::from("/tmp/codewhale-test-workspace"),
locale: crate::localization::Locale::En,
provider_ready: true,
provider: "DeepSeek".to_string(),
model: "deepseek-v4-pro".to_string(),
reasoning: "Auto".to_string(),
subagents_enabled: true,
max_subagents: 8,
launch_concurrency: 3,
max_admitted: 20,
subagent_spawn_depth: 3,
fleet_spawn_depth: 3,
token_budget: Some(100_000),
api_timeout_secs: 120,
heartbeat_timeout_secs: 300,
roster_members: crate::fleet::roster::FleetRoster::built_ins_only()
.members()
.iter()
.map(|member| (member.id.to_lowercase(), member.origin.to_string()))
.collect(),
available_models: vec!["deepseek-v4-pro", "deepseek-v4-flash"],
}
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn sample_draft() -> Box<crate::fleet::profile::FleetProfileDraft> {
let crate::fleet::profile::UntrustedProfileParse::Drafted(draft) =
crate::fleet::profile::FleetProfileDraft::from_untrusted_json(
r#"{"id":"reviewer","role_hint":"reviewer","description":"Reviews diffs.","instructions":"Read. Report. Stop."}"#,
)
else {
panic!("sample draft should parse");
};
draft
}
fn to_review(view: &mut FleetSetupView) {
view.handle_key(key(KeyCode::Enter));
view.handle_key(key(KeyCode::Enter));
assert_eq!(view.step, Step::Review);
}
#[test]
fn review_step_m_requests_model_draft_with_current_answers() {
let mut view = FleetSetupView::from_snapshot(snapshot());
to_review(&mut view);
let action = view.handle_key(key(KeyCode::Char('m')));
let ViewAction::Emit(ViewEvent::FleetProfileModelDraftRequested {
role,
model,
locale,
}) = action
else {
panic!("expected model draft request");
};
assert!(!role.is_empty());
assert!(!model.is_empty());
assert_eq!(locale, crate::localization::Locale::En);
}
#[test]
fn ratify_is_inert_without_a_draft_and_commits_with_one() {
let mut view = FleetSetupView::from_snapshot(snapshot());
to_review(&mut view);
assert!(matches!(
view.handle_key(key(KeyCode::Char('g'))),
ViewAction::None
));
let (title, content) = view.install_model_draft(sample_draft(), "GLM-5.2".to_string());
assert!(title.contains("GLM-5.2"));
assert!(content.contains("id = \"reviewer\""), "{content}");
assert!(content.contains("Nothing is saved until"), "{content}");
let action = view.handle_key(key(KeyCode::Char('g')));
let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft }) =
action
else {
panic!("expected ratify commit event");
};
assert_eq!(draft.id, "reviewer");
}
#[test]
fn changing_answers_discards_a_stale_draft() {
let mut view = FleetSetupView::from_snapshot(snapshot());
to_review(&mut view);
let _ = view.install_model_draft(sample_draft(), "GLM-5.2".to_string());
assert!(view.model_draft.is_some());
view.handle_key(key(KeyCode::Left));
view.handle_key(key(KeyCode::Left));
assert_eq!(view.step, Step::Role);
view.handle_key(key(KeyCode::Down));
assert!(view.model_draft.is_none());
to_review(&mut view);
assert!(matches!(
view.handle_key(key(KeyCode::Char('g'))),
ViewAction::None
));
}
#[test]
fn arrows_move_within_step_and_enter_advances() {
let mut view = FleetSetupView::from_snapshot(snapshot());
assert_eq!(view.step, Step::Role);
view.handle_key(key(KeyCode::Down));
assert_eq!(view.role_idx, 1);
view.handle_key(key(KeyCode::Enter));
assert_eq!(view.step, Step::Model);
view.handle_key(key(KeyCode::Down));
assert_eq!(view.model_idx, 1);
view.handle_key(key(KeyCode::Enter));
assert_eq!(view.step, Step::Review);
view.handle_key(key(KeyCode::Left));
assert_eq!(view.step, Step::Model);
view.handle_key(key(KeyCode::Left));
assert_eq!(view.step, Step::Role);
}
#[test]
fn esc_cancels_from_any_step() {
let mut view = FleetSetupView::from_snapshot(snapshot());
view.handle_key(key(KeyCode::Enter)); let action = view.handle_key(key(KeyCode::Esc));
assert!(matches!(action, ViewAction::Close));
}
#[test]
fn start_on_review_previews_and_ratifies_starter_profile_for_selection() {
let mut view = FleetSetupView::from_snapshot(snapshot());
view.handle_key(key(KeyCode::Down));
view.handle_key(key(KeyCode::Down));
view.handle_key(key(KeyCode::Enter)); view.handle_key(key(KeyCode::Down));
view.handle_key(key(KeyCode::Enter));
let action = view.handle_key(key(KeyCode::Enter)); match action {
ViewAction::Emit(ViewEvent::OpenTextPager { title, content }) => {
assert!(title.contains("starter draft"));
assert!(content.contains("# .codewhale/agents/builder.toml"));
assert!(content.contains("id = \"builder\""));
assert!(content.contains("role_hint = \"builder\""));
assert!(content.contains("model = \"deepseek-v4-pro\""));
assert!(content.contains("Nothing is saved until"));
for forbidden in ["provider", "base_url", "api_key"] {
assert!(
!content.contains(forbidden),
"starter profile must not carry {forbidden}: {content}"
);
}
}
other => panic!("expected starter profile preview, got {other:?}"),
}
assert!(view.model_draft.is_some());
let action = view.handle_key(key(KeyCode::Enter)); let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft }) =
action
else {
panic!("expected ratified starter draft");
};
assert_eq!(draft.id, "builder");
assert_eq!(draft.role_hint, "builder");
assert_eq!(draft.model.as_deref(), Some("deepseek-v4-pro"));
}
#[test]
fn role_and_review_steps_note_roster_overrides() {
let mut view = FleetSetupView::from_snapshot(snapshot());
for _ in 0..3 {
view.handle_key(key(KeyCode::Down));
}
assert_eq!(view.selected_role(), "reviewer");
assert_eq!(
view.roster_override_note().as_deref(),
Some("Overrides the built-in 'reviewer' roster member.")
);
let role_step = render_through_stack(
|| {
let mut v = FleetSetupView::from_snapshot(snapshot());
for _ in 0..3 {
v.handle_key(key(KeyCode::Down));
}
v
},
120,
40,
)
.join("\n");
assert!(
role_step.contains("Overrides the built-in 'reviewer'"),
"{role_step}"
);
let review = render_through_stack(
|| {
let mut v = FleetSetupView::from_snapshot(snapshot());
for _ in 0..3 {
v.handle_key(key(KeyCode::Down));
}
v.step = Step::Review;
v
},
120,
40,
)
.join("\n");
assert!(
review.contains("Overrides the built-in 'reviewer'"),
"{review}"
);
let mut custom_view = FleetSetupView::from_snapshot(snapshot());
for _ in 0..7 {
custom_view.handle_key(key(KeyCode::Down));
}
assert_eq!(custom_view.selected_role(), "custom");
assert!(custom_view.roster_override_note().is_none());
}
#[test]
fn default_selection_targets_manager_inherit() {
let view = FleetSetupView::from_snapshot(snapshot());
let draft = view.starter_profile_draft();
assert_eq!(draft.file_name(), "manager.toml");
assert_eq!(draft.role_hint, "manager");
assert!(draft.model.is_none());
assert!(draft.model_class_hint.is_none());
assert!(
draft
.instructions
.as_deref()
.is_some_and(|text| text.contains("assigned Fleet slice"))
);
}
#[test]
fn role_step_keeps_list_and_detail_separate_at_80_columns() {
let rows = render_through_stack(|| FleetSetupView::from_snapshot(snapshot()), 80, 24);
let text = rows.join("\n");
let manager_row = rows
.iter()
.position(|row| row.contains("> manager"))
.expect("manager row should render");
let custom_row = rows
.iter()
.position(|row| row.contains(" custom"))
.expect("custom row should render");
let summary_row = rows
.iter()
.position(|row| row.contains("Plan & split queued work"))
.expect("selected role summary should render");
let description_row = rows
.iter()
.position(|row| row.contains("Coordinates the Fleet run"))
.expect("selected role description should render");
assert!(
manager_row < custom_row,
"expected the full role list before details:\n{text}"
);
assert!(
custom_row < summary_row,
"selected summary must not share a row with role names:\n{text}"
);
assert!(
custom_row < description_row,
"selected description must render below the list:\n{text}"
);
for row in &rows[manager_row..=custom_row] {
assert!(
!row.contains("Plan & split queued work")
&& !row.contains("Coordinates the Fleet run")
&& !row.contains("Fleet runs sub-agents"),
"role list row contains detail copy at 80 columns: {row:?}\n{text}"
);
}
}
fn render_through_stack(view_at: impl Fn() -> FleetSetupView, w: u16, h: u16) -> Vec<String> {
let area = Rect::new(0, 0, w, h);
let mut buf = Buffer::empty(area);
for y in 0..h {
for x in 0..w {
buf[(x, y)].set_symbol("X");
}
}
let mut stack = ViewStack::new();
stack.push(view_at());
stack.render(area, &mut buf);
(0..h)
.map(|y| {
(0..w)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect()
}
#[test]
fn fleet_setup_is_usable_and_opaque_at_blocker_sizes() {
type Builder = (&'static str, fn() -> FleetSetupView);
let builders: [Builder; 3] = [
("role", || FleetSetupView::from_snapshot(snapshot())),
("model", || {
let mut v = FleetSetupView::from_snapshot(snapshot());
v.step = Step::Model;
v
}),
("review", || {
let mut v = FleetSetupView::from_snapshot(snapshot());
v.step = Step::Review;
v
}),
];
for (label, make) in builders {
for (w, h) in BLOCKER_SIZES {
let rows = render_through_stack(make, w, h);
let text = rows.join("\n");
assert!(
!text.contains('X'),
"{label} {w}x{h}: background bleed-through"
);
assert!(text.contains("cancel"), "{label} {w}x{h}: missing footer");
assert!(
text.contains("agent team"),
"{label} {w}x{h}: missing framing"
);
for (y, row) in rows.iter().enumerate() {
assert!(
UnicodeWidthStr::width(row.trim_end()) <= w as usize,
"{label} {w}x{h}: row {y} overflows: {row:?}"
);
}
}
}
}
#[test]
fn review_lists_model_permissions_tools_and_scope() {
let top = render_through_stack(
|| {
let mut v = FleetSetupView::from_snapshot(snapshot());
v.step = Step::Review;
v
},
120,
40,
)
.join("\n");
for section in ["Role", "Model", "Permissions", "Tools"] {
assert!(top.contains(section), "review missing section: {section}");
}
let bottom = render_through_stack(
|| {
let mut v = FleetSetupView::from_snapshot(snapshot());
v.step = Step::Review;
v.review_scroll = 999; v
},
120,
40,
)
.join("\n");
for needle in ["Workspace", "Review policy", "until ratification"] {
assert!(bottom.contains(needle), "scrolled review missing: {needle}");
}
}
}