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