返回 CodeWhale
fleet_setup.rs
根目录 / crates / tui / src / tui / views / fleet_setup.rs
1 //! `/fleet setup` — a progressive "set up your agent team" flow.
2 //!
3 //! Replaces the old six-column config matrix (#3791). Fleet is presented as an
4 //! agent team: the shortest valid path is role → provider/model → save/apply.
5 //! The review step shows resolved provider, model, auth/readiness, profile
6 //! availability, and overwrite consequences once before anything is written. Thinking defaults to
7 //! inherit and can be adjusted on the review step without an extra wizard
8 //! screen. "Save profile" persists the exact rendered TOML bytes.
9 //!
10 //! NOTE (audit #7 / #3167): the role/model taxonomy and copy below are
11 //! intentionally English for now; #3167 reworks this into an interactive
12 //! provider/model picker that will churn most of this text. The command entry
13 //! (`CmdFleetDescription`) is already localized.
14
15 use std::borrow::Cow;
16 use std::cell::RefCell;
17 use std::path::{Path, PathBuf};
18
19 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
20 use ratatui::{
21 buffer::Buffer,
22 layout::{Constraint, Direction, Layout, Rect},
23 style::{Modifier, Style},
24 text::{Line, Span},
25 widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap},
26 };
27
28 use crate::config::Config;
29 use crate::fleet::profile::FleetProfileScope;
30 use crate::localization::{MessageId, tr};
31 use crate::palette;
32 use crate::tui::app::App;
33 use crate::tui::menu_style;
34 use crate::tui::views::{
35 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, centered_modal_area,
36 render_modal_footer_with_gutter, render_modal_surface, truncate_view_text,
37 };
38
39 const PROFILE_DIR: &str = ".codewhale/agents";
40
41 /// A selectable choice in a wizard step: a short identifier `label`, a one-line
42 /// `summary`, and a longer `description` shown (wrapped) in the detail pane.
43 #[derive(Clone)]
44 struct Choice {
45 label: Cow<'static, str>,
46 summary: Cow<'static, str>,
47 description: Cow<'static, str>,
48 }
49
50 const CHOICE_LIST_WIDTH: u16 = 22;
51 const CHOICE_DETAIL_MIN_WIDTH: u16 = 58;
52 const CHOICE_TWO_COLUMN_MIN_WIDTH: u16 = CHOICE_LIST_WIDTH + CHOICE_DETAIL_MIN_WIDTH;
53
54 /// Agent-team roles. `label` doubles as the profile `role_hint` and file stem,
55 /// so these strings are part of the generated-profile contract.
56 const ROLES: [Choice; 9] = [
57 Choice {
58 label: Cow::Borrowed("manager"),
59 summary: Cow::Borrowed("Plan & split queued work"),
60 description: Cow::Borrowed(
61 "Coordinates the Fleet run: plans the work, splits it into bounded tasks, and dispatches workers.",
62 ),
63 },
64 Choice {
65 label: Cow::Borrowed("scout"),
66 summary: Cow::Borrowed("Read-first research"),
67 description: Cow::Borrowed(
68 "Research and repo reconnaissance. Reads and summarizes before anything is written.",
69 ),
70 },
71 Choice {
72 label: Cow::Borrowed("builder"),
73 summary: Cow::Borrowed("Implements bounded changes"),
74 description: Cow::Borrowed(
75 "Implements changes strictly inside its assigned task scope; writes only what the slice needs.",
76 ),
77 },
78 Choice {
79 label: Cow::Borrowed("reviewer"),
80 summary: Cow::Borrowed("Read-only review"),
81 description: Cow::Borrowed(
82 "Checks regressions, tests, and diffs. Read-only — it never writes.",
83 ),
84 },
85 Choice {
86 label: Cow::Borrowed("verifier"),
87 summary: Cow::Borrowed("Runs focused validation"),
88 description: Cow::Borrowed(
89 "Runs targeted validation and reports receipts back to the orchestrator.",
90 ),
91 },
92 Choice {
93 label: Cow::Borrowed("consultant"),
94 summary: Cow::Borrowed("Read-only second opinion"),
95 description: Cow::Borrowed(
96 "Short-lived, high-reasoning counsel for difficult decisions and overlooked risks. Read-only and shell-less.",
97 ),
98 },
99 Choice {
100 label: Cow::Borrowed("synthesizer"),
101 summary: Cow::Borrowed("Reduce receipts to handoff"),
102 description: Cow::Borrowed(
103 "Turns worker receipts into bounded handoff state instead of raw transcript replay.",
104 ),
105 },
106 Choice {
107 label: Cow::Borrowed("general"),
108 summary: Cow::Borrowed("General-purpose worker"),
109 description: Cow::Borrowed(
110 "A flexible worker with no specialized posture — use it when the task doesn't fit a named role.",
111 ),
112 },
113 Choice {
114 label: Cow::Borrowed("custom"),
115 summary: Cow::Borrowed("Author a profile by hand"),
116 description: Cow::Borrowed(
117 "Define the posture yourself in a workspace agent TOML profile under .codewhale/agents/.",
118 ),
119 },
120 ];
121
122 /// The `inherit` row shown first in the Model step (#3167). Concrete provider
123 /// models follow it, built per-run from EVERY configured provider's catalog
124 /// (#4093), so the user picks a real route — including cross-provider ones —
125 /// instead of an abstract class or only the active provider's models.
126 const MODEL_INHERIT: Choice = Choice {
127 label: Cow::Borrowed("inherit"),
128 summary: Cow::Borrowed("Same model as now"),
129 description: Cow::Borrowed(
130 "Use the operator's current route — provider, model, and reasoning included. Recommended default.",
131 ),
132 };
133
134 const THINKING_CHOICES: &[Choice] = &[
135 Choice {
136 label: Cow::Borrowed("inherit"),
137 summary: Cow::Borrowed("Same thinking as now"),
138 description: Cow::Borrowed(
139 "Reuse the operator's current reasoning setting for this worker. Recommended default.",
140 ),
141 },
142 Choice {
143 label: Cow::Borrowed("off"),
144 summary: Cow::Borrowed("No extra thinking"),
145 description: Cow::Borrowed(
146 "Use for narrow lookups or mechanical work where speed matters.",
147 ),
148 },
149 Choice {
150 label: Cow::Borrowed("low"),
151 summary: Cow::Borrowed("Small thinking budget"),
152 description: Cow::Borrowed(
153 "Use for bounded checks that still benefit from light reasoning.",
154 ),
155 },
156 Choice {
157 label: Cow::Borrowed("medium"),
158 summary: Cow::Borrowed("Balanced thinking budget"),
159 description: Cow::Borrowed("Use for normal implementation and review work."),
160 },
161 Choice {
162 label: Cow::Borrowed("high"),
163 summary: Cow::Borrowed("Deep thinking budget"),
164 description: Cow::Borrowed("Use for harder design, debugging, and integration tasks."),
165 },
166 Choice {
167 label: Cow::Borrowed("max"),
168 summary: Cow::Borrowed("Maximum thinking budget"),
169 description: Cow::Borrowed("Use for hard release, security, and root-cause work."),
170 },
171 Choice {
172 label: Cow::Borrowed("auto"),
173 summary: Cow::Borrowed("Let Codewhale choose"),
174 description: Cow::Borrowed("Choose a thinking tier from the worker prompt at runtime."),
175 },
176 ];
177
178 #[derive(Debug, Clone)]
179 pub struct FleetSetupSnapshot {
180 workspace: PathBuf,
181 locale: crate::localization::Locale,
182 /// Whether the active provider has a key or local runtime — gates the
183 /// model-draft offer, mirroring the constitution card's `provider_ready`.
184 provider_ready: bool,
185 provider: String,
186 model: String,
187 reasoning: String,
188 subagents_enabled: bool,
189 max_subagents: usize,
190 launch_concurrency: usize,
191 max_admitted: usize,
192 subagent_spawn_depth: u32,
193 fleet_spawn_depth: u32,
194 api_timeout_secs: u64,
195 heartbeat_timeout_secs: u64,
196 /// Lowercased roster member ids with their origin labels (built-in /
197 /// config / project), so the wizard can say when a chosen role would
198 /// override an existing roster member.
199 roster_members: Vec<(String, String)>,
200 /// `(exact provider id, model id, readiness label, selectable)` routes for a worker,
201 /// drawn from ALL configured providers — not only the active one (#4093).
202 /// Shown after `inherit` in the Model step so a Fleet worker can be pinned
203 /// to a route independent of the parent/current provider. The provider id
204 /// is a canonical built-in id or the exact named custom table key, not a
205 /// display label — see [`cross_provider_model_routes`].
206 available_models: Vec<(
207 String,
208 String,
209 crate::provider_readiness::ResolvedProviderReadiness,
210 )>,
211 }
212
213 impl FleetSetupSnapshot {
214 #[must_use]
215 pub fn from_app(app: &App, config: &Config) -> Self {
216 let provider = app.effective_route_identity_display().0;
217 let model = if app.auto_model {
218 app.last_effective_model
219 .as_deref()
220 .map(|effective| format!("auto -> {effective}"))
221 .unwrap_or_else(|| "auto".to_string())
222 } else {
223 app.model.clone()
224 };
225 let fleet_spawn_depth = config
226 .fleet
227 .as_ref()
228 .map(|fleet| fleet.exec.max_spawn_depth)
229 .unwrap_or_else(|| codewhale_config::FleetExecConfig::default().max_spawn_depth)
230 .min(codewhale_config::MAX_SPAWN_DEPTH_CEILING);
231 let roster_members =
232 crate::fleet::roster::FleetRoster::load(&config.fleet_config(), &app.workspace)
233 .members()
234 .iter()
235 .map(|member| (member.id.to_lowercase(), member.origin.to_string()))
236 .collect();
237 let active_route_readiness = crate::provider_readiness::resolve_for_model(
238 config,
239 app.api_provider,
240 if app.auto_model { "auto" } else { &app.model },
241 &app.provider_health,
242 );
243
244 Self {
245 workspace: app.workspace.clone(),
246 locale: app.ui_locale,
247 provider_ready: active_route_readiness.can_attempt(),
248 provider,
249 model,
250 reasoning: app.reasoning_effort_display_label(),
251 subagents_enabled: config.subagents_enabled_for_provider(app.api_provider),
252 max_subagents: config.max_subagents_for_provider(app.api_provider),
253 launch_concurrency: config.launch_concurrency_for_provider(app.api_provider),
254 max_admitted: config.max_admitted_subagents_for_provider(app.api_provider),
255 subagent_spawn_depth: config.subagent_max_spawn_depth_for_provider(app.api_provider),
256 fleet_spawn_depth,
257 api_timeout_secs: config.subagent_api_timeout_secs_for_provider(app.api_provider),
258 heartbeat_timeout_secs: config
259 .subagent_heartbeat_timeout_secs_for_provider(app.api_provider),
260 roster_members,
261 available_models: cross_provider_model_routes(
262 config,
263 app.api_provider,
264 &app.provider_health,
265 ),
266 }
267 }
268 }
269
270 /// Build the `(canonical provider id, model id)` pairs selectable for a worker
271 /// from EVERY configured provider — not only the active one (#4093). Fleet
272 /// workers can be pinned to a route independent of the parent/current provider,
273 /// so the Model step must offer the same cross-provider catalog the model
274 /// picker does, instead of the active provider's models alone.
275 ///
276 /// The provider id here is the exact non-secret configured route key. Built-ins
277 /// use their canonical id; named custom routes keep their table key so saved
278 /// Fleet profiles can rebuild the same child client.
279 /// Callers derive a human-readable label from it for UI text.
280 pub(super) fn cross_provider_model_routes(
281 config: &Config,
282 active: crate::config::ApiProvider,
283 health: &crate::provider_readiness::ProviderReadinessSnapshot,
284 ) -> Vec<(
285 String,
286 String,
287 crate::provider_readiness::ResolvedProviderReadiness,
288 )> {
289 let mut routes = Vec::new();
290 let configured = crate::provider_lake::configured_providers(config, active);
291 let legacy_custom_configured = configured.contains(&crate::config::ApiProvider::Custom);
292 for provider in configured
293 .into_iter()
294 .filter(|provider| *provider != crate::config::ApiProvider::Custom)
295 {
296 append_provider_model_routes(
297 &mut routes,
298 config,
299 active,
300 provider,
301 provider.as_str(),
302 health,
303 );
304 }
305
306 // `ApiProvider::Custom` is an enum class, not a route identity. Enumerate
307 // every named custom table so a Fleet on custom A can still pin a worker
308 // to custom B and persist B's exact client route.
309 let mut custom_names = config
310 .providers
311 .as_ref()
312 .map(|providers| providers.custom.keys().cloned().collect::<Vec<_>>())
313 .unwrap_or_default();
314 custom_names.sort();
315 if custom_names.is_empty() && legacy_custom_configured {
316 append_provider_model_routes(
317 &mut routes,
318 config,
319 active,
320 crate::config::ApiProvider::Custom,
321 crate::config::ApiProvider::Custom.as_str(),
322 health,
323 );
324 }
325 for name in custom_names {
326 let mut named_config = config.clone();
327 named_config.provider = Some(name.clone());
328 append_provider_model_routes(
329 &mut routes,
330 &named_config,
331 active,
332 crate::config::ApiProvider::Custom,
333 &name,
334 health,
335 );
336 }
337 routes
338 }
339
340 fn append_provider_model_routes(
341 routes: &mut Vec<(
342 String,
343 String,
344 crate::provider_readiness::ResolvedProviderReadiness,
345 )>,
346 config: &Config,
347 active: crate::config::ApiProvider,
348 provider: crate::config::ApiProvider,
349 provider_id: &str,
350 health: &crate::provider_readiness::ProviderReadinessSnapshot,
351 ) {
352 // The bundled lake is only the baseline. A user may pin a valid
353 // provider-specific preview or private deployment outside that catalog.
354 let mut models = Vec::new();
355 if let Some(model) = config
356 .provider_config_for(provider)
357 .and_then(|entry| entry.model.as_deref())
358 {
359 push_unique_model(&mut models, model);
360 }
361 if provider == active {
362 let active_model = config.default_model();
363 if !active_model.trim().eq_ignore_ascii_case("auto") {
364 push_unique_model(&mut models, &active_model);
365 }
366 }
367 for model in crate::provider_lake::models_for_provider(config, active, provider) {
368 push_unique_model(&mut models, &model);
369 }
370
371 for model in models {
372 let readiness =
373 crate::provider_readiness::resolve_for_model(config, provider, &model, health);
374 routes.push((provider_id.to_string(), model, readiness));
375 }
376 }
377
378 fn push_unique_model(models: &mut Vec<String>, model: &str) {
379 let model = model.trim();
380 if !model.is_empty()
381 && !models
382 .iter()
383 .any(|existing| existing.eq_ignore_ascii_case(model))
384 {
385 models.push(model.to_string());
386 }
387 }
388
389 /// Human-readable label for a built-in provider id, falling back to an exact
390 /// named custom id verbatim.
391 pub(super) fn provider_display_label(provider_id: &str) -> String {
392 crate::config::ApiProvider::parse(provider_id)
393 .filter(|provider| provider.as_str() == provider_id)
394 .map(|provider| provider.display_name().to_string())
395 .unwrap_or_else(|| provider_id.to_string())
396 }
397
398 /// Which focused screen of the wizard is showing.
399 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
400 enum Step {
401 /// Pick the team role.
402 Role,
403 /// Pick the model-routing class.
404 Model,
405 /// Review the full posture and save.
406 Review,
407 }
408
409 /// Per-row Fleet Model step interaction state.
410 ///
411 /// Replaces the old `model_selectable: Vec<bool>` so a dormant external-consent
412 /// route can require explicit activation (#v092-fleet-routes-fix) while
413 /// genuinely unconfigured routes stay blocked with a reason.
414 #[derive(Debug, Clone, PartialEq, Eq)]
415 enum FleetModelRowState {
416 Ready,
417 NeedsActivation,
418 Blocked { reason: String },
419 }
420
421 impl FleetModelRowState {
422 fn from_readiness(readiness: &crate::provider_readiness::ResolvedProviderReadiness) -> Self {
423 if readiness.requires_explicit_activation() {
424 return Self::NeedsActivation;
425 }
426 if let Some(reason) = readiness.blocked_reason() {
427 return Self::Blocked {
428 reason: reason.into_owned(),
429 };
430 }
431 if readiness.can_attempt() {
432 return Self::Ready;
433 }
434 Self::Blocked {
435 reason: readiness
436 .blocked_reason()
437 .map(std::borrow::Cow::into_owned)
438 .unwrap_or_else(|| readiness.label().into_owned()),
439 }
440 }
441 }
442
443 pub struct FleetSetupView {
444 snapshot: FleetSetupSnapshot,
445 step: Step,
446 role_idx: usize,
447 model_idx: usize,
448 thinking_idx: usize,
449 profile_scope: FleetProfileScope,
450 /// Cached `profile_file_status` for the Review step.
451 ///
452 /// `render_review` used to recompute this on every paint — `exists()` +
453 /// `is_dir()` + a full `read_dir` extension count, inside the draw closure
454 /// (#3908). Recomputed on entry to Review and when the scope toggles,
455 /// which are the only inputs it depends on.
456 profile_status: Option<(String, String)>,
457 review_scroll: usize,
458 /// A model-drafted profile awaiting save (already sanitized and
459 /// bounded by the untrusted gate). Cleared when the selection changes so
460 /// a stale draft can never be saved against fresh answers.
461 model_draft: Option<Box<crate::fleet::profile::FleetProfileDraft>>,
462 /// Exact rendered TOML preview for `model_draft` (header comment + the
463 /// deterministic bytes saving would persist). Rendered inline on the
464 /// Review step — never in a separate pager (#4093): a standalone pager
465 /// view owns its own `g`/`G` scroll bindings, which silently swallowed
466 /// the save keypress and left users unable to save without first
467 /// pressing Esc. Keeping the preview and the save control in the same
468 /// view means the footer's `g`/Enter hints are never a lie.
469 model_draft_preview: Option<String>,
470 /// Model-step rows: `inherit` followed by one row per concrete model from
471 /// every configured provider (#4093).
472 model_choices: Vec<Choice>,
473 /// `(provider, model)` aligned with `model_choices`. Index 0 is `inherit`
474 /// (the active route); later rows pin a concrete, possibly cross-provider
475 /// route. Drives the review/copy so a pinned route names its own provider.
476 model_routes: Vec<(String, String)>,
477 /// Interaction state for each aligned Model row. Distinguishes ready rows,
478 /// dormant external-consent rows that need explicit activation, and
479 /// genuinely blocked rows with a short reason.
480 model_row_states: Vec<FleetModelRowState>,
481 /// Typed filter for the Model step (#4639): substring match over
482 /// provider and model id, so provider-heavy catalogs (e.g. OpenRouter)
483 /// stay navigable without a provider→model drill-down.
484 model_query: String,
485 /// Whether the Model step's filter input is capturing keystrokes (`/`
486 /// toggles it; Enter keeps the filter, Esc clears it).
487 model_filter_active: bool,
488 /// Selectable rows registered by the latest render. Keeping mouse geometry
489 /// in the view gives the Fleet walkthrough the same row ownership as its
490 /// keyboard path without coupling the host to this modal's layout.
491 row_hitboxes: RefCell<Vec<(Rect, usize)>>,
492 }
493
494 impl FleetSetupView {
495 /// Refresh row states from a freshly built snapshot while preserving the
496 /// user's current selection position and draft state. Used after the host
497 /// validates a dormant external-consent route so the same row becomes
498 /// Ready without closing and reopening the modal.
499 pub fn refresh_from_snapshot(&mut self, snapshot: FleetSetupSnapshot) {
500 let old_step = self.step;
501 let old_role_idx = self.role_idx;
502 let old_model_idx = self.model_idx;
503 let old_thinking_idx = self.thinking_idx;
504 let old_profile_scope = self.profile_scope;
505 let old_model_query = self.model_query.clone();
506 let old_model_filter_active = self.model_filter_active;
507 let old_review_scroll = self.review_scroll;
508 let old_profile_status = self.profile_status.clone();
509 let old_model_draft = self.model_draft.clone();
510 let old_model_draft_preview = self.model_draft_preview.clone();
511
512 *self = Self::from_snapshot(snapshot);
513
514 self.step = old_step;
515 self.role_idx = old_role_idx;
516 self.model_idx = old_model_idx.min(self.filtered_model_indices().len().saturating_sub(1));
517 self.thinking_idx = old_thinking_idx;
518 self.profile_scope = old_profile_scope;
519 self.model_query = old_model_query;
520 self.model_filter_active = old_model_filter_active;
521 self.review_scroll = old_review_scroll;
522 self.profile_status = old_profile_status;
523 self.model_draft = old_model_draft;
524 self.model_draft_preview = old_model_draft_preview;
525 }
526
527 #[must_use]
528 pub fn new(app: &App, config: &Config) -> Self {
529 Self::from_snapshot(FleetSetupSnapshot::from_app(app, config))
530 }
531
532 /// Open setup for a role the operator already selected in `/fleet`.
533 /// Unknown/custom roster roles map to the explicit custom authoring row;
534 /// Left or Esc still exposes Role so the carried choice is never sticky.
535 #[must_use]
536 pub fn new_for_role(app: &App, config: &Config, role: &str) -> Self {
537 Self::from_snapshot_for_role(FleetSetupSnapshot::from_app(app, config), role)
538 }
539
540 fn from_snapshot_for_role(snapshot: FleetSetupSnapshot, role: &str) -> Self {
541 let mut view = Self::from_snapshot(snapshot);
542 view.role_idx = ROLES
543 .iter()
544 .position(|choice| choice.label.eq_ignore_ascii_case(role.trim()))
545 .unwrap_or(ROLES.len() - 1);
546 view.step = Step::Model;
547 view
548 }
549
550 fn from_snapshot(snapshot: FleetSetupSnapshot) -> Self {
551 let mut model_choices = vec![MODEL_INHERIT];
552 // `inherit` (index 0) maps to the active route; every later row pins a
553 // concrete (provider, model) drawn from all configured providers.
554 let mut model_routes = vec![(snapshot.provider.clone(), snapshot.model.clone())];
555 let mut model_row_states = vec![FleetModelRowState::Ready];
556 for (provider, model, readiness) in &snapshot.available_models {
557 let provider_label = provider_display_label(provider);
558 let readiness_summary = readiness.detail().map_or_else(
559 || readiness.label().into_owned(),
560 |detail| format!("{}: {detail}", readiness.label()),
561 );
562 // Capability badges from the existing catalog/registry owners
563 // (#5038): shown in the word-wrapped detail pane so the picker
564 // list stays narrow-terminal friendly. Unknown models honestly
565 // omit the sentence instead of blocking selection.
566 let capability_note = crate::fleet::capability_badges::resolve_route_capability_badges(
567 Some(provider),
568 model,
569 )
570 .map(|badges| format!(" Capabilities: {}.", badges.summary()))
571 .unwrap_or_default();
572 model_choices.push(Choice {
573 label: Cow::Owned(model.clone()),
574 summary: Cow::Owned(format!(
575 "Pin this model ({provider_label}) · {readiness_summary}"
576 )),
577 description: Cow::Owned(format!(
578 "Route this worker to {model} on {provider_label} instead of inheriting the session route.{capability_note}"
579 )),
580 });
581 // Canonical provider id (not the display label above) — this is
582 // what gets persisted into the saved profile (#4093).
583 model_routes.push((provider.clone(), model.clone()));
584 model_row_states.push(FleetModelRowState::from_readiness(readiness));
585 }
586 Self {
587 snapshot,
588 step: Step::Role,
589 role_idx: 0,
590 model_idx: 0,
591 thinking_idx: 0,
592 // Profiles authored for a person should follow that person across
593 // repositories by default. Project scope remains one `s` away and
594 // keeps higher roster precedence when explicitly selected.
595 profile_scope: FleetProfileScope::Personal,
596 profile_status: None,
597 review_scroll: 0,
598 model_draft: None,
599 model_draft_preview: None,
600 model_choices,
601 model_routes,
602 model_row_states,
603 model_query: String::new(),
604 model_filter_active: false,
605 row_hitboxes: RefCell::new(Vec::new()),
606 }
607 }
608
609 /// Install a sanitized, bounded model draft. The exact TOML preview
610 /// (returned here for the caller's status message) renders inline on the
611 /// Review step — not in a separate pager — so the footer's `g`/Enter
612 /// ratify hints stay true the instant the draft lands (#4093).
613 pub fn install_model_draft(
614 &mut self,
615 mut draft: Box<crate::fleet::profile::FleetProfileDraft>,
616 model_label: String,
617 picked_route: Option<(String, String)>,
618 reasoning_effort: Option<String>,
619 ) -> (String, String) {
620 // Re-inject the route the operator picked at `m`-press time (#4093). A
621 // model draft comes from `from_untrusted_json`, which hard-sets
622 // `provider: None` and echoes whatever `model` the model happened to
623 // emit — so ratifying it verbatim would drop a concrete cross-provider
624 // pick and persist the ambiguous, provider-scoped profile #4093 exists
625 // to prevent. Pinning BOTH fields from the CARRIED route keeps the route
626 // the user actually chose (the model only authored the prose), and is
627 // immune to the selection changing while the async draft is in flight.
628 // `inherit` (a `None` route) leaves `model`/`provider` untouched,
629 // matching the deterministic Enter path.
630 if let Some((provider, model)) = picked_route {
631 draft.model = Some(model);
632 draft.provider = Some(provider);
633 }
634 draft.reasoning_effort = reasoning_effort;
635 let (title, header) = (
636 tr(self.snapshot.locale, MessageId::FleetDraftTitle)
637 .replace("{model_label}", &model_label),
638 tr(self.snapshot.locale, MessageId::FleetDraftHeader)
639 .replace("{name}", &draft.file_name())
640 .replace("{model_label}", &model_label),
641 );
642 let content = format!(
643 "{}{}",
644 self.scope_preview_header(header),
645 draft.render_toml()
646 );
647 self.model_draft = Some(draft);
648 self.model_draft_preview = Some(content.clone());
649 self.review_scroll = 0;
650 (title, content)
651 }
652
653 /// The planner role chosen (drives the profile file name and `role_hint`).
654 fn selected_role(&self) -> String {
655 ROLES[self.role_idx.min(ROLES.len() - 1)].label.to_string()
656 }
657
658 /// Copy note when the chosen role would override an existing roster
659 /// member of the same id (e.g. "overrides built-in reviewer"). A saved
660 /// profile shadows lower roster layers rather than adding a new member.
661 fn roster_override_note(&self) -> Option<String> {
662 let role = self.selected_role().to_lowercase();
663 self.snapshot
664 .roster_members
665 .iter()
666 .find(|(id, _)| *id == role)
667 .map(|(id, origin)| {
668 if self.profile_scope == FleetProfileScope::Personal && origin == "project" {
669 format!(
670 "The project '{id}' profile remains higher precedence; this personal profile applies elsewhere."
671 )
672 } else if self.profile_scope == FleetProfileScope::Personal {
673 format!("Overrides {origin} '{id}' unless a project profile exists.")
674 } else {
675 format!("Overrides the {origin} '{id}' roster member.")
676 }
677 })
678 }
679
680 /// The concrete model chosen for this worker, written to the profile
681 /// `model` field. `None` means `inherit` (reuse the session route).
682 fn selected_model(&self) -> Option<String> {
683 self.selected_route().map(|(_, model)| model)
684 }
685
686 /// The concrete `(provider, model)` chosen for this worker — a pinned route
687 /// independent of the parent/current provider (#4093) — or `None` when
688 /// `inherit` is selected (reuse the session route).
689 fn selected_route(&self) -> Option<(String, String)> {
690 let real_idx = self.real_model_idx();
691 if real_idx == 0 {
692 return None;
693 }
694 self.model_routes.get(real_idx).cloned()
695 }
696
697 /// Indices into `model_choices` visible under the current typed filter
698 /// (#4639). Empty query shows every row; otherwise substring match over
699 /// provider id/label and model id.
700 fn filtered_model_indices(&self) -> Vec<usize> {
701 let query = self.model_query.trim().to_ascii_lowercase();
702 if query.is_empty() {
703 return (0..self.model_choices.len()).collect();
704 }
705 (0..self.model_choices.len())
706 .filter(|idx| {
707 let (provider, model) = &self.model_routes[*idx];
708 model.to_ascii_lowercase().contains(&query)
709 || provider.to_ascii_lowercase().contains(&query)
710 || provider_display_label(provider)
711 .to_ascii_lowercase()
712 .contains(&query)
713 || (*idx == 0 && "inherit same current".contains(&query))
714 })
715 .collect()
716 }
717
718 /// Map the filtered highlight position back to the real `model_choices`
719 /// index. Selection, persistence, and hitboxes all use the real index.
720 fn real_model_idx(&self) -> usize {
721 let filtered = self.filtered_model_indices();
722 if filtered.is_empty() {
723 return 0;
724 }
725 filtered[self.model_idx.min(filtered.len() - 1)]
726 }
727
728 fn selected_reasoning_effort(&self) -> Option<String> {
729 if self.thinking_idx == 0 {
730 return None;
731 }
732 THINKING_CHOICES
733 .get(self.thinking_idx)
734 .map(|choice| choice.label.to_string())
735 }
736
737 fn selected_thinking_label(&self) -> String {
738 self.selected_reasoning_effort()
739 .unwrap_or_else(|| format!("inherit ({})", self.snapshot.reasoning))
740 }
741
742 fn scope_preview_header(&self, header: String) -> String {
743 header.replacen(PROFILE_DIR, self.profile_scope.display_dir(), 1)
744 }
745
746 /// Number of selectable rows on the current step (0 on the review step).
747 fn step_len(&self) -> usize {
748 match self.step {
749 Step::Role => ROLES.len(),
750 Step::Model => self.filtered_model_indices().len(),
751 Step::Review => 0,
752 }
753 }
754
755 fn move_up(&mut self) {
756 match self.step {
757 Step::Role => {
758 self.role_idx =
759 crate::tui::list_nav::wrap_index(self.role_idx, self.step_len(), -1);
760 self.discard_model_draft();
761 }
762 Step::Model => {
763 self.model_idx =
764 crate::tui::list_nav::wrap_index(self.model_idx, self.step_len(), -1);
765 self.discard_model_draft();
766 }
767 Step::Review => self.review_scroll = self.review_scroll.saturating_sub(1),
768 }
769 }
770
771 /// A draft is only valid for the answers it was requested against.
772 fn discard_model_draft(&mut self) {
773 self.model_draft = None;
774 self.model_draft_preview = None;
775 }
776
777 fn move_down(&mut self) {
778 match self.step {
779 Step::Role => {
780 self.role_idx = crate::tui::list_nav::wrap_index(self.role_idx, self.step_len(), 1);
781 self.discard_model_draft();
782 }
783 Step::Model => {
784 self.model_idx =
785 crate::tui::list_nav::wrap_index(self.model_idx, self.step_len(), 1);
786 self.discard_model_draft();
787 }
788 Step::Review => self.review_scroll = self.review_scroll.saturating_add(1),
789 }
790 }
791
792 /// Re-stat the profile directory. Called on the two transitions that can
793 /// change the answer — entering Review, and toggling project/user scope —
794 /// so the Review step never touches the filesystem while painting.
795 fn refresh_profile_status(&mut self) {
796 self.profile_status = Some(profile_file_status(
797 self.profile_scope,
798 &self.snapshot.workspace,
799 ));
800 }
801
802 /// starter profile TOML the next save keypress would persist.
803 fn advance(&mut self) -> ViewAction {
804 match self.step {
805 Step::Role => {
806 self.step = Step::Model;
807 ViewAction::None
808 }
809 Step::Model => {
810 let idx = self.real_model_idx();
811 match self.model_row_states.get(idx) {
812 Some(FleetModelRowState::Ready) => {
813 // Shortest valid path: role → model → review/save.
814 // Thinking defaults to inherit; adjust on review with `t`.
815 self.step = Step::Review;
816 self.review_scroll = 0;
817 self.refresh_profile_status();
818 }
819 Some(FleetModelRowState::NeedsActivation) => {
820 // Dormant external-consent route: explicit human
821 // selection must mint the read capability and validate
822 // only this exact provider/model. Hand off to the host
823 // so rendering stays I/O-free.
824 if let Some((provider_id, model)) = self.model_routes.get(idx)
825 && let Some(provider) = crate::config::ApiProvider::parse(provider_id)
826 && crate::tui::provider_picker::external_consent_target_for_provider(
827 provider,
828 )
829 .is_some()
830 {
831 return ViewAction::Emit(
832 ViewEvent::FleetSetupExternalConsentActivationRequested {
833 provider_id: provider_id.clone(),
834 model: model.clone(),
835 },
836 );
837 }
838 }
839 Some(FleetModelRowState::Blocked { .. }) => {
840 // The summary line already shows the reason; stay on
841 // the Model step so the user can pick a different route.
842 }
843 None => {}
844 }
845 ViewAction::None
846 }
847 Step::Review => self.commit_starter_profile_action(),
848 }
849 }
850
851 /// Step back toward the first screen. Returns `None` at the first step (the
852 /// host closes the modal via Esc instead).
853 fn back(&mut self) -> ViewAction {
854 match self.step {
855 Step::Role => ViewAction::None,
856 Step::Model => {
857 self.step = Step::Role;
858 ViewAction::None
859 }
860 Step::Review => {
861 self.step = Step::Model;
862 ViewAction::None
863 }
864 }
865 }
866
867 /// Persist the deterministic starter profile directly from the Review
868 /// summary. Unlike a model-authored draft, every field is derived from the
869 /// structured choices already visible on this screen, so a second TOML
870 /// ratification state adds no trust boundary.
871 fn commit_starter_profile_action(&self) -> ViewAction {
872 ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested {
873 draft: self.starter_profile_draft(),
874 scope: self.profile_scope,
875 })
876 }
877
878 /// Build a deterministic starter profile for the current role/model
879 /// selection. The same save event persists this as model-drafted profiles,
880 /// so duplicate-id checks and atomic writes stay in one host path.
881 ///
882 /// `provider` is seeded from whatever the user actually picked in the
883 /// Model step (#4093) — a concrete route names its own provider
884 /// explicitly, so the saved profile is never ambiguously scoped to
885 /// whatever provider happens to be active at launch time. `inherit`
886 /// carries no provider, matching its `model: None`.
887 fn starter_profile_draft(&self) -> Box<crate::fleet::profile::FleetProfileDraft> {
888 let role = &ROLES[self.role_idx.min(ROLES.len() - 1)];
889 let route = self.selected_route();
890 Box::new(crate::fleet::profile::FleetProfileDraft {
891 id: profile_file_stem(&role.label),
892 display_name: Some(role.label.to_string()),
893 description: Some(format!("{} - {}", role.summary, role.description)),
894 role_hint: role.label.to_string(),
895 model_class_hint: None,
896 model: route.as_ref().map(|(_, model)| model.clone()),
897 provider: route.map(|(provider, _)| provider),
898 reasoning_effort: self.selected_reasoning_effort(),
899 instructions: Some(format!(
900 "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.",
901 role.label
902 )),
903 })
904 }
905
906 /// The action hints for the current step's footer (wrapped by the shared
907 /// footer renderer so they can never run off the modal edge).
908 fn footer_hints(&self) -> Vec<ActionHint> {
909 let mut hints = Vec::new();
910 match self.step {
911 Step::Role => {
912 hints.push(ActionHint::new("↑/↓", "choose"));
913 hints.push(ActionHint::new("Enter", "next"));
914 }
915 Step::Model => {
916 hints.push(ActionHint::new("↑/↓", "choose"));
917 hints.push(ActionHint::new("/", "filter"));
918 hints.push(ActionHint::new("Enter", "next"));
919 hints.push(ActionHint::new("←", "back"));
920 }
921 Step::Review => {
922 hints.push(ActionHint::new("↑/↓", "scroll"));
923 hints.push(ActionHint::new("s", "save location"));
924 hints.push(ActionHint::new("t", "thinking"));
925 if self.model_draft.is_some() {
926 hints.push(ActionHint::new("Enter", "Save profile"));
927 hints.push(ActionHint::new("g", "Save profile"));
928 hints.push(ActionHint::new("m", "redraft"));
929 } else {
930 hints.push(ActionHint::new("Enter/g", "save"));
931 if self.snapshot.provider_ready {
932 hints.push(ActionHint::new("m", "model draft"));
933 }
934 }
935 hints.push(ActionHint::new("←", "back"));
936 }
937 }
938 hints.push(ActionHint::new("Esc", "cancel"));
939 hints
940 }
941 }
942
943 impl ModalView for FleetSetupView {
944 fn kind(&self) -> ModalKind {
945 ModalKind::FleetSetup
946 }
947
948 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
949 self
950 }
951
952 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
953 match mouse.kind {
954 MouseEventKind::ScrollUp => self.move_up(),
955 MouseEventKind::ScrollDown => self.move_down(),
956 MouseEventKind::Down(MouseButton::Left) => {
957 let row = self.row_hitboxes.borrow().iter().find_map(|(rect, row)| {
958 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
959 .then_some(*row)
960 });
961 if let Some(row) = row {
962 match self.step {
963 Step::Role => self.role_idx = row.min(ROLES.len().saturating_sub(1)),
964 Step::Model => {
965 self.model_idx = row.min(self.step_len().saturating_sub(1));
966 }
967 Step::Review => {}
968 }
969 self.discard_model_draft();
970 }
971 }
972 _ => {}
973 }
974 ViewAction::None
975 }
976
977 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
978 // Model-step filter input captures keystrokes while active (#4639).
979 if self.step == Step::Model && self.model_filter_active {
980 match key.code {
981 KeyCode::Enter => {
982 self.model_filter_active = false;
983 }
984 KeyCode::Esc => {
985 self.model_filter_active = false;
986 self.model_query.clear();
987 self.model_idx = 0;
988 }
989 KeyCode::Backspace => {
990 self.model_query.pop();
991 self.model_idx = 0;
992 }
993 KeyCode::Up => {
994 self.move_up();
995 }
996 KeyCode::Down => {
997 self.move_down();
998 }
999 KeyCode::Char(ch)
1000 if !key.modifiers.intersects(
1001 KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
1002 ) =>
1003 {
1004 self.model_query.push(ch);
1005 self.model_idx = 0;
1006 }
1007 _ => {}
1008 }
1009 return ViewAction::None;
1010 }
1011 match key.code {
1012 KeyCode::Esc if self.step != Step::Role => self.back(),
1013 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
1014 KeyCode::Char('/') if self.step == Step::Model => {
1015 self.model_filter_active = true;
1016 ViewAction::None
1017 }
1018 KeyCode::Up | KeyCode::Char('k') => {
1019 self.move_up();
1020 ViewAction::None
1021 }
1022 KeyCode::Down | KeyCode::Char('j') => {
1023 self.move_down();
1024 ViewAction::None
1025 }
1026 KeyCode::Char('s') if self.step == Step::Review => {
1027 self.profile_scope = self.profile_scope.toggled();
1028 self.discard_model_draft();
1029 self.review_scroll = 0;
1030 self.refresh_profile_status();
1031 ViewAction::None
1032 }
1033 KeyCode::Char('t') if self.step == Step::Review => {
1034 self.thinking_idx = (self.thinking_idx + 1) % THINKING_CHOICES.len();
1035 self.discard_model_draft();
1036 ViewAction::None
1037 }
1038 KeyCode::Char('m') if self.step == Step::Review && self.snapshot.provider_ready => {
1039 let route = self.selected_route();
1040 ViewAction::Emit(ViewEvent::FleetProfileModelDraftRequested {
1041 role: self.selected_role(),
1042 model: route
1043 .as_ref()
1044 .map(|(_, model)| model.clone())
1045 .unwrap_or_else(|| "inherit".to_string()),
1046 // Carry the picked provider so the redrafted profile keeps
1047 // the cross-provider route (#4093). `install_model_draft`
1048 // re-injects it authoritatively from the wizard's current
1049 // selection, but the event stays self-describing.
1050 provider: route.map(|(provider, _)| provider),
1051 reasoning_effort: self.selected_reasoning_effort(),
1052 locale: self.snapshot.locale,
1053 })
1054 }
1055 KeyCode::Char('g') if self.step == Step::Review => {
1056 self.model_draft.clone().map_or_else(
1057 || self.commit_starter_profile_action(),
1058 |draft| {
1059 ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested {
1060 draft,
1061 scope: self.profile_scope,
1062 })
1063 },
1064 )
1065 }
1066 KeyCode::Enter | KeyCode::Right | KeyCode::Char('l')
1067 if self.step == Step::Review && self.model_draft.is_some() =>
1068 {
1069 // A save-ready draft is on screen; Enter should save it,
1070 // not silently start the manual profile-prompt flow and drop
1071 // the draft.
1072 match self.model_draft.clone() {
1073 Some(draft) => {
1074 ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested {
1075 draft,
1076 scope: self.profile_scope,
1077 })
1078 }
1079 None => ViewAction::None,
1080 }
1081 }
1082 KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => self.advance(),
1083 KeyCode::Left | KeyCode::Char('h') => self.back(),
1084 KeyCode::Home => {
1085 self.review_scroll = 0;
1086 ViewAction::None
1087 }
1088 KeyCode::PageUp => {
1089 self.review_scroll = self.review_scroll.saturating_sub(8);
1090 ViewAction::None
1091 }
1092 KeyCode::PageDown => {
1093 self.review_scroll = self.review_scroll.saturating_add(8);
1094 ViewAction::None
1095 }
1096 _ => ViewAction::None,
1097 }
1098 }
1099
1100 fn render(&self, area: Rect, buf: &mut Buffer) {
1101 self.row_hitboxes.borrow_mut().clear();
1102 // Choice steps have a bounded list/detail body and should not expand
1103 // into a tall empty card on roomy terminals. Review is proof-dense and
1104 // scrollable, so it keeps the extra row budgeted for the footer gutter.
1105 let preferred_height = match self.step {
1106 Step::Role => 21,
1107 Step::Model => 22,
1108 Step::Review => 31,
1109 };
1110 let popup_area = centered_modal_area(area, 96, preferred_height, 60, 16);
1111 render_modal_surface(area, popup_area, buf);
1112
1113 let step_no = match self.step {
1114 Step::Role => 1,
1115 Step::Model => 2,
1116 Step::Review => 3,
1117 };
1118 let block = Block::default()
1119 .title(Line::from(Span::styled(
1120 " Fleet setup — your agent team ",
1121 Style::default()
1122 .fg(palette::WHALE_ACTION)
1123 .add_modifier(Modifier::BOLD),
1124 )))
1125 .title_bottom(
1126 Line::from(Span::styled(
1127 format!(" Step {step_no}/3 "),
1128 Style::default().fg(palette::TEXT_MUTED),
1129 ))
1130 .alignment(ratatui::layout::Alignment::Right),
1131 )
1132 .borders(Borders::ALL)
1133 .border_style(Style::default().fg(palette::BORDER_COLOR))
1134 .style(Style::default().bg(palette::WHALE_BG))
1135 .padding(Padding::uniform(1));
1136
1137 let inner = block.inner(popup_area);
1138 block.render(popup_area, buf);
1139
1140 let hints = self.footer_hints();
1141 let content = render_modal_footer_with_gutter(inner, buf, &hints);
1142
1143 // Header (intro + breadcrumb) above the step body.
1144 let chunks = Layout::default()
1145 .direction(Direction::Vertical)
1146 .constraints([Constraint::Length(3), Constraint::Min(1)])
1147 .split(content);
1148 self.render_header(chunks[0], buf);
1149
1150 match self.step {
1151 Step::Role => {
1152 let mut context = vec![
1153 "Fleet runs sub-agents that delegate work. Pick the role this".to_string(),
1154 "team member should play. It becomes the profile role_hint.".to_string(),
1155 ];
1156 if let Some(note) = self.roster_override_note() {
1157 context.push(note);
1158 }
1159 render_choice_step(chunks[1], buf, &ROLES, self.role_idx, &context);
1160 register_choice_hitboxes(chunks[1], ROLES.len(), self.role_idx, &self.row_hitboxes);
1161 }
1162 Step::Model => {
1163 let filtered = self.filtered_model_indices();
1164 let filtered_choices: Vec<Choice> = filtered
1165 .iter()
1166 .map(|idx| self.model_choices[*idx].clone())
1167 .collect();
1168 let selected = self.model_idx.min(filtered.len().saturating_sub(1));
1169 let filter_line = if self.model_filter_active {
1170 format!("Filter: {}▏ (Enter keep · Esc clear)", self.model_query)
1171 } else if !self.model_query.trim().is_empty() {
1172 format!(
1173 "Filter: {} ({} of {} rows · / edit)",
1174 self.model_query,
1175 filtered.len(),
1176 self.model_choices.len()
1177 )
1178 } else {
1179 format!(
1180 "Type / to filter {} routes by provider or model",
1181 self.model_choices.len()
1182 )
1183 };
1184 render_choice_step(
1185 chunks[1],
1186 buf,
1187 &filtered_choices,
1188 selected,
1189 &[
1190 filter_line,
1191 format!(
1192 "Current route: {} / {} · reasoning {}",
1193 self.snapshot.provider, self.snapshot.model, self.snapshot.reasoning
1194 ),
1195 match self.selected_model() {
1196 Some(model) => format!("This worker will run on {model}."),
1197 None => "This worker inherits your current route.".to_string(),
1198 },
1199 ],
1200 );
1201 register_choice_hitboxes(
1202 chunks[1],
1203 filtered_choices.len(),
1204 selected,
1205 &self.row_hitboxes,
1206 );
1207 }
1208 Step::Review => self.render_review(chunks[1], buf),
1209 }
1210 }
1211 }
1212
1213 impl FleetSetupView {
1214 fn render_header(&self, area: Rect, buf: &mut Buffer) {
1215 let (title, subtitle) = match self.step {
1216 Step::Role => (
1217 "Choose a team role",
1218 "Each Fleet member plays one role in the delegation.",
1219 ),
1220 Step::Model => (
1221 "Choose a model",
1222 "Pick this worker's model, or inherit your current route.",
1223 ),
1224 Step::Review if self.model_draft.is_some() => (
1225 "Save profile",
1226 "Exact TOML shown below. Press Enter or g to save, m to redraft.",
1227 ),
1228 Step::Review => (
1229 "Review & save",
1230 "Confirm provider, model, readiness, profile availability, and overwrite, then save the profile.",
1231 ),
1232 };
1233 let lines = vec![
1234 Line::from(Span::styled(
1235 title,
1236 Style::default().fg(palette::WHALE_INFO).bold(),
1237 )),
1238 Line::from(Span::styled(
1239 subtitle,
1240 Style::default().fg(palette::TEXT_MUTED),
1241 )),
1242 ];
1243 Paragraph::new(lines)
1244 .wrap(Wrap { trim: true })
1245 .render(area, buf);
1246 }
1247
1248 fn render_review(&self, area: Rect, buf: &mut Buffer) {
1249 // A ratify-ready draft is on screen: show the exact TOML preview
1250 // inline, scrolled by the same `review_scroll` state, so `g`/Enter in
1251 // THIS view's own `handle_key` ratify it directly — no separate pager
1252 // in the way to swallow the keypress (#4093).
1253 if let Some(preview) = self.model_draft_preview.as_deref() {
1254 render_scrollable_text(area, buf, preview, self.review_scroll);
1255 return;
1256 }
1257
1258 let role = &ROLES[self.role_idx.min(ROLES.len() - 1)];
1259 // Cached on entry to this step and on scope toggle; see `profile_status`.
1260 let profile_value = self
1261 .profile_status
1262 .as_ref()
1263 .map(|(value, _)| value.clone())
1264 .unwrap_or_default();
1265 let file_stem = profile_file_stem(&role.label);
1266 let mut lines: Vec<Line> = Vec::new();
1267 let section = |lines: &mut Vec<Line>, label: &str, body: String| {
1268 lines.push(Line::from(Span::styled(
1269 label.to_string(),
1270 Style::default().fg(palette::WHALE_INFO).bold(),
1271 )));
1272 lines.push(Line::from(Span::styled(
1273 body,
1274 Style::default().fg(palette::TEXT_PRIMARY),
1275 )));
1276 lines.push(Line::from(""));
1277 };
1278
1279 section(
1280 &mut lines,
1281 "Role",
1282 match self.roster_override_note() {
1283 Some(note) => format!("{} — {} · {note}", role.label, role.summary),
1284 None => format!("{} — {}", role.label, role.summary),
1285 },
1286 );
1287 section(
1288 &mut lines,
1289 "Model",
1290 // The picked route's OWN provider, not the parent/current
1291 // session's — a cross-provider pin must never be misreported as
1292 // running on the active provider (#4093).
1293 match self.selected_route() {
1294 Some((provider, model)) => {
1295 let readiness = self
1296 .snapshot
1297 .available_models
1298 .iter()
1299 .find(|(candidate_provider, candidate_model, _)| {
1300 candidate_provider == &provider && candidate_model == &model
1301 })
1302 .map(|(_, _, readiness)| readiness.label().into_owned())
1303 .unwrap_or_else(|| {
1304 if self.snapshot.provider_ready {
1305 "ready".to_string()
1306 } else {
1307 "needs action".to_string()
1308 }
1309 });
1310 format!(
1311 "{model} · provider {} · {readiness}",
1312 provider_display_label(&provider)
1313 )
1314 }
1315 None => format!(
1316 "inherit · route {} / {} · {}",
1317 self.snapshot.provider,
1318 self.snapshot.model,
1319 if self.snapshot.provider_ready {
1320 "ready"
1321 } else {
1322 "needs action"
1323 }
1324 ),
1325 },
1326 );
1327 section(&mut lines, "Thinking", self.selected_thinking_label());
1328 section(
1329 &mut lines,
1330 "Profile availability",
1331 match self.profile_scope {
1332 FleetProfileScope::Project => format!(
1333 "Project — saved with this repository at {PROFILE_DIR}. Press s for a personal profile reusable across repositories. This choice only controls where the profile is available; active workspace, trusted-path, and permission policy still govern execution."
1334 ),
1335 FleetProfileScope::Personal => format!(
1336 "Personal — reusable at {}; project profiles override by id. Press s for project. Scope changes discovery only; workspace, trusted-path, and permission policy still govern execution.",
1337 self.profile_scope.display_dir()
1338 ),
1339 },
1340 );
1341 section(
1342 &mut lines,
1343 "Auth & readiness",
1344 if self.snapshot.provider_ready {
1345 "Active route can be attempted with the current credentials.".to_string()
1346 } else {
1347 "Active route is not ready — fix auth/readiness before relying on this profile at runtime.".to_string()
1348 },
1349 );
1350 section(
1351 &mut lines,
1352 "Permissions",
1353 "Inherit the parent envelope and narrow only. Children cannot widen approval, trust, or secrets, and required approvals stay on.".to_string(),
1354 );
1355 section(
1356 &mut lines,
1357 "Tools",
1358 "Read tools by default; write tools for builders within scope; shell stays policy-gated; artifacts and receipts stay inspectable.".to_string(),
1359 );
1360 section(
1361 &mut lines,
1362 "Workspace & org",
1363 format!(
1364 "{} · sub-agents {} ({} concurrent, {} launch slots, {} admitted) · recursion agent {} / fleet {} (ceiling {})",
1365 self.snapshot.workspace.display(),
1366 if self.snapshot.subagents_enabled {
1367 "enabled"
1368 } else {
1369 "disabled"
1370 },
1371 self.snapshot.max_subagents,
1372 self.snapshot.launch_concurrency,
1373 self.snapshot.max_admitted,
1374 self.snapshot.subagent_spawn_depth,
1375 self.snapshot.fleet_spawn_depth,
1376 codewhale_config::MAX_SPAWN_DEPTH_CEILING,
1377 ),
1378 );
1379 section(&mut lines, "Review policy", self.review_policy_summary());
1380 section(
1381 &mut lines,
1382 "Profile",
1383 format!(
1384 "{}/{file_stem}.toml · {profile_value} present. Press Enter or g once to save the deterministic starter profile.",
1385 self.profile_scope.display_dir(),
1386 ),
1387 );
1388
1389 // `scroll` offsets by *visual* (post-wrap) rows, so the bound must count
1390 // wrapped rows — not logical lines — or the bottom sections become
1391 // unreachable. Estimate each line's wrapped height from its display
1392 // width; an over-estimate is harmless (scroll clamps at the real end).
1393 let wrap_width = usize::from(area.width).max(1);
1394 let visual_rows: usize = lines
1395 .iter()
1396 .map(|line| line.width().div_ceil(wrap_width).max(1))
1397 .sum();
1398 let max_scroll = visual_rows.saturating_sub(usize::from(area.height).max(1));
1399 let scroll = self.review_scroll.min(max_scroll);
1400 Paragraph::new(lines)
1401 .wrap(Wrap { trim: true })
1402 .scroll((scroll as u16, 0))
1403 .render(area, buf);
1404 }
1405
1406 fn review_policy_summary(&self) -> String {
1407 format!(
1408 "Workers run without a token cap by default · {}s api, {}s heartbeat. Launch with Fleet → exec; /fleet workers (or /subagents) shows sub-agents in the current interactive session; /fleet status and codewhale fleet status both read the persistent .codewhale/fleet.jsonl ledger.",
1409 self.snapshot.api_timeout_secs, self.snapshot.heartbeat_timeout_secs
1410 )
1411 }
1412 }
1413
1414 /// Render wrapped, line-scrolled plain text (the ratify-ready draft TOML
1415 /// preview) into `area`, clamping `scroll` to the real wrapped-row bound the
1416 /// same way [`FleetSetupView::render_review`]'s summary does — an
1417 /// over-estimate of wrapped height is harmless (scroll clamps at the end).
1418 fn render_scrollable_text(area: Rect, buf: &mut Buffer, text: &str, scroll: usize) {
1419 let lines: Vec<Line> = text
1420 .lines()
1421 .map(|line| Line::from(line.to_string()))
1422 .collect();
1423 let wrap_width = usize::from(area.width).max(1);
1424 let visual_rows: usize = lines
1425 .iter()
1426 .map(|line| line.width().div_ceil(wrap_width).max(1))
1427 .sum();
1428 let max_scroll = visual_rows.saturating_sub(usize::from(area.height).max(1));
1429 let scroll = scroll.min(max_scroll);
1430 Paragraph::new(lines)
1431 .wrap(Wrap { trim: true })
1432 .scroll((scroll as u16, 0))
1433 .render(area, buf);
1434 }
1435
1436 /// Render a wizard choice step: a list of selectable identifiers on the left and
1437 /// a wrapped detail pane (summary + description + context) on the right. Stacks
1438 /// vertically when the body is too narrow for two columns so nothing truncates.
1439 fn render_choice_step(
1440 area: Rect,
1441 buf: &mut Buffer,
1442 choices: &[Choice],
1443 selected: usize,
1444 context: &[String],
1445 ) {
1446 if area.width == 0 || area.height == 0 {
1447 return;
1448 }
1449
1450 let (list_area, detail_area) = if area.width >= CHOICE_TWO_COLUMN_MIN_WIDTH {
1451 let cols = Layout::default()
1452 .direction(Direction::Horizontal)
1453 .constraints([
1454 Constraint::Length(CHOICE_LIST_WIDTH),
1455 Constraint::Min(CHOICE_DETAIL_MIN_WIDTH),
1456 ])
1457 .split(area);
1458 (cols[0], cols[1])
1459 } else {
1460 let list_height = (choices.len() as u16).min(area.height.saturating_sub(1).max(1));
1461 let rows = Layout::default()
1462 .direction(Direction::Vertical)
1463 .constraints([Constraint::Length(list_height), Constraint::Min(1)])
1464 .split(area);
1465 (rows[0], rows[1])
1466 };
1467
1468 // List: labels are identifiers, so a `▸`-marked single line each is safe.
1469 let list_width = usize::from(list_area.width);
1470 let visible = choices.len().min(usize::from(list_area.height));
1471 let row_start = choice_window_start(choices.len(), selected, visible);
1472 let mut list_lines: Vec<Line> = Vec::with_capacity(visible);
1473 for (idx, choice) in choices.iter().enumerate().skip(row_start).take(visible) {
1474 let is_selected = idx == selected;
1475 let pointer = format!("{} ", crate::tui::glyphs::selection_marker(is_selected));
1476 let style = if is_selected {
1477 menu_style::selected_row_style()
1478 } else {
1479 Style::default().fg(palette::TEXT_PRIMARY)
1480 };
1481 list_lines.push(Line::from(Span::styled(
1482 truncate_view_text(&format!("{pointer}{}", choice.label), list_width),
1483 style,
1484 )));
1485 }
1486 Paragraph::new(list_lines).render(list_area, buf);
1487
1488 // Detail: summary + wrapped description + wrapped context, all word-wrapped.
1489 let choice = &choices[selected.min(choices.len().saturating_sub(1))];
1490 let mut detail_lines: Vec<Line> = vec![
1491 Line::from(Span::styled(
1492 choice.summary.clone(),
1493 Style::default().fg(palette::WHALE_ACTION).bold(),
1494 )),
1495 Line::from(""),
1496 Line::from(Span::styled(
1497 choice.description.clone(),
1498 Style::default().fg(palette::TEXT_PRIMARY),
1499 )),
1500 ];
1501 if !context.is_empty() {
1502 detail_lines.push(Line::from(""));
1503 for entry in context {
1504 detail_lines.push(Line::from(Span::styled(
1505 entry.clone(),
1506 Style::default().fg(palette::TEXT_MUTED),
1507 )));
1508 }
1509 }
1510 Paragraph::new(detail_lines)
1511 .wrap(Wrap { trim: true })
1512 .render(detail_area, buf);
1513 }
1514
1515 /// Register exactly the list column/stack rows painted by
1516 /// [`render_choice_step`]. The detail pane intentionally owns no hitboxes.
1517 fn register_choice_hitboxes(
1518 area: Rect,
1519 choice_count: usize,
1520 selected: usize,
1521 hitboxes: &RefCell<Vec<(Rect, usize)>>,
1522 ) {
1523 if area.width == 0 || area.height == 0 || choice_count == 0 {
1524 return;
1525 }
1526 let list_area = if area.width >= CHOICE_TWO_COLUMN_MIN_WIDTH {
1527 Layout::default()
1528 .direction(Direction::Horizontal)
1529 .constraints([
1530 Constraint::Length(CHOICE_LIST_WIDTH),
1531 Constraint::Min(CHOICE_DETAIL_MIN_WIDTH),
1532 ])
1533 .split(area)[0]
1534 } else {
1535 let list_height = (choice_count as u16).min(area.height.saturating_sub(1).max(1));
1536 Layout::default()
1537 .direction(Direction::Vertical)
1538 .constraints([Constraint::Length(list_height), Constraint::Min(1)])
1539 .split(area)[0]
1540 };
1541 let visible = choice_count.min(usize::from(list_area.height));
1542 let row_start = choice_window_start(choice_count, selected, visible);
1543 let mut rows = hitboxes.borrow_mut();
1544 rows.extend((0..visible).map(|visible_idx| {
1545 let choice_idx = row_start + visible_idx;
1546 (
1547 Rect::new(
1548 list_area.x,
1549 list_area.y.saturating_add(visible_idx as u16),
1550 list_area.width,
1551 1,
1552 ),
1553 choice_idx,
1554 )
1555 }));
1556 }
1557
1558 fn choice_window_start(total: usize, selected: usize, visible: usize) -> usize {
1559 if total <= visible || visible == 0 {
1560 return 0;
1561 }
1562 selected
1563 .saturating_add(1)
1564 .saturating_sub(visible)
1565 .min(total.saturating_sub(visible))
1566 }
1567
1568 fn profile_file_status(scope: FleetProfileScope, workspace: &Path) -> (String, String) {
1569 let dir = match crate::fleet::profile::agent_profile_dir_for_scope(scope, workspace) {
1570 Ok(dir) => dir,
1571 Err(err) => {
1572 return (
1573 "blocked".to_string(),
1574 format!("profile save location unavailable: {err:#}"),
1575 );
1576 }
1577 };
1578 let display_dir = scope.display_dir();
1579 if !dir.exists() {
1580 return (
1581 "0 files".to_string(),
1582 format!("create {display_dir}/*.toml"),
1583 );
1584 }
1585 if !dir.is_dir() {
1586 return (
1587 "blocked".to_string(),
1588 format!("{} is not a dir", dir.display()),
1589 );
1590 }
1591
1592 let count = std::fs::read_dir(&dir)
1593 .ok()
1594 .into_iter()
1595 .flat_map(|entries| entries.flatten())
1596 .filter(|entry| entry.path().extension().and_then(|value| value.to_str()) == Some("toml"))
1597 .count();
1598
1599 if count == 1 {
1600 ("1 file".to_string(), display_dir.to_string())
1601 } else {
1602 (format!("{count} files"), display_dir.to_string())
1603 }
1604 }
1605
1606 /// Sanitize a planner role label into a safe TOML file stem.
1607 fn profile_file_stem(role: &str) -> String {
1608 let stem: String = role
1609 .chars()
1610 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
1611 .collect();
1612 let stem = stem.trim_matches('-').to_ascii_lowercase();
1613 if stem.is_empty() {
1614 "custom".to_string()
1615 } else {
1616 stem
1617 }
1618 }
1619
1620 #[cfg(test)]
1621 mod tests {
1622 use super::*;
1623 use crate::tui::views::ViewStack;
1624 use crossterm::event::KeyModifiers;
1625 use unicode_width::UnicodeWidthStr;
1626
1627 const BLOCKER_SIZES: [(u16, u16); 5] = [(80, 24), (89, 50), (100, 30), (120, 32), (160, 40)];
1628
1629 fn snapshot() -> FleetSetupSnapshot {
1630 FleetSetupSnapshot {
1631 workspace: PathBuf::from("/tmp/codewhale-test-workspace"),
1632 locale: crate::localization::Locale::En,
1633 provider_ready: true,
1634 provider: "DeepSeek".to_string(),
1635 model: "deepseek-v4-pro".to_string(),
1636 reasoning: "Auto".to_string(),
1637 subagents_enabled: true,
1638 max_subagents: 8,
1639 launch_concurrency: 3,
1640 max_admitted: 20,
1641 subagent_spawn_depth: 3,
1642 fleet_spawn_depth: 3,
1643 api_timeout_secs: 120,
1644 heartbeat_timeout_secs: 300,
1645 roster_members: crate::fleet::roster::FleetRoster::built_ins_only()
1646 .members()
1647 .iter()
1648 .map(|member| (member.id.to_lowercase(), member.origin.to_string()))
1649 .collect(),
1650 available_models: vec![
1651 (
1652 "deepseek".to_string(),
1653 "deepseek-v4-pro".to_string(),
1654 crate::provider_readiness::ResolvedProviderReadiness::SavedUnchecked,
1655 ),
1656 (
1657 "deepseek".to_string(),
1658 "deepseek-v4-flash".to_string(),
1659 crate::provider_readiness::ResolvedProviderReadiness::SavedUnchecked,
1660 ),
1661 ],
1662 }
1663 }
1664
1665 fn key(code: KeyCode) -> KeyEvent {
1666 KeyEvent::new(code, KeyModifiers::NONE)
1667 }
1668
1669 fn sample_draft() -> Box<crate::fleet::profile::FleetProfileDraft> {
1670 let crate::fleet::profile::UntrustedProfileParse::Drafted(draft) =
1671 crate::fleet::profile::FleetProfileDraft::from_untrusted_json(
1672 r#"{"id":"reviewer","role_hint":"reviewer","description":"Reviews diffs.","instructions":"Read. Report. Stop."}"#,
1673 )
1674 else {
1675 panic!("sample draft should parse");
1676 };
1677 draft
1678 }
1679
1680 /// #5038: the Model step's detail pane carries capability badges for
1681 /// known catalog models and honestly omits them for unknown models, so
1682 /// stale/absent data never blocks selection.
1683 #[test]
1684 fn model_step_detail_shows_capability_badges_for_known_models_only() {
1685 let mut snap = snapshot();
1686 snap.available_models.push((
1687 "deepseek".to_string(),
1688 "totally-made-up-model-xyz".to_string(),
1689 crate::provider_readiness::ResolvedProviderReadiness::SavedUnchecked,
1690 ));
1691 let view = FleetSetupView::from_snapshot(snap);
1692
1693 let known = view
1694 .model_choices
1695 .iter()
1696 .find(|choice| choice.label == "deepseek-v4-pro")
1697 .expect("known catalog model row");
1698 assert!(
1699 known.description.contains("Capabilities:"),
1700 "{}",
1701 known.description
1702 );
1703 assert!(
1704 known.description.contains("1M ctx"),
1705 "{}",
1706 known.description
1707 );
1708 assert!(
1709 known.description.contains("catalog"),
1710 "catalog-backed rows must name catalog provenance: {}",
1711 known.description
1712 );
1713
1714 let unknown = view.model_choices.last().expect("appended unknown row");
1715 assert_eq!(unknown.label, "totally-made-up-model-xyz");
1716 assert!(
1717 !unknown.description.contains("Capabilities:"),
1718 "{}",
1719 unknown.description
1720 );
1721 // The unknown row stays selectable; absence of data is not a block.
1722 assert_eq!(
1723 view.model_row_states.last(),
1724 Some(&FleetModelRowState::Ready)
1725 );
1726 }
1727
1728 #[test]
1729 fn provider_display_label_preserves_case_colliding_custom_ids() {
1730 assert_eq!(provider_display_label("deepseek"), "DeepSeek");
1731 assert_eq!(provider_display_label("CUSTOM"), "CUSTOM");
1732 assert_eq!(provider_display_label("OPENAI"), "OPENAI");
1733 }
1734
1735 fn to_review(view: &mut FleetSetupView) {
1736 view.handle_key(key(KeyCode::Enter)); // Role -> Model
1737 view.handle_key(key(KeyCode::Enter)); // Model -> Review
1738 assert_eq!(view.step, Step::Review);
1739 }
1740
1741 #[test]
1742 fn review_step_m_requests_model_draft_with_current_answers() {
1743 let mut view = FleetSetupView::from_snapshot(snapshot());
1744 to_review(&mut view);
1745
1746 let action = view.handle_key(key(KeyCode::Char('m')));
1747 let ViewAction::Emit(ViewEvent::FleetProfileModelDraftRequested {
1748 role,
1749 model,
1750 provider,
1751 reasoning_effort,
1752 locale,
1753 }) = action
1754 else {
1755 panic!("expected model draft request");
1756 };
1757 assert!(!role.is_empty());
1758 assert!(!model.is_empty());
1759 // Default selection is `inherit` (model_idx 0), which carries no
1760 // concrete provider route.
1761 assert_eq!(provider, None);
1762 assert_eq!(reasoning_effort, None);
1763 assert_eq!(locale, crate::localization::Locale::En);
1764 }
1765
1766 #[test]
1767 fn m_redraft_preserves_a_cross_provider_pick_regression_4093() {
1768 // #4093 BLOCKER 2 regression: a cross-provider route pick followed by an
1769 // `m` model-assisted redraft must STILL persist the picked provider. A
1770 // model draft comes from `from_untrusted_json`, which hard-sets
1771 // `provider: None` (and can echo any model). Without re-injection the
1772 // ratified profile would carry `model` with no `provider` — the exact
1773 // ambiguous, provider-scoped profile #4093 removes.
1774 //
1775 // The active/session provider is DeepSeek; the picked route is a
1776 // GLM model on Zai — a genuinely different provider than the parent.
1777 let mut snap = snapshot();
1778 snap.provider = "DeepSeek".to_string();
1779 snap.model = "deepseek-v4-pro".to_string();
1780 snap.available_models = vec![(
1781 "zai".to_string(),
1782 "glm-5.2".to_string(),
1783 crate::provider_readiness::ResolvedProviderReadiness::SavedUnchecked,
1784 )];
1785 let mut view = FleetSetupView::from_snapshot(snap);
1786
1787 // Role step: keep the first role. Model step: inherit(0), then the one
1788 // cross-provider row (1) -> pick it. Then advance to Review.
1789 view.handle_key(key(KeyCode::Enter)); // Role -> Model
1790 view.handle_key(key(KeyCode::Down)); // -> the zai/glm-5.2 row
1791 assert_eq!(
1792 view.selected_route(),
1793 Some(("zai".to_string(), "glm-5.2".to_string()))
1794 );
1795 view.handle_key(key(KeyCode::Enter)); // Model -> Review
1796 while view.selected_reasoning_effort().as_deref() != Some("max") {
1797 view.handle_key(key(KeyCode::Char('t')));
1798 }
1799
1800 // `m` requests a draft and carries the picked cross-provider route.
1801 let action = view.handle_key(key(KeyCode::Char('m')));
1802 let ViewAction::Emit(ViewEvent::FleetProfileModelDraftRequested {
1803 model,
1804 provider,
1805 reasoning_effort,
1806 ..
1807 }) = action
1808 else {
1809 panic!("expected model draft request");
1810 };
1811 assert_eq!(model, "glm-5.2");
1812 assert_eq!(provider.as_deref(), Some("zai"));
1813 assert_eq!(reasoning_effort.as_deref(), Some("max"));
1814
1815 // The host reconstructs the picked route from the event exactly as
1816 // `handle_fleet_profile_model_draft` does, and carries it to
1817 // `install_model_draft` (immune to the selection changing mid-draft).
1818 let picked_route = provider.map(|provider| (provider, model.clone()));
1819
1820 // The model returns a draft that (as always) has provider: None — the
1821 // untrusted gate strips any provider a model tries to smuggle.
1822 let drafted = sample_draft();
1823 assert_eq!(drafted.provider, None);
1824
1825 // Installing it re-injects the picked route, so the ratified draft keeps
1826 // BOTH the provider and the model the user actually chose, plus the
1827 // captured thinking tier.
1828 let (_title, content) = view.install_model_draft(
1829 drafted,
1830 "GLM-5.2".to_string(),
1831 picked_route,
1832 reasoning_effort,
1833 );
1834 let ratified = view.model_draft.as_deref().expect("draft installed");
1835 assert_eq!(ratified.provider.as_deref(), Some("zai"));
1836 assert_eq!(ratified.model.as_deref(), Some("glm-5.2"));
1837 assert_eq!(ratified.reasoning_effort.as_deref(), Some("max"));
1838
1839 // The rendered TOML the ratify keypress would persist names the provider
1840 // explicitly — never a provider-scoped ambiguity.
1841 assert!(content.contains("provider = \"zai\""), "{content}");
1842 assert!(content.contains("model = \"glm-5.2\""), "{content}");
1843 assert!(content.contains("reasoning_effort = \"max\""), "{content}");
1844
1845 // And ratifying commits exactly that route.
1846 let action = view.handle_key(key(KeyCode::Char('g')));
1847 let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft, scope }) =
1848 action
1849 else {
1850 panic!("expected ratify commit event");
1851 };
1852 assert_eq!(scope, FleetProfileScope::Personal);
1853 assert_eq!(draft.provider.as_deref(), Some("zai"));
1854 assert_eq!(draft.model.as_deref(), Some("glm-5.2"));
1855 assert_eq!(draft.reasoning_effort.as_deref(), Some("max"));
1856 }
1857
1858 #[test]
1859 fn model_step_filter_narrows_large_catalogs_by_provider_and_model() {
1860 let mut snap = snapshot();
1861 // Simulate an OpenRouter-scale catalog: many rows from one provider.
1862 for i in 0..120 {
1863 snap.available_models.push((
1864 "openrouter".to_string(),
1865 format!("vendor/model-{i:03}"),
1866 crate::provider_readiness::ResolvedProviderReadiness::SavedUnchecked,
1867 ));
1868 }
1869 snap.available_models.push((
1870 "openrouter".to_string(),
1871 "z-ai/glm-5-turbo".to_string(),
1872 crate::provider_readiness::ResolvedProviderReadiness::SavedUnchecked,
1873 ));
1874 let mut view = FleetSetupView::from_snapshot(snap);
1875 // Role → Model.
1876 view.handle_key(key(KeyCode::Enter));
1877 let full_len = view.step_len();
1878 assert!(full_len > 120, "unfiltered shows the whole catalog");
1879
1880 // `/` opens the filter; typing narrows by model id substring.
1881 view.handle_key(key(KeyCode::Char('/')));
1882 for ch in "glm".chars() {
1883 view.handle_key(key(KeyCode::Char(ch)));
1884 }
1885 assert_eq!(view.step_len(), 1, "only the glm row survives the filter");
1886 let route = view.selected_route().expect("filtered selection resolves");
1887 assert_eq!(
1888 route,
1889 ("openrouter".to_string(), "z-ai/glm-5-turbo".to_string())
1890 );
1891
1892 // Provider substring filters too.
1893 view.handle_key(key(KeyCode::Esc));
1894 view.handle_key(key(KeyCode::Char('/')));
1895 for ch in "deepseek".chars() {
1896 view.handle_key(key(KeyCode::Char(ch)));
1897 }
1898 // inherit's route IS the active DeepSeek route, so it matches too.
1899 assert_eq!(
1900 view.step_len(),
1901 3,
1902 "deepseek rows plus the inherit (active deepseek route) match"
1903 );
1904
1905 // Enter keeps the filter but releases the input; Esc in filter clears.
1906 view.handle_key(key(KeyCode::Enter));
1907 assert!(!view.model_filter_active);
1908 assert_eq!(view.step_len(), 3);
1909 view.handle_key(key(KeyCode::Char('/')));
1910 view.handle_key(key(KeyCode::Esc));
1911 assert_eq!(
1912 view.step_len(),
1913 full_len,
1914 "clearing restores the full catalog"
1915 );
1916 }
1917
1918 #[test]
1919 fn review_saves_starter_or_ratifies_installed_model_draft() {
1920 let mut view = FleetSetupView::from_snapshot(snapshot());
1921 to_review(&mut view);
1922
1923 // A structured starter draft is save-ready from the summary.
1924 let action = view.handle_key(key(KeyCode::Char('g')));
1925 let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft, scope }) =
1926 action
1927 else {
1928 panic!("expected starter commit event");
1929 };
1930 assert_eq!(scope, FleetProfileScope::Personal);
1931 assert_eq!(draft.id, "manager");
1932
1933 let mut view = FleetSetupView::from_snapshot(snapshot());
1934 to_review(&mut view);
1935 let (title, content) =
1936 view.install_model_draft(sample_draft(), "GLM-5.2".to_string(), None, None);
1937 assert!(title.contains("GLM-5.2"));
1938 assert!(content.contains("id = \"reviewer\""), "{content}");
1939 assert!(content.contains("Nothing is saved until"), "{content}");
1940
1941 let action = view.handle_key(key(KeyCode::Char('g')));
1942 let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft, scope }) =
1943 action
1944 else {
1945 panic!("expected ratify commit event");
1946 };
1947 assert_eq!(scope, FleetProfileScope::Personal);
1948 assert_eq!(draft.id, "reviewer");
1949 }
1950
1951 #[test]
1952 fn changing_answers_discards_a_stale_draft() {
1953 let mut view = FleetSetupView::from_snapshot(snapshot());
1954 to_review(&mut view);
1955 let _ = view.install_model_draft(sample_draft(), "GLM-5.2".to_string(), None, None);
1956 assert!(view.model_draft.is_some());
1957
1958 // Back to the role step and change the selection: the draft no
1959 // longer matches the answers and must not survive to ratification.
1960 view.handle_key(key(KeyCode::Left));
1961 view.handle_key(key(KeyCode::Left));
1962 view.handle_key(key(KeyCode::Left));
1963 assert_eq!(view.step, Step::Role);
1964 view.handle_key(key(KeyCode::Down));
1965 assert!(view.model_draft.is_none());
1966
1967 to_review(&mut view);
1968 let action = view.handle_key(key(KeyCode::Char('g')));
1969 let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft, .. }) =
1970 action
1971 else {
1972 panic!("expected fresh deterministic starter");
1973 };
1974 assert_eq!(draft.id, "scout");
1975 }
1976
1977 #[test]
1978 fn arrows_move_within_step_and_enter_advances() {
1979 let mut view = FleetSetupView::from_snapshot(snapshot());
1980 assert_eq!(view.step, Step::Role);
1981
1982 view.handle_key(key(KeyCode::Down));
1983 assert_eq!(view.role_idx, 1);
1984
1985 view.handle_key(key(KeyCode::Enter));
1986 assert_eq!(view.step, Step::Model);
1987
1988 view.handle_key(key(KeyCode::Down));
1989 assert_eq!(view.model_idx, 1);
1990
1991 view.handle_key(key(KeyCode::Enter));
1992 assert_eq!(view.step, Step::Review);
1993
1994 // `t` cycles thinking on the review step without an extra wizard screen.
1995 view.handle_key(key(KeyCode::Char('t')));
1996 assert_eq!(view.thinking_idx, 1);
1997
1998 // Left steps back through the wizard.
1999 view.handle_key(key(KeyCode::Left));
2000 assert_eq!(view.step, Step::Model);
2001 view.handle_key(key(KeyCode::Left));
2002 assert_eq!(view.step, Step::Role);
2003 }
2004
2005 #[test]
2006 fn roster_role_handoff_starts_at_model_and_can_return_to_role() {
2007 let mut via_left = FleetSetupView::from_snapshot_for_role(snapshot(), "consultant");
2008 assert_eq!(via_left.step, Step::Model);
2009 assert_eq!(via_left.selected_role(), "consultant");
2010 assert!(matches!(
2011 via_left.handle_key(key(KeyCode::Left)),
2012 ViewAction::None
2013 ));
2014 assert_eq!(via_left.step, Step::Role);
2015 assert_eq!(via_left.selected_role(), "consultant");
2016
2017 let mut via_esc = FleetSetupView::from_snapshot_for_role(snapshot(), "reviewer");
2018 assert_eq!(via_esc.step, Step::Model);
2019 assert_eq!(via_esc.selected_role(), "reviewer");
2020 assert!(matches!(
2021 via_esc.handle_key(key(KeyCode::Esc)),
2022 ViewAction::None
2023 ));
2024 assert_eq!(via_esc.step, Step::Role);
2025 assert_eq!(via_esc.selected_role(), "reviewer");
2026
2027 let custom = FleetSetupView::from_snapshot_for_role(snapshot(), "domain-expert");
2028 assert_eq!(custom.step, Step::Model);
2029 assert_eq!(custom.selected_role(), "custom");
2030 }
2031
2032 #[test]
2033 fn esc_steps_back_then_cancels_from_role() {
2034 let mut view = FleetSetupView::from_snapshot(snapshot());
2035 view.handle_key(key(KeyCode::Enter)); // -> Model
2036 let action = view.handle_key(key(KeyCode::Esc));
2037 assert!(matches!(action, ViewAction::None));
2038 assert_eq!(view.step, Step::Role);
2039 let action = view.handle_key(key(KeyCode::Esc));
2040 assert!(matches!(action, ViewAction::Close));
2041 }
2042
2043 #[test]
2044 fn mouse_selects_rows_and_wheel_matches_keyboard_navigation() {
2045 let mut view = FleetSetupView::from_snapshot(snapshot());
2046 let area = Rect::new(0, 0, 120, 40);
2047 let mut buf = Buffer::empty(area);
2048 view.render(area, &mut buf);
2049 let (rect, row) = view.row_hitboxes.borrow()[2];
2050
2051 view.handle_mouse(MouseEvent {
2052 kind: MouseEventKind::Down(MouseButton::Left),
2053 column: rect.x,
2054 row: rect.y,
2055 modifiers: KeyModifiers::NONE,
2056 });
2057 assert_eq!(row, 2);
2058 assert_eq!(view.role_idx, 2);
2059
2060 view.handle_mouse(MouseEvent {
2061 kind: MouseEventKind::ScrollDown,
2062 column: rect.x,
2063 row: rect.y,
2064 modifiers: KeyModifiers::NONE,
2065 });
2066 assert_eq!(view.role_idx, 3);
2067 view.handle_mouse(MouseEvent {
2068 kind: MouseEventKind::ScrollUp,
2069 column: rect.x,
2070 row: rect.y,
2071 modifiers: KeyModifiers::NONE,
2072 });
2073 assert_eq!(view.role_idx, 2);
2074 }
2075
2076 #[test]
2077 fn compact_choice_window_keeps_deep_selection_visible_and_clickable() {
2078 let mut view = FleetSetupView::from_snapshot(snapshot());
2079 view.role_idx = ROLES.len() - 1;
2080 let area = Rect::new(0, 0, 80, 16);
2081 let mut buf = Buffer::empty(area);
2082 view.render(area, &mut buf);
2083 let rendered = (0..area.height)
2084 .map(|y| {
2085 (0..area.width)
2086 .map(|x| buf[(x, y)].symbol())
2087 .collect::<String>()
2088 })
2089 .collect::<Vec<_>>()
2090 .join("\n");
2091
2092 assert!(rendered.contains("▸ custom"), "{rendered}");
2093 assert!(
2094 view.row_hitboxes
2095 .borrow()
2096 .iter()
2097 .any(|(_, idx)| *idx == ROLES.len() - 1),
2098 "selected row needs an aligned mouse hitbox"
2099 );
2100 }
2101
2102 /// #3908: `render_review` recomputed `profile_file_status` — `exists()` +
2103 /// `is_dir()` + a full `read_dir` extension count — on every paint. It is
2104 /// now computed on the transitions that can change it, so the value the
2105 /// Review step paints must be present and must track a scope toggle.
2106 #[test]
2107 fn review_profile_status_is_cached_on_transitions_not_recomputed_per_paint() {
2108 let mut view = FleetSetupView::from_snapshot(snapshot());
2109 assert!(
2110 view.profile_status.is_none(),
2111 "nothing is stat-ed before the user reaches Review"
2112 );
2113
2114 view.advance();
2115 view.advance();
2116 assert_eq!(view.step, Step::Review);
2117 let on_entry = view
2118 .profile_status
2119 .clone()
2120 .expect("entering Review must populate the cached status");
2121
2122 // Painting repeatedly must not change the cached value — that is the
2123 // whole point — and must not panic on the cached-read path.
2124 let area = Rect::new(0, 0, 80, 24);
2125 for _ in 0..3 {
2126 let mut buf = Buffer::empty(area);
2127 view.render(area, &mut buf);
2128 }
2129 assert_eq!(view.profile_status.as_ref(), Some(&on_entry));
2130
2131 // Toggling scope changes which directory is described, so the cache
2132 // has to be refreshed on that keypress.
2133 let before_scope = view.profile_scope;
2134 view.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
2135 assert_ne!(view.profile_scope, before_scope);
2136 assert!(
2137 view.profile_status.is_some(),
2138 "a scope toggle must leave a freshly computed status behind"
2139 );
2140 }
2141
2142 #[test]
2143 fn profile_status_distinguishes_fresh_and_existing_workspaces() {
2144 let temp = tempfile::tempdir().expect("temp workspace");
2145 assert_eq!(
2146 profile_file_status(FleetProfileScope::Project, temp.path()),
2147 (
2148 "0 files".to_string(),
2149 "create .codewhale/agents/*.toml".to_string()
2150 )
2151 );
2152
2153 let profile_dir = temp.path().join(PROFILE_DIR);
2154 std::fs::create_dir_all(&profile_dir).expect("profile dir");
2155 std::fs::write(profile_dir.join("reviewer.toml"), "id = \"reviewer\"\n")
2156 .expect("existing profile");
2157 assert_eq!(
2158 profile_file_status(FleetProfileScope::Project, temp.path()),
2159 ("1 file".to_string(), PROFILE_DIR.to_string())
2160 );
2161 }
2162
2163 #[test]
2164 fn one_enter_from_review_saves_starter_profile_for_selection() {
2165 let mut view = FleetSetupView::from_snapshot(snapshot());
2166 // Role: manager(0) scout(1) builder(2) -> builder.
2167 view.handle_key(key(KeyCode::Down));
2168 view.handle_key(key(KeyCode::Down));
2169 view.handle_key(key(KeyCode::Enter)); // -> Model
2170 // Model: inherit(0) deepseek-v4-pro(1) -> deepseek-v4-pro.
2171 view.handle_key(key(KeyCode::Down));
2172 view.handle_key(key(KeyCode::Enter)); // Model -> Review
2173 while view.selected_reasoning_effort().as_deref() != Some("max") {
2174 view.handle_key(key(KeyCode::Char('t')));
2175 }
2176
2177 // The Review summary is already the structured confirmation surface;
2178 // one Enter saves the deterministic starter without another state.
2179 let action = view.handle_key(key(KeyCode::Enter));
2180 let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft, scope }) =
2181 action
2182 else {
2183 panic!("expected one-Enter starter save");
2184 };
2185 let content = draft.render_toml();
2186 assert!(content.contains("id = \"builder\""));
2187 assert!(content.contains("role_hint = \"builder\""));
2188 assert!(content.contains("model = \"deepseek-v4-pro\""));
2189 assert!(content.contains("reasoning_effort = \"max\""));
2190 // A concrete cross-provider route pin names its own provider
2191 // explicitly (#4093) — the saved profile must not be ambiguously
2192 // scoped to whatever provider happens to be active at launch time.
2193 assert!(content.contains("provider = \"deepseek\""), "{content}");
2194 for forbidden in ["base_url", "api_key"] {
2195 assert!(
2196 !content.contains(forbidden),
2197 "starter profile must not carry {forbidden}: {content}"
2198 );
2199 }
2200
2201 assert_eq!(scope, FleetProfileScope::Personal);
2202 assert_eq!(draft.id, "builder");
2203 assert_eq!(draft.role_hint, "builder");
2204 assert_eq!(draft.model.as_deref(), Some("deepseek-v4-pro"));
2205 assert_eq!(draft.provider.as_deref(), Some("deepseek"));
2206 assert_eq!(draft.reasoning_effort.as_deref(), Some("max"));
2207 }
2208
2209 #[test]
2210 fn review_defaults_to_personal_and_can_switch_to_project() {
2211 let mut view = FleetSetupView::from_snapshot(snapshot());
2212 to_review(&mut view);
2213
2214 assert_eq!(view.profile_scope, FleetProfileScope::Personal);
2215 view.handle_key(key(KeyCode::Char('s')));
2216 assert_eq!(view.profile_scope, FleetProfileScope::Project);
2217
2218 let action = view.handle_key(key(KeyCode::Enter));
2219 let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft, scope }) =
2220 action
2221 else {
2222 panic!("expected project profile save event");
2223 };
2224 assert_eq!(scope, FleetProfileScope::Project);
2225 let rendered = draft.render_toml();
2226 assert!(rendered.contains("id = \"manager\""), "{rendered}");
2227 }
2228
2229 #[test]
2230 fn inherit_selection_starter_draft_carries_no_provider() {
2231 // `inherit` (no concrete route pin) must never carry a provider —
2232 // there's no explicit route to name (#4093).
2233 let mut view = FleetSetupView::from_snapshot(snapshot());
2234 to_review(&mut view);
2235 let action = view.handle_key(key(KeyCode::Enter));
2236 let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft, .. }) =
2237 action
2238 else {
2239 panic!("expected inherit starter save");
2240 };
2241 assert_eq!(draft.model, None);
2242 assert_eq!(draft.provider, None);
2243 assert_eq!(draft.reasoning_effort, None);
2244 let content = draft.render_toml();
2245 assert!(!content.contains("provider"), "{content}");
2246 assert!(!content.contains("reasoning_effort"), "{content}");
2247 }
2248
2249 #[test]
2250 fn role_and_review_steps_note_roster_overrides() {
2251 // "reviewer" collides with the built-in roster member; the
2252 // role step context and review Role section must both say so.
2253 let mut view = FleetSetupView::from_snapshot(snapshot());
2254 for _ in 0..3 {
2255 view.handle_key(key(KeyCode::Down));
2256 }
2257 assert_eq!(view.selected_role(), "reviewer");
2258 assert_eq!(
2259 view.roster_override_note().as_deref(),
2260 Some("Overrides built-in 'reviewer' unless a project profile exists.")
2261 );
2262
2263 let role_step = render_through_stack(
2264 || {
2265 let mut v = FleetSetupView::from_snapshot(snapshot());
2266 for _ in 0..3 {
2267 v.handle_key(key(KeyCode::Down));
2268 }
2269 v
2270 },
2271 120,
2272 40,
2273 )
2274 .join("\n");
2275 assert!(
2276 role_step.contains("Overrides built-in 'reviewer'"),
2277 "{role_step}"
2278 );
2279
2280 let review = render_through_stack(
2281 || {
2282 let mut v = FleetSetupView::from_snapshot(snapshot());
2283 for _ in 0..3 {
2284 v.handle_key(key(KeyCode::Down));
2285 }
2286 v.step = Step::Review;
2287 v
2288 },
2289 120,
2290 40,
2291 )
2292 .join("\n");
2293 assert!(review.contains("Overrides built-in 'reviewer'"), "{review}");
2294
2295 // "custom" matches no roster member: no override note anywhere.
2296 let mut custom_view = FleetSetupView::from_snapshot(snapshot());
2297 for _ in 0..8 {
2298 custom_view.handle_key(key(KeyCode::Down));
2299 }
2300 assert_eq!(custom_view.selected_role(), "custom");
2301 assert!(custom_view.roster_override_note().is_none());
2302 }
2303
2304 #[test]
2305 fn default_selection_targets_manager_inherit() {
2306 let view = FleetSetupView::from_snapshot(snapshot());
2307 let draft = view.starter_profile_draft();
2308 assert_eq!(draft.file_name(), "manager.toml");
2309 assert_eq!(draft.role_hint, "manager");
2310 assert!(draft.model.is_none());
2311 assert!(draft.model_class_hint.is_none());
2312 assert!(
2313 draft
2314 .instructions
2315 .as_deref()
2316 .is_some_and(|text| text.contains("assigned Fleet slice"))
2317 );
2318 }
2319
2320 #[test]
2321 fn fleet_model_rows_keep_failed_provider_visible_with_reason() {
2322 let mut snap = snapshot();
2323 snap.available_models = vec![(
2324 "zai".to_string(),
2325 "glm-5.2".to_string(),
2326 crate::provider_readiness::ResolvedProviderReadiness::SavedLastCheckFailed {
2327 category: crate::error_taxonomy::ErrorCategory::Authentication,
2328 message: "auth failed".to_string(),
2329 },
2330 )];
2331 let mut view = FleetSetupView::from_snapshot(snap);
2332 assert_eq!(view.model_choices.len(), 2);
2333 assert!(
2334 view.model_choices[1]
2335 .summary
2336 .contains("last check failed (authentication)")
2337 );
2338 assert!(view.model_choices[1].summary.contains("auth failed"));
2339 assert_eq!(
2340 view.model_routes[1],
2341 ("zai".to_string(), "glm-5.2".to_string())
2342 );
2343 assert!(matches!(
2344 &view.model_row_states[1],
2345 FleetModelRowState::Blocked { reason } if reason == "auth failed"
2346 ));
2347 view.step = Step::Model;
2348 view.model_idx = 1;
2349 assert!(matches!(
2350 view.handle_key(key(KeyCode::Enter)),
2351 ViewAction::None
2352 ));
2353 assert_eq!(view.step, Step::Model);
2354 }
2355
2356 #[test]
2357 fn fleet_invalid_route_stays_visible_but_cannot_advance() {
2358 let mut snap = snapshot();
2359 snap.available_models = vec![(
2360 "zai".to_string(),
2361 "broken-model".to_string(),
2362 crate::provider_readiness::ResolvedProviderReadiness::InvalidRoute,
2363 )];
2364 let mut view = FleetSetupView::from_snapshot(snap);
2365 view.step = Step::Model;
2366 view.model_idx = 1;
2367
2368 assert!(view.model_choices[1].summary.contains("invalid route"));
2369 assert!(matches!(
2370 view.handle_key(key(KeyCode::Enter)),
2371 ViewAction::None
2372 ));
2373 assert_eq!(view.step, Step::Model);
2374 }
2375
2376 #[test]
2377 fn fleet_includes_saved_model_outside_bundled_catalog() {
2378 let providers = crate::config::ProvidersConfig {
2379 openrouter: crate::config::ProviderConfig {
2380 api_key: Some("openrouter-test-key".to_string()),
2381 model: Some("acme/private-preview".to_string()),
2382 ..Default::default()
2383 },
2384 ..Default::default()
2385 };
2386 let config = Config {
2387 provider: Some("openrouter".to_string()),
2388 providers: Some(providers),
2389 ..Default::default()
2390 };
2391
2392 let routes = cross_provider_model_routes(
2393 &config,
2394 crate::config::ApiProvider::Openrouter,
2395 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
2396 );
2397
2398 assert!(routes.iter().any(|(provider, model, readiness)| {
2399 provider == "openrouter" && model == "acme/private-preview" && readiness.can_attempt()
2400 }));
2401 assert_eq!(
2402 routes
2403 .iter()
2404 .filter(|(provider, model, _)| {
2405 provider == "openrouter" && model == "acme/private-preview"
2406 })
2407 .count(),
2408 1,
2409 "saved models must not be duplicated when the catalog later learns them"
2410 );
2411 }
2412
2413 #[test]
2414 fn fleet_routes_and_saved_draft_keep_exact_named_custom_provider() {
2415 let mut custom = std::collections::HashMap::new();
2416 for (name, base_url, model) in [
2417 ("custom-a", "http://127.0.0.1:18181/v1", "model-a"),
2418 ("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
2419 ] {
2420 custom.insert(
2421 name.to_string(),
2422 crate::config::ProviderConfig {
2423 kind: Some("openai-compatible".to_string()),
2424 base_url: Some(base_url.to_string()),
2425 model: Some(model.to_string()),
2426 api_key: Some("local-test-key".to_string()),
2427 ..Default::default()
2428 },
2429 );
2430 }
2431 let config = Config {
2432 provider: Some("custom-a".to_string()),
2433 providers: Some(crate::config::ProvidersConfig {
2434 custom,
2435 ..Default::default()
2436 }),
2437 ..Default::default()
2438 };
2439 let routes = cross_provider_model_routes(
2440 &config,
2441 crate::config::ApiProvider::Custom,
2442 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
2443 );
2444 assert!(
2445 routes
2446 .iter()
2447 .any(|(provider, model, _)| { provider == "custom-a" && model == "model-a" })
2448 );
2449 assert!(
2450 routes
2451 .iter()
2452 .any(|(provider, model, _)| { provider == "custom-b" && model == "model-b" })
2453 );
2454 assert!(!routes.iter().any(|(provider, _, _)| provider == "custom"));
2455
2456 let mut view = FleetSetupView::from_snapshot(FleetSetupSnapshot {
2457 available_models: routes,
2458 provider: "custom-a".to_string(),
2459 model: "model-a".to_string(),
2460 ..snapshot()
2461 });
2462 let route = view
2463 .model_routes
2464 .iter()
2465 .find(|(provider, model)| provider == "custom-b" && model == "model-b")
2466 .cloned()
2467 .expect("custom B route selectable while A is active");
2468 let draft = sample_draft();
2469 let (_, rendered) =
2470 view.install_model_draft(draft, "model-b".to_string(), Some(route), None);
2471 assert!(rendered.contains("provider = \"custom-b\""), "{rendered}");
2472 }
2473
2474 #[test]
2475 fn fleet_routes_keep_legacy_literal_custom_without_named_tables() {
2476 let config = Config {
2477 provider: Some("custom".to_string()),
2478 base_url: Some("http://127.0.0.1:18080/v1".to_string()),
2479 api_key: Some("local-test-key".to_string()),
2480 default_text_model: Some("legacy-custom-model".to_string()),
2481 ..Default::default()
2482 };
2483
2484 let routes = cross_provider_model_routes(
2485 &config,
2486 crate::config::ApiProvider::Custom,
2487 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
2488 );
2489
2490 assert!(
2491 routes.iter().any(|(provider, model, readiness)| {
2492 provider == "custom"
2493 && model == "legacy-custom-model"
2494 && matches!(
2495 readiness,
2496 crate::provider_readiness::ResolvedProviderReadiness::LocalUnchecked
2497 )
2498 && readiness.can_attempt()
2499 }),
2500 "{routes:?}"
2501 );
2502 }
2503
2504 #[test]
2505 fn role_step_keeps_list_and_detail_separate_at_80_columns() {
2506 let rows = render_through_stack(|| FleetSetupView::from_snapshot(snapshot()), 80, 24);
2507 let text = rows.join("\n");
2508
2509 let manager_row = rows
2510 .iter()
2511 .position(|row| row.contains("▸ manager"))
2512 .expect("manager row should render");
2513 let custom_row = rows
2514 .iter()
2515 .position(|row| row.contains(" custom"))
2516 .expect("custom row should render");
2517 let summary_row = rows
2518 .iter()
2519 .position(|row| row.contains("Plan & split queued work"))
2520 .expect("selected role summary should render");
2521 let description_row = rows
2522 .iter()
2523 .position(|row| row.contains("Coordinates the Fleet run"))
2524 .expect("selected role description should render");
2525
2526 assert!(
2527 manager_row < custom_row,
2528 "expected the full role list before details:\n{text}"
2529 );
2530 assert!(
2531 custom_row < summary_row,
2532 "selected summary must not share a row with role names:\n{text}"
2533 );
2534 assert!(
2535 custom_row < description_row,
2536 "selected description must render below the list:\n{text}"
2537 );
2538 for row in &rows[manager_row..=custom_row] {
2539 assert!(
2540 !row.contains("Plan & split queued work")
2541 && !row.contains("Coordinates the Fleet run")
2542 && !row.contains("Fleet runs sub-agents"),
2543 "role list row contains detail copy at 80 columns: {row:?}\n{text}"
2544 );
2545 }
2546 }
2547
2548 fn render_through_stack(view_at: impl Fn() -> FleetSetupView, w: u16, h: u16) -> Vec<String> {
2549 let area = Rect::new(0, 0, w, h);
2550 let mut buf = Buffer::empty(area);
2551 for y in 0..h {
2552 for x in 0..w {
2553 buf[(x, y)].set_symbol("X");
2554 }
2555 }
2556 let mut stack = ViewStack::new();
2557 stack.push(view_at());
2558 stack.render(area, &mut buf);
2559 (0..h)
2560 .map(|y| {
2561 (0..w)
2562 .map(|x| buf[(x, y)].symbol().to_string())
2563 .collect::<String>()
2564 })
2565 .collect()
2566 }
2567
2568 #[test]
2569 fn fleet_setup_is_usable_and_opaque_at_blocker_sizes() {
2570 // Exercise each step so all three screens are validated at every size.
2571 type Builder = (&'static str, fn() -> FleetSetupView);
2572 let builders: [Builder; 3] = [
2573 ("role", || FleetSetupView::from_snapshot(snapshot())),
2574 ("model", || {
2575 let mut v = FleetSetupView::from_snapshot(snapshot());
2576 v.step = Step::Model;
2577 v
2578 }),
2579 ("review", || {
2580 let mut v = FleetSetupView::from_snapshot(snapshot());
2581 v.step = Step::Review;
2582 v
2583 }),
2584 ];
2585
2586 for (label, make) in builders {
2587 for (w, h) in BLOCKER_SIZES {
2588 let rows = render_through_stack(make, w, h);
2589 let text = rows.join("\n");
2590
2591 // No bleed-through anywhere in the composited frame.
2592 assert!(
2593 !text.contains('X'),
2594 "{label} {w}x{h}: background bleed-through"
2595 );
2596 // Some action label is always visible.
2597 assert!(text.contains("cancel"), "{label} {w}x{h}: missing footer");
2598 // The first impression communicates Fleet = agent team.
2599 assert!(
2600 text.contains("agent team"),
2601 "{label} {w}x{h}: missing framing"
2602 );
2603 // No row overflows the frame width.
2604 for (y, row) in rows.iter().enumerate() {
2605 assert!(
2606 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
2607 "{label} {w}x{h}: row {y} overflows: {row:?}"
2608 );
2609 }
2610 }
2611 }
2612 }
2613
2614 #[test]
2615 fn review_at_cursor_size_keeps_content_and_actions_apart() {
2616 let rows = render_through_stack(
2617 || {
2618 let mut view = FleetSetupView::from_snapshot(snapshot());
2619 view.step = Step::Review;
2620 view
2621 },
2622 89,
2623 50,
2624 );
2625 let popup = centered_modal_area(Rect::new(0, 0, 89, 50), 96, 31, 60, 16);
2626 let review_row = rows
2627 .iter()
2628 .position(|row| row.contains("Review & save"))
2629 .expect("review heading");
2630 let review_col = rows[review_row]
2631 .chars()
2632 .position(|ch| ch == 'R')
2633 .expect("review heading column") as u16;
2634 assert!(
2635 review_col >= popup.x.saturating_add(2),
2636 "body copy must not touch the popup border: {:?}",
2637 rows[review_row]
2638 );
2639
2640 let action_row = rows
2641 .iter()
2642 .rposition(|row| row.contains("cancel"))
2643 .expect("footer cancel action");
2644 let footer_row = rows[..action_row]
2645 .iter()
2646 .rposition(|row| row.contains("scroll"))
2647 .expect("footer shortcut row");
2648 assert!(footer_row > 0);
2649 let gutter = rows[footer_row - 1]
2650 .chars()
2651 .skip(usize::from(popup.x.saturating_add(1)))
2652 .take(usize::from(popup.width.saturating_sub(2)))
2653 .collect::<String>();
2654 assert!(
2655 gutter.trim().is_empty(),
2656 "review body needs a quiet row before the action rail: {gutter:?}"
2657 );
2658 }
2659
2660 #[test]
2661 fn choice_steps_at_cursor_size_stay_content_sized() {
2662 for (step, expected_height) in [(Step::Role, 21usize), (Step::Model, 22usize)] {
2663 let rows = render_through_stack(
2664 || {
2665 let mut view = FleetSetupView::from_snapshot(snapshot());
2666 view.step = step;
2667 view
2668 },
2669 89,
2670 50,
2671 );
2672 let top = rows
2673 .iter()
2674 .position(|row| row.contains("Fleet setup — your agent team"))
2675 .expect("fleet setup title");
2676 let bottom = rows
2677 .iter()
2678 .rposition(|row| row.contains("Step "))
2679 .expect("fleet setup step receipt");
2680 assert_eq!(
2681 bottom - top + 1,
2682 expected_height,
2683 "choice card should follow its content instead of filling the 89x50 frame"
2684 );
2685 }
2686 }
2687
2688 #[test]
2689 fn review_lists_model_permissions_tools_and_profile_availability() {
2690 // Top of the review: the leading sections are visible without scrolling.
2691 let top = render_through_stack(
2692 || {
2693 let mut v = FleetSetupView::from_snapshot(snapshot());
2694 v.step = Step::Review;
2695 v
2696 },
2697 120,
2698 40,
2699 )
2700 .join("\n");
2701 for section in [
2702 "Role",
2703 "Model",
2704 "Profile availability",
2705 "Auth & readiness",
2706 "Permissions",
2707 ] {
2708 assert!(top.contains(section), "review missing section: {section}");
2709 }
2710 for truth in [
2711 "Scope changes discovery only",
2712 "trusted-path",
2713 "permission policy still",
2714 "govern execution",
2715 ] {
2716 assert!(
2717 top.contains(truth),
2718 "profile availability must not imply execution authority: {top}"
2719 );
2720 }
2721
2722 // The review is intentionally scrollable; scrolling to the bottom reveals
2723 // the workspace/org execution policy, review policy, and honest save note.
2724 let bottom = render_through_stack(
2725 || {
2726 let mut v = FleetSetupView::from_snapshot(snapshot());
2727 v.step = Step::Review;
2728 v.review_scroll = 999; // clamps to max in render
2729 v
2730 },
2731 120,
2732 40,
2733 )
2734 .join("\n");
2735 for needle in [
2736 "Tools",
2737 "Workspace",
2738 "Review policy",
2739 "Press Enter or g once",
2740 ] {
2741 assert!(bottom.contains(needle), "scrolled review missing: {needle}");
2742 }
2743
2744 let policy = FleetSetupView::from_snapshot(snapshot()).review_policy_summary();
2745 for truth in [
2746 "current interactive session",
2747 "codewhale fleet status",
2748 ".codewhale/fleet.jsonl",
2749 ] {
2750 assert!(policy.contains(truth), "review policy missing: {truth}");
2751 }
2752 assert!(
2753 !policy.contains("inspects the ledger"),
2754 "the interactive status command must not claim to inspect the durable ledger: {policy}"
2755 );
2756 }
2757
2758 #[test]
2759 fn dormant_external_consent_row_requires_activation() {
2760 let mut snap = snapshot();
2761 snap.available_models = vec![(
2762 "openai-codex".to_string(),
2763 "gpt-5.6-sol".to_string(),
2764 crate::provider_readiness::ResolvedProviderReadiness::ExternalConsentPendingSelection,
2765 )];
2766 let view = FleetSetupView::from_snapshot(snap);
2767 assert!(
2768 view.model_choices[1]
2769 .summary
2770 .contains("external consent · select to check")
2771 );
2772 assert!(matches!(
2773 view.model_row_states[1],
2774 FleetModelRowState::NeedsActivation
2775 ));
2776 }
2777
2778 #[test]
2779 fn enter_on_dormant_external_consent_emits_activation_event() {
2780 let mut snap = snapshot();
2781 snap.available_models = vec![(
2782 "openai-codex".to_string(),
2783 "gpt-5.6-terra".to_string(),
2784 crate::provider_readiness::ResolvedProviderReadiness::ExternalConsentPendingSelection,
2785 )];
2786 let mut view = FleetSetupView::from_snapshot(snap);
2787 view.handle_key(key(KeyCode::Enter)); // Role -> Model
2788 view.handle_key(key(KeyCode::Down)); // inherit -> codex row
2789 assert_eq!(
2790 view.selected_route(),
2791 Some(("openai-codex".to_string(), "gpt-5.6-terra".to_string()))
2792 );
2793 let action = view.handle_key(key(KeyCode::Enter));
2794 let ViewAction::Emit(ViewEvent::FleetSetupExternalConsentActivationRequested {
2795 provider_id,
2796 model,
2797 }) = action
2798 else {
2799 panic!("expected external-consent activation request, got {action:?}");
2800 };
2801 assert_eq!(provider_id, "openai-codex");
2802 assert_eq!(model, "gpt-5.6-terra");
2803 assert_eq!(
2804 view.step,
2805 Step::Model,
2806 "stays on Model step until host validates"
2807 );
2808 }
2809
2810 #[test]
2811 fn refresh_from_snapshot_makes_activated_row_ready() {
2812 let mut snap = snapshot();
2813 snap.available_models = vec![(
2814 "xai".to_string(),
2815 "grok-4.5".to_string(),
2816 crate::provider_readiness::ResolvedProviderReadiness::ExternalConsentPendingSelection,
2817 )];
2818 let mut view = FleetSetupView::from_snapshot(snap);
2819 view.handle_key(key(KeyCode::Enter)); // Role -> Model
2820 view.handle_key(key(KeyCode::Down)); // xai row
2821 assert!(matches!(
2822 view.model_row_states[1],
2823 FleetModelRowState::NeedsActivation
2824 ));
2825
2826 // Simulate the host validating the route and rebuilding the snapshot:
2827 // the same row is now Ready.
2828 let mut refreshed = snapshot();
2829 refreshed.available_models = vec![(
2830 "xai".to_string(),
2831 "grok-4.5".to_string(),
2832 crate::provider_readiness::ResolvedProviderReadiness::Ready,
2833 )];
2834 view.refresh_from_snapshot(refreshed);
2835
2836 assert!(matches!(
2837 view.model_row_states[1],
2838 FleetModelRowState::Ready
2839 ));
2840 // Selection and step are preserved.
2841 assert_eq!(view.step, Step::Model);
2842 assert_eq!(
2843 view.selected_route(),
2844 Some(("xai".to_string(), "grok-4.5".to_string()))
2845 );
2846 }
2847
2848 #[test]
2849 fn blocked_row_cannot_advance() {
2850 let mut snap = snapshot();
2851 snap.available_models = vec![(
2852 "xai".to_string(),
2853 "grok-4.5".to_string(),
2854 crate::provider_readiness::ResolvedProviderReadiness::MissingKey,
2855 )];
2856 let mut view = FleetSetupView::from_snapshot(snap);
2857 view.step = Step::Model;
2858 view.model_idx = 1;
2859 assert!(matches!(
2860 &view.model_row_states[1],
2861 FleetModelRowState::Blocked { reason } if reason == "missing API key"
2862 ));
2863 assert!(matches!(
2864 view.handle_key(key(KeyCode::Enter)),
2865 ViewAction::None
2866 ));
2867 assert_eq!(view.step, Step::Model);
2868 }
2869
2870 #[test]
2871 fn fleet_setup_includes_openai_codex_account_roster_with_dormant_consent() {
2872 let _env = crate::test_support::lock_test_env();
2873 let codex_home = tempfile::tempdir().expect("Codex home");
2874 let _home = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex_home.path());
2875 std::fs::write(
2876 codex_home.path().join("models_cache.json"),
2877 serde_json::to_vec(&serde_json::json!({
2878 "fetched_at": chrono::Utc::now(),
2879 "models": [
2880 { "slug": "gpt-5.6-sol", "priority": 1 },
2881 { "slug": "gpt-5.6-terra", "priority": 2 },
2882 { "slug": "gpt-5.6-luna", "priority": 3 }
2883 ]
2884 }))
2885 .expect("serialize cache"),
2886 )
2887 .expect("write cache");
2888
2889 let mut config = crate::config::Config::default();
2890 config.providers = Some(crate::config::ProvidersConfig {
2891 openai_codex: crate::config::ProviderConfig {
2892 auth_mode: Some("oauth".to_string()),
2893 external_credentials: Some(
2894 codewhale_config::ExternalCredentialConsentToml::read_only(
2895 codewhale_config::ProviderKind::OpenaiCodex,
2896 codewhale_config::ExternalCredentialSource::CodexCli,
2897 codex_home.path().join("auth.json"),
2898 ),
2899 ),
2900 ..Default::default()
2901 },
2902 ..Default::default()
2903 });
2904
2905 let routes = cross_provider_model_routes(
2906 &config,
2907 crate::config::ApiProvider::Moonshot,
2908 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
2909 );
2910
2911 for model in ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] {
2912 assert!(
2913 routes.iter().any(|(provider, m, readiness)| {
2914 provider == "openai-codex"
2915 && m == model
2916 && matches!(
2917 readiness,
2918 crate::provider_readiness::ResolvedProviderReadiness::ExternalConsentPendingSelection
2919 )
2920 }),
2921 "missing dormant-consent Codex route for {model}: {routes:?}"
2922 );
2923 }
2924 }
2925
2926 #[test]
2927 fn fleet_setup_includes_xai_grok_routes_with_dormant_consent() {
2928 let _env = crate::test_support::lock_test_env();
2929 let grok_home = tempfile::tempdir().expect("Grok home");
2930 let mut config = crate::config::Config::default();
2931 config.providers = Some(crate::config::ProvidersConfig {
2932 xai: crate::config::ProviderConfig {
2933 auth_mode: Some("oauth".to_string()),
2934 external_credentials: Some(
2935 codewhale_config::ExternalCredentialConsentToml::read_only(
2936 codewhale_config::ProviderKind::Xai,
2937 codewhale_config::ExternalCredentialSource::GrokCli,
2938 grok_home.path().join("grok-auth.json"),
2939 ),
2940 ),
2941 ..Default::default()
2942 },
2943 ..Default::default()
2944 });
2945
2946 let routes = cross_provider_model_routes(
2947 &config,
2948 crate::config::ApiProvider::Moonshot,
2949 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
2950 );
2951
2952 let xai_rows: Vec<_> = routes
2953 .iter()
2954 .filter(|(provider, _, _)| provider == "xai")
2955 .collect();
2956 assert!(
2957 !xai_rows.is_empty(),
2958 "xAI routes must be offered when Grok CLI consent is configured: {routes:?}"
2959 );
2960 assert!(
2961 xai_rows.iter().all(|(_, _, readiness)| {
2962 matches!(
2963 readiness,
2964 crate::provider_readiness::ResolvedProviderReadiness::ExternalConsentPendingSelection
2965 )
2966 }),
2967 "every xAI row must require explicit activation: {xai_rows:?}"
2968 );
2969 }
2970 }
2971
2971 lines RUST