返回 CodeWhale
mod.rs
根目录 / crates / tui / src / tui / onboarding / mod.rs
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
750 lines RUST