use bcode_settings::{
ReconciledSetupSection, SettingsDbHealth, SettingsDegradedPanel, SettingsError, SettingsStore,
SetupConfigSummary, SetupMapSnapshot, SetupReadinessReport, SetupReconciliationInput,
SetupSectionId, SetupSectionStatus, settings_degraded_panel,
};
use bmux_tui_components::stepper::{StepItem, StepStatus};
use crate::setup_board::{
BoardConnection, BoardSpot, SetupBoard, SetupBoardOutcome, SetupBoardState,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OnboardingShell {
sections: Vec<ReconciledSetupSection>,
focused_index: usize,
status_message: Option<String>,
pending_confirmation: Option<OnboardingPendingConfirmation>,
board_state: SetupBoardState,
credential_discovery_enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OnboardingWalkthroughReport {
pub initial_section: SetupSectionId,
pub next_section: SetupSectionId,
pub previous_section: SetupSectionId,
pub step_count: usize,
pub rendered_map: String,
pub persisted_focus_reloaded: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnboardingInputAction {
Next,
Previous,
Select,
ToggleProvider,
ToggleAuthProfile,
SelectModelProfile,
CyclePermissionPreset,
ReviewSessionImport,
ReviewPlugins,
ApplyPlan,
Confirm,
CancelConfirmation,
Complete,
Skip,
Launch,
Quit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnboardingActionOutcome {
FocusChanged(SetupSectionId),
Selected(SetupSectionId),
Completed(SetupSectionId),
Skipped(SetupSectionId),
LaunchReady,
Quit,
Ignored,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OnboardingPendingConfirmation {
pub title: String,
pub body: String,
pub action: OnboardingInputAction,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OnboardingSectionDetail {
pub section_id: SetupSectionId,
pub title: String,
pub story: String,
pub status: String,
pub actions: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OnboardingRenderModel {
pub map_lines: Vec<String>,
pub focused_detail: OnboardingSectionDetail,
pub footer_lines: Vec<String>,
pub degraded_panel: Option<SettingsDegradedPanel>,
pub readiness_report: Option<SetupReadinessReport>,
pub pending_confirmation: Option<OnboardingPendingConfirmation>,
}
impl OnboardingRenderModel {
#[must_use]
pub fn snapshot_text(&self) -> String {
let mut lines = self.map_lines.clone();
lines.extend(self.footer_lines.clone());
if let Some(panel) = &self.degraded_panel {
lines.push(panel.message.clone());
}
if let Some(report) = &self.readiness_report {
lines.extend(
report
.items
.iter()
.map(|item| format!("{}: {}", item.section_id.as_str(), item.title)),
);
}
if let Some(confirmation) = &self.pending_confirmation {
lines.push(format!("confirm: {}", confirmation.title));
lines.push(confirmation.body.clone());
}
lines.join("\n")
}
#[must_use]
pub const fn has_visual_composition(&self) -> bool {
!self.map_lines.is_empty()
&& !self.focused_detail.title.is_empty()
&& !self.focused_detail.story.is_empty()
&& !self.focused_detail.actions.is_empty()
&& self.footer_lines.len() >= 2
}
#[must_use]
pub fn secret_audit(&self) -> bcode_settings::SecretAuditReport {
bcode_settings::audit_no_secret_material("onboarding-render", &self.snapshot_text())
}
}
impl OnboardingShell {
pub fn load(
store: &SettingsStore,
config_summary: &SetupConfigSummary,
) -> Result<Self, SettingsError> {
let persisted_sections = store.onboarding_sections()?;
let progress = store.onboarding_progress()?;
let mut input = config_summary.reconciliation_input();
input.current_section = progress
.and_then(|progress| progress.last_section)
.as_deref()
.and_then(setup_section_id_from_str);
Ok(Self::from_reconciliation(&persisted_sections, &input))
}
#[must_use]
pub fn from_reconciliation(
persisted_sections: &[bcode_settings::OnboardingSection],
input: &SetupReconciliationInput,
) -> Self {
let snapshot = SetupMapSnapshot::from_reconciliation(persisted_sections, input);
let focused_index = snapshot
.sections
.iter()
.position(|section| section.status == SetupSectionStatus::Current)
.unwrap_or_default();
let focused_section_id = snapshot
.sections
.get(focused_index)
.map_or(SetupSectionId::Welcome, |section| section.section_id);
Self {
sections: snapshot.sections,
focused_index,
status_message: None,
pending_confirmation: None,
board_state: SetupBoardState::new(focused_section_id),
credential_discovery_enabled: false,
}
}
#[must_use]
pub const fn has_pending_confirmation(&self) -> bool {
self.pending_confirmation.is_some()
}
pub const fn set_credential_discovery_enabled(&mut self, enabled: bool) {
self.credential_discovery_enabled = enabled;
}
#[must_use]
pub fn sections(&self) -> &[ReconciledSetupSection] {
&self.sections
}
#[must_use]
pub fn section_status(&self, section_id: SetupSectionId) -> SetupSectionStatus {
self.sections
.iter()
.find(|section| section.section_id == section_id)
.map_or(SetupSectionStatus::Unvisited, |section| section.status)
}
#[must_use]
pub fn focused_section(&self) -> SetupSectionId {
self.sections
.get(self.focused_index)
.map_or(SetupSectionId::Welcome, |section| section.section_id)
}
pub fn set_status_message(&mut self, message: String) {
self.status_message = Some(message);
}
pub fn focus_next(&mut self) {
if self.sections.is_empty() {
return;
}
self.focused_index = (self.focused_index + 1).min(self.sections.len().saturating_sub(1));
self.mark_current_focus();
}
pub fn focus_previous(&mut self) {
self.focused_index = self.focused_index.saturating_sub(1);
self.mark_current_focus();
}
pub fn persist_focus(
&self,
store: &SettingsStore,
visited_at_ms: u64,
) -> Result<(), SettingsError> {
store.visit_onboarding_section(self.focused_section(), visited_at_ms)
}
#[must_use]
pub fn status_message(&self) -> Option<&str> {
self.status_message.as_deref()
}
pub fn complete_focused_section(
&mut self,
store: &SettingsStore,
completed_at_ms: u64,
) -> Result<(), SettingsError> {
let section_id = self.focused_section();
store.complete_onboarding_section(section_id, completed_at_ms)?;
if let Some(section) = self.sections.get_mut(self.focused_index) {
section.status = SetupSectionStatus::Complete;
section.visited = true;
}
self.status_message = Some(format!(
"{} marked complete",
setup_section_label(section_id)
));
Ok(())
}
pub fn skip_focused_section(
&mut self,
store: &SettingsStore,
skipped_at_ms: u64,
) -> Result<(), SettingsError> {
let section_id = self.focused_section();
store.skip_onboarding_section(section_id, skipped_at_ms)?;
if let Some(section) = self.sections.get_mut(self.focused_index) {
section.status = SetupSectionStatus::Skipped;
section.visited = true;
}
self.status_message = Some(format!("{} skipped", setup_section_label(section_id)));
Ok(())
}
pub fn launch_from_onboarding(
&mut self,
_store: &SettingsStore,
_completed_at_ms: u64,
) -> Result<(), SettingsError> {
self.status_message = Some("Checking effective setup before starting a session".to_owned());
Ok(())
}
pub fn handle_action(
&mut self,
action: OnboardingInputAction,
store: &SettingsStore,
at_ms: u64,
) -> Result<OnboardingActionOutcome, SettingsError> {
match action {
OnboardingInputAction::Next | OnboardingInputAction::Previous => {
Ok(self.handle_navigation_action(action))
}
OnboardingInputAction::Select => self.handle_select_action(store, at_ms),
OnboardingInputAction::ToggleProvider
| OnboardingInputAction::ToggleAuthProfile
| OnboardingInputAction::SelectModelProfile
| OnboardingInputAction::CyclePermissionPreset
| OnboardingInputAction::ReviewSessionImport
| OnboardingInputAction::ReviewPlugins => {
self.handle_draft_action(action, store, at_ms)
}
OnboardingInputAction::ApplyPlan => Ok(self.request_apply_confirmation()),
OnboardingInputAction::Confirm => self.confirm_pending_action(store, at_ms),
OnboardingInputAction::CancelConfirmation => {
self.pending_confirmation = None;
self.status_message = Some("confirmation cancelled".to_owned());
Ok(OnboardingActionOutcome::Ignored)
}
OnboardingInputAction::Complete
| OnboardingInputAction::Skip
| OnboardingInputAction::Launch => self.handle_completion_action(action, store, at_ms),
OnboardingInputAction::Quit => Ok(OnboardingActionOutcome::Quit),
}
}
fn handle_navigation_action(
&mut self,
action: OnboardingInputAction,
) -> OnboardingActionOutcome {
match action {
OnboardingInputAction::Next => self.focus_next(),
OnboardingInputAction::Previous => self.focus_previous(),
_ => {}
}
OnboardingActionOutcome::FocusChanged(self.focused_section())
}
fn handle_select_action(
&mut self,
store: &SettingsStore,
at_ms: u64,
) -> Result<OnboardingActionOutcome, SettingsError> {
self.persist_focus(store, at_ms)?;
self.status_message = Some(format!(
"{} selected",
setup_section_label(self.focused_section())
));
Ok(OnboardingActionOutcome::Selected(self.focused_section()))
}
fn handle_draft_action(
&mut self,
action: OnboardingInputAction,
store: &SettingsStore,
at_ms: u64,
) -> Result<OnboardingActionOutcome, SettingsError> {
match action {
OnboardingInputAction::ToggleProvider => self.toggle_provider(store, at_ms),
OnboardingInputAction::ToggleAuthProfile => self.toggle_auth_profile(store, at_ms),
OnboardingInputAction::SelectModelProfile => self.select_model_profile(store, at_ms),
OnboardingInputAction::CyclePermissionPreset => {
self.cycle_permission_preset(store, at_ms)
}
OnboardingInputAction::ReviewSessionImport => self.review_session_import(store, at_ms),
OnboardingInputAction::ReviewPlugins => self.review_plugins(store, at_ms),
_ => Ok(OnboardingActionOutcome::Ignored),
}
}
fn toggle_provider(
&mut self,
store: &SettingsStore,
at_ms: u64,
) -> Result<OnboardingActionOutcome, SettingsError> {
let draft = store.toggle_draft_provider("openai-compatible", at_ms)?;
self.status_message = Some(format!(
"selected providers: {}",
draft
.providers
.iter()
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
Ok(OnboardingActionOutcome::Selected(SetupSectionId::Providers))
}
fn toggle_auth_profile(
&mut self,
store: &SettingsStore,
at_ms: u64,
) -> Result<OnboardingActionOutcome, SettingsError> {
let draft = store.toggle_draft_auth_profile("default", at_ms)?;
self.status_message = Some(format!(
"selected auth profiles: {}",
draft
.auth_profiles
.iter()
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
Ok(OnboardingActionOutcome::Selected(
SetupSectionId::SecureVault,
))
}
fn select_model_profile(
&mut self,
store: &SettingsStore,
at_ms: u64,
) -> Result<OnboardingActionOutcome, SettingsError> {
let draft = store.select_draft_model_profile("default", at_ms)?;
self.status_message = Some(format!(
"selected model profile: {}",
draft.model_profile.unwrap_or_else(|| "default".to_owned())
));
Ok(OnboardingActionOutcome::Selected(SetupSectionId::Models))
}
fn cycle_permission_preset(
&mut self,
store: &SettingsStore,
at_ms: u64,
) -> Result<OnboardingActionOutcome, SettingsError> {
let draft = store.cycle_draft_permission_preset(at_ms)?;
self.status_message = Some(format!(
"permission preset: {}",
draft
.permission_preset
.unwrap_or_else(|| "balanced".to_owned())
));
Ok(OnboardingActionOutcome::Selected(
SetupSectionId::Permissions,
))
}
fn review_session_import(
&mut self,
store: &SettingsStore,
at_ms: u64,
) -> Result<OnboardingActionOutcome, SettingsError> {
let mut draft = store.onboarding_draft_setup()?;
draft.session_import_reviewed = true;
store.save_onboarding_draft_setup(&draft, at_ms)?;
self.status_message = Some("session import reviewed".to_owned());
Ok(OnboardingActionOutcome::Selected(SetupSectionId::Imports))
}
fn review_plugins(
&mut self,
store: &SettingsStore,
at_ms: u64,
) -> Result<OnboardingActionOutcome, SettingsError> {
let mut draft = store.onboarding_draft_setup()?;
draft.plugins_reviewed = true;
store.save_onboarding_draft_setup(&draft, at_ms)?;
self.status_message = Some("plugin setup reviewed".to_owned());
Ok(OnboardingActionOutcome::Selected(SetupSectionId::Plugins))
}
fn request_apply_confirmation(&mut self) -> OnboardingActionOutcome {
self.pending_confirmation = Some(OnboardingPendingConfirmation {
title: "Apply setup plan?".to_owned(),
body: "Bcode will persist onboarding metadata, run secure imports only when explicitly requested, and reconcile actual config/auth state after apply. Confirm or cancel the reviewed plan.".to_owned(),
action: OnboardingInputAction::ApplyPlan,
});
self.status_message = Some("confirm setup apply".to_owned());
OnboardingActionOutcome::Ignored
}
fn confirm_pending_action(
&mut self,
store: &SettingsStore,
at_ms: u64,
) -> Result<OnboardingActionOutcome, SettingsError> {
let Some(confirmation) = self.pending_confirmation.take() else {
self.status_message = Some("nothing to confirm".to_owned());
return Ok(OnboardingActionOutcome::Ignored);
};
match confirmation.action {
OnboardingInputAction::ApplyPlan => self.handle_apply_plan_action(store, at_ms),
_ => Ok(OnboardingActionOutcome::Ignored),
}
}
fn handle_apply_plan_action(
&mut self,
store: &SettingsStore,
at_ms: u64,
) -> Result<OnboardingActionOutcome, SettingsError> {
let draft = store.onboarding_draft_setup()?;
let detection_entries = if self.credential_discovery_enabled {
store.detection_cache_entries()?
} else {
Vec::new()
};
let secure_import_plans =
bcode_settings::secure_import_plans_from_detection(&detection_entries);
apply_draft_to_user_config(&draft)?;
let config = bcode_config::load_config()?;
let plan = bcode_settings::generate_setup_plan_from_draft(
&draft,
&secure_import_plans,
&bcode_settings::SetupConfigSummary::from_config(&config),
);
let applied = store.apply_setup_plan(&plan, at_ms)?;
let reconciliation = store
.reconcile_setup_apply_with_discovery(&config, self.credential_discovery_enabled)?;
store.put_control_state(
"setup.last_apply_reconciliation",
&serde_json::to_value(&reconciliation)?,
at_ms,
)?;
self.status_message = Some(format!(
"applied {} setup actions; {} actions need external/domain follow-up",
applied.applied_actions.len(),
applied.skipped_actions.len()
));
Ok(OnboardingActionOutcome::Selected(SetupSectionId::Launch))
}
fn handle_completion_action(
&mut self,
action: OnboardingInputAction,
store: &SettingsStore,
at_ms: u64,
) -> Result<OnboardingActionOutcome, SettingsError> {
match action {
OnboardingInputAction::Complete => {
let section_id = self.focused_section();
self.complete_focused_section(store, at_ms)?;
Ok(OnboardingActionOutcome::Completed(section_id))
}
OnboardingInputAction::Skip => {
let section_id = self.focused_section();
self.skip_focused_section(store, at_ms)?;
Ok(OnboardingActionOutcome::Skipped(section_id))
}
OnboardingInputAction::Launch => {
self.launch_from_onboarding(store, at_ms)?;
Ok(OnboardingActionOutcome::LaunchReady)
}
_ => Ok(OnboardingActionOutcome::Ignored),
}
}
pub fn focus_section_index(&mut self, index: usize) {
if index < self.sections.len() {
self.focused_index = index;
self.mark_current_focus();
self.board_state.focused = self.sections[index].section_id;
}
}
#[must_use]
pub const fn board_state(&self) -> &SetupBoardState {
&self.board_state
}
pub fn render_board(
&self,
area: bmux_tui::geometry::Rect,
frame: &mut bmux_tui::paint::PaintCx<'_, '_>,
theme: &super::theme::PresentedTheme,
) {
let spots = self.board_spots();
let connections = onboarding_board_connections();
SetupBoard::new(&spots, &connections)
.theme(theme)
.render(area, &self.board_state, frame);
}
pub fn handle_board_event(
&mut self,
area: bmux_tui::geometry::Rect,
event: &bmux_tui::event::Event,
keymap: &super::keymap::BmuxKeyMap,
) -> SetupBoardOutcome {
let spots = self.board_spots();
let connections = onboarding_board_connections();
let outcome = SetupBoard::new(&spots, &connections).handle_event_with_keymap(
area,
&mut self.board_state,
event,
keymap,
);
if let SetupBoardOutcome::Selected(section_id) | SetupBoardOutcome::Focused(section_id) =
outcome
&& let Some(index) = self
.sections
.iter()
.position(|section| section.section_id == section_id)
{
self.focused_index = index;
self.mark_current_focus();
self.board_state.focused = section_id;
}
outcome
}
#[must_use]
pub fn board_spots(&self) -> Vec<BoardSpot> {
self.sections
.iter()
.map(|section| {
BoardSpot::new(
section.section_id,
setup_section_title(section.section_id),
setup_section_label(section.section_id),
section.status,
)
.layer(setup_section_layer(section.section_id))
})
.collect()
}
#[must_use]
pub fn step_items(&self) -> Vec<StepItem<'static>> {
self.sections
.iter()
.map(|section| {
StepItem::new(
section.section_id.as_str(),
setup_section_label(section.section_id),
)
.status(step_status(section.status))
})
.collect()
}
#[must_use]
pub fn render_text_map(&self) -> String {
SetupMapSnapshot {
sections: self.sections.clone(),
}
.render_text_map()
}
#[must_use]
pub fn render_model(
&self,
health: &SettingsDbHealth,
readiness_report: Option<SetupReadinessReport>,
) -> OnboardingRenderModel {
let map_lines = self
.sections
.iter()
.map(|section| {
format!(
"{} [{}]{}",
setup_section_label(section.section_id),
section.status.as_str(),
if section.visited { " ✓" } else { "" }
)
})
.collect();
OnboardingRenderModel {
map_lines,
focused_detail: self.focused_detail(),
footer_lines: vec![
"Navigate setup sections; select a section to review its available actions."
.to_owned(),
self.status_message.clone().unwrap_or_else(|| {
"Setup state is persisted locally and user config remains TOML-backed."
.to_owned()
}),
],
degraded_panel: (!matches!(health, SettingsDbHealth::Available))
.then(|| settings_degraded_panel(health)),
readiness_report,
pending_confirmation: self.pending_confirmation.clone(),
}
}
#[must_use]
pub fn focused_detail(&self) -> OnboardingSectionDetail {
let section_id = self.focused_section();
let status = self
.sections
.get(self.focused_index)
.map_or(SetupSectionStatus::Unvisited, |section| section.status);
OnboardingSectionDetail {
section_id,
title: setup_section_label(section_id).to_owned(),
story: setup_section_story(section_id).to_owned(),
status: status.as_str().to_owned(),
actions: setup_section_actions(section_id)
.iter()
.map(ToString::to_string)
.collect(),
}
}
pub fn smoke_walkthrough(
store: &SettingsStore,
config_summary: &SetupConfigSummary,
visited_at_ms: u64,
) -> Result<OnboardingWalkthroughReport, SettingsError> {
let mut shell = Self::load(store, config_summary)?;
let initial_section = shell.focused_section();
let rendered_map = shell.render_text_map();
let step_count = shell.step_items().len();
shell.focus_next();
let next_section = shell.focused_section();
shell.persist_focus(store, visited_at_ms)?;
let reloaded = Self::load(store, config_summary)?;
let persisted_focus_reloaded = reloaded.focused_section() == next_section;
shell.focus_previous();
let previous_section = shell.focused_section();
Ok(OnboardingWalkthroughReport {
initial_section,
next_section,
previous_section,
step_count,
rendered_map,
persisted_focus_reloaded,
})
}
fn mark_current_focus(&mut self) {
let focused = self.focused_section();
for section in &mut self.sections {
if section.section_id == focused {
section.status = SetupSectionStatus::Current;
section.visited = true;
} else if section.status == SetupSectionStatus::Current {
section.status = if section.visited {
SetupSectionStatus::Visited
} else {
SetupSectionStatus::Unvisited
};
}
}
}
}
#[must_use]
pub const fn setup_section_title(section_id: SetupSectionId) -> &'static str {
match section_id {
SetupSectionId::Welcome => "Welcome",
SetupSectionId::Detection => "Detection",
SetupSectionId::SecureVault => "Secure Vault",
SetupSectionId::Providers => "Providers",
SetupSectionId::Models => "Models",
SetupSectionId::Permissions => "Permissions",
SetupSectionId::Imports => "Imports",
SetupSectionId::Plugins => "Plugins",
SetupSectionId::Launch => "Launch",
}
}
const fn setup_section_layer(section_id: SetupSectionId) -> u16 {
match section_id {
SetupSectionId::Welcome => 0,
SetupSectionId::Detection | SetupSectionId::SecureVault | SetupSectionId::Providers => 1,
SetupSectionId::Models => 2,
SetupSectionId::Permissions | SetupSectionId::Imports | SetupSectionId::Plugins => 3,
SetupSectionId::Launch => 4,
}
}
fn onboarding_board_connections() -> Vec<BoardConnection> {
vec![
BoardConnection::new(SetupSectionId::Welcome, SetupSectionId::Detection),
BoardConnection::new(SetupSectionId::Welcome, SetupSectionId::SecureVault),
BoardConnection::new(SetupSectionId::Welcome, SetupSectionId::Providers),
BoardConnection::new(SetupSectionId::Detection, SetupSectionId::Models),
BoardConnection::new(SetupSectionId::SecureVault, SetupSectionId::Models),
BoardConnection::new(SetupSectionId::Providers, SetupSectionId::Models),
BoardConnection::new(SetupSectionId::Models, SetupSectionId::Permissions),
BoardConnection::new(SetupSectionId::Models, SetupSectionId::Imports),
BoardConnection::new(SetupSectionId::Models, SetupSectionId::Plugins),
BoardConnection::new(SetupSectionId::Imports, SetupSectionId::Launch),
]
}
const fn setup_section_label(section_id: SetupSectionId) -> &'static str {
setup_section_title(section_id)
}
#[must_use]
pub const fn setup_section_story(section_id: SetupSectionId) -> &'static str {
match section_id {
SetupSectionId::Welcome => {
"Connect a provider and choose a model to get started. Optional settings can be revisited later."
}
SetupSectionId::Detection => {
"Review existing configuration and, when enabled, detected credential sources. Detection does not verify connectivity."
}
SetupSectionId::SecureVault => {
"Secure Vault keeps provider secrets out of plaintext config and guides them into sshenv-backed encrypted storage."
}
SetupSectionId::Providers => {
"Connect the AI providers and subscriptions you want Bcode to use."
}
SetupSectionId::Models => {
"Choose a model profile that balances speed, cost, and capability for your workflow."
}
SetupSectionId::Permissions => {
"Review what Bcode may do with tools and which operations require your approval."
}
SetupSectionId::Imports => {
"Session import is optional. Review supported sources without repairing or replaying history."
}
SetupSectionId::Plugins => {
"Review enabled plugins and disable capabilities you do not need."
}
SetupSectionId::Launch => {
"Review your configuration and resolve required setup before starting a session."
}
}
}
#[must_use]
pub const fn setup_section_actions(section_id: SetupSectionId) -> &'static [&'static str] {
match section_id {
SetupSectionId::Welcome => &["Review detected setup", "Choose quick or full setup"],
SetupSectionId::Detection => &["Run bounded detection", "Inspect safe metadata"],
SetupSectionId::SecureVault => {
&["Import credentials securely", "Explain sshenv/device seal"]
}
SetupSectionId::Providers => &["Add provider", "Choose default provider"],
SetupSectionId::Models => &["Pick default model", "Review model profile"],
SetupSectionId::Permissions => &["Choose permission preset", "Review tool boundaries"],
SetupSectionId::Imports => &["Review import sources", "Skip import for now"],
SetupSectionId::Plugins => &["Review bundled plugins", "Disable unwanted plugins"],
SetupSectionId::Launch => &["Review setup plan", "Launch Bcode"],
}
}
const fn step_status(status: SetupSectionStatus) -> StepStatus {
match status {
SetupSectionStatus::Current => StepStatus::Current,
SetupSectionStatus::Complete | SetupSectionStatus::Secured => StepStatus::Complete,
SetupSectionStatus::Recommended | SetupSectionStatus::NeedsAttention => StepStatus::Warning,
SetupSectionStatus::Blocked => StepStatus::Error,
SetupSectionStatus::Optional | SetupSectionStatus::Skipped => StepStatus::Disabled,
SetupSectionStatus::Visited | SetupSectionStatus::Unvisited => StepStatus::Pending,
}
}
fn setup_section_id_from_str(value: &str) -> Option<SetupSectionId> {
SetupSectionId::all()
.into_iter()
.find(|section| section.as_str() == value)
}
fn apply_draft_to_user_config(
draft: &bcode_settings::OnboardingDraftSetup,
) -> Result<(), SettingsError> {
for provider in &draft.providers {
match provider.as_str() {
"openai-compatible" | "openrouter" | "xai" => {
let provider_name = match provider.as_str() {
"openrouter" => "openrouter",
"xai" => "xai",
_ => "openai",
};
bcode_config::set_openai_compatible_sshenv_auth_mode(
provider_name,
draft
.auth_profiles
.iter()
.next()
.cloned()
.unwrap_or_else(|| "default".to_owned()),
bcode_config::default_auth_vault_path(),
draft.model_profile.clone(),
bcode_config::AuthMode::ApiKey,
None,
)?;
}
"bedrock" => {
bcode_config::set_bedrock_model_profile(
draft.model_profile.as_deref().unwrap_or("default"),
"anthropic.claude-3-5-sonnet-20241022-v2:0".to_owned(),
None,
None,
None,
&[],
)?;
}
_ => {}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use bcode_settings::OnboardingSection;
fn isolated_config_store() -> (tempfile::TempDir, SettingsStore, std::path::PathBuf) {
let temp = tempfile::tempdir().expect("temp dir should be created");
let config_path = std::path::PathBuf::from(
std::env::var_os("BCODE_CONFIG").expect("isolated child config"),
);
let store = SettingsStore::from_settings_db_path(temp.path().join("settings.db"));
(temp, store, config_path)
}
#[test]
fn shell_navigates_and_renders_step_items() {
let persisted = vec![OnboardingSection {
section_id: SetupSectionId::Welcome.as_str().to_owned(),
status: SetupSectionStatus::Visited.as_str().to_owned(),
visited: true,
visited_at_ms: Some(1),
completed_at_ms: None,
skipped_at_ms: None,
dismissed: false,
}];
let input = SetupReconciliationInput {
current_section: Some(SetupSectionId::Welcome),
..SetupReconciliationInput::default()
};
let mut shell = OnboardingShell::from_reconciliation(&persisted, &input);
assert_eq!(shell.focused_section(), SetupSectionId::Welcome);
assert!(
shell
.render_text_map()
.starts_with("welcome:current:visited")
);
shell.focus_next();
assert_eq!(shell.focused_section(), SetupSectionId::Detection);
shell.focus_previous();
assert_eq!(shell.focused_section(), SetupSectionId::Welcome);
assert_eq!(shell.step_items().len(), SetupSectionId::all().len());
}
#[test]
fn shell_handles_actions_and_builds_render_model() {
if std::env::var_os("BCODE_ONBOARDING_CONFIG_TEST_CHILD").is_none() {
let root = tempfile::tempdir().expect("child config root");
let status =
std::process::Command::new(std::env::current_exe().expect("test executable"))
.args([
"--exact",
"onboarding::tests::shell_handles_actions_and_builds_render_model",
"--nocapture",
])
.env("BCODE_ONBOARDING_CONFIG_TEST_CHILD", "1")
.env("BCODE_CONFIG", root.path().join("bcode.toml"))
.status()
.expect("isolated onboarding test");
assert!(status.success());
return;
}
check_shell_actions_and_render_model();
}
fn check_shell_actions_and_render_model() {
let (_temp, store, config_path) = isolated_config_store();
let summary = SetupConfigSummary::default();
let mut shell = OnboardingShell::load(&store, &summary).expect("shell should load");
assert_eq!(
shell
.handle_action(OnboardingInputAction::Next, &store, 50)
.expect("next should succeed"),
OnboardingActionOutcome::FocusChanged(SetupSectionId::Detection)
);
assert_eq!(
shell
.handle_action(OnboardingInputAction::Select, &store, 51)
.expect("select should persist"),
OnboardingActionOutcome::Selected(SetupSectionId::Detection)
);
assert_eq!(
shell
.handle_action(OnboardingInputAction::ToggleProvider, &store, 52)
.expect("provider toggle should persist"),
OnboardingActionOutcome::Selected(SetupSectionId::Providers)
);
assert_eq!(
shell
.handle_action(OnboardingInputAction::ToggleAuthProfile, &store, 53)
.expect("auth toggle should persist"),
OnboardingActionOutcome::Selected(SetupSectionId::SecureVault)
);
assert_eq!(
shell
.handle_action(OnboardingInputAction::SelectModelProfile, &store, 54)
.expect("model selection should persist"),
OnboardingActionOutcome::Selected(SetupSectionId::Models)
);
assert_eq!(
shell
.handle_action(OnboardingInputAction::CyclePermissionPreset, &store, 55)
.expect("permission preset should persist"),
OnboardingActionOutcome::Selected(SetupSectionId::Permissions)
);
assert_eq!(
shell
.handle_action(OnboardingInputAction::ReviewSessionImport, &store, 56)
.expect("import review should persist"),
OnboardingActionOutcome::Selected(SetupSectionId::Imports)
);
assert_eq!(
shell
.handle_action(OnboardingInputAction::ReviewPlugins, &store, 57)
.expect("plugin review should persist"),
OnboardingActionOutcome::Selected(SetupSectionId::Plugins)
);
let draft = store.onboarding_draft_setup().expect("draft should reload");
assert!(draft.providers.contains("openai-compatible"));
assert!(draft.auth_profiles.contains("default"));
assert_eq!(draft.model_profile.as_deref(), Some("default"));
assert_eq!(draft.permission_preset.as_deref(), Some("cautious"));
assert_eq!(
shell
.handle_action(OnboardingInputAction::ApplyPlan, &store, 58)
.expect("setup plan should request confirmation"),
OnboardingActionOutcome::Ignored
);
let render = shell.render_model(&SettingsDbHealth::Available, None);
assert!(render.pending_confirmation.is_some());
assert!(render.snapshot_text().contains("Apply setup plan?"));
assert_eq!(
shell
.handle_action(OnboardingInputAction::Confirm, &store, 59)
.expect("setup plan should apply"),
OnboardingActionOutcome::Selected(SetupSectionId::Launch)
);
assert!(
store
.control_state("setup.last_apply_reconciliation")
.expect("reconciliation should load")
.is_some()
);
let written_config =
std::fs::read_to_string(&config_path).expect("config should be written");
assert!(written_config.contains("bcode.openai-compatible"));
assert!(written_config.contains("backend = \"sshenv\""));
let render = shell.render_model(&SettingsDbHealth::Available, None);
assert!(render.has_visual_composition());
assert!(
render
.map_lines
.iter()
.any(|line| line.contains("Detection"))
);
assert!(
render
.footer_lines
.iter()
.any(|line| line.contains("setup sections"))
);
assert!(render.degraded_panel.is_none());
}
#[test]
fn render_model_includes_degraded_panel() {
let summary = SetupConfigSummary::default();
let shell = OnboardingShell::from_reconciliation(&[], &summary.reconciliation_input());
let render = shell.render_model(
&SettingsDbHealth::Unavailable {
message: "bad db".to_owned(),
},
None,
);
assert!(render.degraded_panel.is_some());
}
#[test]
fn render_model_snapshot_audits_secret_safe() {
let summary = SetupConfigSummary::default();
let shell = OnboardingShell::from_reconciliation(&[], &summary.reconciliation_input());
let render = shell.render_model(&SettingsDbHealth::Available, None);
let audit = render.secret_audit();
assert!(audit.safe);
assert!(render.snapshot_text().contains("Welcome"));
assert!(setup_section_story(SetupSectionId::Welcome).contains("Optional settings"));
assert!(!render.focused_detail.actions.is_empty());
}
#[test]
fn shell_completes_skips_and_launches_sections() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let store = SettingsStore::from_settings_db_path(temp.path().join("settings.db"));
let summary = SetupConfigSummary::default();
let mut shell = OnboardingShell::load(&store, &summary).expect("shell should load");
assert_eq!(
shell
.handle_action(OnboardingInputAction::Complete, &store, 60)
.expect("complete should persist"),
OnboardingActionOutcome::Completed(SetupSectionId::Welcome)
);
shell.focus_next();
assert_eq!(
shell
.handle_action(OnboardingInputAction::Skip, &store, 61)
.expect("skip should persist"),
OnboardingActionOutcome::Skipped(SetupSectionId::Detection)
);
assert_eq!(
shell
.handle_action(OnboardingInputAction::Launch, &store, 62)
.expect("launch should complete onboarding"),
OnboardingActionOutcome::LaunchReady
);
let sections = store.onboarding_sections().expect("sections should load");
let progress = store.onboarding_progress().expect("progress should load");
assert!(sections.iter().any(|section| {
section.section_id == SetupSectionId::Welcome.as_str()
&& section.status == SetupSectionStatus::Complete.as_str()
}));
assert!(sections.iter().any(|section| {
section.section_id == SetupSectionId::Detection.as_str()
&& section.status == SetupSectionStatus::Skipped.as_str()
}));
assert!(progress.is_none_or(|progress| !progress.first_run_completed));
assert_eq!(
shell.status_message(),
Some("Checking effective setup before starting a session")
);
}
#[test]
fn smoke_walkthrough_reports_navigation_and_persistence() {
let temp = tempfile::tempdir().expect("temp dir should be created");
let store = SettingsStore::from_settings_db_path(temp.path().join("settings.db"));
let summary = SetupConfigSummary::default();
let report = OnboardingShell::smoke_walkthrough(&store, &summary, 43)
.expect("smoke walkthrough should pass");
assert_eq!(report.initial_section, SetupSectionId::Welcome);
assert_eq!(report.next_section, SetupSectionId::Detection);
assert_eq!(report.previous_section, SetupSectionId::Welcome);
assert_eq!(report.step_count, SetupSectionId::all().len());
assert!(report.rendered_map.contains("welcome"));
assert!(report.persisted_focus_reloaded);
}
}