返回 CodeWhale
mod.rs
根目录 / crates / tui / src / tui / onboarding / mod.rs
1 //! Calm first-run onboarding: one decision per screen.
2 //!
3 //! The flow asks only for what Codewhale genuinely needs before the user can
4 //! begin: language (only when it cannot be confidently inferred from settings
5 //! or the environment), a provider/model route (only when no usable route is
6 //! configured), and workspace trust (only when a decision is required).
7 //! Appearance, command tours, mode primers, and tips stay in `/setup` and
8 //! contextual help so nothing delays first use. The last screen hands the
9 //! user the real composer pre-seeded with a first task for this folder.
10 //!
11 //! Rendering uses the shared Underwater instrument grammar — one title
12 //! hairline, one bottom action rail — never a bespoke centered card.
13
14 pub mod language;
15 pub mod trust_directory;
16 pub mod welcome;
17
18 use std::path::{Path, PathBuf};
19
20 use ratatui::{
21 Frame,
22 layout::Rect,
23 style::{Modifier, Style},
24 text::{Line, Span},
25 widgets::Paragraph,
26 };
27
28 use crate::tui::app::{App, OnboardingState};
29 use crate::tui::views::{ActionHint, render_modal_footer, render_underwater_surface};
30 use codewhale_localization::MessageId;
31 use codewhale_palette as palette;
32
33 const ONBOARDED_MARKER_FILE: &str = ".onboarded";
34
35 /// Cheap workspace markers that identify a code project, so the seeded first
36 /// task can speak to what is actually in the folder. One `is_file` probe per
37 /// name; no directory walk.
38 const CODE_PROJECT_MARKERS: &[&str] = &[
39 "Cargo.toml",
40 "package.json",
41 "pyproject.toml",
42 "setup.py",
43 "go.mod",
44 "deno.json",
45 "composer.json",
46 ];
47
48 pub fn render(f: &mut Frame, area: Rect, app: &App) {
49 let title = surface_title(app);
50 let hints = action_hints(app);
51 let buf = f.buffer_mut();
52 let inner = render_underwater_surface(area, buf, title);
53 let content = render_modal_footer(inner, buf, &hints);
54 let lines = screen_lines(app, usize::from(content.width), usize::from(content.height));
55 if lines.is_empty() {
56 return;
57 }
58 let body = center_vertically(content, lines.len());
59 f.render_widget(Paragraph::new(lines), body);
60 }
61
62 /// Vertical rest for the short screens: half the leftover rows above, the
63 /// rest below. Content taller than the area stays top-anchored so nothing is
64 /// silently pushed out of view.
65 fn center_vertically(area: Rect, rows: usize) -> Rect {
66 let pad = (area
67 .height
68 .saturating_sub(u16::try_from(rows).unwrap_or(area.height)))
69 / 2;
70 Rect {
71 y: area.y.saturating_add(pad),
72 height: area.height.saturating_sub(pad),
73 ..area
74 }
75 }
76
77 fn surface_title(app: &App) -> String {
78 let base = app.tr(MessageId::OnboardStepsTitle).into_owned();
79 match required_progress(app) {
80 Some((current, total)) => format!("{base} · {current}/{total}"),
81 None => base,
82 }
83 }
84
85 /// The hairline surface title counts only the REQUIRED decisions left in
86 /// this run — never a "Step 1/7" spine. Screens that are not themselves a
87 /// required decision (welcome, ready) show no counter. A decision drops out
88 /// of the count once it is satisfied, so the counter only ever moves
89 /// forward.
90 fn required_progress(app: &App) -> Option<(usize, usize)> {
91 // The counter's denominator is every required decision this run, not
92 // only the ones still ahead: advancing past the language screen must
93 // not shrink "1 of 2" into a bare "1 of 1".
94 let mut steps = Vec::new();
95 if app.onboarding_had_language_step {
96 steps.push(OnboardingState::Language);
97 }
98 if app.onboarding_had_provider_step {
99 steps.push(OnboardingState::Provider);
100 }
101 if app.onboarding_had_trust_step {
102 steps.push(OnboardingState::TrustDirectory);
103 }
104 if steps.len() < 2 {
105 return None;
106 }
107 let current = steps.iter().position(|step| *step == app.onboarding)?;
108 Some((current + 1, steps.len()))
109 }
110
111 fn trust_decision_required(app: &App) -> bool {
112 !app.trust_mode && needs_trust(&app.workspace)
113 }
114
115 fn action_hints(app: &App) -> Vec<ActionHint> {
116 match app.onboarding {
117 OnboardingState::Welcome => vec![
118 ActionHint::new("Enter", app.tr(MessageId::OnboardWelcomeBegin).to_string()),
119 ActionHint::new("Ctrl+C", app.tr(MessageId::OnboardActionExit).to_string()),
120 ],
121 OnboardingState::Language => vec![
122 ActionHint::new(
123 "1-9/a-g",
124 app.tr(MessageId::OnboardLanguagePick).to_string(),
125 ),
126 ActionHint::new("Enter", app.tr(MessageId::OnboardLanguageKeep).to_string()),
127 ActionHint::new("Esc", app.tr(MessageId::OnboardActionBack).to_string()),
128 ],
129 OnboardingState::Provider => vec![
130 ActionHint::new(
131 "Enter",
132 app.tr(MessageId::OnboardProviderChoose).to_string(),
133 ),
134 ActionHint::new(
135 "Ctrl+O",
136 app.tr(MessageId::OnboardProviderOffline).to_string(),
137 ),
138 ActionHint::new("Esc", app.tr(MessageId::OnboardActionBack).to_string()),
139 ],
140 OnboardingState::TrustDirectory => vec![
141 ActionHint::new(
142 "1/Y",
143 app.tr(MessageId::OnboardTrustActionTrust).to_string(),
144 ),
145 ActionHint::new("2/U", app.tr(MessageId::OnboardTrustActionSkip).to_string()),
146 ActionHint::new("3/N", app.tr(MessageId::OnboardTrustActionQuit).to_string()),
147 ],
148 OnboardingState::Ready => vec![
149 // Only keys this screen handles: a `/rc` hint here could not be
150 // typed, because the Ready screen owns the keyboard.
151 ActionHint::new("Enter", app.tr(MessageId::OnboardReadyStart).to_string()),
152 ActionHint::new("C", app.tr(MessageId::OnboardReadyCustomize).to_string()),
153 ],
154 OnboardingState::None => Vec::new(),
155 }
156 }
157
158 fn screen_lines(app: &App, width: usize, height: usize) -> Vec<Line<'static>> {
159 match app.onboarding {
160 OnboardingState::Welcome => welcome::lines(app, width),
161 OnboardingState::Language => language::lines(app, width, height),
162 OnboardingState::Provider => provider_lines(app, width),
163 OnboardingState::TrustDirectory => trust_directory::lines(app, width),
164 OnboardingState::Ready => welcome::ready_lines(app, width),
165 OnboardingState::None => Vec::new(),
166 }
167 }
168
169 fn provider_lines(app: &App, width: usize) -> Vec<Line<'static>> {
170 let mut lines = Vec::new();
171 heading(&mut lines, app, MessageId::OnboardProviderTitle, width);
172 lines.push(Line::from(""));
173 if let Some(notice) = app.onboarding_key_rejected.as_deref() {
174 for segment in wrap_words(notice, width) {
175 lines.push(Line::from(Span::styled(
176 segment,
177 Style::default().fg(palette::STATUS_ERROR),
178 )));
179 }
180 lines.push(Line::from(""));
181 }
182 wrap_body(&mut lines, app, MessageId::OnboardProviderBlurb, width);
183 lines
184 }
185
186 /// Same rule as the welcome headline: a heading is prose and wraps. Today's
187 /// provider title happens to fit at 40 columns in every shipped locale, but it
188 /// fit by luck rather than by construction.
189 fn heading(out: &mut Vec<Line<'static>>, app: &App, id: MessageId, width: usize) {
190 for segment in wrap_words(&app.tr(id), width) {
191 out.push(Line::from(Span::styled(
192 segment,
193 Style::default()
194 .fg(palette::WHALE_ACTION)
195 .add_modifier(Modifier::BOLD),
196 )));
197 }
198 }
199
200 /// Append a body sentence word-wrapped to `width` in the muted body lane.
201 fn wrap_body(lines: &mut Vec<Line<'static>>, app: &App, id: MessageId, width: usize) {
202 let text = app.tr(id);
203 for segment in wrap_words(&text, width) {
204 lines.push(Line::from(Span::styled(
205 segment,
206 Style::default().fg(palette::TEXT_PRIMARY),
207 )));
208 }
209 }
210
211 /// Characters that may not begin a line in Japanese and Chinese typography
212 /// (a small, uncontroversial kinsoku set: closing brackets, sentence-final
213 /// punctuation, and the sound-extension mark). When a width break would strand
214 /// one of these at the start of a line, carry its preceding grapheme forward.
215 const NO_LINE_START: &[char] = &[
216 '。', '、', '.', ',', ',', '。', '」', '』', ')', ']', '}', '〕', '〉', '》', '”', '’',
217 '!', '?', ':', ';', 'ー', '々', '·', '…', '!', '?', ',', '.', ':', ';', ')', ']', '}',
218 ];
219
220 /// Break one unbreakable token into lines of at most `width` display columns.
221 ///
222 /// Japanese, Chinese, and Thai do not separate words with spaces, so an entire
223 /// sentence arrives as a single token. Breaking on grapheme clusters by display
224 /// width is the conventional behaviour for those scripts and is the only way to
225 /// show the text at all; the alternative is the clip this replaces.
226 fn break_by_display_width(text: &str, width: usize) -> Vec<String> {
227 use unicode_segmentation::UnicodeSegmentation;
228 use unicode_width::UnicodeWidthStr;
229
230 let mut out: Vec<String> = Vec::new();
231 let mut current = String::new();
232 let mut current_width = 0usize;
233
234 for cluster in text.graphemes(true) {
235 let cluster_width = UnicodeWidthStr::width(cluster);
236 if current_width + cluster_width > width && !current.is_empty() {
237 let starts_forbidden = cluster
238 .chars()
239 .next()
240 .is_some_and(|c| NO_LINE_START.contains(&c));
241 if starts_forbidden {
242 // Carry the preceding text with its punctuation onto the next
243 // line. Extending a full line instead silently clips it in a
244 // terminal viewport, including on the redaction consent gate.
245 if let Some((split, _)) = current.grapheme_indices(true).rev().find(|(_, part)| {
246 !part
247 .chars()
248 .next()
249 .is_some_and(|ch| NO_LINE_START.contains(&ch))
250 }) && split > 0
251 && UnicodeWidthStr::width(&current[split..]) + cluster_width <= width
252 {
253 let carry = current.split_off(split);
254 out.push(std::mem::replace(&mut current, carry));
255 current_width = UnicodeWidthStr::width(current.as_str());
256 } else {
257 // A punctuation-only run cannot satisfy both typography
258 // and width; preserve every character inside the viewport.
259 out.push(std::mem::take(&mut current));
260 current_width = 0;
261 }
262 } else {
263 out.push(std::mem::take(&mut current));
264 current_width = 0;
265 }
266 }
267 current.push_str(cluster);
268 current_width += cluster_width;
269 }
270
271 if !current.is_empty() {
272 out.push(current);
273 }
274 out
275 }
276
277 /// Word wrap by display width so the composed row count is exact and no
278 /// paragraph re-wrap can clip a locale with longer sentences.
279 pub(crate) fn wrap_words(text: &str, width: usize) -> Vec<String> {
280 use unicode_width::UnicodeWidthStr;
281 let width = width.max(8);
282 let mut out = Vec::new();
283 let mut current = String::new();
284 let mut current_width = 0usize;
285 for word in text.split_whitespace() {
286 let word_width = UnicodeWidthStr::width(word);
287
288 // A token wider than the whole lane cannot fit on any line. Scripts
289 // that do not delimit words produce exactly one such token per
290 // sentence, and the word-only path below never breaks it — it appended
291 // the token whole and the terminal clipped the tail, silently dropping
292 // the second half of every long Japanese string.
293 if word_width > width {
294 if !current.is_empty() {
295 out.push(std::mem::take(&mut current));
296 current_width = 0;
297 }
298 let mut chunks = break_by_display_width(word, width);
299 if let Some(last) = chunks.pop() {
300 out.extend(chunks);
301 current_width = UnicodeWidthStr::width(last.as_str());
302 current = last;
303 }
304 continue;
305 }
306
307 let needed = if current.is_empty() {
308 word_width
309 } else {
310 current_width + 1 + word_width
311 };
312 if !current.is_empty() && needed > width {
313 out.push(std::mem::take(&mut current));
314 current_width = 0;
315 }
316 if !current.is_empty() {
317 current.push(' ');
318 current_width += 1;
319 }
320 current.push_str(word);
321 current_width += word_width;
322 }
323 if !current.is_empty() {
324 out.push(current);
325 }
326 if out.is_empty() {
327 out.push(String::new());
328 }
329 out
330 }
331
332 pub fn default_marker_path() -> Option<PathBuf> {
333 let primary_home = codewhale_config::codewhale_home().ok()?;
334 let legacy_home = if codewhale_config::codewhale_home_is_explicit() {
335 None
336 } else {
337 codewhale_config::legacy_deepseek_home().ok()
338 };
339 Some(marker_path_with_roots(
340 &primary_home,
341 legacy_home.as_deref(),
342 ))
343 }
344
345 #[cfg(test)]
346 fn marker_path_with_home(home: &Path) -> PathBuf {
347 marker_path_with_roots(
348 &home.join(".codewhale"),
349 Some(home.join(".deepseek").as_path()),
350 )
351 }
352
353 fn marker_path_with_roots(primary_home: &Path, legacy_home: Option<&Path>) -> PathBuf {
354 let primary = primary_home.join(ONBOARDED_MARKER_FILE);
355 if primary.exists() {
356 return primary;
357 }
358 if let Some(legacy_home) = legacy_home {
359 let legacy = legacy_home.join(ONBOARDED_MARKER_FILE);
360 if legacy.exists() {
361 return legacy;
362 }
363 }
364 primary
365 }
366
367 pub fn is_onboarded() -> bool {
368 default_marker_path().is_some_and(|path| path.exists())
369 }
370
371 pub fn mark_onboarded() -> std::io::Result<PathBuf> {
372 let path = default_marker_path().ok_or_else(|| {
373 std::io::Error::new(
374 std::io::ErrorKind::NotFound,
375 "Codewhale home directory not found",
376 )
377 })?;
378 mark_onboarded_at_path(path)
379 }
380
381 #[cfg(test)]
382 fn mark_onboarded_at_home(home: &Path) -> std::io::Result<PathBuf> {
383 let path = marker_path_with_home(home);
384 mark_onboarded_at_path(path)
385 }
386
387 fn mark_onboarded_at_path(path: PathBuf) -> std::io::Result<PathBuf> {
388 if let Some(parent) = path.parent() {
389 std::fs::create_dir_all(parent)?;
390 }
391 std::fs::write(&path, "")?;
392 Ok(path)
393 }
394
395 pub fn needs_trust(workspace: &Path) -> bool {
396 if crate::config::is_workspace_trusted(workspace) {
397 return false;
398 }
399
400 let markers = [
401 workspace.join(".deepseek").join("trusted"),
402 workspace.join(".deepseek").join("trust.json"),
403 ];
404 !markers.iter().any(|path| path.exists())
405 }
406
407 pub fn mark_trusted(workspace: &Path) -> anyhow::Result<PathBuf> {
408 crate::config::save_workspace_trust(workspace)
409 }
410
411 /// Whether the UI locale can be trusted without asking. An explicit settings
412 /// value or an environment locale that resolves to a shipped pack is
413 /// confident; anything else defaults to English silently, so first run asks
414 /// once. Returning users never see the language screen.
415 pub fn locale_confidently_inferred(setting: &str) -> bool {
416 let normalized = codewhale_localization::normalize_configured_locale(setting);
417 if normalized.is_some_and(|tag| tag != "auto") {
418 return true;
419 }
420 ["LC_ALL", "LC_MESSAGES", "LANG"].iter().any(|key| {
421 std::env::var(key)
422 .ok()
423 .filter(|value| locale_var_names_a_language(value))
424 .and_then(|value| codewhale_localization::normalize_configured_locale(&value))
425 .is_some_and(|tag| tag != "auto")
426 })
427 }
428
429 /// A POSIX/C locale names an encoding, not a language: `C` and `C.UTF-8`
430 /// pass through `normalize_configured_locale` as concrete tags, so the
431 /// inference gate must reject them explicitly or a stock terminal
432 /// environment reads as a confident language pick.
433 fn locale_var_names_a_language(value: &str) -> bool {
434 let language = value.split(['.', '_', '@']).next().unwrap_or_default();
435 !matches!(language, "" | "C" | "POSIX" | "c" | "posix")
436 }
437
438 /// The example task the ready screen seeds into the composer, chosen from
439 /// what is cheaply visible in the workspace.
440 pub fn first_task_seed(workspace: &Path, locale: codewhale_localization::Locale) -> String {
441 let id = if CODE_PROJECT_MARKERS
442 .iter()
443 .any(|marker| workspace.join(marker).is_file())
444 {
445 MessageId::OnboardSeedCodeProject
446 } else {
447 MessageId::OnboardSeedFolder
448 };
449 codewhale_localization::tr(locale, id).into_owned()
450 }
451
452 /// Welcome → the first decision this run actually needs.
453 pub fn advance_onboarding_from_welcome(app: &mut App) {
454 app.status_message = None;
455 app.onboarding = if app.onboarding_had_language_step {
456 OnboardingState::Language
457 } else if app.onboarding_needs_api_key {
458 OnboardingState::Provider
459 } else if trust_decision_required(app) {
460 OnboardingState::TrustDirectory
461 } else {
462 OnboardingState::Ready
463 };
464 }
465
466 /// Language → the next decision; the language step never repeats.
467 pub fn advance_onboarding_after_language(app: &mut App) {
468 app.status_message = None;
469 app.onboarding = if app.onboarding_needs_api_key {
470 OnboardingState::Provider
471 } else if trust_decision_required(app) {
472 OnboardingState::TrustDirectory
473 } else {
474 OnboardingState::Ready
475 };
476 }
477
478 /// Provider setup → trust when a decision is required, otherwise the ready
479 /// screen.
480 pub fn advance_onboarding_after_provider(app: &mut App) {
481 app.status_message = None;
482 if trust_decision_required(app) {
483 app.onboarding = OnboardingState::TrustDirectory;
484 } else {
485 app.onboarding = OnboardingState::Ready;
486 }
487 }
488
489 /// Take the explicit "explore offline" exit advertised by Provider setup
490 /// (#3927).
491 ///
492 /// The contract this encodes, in full:
493 ///
494 /// * **No provider is selected and no route is activated.** This function must
495 /// never reach `switch_provider`, never persist `provider`, and never write a
496 /// credential. Callers pass only `&mut App`, which makes that structural.
497 /// * **No draft secret is owned by `App`.** The caller closes the canonical
498 /// picker before entering this transition, dropping its private draft.
499 /// * **`onboarding_needs_api_key` stays true**, because nothing was supplied.
500 /// The launch surface, `/setup`, and doctor keep telling the truth.
501 /// * **The remaining required decisions still run** — trust, then the ready
502 /// screen — so browsing offline is a complete first run and not an early
503 /// exit.
504 /// * Queue semantics are inherited from `offline_mode`, untouched here.
505 pub fn choose_offline_explore(app: &mut App) {
506 app.api_key_env_only = false;
507 app.onboarding_needs_api_key = true;
508 app.onboarding_explore_offline = true;
509 app.offline_mode = true;
510 // `advance_*` clears the status bar, so the label is applied after it.
511 advance_onboarding_after_provider(app);
512 app.status_message = Some(
513 app.tr(codewhale_localization::MessageId::OnboardOfflineNotice)
514 .into_owned(),
515 );
516 app.needs_redraw = true;
517 }
518
519 /// Clear the offline-explore label once a real route is activated (#3927).
520 ///
521 /// This is the *only* thing that retires the label: it is not time-based and
522 /// not cleared by dismissing a screen.
523 pub fn clear_offline_explore_on_route_activation(app: &mut App) {
524 app.onboarding_explore_offline = false;
525 }
526
527 /// Finish first run from the ready screen and land in the real composer,
528 /// pre-seeded with a useful first task for this folder. Enter opens the
529 /// product; it never opens another educational surface.
530 pub fn finish_ready_and_open_composer(app: &mut App) {
531 app.finish_onboarding_without_feature_intro();
532 if app.composer.input.trim().is_empty() {
533 let seed = first_task_seed(&app.workspace, app.ui_locale);
534 app.composer.input = seed;
535 app.composer.cursor_position = app.composer.input.chars().count();
536 }
537 app.needs_redraw = true;
538 }
539
540 #[cfg(test)]
541 mod tests {
542 use super::*;
543 use crate::config::Config;
544 use crate::tui::app::{App, TuiOptions};
545 use codewhale_localization::{Locale, MessageId, tr};
546 use std::path::PathBuf;
547
548 /// A first-run app with the onboarding decision reset to "nothing asked
549 /// yet". `App::new` derives that decision from the ambient machine —
550 /// inferable locale, an existing `settings.toml`, a provider key in the
551 /// environment — so a fixture that overrides only the flags it names
552 /// inherits the rest of the developer's box and asserts something
553 /// different in CI. Every test below opts in to the steps it is about.
554 fn test_app_with_locale(locale: Locale) -> App {
555 let options = TuiOptions {
556 ..crate::test_support::test_tui_options(PathBuf::from("."))
557 };
558 let mut app = App::new(options, &Config::default());
559 app.ui_locale = locale;
560 app.onboarding_needs_api_key = false;
561 app.onboarding_missing_key_recovery = false;
562 app.onboarding_explore_offline = false;
563 app.onboarding_had_language_step = false;
564 app.onboarding_had_provider_step = false;
565 app.onboarding_had_trust_step = false;
566 app
567 }
568
569 fn flattened(lines: Vec<Line<'static>>) -> String {
570 lines
571 .into_iter()
572 .flat_map(|line| {
573 line.spans
574 .into_iter()
575 .map(|span| span.content.to_string())
576 .collect::<Vec<_>>()
577 })
578 .collect::<Vec<_>>()
579 .join("\n")
580 }
581
582 // ── Navigation: one decision per screen, conditional ─────────────────
583
584 #[test]
585 fn welcome_routes_to_the_first_decision_this_run_needs() {
586 let tmp = tempfile::tempdir().expect("tempdir");
587
588 // Everything configured: welcome → ready with zero questions between.
589 let mut app = test_app_with_locale(Locale::En);
590 app.workspace = tmp.path().to_path_buf();
591 app.trust_mode = true;
592 app.onboarding_needs_api_key = false;
593 app.onboarding_had_language_step = false;
594 advance_onboarding_from_welcome(&mut app);
595 assert_eq!(app.onboarding, OnboardingState::Ready);
596
597 // No route configured: provider comes first.
598 let mut app = test_app_with_locale(Locale::En);
599 app.workspace = tmp.path().to_path_buf();
600 app.trust_mode = true;
601 app.onboarding_needs_api_key = true;
602 advance_onboarding_from_welcome(&mut app);
603 assert_eq!(app.onboarding, OnboardingState::Provider);
604
605 // Route present, workspace untrusted: trust is the only decision.
606 let mut app = test_app_with_locale(Locale::En);
607 app.workspace = tmp.path().to_path_buf();
608 app.trust_mode = false;
609 app.onboarding_needs_api_key = false;
610 advance_onboarding_from_welcome(&mut app);
611 assert_eq!(app.onboarding, OnboardingState::TrustDirectory);
612
613 // Language cannot be inferred: it precedes every other decision.
614 let mut app = test_app_with_locale(Locale::En);
615 app.workspace = tmp.path().to_path_buf();
616 app.trust_mode = false;
617 app.onboarding_needs_api_key = true;
618 app.onboarding_had_language_step = true;
619 advance_onboarding_from_welcome(&mut app);
620 assert_eq!(app.onboarding, OnboardingState::Language);
621 }
622
623 #[test]
624 fn language_step_never_repeats_and_falls_through_to_ready() {
625 let tmp = tempfile::tempdir().expect("tempdir");
626 let mut app = test_app_with_locale(Locale::En);
627 app.workspace = tmp.path().to_path_buf();
628 app.trust_mode = true;
629 app.onboarding_had_language_step = true;
630 app.onboarding_needs_api_key = false;
631
632 advance_onboarding_after_language(&mut app);
633 assert_eq!(app.onboarding, OnboardingState::Ready);
634 }
635
636 #[test]
637 fn provider_step_routes_to_trust_only_when_a_decision_is_required() {
638 let tmp = tempfile::tempdir().expect("tempdir");
639
640 let mut app = test_app_with_locale(Locale::En);
641 app.workspace = tmp.path().to_path_buf();
642 app.trust_mode = false;
643 app.onboarding_missing_key_recovery = false;
644 advance_onboarding_after_provider(&mut app);
645 assert_eq!(app.onboarding, OnboardingState::TrustDirectory);
646
647 let mut trusted = test_app_with_locale(Locale::En);
648 trusted.workspace = tmp.path().to_path_buf();
649 trusted.trust_mode = true;
650 advance_onboarding_after_provider(&mut trusted);
651 assert_eq!(trusted.onboarding, OnboardingState::Ready);
652 }
653
654 #[test]
655 fn missing_key_recovery_ends_on_ready_like_a_first_run() {
656 let mut app = test_app_with_locale(Locale::En);
657 app.trust_mode = true;
658 app.onboarding_missing_key_recovery = true;
659 advance_onboarding_after_provider(&mut app);
660 assert_eq!(app.onboarding, OnboardingState::Ready);
661 }
662
663 #[test]
664 fn explore_offline_still_traverses_trust_then_ready() {
665 let tmp = tempfile::tempdir().expect("tempdir");
666 let mut app = test_app_with_locale(Locale::En);
667 app.onboarding = OnboardingState::Provider;
668 app.trust_mode = false;
669 app.workspace = tmp.path().to_path_buf();
670
671 choose_offline_explore(&mut app);
672 assert_eq!(app.onboarding, OnboardingState::TrustDirectory);
673 assert!(app.onboarding_explore_offline);
674
675 // A trusted workspace skips only the trust screen, never the ending.
676 let mut trusted = test_app_with_locale(Locale::En);
677 trusted.onboarding = OnboardingState::Provider;
678 trusted.trust_mode = true;
679 choose_offline_explore(&mut trusted);
680 assert_eq!(trusted.onboarding, OnboardingState::Ready);
681 }
682
683 #[test]
684 fn offline_explore_selects_no_provider_and_writes_no_credential() {
685 let mut app = test_app_with_locale(Locale::En);
686 app.onboarding = OnboardingState::Provider;
687 app.onboarding_needs_api_key = true;
688 app.trust_mode = true;
689 let provider_before = app.api_provider;
690 let model_before = app.model.clone();
691
692 choose_offline_explore(&mut app);
693
694 assert_eq!(app.api_provider, provider_before);
695 assert_eq!(app.model, model_before);
696 assert!(!app.api_key_env_only);
697 assert!(app.onboarding_needs_api_key);
698 assert!(app.onboarding_explore_offline);
699 assert!(app.offline_mode);
700 }
701
702 #[test]
703 fn offline_label_only_clears_when_a_route_is_activated() {
704 let mut app = test_app_with_locale(Locale::En);
705 app.trust_mode = true;
706 choose_offline_explore(&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 // ── Conditional required steps: the counter ──────────────────────────
714
715 #[test]
716 fn progress_counts_only_required_decisions() {
717 let tmp = tempfile::tempdir().expect("tempdir");
718
719 // One required decision → no counter at all.
720 let mut app = test_app_with_locale(Locale::En);
721 app.workspace = tmp.path().to_path_buf();
722 app.trust_mode = true;
723 app.onboarding_needs_api_key = true;
724 app.onboarding = OnboardingState::Provider;
725 assert_eq!(required_progress(&app), None);
726
727 // Language + provider: the language screen is 1 of 2.
728 app.onboarding_had_language_step = true;
729 app.onboarding_had_provider_step = true;
730 app.onboarding = OnboardingState::Language;
731 assert_eq!(required_progress(&app), Some((1, 2)));
732 app.onboarding = OnboardingState::Provider;
733 assert_eq!(required_progress(&app), Some((2, 2)));
734
735 // Completing provider setup changes live route state, but never
736 // rewrites the receipt-backed denominator for this run.
737 app.onboarding_needs_api_key = false;
738 app.onboarding_had_trust_step = true;
739 app.onboarding = OnboardingState::TrustDirectory;
740 assert_eq!(required_progress(&app), Some((3, 3)));
741
742 // Welcome and ready are not decisions and never carry a counter.
743 app.onboarding = OnboardingState::Welcome;
744 assert_eq!(required_progress(&app), None);
745 app.onboarding = OnboardingState::Ready;
746 assert_eq!(required_progress(&app), None);
747 }
748
749 // ── Language inference gate ───────────────────────────────────────────
750
751 #[test]
752 fn language_step_is_required_only_when_the_locale_is_not_inferable() {
753 let _env_lock = crate::test_support::lock_test_env();
754 let _guard = crate::test_support::EnvVarGuard::remove("LC_ALL");
755 let _messages = crate::test_support::EnvVarGuard::remove("LC_MESSAGES");
756 let _lang = crate::test_support::EnvVarGuard::remove("LANG");
757
758 assert!(!locale_confidently_inferred("auto"));
759 assert!(!locale_confidently_inferred(""));
760
761 // An explicit settings pick is always confident.
762 assert!(locale_confidently_inferred("ja"));
763 assert!(locale_confidently_inferred("zh-Hans"));
764
765 // A shipped locale in the environment is confident…
766 let _lang = crate::test_support::EnvVarGuard::set("LANG", "ja_JP.UTF-8");
767 assert!(locale_confidently_inferred("auto"));
768
769 // …but a POSIX/C environment is not a language signal.
770 let _lang = crate::test_support::EnvVarGuard::set("LANG", "C");
771 assert!(!locale_confidently_inferred("auto"));
772 let _lang = crate::test_support::EnvVarGuard::set("LANG", "C.UTF-8");
773 assert!(!locale_confidently_inferred("auto"));
774 }
775
776 // ── The seeded first task ────────────────────────────────────────────
777
778 #[test]
779 fn seed_speaks_to_the_workspace_contents() {
780 let code_dir = tempfile::tempdir().expect("tempdir");
781 std::fs::write(code_dir.path().join("Cargo.toml"), "[package]\n").expect("marker");
782 assert_eq!(
783 first_task_seed(code_dir.path(), Locale::En),
784 tr(Locale::En, MessageId::OnboardSeedCodeProject)
785 );
786
787 let plain_dir = tempfile::tempdir().expect("tempdir");
788 std::fs::write(plain_dir.path().join("README.md"), "notes\n").expect("readme");
789 assert_eq!(
790 first_task_seed(plain_dir.path(), Locale::En),
791 tr(Locale::En, MessageId::OnboardSeedFolder)
792 );
793 }
794
795 #[test]
796 fn finishing_from_ready_seeds_the_composer_and_marks_onboarding_done() {
797 let _env_lock = crate::test_support::lock_test_env();
798 let home = tempfile::tempdir().expect("home");
799 let _home = crate::test_support::EnvVarGuard::set("HOME", home.path());
800 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", home.path());
801 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
802
803 let workspace = tempfile::tempdir().expect("workspace");
804 std::fs::write(workspace.path().join("package.json"), "{}\n").expect("marker");
805
806 let mut app = test_app_with_locale(Locale::En);
807 app.workspace = workspace.path().to_path_buf();
808 app.onboarding = OnboardingState::Ready;
809
810 finish_ready_and_open_composer(&mut app);
811
812 assert_eq!(app.onboarding, OnboardingState::None);
813 assert!(is_onboarded(), "the ready screen completes first run");
814 assert_eq!(
815 app.composer.input,
816 tr(Locale::En, MessageId::OnboardSeedCodeProject)
817 );
818 assert_eq!(
819 app.composer.cursor_position,
820 app.composer.input.chars().count()
821 );
822 }
823
824 #[test]
825 fn finishing_from_ready_preserves_a_real_cli_prompt() {
826 let _env_lock = crate::test_support::lock_test_env();
827 let home = tempfile::tempdir().expect("home");
828 let _home = crate::test_support::EnvVarGuard::set("HOME", home.path());
829 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", home.path());
830 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
831
832 let mut app = test_app_with_locale(Locale::En);
833 app.onboarding = OnboardingState::Ready;
834 app.composer.input = "Fix the failing build I asked for.".to_string();
835 app.composer.cursor_position = app.composer.input.chars().count();
836
837 finish_ready_and_open_composer(&mut app);
838
839 assert_eq!(app.onboarding, OnboardingState::None);
840 assert_eq!(app.composer.input, "Fix the failing build I asked for.");
841 assert_eq!(
842 app.composer.cursor_position,
843 app.composer.input.chars().count()
844 );
845 }
846
847 // ── Onboarded-state persistence contract ─────────────────────────────
848
849 #[test]
850 fn fresh_install_marker_path_uses_codewhale_not_legacy() {
851 let tmp = tempfile::tempdir().expect("tempdir");
852
853 let expected = tmp.path().join(".codewhale").join(ONBOARDED_MARKER_FILE);
854 assert_eq!(marker_path_with_home(tmp.path()), expected);
855
856 let written = mark_onboarded_at_home(tmp.path()).expect("mark onboarded");
857 assert_eq!(written, expected);
858 assert!(expected.exists());
859 assert!(
860 !tmp.path().join(".deepseek").exists(),
861 "fresh onboarding must not recreate the legacy .deepseek dir"
862 );
863 }
864
865 #[test]
866 fn existing_legacy_marker_is_preserved() {
867 let tmp = tempfile::tempdir().expect("tempdir");
868 let legacy = tmp.path().join(".deepseek").join(ONBOARDED_MARKER_FILE);
869 std::fs::create_dir_all(legacy.parent().expect("legacy parent")).expect("mkdir legacy");
870 std::fs::write(&legacy, "").expect("seed legacy marker");
871
872 assert_eq!(marker_path_with_home(tmp.path()), legacy);
873 assert_eq!(
874 mark_onboarded_at_home(tmp.path()).expect("mark onboarded"),
875 legacy
876 );
877 }
878
879 #[test]
880 fn codewhale_marker_wins_over_legacy_marker() {
881 let tmp = tempfile::tempdir().expect("tempdir");
882 let primary = tmp.path().join(".codewhale").join(ONBOARDED_MARKER_FILE);
883 let legacy = tmp.path().join(".deepseek").join(ONBOARDED_MARKER_FILE);
884 for marker in [&primary, &legacy] {
885 std::fs::create_dir_all(marker.parent().expect("marker parent")).expect("mkdir");
886 std::fs::write(marker, "").expect("seed marker");
887 }
888
889 assert_eq!(marker_path_with_home(tmp.path()), primary);
890 }
891
892 #[test]
893 fn explicit_codewhale_home_marker_survives_restart_resolution() {
894 let _env_lock = crate::test_support::lock_test_env();
895 let tmp = tempfile::tempdir().expect("tempdir");
896 let ambient_home = tmp.path().join("ambient profile");
897 let isolated_home = tmp.path().join("isolated Codewhale state");
898 let ambient_legacy = ambient_home.join(".deepseek").join(ONBOARDED_MARKER_FILE);
899 std::fs::create_dir_all(ambient_legacy.parent().expect("legacy parent")).expect("mkdir");
900 std::fs::write(&ambient_legacy, "").expect("seed ambient legacy marker");
901 let _home = crate::test_support::EnvVarGuard::set("HOME", &ambient_home);
902 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &ambient_home);
903 let _codewhale_home =
904 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &isolated_home);
905
906 let expected = isolated_home.join(ONBOARDED_MARKER_FILE);
907 assert_eq!(default_marker_path().as_deref(), Some(expected.as_path()));
908 assert!(!is_onboarded());
909
910 let written = mark_onboarded().expect("mark onboarded");
911
912 assert_eq!(written, expected);
913 assert!(is_onboarded());
914 assert_eq!(default_marker_path().as_deref(), Some(expected.as_path()));
915 assert!(ambient_legacy.exists(), "legacy marker remains untouched");
916 assert!(
917 !ambient_home.join(".codewhale").exists(),
918 "an explicit state root must not write into the ambient profile"
919 );
920 }
921
922 // ── Locale completeness for the new copy ─────────────────────────────
923
924 #[test]
925 fn calm_onboarding_copy_is_translated_in_every_complete_pack() {
926 for locale in Locale::shipped_complete() {
927 for id in [
928 MessageId::OnboardWelcomeTitle,
929 MessageId::OnboardWelcomeLead,
930 MessageId::OnboardWelcomeBegin,
931 MessageId::OnboardActionBack,
932 MessageId::OnboardActionExit,
933 MessageId::OnboardStepsTitle,
934 MessageId::OnboardLanguagePick,
935 MessageId::OnboardLanguageKeep,
936 MessageId::OnboardProviderChoose,
937 MessageId::OnboardProviderOffline,
938 MessageId::OnboardTrustActionTrust,
939 MessageId::OnboardTrustActionSkip,
940 MessageId::OnboardTrustActionQuit,
941 MessageId::OnboardReadyTitle,
942 MessageId::OnboardReadyLead,
943 MessageId::OnboardReadyStart,
944 MessageId::OnboardReadyCustomize,
945 MessageId::CmdRemoteControlDescription,
946 MessageId::OnboardSeedCodeProject,
947 MessageId::OnboardSeedFolder,
948 ] {
949 let text = tr(*locale, id);
950 assert!(!text.is_empty(), "{locale:?} {id:?} is empty");
951 if *locale != Locale::En {
952 assert_ne!(
953 text,
954 tr(Locale::En, id),
955 "{locale:?} {id:?} silently fell back to English"
956 );
957 }
958 }
959 }
960 }
961
962 #[test]
963 fn ready_screen_advertises_only_keys_it_handles() {
964 use crate::tui::views::action_footer_lines;
965
966 let mut app = test_app_with_locale(Locale::En);
967 app.onboarding = OnboardingState::Ready;
968 let rail = flattened(action_footer_lines(&action_hints(&app), 80));
969 assert!(rail.contains("Enter"), "{rail}");
970 assert!(rail.contains("change the look"), "{rail}");
971 assert!(
972 !rail.contains("/rc"),
973 "the Ready screen owns the keyboard, so /rc cannot be typed: {rail}"
974 );
975 }
976
977 #[test]
978 fn provider_screen_advertises_the_offline_choice() {
979 use crate::tui::views::action_footer_lines;
980
981 let mut app = test_app_with_locale(Locale::En);
982 app.onboarding = OnboardingState::Provider;
983 let rail = flattened(action_footer_lines(&action_hints(&app), 70));
984 assert!(rail.contains("Enter"), "primary choice first: {rail}");
985 assert!(
986 rail.contains("Ctrl+O"),
987 "offline exit must be advertised: {rail}"
988 );
989 assert!(
990 rail.contains(tr(Locale::En, MessageId::OnboardProviderOffline).as_ref()),
991 "offline exit needs its translated label: {rail}"
992 );
993 }
994
995 #[test]
996 fn wrap_words_breaks_scripts_that_have_no_spaces() {
997 use unicode_width::UnicodeWidthStr;
998 // The real ja provider blurb. `split_whitespace` yields one token, so
999 // the word-only wrapper emitted a single 110-column line and an 80-column
1000 // terminal clipped everything after "ローカ" — the half of the sentence
1001 // that tells the user local runtimes need no key.
1002 let ja = "モデルの実行先を選びます。ホステッドプロバイダーにはキーが必要ですが、ローカルランタイムはキーなしで続行できます。";
1003 assert!(
1004 ja.split_whitespace().count() == 1,
1005 "fixture must be a single whitespace-delimited token"
1006 );
1007
1008 let lines = wrap_words(ja, 76);
1009 assert!(
1010 lines.len() > 1,
1011 "space-less text must wrap, not clip: {lines:?}"
1012 );
1013 for line in &lines {
1014 assert!(
1015 UnicodeWidthStr::width(line.as_str()) <= 76,
1016 "line exceeds the lane: {:?} ({} cols)",
1017 line,
1018 UnicodeWidthStr::width(line.as_str())
1019 );
1020 }
1021 // Nothing may be dropped: the rejoined lines must reproduce the source.
1022 assert_eq!(lines.concat(), ja, "wrapping must not lose characters");
1023 }
1024
1025 #[test]
1026 fn wrap_words_keeps_latin_wrapping_unchanged() {
1027 let text = "Pick where your model runs. Hosted providers need a key; local runtimes can continue without one.";
1028 let lines = wrap_words(text, 60);
1029 assert!(lines.len() >= 2);
1030 for line in &lines {
1031 assert!(line.len() <= 60, "{line:?}");
1032 assert!(!line.starts_with(' ') && !line.ends_with(' '), "{line:?}");
1033 }
1034 assert_eq!(lines.join(" "), text, "word wrapping must round-trip");
1035 }
1036
1037 #[test]
1038 fn wrap_words_does_not_open_a_line_with_japanese_closing_punctuation() {
1039 // Kinsoku: 。、」) and friends may not begin a line.
1040 let text = "あいうえおかきくけこさしすせそたちつてと。";
1041 for width in 4..=20 {
1042 for line in wrap_words(text, width) {
1043 let first = line.chars().next().unwrap();
1044 assert!(
1045 !NO_LINE_START.contains(&first),
1046 "width {width}: line starts with {first:?} in {line:?}"
1047 );
1048 }
1049 }
1050 }
1051
1052 #[test]
1053 fn wrap_words_handles_mixed_latin_and_cjk() {
1054 let text = "Codewhale はこのフォルダーで一緒に作業します。";
1055 let lines = wrap_words(text, 20);
1056 assert_eq!(
1057 lines.join("").replace(' ', ""),
1058 text.replace(' ', ""),
1059 "mixed-script text must not lose characters: {lines:?}"
1060 );
1061 }
1062 }
1063
1063 lines RUST