Skip to main content

zeroclaw_runtime/onboard/
mod.rs

1//! Onboard orchestrator.
2//!
3//! Thin dispatcher above the `OnboardUi` trait (defined in
4//! `zeroclaw-config::traits`). Section-scoped entry points let callers run
5//! just one slice (`zeroclaw onboard channels`) or the whole flow.
6//!
7//! Everything writes through `Config::set_prop` (or its helpers); direct
8//! struct-field assignment is off-limits per the DRY contract.
9
10use std::time::Duration;
11
12use anyhow::{Context, Result};
13use serde::Deserialize;
14use zeroclaw_config::schema::Config;
15use zeroclaw_config::traits::{Answer, OnboardUi, PropKind, SelectItem};
16
17use crate::agent::personality::EDITABLE_PERSONALITY_FILES;
18use crate::agent::personality_templates::{TemplateContext, render as render_personality};
19use crate::i18n;
20
21const CUSTOM_OPENAI_COMPAT_LABEL: &str = "Custom OpenAI-compatible endpoint";
22const OPENAI_COMPAT_MODELS_TIMEOUT: Duration = Duration::from_secs(10);
23
24/// Sections without a tailored interactive wizard. Single source for
25/// the variant list used by `dispatch_section` and `section_has_signal`.
26/// Macros expand pre-typecheck so each match stays exhaustive.
27macro_rules! acknowledge_only_sections {
28    () => {
29        Section::Storage
30            | Section::Cron
31            | Section::Mcp
32            | Section::McpBundles
33            | Section::KnowledgeBundles
34    };
35}
36
37/// Internal prompt / section navigation signal. `Done` = advance. `Back` =
38/// the user pressed Esc; rewind one step. Helpers propagate it up through
39/// `prompt_field` → `prompt_fields_under` → section fn → `run_all`.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41enum Nav {
42    Done,
43    Back,
44}
45
46/// Skip-gate outcome. `Skip` = section already configured, user chose not
47/// to reconfigure. `Enter` = walk the section. `Back` = user pressed Esc
48/// at the skip prompt, bounce to the previous section.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50enum SkipNav {
51    Skip,
52    Enter,
53    Back,
54}
55
56pub mod field_visibility;
57pub mod ui;
58
59#[derive(Debug, Deserialize)]
60struct OpenAiModelsResponse {
61    data: Vec<OpenAiModel>,
62}
63
64#[derive(Debug, Deserialize)]
65struct OpenAiModel {
66    id: String,
67}
68
69pub use zeroclaw_config::sections::Section;
70
71/// What slice of onboarding the orchestrator should run. `None` walks
72/// the full wizard (every [`Section`] in canonical order); `Some(s)`
73/// targets one section. The runtime intentionally has no parallel
74/// `Section`-with-`All` enum — that was three drift surfaces in one
75/// file.
76pub type Target = Option<Section>;
77
78/// First segment of a dotted property path mapped back to the wizard
79/// section it lives under, or `None` for non-wizard paths
80/// (`onboard_state.completed_sections`, etc.).
81#[must_use]
82pub fn section_for_path(path: &str) -> Option<Section> {
83    Section::from_key(path.split('.').next()?)
84}
85
86/// Runtime knobs sourced from CLI flags. `--quick`/`--tui` select the UI
87/// backend at the binary edge and don't appear here — the orchestrator only
88/// cares about per-section behavior.
89#[derive(Debug, Default, Clone)]
90pub struct Flags {
91    /// Skip "keep existing value?" confirmations; always re-prompt.
92    pub force: bool,
93    /// Back up the current config dir and start from `Config::default()`.
94    pub reinit: bool,
95    pub api_key: Option<String>,
96    pub model_provider: Option<String>,
97    pub model: Option<String>,
98    pub memory: Option<String>,
99}
100
101/// Top-level onboard dispatcher. `target` is the canonical
102/// `Option<Section>` from `zeroclaw_runtime::onboard::Target` —
103/// `None` walks the full wizard, `Some(s)` runs a single section.
104pub async fn run(
105    cfg: &mut Config,
106    ui: &mut dyn OnboardUi,
107    target: Target,
108    flags: &Flags,
109) -> Result<()> {
110    let Some(section) = target else {
111        return run_all(cfg, ui, flags).await;
112    };
113    let _ = dispatch_section(cfg, ui, flags, section).await?;
114    Ok(())
115}
116
117/// Run a single onboarding section. Returns `Nav::Done` for sections
118/// without an interactive wizard (the operator reaches them via the
119/// `/config` dashboard or `zeroclaw config set`); the run-all loop
120/// treats Done as "advance to the next section". Exhaustive over
121/// [`Section`] so adding a variant to the canonical enum forces a
122/// match arm here.
123async fn dispatch_section(
124    cfg: &mut Config,
125    ui: &mut dyn OnboardUi,
126    flags: &Flags,
127    section: Section,
128) -> Result<Nav> {
129    // Each arm's future is `Box::pin`'d so the enclosing state machine
130    // holds a small pointer, not the inlined union of every section
131    // handler's locals. Without this the test thread's 2 MiB stack
132    // overflows on the deepest interactive wizards (notably the model-
133    // provider model-discovery path).
134    match section {
135        Section::ModelProviders => Box::pin(model_providers(cfg, ui, flags)).await,
136        Section::TtsProviders | Section::TranscriptionProviders => {
137            Box::pin(no_wizard_acknowledge(
138                ui,
139                section,
140                &format!(
141                    "No interactive wizard yet. Configure via the dashboard at \
142                 /config/{section} or \
143                 `zeroclaw config set {section}.<type>.<alias>.<field> <value>`."
144                ),
145            ))
146            .await
147        }
148        Section::Channels => Box::pin(channels(cfg, ui, flags)).await,
149        Section::Memory => Box::pin(memory(cfg, ui, flags)).await,
150        Section::Hardware => Box::pin(hardware(cfg, ui, flags)).await,
151        Section::Tunnel => Box::pin(tunnel(cfg, ui, flags)).await,
152        Section::Agents => Box::pin(agents(cfg, ui, flags)).await,
153        Section::Skills => Box::pin(skills(cfg, ui, flags)).await,
154        Section::SkillBundles => {
155            Box::pin(one_tier_alias_section(
156                cfg,
157                ui,
158                section,
159                "skill-bundles",
160                "Skill bundle",
161            ))
162            .await
163        }
164        Section::RiskProfiles => {
165            Box::pin(one_tier_alias_section(
166                cfg,
167                ui,
168                section,
169                "risk-profiles",
170                "Risk profile",
171            ))
172            .await
173        }
174        Section::RuntimeProfiles => {
175            Box::pin(one_tier_alias_section(
176                cfg,
177                ui,
178                section,
179                "runtime-profiles",
180                "Runtime profile",
181            ))
182            .await
183        }
184        Section::PeerGroups => {
185            Box::pin(one_tier_alias_section(
186                cfg,
187                ui,
188                section,
189                "peer-groups",
190                "Peer group",
191            ))
192            .await
193        }
194        acknowledge_only_sections!() => {
195            Box::pin(no_wizard_acknowledge(
196                ui,
197                section,
198                &format!(
199                    "Configured via the dashboard at /config/{section} or \
200                 `zeroclaw config set {section}.<alias>.<field> <value>` \
201                 (not part of the initial wizard)."
202                ),
203            ))
204            .await
205        }
206    }
207}
208
209/// Render heading + help + a confirm prompt for sections without a
210/// tailored wizard. Ratatui's `note()` is a no-op without a subsequent
211/// prompt to anchor it, so the confirm makes the message actually render.
212async fn no_wizard_acknowledge(
213    ui: &mut dyn OnboardUi,
214    section: Section,
215    explanation: &str,
216) -> Result<Nav> {
217    let mut label = section.as_str().replace(['_', '-', '.'], " ");
218    if let Some(c) = label.get_mut(0..1) {
219        c.make_ascii_uppercase();
220    }
221    ui.heading(1, &label);
222    let canonical = section.help();
223    let note = if canonical.is_empty() {
224        explanation.to_string()
225    } else {
226        format!("{canonical}\n\n{explanation}")
227    };
228    ui.note(&note);
229    let _ = ui.confirm("Continue", false).await?;
230    Ok(Nav::Done)
231}
232
233/// Walk every section in order with section-level Back. Each section returns
234/// `Nav::Back` when the user pressed Esc at its first prompt; the loop
235/// rewinds to the previous section. Back at the first section exits
236/// onboarding cleanly (user bails out).
237async fn run_all(cfg: &mut Config, ui: &mut dyn OnboardUi, flags: &Flags) -> Result<()> {
238    let order: Vec<Section> = zeroclaw_config::sections::ONBOARDING_SECTIONS.to_vec();
239    let mut i: usize = 0;
240    loop {
241        let Some(section) = order.get(i).copied() else {
242            return Ok(());
243        };
244        match dispatch_section(cfg, ui, flags, section).await? {
245            Nav::Done => i += 1,
246            Nav::Back => {
247                if i == 0 {
248                    return Ok(());
249                }
250                i -= 1;
251            }
252        }
253    }
254}
255
256/// Write a single property and immediately persist the whole config. This is
257/// the ONE path every section takes to mutate cfg, so users who Ctrl+C
258/// mid-flow find their prior answers already saved on disk — re-running
259/// `zeroclaw onboard` picks up where they left off.
260async fn persist(cfg: &mut Config, path: &str, value: &str) -> Result<()> {
261    cfg.set_prop_persistent(path, value)?;
262    cfg.save_dirty().await?;
263    Ok(())
264}
265
266/// Emit the section's heading + help blurb in the canonical layout.
267/// Help copy lives on `Section::help()` in `zeroclaw-config` so the
268/// CLI / TUI / dashboard render the same text without parallel tables.
269/// `display_label` lets per-section code keep its preferred title casing
270/// (the canonical key would otherwise be e.g. `model_providers`).
271fn emit_section_header(ui: &mut dyn OnboardUi, section: Section, display_label: &str) {
272    ui.heading(1, display_label);
273    let help = section.help();
274    if !help.is_empty() {
275        ui.note(help);
276    }
277}
278
279// ── Field-driven helpers ─────────────────────────────────────────────────
280
281/// Per-field default override. When a section knows a sensible default
282/// that lives outside the config (e.g. `AnthropicModelProvider::default_temperature()`),
283/// it builds a list of these and passes them to `prompt_fields_under`.
284/// The prompt surfaces the default as ghost-text inside the input box
285/// plus a "Default: X. Press Enter to accept." line in the help blurb,
286/// only when the field is unset in cfg.
287#[derive(Debug, Clone)]
288pub struct FieldDefault {
289    pub path: String,
290    pub display: String,
291}
292
293fn find_default<'a>(defaults: &'a [FieldDefault], path: &str) -> Option<&'a str> {
294    defaults
295        .iter()
296        .find(|d| d.path == path)
297        .map(|d| d.display.as_str())
298}
299
300/// Multi-line pretty form of a JSON-shaped Object scalar for `$EDITOR`
301/// hand-off. Returns `None` when the input doesn't parse as JSON so the
302/// caller falls through to the raw value (e.g. when the field is still
303/// a placeholder string).
304fn pretty_print_object(value: &str) -> Option<String> {
305    let parsed: serde_json::Value = serde_json::from_str(value.trim()).ok()?;
306    serde_json::to_string_pretty(&parsed).ok()
307}
308
309/// Compact form of an Object scalar suitable for `set_prop`. Round-trips
310/// through `serde_json` so trailing whitespace, comments, and blank lines
311/// the user added during editing are normalised away.
312fn compact_object(edited: &str) -> Option<String> {
313    let parsed: serde_json::Value = serde_json::from_str(edited.trim()).ok()?;
314    serde_json::to_string(&parsed).ok()
315}
316
317/// True when `input` parses as the same `Vec<String>` form `config.toml`
318/// emits. Lets the StringArray prompt accept the bracketed display form
319/// bidirectionally.
320/// Live alias list to render as a picker when the field's `type_hint`
321/// references a typed alias-ref newtype (ChannelRef, AgentAlias, …).
322/// `None` for plain `String` fields so the caller falls through to the
323/// free-text input.
324fn alias_options_for_type_hint(cfg: &Config, type_hint: &str) -> Option<Vec<String>> {
325    let dotted = |prefix: &str| -> Vec<String> {
326        let mut out: Vec<String> = Vec::new();
327        for f in cfg.prop_fields() {
328            if let Some(rest) = f.name.strip_prefix(&format!("{prefix}.")) {
329                let mut parts = rest.splitn(3, '.');
330                if let (Some(ty), Some(alias), Some(_)) = (parts.next(), parts.next(), parts.next())
331                {
332                    let dotted = format!("{ty}.{alias}");
333                    if !out.contains(&dotted) {
334                        out.push(dotted);
335                    }
336                }
337            }
338        }
339        out.sort();
340        out
341    };
342    let bare = |section: &str| -> Vec<String> { cfg.get_map_keys(section).unwrap_or_default() };
343    if type_hint.contains("ChannelRef") {
344        Some(dotted("channels"))
345    } else if type_hint.contains("ModelProviderRef") {
346        Some(dotted("providers.models"))
347    } else if type_hint.contains("TtsProviderRef") {
348        Some(dotted("providers.tts"))
349    } else if type_hint.contains("TranscriptionProviderRef") {
350        Some(dotted("providers.transcription"))
351    } else if type_hint.contains("AgentAlias") {
352        Some(bare("agents"))
353    } else {
354        None
355    }
356}
357
358fn parses_as_string_array(input: &str) -> bool {
359    toml::from_str::<std::collections::HashMap<String, Vec<String>>>(&format!("v = {input}"))
360        .is_ok()
361}
362
363/// Prompt for a single config field identified by its dotted name. Returns
364/// `Nav::Back` when the user pressed Esc at the prompt; `Nav::Done` on any
365/// other outcome (including "kept current value"). `default` is the
366/// section-supplied fallback for unset fields — surfaced in the label and
367/// prefilled into the input.
368async fn prompt_field(
369    cfg: &mut Config,
370    ui: &mut dyn OnboardUi,
371    name: &str,
372    default: Option<&str>,
373) -> Result<Nav> {
374    // Field populated by a `ZEROCLAW_*` env override at load time — show the
375    // env-var name and the TOML path, then skip the prompt. The note clears
376    // when navigation moves to next/previous step.
377    if cfg.prop_is_env_overridden(name) {
378        let env_var = format!("ZEROCLAW_{}", name.replace('.', "__").replace('-', "_"),);
379        ui.note(&format!(
380            "\u{1f489} {name}\n\
381             overridden by env: {env_var}\n\
382             config.toml path: [{name}] — skipping prompt, value sourced from environment.",
383        ));
384        return Ok(Nav::Done);
385    }
386
387    let field = cfg
388        .prop_fields()
389        .into_iter()
390        .find(|f| f.name == name)
391        .ok_or_else(|| {
392            ::zeroclaw_log::record!(
393                WARN,
394                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
395                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
396                    .with_attrs(::serde_json::json!({"name": name})),
397                "onboard: unknown config field"
398            );
399            anyhow::Error::msg(format!("unknown config field: {name}"))
400        })?;
401
402    let short = name.rsplit('.').next().unwrap_or(name);
403    let current = field.display_value;
404    // For bools, `display_value` is always `"true"` or `"false"` — never
405    // empty, never `"<unset>"` — so a naive is-set check can't tell an
406    // explicit user choice apart from the struct-level default. Treat
407    // bools as unset here: the [Yes]/[No] toggle already surfaces the
408    // current state, and collapsing `is_set` lets any passed `default`
409    // render in the prompt label (`enabled (default: true)`) while
410    // keeping the misleading "Current: …" annotation out of the help.
411    let is_set = field.kind != PropKind::Bool && !current.is_empty() && current != "<unset>";
412
413    // Surface the docstring as help text above the prompt, and append
414    // whichever annotation fits the prompt's state: "Default: X" when
415    // the section supplied one and the field is unset, "Current: X"
416    // when the config carries a user-set value (non-bool only).
417    let mut help = field.description.to_string();
418    // List-of-strings fields take comma-separated input. Without this
419    // hint users guess and end up entering things like `["alice"]` as
420    // raw text — the parser then treats that as one big string element
421    // and the saved config is garbage.
422    if field.kind == PropKind::StringArray {
423        if !help.is_empty() {
424            help.push('\n');
425        }
426        help.push_str("Format: alice,bob or [\"alice\", \"bob\"]. Empty = clear list.");
427    }
428    if !is_set
429        && let Some(d) = default
430        && !d.is_empty()
431    {
432        if !help.is_empty() {
433            help.push('\n');
434        }
435        help.push_str(&format!("Default: {d}. Press Enter to accept."));
436    } else if is_set {
437        if !help.is_empty() {
438            help.push('\n');
439        }
440        help.push_str(&format!("Current: {current}. Enter to keep."));
441    }
442    ui.note(&help);
443
444    let prompt = short;
445
446    if field.is_secret {
447        match ui.secret(prompt, is_set).await? {
448            Answer::Back => return Ok(Nav::Back),
449            Answer::Value(Some(value)) => persist(cfg, name, &value).await?,
450            Answer::Value(None) => {}
451        }
452        return Ok(Nav::Done);
453    }
454
455    match field.kind {
456        PropKind::Bool => {
457            let cur = current.parse::<bool>().unwrap_or(false);
458            match ui.confirm(prompt, cur).await? {
459                Answer::Back => return Ok(Nav::Back),
460                Answer::Value(new) if new != cur => persist(cfg, name, &new.to_string()).await?,
461                Answer::Value(_) => {}
462            }
463        }
464        PropKind::String | PropKind::Integer | PropKind::Float => {
465            // Typed alias-ref (ChannelRef, AgentAlias, ModelProviderRef,
466            // TtsProviderRef, TranscriptionProviderRef) — render the
467            // live alias list as a select instead of a free-text input.
468            // Matches what the dashboard does and keeps the CLI surface
469            // from accepting silently-broken refs.
470            if let Some(options) = alias_options_for_type_hint(cfg, field.type_hint) {
471                let items: Vec<SelectItem> = options.iter().map(SelectItem::new).collect();
472                let current_idx = if is_set {
473                    options.iter().position(|v| v == &current)
474                } else {
475                    default.and_then(|d| options.iter().position(|v| v == d))
476                };
477                match ui.select(prompt, &items, current_idx).await? {
478                    Answer::Back => return Ok(Nav::Back),
479                    Answer::Value(idx) => {
480                        let new = options[idx].clone();
481                        if (is_set || !new.is_empty()) && new != current {
482                            persist(cfg, name, &new).await?;
483                        }
484                    }
485                }
486                return Ok(Nav::Done);
487            }
488            // `current` pre-fills the buffer (edit mode); `placeholder`
489            // renders the section default as ghost text. Enter on empty
490            // commits the placeholder.
491            let (prefill, placeholder) = if is_set {
492                (Some(current.as_str()), None)
493            } else {
494                (None, default)
495            };
496            match ui.string(prompt, prefill, placeholder).await? {
497                Answer::Back => return Ok(Nav::Back),
498                Answer::Value(new) => {
499                    if (is_set || !new.is_empty()) && new != current {
500                        persist(cfg, name, &new).await?;
501                    }
502                }
503            }
504        }
505        PropKind::StringArray => {
506            let (prefill, placeholder) = if is_set {
507                (Some(current.as_str()), None)
508            } else {
509                (None, default)
510            };
511            // Accepts comma-separated input or the bracketed form from
512            // config.toml. Reject malformed brackets — otherwise the
513            // parser silently coerces them into a single-element list
514            // of garbage.
515            loop {
516                match ui.string(prompt, prefill, placeholder).await? {
517                    Answer::Back => return Ok(Nav::Back),
518                    Answer::Value(new) => {
519                        let trimmed = new.trim();
520                        if trimmed.starts_with('[') && !parses_as_string_array(trimmed) {
521                            ui.note("Invalid array. Use alice,bob or [\"alice\", \"bob\"].");
522                            continue;
523                        }
524                        if (is_set || !new.is_empty()) && new != current {
525                            persist(cfg, name, &new).await?;
526                        }
527                        ui.note("");
528                        break;
529                    }
530                }
531            }
532        }
533        PropKind::Enum => {
534            let variants = field.enum_variants.map(|get| get()).unwrap_or_default();
535            if variants.is_empty() {
536                ui.warn(&format!("skipping {name}: no enum variants exposed"));
537                return Ok(Nav::Done);
538            }
539            let items: Vec<SelectItem> = variants.iter().map(SelectItem::new).collect();
540            let current_idx = if is_set {
541                variants.iter().position(|v| v == &current)
542            } else {
543                default.and_then(|d| variants.iter().position(|v| v == d))
544            };
545            match ui.select(prompt, &items, current_idx).await? {
546                Answer::Back => return Ok(Nav::Back),
547                Answer::Value(idx) => {
548                    let new = &variants[idx];
549                    if new != &current {
550                        persist(cfg, name, new).await?;
551                    }
552                }
553            }
554        }
555        PropKind::ObjectArray => {
556            // Vec<T> of structs (e.g. mcp.servers). The TUI doesn't have
557            // a multi-row sub-form UI; surface this as a JSON-array text
558            // input so the field is at least editable from the CLI. The
559            // dashboard renders these properly via the per-row editor.
560            let (prefill, placeholder) = if is_set {
561                (Some(current.as_str()), None)
562            } else {
563                (None, default)
564            };
565            match ui.string(prompt, prefill, placeholder).await? {
566                Answer::Back => return Ok(Nav::Back),
567                Answer::Value(new) => {
568                    if (is_set || !new.is_empty()) && new != current {
569                        persist(cfg, name, &new).await?;
570                    }
571                }
572            }
573        }
574        PropKind::Object => {
575            // Struct-shaped scalar (e.g. agents.<a>.workspace.access — a
576            // BTreeMap<AgentAlias, AccessMode>; or model_providers.<id>.pricing).
577            // Maps and structs are awkward as single-line input, so hand
578            // them to $EDITOR via the OnboardUi editor surface. RatatuiUi
579            // suspends, spawns $EDITOR with the current JSON value
580            // pretty-printed, and resumes on save — same key/value flow as
581            // editing any config file by hand. The dashboard renders a
582            // proper structured form via PropKind::Object.
583            let initial = if is_set {
584                pretty_print_object(&current).unwrap_or_else(|| current.clone())
585            } else {
586                default.map(str::to_string).unwrap_or_default()
587            };
588            let hint = format!(
589                "Editing {name}. Save and exit to apply, or quit without saving to keep the current value."
590            );
591            match ui.editor(&hint, &initial).await? {
592                Answer::Back => return Ok(Nav::Back),
593                Answer::Value(new) => {
594                    let normalized = compact_object(&new).unwrap_or_else(|| new.trim().to_string());
595                    if (is_set || !normalized.is_empty()) && normalized != current {
596                        persist(cfg, name, &normalized).await?;
597                    }
598                }
599            }
600        }
601    }
602    Ok(Nav::Done)
603}
604
605/// Iterate every field under `prefix` in `prop_fields()` and prompt for each.
606/// `excludes` lists leaf field names to skip. `defaults` carries per-field
607/// fallback values (e.g. provider-trait defaults) surfaced in the prompt
608/// when the field is unset. Rewinds on `Nav::Back`; propagates `Back` to
609/// the caller when the user rewinds past the first prompt.
610async fn prompt_fields_under(
611    cfg: &mut Config,
612    ui: &mut dyn OnboardUi,
613    prefix: &str,
614    excludes: &[&str],
615    defaults: &[FieldDefault],
616) -> Result<Nav> {
617    let names: Vec<String> = cfg
618        .prop_fields()
619        .into_iter()
620        .filter_map(|f| {
621            let suffix = f.name.strip_prefix(prefix)?.strip_prefix('.')?;
622            if suffix.contains('.') || excludes.contains(&suffix) {
623                return None;
624            }
625            Some(f.name.to_string())
626        })
627        .collect();
628    let mut i: usize = 0;
629    while i < names.len() {
630        let default = find_default(defaults, &names[i]);
631        match prompt_field(cfg, ui, &names[i], default).await? {
632            Nav::Done => i += 1,
633            Nav::Back => {
634                if i == 0 {
635                    return Ok(Nav::Back);
636                }
637                i -= 1;
638            }
639        }
640    }
641    Ok(Nav::Done)
642}
643
644/// Section-level skip gate. A section is "already configured" when EITHER
645/// (a) it has a marker in `onboard_state.completed_sections` (user finished
646/// the flow once), OR (b) the caller supplies a section-specific
647/// has-meaningful-config signal (e.g. providers has a fallback + api-key
648/// set). `--force` bypasses unconditionally.
649async fn skip_if_configured(
650    cfg: &Config,
651    ui: &mut dyn OnboardUi,
652    flags: &Flags,
653    section: Section,
654    label: &str,
655    has_signal: bool,
656) -> Result<SkipNav> {
657    if flags.force {
658        return Ok(SkipNav::Enter);
659    }
660    let key = section.as_str();
661    let seen = cfg
662        .onboard_state
663        .completed_sections
664        .iter()
665        .any(|s| s == key);
666    if !seen && !has_signal {
667        return Ok(SkipNav::Enter);
668    }
669    match ui
670        .confirm(
671            &format!("{label} is already configured. Reconfigure?"),
672            false,
673        )
674        .await?
675    {
676        Answer::Back => Ok(SkipNav::Back),
677        Answer::Value(true) => Ok(SkipNav::Enter),
678        Answer::Value(false) => Ok(SkipNav::Skip),
679    }
680}
681
682/// Per-section meaningful-config detector used as the secondary skip-gate
683/// signal alongside the completed_sections marker. Returns true when the
684/// section has values that can only come from user action (i.e. diverged
685/// from `Config::default()`'s idle state). Exhaustive over [`Section`]
686/// so adding a wizard variant forces a decision here.
687fn section_has_signal(cfg: &Config, section: Section) -> bool {
688    match section {
689        Section::ModelProviders => !cfg.providers.models.is_empty(),
690        // `channels.cli: bool` is a default-true scalar that lives directly
691        // under `channels.*`, so a bare `starts_with("channels.")` check
692        // fires on every fresh install. Require a nested channel config
693        // (e.g. `channels.telegram.bot-token`) — anything with a second dot
694        // segment — to count as user-driven signal.
695        Section::Channels => cfg.prop_fields().iter().any(|f| {
696            f.name
697                .strip_prefix("channels.")
698                .is_some_and(|rest| rest.contains('.'))
699        }),
700        Section::Hardware => cfg.hardware.enabled,
701        // Memory's default backend is "sqlite" and Tunnel's is "none" —
702        // both are valid user choices indistinguishable from untouched
703        // defaults. TTS / transcription providers and agents start
704        // empty; their existence in the typed family map IS the signal,
705        // not a derivable default-divergence. Marker-only for these.
706        Section::TtsProviders
707        | Section::TranscriptionProviders
708        | Section::Memory
709        | Section::Tunnel
710        | Section::Agents
711        | Section::Skills
712        | Section::SkillBundles
713        | Section::RiskProfiles
714        | Section::RuntimeProfiles
715        | Section::PeerGroups => false,
716        acknowledge_only_sections!() => false,
717    }
718}
719
720fn is_known_model_provider_name(model_provider: &str) -> bool {
721    let model_provider = model_provider.trim();
722    zeroclaw_providers::list_model_providers()
723        .iter()
724        .any(|entry| entry.name.eq_ignore_ascii_case(model_provider))
725}
726
727fn openai_compat_models_endpoint(base_url: &str) -> Result<reqwest::Url> {
728    let raw = base_url.trim();
729    if raw.is_empty() {
730        anyhow::bail!("OpenAI-compatible model discovery requires a base URL");
731    }
732
733    let mut endpoint = reqwest::Url::parse(raw)
734        .with_context(|| format!("OpenAI-compatible base URL is invalid: {raw}"))?;
735    if !matches!(endpoint.scheme(), "http" | "https") {
736        anyhow::bail!("OpenAI-compatible base URL must use http:// or https://");
737    }
738
739    let path = endpoint.path().trim_end_matches('/');
740    if path.ends_with("/models") {
741        endpoint.set_query(None);
742        endpoint.set_fragment(None);
743        return Ok(endpoint);
744    }
745
746    let suffix = if path.ends_with("/v1") || path.contains("/v1/") {
747        "models"
748    } else {
749        "v1/models"
750    };
751    let next_path = if path.is_empty() {
752        format!("/{suffix}")
753    } else {
754        format!("{path}/{suffix}")
755    };
756    endpoint.set_path(&next_path);
757    endpoint.set_query(None);
758    endpoint.set_fragment(None);
759    Ok(endpoint)
760}
761
762async fn discover_openai_compat_models(
763    base_url: &str,
764    api_key: Option<&str>,
765) -> Result<Vec<String>> {
766    discover_openai_compat_models_with_timeout(base_url, api_key, OPENAI_COMPAT_MODELS_TIMEOUT)
767        .await
768}
769
770async fn discover_openai_compat_models_with_timeout(
771    base_url: &str,
772    api_key: Option<&str>,
773    timeout: Duration,
774) -> Result<Vec<String>> {
775    let endpoint = openai_compat_models_endpoint(base_url)?;
776    let client = reqwest::Client::builder()
777        .timeout(timeout)
778        .build()
779        .context("failed to build OpenAI-compatible discovery client")?;
780
781    let mut request = client.get(endpoint.clone());
782    if let Some(key) = api_key.map(str::trim).filter(|key| !key.is_empty()) {
783        request = request.bearer_auth(key);
784    }
785
786    let response = request
787        .send()
788        .await
789        .with_context(|| format!("OpenAI-compatible model discovery request failed: {endpoint}"))?;
790    let status = response.status();
791    if !status.is_success() {
792        anyhow::bail!("OpenAI-compatible model discovery failed at {endpoint}: HTTP {status}");
793    }
794
795    let payload: OpenAiModelsResponse = response.json().await.with_context(|| {
796        format!("OpenAI-compatible model discovery returned invalid JSON: {endpoint}")
797    })?;
798    let models: Vec<String> = payload
799        .data
800        .into_iter()
801        .map(|model| model.id.trim().to_string())
802        .filter(|id| !id.is_empty())
803        .collect();
804    if models.is_empty() {
805        anyhow::bail!("OpenAI-compatible model discovery returned no model ids: {endpoint}");
806    }
807    Ok(models)
808}
809
810fn openai_compat_discovery_base_url(
811    model_provider: &str,
812    configured_base_url: Option<&str>,
813) -> Option<String> {
814    configured_base_url
815        .map(str::trim)
816        .filter(|url| !url.is_empty())
817        .map(ToString::to_string)
818        .or_else(|| {
819            model_provider
820                .trim()
821                .strip_prefix("custom:")
822                .map(str::trim)
823                .filter(|url| !url.is_empty())
824                .map(ToString::to_string)
825        })
826}
827
828async fn prompt_custom_openai_base_url(ui: &mut dyn OnboardUi) -> Result<Option<String>> {
829    loop {
830        match ui.string("OpenAI-compatible base URL", None, None).await? {
831            Answer::Back => return Ok(None),
832            Answer::Value(value) => {
833                let normalized = value.trim().trim_end_matches('/').to_string();
834                if openai_compat_models_endpoint(&normalized).is_ok() {
835                    return Ok(Some(normalized));
836                }
837                ui.note("Enter an http:// or https:// URL for an OpenAI-compatible API base.");
838            }
839        }
840    }
841}
842
843/// Record that a section finished so the next run's skip gate can fire.
844/// Prompt for an alias name, validating it in a loop until the user enters a
845/// valid value or backs out. Returns `Some(alias)` on success, `None` on Back.
846async fn prompt_alias_name(ui: &mut dyn OnboardUi, suggestion: &str) -> Result<Option<String>> {
847    loop {
848        // Alias suggestion is a default the user can accept by hitting
849        // Enter, not a pre-filled string to edit — surface it as the
850        // ghost-text placeholder so the input box is otherwise empty.
851        match ui
852            .string(
853                "Alias (name for this configuration)",
854                None,
855                Some(suggestion),
856            )
857            .await?
858        {
859            Answer::Back => return Ok(None),
860            Answer::Value(s) => {
861                let trimmed = if s.trim().is_empty() {
862                    suggestion.to_string()
863                } else {
864                    s.trim().to_string()
865                };
866                match zeroclaw_config::helpers::validate_alias_key(&trimmed) {
867                    Ok(()) => return Ok(Some(trimmed)),
868                    Err(msg) => ui.warn(&format!("Invalid alias: {msg}")),
869                }
870            }
871        }
872    }
873}
874
875async fn mark_completed(cfg: &mut Config, section: Section) -> Result<()> {
876    let key = section.as_str();
877    if cfg
878        .onboard_state
879        .completed_sections
880        .iter()
881        .any(|s| s == key)
882    {
883        return Ok(());
884    }
885    cfg.onboard_state.completed_sections.push(key.to_string());
886    cfg.mark_dirty("onboard-state.completed-sections");
887    cfg.save_dirty().await?;
888    Ok(())
889}
890
891// ── Sections ─────────────────────────────────────────────────────────────
892// Each section returns `Nav::Back` when the user hits Esc at the very first
893// prompt. Back from a later prompt within the section rewinds locally (via
894// prompt_fields_under / per-section loop), never propagates to the parent.
895
896async fn model_providers(cfg: &mut Config, ui: &mut dyn OnboardUi, flags: &Flags) -> Result<Nav> {
897    emit_section_header(ui, Section::ModelProviders, "Providers");
898
899    // Menu is driven by zeroclaw_providers::list_model_providers() — single source
900    // of truth for canonical names, display names, aliases.
901    let entries = zeroclaw_providers::list_model_providers();
902
903    loop {
904        let current_type = cfg.first_model_provider_type().unwrap_or("").to_string();
905
906        let (picked, selected_base_url) = match &flags.model_provider {
907            Some(forced) => (forced.clone(), None),
908            None => {
909                let current_idx = entries.iter().position(|p| p.name == current_type);
910                let mut options: Vec<SelectItem> = entries
911                    .iter()
912                    .map(|p| {
913                        let configured = cfg.providers.models.contains_model_provider_type(p.name);
914                        // "configured" describes the actual provider state —
915                        // at least one alias entry exists in
916                        // `[providers.models.<type>]`. The cursor already
917                        // shows which row the operator is hovering, so a
918                        // second "[active]" badge for current selection
919                        // was redundant and misleading.
920                        let badge = configured.then(|| "[configured]".into());
921                        SelectItem {
922                            label: p.display_name.to_string(),
923                            badge,
924                        }
925                    })
926                    .collect();
927                let custom_idx = options.len();
928                options.push(SelectItem::new(CUSTOM_OPENAI_COMPAT_LABEL));
929                // "Done" lets the user exit model_providers without picking one —
930                // matches the channels picker's escape hatch. Highlight it
931                // by default when no fallback is set yet (first-time setup).
932                let done_idx = options.len();
933                options.push(SelectItem::new("Done"));
934                let initial = current_idx.or(Some(done_idx));
935                let idx = match ui.select("ModelProvider", &options, initial).await? {
936                    Answer::Back => return Ok(Nav::Back),
937                    Answer::Value(idx) => idx,
938                };
939                if idx == done_idx {
940                    break;
941                }
942                if idx == custom_idx {
943                    let Some(base_url) = prompt_custom_openai_base_url(ui).await? else {
944                        continue;
945                    };
946                    ("custom".to_string(), Some(base_url))
947                } else {
948                    (entries[idx].name.to_string(), None)
949                }
950            }
951        };
952
953        // Anchor the breadcrumb to the chosen provider as early as
954        // possible — every prompt that follows (alias, auth, model,
955        // advanced settings) reads against this header. Without the
956        // up-front set, the alias prompt rendered under a generic
957        // "Providers" breadcrumb with no provider-name context.
958        let display_name = entries
959            .iter()
960            .find(|p| p.name == picked)
961            .map(|p| p.display_name)
962            .unwrap_or_else(|| {
963                if picked == "custom" {
964                    CUSTOM_OPENAI_COMPAT_LABEL
965                } else {
966                    picked.as_str()
967                }
968            });
969        ui.heading(2, display_name);
970
971        // When --model-provider is forced via CLI flags skip the alias prompt.
972        // Otherwise show existing aliases as a selectable list with "+ Add new".
973        let alias = if flags.model_provider.is_some() {
974            "default".to_string()
975        } else {
976            let existing_aliases: Vec<String> = cfg
977                .get_map_keys(&format!("providers.models.{picked}"))
978                .unwrap_or_default();
979            if existing_aliases.is_empty() {
980                // Override the API-key help text inherited from the
981                // section intro with alias-specific guidance.
982                ui.note(&format!(
983                    "Short identifier for this {display_name} configuration. \
984                     Letters, digits, underscores. Empty = use the suggested default."
985                ));
986                let Some(a) = prompt_alias_name(ui, "default").await? else {
987                    continue;
988                };
989                a
990            } else {
991                let mut alias_options: Vec<SelectItem> = existing_aliases
992                    .iter()
993                    .map(|a| SelectItem::new(a.clone()))
994                    .collect();
995                let add_new_idx = alias_options.len();
996                alias_options.push(SelectItem::new("+ Add new"));
997                let alias_idx = match ui.select("Alias", &alias_options, Some(0)).await? {
998                    Answer::Back => continue,
999                    Answer::Value(i) => i,
1000                };
1001                if alias_idx == add_new_idx {
1002                    ui.note(&format!(
1003                        "Short identifier for this {display_name} configuration. \
1004                         Letters, digits, underscores. Empty = use the suggested default."
1005                    ));
1006                    let suggestion = format!("{}-2", existing_aliases[0]);
1007                    let Some(a) = prompt_alias_name(ui, &suggestion).await? else {
1008                        continue;
1009                    };
1010                    a
1011                } else {
1012                    existing_aliases[alias_idx].clone()
1013                }
1014            }
1015        };
1016
1017        // Seed the HashMap entry in memory so `prop_fields` can enumerate
1018        // its fields for the prompts below. Not persisted here — the first
1019        // `persist()` for a real value (api_key, model, …) carries it to
1020        // disk. If the user backs out before any value is set, the back
1021        // paths drop the entry so it never reaches the file.
1022        let is_new_entry = cfg.providers.models.find(&picked, &alias).is_none();
1023        cfg.providers.models.ensure(&picked, &alias);
1024
1025        // Per-family typed configs now derive their own default endpoint
1026        // URI via the `ModelEndpoint` trait at runtime construction time.
1027        // The pre-Phase-6 `apply_provider_trait_defaults` walk that copied
1028        // `default_provider_config` field values into the new entry is gone
1029        // — operator-set fields ride the typed config's `Default` impl, and
1030        // family endpoint resolution happens family-side rather than via
1031        // pre-populated entry fields.
1032        if let Some(base_url) = selected_base_url.as_deref() {
1033            cfg.set_prop_persistent(&format!("providers.models.{picked}.{alias}.uri"), base_url)?;
1034        }
1035
1036        // (display_name + heading set up-front, immediately after the
1037        // provider type was picked, so the alias prompt also sees it.)
1038
1039        // Apply CLI-flag overrides up front, then skip those names in the
1040        // interactive pass so the user isn't re-prompted for what they already
1041        // passed on the command line.
1042        let prefix = format!("providers.models.{picked}.{alias}");
1043        let api_key_path = format!("{prefix}.api-key");
1044        if let Some(api_key) = &flags.api_key {
1045            persist(cfg, &api_key_path, api_key).await?;
1046            // An explicit --api-key flag means the user wants standard API-key
1047            // auth. If this alias was previously configured for Codex subscription
1048            // auth, clear that flag so runtime dispatch stops routing to
1049            // OpenAiCodexModelProvider.
1050            if picked == "openai" {
1051                persist(cfg, &format!("{prefix}.requires-openai-auth"), "false").await?;
1052            }
1053        }
1054        if let Some(model) = &flags.model {
1055            persist(cfg, &format!("{prefix}.model"), model).await?;
1056        }
1057
1058        // Authentication phase is prompted explicitly so the user sees a
1059        // clear "API key" step, not a generic `api-key (stored, replace?)`
1060        // lost among other fields. The heading(2) also overrides the
1061        // model_provider subsection so the panel reads "Providers › Authentication".
1062        if flags.api_key.is_none() {
1063            ui.heading(2, &format!("{display_name} › Authentication"));
1064
1065            // OpenAI supports two auth modes: standard API key (platform.openai.com)
1066            // and Codex subscription (ChatGPT Plus/Pro OAuth, no API key needed).
1067            // Offer the choice before prompting for credentials.
1068            if picked == "openai" {
1069                let currently_codex = cfg
1070                    .providers
1071                    .models
1072                    .find("openai", &alias)
1073                    .map(|c| c.requires_openai_auth)
1074                    .unwrap_or(false);
1075                ui.note(&i18n::get_required_cli_string("onboard-openai-auth-note"));
1076                let auth_prompt = i18n::get_required_cli_string("onboard-openai-auth-prompt");
1077                let auth_items = [
1078                    SelectItem::new(i18n::get_required_cli_string("onboard-openai-auth-api-key")),
1079                    SelectItem::new(i18n::get_required_cli_string("onboard-openai-auth-codex")),
1080                ];
1081                let auth_default = if currently_codex { Some(1) } else { Some(0) };
1082                let codex_chosen = match ui.select(&auth_prompt, &auth_items, auth_default).await? {
1083                    Answer::Back => {
1084                        if flags.model_provider.is_some() {
1085                            return Ok(Nav::Back);
1086                        }
1087                        if is_new_entry {
1088                            cfg.providers.models.remove_alias(&picked, &alias);
1089                            cfg.mark_dirty(&format!("providers.models.{picked}.{alias}"));
1090                        }
1091                        continue;
1092                    }
1093                    Answer::Value(1) => true,
1094                    Answer::Value(_) => false,
1095                };
1096                if codex_chosen {
1097                    persist(cfg, &format!("{prefix}.requires-openai-auth"), "true").await?;
1098                    persist(cfg, &format!("{prefix}.wire-api"), "responses").await?;
1099                    ui.note(&i18n::get_required_cli_string(
1100                        "onboard-openai-codex-followup",
1101                    ));
1102                } else {
1103                    if currently_codex {
1104                        persist(cfg, &format!("{prefix}.requires-openai-auth"), "false").await?;
1105                    }
1106                    match prompt_field(cfg, ui, &api_key_path, None).await? {
1107                        Nav::Back => {
1108                            if flags.model_provider.is_some() {
1109                                return Ok(Nav::Back);
1110                            }
1111                            if is_new_entry {
1112                                cfg.providers.models.remove_alias(&picked, &alias);
1113                                cfg.mark_dirty(&format!("providers.models.{picked}.{alias}"));
1114                            }
1115                            continue;
1116                        }
1117                        Nav::Done => {}
1118                    }
1119                }
1120            } else {
1121                match prompt_field(cfg, ui, &api_key_path, None).await? {
1122                    Nav::Back => {
1123                        if flags.model_provider.is_some() {
1124                            return Ok(Nav::Back);
1125                        }
1126                        if is_new_entry {
1127                            cfg.providers.models.remove_alias(&picked, &alias);
1128                            cfg.mark_dirty(&format!("providers.models.{picked}.{alias}"));
1129                        }
1130                        continue;
1131                    }
1132                    Nav::Done => {}
1133                }
1134            }
1135            ui.heading(2, display_name);
1136        }
1137
1138        if flags.model.is_none() {
1139            ui.heading(2, &format!("{display_name} › Model"));
1140            match prompt_model(cfg, ui, &prefix).await? {
1141                Nav::Back => {
1142                    if flags.model_provider.is_some() {
1143                        return Ok(Nav::Back);
1144                    }
1145                    if is_new_entry {
1146                        cfg.providers.models.remove_alias(&picked, &alias);
1147                        cfg.mark_dirty(&format!("providers.models.{picked}.{alias}"));
1148                    }
1149                    continue;
1150                }
1151                Nav::Done => {}
1152            }
1153            ui.heading(2, display_name);
1154        }
1155
1156        // Advanced settings (temperature, timeout, base-url override,
1157        // wire-api, etc.) are gated behind an opt-in. Most users never
1158        // touch these, and the trait-level defaults are sensible.
1159        match offer_advanced_settings(cfg, ui, &prefix).await? {
1160            Nav::Back => {
1161                if flags.model_provider.is_some() {
1162                    return Ok(Nav::Back);
1163                }
1164                continue;
1165            }
1166            Nav::Done => {}
1167        }
1168
1169        break;
1170    }
1171
1172    mark_completed(cfg, Section::ModelProviders).await?;
1173    Ok(Nav::Done)
1174}
1175
1176/// Opt-in gate for the per-provider advanced field sweep. Default N so the
1177/// user breezes through onboarding after auth + model; Y walks them through
1178/// every remaining field (temperature, max_tokens, timeout_secs, base_url,
1179/// wire_api, azure_*, etc.) with the model_provider's trait defaults pre-filled.
1180async fn offer_advanced_settings(
1181    cfg: &mut Config,
1182    ui: &mut dyn OnboardUi,
1183    prefix: &str,
1184) -> Result<Nav> {
1185    ui.heading(2, "Advanced settings");
1186    ui.note(
1187        "Temperature, timeout, base-URL override, wire protocol, etc. The \
1188         model_provider's own defaults are used when these are left unset — skip \
1189         unless you need to override something specific.",
1190    );
1191    match ui.confirm("Configure advanced settings?", false).await? {
1192        Answer::Back => return Ok(Nav::Back),
1193        Answer::Value(false) => return Ok(Nav::Done),
1194        Answer::Value(true) => {}
1195    }
1196
1197    // Per-family typed configs only carry their own family-applicable fields,
1198    // so no per-family exclude list is needed (vs. pre-#6273 when one flat
1199    // ModelProviderConfig had every family's fields jumbled together).
1200    // Excluded: `model` (already prompted via prompt_model) and `api-key`
1201    // (explicit auth phase).
1202    let excludes: Vec<&str> = vec!["model", "api-key"];
1203
1204    // Surface per-field defaults as ghost-text placeholders so the
1205    // operator sees "this is what we'll use if you hit Enter" instead
1206    // of an empty box. URI default comes from the family's
1207    // `FamilyEndpoint::endpoint_uri()` impl; temperature/timeout have
1208    // hardcoded sensible values that match the runtime fallbacks in
1209    // the provider factory.
1210    let mut defaults: Vec<FieldDefault> = Vec::new();
1211    if let Some((type_k, alias_k)) = prefix
1212        .strip_prefix("providers.models.")
1213        .and_then(|rest| rest.split_once('.'))
1214    {
1215        // `resolved_endpoint_uri` only returns Some for multi-region
1216        // families; fall back to the family's canonical default.
1217        let uri = cfg
1218            .providers
1219            .models
1220            .resolved_endpoint_uri(type_k, alias_k)
1221            .map(str::to_string)
1222            .or_else(|| zeroclaw_providers::default_model_provider_url(type_k).map(str::to_string));
1223        if let Some(uri) = uri {
1224            defaults.push(FieldDefault {
1225                path: format!("{prefix}.uri"),
1226                display: uri,
1227            });
1228        }
1229    }
1230    defaults.push(FieldDefault {
1231        path: format!("{prefix}.temperature"),
1232        display: "0.7".to_string(),
1233    });
1234    defaults.push(FieldDefault {
1235        path: format!("{prefix}.timeout-secs"),
1236        display: "120".to_string(),
1237    });
1238
1239    prompt_fields_under(cfg, ui, prefix, &excludes, &defaults).await
1240}
1241
1242/// Prompt for the model field using the model_provider's live model catalog.
1243///
1244/// Calls `ModelProvider::list_models()` (no auth — see `zeroclaw-providers`
1245/// models_dev + native public endpoints). Falls back to a manual string
1246/// input when the model_provider doesn't expose a no-auth list or the fetch fails.
1247/// `prefix` is the full alias-level path: `model_providers.<type>.<alias>`.
1248async fn prompt_model(cfg: &mut Config, ui: &mut dyn OnboardUi, prefix: &str) -> Result<Nav> {
1249    let model_path = format!("{prefix}.model");
1250    let current = cfg.get_prop(&model_path).unwrap_or_default();
1251    let is_set = !current.is_empty() && current != "<unset>";
1252    // Extract type and alias from "providers.models.<type>.<alias>".
1253    let (model_provider, profile) = match prefix.strip_prefix("providers.models.") {
1254        Some(rest) => {
1255            if let Some((type_k, alias_k)) = rest.split_once('.') {
1256                let profile = cfg.providers.models.find(type_k, alias_k);
1257                (type_k.to_string(), profile)
1258            } else {
1259                (rest.to_string(), None)
1260            }
1261        }
1262        None => (prefix.to_string(), None),
1263    };
1264    let api_key = profile.and_then(|entry| entry.api_key.as_deref());
1265    let configured_uri = profile.and_then(|entry| entry.uri.as_deref());
1266    let discovery_base_url = openai_compat_discovery_base_url(&model_provider, configured_uri);
1267    let should_try_openai_compat =
1268        model_provider.trim() == "custom" || !is_known_model_provider_name(&model_provider);
1269
1270    let catalog_models = match zeroclaw_providers::create_model_provider(&model_provider, None) {
1271        Ok(handle) => {
1272            ui.status("Fetching models...");
1273            match handle.list_models().await {
1274                Ok(models) => Some(models),
1275                Err(e) => {
1276                    ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"model_provider": model_provider, "error": format!("{}", e)})), "models.dev catalog fetch failed");
1277                    None
1278                }
1279            }
1280        }
1281        Err(e) => {
1282            ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"model_provider": model_provider, "error": format!("{}", e)})), "model_provider construction failed for model-list probe");
1283            None
1284        }
1285    };
1286    let live_models = match catalog_models.filter(|ms| !ms.is_empty()) {
1287        Some(models) => Some(models),
1288        None if should_try_openai_compat => {
1289            if let Some(base_url) = discovery_base_url.as_deref() {
1290                ui.status("Fetching models from /v1/models...");
1291                match discover_openai_compat_models(base_url, api_key).await {
1292                    Ok(models) => Some(models),
1293                    Err(e) => {
1294                        ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"model_provider": model_provider, "base_url": base_url, "error": format!("{}", e)})), "OpenAI-compatible model discovery failed");
1295                        None
1296                    }
1297                }
1298            } else {
1299                None
1300            }
1301        }
1302        None => None,
1303    };
1304    // Final fallback: query the per-family catalog source table directly.
1305    // Covers providers with typed required fields (Azure resource,
1306    // Bedrock region, …) the operator hasn't populated yet — provider
1307    // construction bails before list_models can run, so we go around it.
1308    let live_models = match live_models {
1309        Some(ms) => Some(ms),
1310        None => match zeroclaw_providers::catalog::list_models_for_family(&model_provider).await {
1311            Ok(ms) if !ms.is_empty() => {
1312                ui.status("");
1313                Some(ms)
1314            }
1315            Ok(_) | Err(_) => None,
1316        },
1317    };
1318    // Both fetch paths above are best-effort; clear the "Fetching..."
1319    // status so it doesn't linger as a stale banner in the TUI log
1320    // pane once the user has moved past the model picker.
1321    ui.status("");
1322
1323    let new_value = match live_models {
1324        Some(models) => {
1325            let items: Vec<SelectItem> = models.iter().map(SelectItem::new).collect();
1326            let current_idx = models.iter().position(|m| m == &current);
1327            match ui.select("Model", &items, current_idx).await? {
1328                Answer::Back => return Ok(Nav::Back),
1329                Answer::Value(idx) => models[idx].clone(),
1330            }
1331        }
1332        None => {
1333            // Live fetch failed or returned empty (model_provider doesn't expose
1334            // a no-auth listing). The underlying error was traced at debug
1335            // level; surface a short provider-named nudge to the user and
1336            // fall back to manual entry.
1337            ui.note(&format!(
1338                "Catalog lookup failed for {model_provider} — enter a model id manually \
1339                 (see the model_provider's docs for the exact format)."
1340            ));
1341            let prefill = if is_set { Some(current.as_str()) } else { None };
1342            match ui.string("Model id", prefill, None).await? {
1343                Answer::Back => return Ok(Nav::Back),
1344                Answer::Value(v) => v,
1345            }
1346        }
1347    };
1348
1349    if new_value != current && !new_value.is_empty() {
1350        persist(cfg, &model_path, &new_value).await?;
1351    }
1352    Ok(Nav::Done)
1353}
1354
1355async fn channels(cfg: &mut Config, ui: &mut dyn OnboardUi, _flags: &Flags) -> Result<Nav> {
1356    emit_section_header(ui, Section::Channels, "Channels");
1357    loop {
1358        // Master list of all channels that exist in the schema, derived from
1359        // the static map_key_sections() metadata. Feature-gated channels drop
1360        // out automatically because their fields aren't registered.
1361        let all_channels: Vec<String> = {
1362            let prefix = "channels.";
1363            zeroclaw_config::schema::Config::map_key_sections()
1364                .into_iter()
1365                .filter_map(|s| {
1366                    s.path
1367                        .strip_prefix(prefix)
1368                        .filter(|rest| !rest.contains('.'))
1369                        .map(String::from)
1370                })
1371                .collect::<std::collections::BTreeSet<_>>()
1372                .into_iter()
1373                .collect()
1374        };
1375        // A channel type is "configured" if the live config has any prop fields under it.
1376        let live_fields: Vec<String> = cfg.prop_fields().into_iter().map(|f| f.name).collect();
1377        let configured: std::collections::BTreeSet<String> = all_channels
1378            .iter()
1379            .filter(|name| {
1380                let prefix = format!("channels.{name}.");
1381                live_fields.iter().any(|f| f.starts_with(&prefix))
1382            })
1383            .cloned()
1384            .collect();
1385
1386        let mut options: Vec<SelectItem> = all_channels
1387            .iter()
1388            .map(|name| {
1389                // Match the model_providers picker's two-tier badge: `[active]`
1390                // wins when the block exists AND `<channel>.enabled = true`,
1391                // otherwise `[configured]` for a present-but-disabled block.
1392                // Web `/onboard` renders the same tiers via
1393                // `schema_walk_picker` in `crates/zeroclaw-gateway/src/api_onboard.rs`.
1394                let is_active = live_fields.iter().any(|f| {
1395                    f.starts_with(&format!("channels.{name}."))
1396                        && f.ends_with(".enabled")
1397                        && cfg.get_prop(f).ok().as_deref() == Some("true")
1398                });
1399                if is_active {
1400                    SelectItem::with_badge(name.clone(), "[active]")
1401                } else if configured.contains(name) {
1402                    SelectItem::with_badge(name.clone(), "[configured]")
1403                } else {
1404                    SelectItem::new(name.clone())
1405                }
1406            })
1407            .collect();
1408        let done_idx = options.len();
1409        options.push(SelectItem::new("Done"));
1410
1411        let idx = match ui.select("Channel", &options, Some(done_idx)).await? {
1412            Answer::Back => return Ok(Nav::Back),
1413            Answer::Value(i) => i,
1414        };
1415        if idx == done_idx {
1416            break;
1417        }
1418
1419        let picked = &all_channels[idx];
1420        // Show existing aliases as selectable; "+ Add new" appended at the end.
1421        let existing_aliases: Vec<String> = cfg
1422            .get_map_keys(&format!("channels.{picked}"))
1423            .unwrap_or_default();
1424        let alias = if existing_aliases.is_empty() {
1425            let Some(a) = prompt_alias_name(ui, "default").await? else {
1426                continue;
1427            };
1428            a
1429        } else {
1430            let mut alias_options: Vec<SelectItem> = existing_aliases
1431                .iter()
1432                .map(|a| SelectItem::new(a.clone()))
1433                .collect();
1434            let add_new_idx = alias_options.len();
1435            alias_options.push(SelectItem::new("+ Add new"));
1436            let alias_idx = match ui.select("Alias", &alias_options, Some(0)).await? {
1437                Answer::Back => continue,
1438                Answer::Value(i) => i,
1439            };
1440            if alias_idx == add_new_idx {
1441                let suggestion = format!("{}-2", existing_aliases[0]);
1442                let Some(a) = prompt_alias_name(ui, &suggestion).await? else {
1443                    continue;
1444                };
1445                a
1446            } else {
1447                existing_aliases[alias_idx].clone()
1448            }
1449        };
1450        cfg.create_map_key(&format!("channels.{picked}"), &alias)
1451            .ok();
1452        let prefix = format!("channels.{picked}.{alias}");
1453        cfg.mark_dirty(&prefix);
1454        cfg.save_dirty().await?;
1455        ui.heading(2, picked);
1456        // Back inside a channel's subfields bounces to the channel list
1457        // (not to the previous section) — user is still inside Channels.
1458        let _ = prompt_fields_under(cfg, ui, &prefix, &[], &[]).await?;
1459    }
1460    mark_completed(cfg, Section::Channels).await?;
1461    Ok(Nav::Done)
1462}
1463
1464async fn memory(cfg: &mut Config, ui: &mut dyn OnboardUi, flags: &Flags) -> Result<Nav> {
1465    emit_section_header(ui, Section::Memory, "Memory");
1466    if flags.memory.is_none() {
1467        match skip_if_configured(
1468            cfg,
1469            ui,
1470            flags,
1471            Section::Memory,
1472            "Memory",
1473            section_has_signal(cfg, Section::Memory),
1474        )
1475        .await?
1476        {
1477            SkipNav::Skip => return Ok(Nav::Done),
1478            SkipNav::Back => return Ok(Nav::Back),
1479            SkipNav::Enter => {}
1480        }
1481    }
1482    let backends = zeroclaw_memory::selectable_memory_backends();
1483    let current_backend = cfg.memory.backend.clone();
1484    let new_backend = match &flags.memory {
1485        Some(forced) => forced.clone(),
1486        None => {
1487            let options: Vec<SelectItem> =
1488                backends.iter().map(|b| SelectItem::new(b.label)).collect();
1489            let current_idx = backends.iter().position(|b| b.key == current_backend);
1490            match ui.select("Memory backend", &options, current_idx).await? {
1491                Answer::Back => return Ok(Nav::Back),
1492                Answer::Value(idx) => backends[idx].key.to_string(),
1493            }
1494        }
1495    };
1496    if new_backend != current_backend {
1497        persist(cfg, "memory.backend", &new_backend).await?;
1498    }
1499
1500    // Back on auto-save bounces to the backend picker (consumed).
1501    let _ = prompt_field(cfg, ui, "memory.auto-save", None).await?;
1502    mark_completed(cfg, Section::Memory).await?;
1503    Ok(Nav::Done)
1504}
1505
1506async fn hardware(cfg: &mut Config, ui: &mut dyn OnboardUi, flags: &Flags) -> Result<Nav> {
1507    emit_section_header(ui, Section::Hardware, "Hardware");
1508    match skip_if_configured(
1509        cfg,
1510        ui,
1511        flags,
1512        Section::Hardware,
1513        "Hardware",
1514        section_has_signal(cfg, Section::Hardware),
1515    )
1516    .await?
1517    {
1518        SkipNav::Skip => return Ok(Nav::Done),
1519        SkipNav::Back => return Ok(Nav::Back),
1520        SkipNav::Enter => {}
1521    }
1522
1523    loop {
1524        match prompt_field(cfg, ui, "hardware.enabled", None).await? {
1525            Nav::Back => return Ok(Nav::Back),
1526            Nav::Done => {}
1527        }
1528        if cfg.hardware.enabled {
1529            match prompt_fields_under(cfg, ui, "hardware", &["enabled"], &[]).await? {
1530                Nav::Back => continue,
1531                Nav::Done => break,
1532            }
1533        } else {
1534            break;
1535        }
1536    }
1537    mark_completed(cfg, Section::Hardware).await?;
1538    Ok(Nav::Done)
1539}
1540
1541async fn tunnel(cfg: &mut Config, ui: &mut dyn OnboardUi, flags: &Flags) -> Result<Nav> {
1542    emit_section_header(ui, Section::Tunnel, "Tunnel");
1543    match skip_if_configured(
1544        cfg,
1545        ui,
1546        flags,
1547        Section::Tunnel,
1548        "Tunnel",
1549        section_has_signal(cfg, Section::Tunnel),
1550    )
1551    .await?
1552    {
1553        SkipNav::Skip => return Ok(Nav::Done),
1554        SkipNav::Back => return Ok(Nav::Back),
1555        SkipNav::Enter => {}
1556    }
1557
1558    loop {
1559        // ModelProvider list derived from the schema: each `tunnel.<name>.*` field
1560        // in prop_fields() names a real model_provider. "none" is always valid and
1561        // has no sub-config, so it's prepended.
1562        let mut provider_names: Vec<String> = cfg
1563            .prop_fields()
1564            .iter()
1565            .filter_map(|f| f.name.strip_prefix("tunnel."))
1566            .filter_map(|suffix| suffix.split_once('.').map(|(head, _)| head.to_string()))
1567            .collect::<std::collections::BTreeSet<_>>()
1568            .into_iter()
1569            .collect();
1570        provider_names.insert(0, "none".to_string());
1571
1572        let options: Vec<SelectItem> = provider_names.iter().map(SelectItem::new).collect();
1573        let current_model_provider = cfg.tunnel.tunnel_provider.clone();
1574        let current_idx = provider_names
1575            .iter()
1576            .position(|p| p == &current_model_provider);
1577        let idx = match ui
1578            .select("Public tunnel model_provider", &options, current_idx)
1579            .await?
1580        {
1581            Answer::Back => return Ok(Nav::Back),
1582            Answer::Value(i) => i,
1583        };
1584        let new_model_provider = provider_names[idx].clone();
1585
1586        if new_model_provider != current_model_provider {
1587            persist(cfg, "tunnel.tunnel-provider", &new_model_provider).await?;
1588        }
1589
1590        if new_model_provider == "none" {
1591            break;
1592        }
1593
1594        let prefix = format!("tunnel.{new_model_provider}");
1595        cfg.init_defaults(Some(&prefix));
1596        cfg.mark_dirty(&prefix);
1597        cfg.save_dirty().await?;
1598        ui.heading(2, &new_model_provider);
1599        match prompt_fields_under(cfg, ui, &prefix, &[], &[]).await? {
1600            Nav::Back => continue,
1601            Nav::Done => break,
1602        }
1603    }
1604    mark_completed(cfg, Section::Tunnel).await?;
1605    Ok(Nav::Done)
1606}
1607
1608async fn agents(cfg: &mut Config, ui: &mut dyn OnboardUi, _flags: &Flags) -> Result<Nav> {
1609    emit_section_header(ui, Section::Agents, "Agents");
1610    loop {
1611        let existing_aliases: Vec<String> = cfg.get_map_keys("agents").unwrap_or_default();
1612        let mut options: Vec<SelectItem> = existing_aliases
1613            .iter()
1614            .map(|a| {
1615                let enabled_path = format!("agents.{a}.enabled");
1616                let is_active = cfg.get_prop(&enabled_path).ok().as_deref() == Some("true");
1617                if is_active {
1618                    SelectItem::with_badge(a.clone(), "[active]")
1619                } else {
1620                    SelectItem::with_badge(a.clone(), "[configured]")
1621                }
1622            })
1623            .collect();
1624        let add_new_idx = options.len();
1625        options.push(SelectItem::new("+ Add new"));
1626        let done_idx = options.len();
1627        options.push(SelectItem::new("Done"));
1628
1629        let idx = match ui.select("Agent", &options, Some(done_idx)).await? {
1630            Answer::Back => return Ok(Nav::Back),
1631            Answer::Value(i) => i,
1632        };
1633
1634        if idx == done_idx {
1635            break;
1636        }
1637
1638        let alias = if idx == add_new_idx {
1639            let suggestion = next_agent_alias_suggestion(&existing_aliases);
1640            let Some(a) = prompt_alias_name(ui, &suggestion).await? else {
1641                continue;
1642            };
1643            a
1644        } else {
1645            existing_aliases[idx].clone()
1646        };
1647
1648        cfg.create_map_key("agents", &alias).ok();
1649        cfg.mark_dirty(&format!("agents.{alias}"));
1650        cfg.save_dirty().await?;
1651        let workspace_dir = cfg.agent_workspace_dir(&alias);
1652        if let Err(err) = tokio::fs::create_dir_all(&workspace_dir).await {
1653            ui.warn(&format!(
1654                "Could not create agent workspace at {}: {err}",
1655                workspace_dir.display()
1656            ));
1657        } else if let Err(err) =
1658            zeroclaw_config::schema::ensure_bootstrap_files(&workspace_dir).await
1659        {
1660            ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"agent": alias, "workspace": workspace_dir.display().to_string(), "err": err.to_string()})), "bootstrap file seed failed (continuing): ");
1661        }
1662        ui.heading(2, &alias);
1663        let _ = prompt_agent_fields(cfg, ui, &alias).await?;
1664    }
1665    mark_completed(cfg, Section::Agents).await?;
1666    Ok(Nav::Done)
1667}
1668
1669/// Generic OneTierAliasMap section walker — used by skill-bundles,
1670/// risk-profiles, runtime-profiles, peer-groups. Lists existing aliases,
1671/// lets the operator add a new one, and recurses into the alias's fields
1672/// via `prompt_fields_under`.
1673async fn one_tier_alias_section(
1674    cfg: &mut Config,
1675    ui: &mut dyn OnboardUi,
1676    section: Section,
1677    section_path: &str,
1678    select_label: &str,
1679) -> Result<Nav> {
1680    emit_section_header(ui, section, select_label);
1681    loop {
1682        let existing: Vec<String> = cfg.get_map_keys(section_path).unwrap_or_default();
1683        let mut options: Vec<SelectItem> = existing
1684            .iter()
1685            .map(|a| SelectItem::new(a.clone()))
1686            .collect();
1687        let add_new_idx = options.len();
1688        options.push(SelectItem::new("+ Add new"));
1689        let done_idx = options.len();
1690        options.push(SelectItem::new("Done"));
1691
1692        let idx = match ui.select(select_label, &options, Some(done_idx)).await? {
1693            Answer::Back => return Ok(Nav::Back),
1694            Answer::Value(i) => i,
1695        };
1696
1697        if idx == done_idx {
1698            break;
1699        }
1700
1701        let alias = if idx == add_new_idx {
1702            let suggestion = next_agent_alias_suggestion(&existing);
1703            let Some(a) = prompt_alias_name(ui, &suggestion).await? else {
1704                continue;
1705            };
1706            a
1707        } else {
1708            existing[idx].clone()
1709        };
1710
1711        cfg.create_map_key(section_path, &alias).ok();
1712        let prefix = format!("{section_path}.{alias}");
1713        cfg.mark_dirty(&prefix);
1714        cfg.save_dirty().await?;
1715        ui.heading(2, &alias);
1716        let _ = prompt_fields_under(cfg, ui, &prefix, &[], &[]).await?;
1717    }
1718    mark_completed(cfg, section).await?;
1719    Ok(Nav::Done)
1720}
1721
1722async fn skills(cfg: &mut Config, ui: &mut dyn OnboardUi, _flags: &Flags) -> Result<Nav> {
1723    emit_section_header(ui, Section::Skills, "Skills");
1724    let nav = prompt_fields_under(cfg, ui, "skills", &[], &[]).await?;
1725    if matches!(nav, Nav::Back) {
1726        return Ok(Nav::Back);
1727    }
1728    mark_completed(cfg, Section::Skills).await?;
1729    Ok(Nav::Done)
1730}
1731
1732/// Suggest the next unused alias when the operator picks "+ Add new".
1733/// On a fresh install, suggests "default". With one existing alias, suggests
1734/// `{first}-2`. From there, increments until an unused suffix is found so
1735/// adding the 4th+ agent doesn't pre-fill an alias that already exists.
1736fn next_agent_alias_suggestion(existing: &[String]) -> String {
1737    if existing.is_empty() {
1738        return "default".to_string();
1739    }
1740    let base = existing[0].as_str();
1741    (2..)
1742        .map(|n| format!("{base}-{n}"))
1743        .find(|candidate| !existing.contains(candidate))
1744        .unwrap_or_else(|| format!("{base}-{}", existing.len() + 1))
1745}
1746
1747/// Build the canonical schema path for a field on an agent alias entry.
1748/// `set_prop` / `get_prop` and the schema's `KNOWN` table always use
1749/// kebab-case field names (the `Configurable` derive does
1750/// `snake_to_kebab` at compile time), so callers passing snake-cased
1751/// field names (matching the Rust struct fields) need this conversion.
1752/// Centralized so future additions can't reintroduce the snake/kebab
1753/// drift bug.
1754fn agent_field_path(alias: &str, snake_field: &str) -> String {
1755    let kebab = snake_field.replace('_', "-");
1756    format!("agents.{alias}.{kebab}")
1757}
1758
1759/// Walk the fields under `agents.<alias>` with prompts tailored to each
1760/// field's role: bool/text via the generic `prompt_field`, the system
1761/// prompt via `$EDITOR`, and every alias-reference field (channels,
1762/// model_provider, risk_profile, …) via a picker over the relevant
1763/// configured aliases. Rewinds with `Nav::Back`.
1764async fn prompt_agent_fields(cfg: &mut Config, ui: &mut dyn OnboardUi, alias: &str) -> Result<Nav> {
1765    let channel_aliases = available_channel_aliases(cfg);
1766    let provider_aliases = available_model_provider_aliases(cfg);
1767    // Paths must be kebab-case — the macro at
1768    // crates/zeroclaw-macros/src/lib.rs:366 builds get_map_keys arms with
1769    // snake_to_kebab field names. Snake_case here silently returns None →
1770    // empty Vec → CLI picker shows the wrong "no aliases" state when the
1771    // user has configured some.
1772    let risk_aliases = cfg.get_map_keys("risk-profiles").unwrap_or_default();
1773    let runtime_aliases = cfg.get_map_keys("runtime-profiles").unwrap_or_default();
1774    let skill_aliases = cfg.get_map_keys("skill-bundles").unwrap_or_default();
1775    let knowledge_aliases = cfg.get_map_keys("knowledge-bundles").unwrap_or_default();
1776    let mcp_aliases = cfg.get_map_keys("mcp-bundles").unwrap_or_default();
1777
1778    let mut step: usize = 0;
1779    loop {
1780        let nav = match step {
1781            0 => prompt_field(cfg, ui, &agent_field_path(alias, "enabled"), None).await?,
1782            1 => prompt_agent_system_prompt(cfg, ui, alias).await?,
1783            2 => prompt_agent_alias_multi(cfg, ui, alias, "channels", &channel_aliases).await?,
1784            3 => {
1785                prompt_agent_alias_single(cfg, ui, alias, "model_provider", &provider_aliases)
1786                    .await?
1787            }
1788            4 => prompt_agent_alias_single(cfg, ui, alias, "risk_profile", &risk_aliases).await?,
1789            5 => {
1790                prompt_agent_alias_single(cfg, ui, alias, "runtime_profile", &runtime_aliases)
1791                    .await?
1792            }
1793            6 => prompt_agent_alias_multi(cfg, ui, alias, "skill_bundles", &skill_aliases).await?,
1794            7 => {
1795                prompt_agent_alias_multi(cfg, ui, alias, "knowledge_bundles", &knowledge_aliases)
1796                    .await?
1797            }
1798            8 => prompt_agent_alias_multi(cfg, ui, alias, "mcp_bundles", &mcp_aliases).await?,
1799            _ => return Ok(Nav::Done),
1800        };
1801        match nav {
1802            Nav::Done => step += 1,
1803            Nav::Back => {
1804                if step == 0 {
1805                    return Ok(Nav::Back);
1806                }
1807                step -= 1;
1808            }
1809        }
1810    }
1811}
1812
1813/// Per-agent personality picker — same UX as the upstream top-level
1814/// `personality` section, scoped to `agents/<alias>/workspace/`. Lists
1815/// every editable personality file with a saved / not-saved badge,
1816/// seeds missing files from the bundled starter templates, and loops
1817/// until the user picks `Done`. Back from the picker rewinds the
1818/// outer agent-field walk; Back from the editor returns to the picker.
1819async fn prompt_agent_system_prompt(
1820    cfg: &Config,
1821    ui: &mut dyn OnboardUi,
1822    alias: &str,
1823) -> Result<Nav> {
1824    let workspace = cfg.agent_workspace_dir(alias);
1825    let template_ctx = TemplateContext {
1826        agent: alias.to_string(),
1827        include_memory: cfg.memory.backend.as_str() != "none",
1828        ..TemplateContext::default()
1829    };
1830
1831    loop {
1832        let mut items: Vec<SelectItem> = EDITABLE_PERSONALITY_FILES
1833            .iter()
1834            .map(|filename| {
1835                let exists = workspace.join(filename).is_file();
1836                SelectItem::with_badge(
1837                    (*filename).to_string(),
1838                    if exists { "saved" } else { "not saved" },
1839                )
1840            })
1841            .collect();
1842        items.push(SelectItem::new("Done"));
1843
1844        match ui.select("Personality file to edit", &items, None).await? {
1845            Answer::Back => return Ok(Nav::Back),
1846            Answer::Value(idx) if idx == EDITABLE_PERSONALITY_FILES.len() => break,
1847            Answer::Value(idx) => {
1848                let filename = EDITABLE_PERSONALITY_FILES[idx];
1849                let path = workspace.join(filename);
1850                let initial = if path.is_file() {
1851                    tokio::fs::read_to_string(&path).await.unwrap_or_default()
1852                } else {
1853                    render_personality(filename, &template_ctx).unwrap_or_default()
1854                };
1855                match ui.editor(&format!("Editing {filename}"), &initial).await? {
1856                    Answer::Back => continue,
1857                    Answer::Value(content) => {
1858                        tokio::fs::create_dir_all(&workspace)
1859                            .await
1860                            .with_context(|| {
1861                                format!(
1862                                    "Failed to create per-agent workspace at {}",
1863                                    workspace.display()
1864                                )
1865                            })?;
1866                        tokio::fs::write(&path, content).await.with_context(|| {
1867                            format!("Failed to write {} at {}", filename, path.display())
1868                        })?;
1869                    }
1870                }
1871            }
1872        }
1873    }
1874    Ok(Nav::Done)
1875}
1876
1877/// Single-select alias picker. Always offers a `(none)` choice so the
1878/// field can be cleared. When the candidate list is empty, falls back to
1879/// a free-text prompt with a hint that no aliases are configured yet.
1880async fn prompt_agent_alias_single(
1881    cfg: &mut Config,
1882    ui: &mut dyn OnboardUi,
1883    alias: &str,
1884    field: &str,
1885    available: &[String],
1886) -> Result<Nav> {
1887    let path = agent_field_path(alias, field);
1888    let current_raw = cfg.get_prop(&path).ok().unwrap_or_default();
1889    let current = if current_raw == "<unset>" {
1890        String::new()
1891    } else {
1892        current_raw
1893    };
1894    let help = field_doc(cfg, &path).unwrap_or_default();
1895    ui.note(&help);
1896
1897    if available.is_empty() {
1898        ui.note(&format!(
1899            "{help}\nNo {field} aliases configured yet. Press Enter to leave empty."
1900        ));
1901        match ui.string(field, Some(&current), None).await? {
1902            Answer::Back => return Ok(Nav::Back),
1903            Answer::Value(new) => {
1904                if new != current {
1905                    persist(cfg, &path, &new).await?;
1906                }
1907                return Ok(Nav::Done);
1908            }
1909        }
1910    }
1911
1912    let mut items: Vec<SelectItem> = vec![SelectItem::new("(none)")];
1913    for a in available {
1914        items.push(SelectItem::new(a.as_str()));
1915    }
1916    let current_idx = if current.is_empty() {
1917        Some(0)
1918    } else {
1919        available
1920            .iter()
1921            .position(|a| a == &current)
1922            .map(|i| i + 1)
1923            .or(Some(0))
1924    };
1925    match ui.select(field, &items, current_idx).await? {
1926        Answer::Back => Ok(Nav::Back),
1927        Answer::Value(0) => {
1928            if !current.is_empty() {
1929                persist(cfg, &path, "").await?;
1930            }
1931            Ok(Nav::Done)
1932        }
1933        Answer::Value(i) => {
1934            let chosen = &available[i - 1];
1935            if chosen != &current {
1936                persist(cfg, &path, chosen).await?;
1937            }
1938            Ok(Nav::Done)
1939        }
1940    }
1941}
1942
1943/// Multi-select alias picker rendered as a vertical toggle list. Each
1944/// available alias is one row prefixed with `[x]` / `[ ]` and a
1945/// `selected` badge when chosen; selecting a row toggles its membership.
1946/// A trailing `Done` row commits the set. Mirrors the model_providers picker
1947/// (see `model_providers()` in this file) so CLI and TUI feel identical.
1948///
1949/// Empty candidate list → no-op skip. Persists nothing if the user
1950/// hasn't changed the selection from what's on disk.
1951async fn prompt_agent_alias_multi(
1952    cfg: &mut Config,
1953    ui: &mut dyn OnboardUi,
1954    alias: &str,
1955    field: &str,
1956    available: &[String],
1957) -> Result<Nav> {
1958    let path = agent_field_path(alias, field);
1959    let current_raw = cfg.get_prop(&path).ok().unwrap_or_default();
1960    let initial = parse_string_array_display(&current_raw);
1961    // Drop currently-selected entries that no longer exist as candidates;
1962    // the validator catches them otherwise but the picker shouldn't
1963    // pretend they're present.
1964    let mut selected: Vec<String> = initial
1965        .iter()
1966        .filter(|s| available.iter().any(|a| a == *s))
1967        .cloned()
1968        .collect();
1969    let help = field_doc(cfg, &path).unwrap_or_default();
1970
1971    if available.is_empty() {
1972        ui.note(&format!(
1973            "{help}\nNo {field} aliases configured yet — skipping."
1974        ));
1975        return Ok(Nav::Done);
1976    }
1977
1978    loop {
1979        ui.note(&format!(
1980            "{help}\nEnter toggles a row. Pick `Done` to commit. ({} of {} selected)",
1981            selected.len(),
1982            available.len(),
1983        ));
1984
1985        let mut items: Vec<SelectItem> = available
1986            .iter()
1987            .map(|a| {
1988                let is_selected = selected.contains(a);
1989                let label = format!("[{}] {a}", if is_selected { "x" } else { " " });
1990                if is_selected {
1991                    SelectItem::with_badge(label, "selected")
1992                } else {
1993                    SelectItem::new(label)
1994                }
1995            })
1996            .collect();
1997        items.push(SelectItem::new("Done"));
1998        let done_idx = items.len() - 1;
1999
2000        match ui.select(field, &items, Some(done_idx)).await? {
2001            Answer::Back => return Ok(Nav::Back),
2002            Answer::Value(i) if i == done_idx => {
2003                let serialized = serialize_string_array_json(&selected);
2004                if serialized != current_raw {
2005                    persist(cfg, &path, &serialized).await?;
2006                }
2007                return Ok(Nav::Done);
2008            }
2009            Answer::Value(i) => {
2010                let alias_at = &available[i];
2011                if let Some(pos) = selected.iter().position(|a| a == alias_at) {
2012                    selected.remove(pos);
2013                } else {
2014                    selected.push(alias_at.clone());
2015                }
2016            }
2017        }
2018    }
2019}
2020
2021fn field_doc(cfg: &Config, path: &str) -> Option<String> {
2022    cfg.prop_fields()
2023        .into_iter()
2024        .find(|f| f.name == path)
2025        .map(|f| f.description.to_string())
2026}
2027
2028/// Parse `get_prop`'s display form for a string array back into a Vec.
2029/// `get_prop` returns toml's display syntax (e.g. `["a", "b"]`), so the
2030/// JSON parser handles both shapes; falls back to comma-split.
2031fn parse_string_array_display(s: &str) -> Vec<String> {
2032    let trimmed = s.trim();
2033    if trimmed.is_empty() || trimmed == "<unset>" || trimmed == "[]" {
2034        return Vec::new();
2035    }
2036    if trimmed.starts_with('[')
2037        && let Ok(arr) = serde_json::from_str::<Vec<String>>(trimmed)
2038    {
2039        return arr;
2040    }
2041    trimmed
2042        .split(',')
2043        .map(|s| s.trim().to_string())
2044        .filter(|s| !s.is_empty())
2045        .collect()
2046}
2047
2048fn serialize_string_array_json(items: &[String]) -> String {
2049    serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string())
2050}
2051
2052/// All currently-configured channel aliases in dotted form
2053/// (`telegram.default`, `discord.work`). Pulled from `prop_fields` so it
2054/// reflects whatever the user has just configured.
2055fn available_channel_aliases(cfg: &Config) -> Vec<String> {
2056    let mut out: Vec<String> = Vec::new();
2057    for f in cfg.prop_fields() {
2058        if let Some(rest) = f.name.strip_prefix("channels.") {
2059            let mut parts = rest.splitn(3, '.');
2060            if let (Some(ty), Some(alias), Some(_leaf)) = (parts.next(), parts.next(), parts.next())
2061            {
2062                let dotted = format!("{ty}.{alias}");
2063                if !out.contains(&dotted) {
2064                    out.push(dotted);
2065                }
2066            }
2067        }
2068    }
2069    out.sort();
2070    out
2071}
2072
2073/// All currently-configured model provider aliases in dotted form
2074/// (`anthropic.default`, `openrouter.work`). Pulled from `prop_fields`.
2075fn available_model_provider_aliases(cfg: &Config) -> Vec<String> {
2076    let mut out: Vec<String> = Vec::new();
2077    for f in cfg.prop_fields() {
2078        if let Some(rest) = f.name.strip_prefix("providers.models.") {
2079            let mut parts = rest.splitn(3, '.');
2080            if let (Some(ty), Some(alias), Some(_leaf)) = (parts.next(), parts.next(), parts.next())
2081            {
2082                let dotted = format!("{ty}.{alias}");
2083                if !out.contains(&dotted) {
2084                    out.push(dotted);
2085                }
2086            }
2087        }
2088    }
2089    out.sort();
2090    out
2091}
2092
2093#[cfg(test)]
2094mod tests {
2095    use super::*;
2096    use crate::onboard::ui::quick::QuickUi;
2097    use axum::Router;
2098    use axum::http::{StatusCode, header};
2099    use axum::routing::get;
2100    use std::sync::Arc;
2101    use tempfile::TempDir;
2102    use tokio::net::TcpListener;
2103    use zeroclaw_config::schema::{
2104        AnthropicModelProviderConfig, Config, ModelProviderConfig, WireApi,
2105    };
2106
2107    #[test]
2108    fn next_agent_alias_suggestion_handles_empty_collision_and_growth() {
2109        // Fresh install → "default".
2110        assert_eq!(next_agent_alias_suggestion(&[]), "default");
2111
2112        // Single existing → first numeric suffix.
2113        let one = vec!["assistant".to_string()];
2114        assert_eq!(next_agent_alias_suggestion(&one), "assistant-2");
2115
2116        // Adding a third when -2 already exists must not collide.
2117        let two = vec!["assistant".to_string(), "assistant-2".to_string()];
2118        assert_eq!(next_agent_alias_suggestion(&two), "assistant-3");
2119
2120        // Non-sequential history still finds the next gap-free suffix.
2121        let four = vec![
2122            "researcher".to_string(),
2123            "researcher-2".to_string(),
2124            "researcher-3".to_string(),
2125            "researcher-5".to_string(),
2126        ];
2127        assert_eq!(next_agent_alias_suggestion(&four), "researcher-4");
2128    }
2129
2130    /// Build a `Config` whose `config_path` / `workspace_dir` live inside a
2131    /// temp directory, so `save()` touches only the scratch tree.
2132    fn test_cfg(temp: &TempDir) -> Config {
2133        Config {
2134            config_path: temp.path().join("config.toml"),
2135            data_dir: temp.path().join("data"),
2136            ..Default::default()
2137        }
2138    }
2139
2140    async fn spawn_models_endpoint(
2141        status: StatusCode,
2142        body: &'static str,
2143        delay: Option<Duration>,
2144    ) -> String {
2145        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2146        let port = listener.local_addr().unwrap().port();
2147        let body = Arc::new(body.to_string());
2148        let app = Router::new().route(
2149            "/v1/models",
2150            get(move || {
2151                let body = body.clone();
2152                async move {
2153                    if let Some(delay) = delay {
2154                        tokio::time::sleep(delay).await;
2155                    }
2156                    (
2157                        status,
2158                        [(header::CONTENT_TYPE, "application/json")],
2159                        body.to_string(),
2160                    )
2161                }
2162            }),
2163        );
2164        tokio::spawn(async move {
2165            axum::serve(listener, app).await.unwrap();
2166        });
2167        format!("http://127.0.0.1:{port}")
2168    }
2169
2170    #[tokio::test]
2171    async fn section_has_signal_providers_requires_models_entry() {
2172        let temp = TempDir::new().unwrap();
2173        let mut cfg = test_cfg(&temp);
2174        assert!(!section_has_signal(&cfg, Section::ModelProviders));
2175        cfg.providers
2176            .models
2177            .ensure("anthropic", "default")
2178            .expect("anthropic typed slot");
2179        assert!(section_has_signal(&cfg, Section::ModelProviders));
2180    }
2181
2182    #[tokio::test]
2183    async fn section_has_signal_hardware_tracks_enabled_flag() {
2184        let temp = TempDir::new().unwrap();
2185        let mut cfg = test_cfg(&temp);
2186        assert!(!section_has_signal(&cfg, Section::Hardware));
2187        cfg.hardware.enabled = true;
2188        assert!(section_has_signal(&cfg, Section::Hardware));
2189    }
2190
2191    #[tokio::test]
2192    async fn section_has_signal_memory_and_tunnel_are_marker_only() {
2193        let temp = TempDir::new().unwrap();
2194        let cfg = test_cfg(&temp);
2195        // Memory defaults to "sqlite" and Tunnel defaults to "none" — both
2196        // are valid user choices indistinguishable from untouched defaults,
2197        // so the completed-sections marker is the only skip-gate signal.
2198        assert!(!section_has_signal(&cfg, Section::Memory));
2199        assert!(!section_has_signal(&cfg, Section::Tunnel));
2200    }
2201
2202    #[tokio::test]
2203    async fn mark_completed_is_dedupe_safe() {
2204        let temp = TempDir::new().unwrap();
2205        let mut cfg = test_cfg(&temp);
2206        mark_completed(&mut cfg, Section::Memory).await.unwrap();
2207        mark_completed(&mut cfg, Section::Memory).await.unwrap();
2208        let count = cfg
2209            .onboard_state
2210            .completed_sections
2211            .iter()
2212            .filter(|s| s.as_str() == "memory")
2213            .count();
2214        assert_eq!(count, 1, "marker should be inserted at most once");
2215    }
2216
2217    #[tokio::test]
2218    async fn skip_gate_skips_when_marked_and_user_declines() {
2219        let temp = TempDir::new().unwrap();
2220        let mut cfg = test_cfg(&temp);
2221        cfg.onboard_state.completed_sections.push("memory".into());
2222
2223        // QuickUi with no scripted answers returns `default` from `confirm`,
2224        // which for the reconfigure prompt is `false` → SkipNav::Skip.
2225        let mut ui = QuickUi::new();
2226        let result = skip_if_configured(
2227            &cfg,
2228            &mut ui,
2229            &Flags::default(),
2230            Section::Memory,
2231            "Memory",
2232            false,
2233        )
2234        .await
2235        .unwrap();
2236        assert_eq!(result, SkipNav::Skip);
2237    }
2238
2239    #[tokio::test]
2240    async fn skip_gate_skips_when_signal_present_and_user_declines() {
2241        let temp = TempDir::new().unwrap();
2242        let cfg = test_cfg(&temp);
2243        // No marker, but caller reports meaningful config in this section.
2244        let mut ui = QuickUi::new();
2245        let result = skip_if_configured(
2246            &cfg,
2247            &mut ui,
2248            &Flags::default(),
2249            Section::Memory,
2250            "Memory",
2251            true,
2252        )
2253        .await
2254        .unwrap();
2255        assert_eq!(result, SkipNav::Skip);
2256    }
2257
2258    #[tokio::test]
2259    async fn skip_gate_enters_when_force_flag_set() {
2260        let temp = TempDir::new().unwrap();
2261        let mut cfg = test_cfg(&temp);
2262        cfg.onboard_state.completed_sections.push("memory".into());
2263
2264        let mut ui = QuickUi::new();
2265        let flags = Flags {
2266            force: true,
2267            ..Default::default()
2268        };
2269        let result = skip_if_configured(&cfg, &mut ui, &flags, Section::Memory, "Memory", true)
2270            .await
2271            .unwrap();
2272        assert_eq!(result, SkipNav::Enter);
2273    }
2274
2275    #[tokio::test]
2276    async fn skip_gate_enters_when_unmarked_and_no_signal() {
2277        let temp = TempDir::new().unwrap();
2278        let cfg = test_cfg(&temp);
2279        let mut ui = QuickUi::new();
2280        let result = skip_if_configured(
2281            &cfg,
2282            &mut ui,
2283            &Flags::default(),
2284            Section::Memory,
2285            "Memory",
2286            false,
2287        )
2288        .await
2289        .unwrap();
2290        assert_eq!(result, SkipNav::Enter);
2291    }
2292
2293    #[tokio::test]
2294    async fn discover_openai_compat_models_parses_valid_models_payload() {
2295        let base_url = spawn_models_endpoint(
2296            StatusCode::OK,
2297            r#"{"object":"list","data":[{"id":"llama-3.3"},{"id":" qwen3-coder "}]}"#,
2298            None,
2299        )
2300        .await;
2301
2302        let models = discover_openai_compat_models(&base_url, Some("sk-test"))
2303            .await
2304            .unwrap();
2305
2306        assert_eq!(models, vec!["llama-3.3", "qwen3-coder"]);
2307    }
2308
2309    #[tokio::test]
2310    async fn discover_openai_compat_models_rejects_malformed_json() {
2311        let base_url = spawn_models_endpoint(StatusCode::OK, r#"{"data":["#, None).await;
2312
2313        let err = discover_openai_compat_models(&base_url, Some("sk-test"))
2314            .await
2315            .unwrap_err()
2316            .to_string();
2317
2318        assert!(
2319            err.contains("invalid JSON"),
2320            "unexpected discovery error: {err}"
2321        );
2322    }
2323
2324    #[tokio::test]
2325    async fn discover_openai_compat_models_reports_unauthorized() {
2326        let base_url =
2327            spawn_models_endpoint(StatusCode::UNAUTHORIZED, r#"{"error":"bad key"}"#, None).await;
2328
2329        let err = discover_openai_compat_models(&base_url, Some("sk-test"))
2330            .await
2331            .unwrap_err()
2332            .to_string();
2333
2334        assert!(
2335            err.contains("HTTP 401"),
2336            "unexpected discovery error: {err}"
2337        );
2338    }
2339
2340    #[tokio::test]
2341    async fn discover_openai_compat_models_reports_not_found() {
2342        let base_url =
2343            spawn_models_endpoint(StatusCode::NOT_FOUND, r#"{"error":"nope"}"#, None).await;
2344
2345        let err = discover_openai_compat_models(&base_url, Some("sk-test"))
2346            .await
2347            .unwrap_err()
2348            .to_string();
2349
2350        assert!(
2351            err.contains("HTTP 404"),
2352            "unexpected discovery error: {err}"
2353        );
2354    }
2355
2356    #[tokio::test]
2357    async fn discover_openai_compat_models_reports_server_error() {
2358        let base_url = spawn_models_endpoint(
2359            StatusCode::INTERNAL_SERVER_ERROR,
2360            r#"{"error":"boom"}"#,
2361            None,
2362        )
2363        .await;
2364
2365        let err = discover_openai_compat_models(&base_url, Some("sk-test"))
2366            .await
2367            .unwrap_err()
2368            .to_string();
2369
2370        assert!(
2371            err.contains("HTTP 500"),
2372            "unexpected discovery error: {err}"
2373        );
2374    }
2375
2376    #[tokio::test]
2377    async fn discover_openai_compat_models_reports_network_timeout() {
2378        let base_url = spawn_models_endpoint(
2379            StatusCode::OK,
2380            r#"{"data":[{"id":"slow-model"}]}"#,
2381            Some(Duration::from_millis(200)),
2382        )
2383        .await;
2384
2385        let err = discover_openai_compat_models_with_timeout(
2386            &base_url,
2387            Some("sk-test"),
2388            Duration::from_millis(50),
2389        )
2390        .await
2391        .unwrap_err()
2392        .to_string();
2393
2394        assert!(
2395            err.contains("request failed"),
2396            "unexpected discovery error: {err}"
2397        );
2398    }
2399
2400    #[tokio::test]
2401    async fn providers_custom_openai_endpoint_discovers_models() {
2402        let temp = TempDir::new().unwrap();
2403        let mut cfg = test_cfg(&temp);
2404        let base_url = spawn_models_endpoint(
2405            StatusCode::OK,
2406            r#"{"data":[{"id":"llama-local"},{"id":"qwen-local"}]}"#,
2407            None,
2408        )
2409        .await;
2410
2411        let flags = Flags::default();
2412        let mut ui = QuickUi::new()
2413            .with("ModelProvider", CUSTOM_OPENAI_COMPAT_LABEL)
2414            .with("OpenAI-compatible base URL", &base_url)
2415            .with("alias", "default")
2416            .with("api-key", "sk-custom-test")
2417            .with("Model", "qwen-local");
2418
2419        Box::pin(run(
2420            &mut cfg,
2421            &mut ui,
2422            Some(Section::ModelProviders),
2423            &flags,
2424        ))
2425        .await
2426        .unwrap();
2427
2428        let model_cfg = cfg
2429            .providers
2430            .models
2431            .find("custom", "default")
2432            .expect("custom model_provider entry should be seeded");
2433        assert_eq!(model_cfg.api_key.as_deref(), Some("sk-custom-test"));
2434        assert_eq!(model_cfg.uri.as_deref(), Some(base_url.as_str()));
2435        assert_eq!(model_cfg.model.as_deref(), Some("qwen-local"));
2436    }
2437
2438    #[tokio::test]
2439    async fn prompt_model_unknown_provider_with_base_url_discovers_models() {
2440        let temp = TempDir::new().unwrap();
2441        let mut cfg = test_cfg(&temp);
2442        let base_url = spawn_models_endpoint(
2443            StatusCode::OK,
2444            r#"{"data":[{"id":"gateway-small"},{"id":"gateway-large"}]}"#,
2445            None,
2446        )
2447        .await;
2448        let entry = cfg
2449            .providers
2450            .models
2451            .ensure("custom", "default")
2452            .expect("custom typed slot");
2453        entry.api_key = Some("sk-gateway-test".into());
2454        entry.uri = Some(base_url);
2455        let mut ui = QuickUi::new().with("Model", "gateway-large");
2456
2457        prompt_model(&mut cfg, &mut ui, "providers.models.custom.default")
2458            .await
2459            .unwrap();
2460
2461        let model_cfg = cfg
2462            .providers
2463            .models
2464            .find("custom", "default")
2465            .expect("custom model_provider entry should remain configured");
2466        assert_eq!(model_cfg.model.as_deref(), Some("gateway-large"));
2467    }
2468
2469    /// Providers section driven entirely by CLI flags: the `--model-provider`,
2470    /// `--api-key`, and `--model` overrides fire up-front, bypassing the
2471    /// `ui.select` menu, the api-key prompt, and `prompt_model` (which
2472    /// would otherwise reach out to `models.dev` for the live catalog).
2473    /// Only the opt-in advanced-settings confirmation remains, and QuickUi
2474    /// defaults that to `false`.
2475    #[tokio::test]
2476    async fn providers_forced_via_flags_persists_and_marks_completed() {
2477        let temp = TempDir::new().unwrap();
2478        let mut cfg = test_cfg(&temp);
2479
2480        let flags = Flags {
2481            model_provider: Some("anthropic".into()),
2482            api_key: Some("sk-ant-test".into()),
2483            model: Some("claude-opus-4-7".into()),
2484            ..Default::default()
2485        };
2486        let mut ui = QuickUi::new();
2487        Box::pin(run(
2488            &mut cfg,
2489            &mut ui,
2490            Some(Section::ModelProviders),
2491            &flags,
2492        ))
2493        .await
2494        .unwrap();
2495
2496        let model_cfg = cfg
2497            .providers
2498            .models
2499            .find("anthropic", "default")
2500            .expect("anthropic.default entry should be seeded");
2501        assert_eq!(model_cfg.model.as_deref(), Some("claude-opus-4-7"));
2502        assert_eq!(model_cfg.api_key.as_deref(), Some("sk-ant-test"));
2503        assert!(
2504            cfg.onboard_state
2505                .completed_sections
2506                .iter()
2507                .any(|s| s == "providers.models"),
2508            "providers.models section should mark completed"
2509        );
2510    }
2511
2512    /// Double-run idempotency for model_providers: prime via flags, then a
2513    /// flags-free second run hits the skip-gate (marker + fallback +
2514    /// models entry = has_signal) and QuickUi's default-false confirm
2515    /// declines reconfigure, leaving the on-disk config byte-identical.
2516    #[tokio::test]
2517    async fn providers_second_run_no_flags_is_idempotent_on_disk() {
2518        let temp = TempDir::new().unwrap();
2519        let mut cfg = test_cfg(&temp);
2520
2521        let prime = Flags {
2522            model_provider: Some("anthropic".into()),
2523            api_key: Some("sk-ant-test".into()),
2524            model: Some("claude-opus-4-7".into()),
2525            ..Default::default()
2526        };
2527        let mut ui = QuickUi::new();
2528        Box::pin(run(
2529            &mut cfg,
2530            &mut ui,
2531            Some(Section::ModelProviders),
2532            &prime,
2533        ))
2534        .await
2535        .unwrap();
2536        let after_first = tokio::fs::read_to_string(&cfg.config_path).await.unwrap();
2537
2538        let mut ui = QuickUi::new();
2539        run(
2540            &mut cfg,
2541            &mut ui,
2542            Some(Section::ModelProviders),
2543            &Flags::default(),
2544        )
2545        .await
2546        .unwrap();
2547        let after_second = tokio::fs::read_to_string(&cfg.config_path).await.unwrap();
2548        assert_eq!(
2549            after_first, after_second,
2550            "second run hit the skip-gate and must not rewrite config.toml"
2551        );
2552    }
2553
2554    /// Channels section with no scripted answers: the user falls onto the
2555    /// pre-selected "Done" option in the channel menu, the section marks
2556    /// completed, and a second run hits the skip-gate and leaves the file
2557    /// bytes unchanged.
2558    #[tokio::test]
2559    async fn channels_done_selection_is_idempotent_on_disk() {
2560        let temp = TempDir::new().unwrap();
2561        let mut cfg = test_cfg(&temp);
2562        let flags = Flags::default();
2563
2564        let mut ui = QuickUi::new();
2565        Box::pin(run(&mut cfg, &mut ui, Some(Section::Channels), &flags))
2566            .await
2567            .unwrap();
2568
2569        assert!(
2570            cfg.onboard_state
2571                .completed_sections
2572                .iter()
2573                .any(|s| s == "channels"),
2574            "first run should mark channels completed"
2575        );
2576        let after_first = tokio::fs::read_to_string(&cfg.config_path).await.unwrap();
2577
2578        let mut ui = QuickUi::new();
2579        Box::pin(run(&mut cfg, &mut ui, Some(Section::Channels), &flags))
2580            .await
2581            .unwrap();
2582        let after_second = tokio::fs::read_to_string(&cfg.config_path).await.unwrap();
2583        assert_eq!(
2584            after_first, after_second,
2585            "second run hit the skip-gate and must not rewrite config.toml"
2586        );
2587    }
2588
2589    /// Smoke test: picking Telegram in the channels menu initializes the
2590    /// subsection and the scripted bot-token lands via `set_prop`. Covers
2591    /// the per-channel field-walk path that `channels_done_selection_*`
2592    /// doesn't exercise (it picks Done immediately).
2593    #[tokio::test]
2594    async fn channels_telegram_selection_writes_entry() {
2595        let temp = TempDir::new().unwrap();
2596        let mut cfg = test_cfg(&temp);
2597        let flags = Flags::default();
2598
2599        let mut ui = QuickUi::new()
2600            .with("bot-token", "stub-tg-token")
2601            // Optional Option<String> field with no default — QuickUi's
2602            // `string` method bails when both answer and current prefill
2603            // are None. An empty-string answer lets prompt_field's
2604            // is-set-guard skip the persist, leaving the field None.
2605            .with("proxy-url", "")
2606            .with("default-target", "")
2607            // Vec<String> with #[serde(default)]; empty answer keeps the
2608            // default empty list. Same shape as proxy-url above.
2609            .with("excluded-tools", "")
2610            .with_sequence("Channel", ["telegram", "Done"]);
2611        Box::pin(run(&mut cfg, &mut ui, Some(Section::Channels), &flags))
2612            .await
2613            .unwrap();
2614
2615        let tg = cfg
2616            .channels
2617            .telegram
2618            .get("default")
2619            .expect("telegram subsection should be initialized");
2620        assert_eq!(tg.bot_token, "stub-tg-token");
2621        assert!(
2622            cfg.onboard_state
2623                .completed_sections
2624                .iter()
2625                .any(|s| s == "channels"),
2626            "channels section should mark completed"
2627        );
2628    }
2629
2630    /// Smoke test: picking Mochat walks the config fields and the
2631    /// resulting config has the scripted base URL and API token
2632    /// round-tripped via `set_prop`.
2633    #[tokio::test]
2634    async fn channels_mochat_selection_persists_url_and_token() {
2635        let temp = TempDir::new().unwrap();
2636        let mut cfg = test_cfg(&temp);
2637        let flags = Flags::default();
2638
2639        let mut ui = QuickUi::new()
2640            .with("api-url", "http://mochat-test:8080/v1")
2641            .with("api-token", "stub-mochat-token")
2642            // Vec<String> with #[serde(default)]; empty answer keeps the
2643            // default empty list.
2644            .with("excluded-tools", "")
2645            .with_sequence("Channel", ["mochat", "Done"]);
2646        Box::pin(run(&mut cfg, &mut ui, Some(Section::Channels), &flags))
2647            .await
2648            .unwrap();
2649
2650        let mc = cfg
2651            .channels
2652            .mochat
2653            .get("default")
2654            .expect("mochat subsection should be initialized");
2655        assert_eq!(mc.api_url, "http://mochat-test:8080/v1");
2656        assert_eq!(mc.api_token, "stub-mochat-token");
2657    }
2658
2659    // ---------------------------------------------------------------------------
2660    // BackAt: a test-only OnboardUi that returns Answer::Back for one named
2661    // prompt, and delegates everything else to an inner QuickUi. Used to drive
2662    // ESC / Back navigation through model_provider and channel flows without spinning
2663    // up the full TUI.
2664    // ---------------------------------------------------------------------------
2665    struct BackAt {
2666        back_prompt: &'static str,
2667        inner: QuickUi,
2668    }
2669
2670    impl BackAt {
2671        fn new(back_prompt: &'static str, inner: QuickUi) -> Self {
2672            Self { back_prompt, inner }
2673        }
2674    }
2675
2676    #[async_trait::async_trait]
2677    impl OnboardUi for BackAt {
2678        async fn confirm(&mut self, prompt: &str, default: bool) -> anyhow::Result<Answer<bool>> {
2679            if prompt == self.back_prompt {
2680                return Ok(Answer::Back);
2681            }
2682            self.inner.confirm(prompt, default).await
2683        }
2684
2685        async fn string(
2686            &mut self,
2687            prompt: &str,
2688            current: Option<&str>,
2689            placeholder: Option<&str>,
2690        ) -> anyhow::Result<Answer<String>> {
2691            if prompt == self.back_prompt {
2692                return Ok(Answer::Back);
2693            }
2694            self.inner.string(prompt, current, placeholder).await
2695        }
2696
2697        async fn secret(
2698            &mut self,
2699            prompt: &str,
2700            has_current: bool,
2701        ) -> anyhow::Result<Answer<Option<String>>> {
2702            if prompt == self.back_prompt {
2703                return Ok(Answer::Back);
2704            }
2705            self.inner.secret(prompt, has_current).await
2706        }
2707
2708        async fn select(
2709            &mut self,
2710            prompt: &str,
2711            items: &[SelectItem],
2712            current: Option<usize>,
2713        ) -> anyhow::Result<Answer<usize>> {
2714            if prompt == self.back_prompt {
2715                return Ok(Answer::Back);
2716            }
2717            self.inner.select(prompt, items, current).await
2718        }
2719
2720        async fn editor(&mut self, hint: &str, initial: &str) -> anyhow::Result<Answer<String>> {
2721            if hint == self.back_prompt {
2722                return Ok(Answer::Back);
2723            }
2724            self.inner.editor(hint, initial).await
2725        }
2726
2727        fn heading(&mut self, level: u8, text: &str) {
2728            self.inner.heading(level, text);
2729        }
2730
2731        fn note(&mut self, msg: &str) {
2732            self.inner.note(msg);
2733        }
2734
2735        fn status(&mut self, msg: &str) {
2736            self.inner.status(msg);
2737        }
2738
2739        fn warn(&mut self, msg: &str) {
2740            self.inner.warn(msg);
2741        }
2742    }
2743
2744    // US-7 / prompt_model regression: model is written to the alias the user
2745    // actually selected, not to a hardcoded ".default." path. A non-default
2746    // alias ("work") must produce model_providers.anthropic.work.model, never
2747    // model_providers.anthropic.default.model.
2748    #[tokio::test]
2749    async fn prompt_model_writes_to_actual_alias_not_hardcoded_default() {
2750        let temp = TempDir::new().unwrap();
2751        let mut cfg = test_cfg(&temp);
2752        cfg.providers
2753            .models
2754            .anthropic
2755            .insert("work".into(), AnthropicModelProviderConfig::default());
2756
2757        let mut ui = QuickUi::new().with("Model", "claude-opus-4-7");
2758        prompt_model(&mut cfg, &mut ui, "providers.models.anthropic.work")
2759            .await
2760            .unwrap();
2761
2762        let work_model = cfg
2763            .providers
2764            .models
2765            .find("anthropic", "work")
2766            .and_then(|c| c.model.as_deref());
2767        assert_eq!(
2768            work_model,
2769            Some("claude-opus-4-7"),
2770            "model must be written to the 'work' alias, not 'default'"
2771        );
2772
2773        let default_model = cfg
2774            .providers
2775            .models
2776            .find("anthropic", "default")
2777            .and_then(|c| c.model.as_deref());
2778        assert!(
2779            default_model.is_none(),
2780            "no 'default' alias should exist — path was hardcoded to 'default' (regression)"
2781        );
2782    }
2783
2784    // US-3 / ESC regression: backing out of api-key prompt on an existing alias
2785    // must leave that alias intact with its original values.
2786    #[tokio::test]
2787    async fn providers_esc_on_existing_alias_leaves_config_untouched() {
2788        let temp = TempDir::new().unwrap();
2789        let mut cfg = test_cfg(&temp);
2790
2791        // Pre-seed an existing alias with a known api_key.
2792        cfg.providers.models.anthropic.insert(
2793            "my-alias".to_string(),
2794            AnthropicModelProviderConfig {
2795                base: ModelProviderConfig {
2796                    api_key: Some("sk-original".into()),
2797                    model: Some("claude-opus-4-7".into()),
2798                    ..Default::default()
2799                },
2800            },
2801        );
2802
2803        // Drive model_providers(): pick anthropic, pick "my-alias" (existing), ESC at api-key.
2804        // The loop should continue (not remove the entry) because is_new_entry = false.
2805        // After ESC the loop re-presents the model_provider select — we then pick "Done".
2806        let mut ui = BackAt::new(
2807            "api-key",
2808            QuickUi::new()
2809                .with_sequence("ModelProvider", ["Anthropic", "Done"])
2810                .with("Alias", "my-alias"),
2811        );
2812        run(
2813            &mut cfg,
2814            &mut ui,
2815            Some(Section::ModelProviders),
2816            &Flags::default(),
2817        )
2818        .await
2819        .unwrap();
2820
2821        let alias_cfg = cfg
2822            .providers
2823            .models
2824            .find("anthropic", "my-alias")
2825            .expect("my-alias must survive ESC on an existing entry");
2826        assert_eq!(
2827            alias_cfg.api_key.as_deref(),
2828            Some("sk-original"),
2829            "original api_key must not be clobbered after ESC"
2830        );
2831        assert_eq!(alias_cfg.model.as_deref(), Some("claude-opus-4-7"));
2832    }
2833
2834    // US-1 / ESC regression: backing out of api-key prompt on a brand-new alias
2835    // must remove the in-progress entry so it never reaches disk.
2836    #[tokio::test]
2837    async fn providers_esc_on_new_alias_removes_entry() {
2838        let temp = TempDir::new().unwrap();
2839        let mut cfg = test_cfg(&temp);
2840
2841        // No pre-existing aliases for anthropic. The alias prompt fires first,
2842        // user types "fresh". ESC at api-key removes "fresh" and loops. Then
2843        // the model_provider select fires again and the user picks "Done".
2844        let mut ui = BackAt::new(
2845            "api-key",
2846            QuickUi::new()
2847                .with_sequence("ModelProvider", ["Anthropic", "Done"])
2848                .with("Alias (name for this configuration)", "fresh"),
2849        );
2850        run(
2851            &mut cfg,
2852            &mut ui,
2853            Some(Section::ModelProviders),
2854            &Flags::default(),
2855        )
2856        .await
2857        .unwrap();
2858
2859        let entry = cfg.providers.models.find("anthropic", "fresh");
2860        assert!(
2861            entry.is_none(),
2862            "in-progress 'fresh' alias must be removed after ESC (never persisted)"
2863        );
2864    }
2865
2866    // Alias key validation — backend enforcement via create_map_key. These tests
2867    // exercise the generated macro code path that calls validate_alias_key before
2868    // inserting, so invalid aliases can never reach the config HashMap.
2869
2870    #[test]
2871    fn create_map_key_rejects_alias_with_dot() {
2872        let temp = TempDir::new().unwrap();
2873        let mut cfg = test_cfg(&temp);
2874        let result = cfg.create_map_key("channels.discord", "my.alias");
2875        assert!(result.is_err(), "dot in alias must be rejected");
2876        assert!(
2877            cfg.channels.discord.is_empty(),
2878            "no entry should be inserted"
2879        );
2880    }
2881
2882    #[test]
2883    fn create_map_key_rejects_alias_with_slash() {
2884        let temp = TempDir::new().unwrap();
2885        let mut cfg = test_cfg(&temp);
2886        let result = cfg.create_map_key("channels.discord", "prod/main");
2887        assert!(result.is_err(), "slash in alias must be rejected");
2888    }
2889
2890    #[test]
2891    fn create_map_key_rejects_alias_with_space() {
2892        let temp = TempDir::new().unwrap();
2893        let mut cfg = test_cfg(&temp);
2894        let result = cfg.create_map_key("channels.discord", "my alias");
2895        assert!(result.is_err(), "space in alias must be rejected");
2896    }
2897
2898    #[test]
2899    fn create_map_key_rejects_alias_starting_with_hyphen() {
2900        let temp = TempDir::new().unwrap();
2901        let mut cfg = test_cfg(&temp);
2902        let result = cfg.create_map_key("channels.discord", "-bad");
2903        assert!(result.is_err(), "leading hyphen in alias must be rejected");
2904    }
2905
2906    #[test]
2907    fn create_map_key_accepts_valid_alias() {
2908        let temp = TempDir::new().unwrap();
2909        let mut cfg = test_cfg(&temp);
2910        // V0.8.0: aliases must be lowercase ASCII alphanumeric only — see
2911        // `validate_alias_key`.
2912        let result = cfg.create_map_key("channels.discord", "prodalerts");
2913        assert!(result.is_ok(), "valid alias must be accepted");
2914        assert!(cfg.channels.discord.contains_key("prodalerts"));
2915    }
2916
2917    #[test]
2918    fn create_map_key_rejects_invalid_on_providers_double_nested() {
2919        let temp = TempDir::new().unwrap();
2920        let mut cfg = test_cfg(&temp);
2921        // The typed `anthropic` slot is already declared on `ModelProviders`;
2922        // no insert needed to access it. The test below verifies that a dotted
2923        // alias key is rejected by `create_map_key`.
2924        // Now try to add an alias with a dot in the name.
2925        let result = cfg.create_map_key("providers.models.anthropic", "my.alias");
2926        assert!(
2927            result.is_err(),
2928            "dot in double-nested alias must be rejected"
2929        );
2930        assert!(
2931            cfg.providers.models.find("anthropic", "my.alias").is_none(),
2932            "no entry should be inserted into the inner map"
2933        );
2934    }
2935
2936    // US-2: get_map_keys returns all configured aliases, not just "default".
2937    // Covers the gateway endpoint regression where MapKeyQuery required `key`
2938    // and returned 400 on every alias-list fetch.
2939    #[tokio::test]
2940    async fn get_map_keys_returns_all_channel_aliases() {
2941        use zeroclaw_config::schema::DiscordConfig;
2942
2943        let temp = TempDir::new().unwrap();
2944        let mut cfg = test_cfg(&temp);
2945        cfg.channels
2946            .discord
2947            .insert("default".into(), DiscordConfig::default());
2948        cfg.channels
2949            .discord
2950            .insert("alerts".into(), DiscordConfig::default());
2951
2952        let mut keys = cfg
2953            .get_map_keys("channels.discord")
2954            .expect("discord has two entries — get_map_keys must return Some");
2955        keys.sort();
2956        assert_eq!(keys, vec!["alerts", "default"]);
2957    }
2958
2959    // US-2 / model model_providers: get_map_keys returns all model_provider aliases across
2960    // both the type and alias layers.
2961    #[tokio::test]
2962    async fn get_map_keys_returns_all_provider_aliases() {
2963        let temp = TempDir::new().unwrap();
2964        let mut cfg = test_cfg(&temp);
2965        cfg.providers
2966            .models
2967            .anthropic
2968            .insert("default".into(), AnthropicModelProviderConfig::default());
2969        cfg.providers
2970            .models
2971            .anthropic
2972            .insert("work".into(), AnthropicModelProviderConfig::default());
2973
2974        let mut keys = cfg
2975            .get_map_keys("providers.models.anthropic")
2976            .expect("anthropic has two aliases — get_map_keys must return Some");
2977        keys.sort();
2978        assert_eq!(keys, vec!["default", "work"]);
2979    }
2980
2981    // Regression: the alias-ref picker must pre-position the cursor on
2982    // whichever entry in `available` matches the field's currently-stored
2983    // value, regardless of `available`'s ordering. Probe via a recorder
2984    // that captures the `current: Option<usize>` the picker passes to
2985    // `ui.select`, and uses Answer::Back to bail without persisting.
2986    /// Codex subscription auth: picking "Codex subscription" for an OpenAI provider
2987    /// must set `requires_openai_auth = true` and `wire_api = responses`, without
2988    /// prompting for an API key.
2989    #[tokio::test]
2990    async fn openai_codex_subscription_auth_sets_flags() {
2991        let temp = TempDir::new().unwrap();
2992        let mut cfg = test_cfg(&temp);
2993
2994        let flags = Flags {
2995            model: Some("codex-mini-latest".into()),
2996            ..Default::default()
2997        };
2998        let mut ui = QuickUi::new()
2999            .with("ModelProvider", "OpenAI")
3000            // Accept "default" alias via placeholder fallback (no scripted answer needed)
3001            .with(
3002                i18n::get_required_cli_string("onboard-openai-auth-prompt"),
3003                i18n::get_required_cli_string("onboard-openai-auth-codex"),
3004            );
3005
3006        Box::pin(run(
3007            &mut cfg,
3008            &mut ui,
3009            Some(Section::ModelProviders),
3010            &flags,
3011        ))
3012        .await
3013        .unwrap();
3014
3015        let entry = cfg
3016            .providers
3017            .models
3018            .find("openai", "default")
3019            .expect("openai.default entry should be seeded");
3020        assert!(
3021            entry.requires_openai_auth,
3022            "requires_openai_auth must be true for Codex subscription"
3023        );
3024        assert_eq!(
3025            entry.wire_api,
3026            Some(WireApi::Responses),
3027            "wire_api must be Responses for Codex subscription"
3028        );
3029        assert_eq!(entry.model.as_deref(), Some("codex-mini-latest"));
3030        assert!(
3031            entry.api_key.is_none(),
3032            "Codex subscription must not prompt for or store an API key"
3033        );
3034    }
3035
3036    /// Switching an existing Codex subscription alias back to API key auth must
3037    /// clear `requires_openai_auth` and prompt for the key.
3038    #[tokio::test]
3039    async fn openai_api_key_auth_clears_codex_flags() {
3040        use zeroclaw_config::schema::OpenAIModelProviderConfig;
3041
3042        let temp = TempDir::new().unwrap();
3043        let mut cfg = test_cfg(&temp);
3044
3045        // Pre-seed an alias that was previously set up with Codex subscription auth.
3046        cfg.providers.models.openai.insert(
3047            "default".to_string(),
3048            OpenAIModelProviderConfig {
3049                base: ModelProviderConfig {
3050                    requires_openai_auth: true,
3051                    wire_api: Some(WireApi::Responses),
3052                    model: Some("codex-mini-latest".into()),
3053                    ..Default::default()
3054                },
3055            },
3056        );
3057
3058        let flags = Flags {
3059            model: Some("gpt-4o".into()),
3060            ..Default::default()
3061        };
3062        let mut ui = QuickUi::new()
3063            .with("ModelProvider", "OpenAI")
3064            .with("Alias", "default")
3065            .with(
3066                i18n::get_required_cli_string("onboard-openai-auth-prompt"),
3067                i18n::get_required_cli_string("onboard-openai-auth-api-key"),
3068            )
3069            .with("api-key", "sk-test-key");
3070
3071        Box::pin(run(
3072            &mut cfg,
3073            &mut ui,
3074            Some(Section::ModelProviders),
3075            &flags,
3076        ))
3077        .await
3078        .unwrap();
3079
3080        let entry = cfg
3081            .providers
3082            .models
3083            .find("openai", "default")
3084            .expect("openai.default entry should remain configured");
3085        assert!(
3086            !entry.requires_openai_auth,
3087            "requires_openai_auth must be false after switching to API key"
3088        );
3089        assert_eq!(entry.api_key.as_deref(), Some("sk-test-key"));
3090    }
3091
3092    /// Regression: `zeroclaw onboard --model-provider openai --api-key sk-...` must
3093    /// clear `requires_openai_auth` even when the alias was previously configured for
3094    /// Codex subscription auth (forced-flag path, no interactive auth phase).
3095    #[tokio::test]
3096    async fn openai_forced_api_key_flag_clears_codex_auth() {
3097        use zeroclaw_config::schema::OpenAIModelProviderConfig;
3098
3099        let temp = TempDir::new().unwrap();
3100        let mut cfg = test_cfg(&temp);
3101
3102        // Pre-seed an alias that was previously set up with Codex subscription auth.
3103        cfg.providers.models.openai.insert(
3104            "default".to_string(),
3105            OpenAIModelProviderConfig {
3106                base: ModelProviderConfig {
3107                    requires_openai_auth: true,
3108                    wire_api: Some(WireApi::Responses),
3109                    model: Some("codex-mini-latest".into()),
3110                    ..Default::default()
3111                },
3112            },
3113        );
3114
3115        // Rerun onboarding with --model-provider openai --api-key sk-... (forced path).
3116        // The interactive auth picker is skipped; requires_openai_auth must still be cleared.
3117        let flags = Flags {
3118            model_provider: Some("openai".into()),
3119            api_key: Some("sk-forced-key".into()),
3120            model: Some("gpt-4o".into()),
3121            ..Default::default()
3122        };
3123        let mut ui = QuickUi::new();
3124
3125        Box::pin(run(
3126            &mut cfg,
3127            &mut ui,
3128            Some(Section::ModelProviders),
3129            &flags,
3130        ))
3131        .await
3132        .unwrap();
3133
3134        let entry = cfg
3135            .providers
3136            .models
3137            .find("openai", "default")
3138            .expect("openai.default entry should remain configured");
3139        assert!(
3140            !entry.requires_openai_auth,
3141            "requires_openai_auth must be cleared when --api-key flag is used on a Codex alias"
3142        );
3143        assert_eq!(
3144            entry.api_key.as_deref(),
3145            Some("sk-forced-key"),
3146            "forced api_key must be persisted"
3147        );
3148    }
3149
3150    #[tokio::test]
3151    async fn agent_alias_picker_preselects_stored_value() {
3152        use zeroclaw_config::schema::AliasedAgentConfig;
3153
3154        struct Capture {
3155            current: Option<usize>,
3156            items: Vec<String>,
3157        }
3158        #[async_trait::async_trait]
3159        impl OnboardUi for Capture {
3160            async fn confirm(&mut self, _: &str, _: bool) -> anyhow::Result<Answer<bool>> {
3161                Ok(Answer::Back)
3162            }
3163            async fn string(
3164                &mut self,
3165                _: &str,
3166                _: Option<&str>,
3167                _: Option<&str>,
3168            ) -> anyhow::Result<Answer<String>> {
3169                Ok(Answer::Back)
3170            }
3171            async fn secret(&mut self, _: &str, _: bool) -> anyhow::Result<Answer<Option<String>>> {
3172                Ok(Answer::Back)
3173            }
3174            async fn select(
3175                &mut self,
3176                _: &str,
3177                items: &[SelectItem],
3178                current: Option<usize>,
3179            ) -> anyhow::Result<Answer<usize>> {
3180                self.current = current;
3181                self.items = items.iter().map(|i| i.label.clone()).collect();
3182                Ok(Answer::Back)
3183            }
3184            async fn editor(&mut self, _: &str, _: &str) -> anyhow::Result<Answer<String>> {
3185                Ok(Answer::Back)
3186            }
3187            fn heading(&mut self, _: u8, _: &str) {}
3188            fn note(&mut self, _: &str) {}
3189            fn status(&mut self, _: &str) {}
3190            fn warn(&mut self, _: &str) {}
3191        }
3192
3193        for available in [
3194            vec!["clamps".to_string(), "glados".to_string()],
3195            vec!["glados".to_string(), "clamps".to_string()],
3196        ] {
3197            let temp = TempDir::new().unwrap();
3198            let mut cfg = test_cfg(&temp);
3199            cfg.agents.insert(
3200                "clamps".into(),
3201                AliasedAgentConfig {
3202                    risk_profile: "clamps".into(),
3203                    ..AliasedAgentConfig::default()
3204                },
3205            );
3206
3207            let mut ui = Capture {
3208                current: None,
3209                items: Vec::new(),
3210            };
3211            prompt_agent_alias_single(&mut cfg, &mut ui, "clamps", "risk_profile", &available)
3212                .await
3213                .unwrap();
3214
3215            let cursor = ui.current.expect("ui.select must receive a current index");
3216            let highlighted = ui.items.get(cursor).cloned().unwrap_or_default();
3217            assert_eq!(
3218                highlighted, "clamps",
3219                "available={available:?}, items={:?}, cursor={cursor} — \
3220                 expected cursor on \"clamps\"",
3221                ui.items,
3222            );
3223        }
3224    }
3225}