返回 CodeWhale
fleet_setup.rs
根目录 / crates / tui / src / tui / views / fleet_setup.rs
1 //! Legacy-profile setup — a progressive "set up your agent team" flow.
2 //!
3 //! `/fleet setup` routes here only when no named v2 Fleet is selected. When a
4 //! v2 Fleet is selected, the host opens that Fleet's exact detail editor so a
5 //! save can never appear to update a member while writing an ignored legacy
6 //! `.codewhale/agents/*.toml` profile.
7 //!
8 //! Replaces the old six-column config matrix (#3791). Fleet is presented as an
9 //! agent team: the shortest valid path remains role → provider/model →
10 //! save/apply. From the Model step, `c` opens an optional, pure composition
11 //! advisory built only from configured routes; accept/edit/reject all return to
12 //! this same human-reviewed save path.
13 //! The review step shows resolved provider, model, auth/readiness, profile
14 //! availability, and overwrite consequences once before anything is written. Thinking defaults to
15 //! inherit and can be adjusted on the review step without an extra wizard
16 //! screen. "Save profile" persists the exact rendered TOML bytes.
17 //!
18 //! NOTE (audit #7 / #3167): the role/model taxonomy and copy below are
19 //! intentionally English for now; #3167 reworks this into an interactive
20 //! provider/model picker that will churn most of this text. The command entry
21 //! (`CmdFleetDescription`) is already localized.
22
23 use std::borrow::Cow;
24 use std::cell::RefCell;
25 use std::collections::BTreeSet;
26 use std::path::{Path, PathBuf};
27
28 use codewhale_workflow::fleet_composition::{
29 CompositionError, CompositionRole, ConfiguredModel, FleetCompositionProposal,
30 FleetCompositionRequest, RatificationState, RoleSuggestion,
31 };
32 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
33 use ratatui::{
34 buffer::Buffer,
35 layout::{Constraint, Direction, Layout, Rect},
36 style::{Modifier, Style},
37 text::{Line, Span},
38 widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap},
39 };
40
41 use crate::config::Config;
42 use crate::fleet::profile::FleetProfileScope;
43 use crate::fleet::role::public_role_label;
44 use crate::tui::app::App;
45 use crate::tui::menu_style;
46 use crate::tui::views::{
47 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, centered_modal_area,
48 render_modal_footer_with_gutter, render_modal_surface, truncate_view_text,
49 };
50 use codewhale_localization::{MessageId, tr};
51 use codewhale_palette as palette;
52
53 const PROFILE_DIR: &str = ".codewhale/agents";
54
55 /// Rows one PageUp/PageDown travels on choice steps. Pages clamp at the ends
56 /// per the shared vocabulary instead of wrapping (#6290).
57 const SETUP_PAGE: usize = 10;
58 /// Lines one PageUp/PageDown scrolls on the Review step (unchanged).
59 const REVIEW_SCROLL_PAGE: usize = 8;
60
61 /// The only two truthful destinations for `/fleet setup`.
62 #[derive(Debug, Clone, PartialEq, Eq)]
63 pub(crate) enum FleetSetupEditTarget {
64 /// No named v2 Fleet is selected, so the legacy profile wizard remains
65 /// the effective roster-authoring surface.
66 LegacyProfiles,
67 /// A named v2 Fleet is selected; edit that exact file and scope.
68 SelectedFleet {
69 name: String,
70 scope: crate::fleet::store::FleetScope,
71 },
72 }
73
74 /// Resolve setup independently of project-profile trust. A broken explicit
75 /// selection fails closed instead of being mistaken for "no Fleet" and
76 /// silently opening the legacy profile writer.
77 pub(crate) fn resolve_fleet_setup_edit_target(
78 workspace: &Path,
79 ) -> Result<FleetSetupEditTarget, String> {
80 match crate::fleet::store::resolve_selected_fleet(workspace) {
81 Ok(Some(selected)) => Ok(FleetSetupEditTarget::SelectedFleet {
82 name: selected.name,
83 scope: selected.scope,
84 }),
85 Ok(None) => Ok(FleetSetupEditTarget::LegacyProfiles),
86 Err(_) => Err(
87 "Selected Fleet is missing or unreadable; open /fleet teams to repair or clear the selection. Legacy profiles were not opened."
88 .to_string(),
89 ),
90 }
91 }
92
93 /// A selectable choice in a wizard step: a short identifier `label`, a one-line
94 /// `summary`, and a longer `description` shown (wrapped) in the detail pane.
95 #[derive(Clone)]
96 struct Choice {
97 label: Cow<'static, str>,
98 summary: Cow<'static, str>,
99 description: Cow<'static, str>,
100 }
101
102 const CHOICE_LIST_WIDTH: u16 = 22;
103 const CHOICE_DETAIL_MIN_WIDTH: u16 = 58;
104 const CHOICE_TWO_COLUMN_MIN_WIDTH: u16 = CHOICE_LIST_WIDTH + CHOICE_DETAIL_MIN_WIDTH;
105
106 /// Agent-team roles. `label` doubles as the profile `role_hint` and file stem,
107 /// so these strings are part of the generated-profile contract.
108 const ROLES: [Choice; 9] = [
109 Choice {
110 label: Cow::Borrowed("manager"),
111 summary: Cow::Borrowed("Plan & split queued work"),
112 description: Cow::Borrowed(
113 "Coordinates the Fleet run: plans the work, splits it into bounded tasks, and dispatches workers.",
114 ),
115 },
116 Choice {
117 label: Cow::Borrowed("explore"),
118 summary: Cow::Borrowed("Read-first research"),
119 description: Cow::Borrowed(
120 "Research and evidence gathering. Reads and summarizes before anything is written.",
121 ),
122 },
123 Choice {
124 label: Cow::Borrowed("implement"),
125 summary: Cow::Borrowed("Implements bounded changes"),
126 description: Cow::Borrowed(
127 "Implements changes strictly inside its assigned task scope; writes only what the slice needs.",
128 ),
129 },
130 Choice {
131 label: Cow::Borrowed("reviewer"),
132 summary: Cow::Borrowed("Read-only review"),
133 description: Cow::Borrowed(
134 "Checks regressions, tests, and diffs. Read-only — it never writes.",
135 ),
136 },
137 Choice {
138 label: Cow::Borrowed("test"),
139 summary: Cow::Borrowed("Bounded validation"),
140 description: Cow::Borrowed(
141 "Runs bounded validation (test/check selections) and reports receipts back to the orchestrator. Never writes — patching is denied; unbounded shell forms are refused.",
142 ),
143 },
144 Choice {
145 label: Cow::Borrowed("advisor"),
146 summary: Cow::Borrowed("Read-only second opinion"),
147 description: Cow::Borrowed(
148 "Short-lived, high-reasoning counsel for difficult decisions and overlooked risks. Read-only and shell-less.",
149 ),
150 },
151 Choice {
152 label: Cow::Borrowed("synthesizer"),
153 summary: Cow::Borrowed("Reduce receipts to handoff"),
154 description: Cow::Borrowed(
155 "Turns worker receipts into bounded handoff state instead of raw transcript replay.",
156 ),
157 },
158 Choice {
159 label: Cow::Borrowed("general"),
160 summary: Cow::Borrowed("General-purpose worker"),
161 description: Cow::Borrowed(
162 "A flexible worker with no specialized posture — use it when the task doesn't fit a named role.",
163 ),
164 },
165 Choice {
166 label: Cow::Borrowed("custom"),
167 summary: Cow::Borrowed("Author a profile by hand"),
168 description: Cow::Borrowed(
169 "Define the posture yourself in a workspace agent TOML profile under .codewhale/agents/.",
170 ),
171 },
172 ];
173
174 /// The `inherit` row shown first in the Model step (#3167). Concrete provider
175 /// models follow it, built per-run from EVERY configured provider's catalog
176 /// (#4093), so the user picks a real route — including cross-provider ones —
177 /// instead of an abstract class or only the active provider's models.
178 const MODEL_INHERIT: Choice = Choice {
179 label: Cow::Borrowed("same as session"),
180 summary: Cow::Borrowed("Same model as now"),
181 description: Cow::Borrowed(
182 "Use your current model — provider and reasoning included. Recommended default.",
183 ),
184 };
185
186 const THINKING_CHOICES: &[Choice] = &[
187 Choice {
188 label: Cow::Borrowed("inherit"),
189 summary: Cow::Borrowed("Same thinking as now"),
190 description: Cow::Borrowed(
191 "Reuse the operator's current reasoning setting for this worker. Recommended default.",
192 ),
193 },
194 Choice {
195 label: Cow::Borrowed("off"),
196 summary: Cow::Borrowed("No extra thinking"),
197 description: Cow::Borrowed(
198 "Use for narrow lookups or mechanical work where speed matters.",
199 ),
200 },
201 Choice {
202 label: Cow::Borrowed("low"),
203 summary: Cow::Borrowed("Small thinking budget"),
204 description: Cow::Borrowed(
205 "Use for bounded checks that still benefit from light reasoning.",
206 ),
207 },
208 Choice {
209 label: Cow::Borrowed("medium"),
210 summary: Cow::Borrowed("Balanced thinking budget"),
211 description: Cow::Borrowed("Use for normal implementation and review work."),
212 },
213 Choice {
214 label: Cow::Borrowed("high"),
215 summary: Cow::Borrowed("Deep thinking budget"),
216 description: Cow::Borrowed("Use for harder design, debugging, and integration tasks."),
217 },
218 Choice {
219 label: Cow::Borrowed("max"),
220 summary: Cow::Borrowed("Maximum thinking budget"),
221 description: Cow::Borrowed("Use for hard release, security, and root-cause work."),
222 },
223 Choice {
224 label: Cow::Borrowed("auto"),
225 summary: Cow::Borrowed("Let Codewhale choose"),
226 description: Cow::Borrowed("Choose a thinking tier from the worker prompt at runtime."),
227 },
228 ];
229
230 #[derive(Debug, Clone)]
231 pub struct FleetSetupSnapshot {
232 workspace: PathBuf,
233 locale: codewhale_localization::Locale,
234 /// Whether the active provider has a key or local runtime — gates the
235 /// model-draft offer, mirroring the constitution card's `provider_ready`.
236 provider_ready: bool,
237 provider: String,
238 model: String,
239 reasoning: String,
240 subagents_enabled: bool,
241 max_subagents: usize,
242 launch_concurrency: usize,
243 max_admitted: usize,
244 subagent_spawn_depth: u32,
245 fleet_spawn_depth: u32,
246 api_timeout_secs: u64,
247 heartbeat_timeout_secs: u64,
248 /// Lowercased roster member ids with their origin labels (built-in /
249 /// config / project), so the wizard can say when a chosen role would
250 /// override an existing roster member.
251 roster_members: Vec<(String, String)>,
252 /// Saved (file-backed) roster members keyed by lowercased id: where the
253 /// file lives and the route it pins, so reopening a saved profile from
254 /// `/fleet` starts from what is on disk instead of the wizard defaults.
255 roster_details: Vec<RosterMemberDetail>,
256 /// Whether project-scope profiles are enabled for this launch
257 /// (`--no-project-config` disables them). When false, "This project" is
258 /// offered disabled with that reason instead of writing a file nothing
259 /// will load.
260 project_profiles_enabled: bool,
261 /// Resolved personal profile directory (`$CODEWHALE_HOME/agents`), or the
262 /// reason it could not be resolved. Captured once at snapshot time so the
263 /// wizard never re-reads the environment while painting and tests can
264 /// point it at a temp dir.
265 personal_profile_dir: Result<PathBuf, String>,
266 /// `(exact provider id, model id, readiness label, selectable)` routes for a worker,
267 /// drawn from ALL configured providers — not only the active one (#4093).
268 /// Shown after `inherit` in the Model step so a Fleet worker can be pinned
269 /// to a route independent of the parent/current provider. The provider id
270 /// is a canonical built-in id or the exact named custom table key, not a
271 /// display label — see [`cross_provider_model_routes`].
272 available_models: Vec<(
273 String,
274 String,
275 crate::provider_readiness::ResolvedProviderReadiness,
276 )>,
277 }
278
279 /// A file-backed roster member as it exists on disk (project or personal).
280 #[derive(Debug, Clone, PartialEq, Eq)]
281 pub struct RosterMemberDetail {
282 id: String,
283 scope: FleetProfileScope,
284 source: PathBuf,
285 provider: Option<String>,
286 model: Option<String>,
287 reasoning_effort: Option<String>,
288 }
289
290 impl FleetSetupSnapshot {
291 #[must_use]
292 pub fn from_app(app: &App, config: &Config) -> Self {
293 let provider = app.effective_route_identity_display().0;
294 let model = if app.auto_model {
295 app.last_effective_model
296 .as_deref()
297 .map(|effective| format!("auto -> {effective}"))
298 .unwrap_or_else(|| "auto".to_string())
299 } else {
300 app.model.clone()
301 };
302 let fleet_spawn_depth = config
303 .fleet
304 .as_ref()
305 .map(|fleet| fleet.exec.max_spawn_depth)
306 .unwrap_or_else(|| codewhale_config::FleetExecConfig::default().max_spawn_depth)
307 .min(codewhale_config::MAX_SPAWN_DEPTH_CEILING);
308 let roster =
309 crate::fleet::roster::FleetRoster::load(&config.fleet_config(), &app.workspace);
310 let roster_members = roster
311 .members()
312 .iter()
313 .map(|member| (member.id.to_lowercase(), member.origin.to_string()))
314 .collect();
315 let roster_details = roster
316 .members()
317 .iter()
318 .filter_map(|member| {
319 let scope = match member.origin {
320 crate::fleet::roster::ProfileOrigin::Workspace => FleetProfileScope::Project,
321 crate::fleet::roster::ProfileOrigin::Personal => FleetProfileScope::Personal,
322 _ => return None,
323 };
324 Some(RosterMemberDetail {
325 id: member.id.to_lowercase(),
326 scope,
327 source: member.source.clone(),
328 provider: member.profile.provider.clone(),
329 model: member.profile.model.clone(),
330 reasoning_effort: member.profile.reasoning_effort.clone(),
331 })
332 })
333 .collect();
334 let active_route_readiness = crate::provider_readiness::resolve_for_model(
335 config,
336 app.api_provider,
337 if app.auto_model { "auto" } else { &app.model },
338 &app.provider_health,
339 );
340
341 Self {
342 workspace: app.workspace.clone(),
343 locale: app.ui_locale,
344 provider_ready: active_route_readiness.can_attempt(),
345 provider,
346 model,
347 reasoning: app.reasoning_effort_display_label(),
348 subagents_enabled: config.subagents_enabled_for_provider(app.api_provider),
349 max_subagents: config.max_subagents_for_provider(app.api_provider),
350 launch_concurrency: config.launch_concurrency_for_provider(app.api_provider),
351 max_admitted: config.max_admitted_subagents_for_provider(app.api_provider),
352 subagent_spawn_depth: config.subagent_max_spawn_depth_for_provider(app.api_provider),
353 fleet_spawn_depth,
354 api_timeout_secs: config.subagent_api_timeout_secs_for_provider(app.api_provider),
355 heartbeat_timeout_secs: config
356 .subagent_heartbeat_timeout_secs_for_provider(app.api_provider),
357 roster_members,
358 roster_details,
359 project_profiles_enabled: crate::fleet::roster::project_agent_profiles_enabled(),
360 personal_profile_dir: crate::fleet::profile::personal_agent_profile_dir()
361 .map_err(|err| format!("{err:#}")),
362 available_models: cross_provider_model_routes(
363 config,
364 app.api_provider,
365 &app.provider_health,
366 ),
367 }
368 }
369 }
370
371 /// Build the `(canonical provider id, model id)` pairs selectable for a worker
372 /// from EVERY configured provider — not only the active one (#4093). Fleet
373 /// workers can be pinned to a route independent of the parent/current provider,
374 /// so the Model step must offer the same cross-provider catalog the model
375 /// picker does, instead of the active provider's models alone.
376 ///
377 /// The provider id here is the exact non-secret configured route key. Built-ins
378 /// use their canonical id; named custom routes keep their table key so saved
379 /// Fleet profiles can rebuild the same child client.
380 /// Callers derive a human-readable label from it for UI text.
381 pub(crate) fn cross_provider_model_routes(
382 config: &Config,
383 active: crate::config::ApiProvider,
384 health: &crate::provider_readiness::ProviderReadinessSnapshot,
385 ) -> Vec<(
386 String,
387 String,
388 crate::provider_readiness::ResolvedProviderReadiness,
389 )> {
390 let mut routes = Vec::new();
391 let configured = crate::provider_lake::configured_providers(config, active);
392 let legacy_custom_configured = configured.contains(&crate::config::ApiProvider::Custom);
393 for provider in configured
394 .into_iter()
395 .filter(|provider| *provider != crate::config::ApiProvider::Custom)
396 {
397 append_provider_model_routes(
398 &mut routes,
399 config,
400 active,
401 provider,
402 provider.as_str(),
403 health,
404 );
405 }
406
407 // `ApiProvider::Custom` is an enum class, not a route identity. Enumerate
408 // every named custom table so a Fleet on custom A can still pin a worker
409 // to custom B and persist B's exact client route.
410 let mut custom_names = config
411 .providers
412 .as_ref()
413 .map(|providers| providers.custom.keys().cloned().collect::<Vec<_>>())
414 .unwrap_or_default();
415 custom_names.sort();
416 if custom_names.is_empty() && legacy_custom_configured {
417 append_provider_model_routes(
418 &mut routes,
419 config,
420 active,
421 crate::config::ApiProvider::Custom,
422 crate::config::ApiProvider::Custom.as_str(),
423 health,
424 );
425 }
426 for name in custom_names {
427 let mut named_config = config.clone();
428 named_config.provider = Some(name.clone());
429 append_provider_model_routes(
430 &mut routes,
431 &named_config,
432 active,
433 crate::config::ApiProvider::Custom,
434 &name,
435 health,
436 );
437 }
438 routes
439 }
440
441 fn append_provider_model_routes(
442 routes: &mut Vec<(
443 String,
444 String,
445 crate::provider_readiness::ResolvedProviderReadiness,
446 )>,
447 config: &Config,
448 active: crate::config::ApiProvider,
449 provider: crate::config::ApiProvider,
450 provider_id: &str,
451 health: &crate::provider_readiness::ProviderReadinessSnapshot,
452 ) {
453 // The bundled lake is only the baseline. A user may pin a valid
454 // provider-specific preview or private deployment outside that catalog.
455 let mut models = Vec::new();
456 if let Some(model) = config
457 .provider_config_for(provider)
458 .and_then(|entry| entry.model.as_deref())
459 {
460 push_unique_model(&mut models, model);
461 }
462 if provider == active {
463 let active_model = config.default_model();
464 if !active_model.trim().eq_ignore_ascii_case("auto") {
465 push_unique_model(&mut models, &active_model);
466 }
467 }
468 for model in crate::provider_lake::models_for_provider(config, active, provider) {
469 push_unique_model(&mut models, &model);
470 }
471
472 for model in models {
473 let readiness =
474 crate::provider_readiness::resolve_for_model(config, provider, &model, health);
475 routes.push((provider_id.to_string(), model, readiness));
476 }
477 }
478
479 fn push_unique_model(models: &mut Vec<String>, model: &str) {
480 let model = model.trim();
481 if !model.is_empty()
482 && !models
483 .iter()
484 .any(|existing| existing.eq_ignore_ascii_case(model))
485 {
486 models.push(model.to_string());
487 }
488 }
489
490 /// Human-readable label for a built-in provider id, falling back to an exact
491 /// named custom id verbatim.
492 /// Does this provider/model route match a typed filter?
493 ///
494 /// Substring over the model id, the provider id, and the provider's display
495 /// label, because a person types "sonnet", "anthropic", or "Claude" and means
496 /// the same row. The inherit row also answers to the words describing it.
497 /// Shared so the setup wizard and the Fleet editor cannot disagree about what
498 /// a query means — the editor had no filter at all, which made picking one
499 /// model out of every configured route an arrow-key errand.
500 pub(super) fn route_matches_query(
501 query: &str,
502 provider: &str,
503 model: &str,
504 is_inherit_row: bool,
505 ) -> bool {
506 let query = query.trim().to_ascii_lowercase();
507 if query.is_empty() {
508 return true;
509 }
510 model.to_ascii_lowercase().contains(&query)
511 || provider.to_ascii_lowercase().contains(&query)
512 || provider_display_label(provider)
513 .to_ascii_lowercase()
514 .contains(&query)
515 || (is_inherit_row && "inherit same as session current".contains(&query))
516 }
517
518 pub(super) fn provider_display_label(provider_id: &str) -> String {
519 crate::config::ApiProvider::parse(provider_id)
520 .filter(|provider| provider.as_str() == provider_id)
521 .map(|provider| provider.display_name().to_string())
522 .unwrap_or_else(|| provider_id.to_string())
523 }
524
525 /// Which focused screen of the wizard is showing.
526 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
527 enum Step {
528 /// Pick the team role.
529 Role,
530 /// Review an inert role-to-model suggestion built from configured routes.
531 Composition,
532 /// Pick the model-routing class.
533 Model,
534 /// Choose where the profile is saved (this project or personal).
535 Destination,
536 /// Review the full posture and save.
537 Review,
538 }
539
540 /// The two save destinations, in the order the Destination step lists them.
541 const DESTINATION_ORDER: [FleetProfileScope; 2] =
542 [FleetProfileScope::Project, FleetProfileScope::Personal];
543
544 /// Resolved facts about one save destination, computed off the paint path
545 /// (on entering the Destination/Review steps and when the role changes).
546 #[derive(Debug, Clone, PartialEq, Eq)]
547 struct DestinationStatus {
548 scope: FleetProfileScope,
549 /// `None` when the destination can be written; otherwise the localized
550 /// reason it is offered disabled.
551 unavailable_reason: Option<String>,
552 /// Exact file that saving would write.
553 target: PathBuf,
554 /// Whether `target` already exists (saving would replace it).
555 target_exists: bool,
556 }
557
558 /// Which control on the Review step owns keyboard focus.
559 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
560 enum ReviewFocus {
561 Save,
562 ChangeDestination,
563 Back,
564 }
565
566 impl ReviewFocus {
567 const ORDER: [Self; 3] = [Self::Save, Self::ChangeDestination, Self::Back];
568
569 fn next(self) -> Self {
570 let idx = Self::ORDER.iter().position(|f| *f == self).unwrap_or(0);
571 Self::ORDER[(idx + 1) % Self::ORDER.len()]
572 }
573
574 fn prev(self) -> Self {
575 let idx = Self::ORDER.iter().position(|f| *f == self).unwrap_or(0);
576 Self::ORDER[(idx + Self::ORDER.len() - 1) % Self::ORDER.len()]
577 }
578 }
579
580 /// The workflow-owned request and its validated, deliberately unratified
581 /// proposal. Keeping the request beside the proposal lets the UI re-run the
582 /// workflow validator at the exact point where a human accepts a suggestion.
583 #[derive(Debug, Clone)]
584 struct CompositionAdvisory {
585 request: FleetCompositionRequest,
586 proposal: FleetCompositionProposal,
587 }
588
589 impl CompositionAdvisory {
590 fn validated_route_for_role(
591 &self,
592 role: &str,
593 ) -> Result<Option<(String, String)>, CompositionError> {
594 let proposal =
595 FleetCompositionProposal::validate(&self.request, self.proposal.suggestions.clone())?;
596 Ok(proposal
597 .suggestions
598 .iter()
599 .find(|suggestion| suggestion.role.eq_ignore_ascii_case(role))
600 .map(|suggestion| (suggestion.provider.clone(), suggestion.model.clone())))
601 }
602 }
603
604 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
605 enum CompositionDecision {
606 Pending,
607 Accepted,
608 Edited,
609 Rejected,
610 }
611
612 /// Per-row Fleet Model step interaction state.
613 ///
614 /// Replaces the old `model_selectable: Vec<bool>` so a dormant external-consent
615 /// route can require explicit activation (#v092-fleet-routes-fix) while
616 /// genuinely unconfigured routes stay blocked with a reason.
617 #[derive(Debug, Clone, PartialEq, Eq)]
618 enum FleetModelRowState {
619 Ready,
620 NeedsActivation,
621 Blocked { reason: String },
622 }
623
624 impl FleetModelRowState {
625 fn from_readiness(readiness: &crate::provider_readiness::ResolvedProviderReadiness) -> Self {
626 if readiness.requires_explicit_activation() {
627 return Self::NeedsActivation;
628 }
629 if let Some(reason) = readiness.blocked_reason() {
630 return Self::Blocked {
631 reason: reason.into_owned(),
632 };
633 }
634 if readiness.can_attempt() {
635 return Self::Ready;
636 }
637 Self::Blocked {
638 reason: readiness
639 .blocked_reason()
640 .map(std::borrow::Cow::into_owned)
641 .unwrap_or_else(|| readiness.label().into_owned()),
642 }
643 }
644 }
645
646 /// Build the setup-time advisory from routes the wizard already resolved from
647 /// the operator's configured providers. The adapter is intentionally pure: it
648 /// sorts and de-duplicates the redacted provider/model pairs, assigns them to
649 /// the built-in roles in stable round-robin order, then asks the workflow
650 /// schema to validate every assignment against that exact pool.
651 fn deterministic_composition_advisory(
652 available_models: &[(
653 String,
654 String,
655 crate::provider_readiness::ResolvedProviderReadiness,
656 )],
657 ) -> Option<CompositionAdvisory> {
658 let mut seen = BTreeSet::new();
659 let mut pool: Vec<ConfiguredModel> = available_models
660 .iter()
661 // Do not recommend a route the Model step would refuse or require the
662 // operator to activate first. Such rows remain available for explicit
663 // human selection in the existing picker.
664 .filter(|(_, _, readiness)| {
665 FleetModelRowState::from_readiness(readiness) == FleetModelRowState::Ready
666 })
667 .filter_map(|(provider, model, _)| {
668 let key = (provider.clone(), model.clone());
669 seen.insert(key.clone())
670 .then(|| ConfiguredModel::new(key.0, key.1, None))
671 })
672 .collect();
673 pool.sort_by(|left, right| {
674 left.provider
675 .cmp(&right.provider)
676 .then_with(|| left.model.cmp(&right.model))
677 });
678
679 let roles: Vec<CompositionRole> = ROLES
680 .iter()
681 // `custom` is an invitation to author a posture, not a semantic Fleet
682 // role, so it stays on the manual Model path.
683 .filter(|role| role.label != "custom")
684 .map(|role| CompositionRole::new(role.label.to_string(), Some(&role.summary)))
685 .collect();
686 let request = FleetCompositionRequest::new(pool, roles).ok()?;
687 let suggestions = request
688 .roles
689 .iter()
690 .enumerate()
691 .map(|(idx, role)| {
692 let configured = &request.pool[idx % request.pool.len()];
693 RoleSuggestion {
694 role: role.role.clone(),
695 provider: configured.provider.clone(),
696 model: configured.model.clone(),
697 reason: Some(
698 "Stable round-robin assignment from the configured model pool.".to_string(),
699 ),
700 }
701 })
702 .collect();
703 let proposal = FleetCompositionProposal::validate(&request, suggestions).ok()?;
704 Some(CompositionAdvisory { request, proposal })
705 }
706
707 /// A role assignment edits only route keys in the original document. The
708 /// source and possible destinations are captured before opening the picker.
709 struct RouteAssignment {
710 editor_id: uuid::Uuid,
711 id: String,
712 template: toml::Table,
713 original_member: crate::fleet::profile::AgentProfile,
714 source: Option<(PathBuf, String)>,
715 source_scope: Option<FleetProfileScope>,
716 destinations: Vec<(PathBuf, Option<String>)>,
717 provider: Option<String>,
718 model: Option<String>,
719 reasoning: Option<String>,
720 }
721
722 fn assignment_source(path: &Path) -> Result<Option<String>, String> {
723 match std::fs::read_to_string(path) {
724 Ok(text) => Ok(Some(text)),
725 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
726 Err(err) => Err(format!("Cannot read {}: {err}", path.display())),
727 }
728 }
729
730 pub struct FleetSetupView {
731 snapshot: FleetSetupSnapshot,
732 assignment: Option<RouteAssignment>,
733 step: Step,
734 role_idx: usize,
735 model_idx: usize,
736 thinking_idx: usize,
737 profile_scope: FleetProfileScope,
738 /// Whether the user has explicitly chosen (or a saved profile supplied) the
739 /// save destination. Until then the header says the choice is still ahead
740 /// instead of silently presenting a default as a decision.
741 scope_decided: bool,
742 /// Highlighted row on the Destination step (index into DESTINATION_ORDER).
743 destination_idx: usize,
744 /// Resolved destination facts for both scopes. Recomputed on entry to the
745 /// Destination/Review steps and when the role (file name) changes; the
746 /// draw path never touches the filesystem (#3908).
747 destinations: Option<[DestinationStatus; 2]>,
748 /// Focused control on the Review step (Tab/Shift-Tab/←/→ move it).
749 review_focus: ReviewFocus,
750 /// Replacing an existing file needs a second Enter on the save control.
751 replace_armed: bool,
752 /// One-line inline notice (e.g. why a model row cannot be selected).
753 /// Cleared on the next navigation key.
754 notice: Option<String>,
755 review_scroll: usize,
756 /// A model-drafted profile awaiting save (already sanitized and
757 /// bounded by the untrusted gate). Cleared when the selection changes so
758 /// a stale draft can never be saved against fresh answers.
759 model_draft: Option<Box<crate::fleet::profile::FleetProfileDraft>>,
760 /// Exact rendered TOML preview for `model_draft` (header comment + the
761 /// deterministic bytes saving would persist). Rendered inline on the
762 /// Review step — never in a separate pager (#4093): a standalone pager
763 /// view owns its own `g`/`G` scroll bindings, which silently swallowed
764 /// the save keypress and left users unable to save without first
765 /// pressing Esc. Keeping the preview and the save control in the same
766 /// view means the footer's `g`/Enter hints are never a lie.
767 model_draft_preview: Option<String>,
768 /// Model-step rows: `inherit` followed by one row per concrete model from
769 /// every configured provider (#4093).
770 model_choices: Vec<Choice>,
771 /// `(provider, model)` aligned with `model_choices`. Index 0 is `inherit`
772 /// (the active route); later rows pin a concrete, possibly cross-provider
773 /// route. Drives the review/copy so a pinned route names its own provider.
774 model_routes: Vec<(String, String)>,
775 /// Interaction state for each aligned Model row. Distinguishes ready rows,
776 /// dormant external-consent rows that need explicit activation, and
777 /// genuinely blocked rows with a short reason.
778 model_row_states: Vec<FleetModelRowState>,
779 /// Typed filter for the Model step (#4639): substring match over
780 /// provider and model id, so provider-heavy catalogs (e.g. OpenRouter)
781 /// stay navigable without a provider→model drill-down.
782 model_query: String,
783 /// Whether the Model step's filter input is capturing keystrokes (`/`
784 /// toggles it; Enter keeps the filter, Esc clears it).
785 model_filter_active: bool,
786 /// Pure workflow-schema proposal shown before the Model picker. It has no
787 /// save, spawn, launch, or snapshot capability.
788 composition: Option<CompositionAdvisory>,
789 composition_decision: CompositionDecision,
790 /// Selectable rows registered by the latest render. Keeping mouse geometry
791 /// in the view gives the Fleet walkthrough the same row ownership as its
792 /// keyboard path without coupling the host to this modal's layout.
793 row_hitboxes: RefCell<Vec<(Rect, usize)>>,
794 }
795
796 impl FleetSetupView {
797 /// Refresh row states from a freshly built snapshot while preserving the
798 /// user's current selection position and draft state. Used after the host
799 /// validates a dormant external-consent route so the same row becomes
800 /// Ready without closing and reopening the modal.
801 pub fn refresh_from_snapshot(&mut self, snapshot: FleetSetupSnapshot) {
802 let old_step = self.step;
803 let old_role_idx = self.role_idx;
804 let old_model_idx = self.model_idx;
805 let old_thinking_idx = self.thinking_idx;
806 let old_profile_scope = self.profile_scope;
807 let old_scope_decided = self.scope_decided;
808 let old_destination_idx = self.destination_idx;
809 let old_review_focus = self.review_focus;
810 let old_model_query = self.model_query.clone();
811 let old_model_filter_active = self.model_filter_active;
812 let old_review_scroll = self.review_scroll;
813 let old_model_draft = self.model_draft.clone();
814 let old_model_draft_preview = self.model_draft_preview.clone();
815
816 *self = Self::from_snapshot(snapshot);
817
818 self.step = old_step;
819 self.role_idx = old_role_idx;
820 self.model_idx = old_model_idx.min(self.filtered_model_indices().len().saturating_sub(1));
821 self.thinking_idx = old_thinking_idx;
822 self.profile_scope = old_profile_scope;
823 self.scope_decided = old_scope_decided;
824 self.destination_idx = old_destination_idx;
825 self.review_focus = old_review_focus;
826 self.model_query = old_model_query;
827 self.model_filter_active = old_model_filter_active;
828 self.review_scroll = old_review_scroll;
829 self.model_draft = old_model_draft;
830 self.model_draft_preview = old_model_draft_preview;
831 if self.step == Step::Composition && !self.has_composition_for_selected_role() {
832 self.step = Step::Model;
833 }
834 if matches!(self.step, Step::Destination | Step::Review) {
835 self.refresh_destinations();
836 }
837 }
838
839 #[must_use]
840 pub fn new(app: &App, config: &Config) -> Self {
841 Self::from_snapshot(FleetSetupSnapshot::from_app(app, config))
842 }
843
844 /// Open setup for a role the operator already selected in `/fleet`.
845 /// Unknown/custom roster roles map to the explicit custom authoring row;
846 /// Left or Esc still exposes Role so the carried choice is never sticky.
847 #[must_use]
848 pub fn new_for_role(app: &App, config: &Config, role: &str) -> Self {
849 Self::from_snapshot_for_role(FleetSetupSnapshot::from_app(app, config), role)
850 }
851
852 pub(crate) fn new_for_route_assignment(
853 app: &App,
854 config: &Config,
855 id: &str,
856 ) -> Result<Self, String> {
857 let roster = crate::fleet::identity::load_effective_roster(
858 &config.fleet_config(),
859 &app.workspace,
860 Some(app.plugin_registry.as_ref()),
861 );
862 let member = roster
863 .members()
864 .iter()
865 .find(|member| member.id == id)
866 .ok_or_else(|| "This role is no longer available. Reopen Fleet.".to_string())?;
867 if matches!(
868 member.origin,
869 crate::fleet::roster::ProfileOrigin::Plugin
870 | crate::fleet::roster::ProfileOrigin::Config
871 ) {
872 return Err(format!(
873 "{} is managed by {} ({}). Change its model there; copying it into a profile would discard its original controls.",
874 member.id,
875 member.origin,
876 member.source.display()
877 ));
878 }
879 if member.origin == crate::fleet::roster::ProfileOrigin::BuiltIn
880 && (member.profile.permissions != Default::default()
881 || member.profile.delegation != Default::default())
882 {
883 return Err("This role has controls that cannot be copied into a profile. Edit its defining configuration.".into());
884 }
885 let mut view = Self::new_for_role(app, config, id);
886 let source_scope = match member.origin {
887 crate::fleet::roster::ProfileOrigin::Workspace => Some(FleetProfileScope::Project),
888 crate::fleet::roster::ProfileOrigin::Personal => Some(FleetProfileScope::Personal),
889 _ => None,
890 };
891 let source = if source_scope.is_some() {
892 Some((
893 member.source.clone(),
894 assignment_source(&member.source)?
895 .ok_or_else(|| "The saved role disappeared. Reopen Fleet.".to_string())?,
896 ))
897 } else {
898 None
899 };
900 let template = if let Some((_, text)) = &source {
901 toml::from_str::<toml::Table>(text).map_err(|err| err.to_string())?
902 } else {
903 let draft = crate::fleet::profile::FleetProfileDraft {
904 id: member.id.clone(),
905 display_name: member.display_name.clone(),
906 description: member.description.clone(),
907 role_hint: member.profile.role.name.clone(),
908 model_class_hint: Some(member.profile.loadout.as_str().to_string()),
909 model: member.profile.model.clone(),
910 provider: member.profile.provider.clone(),
911 reasoning_effort: member.profile.reasoning_effort.clone(),
912 instructions: member.profile.role.instructions.clone(),
913 };
914 toml::from_str::<toml::Table>(&draft.render_toml()).map_err(|err| err.to_string())?
915 };
916 view.assignment = Some(RouteAssignment {
917 editor_id: uuid::Uuid::new_v4(),
918 id: member.id.clone(),
919 template,
920 original_member: member.clone(),
921 source,
922 source_scope,
923 destinations: Vec::new(),
924 provider: member.profile.provider.clone(),
925 model: member.profile.model.clone(),
926 reasoning: member.profile.reasoning_effort.clone(),
927 });
928 view.refresh_destinations();
929 let targets = view
930 .destinations
931 .as_ref()
932 .into_iter()
933 .flatten()
934 .filter(|destination| destination.unavailable_reason.is_none())
935 .map(|destination| {
936 Ok((
937 destination.target.clone(),
938 assignment_source(&destination.target)?,
939 ))
940 })
941 .collect::<Result<Vec<_>, String>>()?;
942 view.assignment.as_mut().unwrap().destinations = targets;
943 view.step = Step::Destination;
944 Ok(view)
945 }
946
947 pub(crate) fn route_pick_request(&self) -> ViewAction {
948 self.assignment
949 .as_ref()
950 .map_or(ViewAction::None, |assignment| {
951 ViewAction::Emit(ViewEvent::FleetProfileRoutePickRequested {
952 editor_id: assignment.editor_id,
953 })
954 })
955 }
956
957 pub(crate) fn assignment_context(&self) -> (String, String) {
958 (self.selected_role(), self.saves_to_line())
959 }
960
961 pub(crate) fn route_selection(
962 &self,
963 editor_id: uuid::Uuid,
964 ) -> Option<super::fleet_detail::FleetRouteSelection> {
965 let assignment = self
966 .assignment
967 .as_ref()
968 .filter(|assignment| assignment.editor_id == editor_id)?;
969 Some(super::fleet_detail::FleetRouteSelection {
970 provider: assignment.provider.clone(),
971 model: assignment.model.clone(),
972 reasoning: assignment.reasoning.as_deref().and_then(|value| {
973 crate::reasoning_preference::ReasoningEffort::parse_strict(value).ok()
974 }),
975 allow_inherit: true,
976 })
977 }
978
979 pub(crate) fn accept_route(
980 &mut self,
981 editor_id: uuid::Uuid,
982 provider: String,
983 model: String,
984 reasoning: Option<crate::reasoning_preference::ReasoningEffort>,
985 ) -> bool {
986 let Some(assignment) = self
987 .assignment
988 .as_mut()
989 .filter(|assignment| assignment.editor_id == editor_id)
990 else {
991 return false;
992 };
993 assignment.provider = (model != "auto").then_some(provider);
994 assignment.model = (model != "auto").then_some(model);
995 assignment.reasoning = reasoning.map(|effort| effort.as_setting().to_string());
996 self.step = if self.scope_decided {
997 Step::Review
998 } else {
999 Step::Destination
1000 };
1001 self.refresh_destinations();
1002 true
1003 }
1004
1005 pub(crate) fn commit_route_assignment(
1006 &self,
1007 editor_id: uuid::Uuid,
1008 app: &App,
1009 config: &Config,
1010 ) -> Result<String, String> {
1011 let assignment = self
1012 .assignment
1013 .as_ref()
1014 .filter(|assignment| assignment.editor_id == editor_id)
1015 .ok_or_else(|| "This assignment is no longer open.".to_string())?;
1016 if !matches!(
1017 resolve_fleet_setup_edit_target(&app.workspace),
1018 Ok(FleetSetupEditTarget::LegacyProfiles)
1019 ) {
1020 return Err("The selected team changed. Reopen Fleet before saving.".into());
1021 }
1022 let roster = crate::fleet::identity::load_effective_roster(
1023 &config.fleet_config(),
1024 &app.workspace,
1025 Some(app.plugin_registry.as_ref()),
1026 );
1027 if !roster
1028 .members()
1029 .iter()
1030 .any(|member| member == &assignment.original_member)
1031 {
1032 return Err(
1033 "This role changed while you were choosing. Reopen Fleet before saving.".into(),
1034 );
1035 }
1036 if !self.scope_decided || !self.selected_destination_available() {
1037 return Err("Choose an available save destination first.".into());
1038 }
1039 if self.profile_scope == FleetProfileScope::Project
1040 && !crate::fleet::roster::project_agent_profiles_enabled()
1041 {
1042 return Err("Project profiles are disabled for this launch.".into());
1043 }
1044 if let Some(provider) = assignment.provider.as_deref()
1045 && let Some(reason) = crate::commands::fleet_provider_rejection(app, config, provider)
1046 {
1047 return Err(reason);
1048 }
1049 if let Some((path, expected)) = &assignment.source
1050 && assignment_source(path)?.as_ref() != Some(expected)
1051 {
1052 return Err("This role changed on disk. Reopen Fleet before saving.".into());
1053 }
1054 let target = &self
1055 .destination_for(self.profile_scope)
1056 .ok_or("Save destination unavailable")?
1057 .target;
1058 let expected = assignment
1059 .destinations
1060 .iter()
1061 .find(|(path, _)| path == target)
1062 .ok_or("Save destination changed. Reopen Fleet.")?;
1063 if assignment_source(target)? != expected.1 {
1064 return Err("The destination changed on disk. Reopen Fleet before saving.".into());
1065 }
1066 let dir = target.parent().ok_or("Invalid profile destination")?;
1067 let identities = crate::fleet::profile::load_agent_profile_identities_from_dir(dir)
1068 .map_err(|err| err.to_string())?;
1069 if identities.iter().any(|profile| {
1070 profile.id.eq_ignore_ascii_case(&assignment.id) && profile.source != *target
1071 }) {
1072 return Err("Another file already defines this role. Reopen Fleet.".into());
1073 }
1074 let mut table = assignment.template.clone();
1075 for alias in [
1076 "model",
1077 "model_hint",
1078 "model_id",
1079 "provider",
1080 "reasoning_effort",
1081 "thinking",
1082 "reasoning",
1083 ] {
1084 table.remove(alias);
1085 }
1086 for (key, value) in [
1087 ("model", &assignment.model),
1088 ("provider", &assignment.provider),
1089 ("reasoning_effort", &assignment.reasoning),
1090 ] {
1091 if let Some(value) = value {
1092 table.insert(key.into(), toml::Value::String(value.clone()));
1093 }
1094 }
1095 table.insert("loadout".into(), toml::Value::String("inherit".into()));
1096 let text = toml::to_string_pretty(&table).map_err(|err| err.to_string())?;
1097 let mut transaction = codewhale_config::persistence::SetupTransaction::new();
1098 transaction.stage(target.clone(), text.into_bytes());
1099 transaction.commit().map_err(|err| err.to_string())?;
1100 Ok(format!(
1101 "{} model saved · {}",
1102 assignment.id,
1103 target.display()
1104 ))
1105 }
1106
1107 fn from_snapshot_for_role(snapshot: FleetSetupSnapshot, role: &str) -> Self {
1108 let mut view = Self::from_snapshot(snapshot);
1109 let role = public_role_label(role);
1110 view.role_idx = ROLES
1111 .iter()
1112 .position(|choice| choice.label.eq_ignore_ascii_case(&role))
1113 .unwrap_or(ROLES.len() - 1);
1114 view.step = Step::Model;
1115 // Reopening a SAVED member edits what is on disk: preselect its route,
1116 // thinking tier, and — most importantly — the scope it was saved in,
1117 // so "edit" can never quietly land in the other destination.
1118 let role_id = role.trim().to_ascii_lowercase();
1119 let saved = view
1120 .snapshot
1121 .roster_details
1122 .iter()
1123 .find(|detail| detail.id == role_id || public_role_label(&detail.id) == role)
1124 .cloned();
1125 if let Some(saved) = saved {
1126 view.profile_scope = saved.scope;
1127 view.scope_decided = true;
1128 view.destination_idx = DESTINATION_ORDER
1129 .iter()
1130 .position(|scope| *scope == saved.scope)
1131 .unwrap_or(0);
1132 if let Some(model) = saved.model.as_deref() {
1133 let idx = view.model_routes.iter().position(|(provider, candidate)| {
1134 candidate == model
1135 && saved
1136 .provider
1137 .as_deref()
1138 .is_none_or(|p| p.eq_ignore_ascii_case(provider))
1139 });
1140 if let Some(idx) = idx {
1141 view.model_idx = idx;
1142 }
1143 }
1144 if let Some(effort) = saved.reasoning_effort.as_deref()
1145 && let Some(idx) = THINKING_CHOICES
1146 .iter()
1147 .position(|choice| choice.label.eq_ignore_ascii_case(effort))
1148 {
1149 view.thinking_idx = idx;
1150 }
1151 }
1152 view
1153 }
1154
1155 fn from_snapshot(snapshot: FleetSetupSnapshot) -> Self {
1156 let mut model_choices = vec![MODEL_INHERIT];
1157 // `inherit` (index 0) maps to the active route; every later row pins a
1158 // concrete (provider, model) drawn from all configured providers.
1159 let mut model_routes = vec![(snapshot.provider.clone(), snapshot.model.clone())];
1160 let mut model_row_states = vec![FleetModelRowState::Ready];
1161 for (provider, model, readiness) in &snapshot.available_models {
1162 let provider_label = provider_display_label(provider);
1163 let readiness_summary = readiness.detail().map_or_else(
1164 || readiness.label().into_owned(),
1165 |detail| format!("{}: {detail}", readiness.label()),
1166 );
1167 // Capability badges from the existing catalog/registry owners
1168 // (#5038): shown in the word-wrapped detail pane so the picker
1169 // list stays narrow-terminal friendly. Unknown models honestly
1170 // omit the sentence instead of blocking selection.
1171 let capability_note = crate::fleet::capability_badges::resolve_route_capability_badges(
1172 Some(provider),
1173 model,
1174 )
1175 .map(|badges| format!(" Capabilities: {}.", badges.summary()))
1176 .unwrap_or_default();
1177 model_choices.push(Choice {
1178 label: Cow::Owned(model.clone()),
1179 summary: Cow::Owned(format!(
1180 "Pin this model ({provider_label}) · {readiness_summary}"
1181 )),
1182 description: Cow::Owned(format!(
1183 "Route this worker to {model} on {provider_label} instead of inheriting the session route.{capability_note}"
1184 )),
1185 });
1186 // Canonical provider id (not the display label above) — this is
1187 // what gets persisted into the saved profile (#4093).
1188 model_routes.push((provider.clone(), model.clone()));
1189 model_row_states.push(FleetModelRowState::from_readiness(readiness));
1190 }
1191 let composition = deterministic_composition_advisory(&snapshot.available_models);
1192 Self {
1193 snapshot,
1194 assignment: None,
1195 step: Step::Role,
1196 role_idx: 0,
1197 model_idx: 0,
1198 thinking_idx: 0,
1199 // Profiles authored for a person should follow that person across
1200 // repositories by default. Project scope remains one `s` away and
1201 // keeps higher roster precedence when explicitly selected.
1202 profile_scope: FleetProfileScope::Personal,
1203 scope_decided: false,
1204 destination_idx: DESTINATION_ORDER.len() - 1,
1205 destinations: None,
1206 review_focus: ReviewFocus::Save,
1207 replace_armed: false,
1208 notice: None,
1209 review_scroll: 0,
1210 model_draft: None,
1211 model_draft_preview: None,
1212 model_choices,
1213 model_routes,
1214 model_row_states,
1215 model_query: String::new(),
1216 model_filter_active: false,
1217 composition,
1218 composition_decision: CompositionDecision::Pending,
1219 row_hitboxes: RefCell::new(Vec::new()),
1220 }
1221 }
1222
1223 /// Install a sanitized, bounded model draft. The exact TOML preview
1224 /// (returned here for the caller's status message) renders inline on the
1225 /// Review step — not in a separate pager — so the footer's `g`/Enter
1226 /// ratify hints stay true the instant the draft lands (#4093).
1227 pub fn install_model_draft(
1228 &mut self,
1229 mut draft: Box<crate::fleet::profile::FleetProfileDraft>,
1230 model_label: String,
1231 picked_route: Option<(String, String)>,
1232 reasoning_effort: Option<String>,
1233 ) -> (String, String) {
1234 // Re-inject the route the operator picked at `m`-press time (#4093). A
1235 // model draft comes from `from_untrusted_json`, which hard-sets
1236 // `provider: None` and echoes whatever `model` the model happened to
1237 // emit — so ratifying it verbatim would drop a concrete cross-provider
1238 // pick and persist the ambiguous, provider-scoped profile #4093 exists
1239 // to prevent. Pinning BOTH fields from the CARRIED route keeps the route
1240 // the user actually chose (the model only authored the prose), and is
1241 // immune to the selection changing while the async draft is in flight.
1242 // `inherit` (a `None` route) leaves `model`/`provider` untouched,
1243 // matching the deterministic Enter path.
1244 if let Some((provider, model)) = picked_route {
1245 draft.model = Some(model);
1246 draft.provider = Some(provider);
1247 }
1248 draft.reasoning_effort = reasoning_effort;
1249 let (title, header) = (
1250 tr(self.snapshot.locale, MessageId::FleetDraftTitle)
1251 .replace("{model_label}", &model_label),
1252 tr(self.snapshot.locale, MessageId::FleetDraftHeader)
1253 .replace("{name}", &draft.file_name())
1254 .replace("{model_label}", &model_label),
1255 );
1256 let content = format!(
1257 "{}{}",
1258 self.scope_preview_header(header),
1259 draft.render_toml()
1260 );
1261 self.model_draft = Some(draft);
1262 self.model_draft_preview = Some(content.clone());
1263 self.review_scroll = 0;
1264 (title, content)
1265 }
1266
1267 /// The planner role chosen (drives the profile file name and `role_hint`).
1268 fn selected_role(&self) -> String {
1269 self.assignment
1270 .as_ref()
1271 .map(|assignment| assignment.id.clone())
1272 .unwrap_or_else(|| ROLES[self.role_idx.min(ROLES.len() - 1)].label.to_string())
1273 }
1274
1275 fn has_composition_for_selected_role(&self) -> bool {
1276 let role = self.selected_role();
1277 self.composition.as_ref().is_some_and(|advisory| {
1278 advisory
1279 .proposal
1280 .suggestions
1281 .iter()
1282 .any(|suggestion| suggestion.role.eq_ignore_ascii_case(&role))
1283 })
1284 }
1285
1286 /// Re-validate the entire proposal against its original explicit pool,
1287 /// then return the selected role's route. An out-of-pool proposal never
1288 /// reaches `model_idx`, even if the in-memory advisory were corrupted.
1289 fn validated_composition_route(&self) -> Option<(String, String)> {
1290 self.composition
1291 .as_ref()?
1292 .validated_route_for_role(&self.selected_role())
1293 .ok()?
1294 }
1295
1296 fn select_model_route(&mut self, route: &(String, String)) -> bool {
1297 let Some(idx) = self
1298 .model_routes
1299 .iter()
1300 .position(|candidate| candidate == route)
1301 else {
1302 return false;
1303 };
1304 self.model_query.clear();
1305 self.model_filter_active = false;
1306 self.model_idx = idx;
1307 true
1308 }
1309
1310 fn accept_composition(&mut self) -> ViewAction {
1311 let Some(route) = self.validated_composition_route() else {
1312 return ViewAction::None;
1313 };
1314 if !self.select_model_route(&route) {
1315 return ViewAction::None;
1316 }
1317 self.composition_decision = CompositionDecision::Accepted;
1318 // Accepting a suggestion still routes through the Destination step:
1319 // where the file lives is a human decision, not part of the advisory.
1320 self.step = Step::Destination;
1321 self.refresh_destinations();
1322 ViewAction::None
1323 }
1324
1325 fn edit_composition(&mut self) -> ViewAction {
1326 let Some(route) = self.validated_composition_route() else {
1327 return ViewAction::None;
1328 };
1329 if !self.select_model_route(&route) {
1330 return ViewAction::None;
1331 }
1332 self.composition_decision = CompositionDecision::Edited;
1333 self.step = Step::Model;
1334 ViewAction::None
1335 }
1336
1337 fn reject_composition(&mut self) -> ViewAction {
1338 self.composition_decision = CompositionDecision::Rejected;
1339 self.step = Step::Model;
1340 ViewAction::None
1341 }
1342
1343 /// Copy note when the chosen role would override an existing roster
1344 /// member of the same id (e.g. "overrides built-in reviewer"). A saved
1345 /// profile shadows lower roster layers rather than adding a new member.
1346 fn roster_override_note(&self) -> Option<String> {
1347 self.override_note_for_scope(self.profile_scope)
1348 }
1349
1350 /// Precedence consequence of saving the selected role into `scope`, given
1351 /// what the roster already contains for that id. Returns `None` when the
1352 /// id is new everywhere.
1353 fn override_note_for_scope(&self, scope: FleetProfileScope) -> Option<String> {
1354 let role = self.selected_role().to_lowercase();
1355 let locale = self.snapshot.locale;
1356 let (id, origin) = self
1357 .snapshot
1358 .roster_members
1359 .iter()
1360 .find(|(id, _)| *id == role)?;
1361 let has_project_copy = self
1362 .snapshot
1363 .roster_details
1364 .iter()
1365 .any(|d| d.id == role && d.scope == FleetProfileScope::Project);
1366 let has_personal_copy = self
1367 .snapshot
1368 .roster_details
1369 .iter()
1370 .any(|d| d.id == role && d.scope == FleetProfileScope::Personal);
1371 Some(match scope {
1372 FleetProfileScope::Personal if has_project_copy => {
1373 tr(locale, MessageId::FleetDestOverridesProject).replace("{id}", id)
1374 }
1375 FleetProfileScope::Project if has_personal_copy => {
1376 tr(locale, MessageId::FleetDestOverridesPersonal).replace("{id}", id)
1377 }
1378 _ => tr(locale, MessageId::FleetDestOverridesBuiltIn)
1379 .replace("{origin}", origin)
1380 .replace("{id}", id),
1381 })
1382 }
1383
1384 /// Localized "This project" / "Personal" label for a scope.
1385 fn scope_label(&self, scope: FleetProfileScope) -> String {
1386 tr(
1387 self.snapshot.locale,
1388 match scope {
1389 FleetProfileScope::Project => MessageId::FleetDestProjectLabel,
1390 FleetProfileScope::Personal => MessageId::FleetDestPersonalLabel,
1391 },
1392 )
1393 .into_owned()
1394 }
1395
1396 fn destination_for(&self, scope: FleetProfileScope) -> Option<&DestinationStatus> {
1397 self.destinations
1398 .as_ref()
1399 .and_then(|all| all.iter().find(|d| d.scope == scope))
1400 }
1401
1402 /// The header chip: where the file will be written, or that the choice is
1403 /// still ahead. Visible on every step so the destination is never a
1404 /// surprise on the last screen.
1405 fn saves_to_line(&self) -> String {
1406 let locale = self.snapshot.locale;
1407 if !self.scope_decided {
1408 return tr(locale, MessageId::FleetSavesToUndecided).into_owned();
1409 }
1410 let path = self
1411 .destination_for(self.profile_scope)
1412 .map(|d| d.target.display().to_string())
1413 .unwrap_or_else(|| self.projected_target(self.profile_scope));
1414 tr(locale, MessageId::FleetSavesToChip)
1415 .replace("{scope}", &self.scope_label(self.profile_scope))
1416 .replace("{path}", &path)
1417 }
1418
1419 /// Best-effort target path without touching the filesystem (used before
1420 /// `refresh_destinations` has run for the current role).
1421 fn projected_target(&self, scope: FleetProfileScope) -> String {
1422 let file = format!("{}.toml", profile_file_stem(&self.selected_role()));
1423 match scope {
1424 FleetProfileScope::Project => self
1425 .snapshot
1426 .workspace
1427 .join(crate::fleet::profile::WORKSPACE_AGENT_PROFILE_DIR)
1428 .join(file)
1429 .display()
1430 .to_string(),
1431 FleetProfileScope::Personal => match &self.snapshot.personal_profile_dir {
1432 Ok(dir) => dir.join(file).display().to_string(),
1433 Err(_) => format!("{}/{file}", scope.display_dir()),
1434 },
1435 }
1436 }
1437
1438 /// The label of the primary Review action — it names its effect.
1439 fn save_action_label(&self) -> String {
1440 let locale = self.snapshot.locale;
1441 let exists = self
1442 .destination_for(self.profile_scope)
1443 .is_some_and(|d| d.target_exists);
1444 if exists && self.replace_armed {
1445 let file = self
1446 .destination_for(self.profile_scope)
1447 .and_then(|d| {
1448 d.target
1449 .file_name()
1450 .map(|f| f.to_string_lossy().into_owned())
1451 })
1452 .unwrap_or_default();
1453 return tr(locale, MessageId::FleetActionConfirmReplace).replace("{file}", &file);
1454 }
1455 tr(
1456 locale,
1457 match (self.profile_scope, exists) {
1458 (FleetProfileScope::Project, false) => MessageId::FleetActionSaveProject,
1459 (FleetProfileScope::Personal, false) => MessageId::FleetActionSavePersonal,
1460 (FleetProfileScope::Project, true) => MessageId::FleetActionReplaceProject,
1461 (FleetProfileScope::Personal, true) => MessageId::FleetActionReplacePersonal,
1462 },
1463 )
1464 .into_owned()
1465 }
1466
1467 /// Whether the currently chosen destination can be written.
1468 fn selected_destination_available(&self) -> bool {
1469 self.destination_for(self.profile_scope)
1470 .is_none_or(|d| d.unavailable_reason.is_none())
1471 }
1472
1473 /// The concrete model chosen for this worker, written to the profile
1474 /// `model` field. `None` means `inherit` (reuse the session route).
1475 fn selected_model(&self) -> Option<String> {
1476 self.selected_route().map(|(_, model)| model)
1477 }
1478
1479 /// The concrete `(provider, model)` chosen for this worker — a pinned route
1480 /// independent of the parent/current provider (#4093) — or `None` when
1481 /// `inherit` is selected (reuse the session route).
1482 fn selected_route(&self) -> Option<(String, String)> {
1483 if let Some(assignment) = &self.assignment {
1484 return assignment.provider.clone().zip(assignment.model.clone());
1485 }
1486 let real_idx = self.real_model_idx();
1487 if real_idx == 0 {
1488 return None;
1489 }
1490 self.model_routes.get(real_idx).cloned()
1491 }
1492
1493 /// Indices into `model_choices` visible under the current typed filter
1494 /// (#4639). Empty query shows every row; otherwise substring match over
1495 /// provider id/label and model id.
1496 fn filtered_model_indices(&self) -> Vec<usize> {
1497 (0..self.model_choices.len())
1498 .filter(|idx| {
1499 let (provider, model) = &self.model_routes[*idx];
1500 route_matches_query(&self.model_query, provider, model, *idx == 0)
1501 })
1502 .collect()
1503 }
1504
1505 /// Map the filtered highlight position back to the real `model_choices`
1506 /// index. Selection, persistence, and hitboxes all use the real index.
1507 fn real_model_idx(&self) -> usize {
1508 let filtered = self.filtered_model_indices();
1509 if filtered.is_empty() {
1510 return 0;
1511 }
1512 filtered[self.model_idx.min(filtered.len() - 1)]
1513 }
1514
1515 fn selected_reasoning_effort(&self) -> Option<String> {
1516 if let Some(assignment) = &self.assignment {
1517 return assignment.reasoning.clone();
1518 }
1519 if self.thinking_idx == 0 {
1520 return None;
1521 }
1522 THINKING_CHOICES
1523 .get(self.thinking_idx)
1524 .map(|choice| choice.label.to_string())
1525 }
1526
1527 fn selected_thinking_label(&self) -> String {
1528 self.selected_reasoning_effort()
1529 .unwrap_or_else(|| format!("same as session ({})", self.snapshot.reasoning))
1530 }
1531
1532 fn scope_preview_header(&self, header: String) -> String {
1533 header.replacen(PROFILE_DIR, self.profile_scope.display_dir(), 1)
1534 }
1535
1536 /// Number of selectable rows on the current step (0 on the review step).
1537 fn step_len(&self) -> usize {
1538 match self.step {
1539 Step::Role => ROLES.len(),
1540 Step::Composition => 0,
1541 Step::Model => self.filtered_model_indices().len(),
1542 Step::Destination => DESTINATION_ORDER.len(),
1543 Step::Review => 0,
1544 }
1545 }
1546
1547 fn move_up(&mut self) {
1548 match self.step {
1549 Step::Role => {
1550 self.role_idx =
1551 crate::tui::list_nav::wrap_index(self.role_idx, self.step_len(), -1);
1552 self.discard_model_draft();
1553 self.composition_decision = CompositionDecision::Pending;
1554 }
1555 Step::Composition => {}
1556 Step::Model => {
1557 self.model_idx =
1558 crate::tui::list_nav::wrap_index(self.model_idx, self.step_len(), -1);
1559 self.discard_model_draft();
1560 if self.composition_decision != CompositionDecision::Pending {
1561 self.composition_decision = CompositionDecision::Edited;
1562 }
1563 }
1564 Step::Destination => {
1565 self.destination_idx =
1566 crate::tui::list_nav::wrap_index(self.destination_idx, self.step_len(), -1);
1567 }
1568 Step::Review => self.review_scroll = self.review_scroll.saturating_sub(1),
1569 }
1570 }
1571
1572 /// A draft is only valid for the answers it was requested against.
1573 fn discard_model_draft(&mut self) {
1574 self.model_draft = None;
1575 self.model_draft_preview = None;
1576 }
1577
1578 fn move_down(&mut self) {
1579 match self.step {
1580 Step::Role => {
1581 self.role_idx = crate::tui::list_nav::wrap_index(self.role_idx, self.step_len(), 1);
1582 self.discard_model_draft();
1583 self.composition_decision = CompositionDecision::Pending;
1584 }
1585 Step::Composition => {}
1586 Step::Model => {
1587 self.model_idx =
1588 crate::tui::list_nav::wrap_index(self.model_idx, self.step_len(), 1);
1589 self.discard_model_draft();
1590 if self.composition_decision != CompositionDecision::Pending {
1591 self.composition_decision = CompositionDecision::Edited;
1592 }
1593 }
1594 Step::Destination => {
1595 self.destination_idx =
1596 crate::tui::list_nav::wrap_index(self.destination_idx, self.step_len(), 1);
1597 }
1598 Step::Review => self.review_scroll = self.review_scroll.saturating_add(1),
1599 }
1600 }
1601
1602 /// Apply one [`list_nav`](crate::tui::list_nav) motion (#6290), returning
1603 /// whether it was consumed. Steps wrap; pages travel [`SETUP_PAGE`] rows
1604 /// and clamp. On the Review step the same keys scroll the proof pane
1605 /// instead — it has no row list — and render clamps the offset. The
1606 /// region axis is declined so Tab/Left/Right keep their explicit wizard
1607 /// arms below.
1608 fn apply_motion(&mut self, motion: crate::tui::list_nav::Motion) -> bool {
1609 use crate::tui::list_nav::Motion;
1610 match motion {
1611 Motion::Prev => {
1612 self.move_up();
1613 true
1614 }
1615 Motion::Next => {
1616 self.move_down();
1617 true
1618 }
1619 Motion::RegionPrev | Motion::RegionNext => false,
1620 _ => {
1621 if self.step == Step::Review {
1622 match motion {
1623 Motion::PagePrev => {
1624 self.review_scroll =
1625 self.review_scroll.saturating_sub(REVIEW_SCROLL_PAGE);
1626 }
1627 Motion::PageNext => {
1628 self.review_scroll =
1629 self.review_scroll.saturating_add(REVIEW_SCROLL_PAGE);
1630 }
1631 Motion::First => self.review_scroll = 0,
1632 // Render clamps to the content height.
1633 Motion::Last => self.review_scroll = usize::MAX,
1634 _ => return false,
1635 }
1636 return true;
1637 }
1638 let len = self.step_len();
1639 if len == 0 {
1640 return false;
1641 }
1642 let current = match self.step {
1643 Step::Role => self.role_idx,
1644 Step::Model => self.model_idx,
1645 Step::Destination => self.destination_idx,
1646 _ => return false,
1647 };
1648 let Some(next) = crate::tui::list_nav::apply(current, len, SETUP_PAGE, motion)
1649 else {
1650 return false;
1651 };
1652 match self.step {
1653 Step::Role => {
1654 self.role_idx = next;
1655 self.discard_model_draft();
1656 self.composition_decision = CompositionDecision::Pending;
1657 }
1658 Step::Model => {
1659 self.model_idx = next;
1660 self.discard_model_draft();
1661 if self.composition_decision != CompositionDecision::Pending {
1662 self.composition_decision = CompositionDecision::Edited;
1663 }
1664 }
1665 Step::Destination => {
1666 self.destination_idx = next;
1667 }
1668 _ => {}
1669 }
1670 true
1671 }
1672 }
1673 }
1674
1675 /// Re-stat the profile directory. Called on the two transitions that can
1676 /// change the answer — entering Review, and toggling project/user scope —
1677 /// so the Review step never touches the filesystem while painting.
1678 fn refresh_destinations(&mut self) {
1679 let file = format!("{}.toml", profile_file_stem(&self.selected_role()));
1680 let statuses = DESTINATION_ORDER.map(|scope| {
1681 let file = self
1682 .assignment
1683 .as_ref()
1684 .filter(|assignment| assignment.source_scope == Some(scope))
1685 .and_then(|assignment| assignment.source.as_ref())
1686 .and_then(|(path, _)| path.file_name())
1687 .and_then(|name| name.to_str())
1688 .unwrap_or(&file);
1689 destination_status(
1690 scope,
1691 &self.snapshot.workspace,
1692 &self.snapshot.personal_profile_dir,
1693 file,
1694 self.snapshot.project_profiles_enabled,
1695 self.snapshot.locale,
1696 )
1697 });
1698 self.destinations = Some(statuses);
1699 self.replace_armed = false;
1700 }
1701
1702 /// Choose a destination explicitly (Destination step or roster preload).
1703 fn choose_destination(&mut self, scope: FleetProfileScope) {
1704 if self.profile_scope != scope {
1705 self.discard_model_draft();
1706 }
1707 self.profile_scope = scope;
1708 self.scope_decided = true;
1709 self.destination_idx = DESTINATION_ORDER
1710 .iter()
1711 .position(|s| *s == scope)
1712 .unwrap_or(0);
1713 self.replace_armed = false;
1714 }
1715
1716 /// starter profile TOML the next save keypress would persist.
1717 fn advance(&mut self) -> ViewAction {
1718 match self.step {
1719 Step::Role => {
1720 self.step = Step::Model;
1721 ViewAction::None
1722 }
1723 Step::Composition => self.accept_composition(),
1724 Step::Model => {
1725 let idx = self.real_model_idx();
1726 match self.model_row_states.get(idx) {
1727 Some(FleetModelRowState::Ready) => {
1728 // Path: role → model → destination → review/save.
1729 // Thinking defaults to inherit; adjust on review with `t`.
1730 self.notice = None;
1731 self.step = Step::Destination;
1732 self.refresh_destinations();
1733 }
1734 Some(FleetModelRowState::NeedsActivation) => {
1735 // Dormant external-consent route: explicit human
1736 // selection must mint the read capability and validate
1737 // only this exact provider/model. Hand off to the host
1738 // so rendering stays I/O-free.
1739 if let Some((provider_id, model)) = self.model_routes.get(idx)
1740 && let Some(provider) = crate::config::ApiProvider::parse(provider_id)
1741 && crate::tui::provider_picker::external_consent_target_for_provider(
1742 provider,
1743 )
1744 .is_some()
1745 {
1746 return ViewAction::Emit(
1747 ViewEvent::FleetSetupExternalConsentActivationRequested {
1748 provider_id: provider_id.clone(),
1749 model: model.clone(),
1750 },
1751 );
1752 }
1753 }
1754 Some(FleetModelRowState::Blocked { reason }) => {
1755 // Stay on the Model step, but say why Enter did nothing
1756 // and where to fix it instead of failing silently.
1757 self.notice = Some(
1758 tr(self.snapshot.locale, MessageId::FleetModelRowBlockedNotice)
1759 .replace("{reason}", reason),
1760 );
1761 }
1762 None => {}
1763 }
1764 ViewAction::None
1765 }
1766 Step::Destination => {
1767 let scope =
1768 DESTINATION_ORDER[self.destination_idx.min(DESTINATION_ORDER.len() - 1)];
1769 let available = self
1770 .destination_for(scope)
1771 .is_none_or(|d| d.unavailable_reason.is_none());
1772 if !available {
1773 // A disabled destination never falls back to the other one.
1774 return ViewAction::None;
1775 }
1776 self.choose_destination(scope);
1777 self.step = Step::Review;
1778 self.review_scroll = 0;
1779 self.review_focus = ReviewFocus::Save;
1780 self.refresh_destinations();
1781 ViewAction::None
1782 }
1783 Step::Review => self.activate_review_focus(),
1784 }
1785 }
1786
1787 /// Enter on the Review step acts on the focused control.
1788 fn activate_review_focus(&mut self) -> ViewAction {
1789 match self.review_focus {
1790 ReviewFocus::Save => self.save_action(),
1791 ReviewFocus::ChangeDestination => {
1792 self.step = Step::Destination;
1793 self.refresh_destinations();
1794 ViewAction::None
1795 }
1796 ReviewFocus::Back => self.back(),
1797 }
1798 }
1799
1800 /// The single save path for both the deterministic starter profile and a
1801 /// model-authored draft. Replacing an existing file requires a second
1802 /// press: the first arms the control and renames it; nothing is written
1803 /// until the second. An unavailable destination never saves.
1804 fn save_action(&mut self) -> ViewAction {
1805 if !self.scope_decided || !self.selected_destination_available() {
1806 return ViewAction::None;
1807 }
1808 let exists = self
1809 .destination_for(self.profile_scope)
1810 .is_some_and(|d| d.target_exists);
1811 if exists && !self.replace_armed {
1812 self.replace_armed = true;
1813 return ViewAction::None;
1814 }
1815 if let Some(assignment) = &self.assignment {
1816 return ViewAction::Emit(ViewEvent::FleetProfileRouteCommitRequested {
1817 editor_id: assignment.editor_id,
1818 });
1819 }
1820 match self.model_draft.clone() {
1821 Some(draft) => ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested {
1822 draft,
1823 scope: self.profile_scope,
1824 }),
1825 None => self.commit_starter_profile_action(),
1826 }
1827 }
1828
1829 /// Step back toward the first screen. Returns `None` at the first step (the
1830 /// host closes the modal via Esc instead).
1831 fn back(&mut self) -> ViewAction {
1832 if self.assignment.is_some() {
1833 return self.route_pick_request();
1834 }
1835 match self.step {
1836 Step::Role => ViewAction::None,
1837 Step::Composition => {
1838 self.step = Step::Model;
1839 ViewAction::None
1840 }
1841 Step::Model => {
1842 self.notice = None;
1843 self.step = Step::Role;
1844 ViewAction::None
1845 }
1846 Step::Destination => {
1847 self.step = Step::Model;
1848 ViewAction::None
1849 }
1850 Step::Review => {
1851 self.replace_armed = false;
1852 self.step = Step::Destination;
1853 self.refresh_destinations();
1854 ViewAction::None
1855 }
1856 }
1857 }
1858
1859 /// Persist the deterministic starter profile directly from the Review
1860 /// summary. Unlike a model-authored draft, every field is derived from the
1861 /// structured choices already visible on this screen, so a second TOML
1862 /// ratification state adds no trust boundary.
1863 fn commit_starter_profile_action(&self) -> ViewAction {
1864 ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested {
1865 draft: self.starter_profile_draft(),
1866 scope: self.profile_scope,
1867 })
1868 }
1869
1870 /// Build a deterministic starter profile for the current role/model
1871 /// selection. The same save event persists this as model-drafted profiles,
1872 /// so duplicate-id checks and atomic writes stay in one host path.
1873 ///
1874 /// `provider` is seeded from whatever the user actually picked in the
1875 /// Model step (#4093) — a concrete route names its own provider
1876 /// explicitly, so the saved profile is never ambiguously scoped to
1877 /// whatever provider happens to be active at launch time. `inherit`
1878 /// carries no provider, matching its `model: None`.
1879 fn starter_profile_draft(&self) -> Box<crate::fleet::profile::FleetProfileDraft> {
1880 let role = &ROLES[self.role_idx.min(ROLES.len() - 1)];
1881 let route = self.selected_route();
1882 Box::new(crate::fleet::profile::FleetProfileDraft {
1883 id: profile_file_stem(&role.label),
1884 display_name: Some(role.label.to_string()),
1885 description: Some(format!("{} - {}", role.summary, role.description)),
1886 role_hint: role.label.to_string(),
1887 model_class_hint: None,
1888 model: route.as_ref().map(|(_, model)| model.clone()),
1889 provider: route.map(|(provider, _)| provider),
1890 reasoning_effort: self.selected_reasoning_effort(),
1891 instructions: Some(format!(
1892 "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.",
1893 role.label
1894 )),
1895 })
1896 }
1897
1898 /// The action hints for the current step's footer (wrapped by the shared
1899 /// footer renderer so they can never run off the modal edge).
1900 fn footer_hints(&self) -> Vec<ActionHint> {
1901 let mut hints = Vec::new();
1902 match self.step {
1903 Step::Role => {
1904 hints.push(ActionHint::new("↑/↓", "choose"));
1905 hints.push(ActionHint::new("Enter", "next"));
1906 }
1907 Step::Composition => {
1908 hints.push(ActionHint::new("a/Enter", "accept"));
1909 hints.push(ActionHint::new("e", "edit"));
1910 hints.push(ActionHint::new("r", "reject"));
1911 hints.push(ActionHint::new("←", "back"));
1912 }
1913 Step::Model => {
1914 hints.push(ActionHint::new("↑/↓", "choose"));
1915 hints.push(ActionHint::new("/", "filter"));
1916 if self.has_composition_for_selected_role() {
1917 hints.push(ActionHint::new("c", "suggest"));
1918 }
1919 hints.push(ActionHint::new("Enter", "next"));
1920 hints.push(ActionHint::new("←", "back"));
1921 }
1922 Step::Destination => {
1923 hints.push(ActionHint::new("↑/↓", "choose"));
1924 hints.push(ActionHint::new("Enter/Space", "next"));
1925 hints.push(ActionHint::new("←", "back"));
1926 }
1927 Step::Review => {
1928 hints.push(ActionHint::new("Tab", "focus"));
1929 hints.push(ActionHint::new("Enter", "activate"));
1930 hints.push(ActionHint::new("↑/↓", "scroll"));
1931 hints.push(ActionHint::new("t", "thinking"));
1932 if self.assignment.is_some() {
1933 // Route changes do not regenerate role instructions.
1934 } else if self.model_draft.is_some() {
1935 hints.push(ActionHint::new("m", "redraft"));
1936 } else if self.snapshot.provider_ready {
1937 hints.push(ActionHint::new("m", "model draft"));
1938 }
1939 hints.push(ActionHint::new("←", "back"));
1940 }
1941 }
1942 // Esc is honest: it steps back everywhere except the first screen,
1943 // where it cancels the wizard.
1944 if self.step == Step::Role {
1945 hints.push(ActionHint::new("Esc", "cancel"));
1946 } else {
1947 hints.push(ActionHint::new("Esc", "back"));
1948 }
1949 hints
1950 }
1951 }
1952
1953 impl ModalView for FleetSetupView {
1954 fn kind(&self) -> ModalKind {
1955 ModalKind::FleetSetup
1956 }
1957
1958 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
1959 self
1960 }
1961
1962 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
1963 match mouse.kind {
1964 MouseEventKind::ScrollUp => self.move_up(),
1965 MouseEventKind::ScrollDown => self.move_down(),
1966 MouseEventKind::Down(MouseButton::Left) => {
1967 let row = self.row_hitboxes.borrow().iter().find_map(|(rect, row)| {
1968 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
1969 .then_some(*row)
1970 });
1971 if let Some(row) = row {
1972 match self.step {
1973 Step::Role => {
1974 self.role_idx = row.min(ROLES.len().saturating_sub(1));
1975 self.composition_decision = CompositionDecision::Pending;
1976 }
1977 Step::Composition => {}
1978 Step::Model => {
1979 self.model_idx = row.min(self.step_len().saturating_sub(1));
1980 if self.composition_decision != CompositionDecision::Pending {
1981 self.composition_decision = CompositionDecision::Edited;
1982 }
1983 }
1984 Step::Destination => {
1985 self.destination_idx = row.min(DESTINATION_ORDER.len() - 1);
1986 return ViewAction::None;
1987 }
1988 Step::Review => {}
1989 }
1990 self.discard_model_draft();
1991 }
1992 }
1993 _ => {}
1994 }
1995 ViewAction::None
1996 }
1997
1998 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
1999 // Model-step filter input captures keystrokes while active (#4639).
2000 if self.step == Step::Model && self.model_filter_active {
2001 // Typing-safe movement set: pages and edges work while filtering
2002 // without letter aliases eating the query (#6290).
2003 if let Some(motion) = crate::tui::list_nav::motion_while_typing(&key)
2004 && self.apply_motion(motion)
2005 {
2006 return ViewAction::None;
2007 }
2008 match key.code {
2009 KeyCode::Enter => {
2010 self.model_filter_active = false;
2011 }
2012 KeyCode::Esc => {
2013 self.model_filter_active = false;
2014 self.model_query.clear();
2015 self.model_idx = 0;
2016 }
2017 KeyCode::Backspace => {
2018 self.model_query.pop();
2019 self.model_idx = 0;
2020 if self.composition_decision != CompositionDecision::Pending {
2021 self.composition_decision = CompositionDecision::Edited;
2022 }
2023 }
2024 KeyCode::Char(ch)
2025 if !key.modifiers.intersects(
2026 KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
2027 ) =>
2028 {
2029 self.model_query.push(ch);
2030 self.model_idx = 0;
2031 if self.composition_decision != CompositionDecision::Pending {
2032 self.composition_decision = CompositionDecision::Edited;
2033 }
2034 }
2035 _ => {}
2036 }
2037 return ViewAction::None;
2038 }
2039 // Any navigation key clears a one-shot notice; the notice is re-set
2040 // below when the same blocked action is attempted again.
2041 if !matches!(key.code, KeyCode::Null) {
2042 self.notice = None;
2043 }
2044 // Movement keys come from the shared vocabulary (#6290), j/k aliases
2045 // included; the region axis is declined so Tab/Left/Right keep their
2046 // explicit wizard arms, and letter verbs below are unaffected.
2047 if let Some(motion) = crate::tui::list_nav::motion(&key)
2048 && self.apply_motion(motion)
2049 {
2050 return ViewAction::None;
2051 }
2052 if self.assignment.is_some() && matches!(key.code, KeyCode::Char('t')) {
2053 return self.route_pick_request();
2054 }
2055 if self.assignment.is_some() && matches!(key.code, KeyCode::Char('m')) {
2056 return ViewAction::None;
2057 }
2058 match key.code {
2059 KeyCode::Esc if self.step != Step::Role => self.back(),
2060 KeyCode::Esc => ViewAction::Close,
2061 KeyCode::Char('q') if self.step == Step::Role => ViewAction::Close,
2062 // Tab moves focus; it never changes where the file is written.
2063 KeyCode::Tab if self.step == Step::Review => {
2064 self.review_focus = self.review_focus.next();
2065 self.replace_armed = false;
2066 ViewAction::None
2067 }
2068 KeyCode::BackTab if self.step == Step::Review => {
2069 self.review_focus = self.review_focus.prev();
2070 self.replace_armed = false;
2071 ViewAction::None
2072 }
2073 KeyCode::Right | KeyCode::Char('l') if self.step == Step::Review => {
2074 self.review_focus = self.review_focus.next();
2075 self.replace_armed = false;
2076 ViewAction::None
2077 }
2078 KeyCode::Char(' ') if self.step == Step::Destination => self.advance(),
2079 KeyCode::Char(' ') if self.step == Step::Review => self.activate_review_focus(),
2080 KeyCode::Char('a') if self.step == Step::Composition => self.accept_composition(),
2081 KeyCode::Char('e') if self.step == Step::Composition => self.edit_composition(),
2082 KeyCode::Char('r') if self.step == Step::Composition => self.reject_composition(),
2083 KeyCode::Char('c')
2084 if self.step == Step::Model && self.has_composition_for_selected_role() =>
2085 {
2086 self.composition_decision = CompositionDecision::Pending;
2087 self.discard_model_draft();
2088 self.step = Step::Composition;
2089 ViewAction::None
2090 }
2091 KeyCode::Char('/') if self.step == Step::Model => {
2092 self.model_filter_active = true;
2093 ViewAction::None
2094 }
2095 // Secondary accelerator: jump to the Destination step. The primary
2096 // way to change the destination is the focused Review control.
2097 KeyCode::Char('s') if self.step == Step::Review => {
2098 self.replace_armed = false;
2099 self.step = Step::Destination;
2100 self.refresh_destinations();
2101 ViewAction::None
2102 }
2103 KeyCode::Char('t') if self.step == Step::Review => {
2104 self.thinking_idx = (self.thinking_idx + 1) % THINKING_CHOICES.len();
2105 self.discard_model_draft();
2106 ViewAction::None
2107 }
2108 KeyCode::Char('m') if self.step == Step::Review && self.snapshot.provider_ready => {
2109 let route = self.selected_route();
2110 ViewAction::Emit(ViewEvent::FleetProfileModelDraftRequested {
2111 role: self.selected_role(),
2112 model: route
2113 .as_ref()
2114 .map(|(_, model)| model.clone())
2115 .unwrap_or_else(|| "inherit".to_string()),
2116 // Carry the picked provider so the redrafted profile keeps
2117 // the cross-provider route (#4093). `install_model_draft`
2118 // re-injects it authoritatively from the wizard's current
2119 // selection, but the event stays self-describing.
2120 provider: route.map(|(provider, _)| provider),
2121 reasoning_effort: self.selected_reasoning_effort(),
2122 locale: self.snapshot.locale,
2123 })
2124 }
2125 KeyCode::Char('g') if self.step == Step::Review => {
2126 self.review_focus = ReviewFocus::Save;
2127 self.save_action()
2128 }
2129 KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => self.advance(),
2130 KeyCode::Left | KeyCode::Char('h') => self.back(),
2131 _ => ViewAction::None,
2132 }
2133 }
2134
2135 fn render(&self, area: Rect, buf: &mut Buffer) {
2136 self.row_hitboxes.borrow_mut().clear();
2137 // Choice steps have a bounded list/detail body and should not expand
2138 // into a tall empty card on roomy terminals. Review is proof-dense and
2139 // scrollable, so it keeps the extra row budgeted for the footer gutter.
2140 let preferred_height = match self.step {
2141 Step::Role => 22,
2142 Step::Composition => 26,
2143 Step::Model => 23,
2144 Step::Destination => 22,
2145 Step::Review => 32,
2146 };
2147 let popup_area = centered_modal_area(area, 96, preferred_height, 60, 16);
2148 render_modal_surface(area, popup_area, buf);
2149
2150 let step_no = match self.step {
2151 Step::Role => 1,
2152 Step::Composition => 2,
2153 Step::Model => 2,
2154 Step::Destination => 3,
2155 Step::Review => 4,
2156 };
2157 let block = Block::default()
2158 .title(Line::from(Span::styled(
2159 " Fleet setup — your agent team ",
2160 Style::default()
2161 .fg(palette::WHALE_ACTION)
2162 .add_modifier(Modifier::BOLD),
2163 )))
2164 .title_bottom(
2165 Line::from(Span::styled(
2166 format!(" Step {step_no}/4 "),
2167 Style::default().fg(palette::TEXT_MUTED),
2168 ))
2169 .alignment(ratatui::layout::Alignment::Right),
2170 )
2171 .borders(Borders::ALL)
2172 .border_style(Style::default().fg(palette::BORDER_COLOR))
2173 .style(Style::default().bg(palette::WHALE_BG))
2174 .padding(Padding::uniform(1));
2175
2176 let inner = block.inner(popup_area);
2177 block.render(popup_area, buf);
2178
2179 let hints = self.footer_hints();
2180 let content = render_modal_footer_with_gutter(inner, buf, &hints);
2181
2182 // Header (title + subtitle + "Saves to" chip) above the step body.
2183 // In the Compact tier the subtitle is dropped so the chip survives.
2184 let header_rows = if content.height < 4 {
2185 1
2186 } else if content.height < 12 {
2187 2
2188 } else {
2189 3
2190 };
2191 let chunks = Layout::default()
2192 .direction(Direction::Vertical)
2193 .constraints([Constraint::Length(header_rows), Constraint::Min(1)])
2194 .split(content);
2195 self.render_header(chunks[0], buf);
2196
2197 match self.step {
2198 Step::Role => {
2199 let mut context = vec![
2200 "Fleet runs sub-agents that delegate work. Pick the role this team member should play; the saved profile carries it as its role_hint.".to_string(),
2201 ];
2202 if let Some(note) = self.roster_override_note() {
2203 context.push(note);
2204 }
2205 render_choice_step(chunks[1], buf, &ROLES, self.role_idx, &context);
2206 register_choice_hitboxes(chunks[1], ROLES.len(), self.role_idx, &self.row_hitboxes);
2207 }
2208 Step::Destination => {
2209 self.render_destination(chunks[1], buf);
2210 register_choice_hitboxes(
2211 chunks[1],
2212 DESTINATION_ORDER.len(),
2213 self.destination_idx,
2214 &self.row_hitboxes,
2215 );
2216 }
2217 Step::Composition => self.render_composition(chunks[1], buf),
2218 Step::Model => {
2219 let filtered = self.filtered_model_indices();
2220 // Compact tier: the row summary and any notice matter more
2221 // than the long route description, which would push them
2222 // below the fold.
2223 let compact = chunks[1].height < 12;
2224 let filtered_choices: Vec<Choice> = filtered
2225 .iter()
2226 .map(|idx| {
2227 let mut choice = self.model_choices[*idx].clone();
2228 if compact {
2229 choice.description = Cow::Borrowed("");
2230 }
2231 choice
2232 })
2233 .collect();
2234 let selected = self.model_idx.min(filtered.len().saturating_sub(1));
2235 let filter_line = if self.model_filter_active {
2236 format!("Filter: {}▏ (Enter keep · Esc clear)", self.model_query)
2237 } else if !self.model_query.trim().is_empty() {
2238 format!(
2239 "Filter: {} ({} of {} rows · / edit)",
2240 self.model_query,
2241 filtered.len(),
2242 self.model_choices.len()
2243 )
2244 } else {
2245 format!(
2246 "Type / to filter {} models by provider or name",
2247 self.model_choices.len()
2248 )
2249 };
2250 let mut context = Vec::new();
2251 if let Some(notice) = &self.notice {
2252 context.push(notice.clone());
2253 }
2254 context.push(filter_line);
2255 context.push(format!(
2256 "Current model: {} / {} · reasoning {}",
2257 self.snapshot.provider, self.snapshot.model, self.snapshot.reasoning
2258 ));
2259 context.push(match self.selected_model() {
2260 Some(model) => format!("This member will run on {model}."),
2261 None => "This member uses your current model.".to_string(),
2262 });
2263 if filtered_choices.is_empty() {
2264 context.push(
2265 "No routes match this filter. Keep typing, or press Esc to clear it."
2266 .to_string(),
2267 );
2268 }
2269 render_choice_step(chunks[1], buf, &filtered_choices, selected, &context);
2270 register_choice_hitboxes(
2271 chunks[1],
2272 filtered_choices.len(),
2273 selected,
2274 &self.row_hitboxes,
2275 );
2276 }
2277 Step::Review => self.render_review(chunks[1], buf),
2278 }
2279 }
2280 }
2281
2282 impl FleetSetupView {
2283 fn render_header(&self, area: Rect, buf: &mut Buffer) {
2284 let (title, subtitle): (Cow<'static, str>, Cow<'static, str>) = match self.step {
2285 Step::Role => (
2286 Cow::Borrowed("Choose a team role"),
2287 Cow::Borrowed("Each Fleet member plays one role in the delegation."),
2288 ),
2289 Step::Composition => (
2290 Cow::Borrowed("Unratified composition suggestion"),
2291 Cow::Borrowed(
2292 "Review the configured-pool assignments; nothing is saved or running.",
2293 ),
2294 ),
2295 Step::Model => (
2296 Cow::Borrowed("Choose a model"),
2297 Cow::Borrowed("Pick this worker's model, or inherit your current route."),
2298 ),
2299 Step::Destination => (
2300 Cow::Owned(tr(self.snapshot.locale, MessageId::FleetDestStepTitle).into_owned()),
2301 Cow::Owned(tr(self.snapshot.locale, MessageId::FleetDestStepSubtitle).into_owned()),
2302 ),
2303 Step::Review if self.assignment.is_some() => (
2304 Cow::Owned(format!("Review {} model", self.selected_role())),
2305 Cow::Borrowed(
2306 "Save this role's model and thinking; the session model stays unchanged.",
2307 ),
2308 ),
2309 Step::Review if self.model_draft.is_some() => (
2310 Cow::Borrowed("Save profile"),
2311 Cow::Borrowed(
2312 "Exact TOML shown below; nothing is written until you activate the save control.",
2313 ),
2314 ),
2315 Step::Review => (
2316 Cow::Borrowed("Review & save"),
2317 Cow::Borrowed("Nothing is written until you activate the save control."),
2318 ),
2319 };
2320 let chip_style = Style::default().fg(palette::TEXT_MUTED);
2321 let mut lines = vec![Line::from(Span::styled(
2322 title.into_owned(),
2323 Style::default().fg(palette::WHALE_ACTION).bold(),
2324 ))];
2325 if area.height >= 3 {
2326 lines.push(Line::from(Span::styled(
2327 subtitle.into_owned(),
2328 Style::default().fg(palette::TEXT_MUTED),
2329 )));
2330 }
2331 lines.push(Line::from(Span::styled(
2332 truncate_view_text(&self.saves_to_line(), usize::from(area.width)),
2333 chip_style,
2334 )));
2335 // No wrapping: each header row is one line, so the chip row is
2336 // always the last row and never pushed out by a long subtitle.
2337 Paragraph::new(lines).render(area, buf);
2338 }
2339
2340 /// The Destination step: a focused two-option list (This project /
2341 /// Personal) with the exact resolved file, whether it will be replaced,
2342 /// and the precedence consequence, for the highlighted option.
2343 fn render_destination(&self, area: Rect, buf: &mut Buffer) {
2344 let locale = self.snapshot.locale;
2345 // Compact tier: keep the choice, the file, and the consequence; drop
2346 // the long explanation rather than clip the file line off-screen.
2347 let compact = area.height < 12;
2348 let choices: Vec<Choice> = DESTINATION_ORDER
2349 .iter()
2350 .map(|scope| {
2351 let unavailable = self
2352 .destination_for(*scope)
2353 .and_then(|d| d.unavailable_reason.clone());
2354 let (label, summary, description) = match scope {
2355 FleetProfileScope::Project => (
2356 MessageId::FleetDestProjectLabel,
2357 MessageId::FleetDestProjectSummary,
2358 MessageId::FleetDestProjectDescription,
2359 ),
2360 FleetProfileScope::Personal => (
2361 MessageId::FleetDestPersonalLabel,
2362 MessageId::FleetDestPersonalSummary,
2363 MessageId::FleetDestPersonalDescription,
2364 ),
2365 };
2366 let _ = unavailable;
2367 Choice {
2368 label: Cow::Owned(tr(locale, label).into_owned()),
2369 summary: Cow::Owned(tr(locale, summary).into_owned()),
2370 description: if compact {
2371 Cow::Borrowed("")
2372 } else {
2373 tr(locale, description)
2374 },
2375 }
2376 })
2377 .collect();
2378 let selected = self.destination_idx.min(DESTINATION_ORDER.len() - 1);
2379 let scope = DESTINATION_ORDER[selected];
2380 let mut context = Vec::new();
2381 match self.destination_for(scope) {
2382 Some(status) => {
2383 if let Some(reason) = &status.unavailable_reason {
2384 context.push(
2385 tr(locale, MessageId::FleetDestUnavailable).replace("{reason}", reason),
2386 );
2387 }
2388 context.push(
2389 tr(locale, MessageId::FleetDestPathLine)
2390 .replace("{path}", &status.target.display().to_string()),
2391 );
2392 if status.target_exists {
2393 context.push(
2394 tr(locale, MessageId::FleetDestWillReplace)
2395 .replace("{path}", &status.target.display().to_string()),
2396 );
2397 }
2398 }
2399 None => context.push(
2400 tr(locale, MessageId::FleetDestPathLine)
2401 .replace("{path}", &self.projected_target(scope)),
2402 ),
2403 }
2404 if let Some(note) = self.override_note_for_scope(scope) {
2405 context.push(note);
2406 }
2407 render_choice_step(area, buf, &choices, selected, &context);
2408 }
2409
2410 fn render_composition(&self, area: Rect, buf: &mut Buffer) {
2411 let Some(advisory) = self.composition.as_ref() else {
2412 Paragraph::new("No configured model pool is available. Press e to choose manually.")
2413 .wrap(Wrap { trim: true })
2414 .render(area, buf);
2415 return;
2416 };
2417 let selected_role = self.selected_role();
2418 let mut lines = vec![
2419 Line::from(Span::styled(
2420 format!(
2421 "{} · {}",
2422 advisory.proposal.ratification.as_str().to_ascii_uppercase(),
2423 advisory.proposal.advisory
2424 ),
2425 Style::default().fg(palette::STATUS_WARNING).bold(),
2426 )),
2427 Line::from(""),
2428 ];
2429 for suggestion in &advisory.proposal.suggestions {
2430 let selected = suggestion.role.eq_ignore_ascii_case(&selected_role);
2431 lines.push(Line::from(vec![
2432 Span::styled(
2433 format!(
2434 "{} {}",
2435 crate::tui::glyphs::selection_marker(selected),
2436 suggestion.role
2437 ),
2438 if selected {
2439 menu_style::selected_row_style()
2440 } else {
2441 Style::default().fg(palette::TEXT_PRIMARY)
2442 },
2443 ),
2444 Span::styled(
2445 format!(
2446 " → {}/{}",
2447 provider_display_label(&suggestion.provider),
2448 suggestion.model
2449 ),
2450 Style::default().fg(palette::TEXT_MUTED),
2451 ),
2452 ]));
2453 }
2454 lines.extend([
2455 Line::from(""),
2456 Line::from(Span::styled(
2457 format!(
2458 "Accept applies only the {selected_role} suggestion to this unsaved profile. Edit highlights it in the configured model picker; reject keeps your current selection."
2459 ),
2460 Style::default().fg(palette::TEXT_MUTED),
2461 )),
2462 ]);
2463 debug_assert_eq!(
2464 advisory.proposal.ratification,
2465 RatificationState::Unratified
2466 );
2467 Paragraph::new(lines)
2468 .wrap(Wrap { trim: true })
2469 .render(area, buf);
2470 }
2471
2472 fn render_review(&self, area: Rect, buf: &mut Buffer) {
2473 if area.width == 0 || area.height == 0 {
2474 return;
2475 }
2476 // Row 1: the focused action controls. Row 2+: the scrollable summary.
2477 // Keeping the controls out of the scroll region means the save action
2478 // and its label are visible at every scroll offset and every size.
2479 let rows = Layout::default()
2480 .direction(Direction::Vertical)
2481 .constraints([Constraint::Length(2), Constraint::Min(1)])
2482 .split(area);
2483 self.render_review_actions(rows[0], buf);
2484 let body = rows[1];
2485
2486 if let Some(assignment) = &self.assignment {
2487 let model = assignment
2488 .model
2489 .as_deref()
2490 .unwrap_or("Follow current session");
2491 let provider = assignment.provider.as_deref().unwrap_or("session provider");
2492 let thinking = assignment
2493 .reasoning
2494 .as_deref()
2495 .unwrap_or("Follow current session");
2496 let text = format!(
2497 "Role: {}\nModel: {model} · {provider}\nThinking: {thinking}\n{}\n\nOnly the model and thinking assignment changes. Existing name, description, instructions, tools and permissions are preserved. The current session model stays unchanged.",
2498 assignment.id,
2499 self.saves_to_line()
2500 );
2501 render_scrollable_text(body, buf, &text, self.review_scroll);
2502 return;
2503 }
2504
2505 // A ratify-ready draft is on screen: show the exact TOML preview
2506 // inline, scrolled by the same `review_scroll` state, so the save
2507 // control in THIS view ratifies it directly — no separate pager in the
2508 // way to swallow the keypress (#4093).
2509 if let Some(preview) = self.model_draft_preview.as_deref() {
2510 render_scrollable_text(body, buf, preview, self.review_scroll);
2511 return;
2512 }
2513
2514 let role = &ROLES[self.role_idx.min(ROLES.len() - 1)];
2515 let locale = self.snapshot.locale;
2516 let mut lines: Vec<Line> = Vec::new();
2517 let section = |lines: &mut Vec<Line>, label: &str, body: String| {
2518 lines.push(Line::from(Span::styled(
2519 label.to_string(),
2520 Style::default().fg(palette::WHALE_ACTION).bold(),
2521 )));
2522 lines.push(Line::from(Span::styled(
2523 body,
2524 Style::default().fg(palette::TEXT_PRIMARY),
2525 )));
2526 lines.push(Line::from(""));
2527 };
2528
2529 // "Saves to" comes first: it is the decision this screen exists to
2530 // confirm. Exact file, replace/create, precedence consequence.
2531 let mut saves_to = vec![format!(
2532 "{} · {}",
2533 self.scope_label(self.profile_scope),
2534 self.destination_for(self.profile_scope)
2535 .map(|d| d.target.display().to_string())
2536 .unwrap_or_else(|| self.projected_target(self.profile_scope))
2537 )];
2538 if let Some(status) = self.destination_for(self.profile_scope) {
2539 if let Some(reason) = &status.unavailable_reason {
2540 saves_to
2541 .push(tr(locale, MessageId::FleetDestUnavailable).replace("{reason}", reason));
2542 } else if status.target_exists {
2543 saves_to.push(
2544 tr(locale, MessageId::FleetDestWillReplace)
2545 .replace("{path}", &status.target.display().to_string()),
2546 );
2547 }
2548 }
2549 if let Some(note) = self.roster_override_note() {
2550 saves_to.push(note);
2551 }
2552 section(
2553 &mut lines,
2554 &tr(locale, MessageId::FleetReviewSavesTo),
2555 saves_to.join(" · "),
2556 );
2557 section(
2558 &mut lines,
2559 "Role",
2560 format!("{} — {}", role.label, role.summary),
2561 );
2562 section(
2563 &mut lines,
2564 "Model",
2565 // The picked route's OWN provider, not the parent/current
2566 // session's — a cross-provider pin must never be misreported as
2567 // running on the active provider (#4093).
2568 match self.selected_route() {
2569 Some((provider, model)) => {
2570 let readiness = self
2571 .snapshot
2572 .available_models
2573 .iter()
2574 .find(|(candidate_provider, candidate_model, _)| {
2575 candidate_provider == &provider && candidate_model == &model
2576 })
2577 .map(|(_, _, readiness)| readiness.label().into_owned())
2578 .unwrap_or_else(|| {
2579 if self.snapshot.provider_ready {
2580 "ready".to_string()
2581 } else {
2582 "needs action".to_string()
2583 }
2584 });
2585 format!(
2586 "{model} · provider {} · {readiness}",
2587 provider_display_label(&provider)
2588 )
2589 }
2590 None => format!(
2591 "inherit · route {} / {} · {}",
2592 self.snapshot.provider,
2593 self.snapshot.model,
2594 if self.snapshot.provider_ready {
2595 "ready"
2596 } else {
2597 "needs action"
2598 }
2599 ),
2600 },
2601 );
2602 match self.composition_decision {
2603 CompositionDecision::Accepted => section(
2604 &mut lines,
2605 "Composition",
2606 "Accepted the configured-pool suggestion for this role. It remains unsaved until you save this profile; no Fleet was launched or changed.".to_string(),
2607 ),
2608 CompositionDecision::Edited => section(
2609 &mut lines,
2610 "Composition",
2611 "Edited the suggestion in the configured model picker. This review is the only save boundary.".to_string(),
2612 ),
2613 CompositionDecision::Rejected => section(
2614 &mut lines,
2615 "Composition",
2616 "Rejected the suggestion and kept the manually selected route. Nothing was saved or launched by the advisory.".to_string(),
2617 ),
2618 CompositionDecision::Pending => {}
2619 }
2620 section(&mut lines, "Thinking", self.selected_thinking_label());
2621 section(
2622 &mut lines,
2623 "Auth & readiness",
2624 if self.snapshot.provider_ready {
2625 "Active route can be attempted with the current credentials.".to_string()
2626 } else {
2627 "Active route is not ready — fix auth/readiness before relying on this profile at runtime.".to_string()
2628 },
2629 );
2630 section(
2631 &mut lines,
2632 "Permissions",
2633 "Access: members can only narrow what the session allows. They cannot widen approval, trust, or secrets, and required approvals stay on.".to_string(),
2634 );
2635 section(
2636 &mut lines,
2637 "Tools",
2638 "Read tools by default; write tools for builders within scope; shell stays policy-gated; artifacts and receipts stay inspectable.".to_string(),
2639 );
2640 section(
2641 &mut lines,
2642 "Workspace & org",
2643 format!(
2644 "{} · sub-agents {} ({} concurrent, {} launch slots, {} admitted) · recursion agent {} / Fleet {} (ceiling {})",
2645 self.snapshot.workspace.display(),
2646 if self.snapshot.subagents_enabled {
2647 "enabled"
2648 } else {
2649 "disabled"
2650 },
2651 self.snapshot.max_subagents,
2652 self.snapshot.launch_concurrency,
2653 self.snapshot.max_admitted,
2654 self.snapshot.subagent_spawn_depth,
2655 self.snapshot.fleet_spawn_depth,
2656 codewhale_config::MAX_SPAWN_DEPTH_CEILING,
2657 ),
2658 );
2659 section(&mut lines, "Review policy", self.review_policy_summary());
2660
2661 // `scroll` offsets by *visual* (post-wrap) rows, so the bound must count
2662 // wrapped rows — not logical lines — or the bottom sections become
2663 // unreachable. Estimate each line's wrapped height from its display
2664 // width; an over-estimate is harmless (scroll clamps at the real end).
2665 let wrap_width = usize::from(body.width).max(1);
2666 let visual_rows: usize = lines
2667 .iter()
2668 .map(|line| line.width().div_ceil(wrap_width).max(1))
2669 .sum();
2670 let max_scroll = visual_rows.saturating_sub(usize::from(body.height).max(1));
2671 let scroll = self.review_scroll.min(max_scroll);
2672 Paragraph::new(lines)
2673 .wrap(Wrap { trim: true })
2674 .scroll((scroll as u16, 0))
2675 .render(body, buf);
2676 }
2677
2678 /// The Review step's focused control row: [Save…] [Change destination]
2679 /// [Back]. The focused control is drawn with the canonical selection style
2680 /// and the `▸` marker; a disabled save control (unavailable destination)
2681 /// is dimmed and named with the reason on the "Saves to" line.
2682 fn render_review_actions(&self, area: Rect, buf: &mut Buffer) {
2683 let locale = self.snapshot.locale;
2684 let save_enabled = self.scope_decided && self.selected_destination_available();
2685 let controls: [(ReviewFocus, String, bool); 3] = [
2686 (ReviewFocus::Save, self.save_action_label(), save_enabled),
2687 (
2688 ReviewFocus::ChangeDestination,
2689 tr(locale, MessageId::FleetActionChangeDestination).into_owned(),
2690 true,
2691 ),
2692 (
2693 ReviewFocus::Back,
2694 tr(locale, MessageId::FleetActionBack).into_owned(),
2695 true,
2696 ),
2697 ];
2698 let mut spans: Vec<Span> = Vec::new();
2699 for (focus, label, enabled) in controls {
2700 let focused = focus == self.review_focus;
2701 let text = format!(
2702 "{} {} ",
2703 crate::tui::glyphs::selection_marker(focused),
2704 label
2705 );
2706 let style = match (focused, enabled) {
2707 (true, true) => menu_style::selected_row_style(),
2708 (true, false) => menu_style::disabled_selected_row_style(),
2709 (false, true) => Style::default().fg(palette::TEXT_PRIMARY),
2710 (false, false) => Style::default().fg(palette::TEXT_MUTED).dim(),
2711 };
2712 spans.push(Span::styled(text, style));
2713 spans.push(Span::raw(" "));
2714 }
2715 Paragraph::new(vec![Line::from(spans), Line::from("")])
2716 .wrap(Wrap { trim: true })
2717 .render(area, buf);
2718 }
2719
2720 fn review_policy_summary(&self) -> String {
2721 format!(
2722 "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.",
2723 self.snapshot.api_timeout_secs, self.snapshot.heartbeat_timeout_secs
2724 )
2725 }
2726 }
2727
2728 /// Render wrapped, line-scrolled plain text (the ratify-ready draft TOML
2729 /// preview) into `area`, clamping `scroll` to the real wrapped-row bound the
2730 /// same way [`FleetSetupView::render_review`]'s summary does — an
2731 /// over-estimate of wrapped height is harmless (scroll clamps at the end).
2732 fn render_scrollable_text(area: Rect, buf: &mut Buffer, text: &str, scroll: usize) {
2733 let lines: Vec<Line> = text
2734 .lines()
2735 .map(|line| Line::from(line.to_string()))
2736 .collect();
2737 let wrap_width = usize::from(area.width).max(1);
2738 let visual_rows: usize = lines
2739 .iter()
2740 .map(|line| line.width().div_ceil(wrap_width).max(1))
2741 .sum();
2742 let max_scroll = visual_rows.saturating_sub(usize::from(area.height).max(1));
2743 let scroll = scroll.min(max_scroll);
2744 Paragraph::new(lines)
2745 .wrap(Wrap { trim: true })
2746 .scroll((scroll as u16, 0))
2747 .render(area, buf);
2748 }
2749
2750 /// Render a wizard choice step: a list of selectable identifiers on the left and
2751 /// a wrapped detail pane (summary + description + context) on the right. Stacks
2752 /// vertically when the body is too narrow for two columns so nothing truncates.
2753 fn render_choice_step(
2754 area: Rect,
2755 buf: &mut Buffer,
2756 choices: &[Choice],
2757 selected: usize,
2758 context: &[String],
2759 ) {
2760 if area.width == 0 || area.height == 0 {
2761 return;
2762 }
2763
2764 let (list_area, detail_area) = if area.width >= CHOICE_TWO_COLUMN_MIN_WIDTH {
2765 let cols = Layout::default()
2766 .direction(Direction::Horizontal)
2767 .constraints([
2768 Constraint::Length(CHOICE_LIST_WIDTH),
2769 Constraint::Min(CHOICE_DETAIL_MIN_WIDTH),
2770 ])
2771 .split(area);
2772 (cols[0], cols[1])
2773 } else {
2774 let list_height = (choices.len() as u16).min(area.height);
2775 let rows = Layout::default()
2776 .direction(Direction::Vertical)
2777 .constraints([Constraint::Length(list_height), Constraint::Min(0)])
2778 .split(area);
2779 (rows[0], rows[1])
2780 };
2781
2782 // No choices (a type-to-filter query that matches nothing): there is no
2783 // row to point at and no detail to show, so paint the context — the
2784 // caller adds the "no matches" hint — and stop before indexing (#5953).
2785 if choices.is_empty() {
2786 let lines: Vec<Line> = context
2787 .iter()
2788 .map(|entry| {
2789 Line::from(Span::styled(
2790 entry.clone(),
2791 Style::default().fg(palette::TEXT_MUTED),
2792 ))
2793 })
2794 .collect();
2795 Paragraph::new(lines)
2796 .wrap(Wrap { trim: true })
2797 .render(detail_area, buf);
2798 return;
2799 }
2800
2801 // List: labels are identifiers, so a `▸`-marked single line each is safe.
2802 let list_width = usize::from(list_area.width);
2803 let visible = choices.len().min(usize::from(list_area.height));
2804 let row_start = choice_window_start(choices.len(), selected, visible);
2805 let mut list_lines: Vec<Line> = Vec::with_capacity(visible);
2806 for (idx, choice) in choices.iter().enumerate().skip(row_start).take(visible) {
2807 let is_selected = idx == selected;
2808 let pointer = format!("{} ", crate::tui::glyphs::selection_marker(is_selected));
2809 let style = if is_selected {
2810 menu_style::selected_row_style()
2811 } else {
2812 Style::default().fg(palette::TEXT_PRIMARY)
2813 };
2814 list_lines.push(Line::from(Span::styled(
2815 truncate_view_text(&format!("{pointer}{}", choice.label), list_width),
2816 style,
2817 )));
2818 }
2819 Paragraph::new(list_lines).render(list_area, buf);
2820
2821 // Detail: summary + wrapped description + wrapped context, all word-wrapped.
2822 let choice = &choices[selected.min(choices.len().saturating_sub(1))];
2823 let mut detail_lines: Vec<Line> = vec![Line::from(Span::styled(
2824 choice.summary.clone(),
2825 Style::default().fg(palette::WHALE_ACTION).bold(),
2826 ))];
2827 // An empty description (compact tiers drop the long explanation so the
2828 // decisive facts stay on screen) leaves no orphan blank rows behind.
2829 if !choice.description.is_empty() {
2830 detail_lines.push(Line::from(""));
2831 detail_lines.push(Line::from(Span::styled(
2832 choice.description.clone(),
2833 Style::default().fg(palette::TEXT_PRIMARY),
2834 )));
2835 }
2836 if !context.is_empty() {
2837 detail_lines.push(Line::from(""));
2838 for entry in context {
2839 detail_lines.push(Line::from(Span::styled(
2840 entry.clone(),
2841 Style::default().fg(palette::TEXT_MUTED),
2842 )));
2843 }
2844 }
2845 Paragraph::new(detail_lines)
2846 .wrap(Wrap { trim: true })
2847 .render(detail_area, buf);
2848 }
2849
2850 /// Register exactly the list column/stack rows painted by
2851 /// [`render_choice_step`]. The detail pane intentionally owns no hitboxes.
2852 fn register_choice_hitboxes(
2853 area: Rect,
2854 choice_count: usize,
2855 selected: usize,
2856 hitboxes: &RefCell<Vec<(Rect, usize)>>,
2857 ) {
2858 if area.width == 0 || area.height == 0 || choice_count == 0 {
2859 return;
2860 }
2861 let list_area = if area.width >= CHOICE_TWO_COLUMN_MIN_WIDTH {
2862 Layout::default()
2863 .direction(Direction::Horizontal)
2864 .constraints([
2865 Constraint::Length(CHOICE_LIST_WIDTH),
2866 Constraint::Min(CHOICE_DETAIL_MIN_WIDTH),
2867 ])
2868 .split(area)[0]
2869 } else {
2870 let list_height = (choice_count as u16).min(area.height);
2871 Layout::default()
2872 .direction(Direction::Vertical)
2873 .constraints([Constraint::Length(list_height), Constraint::Min(0)])
2874 .split(area)[0]
2875 };
2876 let visible = choice_count.min(usize::from(list_area.height));
2877 let row_start = choice_window_start(choice_count, selected, visible);
2878 let mut rows = hitboxes.borrow_mut();
2879 rows.extend((0..visible).map(|visible_idx| {
2880 let choice_idx = row_start + visible_idx;
2881 (
2882 Rect::new(
2883 list_area.x,
2884 list_area.y.saturating_add(visible_idx as u16),
2885 list_area.width,
2886 1,
2887 ),
2888 choice_idx,
2889 )
2890 }));
2891 }
2892
2893 fn choice_window_start(total: usize, selected: usize, visible: usize) -> usize {
2894 if total <= visible || visible == 0 {
2895 return 0;
2896 }
2897 selected
2898 .saturating_add(1)
2899 .saturating_sub(visible)
2900 .min(total.saturating_sub(visible))
2901 }
2902
2903 /// Resolve one save destination off the paint path: the exact target file,
2904 /// whether it already exists, and — when it cannot be written — the localized
2905 /// reason. A disabled destination is never silently swapped for the other.
2906 fn destination_status(
2907 scope: FleetProfileScope,
2908 workspace: &Path,
2909 personal_dir: &Result<PathBuf, String>,
2910 file_name: &str,
2911 project_profiles_enabled: bool,
2912 locale: codewhale_localization::Locale,
2913 ) -> DestinationStatus {
2914 let dir: Result<PathBuf, String> = match scope {
2915 FleetProfileScope::Project => {
2916 Ok(workspace.join(crate::fleet::profile::WORKSPACE_AGENT_PROFILE_DIR))
2917 }
2918 FleetProfileScope::Personal => personal_dir.clone(),
2919 };
2920 let (target, mut unavailable_reason) = match dir {
2921 Ok(dir) => (dir.join(file_name), None),
2922 Err(err) => (
2923 PathBuf::from(scope.display_dir()).join(file_name),
2924 Some(tr(locale, MessageId::FleetDestReasonHomeUnavailable).replace("{error}", &err)),
2925 ),
2926 };
2927 if unavailable_reason.is_none() {
2928 match scope {
2929 FleetProfileScope::Project => {
2930 if !project_profiles_enabled {
2931 unavailable_reason =
2932 Some(tr(locale, MessageId::FleetDestReasonNoProjectConfig).into_owned());
2933 } else if !workspace.is_dir() {
2934 unavailable_reason = Some(
2935 tr(locale, MessageId::FleetDestReasonWorkspaceMissing)
2936 .replace("{path}", &workspace.display().to_string()),
2937 );
2938 }
2939 }
2940 FleetProfileScope::Personal => {}
2941 }
2942 }
2943 if unavailable_reason.is_none()
2944 && let Some(parent) = target.parent()
2945 && parent.exists()
2946 && !parent.is_dir()
2947 {
2948 unavailable_reason = Some(
2949 tr(locale, MessageId::FleetDestReasonWorkspaceMissing)
2950 .replace("{path}", &parent.display().to_string()),
2951 );
2952 }
2953 let target_exists = unavailable_reason.is_none() && target.is_file();
2954 DestinationStatus {
2955 scope,
2956 unavailable_reason,
2957 target,
2958 target_exists,
2959 }
2960 }
2961
2962 /// Sanitize a planner role label into a safe TOML file stem.
2963 fn profile_file_stem(role: &str) -> String {
2964 let stem: String = role
2965 .chars()
2966 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
2967 .collect();
2968 let stem = stem.trim_matches('-').to_ascii_lowercase();
2969 if stem.is_empty() {
2970 "custom".to_string()
2971 } else {
2972 stem
2973 }
2974 }
2975
2976 #[cfg(test)]
2977 mod tests {
2978 use super::*;
2979 use crate::tui::views::ViewStack;
2980 use crossterm::event::KeyModifiers;
2981 use unicode_width::UnicodeWidthStr;
2982
2983 const BLOCKER_SIZES: [(u16, u16); 5] = [(80, 24), (89, 50), (100, 30), (120, 32), (160, 40)];
2984
2985 #[test]
2986 fn role_assignment_preserves_profile_fields_and_rejects_stale_source() {
2987 let _env = crate::test_support::lock_test_env();
2988 let workspace = tempfile::tempdir().unwrap();
2989 let directory = workspace
2990 .path()
2991 .join(crate::fleet::profile::WORKSPACE_AGENT_PROFILE_DIR);
2992 std::fs::create_dir_all(&directory).unwrap();
2993 let path = directory.join("different-file-name.toml");
2994 let original = r#"id = "custom-reviewer"
2995 display_name = "My reviewer"
2996 description = "Keep this description"
2997 role_hint = "reviewer"
2998 loadout = "inherit"
2999 model = "previous-model"
3000 provider = "deepseek"
3001 [instructions]
3002 text = "Keep these precise instructions"
3003 [tools]
3004 posture = "read-only"
3005 [permissions]
3006 allow_shell = false
3007 trust = false
3008 approval_required = true
3009 "#;
3010 std::fs::write(&path, original).unwrap();
3011 let config = Config::default();
3012 let app = App::new(
3013 crate::test_support::test_tui_options(workspace.path()),
3014 &config,
3015 );
3016 let mut view =
3017 FleetSetupView::new_for_route_assignment(&app, &config, "custom-reviewer").unwrap();
3018 let editor_id = view.assignment.as_ref().unwrap().editor_id;
3019 assert_eq!(view.selected_role(), "custom-reviewer");
3020 assert_eq!(
3021 view.destination_for(FleetProfileScope::Project)
3022 .unwrap()
3023 .target,
3024 path
3025 );
3026 assert!(view.accept_route(
3027 editor_id,
3028 "deepseek".into(),
3029 "auto".into(),
3030 Some(crate::reasoning_preference::ReasoningEffort::High)
3031 ));
3032 assert_eq!(view.step, Step::Review);
3033 assert_eq!(
3034 std::fs::read_to_string(&path).unwrap(),
3035 original,
3036 "picker/review must not write"
3037 );
3038 view.commit_route_assignment(editor_id, &app, &config)
3039 .unwrap();
3040 let before: toml::Table = toml::from_str(original).unwrap();
3041 let after: toml::Table = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
3042 for name in [
3043 "id",
3044 "display_name",
3045 "description",
3046 "role_hint",
3047 "instructions",
3048 "tools",
3049 "permissions",
3050 ] {
3051 assert_eq!(
3052 after.get(name),
3053 before.get(name),
3054 "{name} changed during route assignment"
3055 );
3056 }
3057 assert!(!after.contains_key("model") && !after.contains_key("provider"));
3058 assert_eq!(after["reasoning_effort"].as_str(), Some("high"));
3059 let mut stale =
3060 FleetSetupView::new_for_route_assignment(&app, &config, "custom-reviewer").unwrap();
3061 let stale_id = stale.assignment.as_ref().unwrap().editor_id;
3062 stale.accept_route(stale_id, "deepseek".into(), "auto".into(), None);
3063 let changed = format!(
3064 "{}\n# Another editor changed this file\n",
3065 std::fs::read_to_string(&path).unwrap()
3066 );
3067 std::fs::write(&path, &changed).unwrap();
3068 assert!(
3069 stale
3070 .commit_route_assignment(stale_id, &app, &config)
3071 .is_err()
3072 );
3073 assert_eq!(std::fs::read_to_string(&path).unwrap(), changed);
3074 }
3075
3076 #[test]
3077 fn builtin_role_assignment_requires_destination_and_rejects_new_override() {
3078 let _env = crate::test_support::lock_test_env();
3079 let workspace = tempfile::tempdir().unwrap();
3080 let config = Config::default();
3081 let app = App::new(
3082 crate::test_support::test_tui_options(workspace.path()),
3083 &config,
3084 );
3085 let mut view = FleetSetupView::new_for_route_assignment(&app, &config, "manager").unwrap();
3086 let id = view.assignment.as_ref().unwrap().editor_id;
3087 view.accept_route(id, "deepseek".into(), "auto".into(), None);
3088 assert_eq!(view.step, Step::Destination);
3089 assert!(!view.scope_decided);
3090 assert!(view.commit_route_assignment(id, &app, &config).is_err());
3091 let directory = workspace
3092 .path()
3093 .join(crate::fleet::profile::WORKSPACE_AGENT_PROFILE_DIR);
3094 std::fs::create_dir_all(&directory).unwrap();
3095 std::fs::write(directory.join("manager.toml"), "id = \"manager\"\n").unwrap();
3096 view.choose_destination(FleetProfileScope::Personal);
3097 view.refresh_destinations();
3098 assert!(view.commit_route_assignment(id, &app, &config).is_err());
3099 }
3100
3101 fn snapshot() -> FleetSetupSnapshot {
3102 FleetSetupSnapshot {
3103 workspace: PathBuf::from("/tmp/codewhale-test-workspace"),
3104 locale: codewhale_localization::Locale::En,
3105 provider_ready: true,
3106 provider: "DeepSeek".to_string(),
3107 model: "deepseek-v4-pro".to_string(),
3108 reasoning: "Auto".to_string(),
3109 subagents_enabled: true,
3110 max_subagents: 8,
3111 launch_concurrency: 3,
3112 max_admitted: 20,
3113 subagent_spawn_depth: 3,
3114 fleet_spawn_depth: 3,
3115 api_timeout_secs: 120,
3116 heartbeat_timeout_secs: 300,
3117 roster_members: crate::fleet::roster::FleetRoster::built_ins_only()
3118 .members()
3119 .iter()
3120 .map(|member| (member.id.to_lowercase(), member.origin.to_string()))
3121 .collect(),
3122 roster_details: Vec::new(),
3123 project_profiles_enabled: true,
3124 personal_profile_dir: Ok(test_personal_dir()),
3125 available_models: vec![
3126 (
3127 "deepseek".to_string(),
3128 "deepseek-v4-pro".to_string(),
3129 crate::provider_readiness::ResolvedProviderReadiness::SavedUnchecked,
3130 ),
3131 (
3132 "deepseek".to_string(),
3133 "deepseek-v4-flash".to_string(),
3134 crate::provider_readiness::ResolvedProviderReadiness::SavedUnchecked,
3135 ),
3136 ],
3137 }
3138 }
3139
3140 #[test]
3141 fn setup_target_routes_selected_v2_and_fails_closed_for_stale_selection() {
3142 let _lock = crate::test_support::lock_test_env();
3143 let workspace = tempfile::TempDir::new().expect("workspace");
3144 let personal_home = workspace.path().join("personal-home");
3145 std::fs::create_dir_all(&personal_home).expect("personal home");
3146 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &personal_home);
3147 assert_eq!(
3148 resolve_fleet_setup_edit_target(workspace.path()).expect("no selection"),
3149 FleetSetupEditTarget::LegacyProfiles
3150 );
3151
3152 let fleet =
3153 crate::fleet::store::FleetFile::new("Launch".to_string(), None).expect("valid Fleet");
3154 let fleet_path = crate::fleet::store::save_fleet(
3155 &fleet,
3156 crate::fleet::store::FleetScope::Workspace,
3157 workspace.path(),
3158 )
3159 .expect("save Fleet");
3160 crate::fleet::store::set_selected(
3161 "Launch",
3162 crate::fleet::store::FleetScope::Workspace,
3163 workspace.path(),
3164 )
3165 .expect("select Fleet");
3166
3167 assert_eq!(
3168 resolve_fleet_setup_edit_target(workspace.path()).expect("selected Fleet"),
3169 FleetSetupEditTarget::SelectedFleet {
3170 name: "Launch".to_string(),
3171 scope: crate::fleet::store::FleetScope::Workspace,
3172 }
3173 );
3174
3175 std::fs::remove_file(fleet_path).expect("make selection stale");
3176 let error = resolve_fleet_setup_edit_target(workspace.path())
3177 .expect_err("a stale selection must not open legacy setup");
3178 assert!(error.contains("Legacy profiles were not opened"), "{error}");
3179 }
3180
3181 fn key(code: KeyCode) -> KeyEvent {
3182 KeyEvent::new(code, KeyModifiers::NONE)
3183 }
3184
3185 /// A hermetic personal profile dir shared by the fixture snapshot, so no
3186 /// test reads the developer's real `$CODEWHALE_HOME/agents`.
3187 fn test_personal_dir() -> PathBuf {
3188 static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
3189 DIR.get_or_init(|| tempfile::tempdir().expect("personal dir"))
3190 .path()
3191 .join("agents")
3192 }
3193
3194 fn sample_draft() -> Box<crate::fleet::profile::FleetProfileDraft> {
3195 let crate::fleet::profile::UntrustedProfileParse::Drafted(draft) =
3196 crate::fleet::profile::FleetProfileDraft::from_untrusted_json(
3197 r#"{"id":"reviewer","role_hint":"reviewer","description":"Reviews diffs.","instructions":"Read. Report. Stop."}"#,
3198 )
3199 else {
3200 panic!("sample draft should parse");
3201 };
3202 draft
3203 }
3204
3205 /// #5038: the Model step's detail pane carries capability badges for
3206 /// known catalog models and honestly omits them for unknown models, so
3207 /// stale/absent data never blocks selection.
3208 #[test]
3209 fn model_step_detail_shows_capability_badges_for_known_models_only() {
3210 let mut snap = snapshot();
3211 snap.available_models.push((
3212 "deepseek".to_string(),
3213 "totally-made-up-model-xyz".to_string(),
3214 crate::provider_readiness::ResolvedProviderReadiness::SavedUnchecked,
3215 ));
3216 let view = FleetSetupView::from_snapshot(snap);
3217
3218 let known = view
3219 .model_choices
3220 .iter()
3221 .find(|choice| choice.label == "deepseek-v4-pro")
3222 .expect("known catalog model row");
3223 assert!(
3224 known.description.contains("Capabilities:"),
3225 "{}",
3226 known.description
3227 );
3228 assert!(
3229 known.description.contains("1M ctx"),
3230 "{}",
3231 known.description
3232 );
3233 assert!(
3234 known.description.contains("catalog"),
3235 "catalog-backed rows must name catalog provenance: {}",
3236 known.description
3237 );
3238
3239 let unknown = view.model_choices.last().expect("appended unknown row");
3240 assert_eq!(unknown.label, "totally-made-up-model-xyz");
3241 assert!(
3242 !unknown.description.contains("Capabilities:"),
3243 "{}",
3244 unknown.description
3245 );
3246 // The unknown row stays selectable; absence of data is not a block.
3247 assert_eq!(
3248 view.model_row_states.last(),
3249 Some(&FleetModelRowState::Ready)
3250 );
3251 }
3252
3253 #[test]
3254 fn provider_display_label_preserves_case_colliding_custom_ids() {
3255 assert_eq!(provider_display_label("deepseek"), "DeepSeek");
3256 assert_eq!(provider_display_label("CUSTOM"), "CUSTOM");
3257 assert_eq!(provider_display_label("OPENAI"), "OPENAI");
3258 }
3259
3260 fn to_review(view: &mut FleetSetupView) {
3261 view.handle_key(key(KeyCode::Enter)); // Role -> Model
3262 view.handle_key(key(KeyCode::Enter)); // Model -> Destination
3263 assert_eq!(view.step, Step::Destination);
3264 view.handle_key(key(KeyCode::Enter)); // Destination (Personal) -> Review
3265 assert_eq!(view.step, Step::Review);
3266 }
3267
3268 /// Rendered text with all whitespace and box borders removed, so a phrase
3269 /// or path that wrapped across rows (temp-dir paths vary in length per
3270 /// platform and CI runner) still compares as one token.
3271 fn squashed(text: &str) -> String {
3272 text.chars()
3273 .filter(|c| !c.is_whitespace() && !matches!(c, '│' | '┃' | '┆' | '┊' | '|'))
3274 .collect()
3275 }
3276
3277 fn contains_wrapped(text: &str, needle: &str) -> bool {
3278 squashed(text).contains(&squashed(needle))
3279 }
3280
3281 fn rendered_text(view: &FleetSetupView, w: u16, h: u16) -> String {
3282 let area = Rect::new(0, 0, w, h);
3283 let mut buf = Buffer::empty(area);
3284 view.render(area, &mut buf);
3285 (0..h)
3286 .map(|y| {
3287 (0..w)
3288 .map(|x| buf[(x, y)].symbol().to_string())
3289 .collect::<String>()
3290 })
3291 .collect::<Vec<_>>()
3292 .join("\n")
3293 }
3294
3295 fn workspace_snapshot(workspace: &Path) -> FleetSetupSnapshot {
3296 FleetSetupSnapshot {
3297 workspace: workspace.to_path_buf(),
3298 ..snapshot()
3299 }
3300 }
3301
3302 // ------------------------------------------------------------------
3303 // Save-scope redesign: destination step, review actions, no silent writes.
3304 // ------------------------------------------------------------------
3305
3306 #[test]
3307 fn destination_step_sits_between_model_and_review_and_names_the_exact_file() {
3308 let temp = tempfile::tempdir().expect("temp workspace");
3309 let mut view = FleetSetupView::from_snapshot(workspace_snapshot(temp.path()));
3310 view.handle_key(key(KeyCode::Down)); // explore
3311 view.handle_key(key(KeyCode::Enter)); // -> Model
3312 view.handle_key(key(KeyCode::Enter)); // inherit -> Destination
3313 assert_eq!(view.step, Step::Destination);
3314 assert!(
3315 !view.scope_decided,
3316 "nothing is decided until the user picks"
3317 );
3318 let text = rendered_text(&view, 120, 32);
3319 assert!(text.contains("Where should this profile live?"), "{text}");
3320 assert!(text.contains("This project"), "{text}");
3321 assert!(text.contains("Personal"), "{text}");
3322 assert!(text.contains("Step 3/4"), "{text}");
3323 // The highlighted (Personal) row shows its resolved file.
3324 let personal = test_personal_dir().join("explore.toml");
3325 assert!(
3326 text.contains("File:") && text.contains("agents"),
3327 "resolved file must be visible: {text}"
3328 );
3329 assert_eq!(view.destinations.as_ref().unwrap()[1].target, personal);
3330 // Up -> This project shows the workspace file.
3331 view.handle_key(key(KeyCode::Up));
3332 let text = rendered_text(&view, 120, 32);
3333 let project = temp.path().join(PROFILE_DIR).join("explore.toml");
3334 assert!(!text.contains("Will replace"), "{text}");
3335 assert_eq!(view.destinations.as_ref().unwrap()[0].target, project);
3336 }
3337
3338 #[test]
3339 fn header_chip_says_where_it_saves_on_every_step_once_decided() {
3340 let temp = tempfile::tempdir().expect("temp workspace");
3341 let mut view = FleetSetupView::from_snapshot(workspace_snapshot(temp.path()));
3342 let text = rendered_text(&view, 120, 32);
3343 assert!(text.contains("Saves to: choose in step 3"), "{text}");
3344 view.handle_key(key(KeyCode::Enter));
3345 view.handle_key(key(KeyCode::Enter));
3346 view.handle_key(key(KeyCode::Up)); // This project
3347 view.handle_key(key(KeyCode::Enter)); // -> Review
3348 assert_eq!(view.step, Step::Review);
3349 assert!(view.scope_decided);
3350 assert_eq!(view.profile_scope, FleetProfileScope::Project);
3351 let text = rendered_text(&view, 120, 32);
3352 assert!(text.contains("Saves to: This project"), "{text}");
3353 assert!(text.contains("Save to this project"), "{text}");
3354 // Going back keeps the decided destination visible while revising.
3355 view.handle_key(key(KeyCode::Esc)); // -> Destination
3356 view.handle_key(key(KeyCode::Esc)); // -> Model
3357 assert_eq!(view.step, Step::Model);
3358 let text = rendered_text(&view, 120, 32);
3359 assert!(text.contains("Saves to: This project"), "{text}");
3360 }
3361
3362 #[test]
3363 fn switching_destination_preserves_role_model_and_thinking() {
3364 let temp = tempfile::tempdir().expect("temp workspace");
3365 let mut view = FleetSetupView::from_snapshot(workspace_snapshot(temp.path()));
3366 view.handle_key(key(KeyCode::Down));
3367 view.handle_key(key(KeyCode::Down)); // builder
3368 view.handle_key(key(KeyCode::Enter));
3369 view.handle_key(key(KeyCode::Down)); // deepseek-v4-pro
3370 view.handle_key(key(KeyCode::Enter)); // -> Destination
3371 view.handle_key(key(KeyCode::Up)); // This project
3372 view.handle_key(key(KeyCode::Enter)); // -> Review
3373 view.handle_key(key(KeyCode::Char('t'))); // thinking: off
3374 let role = view.selected_role();
3375 let route = view.selected_route();
3376 let thinking = view.thinking_idx;
3377 assert_eq!(view.profile_scope, FleetProfileScope::Project);
3378 // Change destination via the focused control, pick Personal.
3379 view.handle_key(key(KeyCode::Tab));
3380 assert_eq!(view.review_focus, ReviewFocus::ChangeDestination);
3381 assert_eq!(
3382 view.profile_scope,
3383 FleetProfileScope::Project,
3384 "Tab never changes scope"
3385 );
3386 view.handle_key(key(KeyCode::Enter));
3387 assert_eq!(view.step, Step::Destination);
3388 view.handle_key(key(KeyCode::Down));
3389 view.handle_key(key(KeyCode::Char(' ')));
3390 assert_eq!(view.step, Step::Review);
3391 assert_eq!(view.profile_scope, FleetProfileScope::Personal);
3392 assert_eq!(view.selected_role(), role);
3393 assert_eq!(view.selected_route(), route);
3394 assert_eq!(view.thinking_idx, thinking);
3395 let text = rendered_text(&view, 120, 32);
3396 assert!(text.contains("Save as Personal profile"), "{text}");
3397 }
3398
3399 #[test]
3400 fn existing_target_is_announced_and_needs_a_second_enter_to_replace() {
3401 let temp = tempfile::tempdir().expect("temp workspace");
3402 let dir = temp.path().join(PROFILE_DIR);
3403 std::fs::create_dir_all(&dir).expect("dir");
3404 std::fs::write(dir.join("manager.toml"), "id = \"manager\"\n").expect("existing");
3405 let mut view = FleetSetupView::from_snapshot(workspace_snapshot(temp.path()));
3406 view.handle_key(key(KeyCode::Enter)); // manager
3407 view.handle_key(key(KeyCode::Enter)); // inherit -> Destination
3408 view.handle_key(key(KeyCode::Up)); // This project
3409 let text = rendered_text(&view, 120, 32);
3410 assert!(
3411 contains_wrapped(&text, "Will replace the existing file"),
3412 "{text}"
3413 );
3414 view.handle_key(key(KeyCode::Enter)); // -> Review
3415 let text = rendered_text(&view, 120, 32);
3416 assert!(contains_wrapped(&text, "Replace in this project"), "{text}");
3417 assert!(
3418 contains_wrapped(&text, "Will replace the existing file"),
3419 "{text}"
3420 );
3421 // First Enter arms; nothing is emitted.
3422 let action = view.handle_key(key(KeyCode::Enter));
3423 assert!(
3424 matches!(action, ViewAction::None),
3425 "first Enter must not save"
3426 );
3427 assert!(view.replace_armed);
3428 let text = rendered_text(&view, 120, 32);
3429 assert!(
3430 text.contains("Press Enter again to replace manager.toml"),
3431 "{text}"
3432 );
3433 // Moving focus disarms.
3434 view.handle_key(key(KeyCode::Tab));
3435 assert!(!view.replace_armed);
3436 view.handle_key(key(KeyCode::BackTab));
3437 view.handle_key(key(KeyCode::Enter)); // arm again
3438 let action = view.handle_key(key(KeyCode::Enter)); // confirm
3439 let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft, scope }) =
3440 action
3441 else {
3442 panic!("second Enter saves");
3443 };
3444 assert_eq!(scope, FleetProfileScope::Project);
3445 assert_eq!(draft.id, "manager");
3446 }
3447
3448 #[test]
3449 fn project_destination_is_disabled_with_a_reason_and_never_falls_back() {
3450 let temp = tempfile::tempdir().expect("temp workspace");
3451 let mut view = FleetSetupView::from_snapshot(FleetSetupSnapshot {
3452 project_profiles_enabled: false,
3453 ..workspace_snapshot(temp.path())
3454 });
3455 view.handle_key(key(KeyCode::Enter));
3456 view.handle_key(key(KeyCode::Enter)); // -> Destination
3457 view.handle_key(key(KeyCode::Up)); // This project (disabled)
3458 let text = rendered_text(&view, 120, 32);
3459 assert!(
3460 text.contains("Not available: project profiles are disabled"),
3461 "{text}"
3462 );
3463 let action = view.handle_key(key(KeyCode::Enter));
3464 assert!(matches!(action, ViewAction::None));
3465 assert_eq!(
3466 view.step,
3467 Step::Destination,
3468 "disabled destination does not advance"
3469 );
3470 assert!(!view.scope_decided);
3471 assert_eq!(
3472 view.profile_scope,
3473 FleetProfileScope::Personal,
3474 "no silent fallback either way: the scope is untouched"
3475 );
3476 // Personal still works.
3477 view.handle_key(key(KeyCode::Down));
3478 view.handle_key(key(KeyCode::Enter));
3479 assert_eq!(view.step, Step::Review);
3480 assert_eq!(view.profile_scope, FleetProfileScope::Personal);
3481 }
3482
3483 #[test]
3484 fn precedence_consequences_are_stated_for_both_destinations() {
3485 let temp = tempfile::tempdir().expect("temp workspace");
3486 let project_source = temp.path().join(PROFILE_DIR).join("explore.toml");
3487 let mut snap = workspace_snapshot(temp.path());
3488 snap.roster_members.retain(|(id, _)| id != "explore");
3489 snap.roster_members
3490 .push(("explore".to_string(), "project".to_string()));
3491 snap.roster_details.push(RosterMemberDetail {
3492 id: "explore".to_string(),
3493 scope: FleetProfileScope::Project,
3494 source: project_source,
3495 provider: Some("deepseek".to_string()),
3496 model: Some("deepseek-v4-flash".to_string()),
3497 reasoning_effort: None,
3498 });
3499 let mut view = FleetSetupView::from_snapshot(snap);
3500 view.handle_key(key(KeyCode::Down)); // explore
3501 view.handle_key(key(KeyCode::Enter));
3502 view.handle_key(key(KeyCode::Enter)); // -> Destination (Personal highlighted)
3503 let text = rendered_text(&view, 120, 32);
3504 assert!(text.contains("already has a"), "{text}");
3505 view.handle_key(key(KeyCode::Up)); // This project
3506 let text = rendered_text(&view, 120, 32);
3507 assert!(!text.contains("already has a"), "{text}");
3508 assert!(text.contains("Replaces the project"), "{text}");
3509 }
3510
3511 #[test]
3512 fn reopening_a_saved_member_preloads_its_scope_and_route() {
3513 let temp = tempfile::tempdir().expect("temp workspace");
3514 let mut snap = workspace_snapshot(temp.path());
3515 snap.roster_members.retain(|(id, _)| id != "scout");
3516 snap.roster_members
3517 .push(("scout".to_string(), "project".to_string()));
3518 snap.roster_details.push(RosterMemberDetail {
3519 id: "scout".to_string(),
3520 scope: FleetProfileScope::Project,
3521 source: temp.path().join(PROFILE_DIR).join("scout.toml"),
3522 provider: Some("deepseek".to_string()),
3523 model: Some("deepseek-v4-flash".to_string()),
3524 reasoning_effort: Some("high".to_string()),
3525 });
3526 let view = FleetSetupView::from_snapshot_for_role(snap, "scout");
3527 assert_eq!(view.step, Step::Model);
3528 assert!(view.scope_decided);
3529 assert_eq!(view.profile_scope, FleetProfileScope::Project);
3530 assert_eq!(
3531 view.selected_route(),
3532 Some(("deepseek".to_string(), "deepseek-v4-flash".to_string()))
3533 );
3534 assert_eq!(view.selected_reasoning_effort().as_deref(), Some("high"));
3535 let text = rendered_text(&view, 120, 32);
3536 assert!(text.contains("Saves to: This project"), "{text}");
3537 }
3538
3539 #[test]
3540 fn blocked_model_row_explains_why_enter_did_nothing() {
3541 let mut snap = snapshot();
3542 snap.available_models = vec![(
3543 "xai".to_string(),
3544 "grok-4.5".to_string(),
3545 crate::provider_readiness::ResolvedProviderReadiness::MissingKey,
3546 )];
3547 let mut view = FleetSetupView::from_snapshot(snap);
3548 view.handle_key(key(KeyCode::Enter)); // -> Model
3549 view.model_idx = 1;
3550 view.handle_key(key(KeyCode::Enter));
3551 assert_eq!(view.step, Step::Model);
3552 assert!(
3553 view.notice
3554 .as_deref()
3555 .is_some_and(|n| n.contains("Not selectable"))
3556 );
3557 let text = rendered_text(&view, 120, 32);
3558 assert!(text.contains("Not selectable"), "{text}");
3559 view.handle_key(key(KeyCode::Down));
3560 assert!(view.notice.is_none(), "navigation clears the notice");
3561 }
3562
3563 #[test]
3564 fn q_only_cancels_from_the_first_step_and_esc_is_back_elsewhere() {
3565 let mut view = FleetSetupView::from_snapshot(snapshot());
3566 view.handle_key(key(KeyCode::Enter)); // -> Model
3567 assert!(matches!(
3568 view.handle_key(key(KeyCode::Char('q'))),
3569 ViewAction::None
3570 ));
3571 assert_eq!(view.step, Step::Model);
3572 assert!(matches!(
3573 view.handle_key(key(KeyCode::Esc)),
3574 ViewAction::None
3575 ));
3576 assert_eq!(view.step, Step::Role);
3577 assert!(matches!(
3578 view.handle_key(key(KeyCode::Char('q'))),
3579 ViewAction::Close
3580 ));
3581 let hints = view.footer_hints();
3582 assert!(hints.iter().any(|h| h.key == "Esc" && h.label == "cancel"));
3583 }
3584
3585 #[test]
3586 fn destination_and_review_stay_readable_at_60x16_80x24_and_120x32() {
3587 let temp = tempfile::tempdir().expect("temp workspace");
3588 for (w, h) in [(60u16, 16u16), (80, 24), (120, 32)] {
3589 let mut view = FleetSetupView::from_snapshot(workspace_snapshot(temp.path()));
3590 view.handle_key(key(KeyCode::Enter));
3591 view.handle_key(key(KeyCode::Enter)); // -> Destination
3592 let text = rendered_text(&view, w, h);
3593 assert!(text.contains("This project"), "{w}x{h}: {text}");
3594 assert!(text.contains("Personal"), "{w}x{h}: {text}");
3595 assert!(text.contains("File:"), "{w}x{h}: {text}");
3596 assert!(text.contains("Saves to:"), "{w}x{h}: {text}");
3597 view.handle_key(key(KeyCode::Up));
3598 view.handle_key(key(KeyCode::Enter)); // -> Review
3599 let text = rendered_text(&view, w, h);
3600 assert!(text.contains("Save to this project"), "{w}x{h}: {text}");
3601 assert!(text.contains("Saves to: This project"), "{w}x{h}: {text}");
3602 for line in text.lines() {
3603 assert!(
3604 unicode_width::UnicodeWidthStr::width(line) <= usize::from(w),
3605 "{w}x{h}: overflow: {line}"
3606 );
3607 }
3608 }
3609 }
3610
3611 fn open_composition(view: &mut FleetSetupView) {
3612 view.handle_key(key(KeyCode::Enter)); // Role -> Model
3613 assert_eq!(view.step, Step::Model);
3614 view.handle_key(key(KeyCode::Char('c')));
3615 assert_eq!(view.step, Step::Composition);
3616 }
3617
3618 #[test]
3619 fn composition_is_deterministic_unratified_and_pool_bounded() {
3620 let first = FleetSetupView::from_snapshot(snapshot());
3621 let second = FleetSetupView::from_snapshot(snapshot());
3622 let first = first.composition.expect("configured pool advisory");
3623 let second = second.composition.expect("configured pool advisory");
3624
3625 assert_eq!(first.proposal, second.proposal);
3626 assert_eq!(first.proposal.ratification, RatificationState::Unratified);
3627 assert!(!first.proposal.is_actionable());
3628 assert_eq!(
3629 first.request.pool_keys(),
3630 vec![
3631 "deepseek/deepseek-v4-flash".to_string(),
3632 "deepseek/deepseek-v4-pro".to_string(),
3633 ]
3634 );
3635 for suggestion in &first.proposal.suggestions {
3636 assert!(
3637 first
3638 .request
3639 .pool_contains(&suggestion.provider, &suggestion.model),
3640 "{suggestion:?} escaped the configured pool"
3641 );
3642 }
3643
3644 let rendered = render_through_stack(
3645 || {
3646 let mut view = FleetSetupView::from_snapshot(snapshot());
3647 open_composition(&mut view);
3648 view
3649 },
3650 120,
3651 40,
3652 )
3653 .join("\n");
3654 assert!(rendered.contains("UNRATIFIED"), "{rendered}");
3655 assert!(rendered.contains("Suggestion only"), "{rendered}");
3656 assert!(rendered.contains("a/Enter accept"), "{rendered}");
3657 assert!(rendered.contains("e edit"), "{rendered}");
3658 assert!(rendered.contains("r reject"), "{rendered}");
3659 }
3660
3661 #[test]
3662 fn composition_accept_edit_and_reject_keep_the_existing_save_boundary() {
3663 let mut accepted = FleetSetupView::from_snapshot(snapshot());
3664 open_composition(&mut accepted);
3665 let expected = accepted
3666 .validated_composition_route()
3667 .expect("selected role suggestion");
3668 assert!(matches!(
3669 accepted.handle_key(key(KeyCode::Char('a'))),
3670 ViewAction::None
3671 ));
3672 assert_eq!(accepted.step, Step::Destination);
3673 accepted.handle_key(key(KeyCode::Enter)); // Destination -> Review
3674 assert_eq!(accepted.step, Step::Review);
3675 assert_eq!(accepted.composition_decision, CompositionDecision::Accepted);
3676 assert_eq!(accepted.selected_route().as_ref(), Some(&expected));
3677 let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft, .. }) =
3678 accepted.handle_key(key(KeyCode::Enter))
3679 else {
3680 panic!("only the existing review save path may persist an accepted suggestion");
3681 };
3682 assert_eq!(
3683 (draft.provider.as_deref(), draft.model.as_deref()),
3684 (Some(expected.0.as_str()), Some(expected.1.as_str()))
3685 );
3686
3687 let mut edited = FleetSetupView::from_snapshot(snapshot());
3688 open_composition(&mut edited);
3689 let suggested = edited
3690 .validated_composition_route()
3691 .expect("selected role suggestion");
3692 assert!(matches!(
3693 edited.handle_key(key(KeyCode::Char('e'))),
3694 ViewAction::None
3695 ));
3696 assert_eq!(edited.step, Step::Model);
3697 assert_eq!(edited.composition_decision, CompositionDecision::Edited);
3698 assert_eq!(edited.selected_route().as_ref(), Some(&suggested));
3699
3700 let mut rejected = FleetSetupView::from_snapshot(snapshot());
3701 open_composition(&mut rejected);
3702 assert!(rejected.selected_route().is_none());
3703 assert!(matches!(
3704 rejected.handle_key(key(KeyCode::Char('r'))),
3705 ViewAction::None
3706 ));
3707 assert_eq!(rejected.step, Step::Model);
3708 assert_eq!(rejected.composition_decision, CompositionDecision::Rejected);
3709 assert!(rejected.selected_route().is_none());
3710 }
3711
3712 #[test]
3713 fn composition_acceptance_revalidates_and_rejects_an_out_of_pool_route() {
3714 let mut view = FleetSetupView::from_snapshot(snapshot());
3715 open_composition(&mut view);
3716 let advisory = view.composition.as_mut().expect("advisory");
3717 let manager = advisory
3718 .proposal
3719 .suggestions
3720 .iter_mut()
3721 .find(|suggestion| suggestion.role == "manager")
3722 .expect("manager suggestion");
3723 manager.provider = "unconfigured".to_string();
3724 manager.model = "outside-pool".to_string();
3725 assert!(matches!(
3726 advisory.validated_route_for_role("manager"),
3727 Err(CompositionError::ModelOutsidePool { .. })
3728 ));
3729
3730 assert!(matches!(
3731 view.handle_key(key(KeyCode::Char('a'))),
3732 ViewAction::None
3733 ));
3734 assert_eq!(view.step, Step::Composition);
3735 assert_eq!(view.composition_decision, CompositionDecision::Pending);
3736 assert!(view.selected_route().is_none());
3737 }
3738
3739 #[test]
3740 fn composition_does_not_suggest_a_blocked_configured_route() {
3741 let mut snap = snapshot();
3742 snap.available_models.push((
3743 "anthropic".to_string(),
3744 "blocked-model".to_string(),
3745 crate::provider_readiness::ResolvedProviderReadiness::SavedLastCheckFailed {
3746 category: crate::error_taxonomy::ErrorCategory::Authentication,
3747 message: "auth failed".to_string(),
3748 },
3749 ));
3750 let view = FleetSetupView::from_snapshot(snap);
3751 let advisory = view.composition.expect("ready pool still composes");
3752 assert!(!advisory.request.pool_contains("anthropic", "blocked-model"));
3753 assert!(
3754 advisory
3755 .proposal
3756 .suggestions
3757 .iter()
3758 .all(|suggestion| suggestion.model != "blocked-model")
3759 );
3760 }
3761
3762 #[test]
3763 fn review_step_m_requests_model_draft_with_current_answers() {
3764 let mut view = FleetSetupView::from_snapshot(snapshot());
3765 to_review(&mut view);
3766
3767 let action = view.handle_key(key(KeyCode::Char('m')));
3768 let ViewAction::Emit(ViewEvent::FleetProfileModelDraftRequested {
3769 role,
3770 model,
3771 provider,
3772 reasoning_effort,
3773 locale,
3774 }) = action
3775 else {
3776 panic!("expected model draft request");
3777 };
3778 assert!(!role.is_empty());
3779 assert!(!model.is_empty());
3780 // Default selection is `inherit` (model_idx 0), which carries no
3781 // concrete provider route.
3782 assert_eq!(provider, None);
3783 assert_eq!(reasoning_effort, None);
3784 assert_eq!(locale, codewhale_localization::Locale::En);
3785 }
3786
3787 #[test]
3788 fn m_redraft_preserves_a_cross_provider_pick_regression_4093() {
3789 // #4093 BLOCKER 2 regression: a cross-provider route pick followed by an
3790 // `m` model-assisted redraft must STILL persist the picked provider. A
3791 // model draft comes from `from_untrusted_json`, which hard-sets
3792 // `provider: None` (and can echo any model). Without re-injection the
3793 // ratified profile would carry `model` with no `provider` — the exact
3794 // ambiguous, provider-scoped profile #4093 removes.
3795 //
3796 // The active/session provider is DeepSeek; the picked route is a
3797 // GLM model on Zai — a genuinely different provider than the parent.
3798 let mut snap = snapshot();
3799 snap.provider = "DeepSeek".to_string();
3800 snap.model = "deepseek-v4-pro".to_string();
3801 snap.available_models = vec![(
3802 "zai".to_string(),
3803 "glm-5.2".to_string(),
3804 crate::provider_readiness::ResolvedProviderReadiness::SavedUnchecked,
3805 )];
3806 let mut view = FleetSetupView::from_snapshot(snap);
3807
3808 // Role step: keep the first role. Model step: inherit(0), then the one
3809 // cross-provider row (1) -> pick it. Then advance to Review.
3810 view.handle_key(key(KeyCode::Enter)); // Role -> Model
3811 view.handle_key(key(KeyCode::Down)); // -> the zai/glm-5.2 row
3812 assert_eq!(
3813 view.selected_route(),
3814 Some(("zai".to_string(), "glm-5.2".to_string()))
3815 );
3816 view.handle_key(key(KeyCode::Enter)); // Model -> Destination
3817 view.handle_key(key(KeyCode::Enter)); // Destination -> Review
3818 assert_eq!(view.step, Step::Review);
3819 while view.selected_reasoning_effort().as_deref() != Some("max") {
3820 view.handle_key(key(KeyCode::Char('t')));
3821 }
3822
3823 // `m` requests a draft and carries the picked cross-provider route.
3824 let action = view.handle_key(key(KeyCode::Char('m')));
3825 let ViewAction::Emit(ViewEvent::FleetProfileModelDraftRequested {
3826 model,
3827 provider,
3828 reasoning_effort,
3829 ..
3830 }) = action
3831 else {
3832 panic!("expected model draft request");
3833 };
3834 assert_eq!(model, "glm-5.2");
3835 assert_eq!(provider.as_deref(), Some("zai"));
3836 assert_eq!(reasoning_effort.as_deref(), Some("max"));
3837
3838 // The host reconstructs the picked route from the event exactly as
3839 // `handle_fleet_profile_model_draft` does, and carries it to
3840 // `install_model_draft` (immune to the selection changing mid-draft).
3841 let picked_route = provider.map(|provider| (provider, model.clone()));
3842
3843 // The model returns a draft that (as always) has provider: None — the
3844 // untrusted gate strips any provider a model tries to smuggle.
3845 let drafted = sample_draft();
3846 assert_eq!(drafted.provider, None);
3847
3848 // Installing it re-injects the picked route, so the ratified draft keeps
3849 // BOTH the provider and the model the user actually chose, plus the
3850 // captured thinking tier.
3851 let (_title, content) = view.install_model_draft(
3852 drafted,
3853 "GLM-5.2".to_string(),
3854 picked_route,
3855 reasoning_effort,
3856 );
3857 let ratified = view.model_draft.as_deref().expect("draft installed");
3858 assert_eq!(ratified.provider.as_deref(), Some("zai"));
3859 assert_eq!(ratified.model.as_deref(), Some("glm-5.2"));
3860 assert_eq!(ratified.reasoning_effort.as_deref(), Some("max"));
3861
3862 // The rendered TOML the ratify keypress would persist names the provider
3863 // explicitly — never a provider-scoped ambiguity.
3864 assert!(content.contains("provider = \"zai\""), "{content}");
3865 assert!(content.contains("model = \"glm-5.2\""), "{content}");
3866 assert!(content.contains("reasoning_effort = \"max\""), "{content}");
3867
3868 // And ratifying commits exactly that route.
3869 let action = view.handle_key(key(KeyCode::Char('g')));
3870 let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft, scope }) =
3871 action
3872 else {
3873 panic!("expected ratify commit event");
3874 };
3875 assert_eq!(scope, FleetProfileScope::Personal);
3876 assert_eq!(draft.provider.as_deref(), Some("zai"));
3877 assert_eq!(draft.model.as_deref(), Some("glm-5.2"));
3878 assert_eq!(draft.reasoning_effort.as_deref(), Some("max"));
3879 }
3880
3881 #[test]
3882 fn model_step_filter_with_no_matches_renders_a_hint_instead_of_panicking() {
3883 // #5953: a type-to-filter query that matches nothing left
3884 // `render_choice_step` indexing an empty slice.
3885 let snap = snapshot();
3886 let mut view = FleetSetupView::from_snapshot(snap);
3887 view.handle_key(key(KeyCode::Enter));
3888 view.handle_key(key(KeyCode::Char('/')));
3889 for ch in "minimax ".chars() {
3890 view.handle_key(key(KeyCode::Char(ch)));
3891 }
3892 assert!(view.model_filter_active);
3893 assert_eq!(
3894 view.step_len(),
3895 0,
3896 "the query must match nothing for this test"
3897 );
3898 // Every size must render without panicking; the sizes with a detail
3899 // pane must also explain the empty list.
3900 for (w, h, expect_hint) in [(120u16, 40u16, true), (80, 24, true), (60, 12, false)] {
3901 let area = Rect::new(0, 0, w, h);
3902 let mut buf = Buffer::empty(area);
3903 view.render(area, &mut buf);
3904 let text: String = (0..h)
3905 .map(|y| {
3906 (0..w)
3907 .map(|x| buf[(x, y)].symbol().to_string())
3908 .collect::<String>()
3909 })
3910 .collect::<Vec<_>>()
3911 .join("\n");
3912 if expect_hint {
3913 assert!(
3914 text.contains("No routes match"),
3915 "{w}x{h} must explain the empty list:\n{text}"
3916 );
3917 }
3918 }
3919 // Esc clears the filter and the full catalog comes back.
3920 view.handle_key(key(KeyCode::Esc));
3921 assert!(view.step_len() > 0);
3922 }
3923
3924 #[test]
3925 fn model_step_filter_narrows_large_catalogs_by_provider_and_model() {
3926 let mut snap = snapshot();
3927 // Simulate an OpenRouter-scale catalog: many rows from one provider.
3928 for i in 0..120 {
3929 snap.available_models.push((
3930 "openrouter".to_string(),
3931 format!("vendor/model-{i:03}"),
3932 crate::provider_readiness::ResolvedProviderReadiness::SavedUnchecked,
3933 ));
3934 }
3935 snap.available_models.push((
3936 "openrouter".to_string(),
3937 "z-ai/glm-5-turbo".to_string(),
3938 crate::provider_readiness::ResolvedProviderReadiness::SavedUnchecked,
3939 ));
3940 let mut view = FleetSetupView::from_snapshot(snap);
3941 // Role → Model.
3942 view.handle_key(key(KeyCode::Enter));
3943 let full_len = view.step_len();
3944 assert!(full_len > 120, "unfiltered shows the whole catalog");
3945
3946 // `/` opens the filter; typing narrows by model id substring.
3947 view.handle_key(key(KeyCode::Char('/')));
3948 for ch in "glm".chars() {
3949 view.handle_key(key(KeyCode::Char(ch)));
3950 }
3951 assert_eq!(view.step_len(), 1, "only the glm row survives the filter");
3952 let route = view.selected_route().expect("filtered selection resolves");
3953 assert_eq!(
3954 route,
3955 ("openrouter".to_string(), "z-ai/glm-5-turbo".to_string())
3956 );
3957
3958 // Provider substring filters too.
3959 view.handle_key(key(KeyCode::Esc));
3960 view.handle_key(key(KeyCode::Char('/')));
3961 for ch in "deepseek".chars() {
3962 view.handle_key(key(KeyCode::Char(ch)));
3963 }
3964 // inherit's route IS the active DeepSeek route, so it matches too.
3965 assert_eq!(
3966 view.step_len(),
3967 3,
3968 "deepseek rows plus the inherit (active deepseek route) match"
3969 );
3970
3971 // Enter keeps the filter but releases the input; Esc in filter clears.
3972 view.handle_key(key(KeyCode::Enter));
3973 assert!(!view.model_filter_active);
3974 assert_eq!(view.step_len(), 3);
3975 view.handle_key(key(KeyCode::Char('/')));
3976 view.handle_key(key(KeyCode::Esc));
3977 assert_eq!(
3978 view.step_len(),
3979 full_len,
3980 "clearing restores the full catalog"
3981 );
3982 }
3983
3984 #[test]
3985 fn review_saves_starter_or_ratifies_installed_model_draft() {
3986 let mut view = FleetSetupView::from_snapshot(snapshot());
3987 to_review(&mut view);
3988
3989 // A structured starter draft is save-ready from the summary.
3990 let action = view.handle_key(key(KeyCode::Char('g')));
3991 let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft, scope }) =
3992 action
3993 else {
3994 panic!("expected starter commit event");
3995 };
3996 assert_eq!(scope, FleetProfileScope::Personal);
3997 assert_eq!(draft.id, "manager");
3998
3999 let mut view = FleetSetupView::from_snapshot(snapshot());
4000 to_review(&mut view);
4001 let (title, content) =
4002 view.install_model_draft(sample_draft(), "GLM-5.2".to_string(), None, None);
4003 assert!(title.contains("GLM-5.2"));
4004 assert!(content.contains("id = \"reviewer\""), "{content}");
4005 assert!(content.contains("Nothing is saved until"), "{content}");
4006
4007 let action = view.handle_key(key(KeyCode::Char('g')));
4008 let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft, scope }) =
4009 action
4010 else {
4011 panic!("expected ratify commit event");
4012 };
4013 assert_eq!(scope, FleetProfileScope::Personal);
4014 assert_eq!(draft.id, "reviewer");
4015 }
4016
4017 #[test]
4018 fn changing_answers_discards_a_stale_draft() {
4019 let mut view = FleetSetupView::from_snapshot(snapshot());
4020 to_review(&mut view);
4021 let _ = view.install_model_draft(sample_draft(), "GLM-5.2".to_string(), None, None);
4022 assert!(view.model_draft.is_some());
4023
4024 // Back to the role step and change the selection: the draft no
4025 // longer matches the answers and must not survive to ratification.
4026 view.handle_key(key(KeyCode::Left)); // Review -> Destination
4027 view.handle_key(key(KeyCode::Left)); // Destination -> Model
4028 view.handle_key(key(KeyCode::Left)); // Model -> Role
4029 assert_eq!(view.step, Step::Role);
4030 view.handle_key(key(KeyCode::Down));
4031 assert!(view.model_draft.is_none());
4032
4033 to_review(&mut view);
4034 let action = view.handle_key(key(KeyCode::Char('g')));
4035 let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft, .. }) =
4036 action
4037 else {
4038 panic!("expected fresh deterministic starter");
4039 };
4040 assert_eq!(draft.id, "explore");
4041 }
4042
4043 #[test]
4044 fn arrows_move_within_step_and_enter_advances() {
4045 let mut view = FleetSetupView::from_snapshot(snapshot());
4046 assert_eq!(view.step, Step::Role);
4047
4048 view.handle_key(key(KeyCode::Down));
4049 assert_eq!(view.role_idx, 1);
4050
4051 view.handle_key(key(KeyCode::Enter));
4052 assert_eq!(view.step, Step::Model);
4053
4054 view.handle_key(key(KeyCode::Down));
4055 assert_eq!(view.model_idx, 1);
4056
4057 view.handle_key(key(KeyCode::Enter));
4058 assert_eq!(view.step, Step::Destination);
4059 view.handle_key(key(KeyCode::Enter));
4060 assert_eq!(view.step, Step::Review);
4061
4062 // `t` cycles thinking on the review step without an extra wizard screen.
4063 view.handle_key(key(KeyCode::Char('t')));
4064 assert_eq!(view.thinking_idx, 1);
4065
4066 // Left steps back through the wizard.
4067 view.handle_key(key(KeyCode::Left));
4068 assert_eq!(view.step, Step::Destination);
4069 view.handle_key(key(KeyCode::Left));
4070 assert_eq!(view.step, Step::Model);
4071 view.handle_key(key(KeyCode::Left));
4072 assert_eq!(view.step, Step::Role);
4073 }
4074
4075 #[test]
4076 fn roster_role_handoff_starts_at_model_and_can_return_to_role() {
4077 let mut via_left = FleetSetupView::from_snapshot_for_role(snapshot(), "consultant");
4078 assert_eq!(via_left.step, Step::Model);
4079 assert_eq!(via_left.selected_role(), "advisor");
4080 assert!(matches!(
4081 via_left.handle_key(key(KeyCode::Left)),
4082 ViewAction::None
4083 ));
4084 assert_eq!(via_left.step, Step::Role);
4085 assert_eq!(via_left.selected_role(), "advisor");
4086
4087 let mut via_esc = FleetSetupView::from_snapshot_for_role(snapshot(), "reviewer");
4088 assert_eq!(via_esc.step, Step::Model);
4089 assert_eq!(via_esc.selected_role(), "reviewer");
4090 assert!(matches!(
4091 via_esc.handle_key(key(KeyCode::Esc)),
4092 ViewAction::None
4093 ));
4094 assert_eq!(via_esc.step, Step::Role);
4095 assert_eq!(via_esc.selected_role(), "reviewer");
4096
4097 let custom = FleetSetupView::from_snapshot_for_role(snapshot(), "domain-expert");
4098 assert_eq!(custom.step, Step::Model);
4099 assert_eq!(custom.selected_role(), "custom");
4100 }
4101
4102 #[test]
4103 fn esc_steps_back_then_cancels_from_role() {
4104 let mut view = FleetSetupView::from_snapshot(snapshot());
4105 view.handle_key(key(KeyCode::Enter)); // -> Model
4106 let action = view.handle_key(key(KeyCode::Esc));
4107 assert!(matches!(action, ViewAction::None));
4108 assert_eq!(view.step, Step::Role);
4109 let action = view.handle_key(key(KeyCode::Esc));
4110 assert!(matches!(action, ViewAction::Close));
4111 }
4112
4113 #[test]
4114 fn mouse_selects_rows_and_wheel_matches_keyboard_navigation() {
4115 let mut view = FleetSetupView::from_snapshot(snapshot());
4116 let area = Rect::new(0, 0, 120, 40);
4117 let mut buf = Buffer::empty(area);
4118 view.render(area, &mut buf);
4119 let (rect, row) = view.row_hitboxes.borrow()[2];
4120
4121 view.handle_mouse(MouseEvent {
4122 kind: MouseEventKind::Down(MouseButton::Left),
4123 column: rect.x,
4124 row: rect.y,
4125 modifiers: KeyModifiers::NONE,
4126 });
4127 assert_eq!(row, 2);
4128 assert_eq!(view.role_idx, 2);
4129
4130 view.handle_mouse(MouseEvent {
4131 kind: MouseEventKind::ScrollDown,
4132 column: rect.x,
4133 row: rect.y,
4134 modifiers: KeyModifiers::NONE,
4135 });
4136 assert_eq!(view.role_idx, 3);
4137 view.handle_mouse(MouseEvent {
4138 kind: MouseEventKind::ScrollUp,
4139 column: rect.x,
4140 row: rect.y,
4141 modifiers: KeyModifiers::NONE,
4142 });
4143 assert_eq!(view.role_idx, 2);
4144 }
4145
4146 #[test]
4147 fn compact_choice_window_keeps_deep_selection_visible_and_clickable() {
4148 let mut view = FleetSetupView::from_snapshot(snapshot());
4149 view.role_idx = ROLES.len() - 1;
4150 let area = Rect::new(0, 0, 80, 16);
4151 let mut buf = Buffer::empty(area);
4152 view.render(area, &mut buf);
4153 let rendered = (0..area.height)
4154 .map(|y| {
4155 (0..area.width)
4156 .map(|x| buf[(x, y)].symbol())
4157 .collect::<String>()
4158 })
4159 .collect::<Vec<_>>()
4160 .join("\n");
4161
4162 assert!(rendered.contains("▸ custom"), "{rendered}");
4163 assert!(
4164 view.row_hitboxes
4165 .borrow()
4166 .iter()
4167 .any(|(_, idx)| *idx == ROLES.len() - 1),
4168 "selected row needs an aligned mouse hitbox"
4169 );
4170 }
4171
4172 /// #3908: destination facts (exists/is_dir) are computed on the
4173 /// transitions that can change them — never per paint.
4174 #[test]
4175 fn review_destinations_are_cached_on_transitions_not_recomputed_per_paint() {
4176 let mut view = FleetSetupView::from_snapshot(snapshot());
4177 assert!(
4178 view.destinations.is_none(),
4179 "nothing is stat-ed before the user reaches the Destination step"
4180 );
4181
4182 view.advance(); // Role -> Model
4183 view.advance(); // Model -> Destination
4184 assert_eq!(view.step, Step::Destination);
4185 let on_entry = view
4186 .destinations
4187 .clone()
4188 .expect("entering Destination must populate the cached statuses");
4189 view.advance(); // Destination -> Review
4190 assert_eq!(view.step, Step::Review);
4191
4192 // Painting repeatedly must not change the cached value — that is the
4193 // whole point — and must not panic on the cached-read path.
4194 let area = Rect::new(0, 0, 80, 24);
4195 for _ in 0..3 {
4196 let mut buf = Buffer::empty(area);
4197 view.render(area, &mut buf);
4198 }
4199 assert_eq!(view.destinations.as_ref(), Some(&on_entry));
4200 }
4201
4202 #[test]
4203 fn destination_status_reports_new_file_replace_and_disabled_reasons() {
4204 let temp = tempfile::tempdir().expect("temp workspace");
4205 let personal = Ok(temp.path().join("home-agents"));
4206 let fresh = destination_status(
4207 FleetProfileScope::Project,
4208 temp.path(),
4209 &personal,
4210 "reviewer.toml",
4211 true,
4212 codewhale_localization::Locale::En,
4213 );
4214 assert_eq!(
4215 fresh.target,
4216 temp.path().join(PROFILE_DIR).join("reviewer.toml")
4217 );
4218 assert!(!fresh.target_exists);
4219 assert!(fresh.unavailable_reason.is_none());
4220
4221 let profile_dir = temp.path().join(PROFILE_DIR);
4222 std::fs::create_dir_all(&profile_dir).expect("profile dir");
4223 std::fs::write(profile_dir.join("reviewer.toml"), "id = \"reviewer\"\n")
4224 .expect("existing profile");
4225 let existing = destination_status(
4226 FleetProfileScope::Project,
4227 temp.path(),
4228 &personal,
4229 "reviewer.toml",
4230 true,
4231 codewhale_localization::Locale::En,
4232 );
4233 assert!(
4234 existing.target_exists,
4235 "the exact target file is detected, not a dir count"
4236 );
4237
4238 let disabled = destination_status(
4239 FleetProfileScope::Project,
4240 temp.path(),
4241 &personal,
4242 "reviewer.toml",
4243 false,
4244 codewhale_localization::Locale::En,
4245 );
4246 assert!(
4247 disabled
4248 .unavailable_reason
4249 .as_deref()
4250 .is_some_and(|r| r.contains("--no-project-config")),
4251 "{disabled:?}"
4252 );
4253
4254 let missing = destination_status(
4255 FleetProfileScope::Project,
4256 &temp.path().join("does-not-exist"),
4257 &personal,
4258 "reviewer.toml",
4259 true,
4260 codewhale_localization::Locale::En,
4261 );
4262 assert!(missing.unavailable_reason.is_some(), "{missing:?}");
4263 }
4264
4265 #[test]
4266 fn one_enter_from_review_saves_starter_profile_for_selection() {
4267 let mut view = FleetSetupView::from_snapshot(snapshot());
4268 // Role: manager(0) scout(1) builder(2) -> builder.
4269 view.handle_key(key(KeyCode::Down));
4270 view.handle_key(key(KeyCode::Down));
4271 view.handle_key(key(KeyCode::Enter)); // -> Model
4272 // Model: inherit(0) deepseek-v4-pro(1) -> deepseek-v4-pro.
4273 view.handle_key(key(KeyCode::Down));
4274 view.handle_key(key(KeyCode::Enter)); // Model -> Destination
4275 view.handle_key(key(KeyCode::Enter)); // Destination -> Review
4276 assert_eq!(view.step, Step::Review);
4277 while view.selected_reasoning_effort().as_deref() != Some("max") {
4278 view.handle_key(key(KeyCode::Char('t')));
4279 }
4280
4281 // The Review summary is already the structured confirmation surface;
4282 // one Enter saves the deterministic starter without another state.
4283 let action = view.handle_key(key(KeyCode::Enter));
4284 let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft, scope }) =
4285 action
4286 else {
4287 panic!("expected one-Enter starter save");
4288 };
4289 let content = draft.render_toml();
4290 assert!(content.contains("id = \"implement\""));
4291 assert!(content.contains("role_hint = \"implement\""));
4292 assert!(content.contains("model = \"deepseek-v4-pro\""));
4293 assert!(content.contains("reasoning_effort = \"max\""));
4294 // A concrete cross-provider route pin names its own provider
4295 // explicitly (#4093) — the saved profile must not be ambiguously
4296 // scoped to whatever provider happens to be active at launch time.
4297 assert!(content.contains("provider = \"deepseek\""), "{content}");
4298 for forbidden in ["base_url", "api_key"] {
4299 assert!(
4300 !content.contains(forbidden),
4301 "starter profile must not carry {forbidden}: {content}"
4302 );
4303 }
4304
4305 assert_eq!(scope, FleetProfileScope::Personal);
4306 assert_eq!(draft.id, "implement");
4307 assert_eq!(draft.role_hint, "implement");
4308 assert_eq!(draft.model.as_deref(), Some("deepseek-v4-pro"));
4309 assert_eq!(draft.provider.as_deref(), Some("deepseek"));
4310 assert_eq!(draft.reasoning_effort.as_deref(), Some("max"));
4311 }
4312
4313 #[test]
4314 fn review_defaults_to_personal_and_can_switch_to_project() {
4315 let temp = tempfile::tempdir().expect("temp workspace");
4316 let mut view = FleetSetupView::from_snapshot(workspace_snapshot(temp.path()));
4317 to_review(&mut view);
4318
4319 assert_eq!(view.profile_scope, FleetProfileScope::Personal);
4320 // `s` is a secondary accelerator back to the Destination step; the
4321 // destination itself is chosen with a focused control, never toggled
4322 // silently.
4323 view.handle_key(key(KeyCode::Char('s')));
4324 assert_eq!(view.step, Step::Destination);
4325 assert_eq!(
4326 view.profile_scope,
4327 FleetProfileScope::Personal,
4328 "s alone changes nothing"
4329 );
4330 view.handle_key(key(KeyCode::Up)); // This project
4331 view.handle_key(key(KeyCode::Enter));
4332 assert_eq!(view.step, Step::Review);
4333 assert_eq!(view.profile_scope, FleetProfileScope::Project);
4334
4335 let action = view.handle_key(key(KeyCode::Enter));
4336 let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft, scope }) =
4337 action
4338 else {
4339 panic!("expected project profile save event");
4340 };
4341 assert_eq!(scope, FleetProfileScope::Project);
4342 let rendered = draft.render_toml();
4343 assert!(rendered.contains("id = \"manager\""), "{rendered}");
4344 }
4345
4346 #[test]
4347 fn inherit_selection_starter_draft_carries_no_provider() {
4348 // `inherit` (no concrete route pin) must never carry a provider —
4349 // there's no explicit route to name (#4093).
4350 let mut view = FleetSetupView::from_snapshot(snapshot());
4351 to_review(&mut view);
4352 let action = view.handle_key(key(KeyCode::Enter));
4353 let ViewAction::EmitAndClose(ViewEvent::FleetProfileDraftCommitRequested { draft, .. }) =
4354 action
4355 else {
4356 panic!("expected inherit starter save");
4357 };
4358 assert_eq!(draft.model, None);
4359 assert_eq!(draft.provider, None);
4360 assert_eq!(draft.reasoning_effort, None);
4361 let content = draft.render_toml();
4362 assert!(!content.contains("provider"), "{content}");
4363 assert!(!content.contains("reasoning_effort"), "{content}");
4364 }
4365
4366 #[test]
4367 fn role_and_review_steps_note_roster_overrides() {
4368 // "reviewer" collides with the built-in roster member; the
4369 // role step context and review Role section must both say so.
4370 let mut view = FleetSetupView::from_snapshot(snapshot());
4371 for _ in 0..3 {
4372 view.handle_key(key(KeyCode::Down));
4373 }
4374 assert_eq!(view.selected_role(), "reviewer");
4375 assert_eq!(
4376 view.roster_override_note().as_deref(),
4377 Some("Replaces the built-in 'reviewer' role in the roster.")
4378 );
4379
4380 let role_step = render_through_stack(
4381 || {
4382 let mut v = FleetSetupView::from_snapshot(snapshot());
4383 for _ in 0..3 {
4384 v.handle_key(key(KeyCode::Down));
4385 }
4386 v
4387 },
4388 120,
4389 40,
4390 )
4391 .join("\n");
4392 assert!(
4393 contains_wrapped(&role_step, "Replaces the built-in 'reviewer'"),
4394 "{role_step}"
4395 );
4396
4397 let review = render_through_stack(
4398 || {
4399 let mut v = FleetSetupView::from_snapshot(snapshot());
4400 for _ in 0..3 {
4401 v.handle_key(key(KeyCode::Down));
4402 }
4403 v.step = Step::Review;
4404 v
4405 },
4406 120,
4407 40,
4408 )
4409 .join("\n");
4410 assert!(
4411 contains_wrapped(&review, "Replaces the built-in 'reviewer'"),
4412 "{review}"
4413 );
4414
4415 // "custom" also matches a built-in roster member.
4416 let mut custom_view = FleetSetupView::from_snapshot(snapshot());
4417 for _ in 0..8 {
4418 custom_view.handle_key(key(KeyCode::Down));
4419 }
4420 assert_eq!(custom_view.selected_role(), "custom");
4421 assert_eq!(
4422 custom_view.roster_override_note().as_deref(),
4423 Some("Replaces the built-in 'custom' role in the roster.")
4424 );
4425 }
4426
4427 #[test]
4428 fn default_selection_targets_manager_inherit() {
4429 let view = FleetSetupView::from_snapshot(snapshot());
4430 let draft = view.starter_profile_draft();
4431 assert_eq!(draft.file_name(), "manager.toml");
4432 assert_eq!(draft.role_hint, "manager");
4433 assert!(draft.model.is_none());
4434 assert!(draft.model_class_hint.is_none());
4435 assert!(
4436 draft
4437 .instructions
4438 .as_deref()
4439 .is_some_and(|text| text.contains("assigned Fleet slice"))
4440 );
4441 }
4442
4443 #[test]
4444 fn fleet_model_rows_keep_failed_provider_visible_with_reason() {
4445 let mut snap = snapshot();
4446 snap.available_models = vec![(
4447 "zai".to_string(),
4448 "glm-5.2".to_string(),
4449 crate::provider_readiness::ResolvedProviderReadiness::SavedLastCheckFailed {
4450 category: crate::error_taxonomy::ErrorCategory::Authentication,
4451 message: "auth failed".to_string(),
4452 },
4453 )];
4454 let mut view = FleetSetupView::from_snapshot(snap);
4455 assert_eq!(view.model_choices.len(), 2);
4456 assert!(
4457 view.model_choices[1]
4458 .summary
4459 .contains("last check failed (authentication)")
4460 );
4461 assert!(view.model_choices[1].summary.contains("auth failed"));
4462 assert_eq!(
4463 view.model_routes[1],
4464 ("zai".to_string(), "glm-5.2".to_string())
4465 );
4466 assert!(matches!(
4467 &view.model_row_states[1],
4468 FleetModelRowState::Blocked { reason } if reason == "auth failed"
4469 ));
4470 view.step = Step::Model;
4471 view.model_idx = 1;
4472 assert!(matches!(
4473 view.handle_key(key(KeyCode::Enter)),
4474 ViewAction::None
4475 ));
4476 assert_eq!(view.step, Step::Model);
4477 }
4478
4479 #[test]
4480 fn fleet_invalid_route_stays_visible_but_cannot_advance() {
4481 let mut snap = snapshot();
4482 snap.available_models = vec![(
4483 "zai".to_string(),
4484 "broken-model".to_string(),
4485 crate::provider_readiness::ResolvedProviderReadiness::InvalidRoute,
4486 )];
4487 let mut view = FleetSetupView::from_snapshot(snap);
4488 view.step = Step::Model;
4489 view.model_idx = 1;
4490
4491 assert!(view.model_choices[1].summary.contains("invalid route"));
4492 assert!(matches!(
4493 view.handle_key(key(KeyCode::Enter)),
4494 ViewAction::None
4495 ));
4496 assert_eq!(view.step, Step::Model);
4497 }
4498
4499 #[test]
4500 fn fleet_includes_saved_model_outside_bundled_catalog() {
4501 let providers = crate::config::ProvidersConfig {
4502 openrouter: crate::config::ProviderConfig {
4503 api_key: Some("openrouter-test-key".to_string()),
4504 model: Some("acme/private-preview".to_string()),
4505 ..Default::default()
4506 },
4507 ..Default::default()
4508 };
4509 let config = Config {
4510 provider: Some("openrouter".to_string()),
4511 providers: Some(providers),
4512 ..Default::default()
4513 };
4514
4515 let routes = cross_provider_model_routes(
4516 &config,
4517 crate::config::ApiProvider::Openrouter,
4518 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
4519 );
4520
4521 assert!(routes.iter().any(|(provider, model, readiness)| {
4522 provider == "openrouter" && model == "acme/private-preview" && readiness.can_attempt()
4523 }));
4524 assert_eq!(
4525 routes
4526 .iter()
4527 .filter(|(provider, model, _)| {
4528 provider == "openrouter" && model == "acme/private-preview"
4529 })
4530 .count(),
4531 1,
4532 "saved models must not be duplicated when the catalog later learns them"
4533 );
4534 }
4535
4536 #[test]
4537 fn fleet_routes_and_saved_draft_keep_exact_named_custom_provider() {
4538 let mut custom = std::collections::HashMap::new();
4539 for (name, base_url, model) in [
4540 ("custom-a", "http://127.0.0.1:18181/v1", "model-a"),
4541 ("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
4542 ] {
4543 custom.insert(
4544 name.to_string(),
4545 crate::config::ProviderConfig {
4546 kind: Some("openai-compatible".to_string()),
4547 base_url: Some(base_url.to_string()),
4548 model: Some(model.to_string()),
4549 api_key: Some("local-test-key".to_string()),
4550 ..Default::default()
4551 },
4552 );
4553 }
4554 let config = Config {
4555 provider: Some("custom-a".to_string()),
4556 providers: Some(crate::config::ProvidersConfig {
4557 custom,
4558 ..Default::default()
4559 }),
4560 ..Default::default()
4561 };
4562 let routes = cross_provider_model_routes(
4563 &config,
4564 crate::config::ApiProvider::Custom,
4565 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
4566 );
4567 assert!(
4568 routes
4569 .iter()
4570 .any(|(provider, model, _)| { provider == "custom-a" && model == "model-a" })
4571 );
4572 assert!(
4573 routes
4574 .iter()
4575 .any(|(provider, model, _)| { provider == "custom-b" && model == "model-b" })
4576 );
4577 assert!(!routes.iter().any(|(provider, _, _)| provider == "custom"));
4578
4579 let mut view = FleetSetupView::from_snapshot(FleetSetupSnapshot {
4580 available_models: routes,
4581 provider: "custom-a".to_string(),
4582 model: "model-a".to_string(),
4583 ..snapshot()
4584 });
4585 let route = view
4586 .model_routes
4587 .iter()
4588 .find(|(provider, model)| provider == "custom-b" && model == "model-b")
4589 .cloned()
4590 .expect("custom B route selectable while A is active");
4591 let draft = sample_draft();
4592 let (_, rendered) =
4593 view.install_model_draft(draft, "model-b".to_string(), Some(route), None);
4594 assert!(rendered.contains("provider = \"custom-b\""), "{rendered}");
4595 }
4596
4597 #[test]
4598 fn fleet_routes_keep_legacy_literal_custom_without_named_tables() {
4599 let config = Config {
4600 provider: Some("custom".to_string()),
4601 base_url: Some("http://127.0.0.1:18080/v1".to_string()),
4602 api_key: Some("local-test-key".to_string()),
4603 default_text_model: Some("legacy-custom-model".to_string()),
4604 ..Default::default()
4605 };
4606
4607 let routes = cross_provider_model_routes(
4608 &config,
4609 crate::config::ApiProvider::Custom,
4610 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
4611 );
4612
4613 assert!(
4614 routes.iter().any(|(provider, model, readiness)| {
4615 provider == "custom"
4616 && model == "legacy-custom-model"
4617 && matches!(
4618 readiness,
4619 crate::provider_readiness::ResolvedProviderReadiness::LocalUnchecked
4620 )
4621 && readiness.can_attempt()
4622 }),
4623 "{routes:?}"
4624 );
4625 }
4626
4627 #[test]
4628 fn role_step_keeps_list_and_detail_separate_at_80_columns() {
4629 let rows = render_through_stack(|| FleetSetupView::from_snapshot(snapshot()), 80, 24);
4630 let text = rows.join("\n");
4631
4632 let manager_row = rows
4633 .iter()
4634 .position(|row| row.contains("▸ manager"))
4635 .expect("manager row should render");
4636 let custom_row = rows
4637 .iter()
4638 .position(|row| row.contains(" custom"))
4639 .expect("custom row should render");
4640 let summary_row = rows
4641 .iter()
4642 .position(|row| row.contains("Plan & split queued work"))
4643 .expect("selected role summary should render");
4644 let description_row = rows
4645 .iter()
4646 .position(|row| row.contains("Coordinates the Fleet run"))
4647 .expect("selected role description should render");
4648
4649 assert!(
4650 manager_row < custom_row,
4651 "expected the full role list before details:\n{text}"
4652 );
4653 assert!(
4654 custom_row < summary_row,
4655 "selected summary must not share a row with role names:\n{text}"
4656 );
4657 assert!(
4658 custom_row < description_row,
4659 "selected description must render below the list:\n{text}"
4660 );
4661 for row in &rows[manager_row..=custom_row] {
4662 assert!(
4663 !row.contains("Plan & split queued work")
4664 && !row.contains("Coordinates the Fleet run")
4665 && !row.contains("Fleet runs sub-agents"),
4666 "role list row contains detail copy at 80 columns: {row:?}\n{text}"
4667 );
4668 }
4669 }
4670
4671 const BLEED_FILL: &str = "\u{e000}";
4672
4673 fn render_through_stack(view_at: impl Fn() -> FleetSetupView, w: u16, h: u16) -> Vec<String> {
4674 let area = Rect::new(0, 0, w, h);
4675 let mut buf = Buffer::empty(area);
4676 for y in 0..h {
4677 for x in 0..w {
4678 // A private-use glyph that no rendered copy or temp path can
4679 // contain, so bleed-through detection cannot false-positive
4680 // on a path like `/Volumes/VIXinSSD/...`.
4681 buf[(x, y)].set_symbol(BLEED_FILL);
4682 }
4683 }
4684 let mut stack = ViewStack::new();
4685 stack.push(view_at());
4686 stack.render(area, &mut buf);
4687 (0..h)
4688 .map(|y| {
4689 (0..w)
4690 .map(|x| buf[(x, y)].symbol().to_string())
4691 .collect::<String>()
4692 })
4693 .collect()
4694 }
4695
4696 #[test]
4697 fn fleet_setup_is_usable_and_opaque_at_blocker_sizes() {
4698 // Exercise each step so all three screens are validated at every size.
4699 type Builder = (&'static str, fn() -> FleetSetupView);
4700 let builders: [Builder; 3] = [
4701 ("role", || FleetSetupView::from_snapshot(snapshot())),
4702 ("model", || {
4703 let mut v = FleetSetupView::from_snapshot(snapshot());
4704 v.step = Step::Model;
4705 v
4706 }),
4707 ("review", || {
4708 let mut v = FleetSetupView::from_snapshot(snapshot());
4709 v.step = Step::Review;
4710 v
4711 }),
4712 ];
4713
4714 for (label, make) in builders {
4715 for (w, h) in BLOCKER_SIZES {
4716 let rows = render_through_stack(make, w, h);
4717 let text = rows.join("\n");
4718
4719 // No bleed-through anywhere in the composited frame.
4720 assert!(
4721 !text.contains(BLEED_FILL),
4722 "{label} {w}x{h}: background bleed-through"
4723 );
4724 // Some action label is always visible.
4725 assert!(text.contains("Esc"), "{label} {w}x{h}: missing footer");
4726 // The first impression communicates Fleet = agent team.
4727 assert!(
4728 text.contains("agent team"),
4729 "{label} {w}x{h}: missing framing"
4730 );
4731 // No row overflows the frame width.
4732 for (y, row) in rows.iter().enumerate() {
4733 assert!(
4734 UnicodeWidthStr::width(row.trim_end()) <= w as usize,
4735 "{label} {w}x{h}: row {y} overflows: {row:?}"
4736 );
4737 }
4738 }
4739 }
4740 }
4741
4742 #[test]
4743 fn review_at_cursor_size_keeps_content_and_actions_apart() {
4744 let rows = render_through_stack(
4745 || {
4746 let mut view = FleetSetupView::from_snapshot(snapshot());
4747 view.step = Step::Review;
4748 view
4749 },
4750 89,
4751 50,
4752 );
4753 let popup = centered_modal_area(Rect::new(0, 0, 89, 50), 96, 31, 60, 16);
4754 let review_row = rows
4755 .iter()
4756 .position(|row| row.contains("Review & save"))
4757 .expect("review heading");
4758 let review_col = rows[review_row]
4759 .chars()
4760 .position(|ch| ch == 'R')
4761 .expect("review heading column") as u16;
4762 assert!(
4763 review_col >= popup.x.saturating_add(2),
4764 "body copy must not touch the popup border: {:?}",
4765 rows[review_row]
4766 );
4767
4768 let action_row = rows
4769 .iter()
4770 .rposition(|row| row.contains("Esc"))
4771 .expect("footer Esc action");
4772 let footer_row = rows[..=action_row]
4773 .iter()
4774 .rposition(|row| row.contains("scroll"))
4775 .expect("footer shortcut row");
4776 assert!(footer_row > 0);
4777 let gutter = rows[footer_row - 1]
4778 .chars()
4779 .skip(usize::from(popup.x.saturating_add(1)))
4780 .take(usize::from(popup.width.saturating_sub(2)))
4781 .collect::<String>();
4782 assert!(
4783 gutter.trim().is_empty(),
4784 "review body needs a quiet row before the action rail: {gutter:?}"
4785 );
4786 }
4787
4788 #[test]
4789 fn choice_steps_at_cursor_size_stay_content_sized() {
4790 for (step, expected_height) in [(Step::Role, 22usize), (Step::Model, 23usize)] {
4791 let rows = render_through_stack(
4792 || {
4793 let mut view = FleetSetupView::from_snapshot(snapshot());
4794 view.step = step;
4795 view
4796 },
4797 89,
4798 50,
4799 );
4800 let top = rows
4801 .iter()
4802 .position(|row| row.contains("Fleet setup — your agent team"))
4803 .expect("fleet setup title");
4804 let bottom = rows
4805 .iter()
4806 .rposition(|row| row.contains("Step "))
4807 .expect("fleet setup step receipt");
4808 assert_eq!(
4809 bottom - top + 1,
4810 expected_height,
4811 "choice card should follow its content instead of filling the 89x50 frame"
4812 );
4813 }
4814 }
4815
4816 #[test]
4817 fn review_lists_model_permissions_tools_and_profile_availability() {
4818 // Top of the review: the leading sections are visible without scrolling.
4819 let top = render_through_stack(
4820 || {
4821 let mut v = FleetSetupView::from_snapshot(snapshot());
4822 v.step = Step::Review;
4823 v
4824 },
4825 120,
4826 40,
4827 )
4828 .join("\n");
4829 for section in [
4830 "Saves to",
4831 "Role",
4832 "Model",
4833 "Auth & readiness",
4834 "Permissions",
4835 ] {
4836 assert!(top.contains(section), "review missing section: {section}");
4837 }
4838 // The destination line names the scope and the exact file; the
4839 // permission posture stays governed by the sections below it.
4840 assert!(top.contains("Personal · "), "{top}");
4841 assert!(top.contains("agents"), "{top}");
4842 assert!(
4843 top.contains("can only narrow what the session allows"),
4844 "{top}"
4845 );
4846
4847 // The review is intentionally scrollable; scrolling to the bottom reveals
4848 // the workspace/org execution policy, review policy, and honest save note.
4849 let bottom = render_through_stack(
4850 || {
4851 let mut v = FleetSetupView::from_snapshot(snapshot());
4852 v.step = Step::Review;
4853 v.review_scroll = 999; // clamps to max in render
4854 v
4855 },
4856 120,
4857 40,
4858 )
4859 .join("\n");
4860 for needle in [
4861 "Tools",
4862 "Workspace",
4863 "Review policy",
4864 "Save as Personal profile",
4865 ] {
4866 assert!(bottom.contains(needle), "scrolled review missing: {needle}");
4867 }
4868
4869 let policy = FleetSetupView::from_snapshot(snapshot()).review_policy_summary();
4870 for truth in [
4871 "current interactive session",
4872 "codewhale fleet status",
4873 ".codewhale/fleet.jsonl",
4874 ] {
4875 assert!(policy.contains(truth), "review policy missing: {truth}");
4876 }
4877 assert!(
4878 !policy.contains("inspects the ledger"),
4879 "the interactive status command must not claim to inspect the durable ledger: {policy}"
4880 );
4881 }
4882
4883 #[test]
4884 fn dormant_external_consent_row_requires_activation() {
4885 let mut snap = snapshot();
4886 snap.available_models = vec![(
4887 "openai-codex".to_string(),
4888 "gpt-5.6-sol".to_string(),
4889 crate::provider_readiness::ResolvedProviderReadiness::ExternalConsentPendingSelection,
4890 )];
4891 let view = FleetSetupView::from_snapshot(snap);
4892 assert!(
4893 view.model_choices[1]
4894 .summary
4895 .contains("external consent · select to check")
4896 );
4897 assert!(matches!(
4898 view.model_row_states[1],
4899 FleetModelRowState::NeedsActivation
4900 ));
4901 }
4902
4903 #[test]
4904 fn enter_on_dormant_external_consent_emits_activation_event() {
4905 let mut snap = snapshot();
4906 snap.available_models = vec![(
4907 "openai-codex".to_string(),
4908 "gpt-5.6-terra".to_string(),
4909 crate::provider_readiness::ResolvedProviderReadiness::ExternalConsentPendingSelection,
4910 )];
4911 let mut view = FleetSetupView::from_snapshot(snap);
4912 view.handle_key(key(KeyCode::Enter)); // Role -> Model
4913 view.handle_key(key(KeyCode::Down)); // inherit -> codex row
4914 assert_eq!(
4915 view.selected_route(),
4916 Some(("openai-codex".to_string(), "gpt-5.6-terra".to_string()))
4917 );
4918 let action = view.handle_key(key(KeyCode::Enter));
4919 let ViewAction::Emit(ViewEvent::FleetSetupExternalConsentActivationRequested {
4920 provider_id,
4921 model,
4922 }) = action
4923 else {
4924 panic!("expected external-consent activation request, got {action:?}");
4925 };
4926 assert_eq!(provider_id, "openai-codex");
4927 assert_eq!(model, "gpt-5.6-terra");
4928 assert_eq!(
4929 view.step,
4930 Step::Model,
4931 "stays on Model step until host validates"
4932 );
4933 }
4934
4935 #[test]
4936 fn refresh_from_snapshot_makes_activated_row_ready() {
4937 let mut snap = snapshot();
4938 snap.available_models = vec![(
4939 "xai".to_string(),
4940 "grok-4.5".to_string(),
4941 crate::provider_readiness::ResolvedProviderReadiness::ExternalConsentPendingSelection,
4942 )];
4943 let mut view = FleetSetupView::from_snapshot(snap);
4944 view.handle_key(key(KeyCode::Enter)); // Role -> Model
4945 view.handle_key(key(KeyCode::Down)); // xai row
4946 assert!(matches!(
4947 view.model_row_states[1],
4948 FleetModelRowState::NeedsActivation
4949 ));
4950
4951 // Simulate the host validating the route and rebuilding the snapshot:
4952 // the same row is now Ready.
4953 let mut refreshed = snapshot();
4954 refreshed.available_models = vec![(
4955 "xai".to_string(),
4956 "grok-4.5".to_string(),
4957 crate::provider_readiness::ResolvedProviderReadiness::Ready,
4958 )];
4959 view.refresh_from_snapshot(refreshed);
4960
4961 assert!(matches!(
4962 view.model_row_states[1],
4963 FleetModelRowState::Ready
4964 ));
4965 // Selection and step are preserved.
4966 assert_eq!(view.step, Step::Model);
4967 assert_eq!(
4968 view.selected_route(),
4969 Some(("xai".to_string(), "grok-4.5".to_string()))
4970 );
4971 }
4972
4973 #[test]
4974 fn blocked_row_cannot_advance() {
4975 let mut snap = snapshot();
4976 snap.available_models = vec![(
4977 "xai".to_string(),
4978 "grok-4.5".to_string(),
4979 crate::provider_readiness::ResolvedProviderReadiness::MissingKey,
4980 )];
4981 let mut view = FleetSetupView::from_snapshot(snap);
4982 view.step = Step::Model;
4983 view.model_idx = 1;
4984 assert!(matches!(
4985 &view.model_row_states[1],
4986 FleetModelRowState::Blocked { reason } if reason == "missing API key"
4987 ));
4988 assert!(matches!(
4989 view.handle_key(key(KeyCode::Enter)),
4990 ViewAction::None
4991 ));
4992 assert_eq!(view.step, Step::Model);
4993 }
4994
4995 #[test]
4996 fn fleet_setup_includes_openai_codex_account_roster_with_dormant_consent() {
4997 let _env = crate::test_support::lock_test_env();
4998 let codex_home = tempfile::tempdir().expect("Codex home");
4999 let _home = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex_home.path());
5000 std::fs::write(
5001 codex_home.path().join("models_cache.json"),
5002 serde_json::to_vec(&serde_json::json!({
5003 "fetched_at": chrono::Utc::now(),
5004 "models": [
5005 { "slug": "gpt-5.6-sol", "priority": 1 },
5006 { "slug": "gpt-5.6-terra", "priority": 2 },
5007 { "slug": "gpt-5.6-luna", "priority": 3 }
5008 ]
5009 }))
5010 .expect("serialize cache"),
5011 )
5012 .expect("write cache");
5013
5014 let mut config = crate::config::Config::default();
5015 config.providers = Some(crate::config::ProvidersConfig {
5016 openai_codex: crate::config::ProviderConfig {
5017 auth_mode: Some("oauth".to_string()),
5018 external_credentials: Some(
5019 codewhale_config::ExternalCredentialConsentToml::read_only(
5020 codewhale_config::ProviderKind::OpenaiCodex,
5021 codewhale_config::ExternalCredentialSource::CodexCli,
5022 codex_home.path().join("auth.json"),
5023 ),
5024 ),
5025 ..Default::default()
5026 },
5027 ..Default::default()
5028 });
5029
5030 let routes = cross_provider_model_routes(
5031 &config,
5032 crate::config::ApiProvider::Moonshot,
5033 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
5034 );
5035
5036 for model in ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] {
5037 assert!(
5038 routes.iter().any(|(provider, m, readiness)| {
5039 provider == "openai-codex"
5040 && m == model
5041 && matches!(
5042 readiness,
5043 crate::provider_readiness::ResolvedProviderReadiness::ExternalConsentPendingSelection
5044 )
5045 }),
5046 "missing dormant-consent Codex route for {model}: {routes:?}"
5047 );
5048 }
5049 }
5050
5051 #[test]
5052 fn fleet_setup_includes_xai_grok_routes_with_dormant_consent() {
5053 let _env = crate::test_support::lock_test_env();
5054 let grok_home = tempfile::tempdir().expect("Grok home");
5055 let mut config = crate::config::Config::default();
5056 config.providers = Some(crate::config::ProvidersConfig {
5057 xai: crate::config::ProviderConfig {
5058 auth_mode: Some("oauth".to_string()),
5059 external_credentials: Some(
5060 codewhale_config::ExternalCredentialConsentToml::read_only(
5061 codewhale_config::ProviderKind::Xai,
5062 codewhale_config::ExternalCredentialSource::GrokCli,
5063 grok_home.path().join("grok-auth.json"),
5064 ),
5065 ),
5066 ..Default::default()
5067 },
5068 ..Default::default()
5069 });
5070
5071 let routes = cross_provider_model_routes(
5072 &config,
5073 crate::config::ApiProvider::Moonshot,
5074 &crate::provider_readiness::ProviderReadinessSnapshot::default(),
5075 );
5076
5077 let xai_rows: Vec<_> = routes
5078 .iter()
5079 .filter(|(provider, _, _)| provider == "xai")
5080 .collect();
5081 assert!(
5082 !xai_rows.is_empty(),
5083 "xAI routes must be offered when Grok CLI consent is configured: {routes:?}"
5084 );
5085 assert!(
5086 xai_rows.iter().all(|(_, _, readiness)| {
5087 matches!(
5088 readiness,
5089 crate::provider_readiness::ResolvedProviderReadiness::ExternalConsentPendingSelection
5090 )
5091 }),
5092 "every xAI row must require explicit activation: {xai_rows:?}"
5093 );
5094 }
5095 }
5096
5096 lines RUST