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