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, CommandPaletteAction, 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 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: "main",
summary: "Default orchestrator",
description: "The parent for the whole Fleet. Owns topology and verifies the claims workers return.",
},
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: "custom",
summary: "Author a profile by hand",
description: "Define the posture yourself in a workspace agent TOML profile under .codewhale/agents/.",
},
];
const MODEL_CLASSES: [Choice; 6] = [
Choice {
label: "inherit",
summary: "Same model as now",
description: "Reuse the active provider, model, and reasoning for this worker. Recommended default.",
},
Choice {
label: "fast",
summary: "Low-latency scout",
description: "An opt-in low-latency class for wide fan-out and quick reconnaissance.",
},
Choice {
label: "balanced",
summary: "Everyday build/review",
description: "A balanced class for normal build and review work.",
},
Choice {
label: "strong",
summary: "Hard problems",
description: "The strongest class for security, release, and architecture work.",
},
Choice {
label: "deep-reasoning",
summary: "More reasoning",
description: "Higher reasoning effort when the active route supports it.",
},
Choice {
label: "tool-heavy",
summary: "Operator workflows",
description: "Shell- and artifact-heavy operator workflows.",
},
];
#[derive(Debug, Clone)]
pub struct FleetSetupSnapshot {
workspace: PathBuf,
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,
}
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);
Self {
workspace: app.workspace.clone(),
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),
}
}
}
#[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,
}
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 {
Self {
snapshot,
step: Step::Role,
role_idx: 0,
model_idx: 0,
review_scroll: 0,
}
}
fn selected_role(&self) -> &'static str {
ROLES[self.role_idx.min(ROLES.len() - 1)].label
}
fn selected_model_class(&self) -> &'static str {
model_class_hint(MODEL_CLASSES[self.model_idx.min(MODEL_CLASSES.len() - 1)].label)
}
fn step_len(&self) -> usize {
match self.step {
Step::Role => ROLES.len(),
Step::Model => MODEL_CLASSES.len(),
Step::Review => 0,
}
}
fn move_up(&mut self) {
match self.step {
Step::Role => self.role_idx = self.role_idx.saturating_sub(1),
Step::Model => self.model_idx = self.model_idx.saturating_sub(1),
Step::Review => self.review_scroll = self.review_scroll.saturating_sub(1),
}
}
fn move_down(&mut self) {
match self.step {
Step::Role => {
self.role_idx = (self.role_idx + 1).min(self.step_len().saturating_sub(1));
}
Step::Model => {
self.model_idx = (self.model_idx + 1).min(self.step_len().saturating_sub(1));
}
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.insert_profile_prompt_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 insert_profile_prompt_action(&self) -> ViewAction {
ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
action: CommandPaletteAction::InsertText {
text: self.profile_prompt(),
},
})
}
fn profile_prompt(&self) -> String {
profile_authoring_prompt(
&self.snapshot,
self.selected_role(),
self.selected_model_class(),
)
}
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"));
hints.push(ActionHint::new("Enter", "start"));
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::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 => render_choice_step(
chunks[1],
buf,
&ROLES,
self.role_idx,
&[
"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(),
],
),
Step::Model => render_choice_step(
chunks[1],
buf,
&MODEL_CLASSES,
self.model_idx,
&[
format!(
"Current route: {} / {} · reasoning {}",
self.snapshot.provider, self.snapshot.model, self.snapshot.reasoning
),
format!(
"Maps to model_class_hint = {}.",
self.selected_model_class()
),
],
),
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 class",
"How this worker should be routed. Inherit keeps your current model.",
),
Step::Review => (
"Review & start",
"Confirm the posture below, then start to author the profile.",
),
};
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 model = &MODEL_CLASSES[self.model_idx.min(MODEL_CLASSES.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",
format!("{} — {}", role.label, role.summary),
);
section(
&mut lines,
"Model",
format!(
"{} (model_class_hint = {}) · route {} / {}, reasoning {}",
model.label,
self.selected_model_class(),
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 inserts a safe authoring prompt into the composer — nothing is written to disk.",
),
);
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 >= 56 {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(22), Constraint::Min(20)])
.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 model_class_hint(label: &str) -> &'static str {
match label {
"fast" => "fast",
"balanced" => "balanced",
"strong" => "deep-reasoning",
"deep-reasoning" => "deep-reasoning",
"tool-heavy" => "tool-heavy",
_ => "inherit",
}
}
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
}
}
fn profile_authoring_prompt(
snapshot: &FleetSetupSnapshot,
role: &str,
model_class: &str,
) -> String {
let file_stem = profile_file_stem(role);
format!(
"Create a safe CodeWhale Fleet agent profile file for this workspace.\n\n\
Selected planner role: {role}. Selected model class: {model_class}.\n\
Target path: {PROFILE_DIR}/{file_stem}.toml\n\
Current route context only: provider = {provider}, model = {model}, reasoning = {reasoning}\n\n\
Write TOML using only this schema:\n\
- name\n\
- display_name\n\
- description\n\
- role_hint (set to \"{role}\")\n\
- model_class_hint (set to \"{model_class}\"; one of inherit, fast, balanced, deep-reasoning, code, review, or tool-heavy)\n\
- model (optional explicit model id on the active/resolved route; omit to inherit the current route)\n\
- [instructions].text\n\
- [tools].posture = \"read-only\"\n\n\
Do not include provider, base_url, api_key, auth, secrets, trust, allow_shell, or approval_required=false.\n\
If model is present, keep it to a visible model id such as deepseek-v4-pro or glm-5.2.\n\
Fleet product shape:\n\
- Fleet is the durable sub-agent config surface: slots, profiles, models, tools, and ledger\n\
- one main orchestrator profile coordinates the Fleet run and verifies returned claims\n\
- workers are summoned as focused Fleet members with only their assigned slice\n\
- default model behavior is same-route inheritance; choose fast/strong/code/review only when the role needs it\n\
- DeepSeek-style model tiers are recommendations, not hierarchy rules; every slot may override model\n\
- WhaleFlow plans may select and monitor Fleet slots, but Fleet owns the worker config\n\
- do not encode a recursive worker tree in [instructions].text; topology belongs to the orchestrator, not each worker\n\n\
Keep the profile permission-narrowing and compatible with recursive Fleet role workers.",
provider = snapshot.provider,
model = snapshot.model,
reasoning = snapshot.reasoning
)
}
#[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"),
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,
}
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::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_inserts_profile_prompt_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::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::EmitAndClose(ViewEvent::CommandPaletteSelected {
action: CommandPaletteAction::InsertText { text },
}) => {
assert!(text.contains("Target path: .codewhale/agents/builder.toml"));
assert!(text.contains("role_hint (set to \"builder\")"));
assert!(text.contains("model_class_hint (set to \"fast\""));
assert!(text.contains("provider = DeepSeek"));
assert!(text.contains("Do not include provider, base_url"));
assert!(text.contains("Fleet is the durable sub-agent config surface"));
assert!(text.contains("topology belongs to the orchestrator"));
}
other => panic!("expected profile prompt insertion, got {other:?}"),
}
}
#[test]
fn default_selection_targets_manager_inherit() {
let view = FleetSetupView::from_snapshot(snapshot());
let prompt = view.profile_prompt();
assert!(prompt.contains("Target path: .codewhale/agents/manager.toml"));
assert!(prompt.contains("model_class_hint (set to \"inherit\""));
assert!(prompt.contains("Current route context only"));
assert!(prompt.contains("permission-narrowing"));
}
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", "nothing is written to disk"] {
assert!(bottom.contains(needle), "scrolled review missing: {needle}");
}
}
}