| 1 | //! Onboarding flow rendering and helpers. |
| 2 | |
| 3 | pub mod language; |
| 4 | pub mod mental_models; |
| 5 | pub mod trust_directory; |
| 6 | pub mod welcome; |
| 7 | |
| 8 | use std::path::{Path, PathBuf}; |
| 9 | |
| 10 | use ratatui::{ |
| 11 | Frame, |
| 12 | layout::Rect, |
| 13 | style::{Modifier, Style}, |
| 14 | text::{Line, Span}, |
| 15 | widgets::{Block, Borders, Padding, Paragraph, Wrap}, |
| 16 | }; |
| 17 | |
| 18 | use crate::palette; |
| 19 | use crate::tui::app::{App, OnboardingState}; |
| 20 | |
| 21 | const ONBOARDED_MARKER_FILE: &str = ".onboarded"; |
| 22 | |
| 23 | pub fn render(f: &mut Frame, area: Rect, app: &App) { |
| 24 | let block = Block::default().style(Style::default().bg(palette::WHALE_BG)); |
| 25 | f.render_widget(block, area); |
| 26 | |
| 27 | const TOP_MARGIN: u16 = 2; |
| 28 | let content_width = 76.min(area.width.saturating_sub(4)); |
| 29 | let content_height = 20.min(area.height.saturating_sub(TOP_MARGIN + 2)); |
| 30 | let content_area = Rect { |
| 31 | x: (area.width.saturating_sub(content_width)) / 2, |
| 32 | y: TOP_MARGIN, |
| 33 | width: content_width, |
| 34 | height: content_height, |
| 35 | }; |
| 36 | |
| 37 | let lines = match app.onboarding { |
| 38 | OnboardingState::Welcome => welcome::lines(app), |
| 39 | OnboardingState::Language => language::lines(app), |
| 40 | OnboardingState::Appearance => appearance_lines(app), |
| 41 | OnboardingState::Provider => provider_lines(app), |
| 42 | OnboardingState::TrustDirectory => { |
| 43 | // Inner text width: panel borders (2) plus horizontal padding (4). |
| 44 | trust_directory::lines(app, usize::from(content_width.saturating_sub(6))) |
| 45 | } |
| 46 | OnboardingState::MentalModels => mental_models::lines(app), |
| 47 | OnboardingState::Tips => tips_lines(app), |
| 48 | OnboardingState::None => Vec::new(), |
| 49 | }; |
| 50 | |
| 51 | if !lines.is_empty() { |
| 52 | let mut panel = Block::default() |
| 53 | .title(Line::from(Span::styled( |
| 54 | " Codewhale ", |
| 55 | Style::default() |
| 56 | .fg(palette::WHALE_HUMAN) |
| 57 | .add_modifier(Modifier::BOLD), |
| 58 | ))) |
| 59 | .borders(Borders::ALL) |
| 60 | .border_style(Style::default().fg(palette::BORDER_COLOR)) |
| 61 | .style(Style::default().bg(palette::WHALE_PANEL)) |
| 62 | .padding(Padding::new(2, 2, 1, 1)); |
| 63 | if !app.onboarding_workspace_trust_gate { |
| 64 | let (step, total) = onboarding_step(app); |
| 65 | panel = panel.title_bottom(Line::from(Span::styled( |
| 66 | format!(" Step {step}/{total} "), |
| 67 | Style::default() |
| 68 | .fg(palette::TEXT_MUTED) |
| 69 | .add_modifier(Modifier::BOLD), |
| 70 | ))); |
| 71 | } |
| 72 | let inner = panel.inner(content_area); |
| 73 | f.render_widget(panel, content_area); |
| 74 | let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); |
| 75 | f.render_widget(paragraph, inner); |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | /// Position and length of the first-run spine. |
| 80 | /// |
| 81 | /// Welcome, Language, Appearance (#3937), Mental Models, and Tips are always |
| 82 | /// shown; provider setup and the trust screen are conditional. |
| 83 | fn onboarding_step(app: &App) -> (usize, usize) { |
| 84 | let mut total = 5; |
| 85 | if app.onboarding_had_provider_step { |
| 86 | total += 1; |
| 87 | } |
| 88 | if app.onboarding_had_trust_step { |
| 89 | total += 1; |
| 90 | } |
| 91 | |
| 92 | let step = match app.onboarding { |
| 93 | OnboardingState::Welcome => 1, |
| 94 | OnboardingState::Language => 2, |
| 95 | OnboardingState::Appearance => 3, |
| 96 | OnboardingState::Provider => 4, |
| 97 | OnboardingState::TrustDirectory => { |
| 98 | if app.onboarding_had_provider_step { |
| 99 | 5 |
| 100 | } else { |
| 101 | 4 |
| 102 | } |
| 103 | } |
| 104 | OnboardingState::MentalModels => total - 1, |
| 105 | OnboardingState::Tips => total, |
| 106 | OnboardingState::None => total, |
| 107 | }; |
| 108 | |
| 109 | (step, total) |
| 110 | } |
| 111 | |
| 112 | /// The card rendered behind the theme picker on the appearance step (#3937). |
| 113 | /// |
| 114 | /// The picker itself owns the list and the live preview; this card carries the |
| 115 | /// promise (nothing is saved until Enter) in one short line, because that is |
| 116 | /// the part a first-run user cannot discover from the list alone. |
| 117 | fn appearance_lines(app: &App) -> Vec<ratatui::text::Line<'static>> { |
| 118 | use crate::localization::MessageId; |
| 119 | use ratatui::style::Modifier; |
| 120 | use ratatui::text::{Line, Span}; |
| 121 | |
| 122 | vec![ |
| 123 | Line::from(Span::styled( |
| 124 | app.tr(MessageId::OnboardAppearanceTitle).to_string(), |
| 125 | Style::default() |
| 126 | .fg(palette::WHALE_INFO) |
| 127 | .add_modifier(Modifier::BOLD), |
| 128 | )), |
| 129 | Line::from(""), |
| 130 | Line::from(Span::styled( |
| 131 | app.tr(MessageId::OnboardAppearanceBlurb).to_string(), |
| 132 | Style::default().fg(palette::TEXT_MUTED), |
| 133 | )), |
| 134 | Line::from(""), |
| 135 | Line::from(Span::styled( |
| 136 | app.tr(MessageId::OnboardAppearanceFooter).to_string(), |
| 137 | Style::default().fg(palette::TEXT_MUTED), |
| 138 | )), |
| 139 | ] |
| 140 | } |
| 141 | |
| 142 | pub fn tips_lines(app: &App) -> Vec<ratatui::text::Line<'static>> { |
| 143 | use crate::localization::MessageId; |
| 144 | use ratatui::style::Modifier; |
| 145 | use ratatui::text::{Line, Span}; |
| 146 | |
| 147 | let mut lines = vec![ |
| 148 | Line::from(Span::styled( |
| 149 | app.tr(MessageId::OnboardTipsTitle).to_string(), |
| 150 | Style::default() |
| 151 | .fg(palette::WHALE_INFO) |
| 152 | .add_modifier(Modifier::BOLD), |
| 153 | )), |
| 154 | Line::from(""), |
| 155 | ]; |
| 156 | // The offline choice is a durable posture, not a passing toast: the final |
| 157 | // screen states it plainly alongside the one command that recovers. |
| 158 | if app.onboarding_explore_offline { |
| 159 | lines.push(Line::from(Span::styled( |
| 160 | app.tr(MessageId::OnboardOfflineTipsLine).to_string(), |
| 161 | Style::default().fg(palette::STATUS_WARNING), |
| 162 | ))); |
| 163 | lines.push(Line::from("")); |
| 164 | } |
| 165 | lines.extend([ |
| 166 | Line::from(Span::raw(app.tr(MessageId::OnboardTipsLine1).to_string())), |
| 167 | Line::from(Span::raw(app.tr(MessageId::OnboardTipsLine2).to_string())), |
| 168 | Line::from(Span::raw(app.tr(MessageId::OnboardTipsLine3).to_string())), |
| 169 | Line::from(Span::raw(app.tr(MessageId::OnboardTipsLine4).to_string())), |
| 170 | Line::from(vec![ |
| 171 | Span::raw(app.tr(MessageId::OnboardTipsDoctorPrefix).to_string()), |
| 172 | Span::styled( |
| 173 | "codewhale doctor", |
| 174 | Style::default() |
| 175 | .fg(palette::TEXT_PRIMARY) |
| 176 | .add_modifier(Modifier::BOLD), |
| 177 | ), |
| 178 | Span::raw(app.tr(MessageId::OnboardTipsDoctorSuffix).to_string()), |
| 179 | ]), |
| 180 | Line::from(vec![ |
| 181 | Span::styled( |
| 182 | app.tr(MessageId::OnboardTipsFooterEnter).to_string(), |
| 183 | Style::default() |
| 184 | .fg(palette::TEXT_PRIMARY) |
| 185 | .add_modifier(Modifier::BOLD), |
| 186 | ), |
| 187 | Span::styled( |
| 188 | app.tr(MessageId::OnboardTipsFooterAction).to_string(), |
| 189 | Style::default().fg(palette::TEXT_MUTED), |
| 190 | ), |
| 191 | ]), |
| 192 | ]); |
| 193 | lines |
| 194 | } |
| 195 | |
| 196 | pub fn default_marker_path() -> Option<PathBuf> { |
| 197 | let primary_home = codewhale_config::codewhale_home().ok()?; |
| 198 | let legacy_home = if codewhale_config::codewhale_home_is_explicit() { |
| 199 | None |
| 200 | } else { |
| 201 | codewhale_config::legacy_deepseek_home().ok() |
| 202 | }; |
| 203 | Some(marker_path_with_roots( |
| 204 | &primary_home, |
| 205 | legacy_home.as_deref(), |
| 206 | )) |
| 207 | } |
| 208 | |
| 209 | #[cfg(test)] |
| 210 | fn marker_path_with_home(home: &Path) -> PathBuf { |
| 211 | marker_path_with_roots( |
| 212 | &home.join(".codewhale"), |
| 213 | Some(home.join(".deepseek").as_path()), |
| 214 | ) |
| 215 | } |
| 216 | |
| 217 | fn marker_path_with_roots(primary_home: &Path, legacy_home: Option<&Path>) -> PathBuf { |
| 218 | let primary = primary_home.join(ONBOARDED_MARKER_FILE); |
| 219 | if primary.exists() { |
| 220 | return primary; |
| 221 | } |
| 222 | if let Some(legacy_home) = legacy_home { |
| 223 | let legacy = legacy_home.join(ONBOARDED_MARKER_FILE); |
| 224 | if legacy.exists() { |
| 225 | return legacy; |
| 226 | } |
| 227 | } |
| 228 | primary |
| 229 | } |
| 230 | |
| 231 | pub fn is_onboarded() -> bool { |
| 232 | default_marker_path().is_some_and(|path| path.exists()) |
| 233 | } |
| 234 | |
| 235 | pub fn mark_onboarded() -> std::io::Result<PathBuf> { |
| 236 | let path = default_marker_path().ok_or_else(|| { |
| 237 | std::io::Error::new( |
| 238 | std::io::ErrorKind::NotFound, |
| 239 | "Codewhale home directory not found", |
| 240 | ) |
| 241 | })?; |
| 242 | mark_onboarded_at_path(path) |
| 243 | } |
| 244 | |
| 245 | #[cfg(test)] |
| 246 | fn mark_onboarded_at_home(home: &Path) -> std::io::Result<PathBuf> { |
| 247 | let path = marker_path_with_home(home); |
| 248 | mark_onboarded_at_path(path) |
| 249 | } |
| 250 | |
| 251 | fn mark_onboarded_at_path(path: PathBuf) -> std::io::Result<PathBuf> { |
| 252 | if let Some(parent) = path.parent() { |
| 253 | std::fs::create_dir_all(parent)?; |
| 254 | } |
| 255 | std::fs::write(&path, "")?; |
| 256 | Ok(path) |
| 257 | } |
| 258 | |
| 259 | pub fn needs_trust(workspace: &Path) -> bool { |
| 260 | if crate::config::is_workspace_trusted(workspace) { |
| 261 | return false; |
| 262 | } |
| 263 | |
| 264 | let markers = [ |
| 265 | workspace.join(".deepseek").join("trusted"), |
| 266 | workspace.join(".deepseek").join("trust.json"), |
| 267 | ]; |
| 268 | !markers.iter().any(|path| path.exists()) |
| 269 | } |
| 270 | |
| 271 | pub fn mark_trusted(workspace: &Path) -> anyhow::Result<PathBuf> { |
| 272 | crate::config::save_workspace_trust(workspace) |
| 273 | } |
| 274 | |
| 275 | /// Welcome → Language transition. Clears the status message bar. |
| 276 | pub fn advance_onboarding_from_welcome(app: &mut App) { |
| 277 | app.status_message = None; |
| 278 | app.onboarding = OnboardingState::Language; |
| 279 | } |
| 280 | |
| 281 | /// Language → appearance (#3937). |
| 282 | pub fn advance_onboarding_after_language(app: &mut App) { |
| 283 | app.status_message = None; |
| 284 | app.onboarding = OnboardingState::Appearance; |
| 285 | } |
| 286 | |
| 287 | /// Appearance → next step. Exactly the routing the language step used to |
| 288 | /// perform: the appearance step is inserted into the spine, it replaces |
| 289 | /// nothing. Routes to Provider setup when the session lacks a key, to |
| 290 | /// TrustDirectory when the workspace is untrusted, otherwise to the |
| 291 | /// mental-model primer. |
| 292 | pub fn advance_onboarding_after_appearance(app: &mut App) { |
| 293 | app.status_message = None; |
| 294 | if app.onboarding_needs_api_key { |
| 295 | app.onboarding = OnboardingState::Provider; |
| 296 | } else if !app.trust_mode && needs_trust(&app.workspace) { |
| 297 | app.onboarding = OnboardingState::TrustDirectory; |
| 298 | } else { |
| 299 | app.onboarding = OnboardingState::MentalModels; |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | /// Take the explicit "explore offline" exit advertised by Provider setup |
| 304 | /// (#3927). |
| 305 | /// |
| 306 | /// The contract this encodes, in full: |
| 307 | /// |
| 308 | /// * **No provider is selected and no route is activated.** This function must |
| 309 | /// never reach `switch_provider`, never persist `provider`, and never write a |
| 310 | /// credential. Callers pass only `&mut App`, which makes that structural. |
| 311 | /// * **No draft secret is owned by `App`.** The caller closes the canonical |
| 312 | /// picker before entering this transition, dropping its private draft. |
| 313 | /// * **`onboarding_needs_api_key` stays true**, because nothing was supplied. |
| 314 | /// The launch surface, `/setup`, and doctor keep telling the truth. |
| 315 | /// * **The remaining onboarding steps still run** — trust, then the mental |
| 316 | /// model primer and tips — so browsing offline is a complete first run and |
| 317 | /// not an early exit. |
| 318 | /// * Queue semantics are inherited from `offline_mode`, untouched here. |
| 319 | pub fn choose_offline_explore(app: &mut App) { |
| 320 | app.api_key_env_only = false; |
| 321 | app.onboarding_needs_api_key = true; |
| 322 | app.onboarding_explore_offline = true; |
| 323 | app.offline_mode = true; |
| 324 | // `advance_*` clears the status bar, so the label is applied after it. |
| 325 | advance_onboarding_after_provider(app); |
| 326 | app.status_message = Some( |
| 327 | app.tr(crate::localization::MessageId::OnboardOfflineNotice) |
| 328 | .into_owned(), |
| 329 | ); |
| 330 | app.needs_redraw = true; |
| 331 | } |
| 332 | |
| 333 | /// Clear the offline-explore label once a real route is activated (#3927). |
| 334 | /// |
| 335 | /// This is the *only* thing that retires the label: it is not time-based and |
| 336 | /// not cleared by dismissing a screen. |
| 337 | pub fn clear_offline_explore_on_route_activation(app: &mut App) { |
| 338 | app.onboarding_explore_offline = false; |
| 339 | } |
| 340 | |
| 341 | pub fn advance_onboarding_after_provider(app: &mut App) { |
| 342 | app.status_message = None; |
| 343 | if !app.trust_mode && needs_trust(&app.workspace) { |
| 344 | app.onboarding = OnboardingState::TrustDirectory; |
| 345 | } else if app.onboarding_missing_key_recovery { |
| 346 | app.onboarding = OnboardingState::Tips; |
| 347 | } else { |
| 348 | app.onboarding = OnboardingState::MentalModels; |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | pub fn back_from_mental_models(app: &mut App) { |
| 353 | app.status_message = None; |
| 354 | app.onboarding = if app.onboarding_had_trust_step { |
| 355 | OnboardingState::TrustDirectory |
| 356 | } else if app.onboarding_had_provider_step { |
| 357 | OnboardingState::Provider |
| 358 | } else { |
| 359 | OnboardingState::Appearance |
| 360 | }; |
| 361 | } |
| 362 | |
| 363 | fn provider_lines(app: &App) -> Vec<ratatui::text::Line<'static>> { |
| 364 | use crate::localization::MessageId; |
| 365 | use ratatui::style::Modifier; |
| 366 | use ratatui::text::{Line, Span}; |
| 367 | |
| 368 | vec![ |
| 369 | Line::from(Span::styled( |
| 370 | app.tr(MessageId::OnboardProviderTitle).to_string(), |
| 371 | Style::default() |
| 372 | .fg(palette::WHALE_INFO) |
| 373 | .add_modifier(Modifier::BOLD), |
| 374 | )), |
| 375 | Line::from(""), |
| 376 | Line::from(Span::styled( |
| 377 | app.tr(MessageId::OnboardProviderBlurb).to_string(), |
| 378 | Style::default().fg(palette::TEXT_MUTED), |
| 379 | )), |
| 380 | Line::from(""), |
| 381 | Line::from(Span::styled( |
| 382 | app.tr(MessageId::OnboardOfflineOption).to_string(), |
| 383 | Style::default().fg(palette::TEXT_MUTED), |
| 384 | )), |
| 385 | Line::from(Span::styled( |
| 386 | app.tr(MessageId::OnboardProviderFooter).to_string(), |
| 387 | Style::default().fg(palette::TEXT_MUTED), |
| 388 | )), |
| 389 | ] |
| 390 | } |
| 391 | |
| 392 | #[cfg(test)] |
| 393 | mod tests { |
| 394 | use super::*; |
| 395 | use crate::config::Config; |
| 396 | use crate::localization::Locale; |
| 397 | use crate::tui::app::{App, TuiOptions}; |
| 398 | use std::path::PathBuf; |
| 399 | |
| 400 | fn test_app_with_locale(locale: Locale) -> App { |
| 401 | let options = TuiOptions { |
| 402 | ..crate::test_support::test_tui_options(PathBuf::from(".")) |
| 403 | }; |
| 404 | let mut app = App::new(options, &Config::default()); |
| 405 | app.ui_locale = locale; |
| 406 | app |
| 407 | } |
| 408 | |
| 409 | fn flattened(lines: Vec<ratatui::text::Line<'static>>) -> String { |
| 410 | lines |
| 411 | .into_iter() |
| 412 | .flat_map(|line| { |
| 413 | line.spans |
| 414 | .into_iter() |
| 415 | .map(|span| span.content.to_string()) |
| 416 | .collect::<Vec<_>>() |
| 417 | }) |
| 418 | .collect::<Vec<_>>() |
| 419 | .join("\n") |
| 420 | } |
| 421 | |
| 422 | #[test] |
| 423 | fn tips_copy_points_to_setup_and_constitution() { |
| 424 | let app = test_app_with_locale(Locale::En); |
| 425 | let body = flattened(tips_lines(&app)); |
| 426 | |
| 427 | assert!(body.contains("/setup")); |
| 428 | assert!(body.contains("/constitution")); |
| 429 | assert!(body.contains("/provider")); |
| 430 | assert!(body.contains("/model")); |
| 431 | assert!(body.contains("codewhale doctor")); |
| 432 | assert!(body.contains("open setup if it needs attention")); |
| 433 | assert!(!body.contains("open the workspace")); |
| 434 | } |
| 435 | |
| 436 | #[test] |
| 437 | fn trust_footer_advertises_only_explicit_trust_keys() { |
| 438 | let app = test_app_with_locale(Locale::En); |
| 439 | let lines = trust_directory::lines(&app, 70); |
| 440 | let footer = lines |
| 441 | .last() |
| 442 | .expect("trust footer") |
| 443 | .spans |
| 444 | .iter() |
| 445 | .map(|span| span.content.as_ref()) |
| 446 | .collect::<String>(); |
| 447 | |
| 448 | assert_eq!( |
| 449 | footer, |
| 450 | "Press 1/Y to trust and continue, 2/U to continue without trusting, 3/N/Esc to quit Codewhale" |
| 451 | ); |
| 452 | } |
| 453 | |
| 454 | #[test] |
| 455 | fn fresh_install_marker_path_uses_codewhale_not_legacy() { |
| 456 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 457 | |
| 458 | let expected = tmp.path().join(".codewhale").join(ONBOARDED_MARKER_FILE); |
| 459 | assert_eq!(marker_path_with_home(tmp.path()), expected); |
| 460 | |
| 461 | let written = mark_onboarded_at_home(tmp.path()).expect("mark onboarded"); |
| 462 | assert_eq!(written, expected); |
| 463 | assert!(expected.exists()); |
| 464 | assert!( |
| 465 | !tmp.path().join(".deepseek").exists(), |
| 466 | "fresh onboarding must not recreate the legacy .deepseek dir" |
| 467 | ); |
| 468 | } |
| 469 | |
| 470 | #[test] |
| 471 | fn existing_legacy_marker_is_preserved() { |
| 472 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 473 | let legacy = tmp.path().join(".deepseek").join(ONBOARDED_MARKER_FILE); |
| 474 | std::fs::create_dir_all(legacy.parent().expect("legacy parent")).expect("mkdir legacy"); |
| 475 | std::fs::write(&legacy, "").expect("seed legacy marker"); |
| 476 | |
| 477 | assert_eq!(marker_path_with_home(tmp.path()), legacy); |
| 478 | assert_eq!( |
| 479 | mark_onboarded_at_home(tmp.path()).expect("mark onboarded"), |
| 480 | legacy |
| 481 | ); |
| 482 | } |
| 483 | |
| 484 | #[test] |
| 485 | fn codewhale_marker_wins_over_legacy_marker() { |
| 486 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 487 | let primary = tmp.path().join(".codewhale").join(ONBOARDED_MARKER_FILE); |
| 488 | let legacy = tmp.path().join(".deepseek").join(ONBOARDED_MARKER_FILE); |
| 489 | for marker in [&primary, &legacy] { |
| 490 | std::fs::create_dir_all(marker.parent().expect("marker parent")).expect("mkdir"); |
| 491 | std::fs::write(marker, "").expect("seed marker"); |
| 492 | } |
| 493 | |
| 494 | assert_eq!(marker_path_with_home(tmp.path()), primary); |
| 495 | } |
| 496 | |
| 497 | #[test] |
| 498 | fn explicit_codewhale_home_marker_survives_restart_resolution() { |
| 499 | let _env_lock = crate::test_support::lock_test_env(); |
| 500 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 501 | let ambient_home = tmp.path().join("ambient profile"); |
| 502 | let isolated_home = tmp.path().join("isolated Codewhale state"); |
| 503 | let ambient_legacy = ambient_home.join(".deepseek").join(ONBOARDED_MARKER_FILE); |
| 504 | std::fs::create_dir_all(ambient_legacy.parent().expect("legacy parent")) |
| 505 | .expect("mkdir legacy"); |
| 506 | std::fs::write(&ambient_legacy, "").expect("seed ambient legacy marker"); |
| 507 | let _home = crate::test_support::EnvVarGuard::set("HOME", &ambient_home); |
| 508 | let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &ambient_home); |
| 509 | let _codewhale_home = |
| 510 | crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &isolated_home); |
| 511 | |
| 512 | let expected = isolated_home.join(ONBOARDED_MARKER_FILE); |
| 513 | assert_eq!(default_marker_path().as_deref(), Some(expected.as_path())); |
| 514 | assert!(!is_onboarded()); |
| 515 | |
| 516 | let written = mark_onboarded().expect("mark onboarded"); |
| 517 | |
| 518 | assert_eq!(written, expected); |
| 519 | assert!(is_onboarded()); |
| 520 | assert_eq!(default_marker_path().as_deref(), Some(expected.as_path())); |
| 521 | assert!(ambient_legacy.exists(), "legacy marker remains untouched"); |
| 522 | assert!( |
| 523 | !ambient_home.join(".codewhale").exists(), |
| 524 | "an explicit state root must not write into the ambient profile" |
| 525 | ); |
| 526 | } |
| 527 | |
| 528 | // ── #3937: the "Make it yours" appearance step ─────────────────────── |
| 529 | |
| 530 | #[test] |
| 531 | fn appearance_sits_between_language_and_the_routing_the_language_step_used_to_do() { |
| 532 | // Untrusted workspace, key already present: language used to route |
| 533 | // straight to trust. It now routes through appearance and lands in |
| 534 | // exactly the same place. |
| 535 | let mut app = test_app_with_locale(Locale::En); |
| 536 | app.onboarding = OnboardingState::Language; |
| 537 | app.onboarding_needs_api_key = false; |
| 538 | app.trust_mode = false; |
| 539 | app.workspace = tempfile::tempdir().expect("tempdir").path().to_path_buf(); |
| 540 | |
| 541 | advance_onboarding_after_language(&mut app); |
| 542 | assert_eq!(app.onboarding, OnboardingState::Appearance); |
| 543 | advance_onboarding_after_appearance(&mut app); |
| 544 | assert_eq!(app.onboarding, OnboardingState::TrustDirectory); |
| 545 | |
| 546 | // And the credential path is unchanged too. |
| 547 | let mut keyless = test_app_with_locale(Locale::En); |
| 548 | keyless.onboarding = OnboardingState::Language; |
| 549 | keyless.onboarding_needs_api_key = true; |
| 550 | advance_onboarding_after_language(&mut keyless); |
| 551 | assert_eq!(keyless.onboarding, OnboardingState::Appearance); |
| 552 | advance_onboarding_after_appearance(&mut keyless); |
| 553 | assert_eq!(keyless.onboarding, OnboardingState::Provider); |
| 554 | } |
| 555 | |
| 556 | #[test] |
| 557 | fn the_step_counter_grows_with_the_spine_instead_of_overflowing() { |
| 558 | // The shortest first run: no key step, no trust step. |
| 559 | let mut app = test_app_with_locale(Locale::En); |
| 560 | app.onboarding_had_provider_step = false; |
| 561 | app.onboarding_had_trust_step = false; |
| 562 | app.onboarding = OnboardingState::Appearance; |
| 563 | let (step, total) = onboarding_step(&app); |
| 564 | assert_eq!((step, total), (3, 5)); |
| 565 | |
| 566 | // The longest: provider setup plus trust. |
| 567 | app.onboarding_had_provider_step = true; |
| 568 | app.onboarding_had_trust_step = true; |
| 569 | for (state, expected) in [ |
| 570 | (OnboardingState::Welcome, 1), |
| 571 | (OnboardingState::Language, 2), |
| 572 | (OnboardingState::Appearance, 3), |
| 573 | (OnboardingState::Provider, 4), |
| 574 | (OnboardingState::TrustDirectory, 5), |
| 575 | (OnboardingState::MentalModels, 6), |
| 576 | (OnboardingState::Tips, 7), |
| 577 | ] { |
| 578 | app.onboarding = state; |
| 579 | let (step, total) = onboarding_step(&app); |
| 580 | assert_eq!(step, expected, "{state:?}"); |
| 581 | assert_eq!(total, 7, "{state:?}"); |
| 582 | assert!(step <= total, "{state:?} overflowed the counter"); |
| 583 | } |
| 584 | } |
| 585 | |
| 586 | #[test] |
| 587 | fn back_from_the_primer_returns_to_appearance_when_there_was_no_provider_step() { |
| 588 | let mut app = test_app_with_locale(Locale::En); |
| 589 | app.onboarding = OnboardingState::MentalModels; |
| 590 | app.onboarding_had_provider_step = false; |
| 591 | app.onboarding_had_trust_step = false; |
| 592 | |
| 593 | back_from_mental_models(&mut app); |
| 594 | |
| 595 | assert_eq!(app.onboarding, OnboardingState::Appearance); |
| 596 | } |
| 597 | |
| 598 | #[test] |
| 599 | fn appearance_card_states_the_promise_the_theme_list_cannot() { |
| 600 | let mut app = test_app_with_locale(Locale::En); |
| 601 | app.onboarding = OnboardingState::Appearance; |
| 602 | let body = flattened(appearance_lines(&app)); |
| 603 | |
| 604 | assert!(body.contains("Make It Yours")); |
| 605 | // The card exists to say what the picker's list cannot: nothing is |
| 606 | // saved until Enter, and Esc puts back what was there. |
| 607 | assert!(body.contains("Enter")); |
| 608 | assert!(body.contains("Esc")); |
| 609 | } |
| 610 | |
| 611 | #[test] |
| 612 | fn appearance_copy_is_translated_in_every_complete_pack() { |
| 613 | use crate::localization::{MessageId, tr}; |
| 614 | |
| 615 | for locale in Locale::shipped_complete() { |
| 616 | for id in [ |
| 617 | MessageId::OnboardAppearanceTitle, |
| 618 | MessageId::OnboardAppearanceBlurb, |
| 619 | MessageId::OnboardAppearanceFooter, |
| 620 | MessageId::OnboardWelcomeStepAppearance, |
| 621 | ] { |
| 622 | let text = tr(*locale, id); |
| 623 | assert!(!text.is_empty(), "{locale:?} {id:?} is empty"); |
| 624 | assert!(!text.contains('{'), "{locale:?} {id:?}: {text}"); |
| 625 | if *locale != Locale::En { |
| 626 | assert_ne!( |
| 627 | text, |
| 628 | tr(Locale::En, id), |
| 629 | "{locale:?} {id:?} silently fell back to English" |
| 630 | ); |
| 631 | } |
| 632 | } |
| 633 | } |
| 634 | } |
| 635 | |
| 636 | // ── #3927: the explicit offline ("explore") choice ─────────────────── |
| 637 | |
| 638 | #[test] |
| 639 | fn explore_offline_selects_no_provider_and_keeps_the_key_still_missing() { |
| 640 | let mut app = test_app_with_locale(Locale::En); |
| 641 | let provider_before = app.api_provider; |
| 642 | let model_before = app.model.clone(); |
| 643 | app.onboarding = OnboardingState::Provider; |
| 644 | app.onboarding_needs_api_key = true; |
| 645 | app.trust_mode = true; |
| 646 | |
| 647 | choose_offline_explore(&mut app); |
| 648 | |
| 649 | // No provider selected, no route activated. |
| 650 | assert_eq!(app.api_provider, provider_before); |
| 651 | assert_eq!(app.model, model_before); |
| 652 | assert!(!app.api_key_env_only); |
| 653 | // The install still honestly reports that no credential exists. |
| 654 | assert!(app.onboarding_needs_api_key); |
| 655 | assert!(app.onboarding_explore_offline); |
| 656 | assert!(app.offline_mode); |
| 657 | } |
| 658 | |
| 659 | #[test] |
| 660 | fn explore_offline_label_contains_only_recovery_guidance() { |
| 661 | let mut app = test_app_with_locale(Locale::En); |
| 662 | app.onboarding = OnboardingState::Provider; |
| 663 | app.trust_mode = true; |
| 664 | |
| 665 | choose_offline_explore(&mut app); |
| 666 | |
| 667 | let label = app.status_message.clone().expect("offline label"); |
| 668 | assert!( |
| 669 | label.contains("/provider"), |
| 670 | "label must name recovery: {label}" |
| 671 | ); |
| 672 | |
| 673 | app.onboarding = OnboardingState::Tips; |
| 674 | let tips = flattened(tips_lines(&app)); |
| 675 | assert!(tips.contains("/provider")); |
| 676 | } |
| 677 | |
| 678 | #[test] |
| 679 | fn explore_offline_still_traverses_trust_then_the_rest_of_onboarding() { |
| 680 | let mut app = test_app_with_locale(Locale::En); |
| 681 | app.onboarding = OnboardingState::Provider; |
| 682 | app.trust_mode = false; |
| 683 | app.workspace = tempfile::tempdir().expect("tempdir").path().to_path_buf(); |
| 684 | |
| 685 | choose_offline_explore(&mut app); |
| 686 | assert_eq!(app.onboarding, OnboardingState::TrustDirectory); |
| 687 | |
| 688 | // A trusted workspace skips only the trust screen, never the primer. |
| 689 | let mut trusted = test_app_with_locale(Locale::En); |
| 690 | trusted.onboarding = OnboardingState::Provider; |
| 691 | trusted.trust_mode = true; |
| 692 | choose_offline_explore(&mut trusted); |
| 693 | assert_eq!(trusted.onboarding, OnboardingState::MentalModels); |
| 694 | } |
| 695 | |
| 696 | #[test] |
| 697 | fn offline_label_only_clears_when_a_route_is_activated() { |
| 698 | let mut app = test_app_with_locale(Locale::En); |
| 699 | app.trust_mode = true; |
| 700 | choose_offline_explore(&mut app); |
| 701 | assert!(app.onboarding_explore_offline); |
| 702 | |
| 703 | // Walking the rest of onboarding does not clear it. |
| 704 | app.onboarding = OnboardingState::Tips; |
| 705 | assert!(app.onboarding_explore_offline); |
| 706 | back_from_mental_models(&mut app); |
| 707 | assert!(app.onboarding_explore_offline); |
| 708 | |
| 709 | clear_offline_explore_on_route_activation(&mut app); |
| 710 | assert!(!app.onboarding_explore_offline); |
| 711 | } |
| 712 | |
| 713 | #[test] |
| 714 | fn provider_screen_advertises_the_offline_choice() { |
| 715 | let app = test_app_with_locale(Locale::En); |
| 716 | let provider = flattened(provider_lines(&app)); |
| 717 | assert!( |
| 718 | provider.contains("Ctrl+O"), |
| 719 | "offline exit must be advertised: {provider}" |
| 720 | ); |
| 721 | assert!(provider.contains("offline"), "{provider}"); |
| 722 | } |
| 723 | |
| 724 | #[test] |
| 725 | fn offline_choice_copy_is_translated_in_every_complete_pack() { |
| 726 | use crate::localization::{MessageId, tr}; |
| 727 | |
| 728 | for locale in Locale::shipped_complete() { |
| 729 | for id in [ |
| 730 | MessageId::OnboardOfflineOption, |
| 731 | MessageId::OnboardOfflineNotice, |
| 732 | MessageId::OnboardOfflineTipsLine, |
| 733 | ] { |
| 734 | let text = tr(*locale, id); |
| 735 | assert!(!text.is_empty(), "{locale:?} {id:?} is empty"); |
| 736 | if *locale != Locale::En { |
| 737 | assert_ne!( |
| 738 | text, |
| 739 | tr(Locale::En, id), |
| 740 | "{locale:?} {id:?} silently fell back to English" |
| 741 | ); |
| 742 | } |
| 743 | } |
| 744 | // Commands and key names are composed in code, never translated. |
| 745 | assert!(tr(*locale, MessageId::OnboardOfflineNotice).contains("/provider")); |
| 746 | assert!(tr(*locale, MessageId::OnboardOfflineOption).contains("Ctrl+O")); |
| 747 | } |
| 748 | } |
| 749 | } |
| 750 |