atomcode_tuix/modals/onboarding_wizard.rs
1// crates/atomcode-tuix/src/modals/onboarding_wizard.rs
2//
3// Multi-step first-run / `/welcome` onboarding wizard. Three real
4// steps (Intro / Language / Setup) plus one synthetic Confirm step
5// that fires only when `/welcome` runs mid-session and would clobber
6// existing scrollback.
7//
8// Replaces `welcome_wizard.rs` (deleted in Task 9). Same `LoopCtx`
9// post-close flag side-channel (`pending_run_codingplan`,
10// `pending_open_provider_wizard`) as before — only the in-modal flow
11// changes.
12//
13// This file lands in slices across the plan tasks:
14// * Task 2 (this slice): `draw_panel` box-drawing helper + tests.
15// * Task 3: `OnboardingWizard` struct + Step enum + transitions.
16// * Task 4-6: per-step `draw_*` + Modal trait impl.
17
18use unicode_width::UnicodeWidthStr;
19
20/// ASCII fallback set for the box-drawing glyphs and decorative
21/// content chars. Switched on when `caps.unicode_symbols == false`
22/// (Windows legacy conhost, `LANG=C`, `TERM=dumb`, etc.) so users on
23/// fonts that miss the Unicode glyphs see a tidy ASCII box instead
24/// of tofu + drifting borders. The drift is the real bug — `●`, `·`,
25/// `←` all return width 1 from `unicode-width` but conhost allocates
26/// them slightly wider in practice, so the right `│` lands at a
27/// different column on every row that contains one.
28fn box_chars(unicode_symbols: bool) -> (&'static str, &'static str, &'static str, &'static str, &'static str, &'static str) {
29 if unicode_symbols {
30 ("┌", "┐", "└", "┘", "─", "│")
31 } else {
32 ("+", "+", "+", "+", "-", "|")
33 }
34}
35
36/// Strip decorative Unicode glyphs out of `s` when running on a
37/// terminal that lacks reliable Unicode rendering / cell-width
38/// accounting (Windows legacy conhost et al). Each substitution
39/// returns an ASCII string of EQUAL display width to what
40/// `unicode-width` thought the original was — keeps the right border
41/// pinned to the same column on every row.
42fn ascii_fallback(s: &str) -> String {
43 let mut out = String::with_capacity(s.len());
44 for ch in s.chars() {
45 match ch {
46 '●' | '•' => out.push('*'),
47 '○' => out.push('o'),
48 '·' => out.push('-'),
49 '←' => out.push('<'),
50 '→' => out.push('>'),
51 '↑' => out.push('^'),
52 '↓' => out.push('v'),
53 '█' => out.push('#'),
54 // Box-drawing glyphs in content (e.g. tables emitted by
55 // markdown into the panel) get the same swap as the
56 // outer panel border.
57 '┌' | '┐' | '└' | '┘' | '┬' | '┴' | '├' | '┤' | '┼' => out.push('+'),
58 '─' => out.push('-'),
59 '│' => out.push('|'),
60 other => out.push(other),
61 }
62 }
63 out
64}
65
66/// Build the lines of a Cyan-bordered panel.
67///
68/// Returns one string per terminal row: top border with title, content
69/// lines with side borders + padding, bottom border with step indicator.
70/// `width` is the total external width including both border columns;
71/// inner content area is `width - 4` (2 padding cells on each side).
72///
73/// `unicode_symbols=false` swaps the box-drawing glyphs for `+ - |`
74/// and substitutes the decorative chars (`●`, `○`, `·`, `←`, `•`,
75/// `█`) inside each content line. Wired from `state.unicode_symbols`
76/// so Windows legacy conhost / `LANG=C` / `TERM=dumb` users see a
77/// clean ASCII box with the right border still column-aligned.
78///
79/// The returned strings include SGR colour codes so the renderer paints
80/// the borders cyan and the title brand-magenta. Pass these strings to
81/// `UiLine::CommandOutput`.
82pub(super) fn draw_panel(
83 title: &str,
84 content: &[String],
85 step_indicator: &str,
86 width: usize,
87 unicode_symbols: bool,
88) -> Vec<String> {
89 use crossterm::style::{Color, ResetColor, SetForegroundColor};
90 let border = Color::Cyan; // Palette::BORDER
91 let brand = Color::Magenta; // Palette::BRAND
92 let (tl, tr, bl, br_c, h, v) = box_chars(unicode_symbols);
93
94 // Sanitise content lines and the title/indicator strings when
95 // ASCII fallback is active. Done once here rather than at every
96 // call site so call sites stay readable.
97 let title_owned: String;
98 let title_seg_src: &str = if unicode_symbols {
99 title
100 } else {
101 title_owned = ascii_fallback(title);
102 &title_owned
103 };
104 let step_owned: String;
105 let step_src: &str = if unicode_symbols {
106 step_indicator
107 } else {
108 step_owned = ascii_fallback(step_indicator);
109 &step_owned
110 };
111
112 let mut out = Vec::with_capacity(content.len() + 2);
113 let inner_width = width.saturating_sub(4);
114
115 // Top border: ┌─ <title> ─...─┐
116 let title_seg = format!(" {title_seg_src} ");
117 let title_width = UnicodeWidthStr::width(title_seg.as_str());
118 let dashes_after = inner_width.saturating_sub(title_width);
119 let top = format!(
120 "{b}{tl}{h}{r}{br}{tt}{r}{b}{dash}{h}{tr}{r}",
121 b = SetForegroundColor(border),
122 br = SetForegroundColor(brand),
123 tl = tl,
124 tr = tr,
125 h = h,
126 tt = title_seg,
127 dash = h.repeat(dashes_after),
128 r = ResetColor,
129 );
130 out.push(top);
131
132 // Content rows: │ <2 sp pad> <line padded to inner_width-2> <2 sp pad> │
133 for raw in content {
134 let owned;
135 let line: &str = if unicode_symbols {
136 raw.as_str()
137 } else {
138 owned = ascii_fallback(raw);
139 &owned
140 };
141 let line_width = UnicodeWidthStr::width(line);
142 let pad = (inner_width.saturating_sub(2)).saturating_sub(line_width);
143 let row = format!(
144 "{b}{v}{r} {line}{pad} {b}{v}{r}",
145 b = SetForegroundColor(border),
146 r = ResetColor,
147 v = v,
148 line = line,
149 pad = " ".repeat(pad),
150 );
151 out.push(row);
152 }
153
154 // Bottom border: └─ <step_indicator> ─...─┘
155 let step_seg = format!(" {step_src} ");
156 let step_w = UnicodeWidthStr::width(step_seg.as_str());
157 let dashes_after_step = inner_width.saturating_sub(step_w);
158 let bot = format!(
159 "{b}{bl}{h}{step_seg}{dash}{h}{br_c}{r}",
160 b = SetForegroundColor(border),
161 bl = bl,
162 br_c = br_c,
163 h = h,
164 step_seg = step_seg,
165 dash = h.repeat(dashes_after_step),
166 r = ResetColor,
167 );
168 out.push(bot);
169
170 out
171}
172
173/// Right-pad `s` with spaces until its visible width reaches
174/// `target`. Returns the input unchanged if it's already that wide
175/// or wider. Used to align option-label columns in Setup step so
176/// hints sit at the same x-position across all 3 rows.
177fn pad_to_width(s: &str, target: usize) -> String {
178 let w = UnicodeWidthStr::width(s);
179 if w >= target {
180 return s.to_string();
181 }
182 format!("{s}{}", " ".repeat(target - w))
183}
184
185/// Footer rows the renderer reserves at the bottom (spinner +
186/// top_rule + input + bot_rule + status). Used to compute how much
187/// vertical body space is available for centering — the wizard
188/// panel must not push into the footer.
189const FOOTER_ROWS: usize = 5;
190
191/// Wrap `lines` with top + bottom padding blanks and a left
192/// indent so the wizard panel sits at the visual centre of the
193/// visible body area. `panel_width` is the horizontal extent of
194/// the widest line (typically the bordered panel — 80 cols
195/// capped); callers pass this in rather than scanning every line
196/// for SGR width because draw_panel-shaped output already commits
197/// to a known width.
198///
199/// Both renderers (retained's DECSTBM region + alt_screen with
200/// sticky_bottom) anchor body content to the body region's BOTTOM:
201/// when total pushed rows < body_height, the auto-empty rows
202/// appear ABOVE the content, not below. So top-padding alone just
203/// stacks redundant blanks against the existing auto-empty strip
204/// while the panel stays glued to the footer. Filling the region
205/// to exactly body_height with top_blanks + content + bottom_blanks
206/// pushes the panel into the actual middle row in both renderers.
207///
208/// Each rendered line keeps its own SGR; we prepend bare spaces,
209/// which contribute no styling, so colour spans on the original
210/// line stay intact.
211fn center_lines(
212 lines: Vec<String>,
213 panel_width: usize,
214 term_cols: u16,
215 term_rows: u16,
216) -> Vec<String> {
217 let term_cols = term_cols as usize;
218 let term_rows = term_rows as usize;
219 let body_rows = term_rows.saturating_sub(FOOTER_ROWS);
220 let free = body_rows.saturating_sub(lines.len());
221 let top_blanks = free / 2;
222 let bottom_blanks = free - top_blanks;
223 let left_pad = term_cols.saturating_sub(panel_width) / 2;
224 let pad_str = " ".repeat(left_pad);
225 let mut out = Vec::with_capacity(lines.len() + free);
226 for _ in 0..top_blanks {
227 out.push(String::new());
228 }
229 for line in lines {
230 if line.is_empty() || left_pad == 0 {
231 out.push(line);
232 } else {
233 out.push(format!("{pad_str}{line}"));
234 }
235 }
236 for _ in 0..bottom_blanks {
237 out.push(String::new());
238 }
239 out
240}
241
242/// Mirror of `/clear`'s "fresh idle view" emission: clear has
243/// already been issued by the caller, this pushes the AtomCode
244/// banner + cwd + model + slash-hint tips that the renderer's
245/// `UiLine::Welcome` handler paints. Used when OnboardingWizard
246/// exits to drop the user onto the standard session view (same
247/// frame the `/clear` slash command produces) instead of a blank
248/// canvas with only the idle prompt. Does NOT emit the
249/// `CmdNewSession` "新会话已开始" toast — onboarding-exit is not
250/// the same intent as `/session` and the toast would be noise.
251pub(crate) fn paint_welcome(
252 ctx: &crate::event_loop::LoopCtx,
253 renderer: &mut dyn crate::render::Renderer,
254) {
255 let dir_display = crate::platform::collapse_home(&ctx.working_dir.to_string_lossy());
256 renderer.render(crate::render::UiLine::Welcome {
257 model: ctx.model_name.clone(),
258 working_dir: dir_display,
259 });
260 renderer.flush();
261}
262
263// ───────────────────────────────────────────────────────────────────
264// State machine
265// ───────────────────────────────────────────────────────────────────
266//
267// `OnboardingWizard` owns the selection indices and a `Step` cursor.
268// Keyboard input flows through `handle_key_pure`, which mutates state
269// and returns a `PureOutcome` describing what the Modal-trait wrapper
270// (Task 6) should do with the world (apply locale, set pending_*
271// flags, clear+redraw, etc.). Splitting the side effects out keeps
272// state-machine tests trivially `LoopCtx`-free.
273
274use crossterm::event::{KeyCode, KeyModifiers};
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum Step {
278 /// Synthetic pre-step shown only when `/welcome` is invoked
279 /// mid-session AND there's prior conversation. y/Y advances to
280 /// Intro after `clear_screen`; n/N/Esc closes without clearing.
281 Confirm,
282 Intro,
283 Language,
284 Setup,
285 /// One-shot CodingPlan fast path entered on first-launch only
286 /// (NOT from `/welcome`). Renders a QR for the AtomGit OAuth
287 /// short link + the raw URL fallback. Enter → close with
288 /// `pending_run_codingplan = true` so the existing `/codingplan`
289 /// driver picks up the just-completed login + claim flow.
290 /// Esc bails to the welcome banner with no auth changes.
291 ///
292 /// PR 1a (this commit): user manually presses Enter after
293 /// scanning. PR 1b will spawn a polling task that closes the
294 /// modal automatically the moment AtomGit reports authorisation.
295 QrLogin,
296}
297
298pub struct OnboardingWizard {
299 pub(super) step: Step,
300 /// 0=Auto-detect, 1=English, 2=ZhCn
301 pub(super) language_idx: usize,
302 /// 0=CodingPlan, 1=Manual, 2=Skip
303 pub(super) setup_idx: usize,
304 /// Set when constructed via `/welcome` mid-session with non-empty
305 /// body. Read by Task 8's slash command path to decide cleanup
306 /// behaviour after Close (whether to skip the post-modal idle
307 /// redraw to avoid double-painting).
308 #[allow(dead_code)] // consumed in Task 8 (/welcome slash command)
309 pub(super) needs_confirm: bool,
310 /// Populated by `new_qr_fast_path` after a successful
311 /// `start_login()` round-trip. `None` for every other constructor.
312 /// Shown verbatim under the QR as the paste-into-browser fallback.
313 pub(super) qr_login_url: Option<String>,
314 /// Populated by `new_qr_fast_path` when `start_login()` itself
315 /// fails (network down, broker 5xx). Surfaced in place of the QR
316 /// so the user can read what went wrong instead of staring at a
317 /// blank panel; Esc bails, Enter retries by re-running
318 /// `start_login()` in `handle_key_pure`'s `Retry` outcome.
319 pub(super) qr_login_error: Option<String>,
320 /// Live `LoginSession` produced by the most recent `start_login()`
321 /// call. The event loop pulls this out via `take_pending_session`
322 /// right after constructing the wizard so a background poll
323 /// thread (see `event_loop::oauth_poll`) can watch for the user
324 /// completing the in-browser consent and auto-close the modal —
325 /// no manual Enter required. `None` after a take, after an Esc,
326 /// or when `start_login()` itself errored at construction.
327 pub(super) pending_session:
328 Option<atomcode_core::auth::oauth::LoginSession>,
329}
330
331impl OnboardingWizard {
332 /// Standard constructor — `/welcome` with empty body. The historic
333 /// 3-step (Intro → Language → Setup) flow stays here intact for
334 /// `/welcome` re-runs; first-launch onboarding now goes through
335 /// [`Self::new_qr_fast_path`] instead.
336 pub fn new() -> Self {
337 Self {
338 step: Step::Intro,
339 language_idx: 0,
340 setup_idx: 0,
341 needs_confirm: false,
342 qr_login_url: None,
343 qr_login_error: None,
344 pending_session: None,
345 }
346 }
347
348 /// Constructor for `/welcome` mid-session when body is non-empty.
349 /// Wizard opens at the synthetic Confirm step; user must press y
350 /// before any clear or further drawing happens.
351 pub fn new_with_confirm() -> Self {
352 Self {
353 step: Step::Confirm,
354 language_idx: 0,
355 setup_idx: 0,
356 needs_confirm: true,
357 qr_login_url: None,
358 qr_login_error: None,
359 pending_session: None,
360 }
361 }
362
363 /// First-launch fast path. Skips the old 3-step Intro / Language /
364 /// Setup flow and goes straight to a single QR screen for the
365 /// AtomGit OAuth short link — scan, log in, the background poll
366 /// thread auto-closes the modal and hands off to `/codingplan`
367 /// for the claim. Language defaults to auto-detect from `$LC_ALL`
368 /// / `$LANG` (i18n step gone); user can switch later via
369 /// `/language`.
370 ///
371 /// Synchronously calls [`atomcode_core::auth::oauth::start_login`]
372 /// up front so the QR is paintable the moment the modal opens.
373 /// On network failure the error is stashed on the wizard and
374 /// rendered in place of the QR — Esc bails, Enter retries via
375 /// `handle_key_pure`'s `RetryQrLogin` outcome.
376 ///
377 /// The successful `LoginSession` is held on `pending_session` so
378 /// the event loop can pull it out (see `take_pending_session`)
379 /// and hand it to a background poll thread. The wizard itself
380 /// doesn't know about polling — that plumbing stays in the event
381 /// loop.
382 pub fn new_qr_fast_path() -> Self {
383 let (qr_login_url, qr_login_error, pending_session) =
384 match atomcode_core::auth::oauth::start_login() {
385 Ok(session) => (Some(session.url().to_string()), None, Some(session)),
386 Err(e) => (None, Some(format!("{e:#}")), None),
387 };
388 Self {
389 step: Step::QrLogin,
390 language_idx: 0,
391 setup_idx: 0,
392 needs_confirm: false,
393 qr_login_url,
394 qr_login_error,
395 pending_session,
396 }
397 }
398
399 /// Pull the freshly-constructed `LoginSession` out so the event
400 /// loop can spawn a background poll thread against it. Returns
401 /// `None` if there is no session to take (Esc was hit, or
402 /// `start_login` errored at construction, or another caller
403 /// already took it). Called exactly once per QR session by
404 /// `event_loop::run_loop`'s first-launch setup; subsequent calls
405 /// return None and are harmless.
406 pub fn take_pending_session(
407 &mut self,
408 ) -> Option<atomcode_core::auth::oauth::LoginSession> {
409 self.pending_session.take()
410 }
411
412 // (set_qr_login_error removed — the event-loop's Failed handler
413 // closes the modal instead of injecting state, so this is unused.
414 // Re-add if PR 1c lands a Modal trait extension + downcast path
415 // that keeps the wizard open on poll failure.)
416
417 /// Pre-select the language idx based on existing config. Used by
418 /// `/welcome` so a user who already picked ZhCn lands on row 3 of
419 /// step 2 instead of Auto-detect.
420 pub fn with_initial_language(
421 mut self,
422 config_lang: Option<atomcode_core::locale::Locale>,
423 ) -> Self {
424 self.language_idx = match config_lang {
425 None => 0,
426 Some(atomcode_core::locale::Locale::En) => 1,
427 Some(atomcode_core::locale::Locale::ZhCn) => 2,
428 };
429 self
430 }
431
432 /// Test-only: dispatch a single key with no modifiers, ignoring
433 /// any side-effect outcome the pure handler returns. Used for
434 /// state-machine unit tests; real Modal::handle_key (Task 6) reads
435 /// the outcome and drives ctx mutations + redraws accordingly.
436 #[cfg(test)]
437 pub(super) fn handle_key_for_test(&mut self, code: KeyCode) {
438 let _ = self.handle_key_pure(code, KeyModifiers::NONE);
439 }
440
441 /// Pure key handling: only mutates `self`, no side effects against
442 /// the world. The Modal::handle_key wrapper (Task 6) calls this,
443 /// then performs the i18n / config / flag side effects based on
444 /// the returned `PureOutcome`.
445 pub(super) fn handle_key_pure(
446 &mut self,
447 code: KeyCode,
448 _mods: KeyModifiers,
449 ) -> PureOutcome {
450 use Step::*;
451 match (self.step, code) {
452 // Confirm
453 (Confirm, KeyCode::Char('y')) | (Confirm, KeyCode::Char('Y')) => {
454 self.step = Intro;
455 PureOutcome::ClearAndRedraw
456 }
457 (Confirm, KeyCode::Char('n'))
458 | (Confirm, KeyCode::Char('N'))
459 | (Confirm, KeyCode::Esc) => PureOutcome::Close,
460
461 // Intro
462 (Intro, KeyCode::Enter) => {
463 self.step = Language;
464 PureOutcome::ClearAndRedraw
465 }
466 (Intro, KeyCode::Esc) => PureOutcome::Close,
467 // Left arrow at intro is no-op (no previous step).
468
469 // Language
470 (Language, KeyCode::Up) => {
471 self.language_idx = self.language_idx.saturating_sub(1);
472 PureOutcome::Redraw
473 }
474 (Language, KeyCode::Down) => {
475 if self.language_idx < 2 {
476 self.language_idx += 1;
477 }
478 PureOutcome::Redraw
479 }
480 // Number keys are shortcuts: pick AND commit in one
481 // keystroke. The arrow-key path still requires Enter,
482 // but typing a digit is unambiguous — the user already
483 // expressed intent, no second confirmation needed.
484 (Language, KeyCode::Char('1')) => {
485 self.language_idx = 0;
486 PureOutcome::ApplyLanguageThenAdvance
487 }
488 (Language, KeyCode::Char('2')) => {
489 self.language_idx = 1;
490 PureOutcome::ApplyLanguageThenAdvance
491 }
492 (Language, KeyCode::Char('3')) => {
493 self.language_idx = 2;
494 PureOutcome::ApplyLanguageThenAdvance
495 }
496 (Language, KeyCode::Enter) => PureOutcome::ApplyLanguageThenAdvance,
497 (Language, KeyCode::Left) => {
498 self.step = Intro;
499 PureOutcome::ClearAndRedraw
500 }
501 (Language, KeyCode::Esc) => PureOutcome::Close,
502
503 // Setup
504 (Setup, KeyCode::Up) => {
505 self.setup_idx = self.setup_idx.saturating_sub(1);
506 PureOutcome::Redraw
507 }
508 (Setup, KeyCode::Down) => {
509 if self.setup_idx < 2 {
510 self.setup_idx += 1;
511 }
512 PureOutcome::Redraw
513 }
514 (Setup, KeyCode::Char('1')) => {
515 self.setup_idx = 0;
516 PureOutcome::ApplySetupThenClose
517 }
518 (Setup, KeyCode::Char('2')) => {
519 self.setup_idx = 1;
520 PureOutcome::ApplySetupThenClose
521 }
522 (Setup, KeyCode::Char('3')) => {
523 self.setup_idx = 2;
524 PureOutcome::ApplySetupThenClose
525 }
526 (Setup, KeyCode::Enter) => PureOutcome::ApplySetupThenClose,
527 (Setup, KeyCode::Left) => {
528 self.step = Language;
529 PureOutcome::ClearAndRedraw
530 }
531 (Setup, KeyCode::Esc) => PureOutcome::Close,
532
533 // QrLogin (fast path, first-launch only).
534 // - start_login failed → Enter retries, Esc bails.
535 // - start_login ok → Enter mirrors the
536 // `session.open_browser_best_effort()` call that
537 // `/codingplan`'s `run_oauth_with_renderer` makes
538 // automatically, so a user who'd rather click than scan
539 // gets a one-key path into the consent page. We
540 // deliberately do NOT re-run start_login here — that's
541 // what the old ApplyQrLoginThenClose did, and it raced
542 // the background poll's auth.toml write, painting a
543 // duplicate QR + URL block into scrollback. The new
544 // outcome only spawns the platform browser command;
545 // failures (xdg-open missing on Linux, no $DISPLAY,
546 // etc.) are silently swallowed and the on-screen QR
547 // + URL stay as fallbacks. The in-flight poll still
548 // auto-closes the modal on its own.
549 (QrLogin, KeyCode::Enter) => {
550 if self.qr_login_error.is_some() {
551 PureOutcome::RetryQrLogin
552 } else if self.qr_login_url.is_some() {
553 PureOutcome::OpenQrUrlInBrowser
554 } else {
555 PureOutcome::Noop
556 }
557 }
558 (QrLogin, KeyCode::Esc) => PureOutcome::Close,
559
560 _ => PureOutcome::Noop,
561 }
562 }
563}
564
565impl OnboardingWizard {
566 /// Build all output lines for step 1 (Intro). `term_cols` /
567 /// `term_rows` are taken from `crossterm::terminal::size()` at the
568 /// caller; passed in so tests don't need a real terminal.
569 /// Returns SGR-laced strings ready for `UiLine::CommandOutput`.
570 ///
571 /// `term_rows < 22` triggers the compact fallback — drops the
572 /// 5-line ASCII logo + Ctrl+C hint so the box fits 18-row
573 /// terminals. Spec threshold: full layout needs 18 rows (16 box +
574 /// 2 header); compact needs 13 (11 box + 2 header).
575 pub(super) fn draw_intro_lines(
576 &self,
577 term_cols: u16,
578 term_rows: u16,
579 unicode_symbols: bool,
580 ) -> Vec<String> {
581 use crate::i18n::{t, Msg};
582 let compact = term_rows < 22;
583
584 // Step header (above box)
585 let mut out = Vec::new();
586 out.push(t(Msg::OnboardingStepHeaderWelcome).into_owned());
587 out.push(String::new()); // blank line between header and box
588
589 // Build content lines
590 let mut content: Vec<String> = Vec::new();
591 content.push(String::new()); // top padding
592
593 if !compact {
594 // 5-line pure block-glyph logo. Uses only `█` and spaces
595 // so it renders uniformly in every monospaced font —
596 // mixing solid blocks with thin box-drawing chars (the
597 // ANSI Shadow style) broke in fonts that draw `█` at
598 // 100% cell coverage while keeping `╔═` at line weight,
599 // leaving the shadow outline floating disjointly from
600 // the letter bodies. Each row is 49 cells; 12-col
601 // leading pad centres the 49-wide logo inside
602 // draw_panel's 74-col content area (12 + 49 + 13 = 74).
603 content.push(" ███ █████ ███ █ █ ████ ███ ████ █████".to_string());
604 content.push(" █ █ █ █ █ ██ ██ █ █ █ █ █ █ ".to_string());
605 content.push(" █████ █ █ █ █ █ █ █ █ █ █ █ █ ████ ".to_string());
606 content.push(" █ █ █ █ █ █ █ █ █ █ █ █ █ █ ".to_string());
607 content.push(" █ █ █ ███ █ █ ████ ███ ████ █████".to_string());
608 content.push(String::new());
609 content.push(
610 t(Msg::OnboardingIntroVersionLine {
611 v: env!("CARGO_PKG_VERSION"),
612 })
613 .into_owned(),
614 );
615 content.push(String::new());
616 content.push(t(Msg::OnboardingIntroBullet1).into_owned());
617 content.push(t(Msg::OnboardingIntroBullet2).into_owned());
618 content.push(t(Msg::OnboardingIntroBullet3).into_owned());
619 content.push(String::new());
620 content.push(t(Msg::OnboardingIntroPressEnter).into_owned());
621 content.push(t(Msg::OnboardingIntroCtrlC).into_owned());
622 } else {
623 // Compact: no logo + no Ctrl+C hint. Just product line +
624 // tagline + bullets + press-enter.
625 content.push(format!("AtomCode v{}", env!("CARGO_PKG_VERSION")));
626 content.push(t(Msg::OnboardingIntroCompactTagline).into_owned());
627 content.push(String::new());
628 content.push(t(Msg::OnboardingIntroBullet1).into_owned());
629 content.push(t(Msg::OnboardingIntroBullet2).into_owned());
630 content.push(t(Msg::OnboardingIntroBullet3).into_owned());
631 content.push(String::new());
632 content.push(t(Msg::OnboardingIntroPressEnter).into_owned());
633 }
634 content.push(String::new()); // bottom padding
635
636 out.extend(draw_panel(
637 &t(Msg::OnboardingPanelTitle),
638 &content,
639 "Step 1/3",
640 (term_cols as usize).min(80),
641 unicode_symbols,
642 ));
643 ascii_fallback_step(out, unicode_symbols)
644 }
645
646 /// Build all output lines for step 2 (Language). Bilingual title
647 /// is locale-independent (it IS the moment the user picks
648 /// locale); the prompt + option labels + nav hint follow the
649 /// current global locale.
650 pub(super) fn draw_language_lines(&self, term_cols: u16, unicode_symbols: bool) -> Vec<String> {
651 use crate::i18n::{t, Msg};
652
653 let mut out = Vec::new();
654 out.push(t(Msg::OnboardingStepHeaderLanguage).into_owned());
655 out.push(String::new());
656
657 let options = [
658 t(Msg::OnboardingLanguageOptionAuto).into_owned(),
659 t(Msg::OnboardingLanguageOptionEn).into_owned(),
660 t(Msg::OnboardingLanguageOptionZhCn).into_owned(),
661 ];
662
663 let mut content: Vec<String> = Vec::new();
664 content.push(String::new());
665 content.push(t(Msg::OnboardingLanguageTitleBilingual).into_owned());
666 content.push(String::new());
667 content.push(t(Msg::OnboardingLanguagePrompt).into_owned());
668 content.push(String::new());
669 for (i, label) in options.iter().enumerate() {
670 let bullet = if i == self.language_idx { '●' } else { '○' };
671 content.push(format!("{bullet} [{}] {}", i + 1, label));
672 }
673 content.push(String::new());
674 content.push(t(Msg::OnboardingNavHint).into_owned());
675 content.push(String::new());
676
677 out.extend(draw_panel(
678 &t(Msg::OnboardingPanelTitle),
679 &content,
680 "Step 2/3",
681 (term_cols as usize).min(80),
682 unicode_symbols,
683 ));
684 ascii_fallback_step(out, unicode_symbols)
685 }
686
687 /// Apply the user's language choice — called when Enter pressed
688 /// in step 2. Mutates `config.language`, flips the global locale,
689 /// and persists the config to disk. Returns the locale that was
690 /// applied so the caller can also surface a confirmation message.
691 ///
692 /// Auto-detect (`language_idx == 0`) clears `config.language` so
693 /// the resolver re-derives from env on next launch; the running
694 /// session also re-resolves immediately so the next redraw uses
695 /// the env-detected locale.
696 pub(super) fn apply_language(
697 &self,
698 config: &mut atomcode_core::config::Config,
699 ) -> anyhow::Result<atomcode_core::locale::Locale> {
700 use atomcode_core::locale::Locale;
701 let new_locale = match self.language_idx {
702 0 => {
703 // Auto-detect: clear config field, re-resolve from env.
704 config.language = None;
705 crate::i18n::resolve_initial_locale(None, None)
706 }
707 1 => {
708 config.language = Some(Locale::En);
709 Locale::En
710 }
711 2 => {
712 config.language = Some(Locale::ZhCn);
713 Locale::ZhCn
714 }
715 _ => unreachable!("language_idx is bounded 0..=2"),
716 };
717 crate::i18n::set_locale(new_locale);
718 config.save(&atomcode_core::config::Config::default_path())?;
719 Ok(new_locale)
720 }
721
722 /// Build all output lines for step 3 (Setup). Reuses the existing
723 /// `WelcomeOption*` Msg variants from the old wizard so the
724 /// already-translated CodingPlan / Manual / Skip labels stay
725 /// consistent. Labels are right-padded to 22 visible cols so the
726 /// hint column lines up across rows even when one label is
727 /// English ("Configure manually") and another is Chinese
728 /// ("配置 CodingPlan") that takes fewer chars but more grid cells.
729 pub(super) fn draw_setup_lines(&self, term_cols: u16, unicode_symbols: bool) -> Vec<String> {
730 use crate::i18n::{t, Msg};
731
732 let mut out = Vec::new();
733 out.push(t(Msg::OnboardingStepHeaderSetup).into_owned());
734 out.push(String::new());
735
736 let options = [
737 (
738 t(Msg::WelcomeOptionCodingPlan).into_owned(),
739 t(Msg::WelcomeOptionCodingPlanHint).into_owned(),
740 ),
741 (
742 t(Msg::WelcomeOptionConfigureManually).into_owned(),
743 t(Msg::WelcomeOptionConfigureManuallyHint).into_owned(),
744 ),
745 (
746 t(Msg::WelcomeOptionSkip).into_owned(),
747 t(Msg::WelcomeOptionSkipHint).into_owned(),
748 ),
749 ];
750
751 let mut content: Vec<String> = Vec::new();
752 content.push(String::new());
753 content.push(t(Msg::OnboardingSetupTitle).into_owned());
754 content.push(String::new());
755 for (i, (label, hint)) in options.iter().enumerate() {
756 let bullet = if i == self.setup_idx { '●' } else { '○' };
757 let label_padded = pad_to_width(label, 22);
758 content.push(format!("{bullet} [{}] {} {}", i + 1, label_padded, hint));
759 }
760 content.push(String::new());
761 content.push(t(Msg::OnboardingNavHint).into_owned());
762 content.push(String::new());
763
764 out.extend(draw_panel(
765 &t(Msg::OnboardingPanelTitle),
766 &content,
767 "Step 3/3",
768 (term_cols as usize).min(80),
769 unicode_symbols,
770 ));
771 ascii_fallback_step(out, unicode_symbols)
772 }
773
774 /// QR fast-path (single-page first-launch onboarding).
775 ///
776 /// Layout (when `start_login` succeeded):
777 /// ```text
778 /// Step 1/1 · 扫码登录
779 /// ┌─ AtomCode ──────────────────────────────────┐
780 /// │ 扫码登录,自动领取 CodingPlan 免费额度 │
781 /// │ │
782 /// │ <QR block> │
783 /// │ │
784 /// │ 手机扫码,或在浏览器打开: │
785 /// │ https://acs.atomgit.com/s/AbC123 │
786 /// │ │
787 /// │ 扫码完成后按 Enter 继续 │
788 /// │ │
789 /// │ Esc 跳过 · /codingplan 重试 · /provider … │
790 /// └─ Step 1/1 ─────────────────────────────────┘
791 /// ```
792 ///
793 /// Failure layout (`start_login` errored at construction time):
794 /// the QR block is replaced with the error message, and the
795 /// instruction line below reads "按 Enter 重试" — handled by
796 /// `RetryQrLogin` in `handle_key_pure`.
797 ///
798 /// ASCII-only terminals (`unicode_symbols == false`): QR is
799 /// omitted entirely; URL is shown as the only login affordance
800 /// so the user can paste it into a browser on a different machine.
801 /// QR glyphs render as `□` tofu on Windows legacy conhost / `LANG=C`
802 /// and a tofu QR is silently unscannable — better to show nothing.
803 pub(super) fn draw_qr_login_lines(
804 &self,
805 term_cols: u16,
806 unicode_symbols: bool,
807 ) -> Vec<String> {
808 let panel_width = (term_cols as usize).min(80);
809 let inner_width = panel_width.saturating_sub(4);
810 // Cells available for content inside the panel's `│ <2sp> ... <2sp> │`
811 // padding. Centring is leading-space prefix; draw_panel adds the
812 // trailing pad to inner_width-2.
813 let cell_w = inner_width.saturating_sub(2);
814 let center = |s: &str| -> String {
815 let w = UnicodeWidthStr::width(s);
816 let pad = cell_w.saturating_sub(w) / 2;
817 format!("{}{}", " ".repeat(pad), s)
818 };
819
820 let mut content: Vec<String> = Vec::new();
821 content.push(String::new());
822 content.push(center("微信扫码登录,自动领取 CodingPlan 免费额度"));
823 content.push(String::new());
824
825 if let Some(reason) = &self.qr_login_error {
826 // start_login failed at construction. Surface the cause
827 // so the user knows whether to check network, broker, or
828 // their own clock; offer Enter-to-retry below.
829 content.push(center("✗ 无法生成登录链接"));
830 content.push(String::new());
831 // Error reason may be long; just left-align with indent
832 // rather than centre — easier to scan.
833 content.push(format!(" {}", reason));
834 content.push(String::new());
835 content.push(center("按 Enter 重试 · Esc 跳过"));
836 } else if let Some(url) = &self.qr_login_url {
837 // Render QR block (Unicode mode) or skip it (ASCII).
838 if let Some(qr_rows) = super::qr::render_for_terminal(url, unicode_symbols) {
839 for row in qr_rows {
840 content.push(center(&row));
841 }
842 content.push(String::new());
843 }
844 content.push(center(if unicode_symbols {
845 "或在浏览器打开:"
846 } else {
847 "无法显示二维码 — 请在浏览器打开:"
848 }));
849 content.push(center(url));
850 content.push(center("(按 Enter 自动打开)"));
851 content.push(String::new());
852 // Polling thread auto-closes the modal the moment AtomGit
853 // reports authorisation, so no force-continue Enter is
854 // needed. Enter is wired to a best-effort browser launch
855 // on the URL above (mirrors what /codingplan does); see
856 // handle_key_pure's QrLogin Enter arm for the rationale
857 // and the historical duplicate-QR bug that gates it.
858 content.push(center("扫码完成后自动跳转"));
859 } else {
860 // Shouldn't happen — `new_qr_fast_path` always populates
861 // exactly one of url / error. Defensive fallback so a
862 // broken constructor doesn't paint a blank panel.
863 content.push(center("(状态未初始化)"));
864 }
865 content.push(String::new());
866 content.push(center("Esc 跳过 · /codingplan 重试 · /provider 手动配置"));
867 content.push(String::new());
868
869 let mut out = Vec::new();
870 out.push("扫码登录 · 领取CodingPlan".to_string());
871 out.push(String::new());
872 // Panel title carries the running atomcode version so users
873 // reporting a screenshot tell us the build their bug landed
874 // in without having to /status first. CARGO_PKG_VERSION is
875 // workspace-bound (e.g. "4.23.1") — matches the convention
876 // used by the Step::Intro version line above.
877 let panel_title = format!("AtomCode · v{}", env!("CARGO_PKG_VERSION"));
878 out.extend(draw_panel(
879 &panel_title,
880 &content,
881 "Step 1/1",
882 panel_width,
883 unicode_symbols,
884 ));
885 ascii_fallback_step(out, unicode_symbols)
886 }
887}
888
889/// Trailing pass over a step's full output (header + box + footer
890/// blanks). `draw_panel` already substitutes Unicode inside its
891/// boxed rows, but the step header rows pushed BEFORE the box don't
892/// go through it — so e.g. "Step 3/3 · Setup" would still carry the
893/// middle dot on a Windows-legacy-console session. Running the
894/// fallback over the whole vec catches those; it's a no-op on rows
895/// the panel already sanitised (none of `+ - | * o < > ^ v #` are in
896/// the substitution set, so they pass through unchanged).
897fn ascii_fallback_step(lines: Vec<String>, unicode_symbols: bool) -> Vec<String> {
898 if unicode_symbols {
899 return lines;
900 }
901 lines.into_iter().map(|l| ascii_fallback(&l)).collect()
902}
903
904impl Default for OnboardingWizard {
905 fn default() -> Self {
906 Self::new()
907 }
908}
909
910impl crate::modals::Modal for OnboardingWizard {
911 fn handle_key(
912 &mut self,
913 code: KeyCode,
914 mods: KeyModifiers,
915 buf: &mut crate::event_loop::Buffer,
916 state: &mut crate::state::UiState,
917 ctx: &mut crate::event_loop::LoopCtx,
918 renderer: &mut dyn crate::render::Renderer,
919 ) -> anyhow::Result<crate::modals::ModalAction> {
920 use crate::modals::ModalAction;
921 let outcome = self.handle_key_pure(code, mods);
922 match outcome {
923 PureOutcome::Noop => Ok(ModalAction::Continue),
924 PureOutcome::Redraw | PureOutcome::ClearAndRedraw => {
925 // Both within-step navigation (1/2/3, ↑/↓) and step
926 // transitions need to wipe the previous panel before
927 // repainting. The wizard's draw() pushes CommandOutput
928 // rows that the retained renderer appends to
929 // scrollback — without clear_screen, every keystroke
930 // would stack another full panel below the last one.
931 // The body context above is already gone by the time
932 // any non-Confirm step runs (Confirm→Intro is itself
933 // a clear-and-redraw), so reclearing here is free.
934 renderer.clear_screen();
935 self.draw(buf, state, ctx, renderer);
936 Ok(ModalAction::Continue)
937 }
938 PureOutcome::ApplyLanguageThenAdvance => {
939 if let Err(e) = self.apply_language(&mut ctx.config) {
940 let msg = crate::i18n::t(crate::i18n::Msg::ConfigSaveFailed {
941 error: &e.to_string(),
942 });
943 renderer.render(crate::render::UiLine::CommandOutput(
944 format!("{}\n", msg),
945 ));
946 }
947 self.step = Step::Setup;
948 renderer.clear_screen();
949 self.draw(buf, state, ctx, renderer);
950 Ok(ModalAction::Continue)
951 }
952 PureOutcome::OpenQrUrlInBrowser => {
953 // Same call /codingplan's run_oauth_with_renderer
954 // makes after rendering the QR. Best-effort: failures
955 // (xdg-open missing on a minimal Linux image, no
956 // $DISPLAY in an SSH session, etc.) are swallowed so
957 // the modal stays put and the user falls back to
958 // scanning the QR or copying the URL. No redraw —
959 // panel content is unchanged; the browser launch is
960 // a pure side effect.
961 if let Some(url) = &self.qr_login_url {
962 let _ = atomcode_core::auth::oauth::open_browser(url);
963 }
964 Ok(ModalAction::Continue)
965 }
966 PureOutcome::RetryQrLogin => {
967 // Re-run start_login() in-place so the user can recover
968 // from a transient network blip without restarting
969 // atomcode. Mirrors the constructor — synchronous
970 // round-trip, store either url or error, AND on success
971 // spawn a fresh background poll thread so the new
972 // session auto-completes the way the original did.
973 match atomcode_core::auth::oauth::start_login() {
974 Ok(session) => {
975 self.qr_login_url = Some(session.url().to_string());
976 self.qr_login_error = None;
977 // session is consumed by `spawn_oauth_poll`;
978 // `pending_session` stays None because the
979 // task owns it now.
980 crate::event_loop::oauth_poll::spawn_oauth_poll(
981 session,
982 Some(std::sync::Arc::clone(&ctx.telemetry)),
983 ctx.oauth_event_tx.clone(),
984 ctx.wake_tx.clone(),
985 );
986 }
987 Err(e) => {
988 self.qr_login_url = None;
989 self.qr_login_error = Some(format!("{e:#}"));
990 }
991 }
992 renderer.clear_screen();
993 self.draw(buf, state, ctx, renderer);
994 Ok(ModalAction::Continue)
995 }
996 PureOutcome::ApplySetupThenClose => {
997 match self.setup_idx {
998 0 => ctx.pending_run_codingplan = true,
999 1 => ctx.pending_open_provider_wizard = true,
1000 _ => { /* Skip — no flag */ }
1001 }
1002 // Setup always runs on a wizard-owned screen
1003 // (Confirm→Intro and every subsequent transition is
1004 // a clear-and-redraw). Wipe the panel before
1005 // returning Close so the next view starts on a clean
1006 // canvas. For Skip (no follow-up flag) we also
1007 // render the welcome banner here so the user lands
1008 // on the regular idle session view (AtomCode banner
1009 // + cwd + model + tips), not a blank screen with
1010 // just an input prompt. CodingPlan and Provider
1011 // takeovers paint their own UI, so we only emit
1012 // Welcome for the Skip branch.
1013 renderer.clear_screen();
1014 if self.setup_idx == 2 {
1015 paint_welcome(ctx, renderer);
1016 }
1017 Ok(ModalAction::Close)
1018 }
1019 PureOutcome::Close => {
1020 // Esc/N from Confirm preserves the body context —
1021 // clearing there would wipe the conversation the
1022 // user just declined to discard. Esc from any other
1023 // step bails out of onboarding entirely; render the
1024 // welcome banner so the user sees the standard idle
1025 // session view instead of a blank canvas.
1026 if self.step != Step::Confirm {
1027 renderer.clear_screen();
1028 paint_welcome(ctx, renderer);
1029 }
1030 Ok(ModalAction::Close)
1031 }
1032 }
1033 }
1034
1035 fn draw(
1036 &self,
1037 _buf: &crate::event_loop::Buffer,
1038 state: &crate::state::UiState,
1039 ctx: &crate::event_loop::LoopCtx,
1040 renderer: &mut dyn crate::render::Renderer,
1041 ) {
1042 let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
1043 // The wizard panel is capped at 80 cols by draw_panel; use that
1044 // as the centering anchor so the bordered box stays at the
1045 // canvas middle in wide terminals. Confirm is deliberately
1046 // left uncentred — it's an inline scrollback message that
1047 // shares space with the preserved body context.
1048 let panel_width = (cols as usize).min(80);
1049 // Mirror of TerminalCaps::unicode_symbols — false on Windows
1050 // legacy conhost / LANG=C / TERM=dumb. Threaded into the
1051 // panel + content rendering so those terminals get an ASCII
1052 // box (`+ - |`) with `●·←` substituted out, which keeps the
1053 // right border column-aligned (the chief visible Win10 bug).
1054 let unicode = state.unicode_symbols;
1055 let lines = match self.step {
1056 Step::Confirm => {
1057 // No box for the y/N prompt — one inline line.
1058 let msg = crate::i18n::t(crate::i18n::Msg::OnboardingConfirmClear).into_owned();
1059 vec![if unicode { msg } else { ascii_fallback(&msg) }]
1060 }
1061 Step::Intro => center_lines(self.draw_intro_lines(cols, rows, unicode), panel_width, cols, rows),
1062 Step::Language => center_lines(self.draw_language_lines(cols, unicode), panel_width, cols, rows),
1063 Step::Setup => center_lines(self.draw_setup_lines(cols, unicode), panel_width, cols, rows),
1064 Step::QrLogin => center_lines(self.draw_qr_login_lines(cols, unicode), panel_width, cols, rows),
1065 };
1066 for line in lines {
1067 // No trailing `\n` — the retained renderer's
1068 // push_body_text splits on `\n` and treats the empty
1069 // chunk after a trailing newline as ANOTHER blank row,
1070 // so `"foo\n"` produced two rows (foo + blank) and the
1071 // wizard's letterforms ended up with a blank line
1072 // between every glyph row. Empty strings already in
1073 // `lines` (top/bottom pad, between-section blanks) emit
1074 // their own blank rows by themselves.
1075 renderer.render(crate::render::UiLine::CommandOutput(line));
1076 }
1077 // Reset the footer's cached input/menu state. The retained
1078 // renderer stores `input_buf`/`menu` separately from
1079 // scrollback; without an explicit InputPrompt re-render here,
1080 // a user who triggered `/welcome` from the slash menu (typed
1081 // `/w`, hit Enter on the highlighted `/welcome`) would still
1082 // see `❯ /w` plus the slash-menu dropdown lingering under the
1083 // wizard — and Backspace wouldn't budge it because the
1084 // in-memory buffer was already cleared by the dispatch path.
1085 // Empty buf + no menu wipes both visuals; the modal owns key
1086 // input until it closes, so the bare `❯ ` underneath is
1087 // purely cosmetic.
1088 renderer.render(crate::render::UiLine::InputPrompt {
1089 buf: String::new(),
1090 cursor_byte: 0,
1091 menu: None,
1092 status: crate::event_loop::build_status(state, ctx),
1093 attachments: Vec::new(),
1094 });
1095 renderer.flush();
1096 }
1097}
1098
1099/// Outcome of `handle_key_pure` — what the Modal-trait wrapper should
1100/// do with the world after the pure transition. Splitting this out
1101/// keeps state-machine tests free of LoopCtx / renderer mocks.
1102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1103pub(super) enum PureOutcome {
1104 /// Modal stays open; redraw on next tick (selection moved within
1105 /// step, no step transition).
1106 Redraw,
1107 /// Modal stays open; clear screen + redraw (step transitioned).
1108 ClearAndRedraw,
1109 /// Apply language pick (i18n::set_locale + persist), then
1110 /// transition to Setup + ClearAndRedraw.
1111 ApplyLanguageThenAdvance,
1112 /// Set the appropriate `pending_*` flag based on `setup_idx`, then
1113 /// close.
1114 ApplySetupThenClose,
1115 /// QR step's `start_login()` previously errored; user pressed
1116 /// Enter to retry. Wrapper re-runs `start_login()` and resets
1117 /// the wizard's `qr_login_url` / `qr_login_error` fields, then
1118 /// ClearAndRedraw.
1119 RetryQrLogin,
1120 /// QR step Enter on the happy path — launch the platform browser
1121 /// at the already-displayed login URL, mirroring the
1122 /// `session.open_browser_best_effort()` call `/codingplan` makes
1123 /// automatically. Failures are silently swallowed (xdg-open
1124 /// missing, headless Linux, etc.); the QR + URL remain on screen
1125 /// as fallbacks, so the modal layout doesn't change.
1126 OpenQrUrlInBrowser,
1127 /// Close modal, no side effect.
1128 Close,
1129 /// Ignore the key.
1130 Noop,
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135 use super::*;
1136
1137 /// Strip every SGR escape so we can assert on the visible glyphs.
1138 fn strip_sgr(s: &str) -> String {
1139 let mut out = String::with_capacity(s.len());
1140 let mut chars = s.chars().peekable();
1141 while let Some(c) = chars.next() {
1142 if c == '\x1b' && chars.peek() == Some(&'[') {
1143 chars.next(); // consume '['
1144 while let Some(&n) = chars.peek() {
1145 chars.next();
1146 if n == 'm' || n.is_alphabetic() {
1147 break;
1148 }
1149 }
1150 continue;
1151 }
1152 out.push(c);
1153 }
1154 out
1155 }
1156
1157 #[test]
1158 fn top_border_has_title() {
1159 let lines = draw_panel("AtomCode", &[], "Step 1/3", 60, true);
1160 let plain = strip_sgr(&lines[0]);
1161 assert!(plain.starts_with("┌─ AtomCode "));
1162 assert!(plain.ends_with("─┐"));
1163 }
1164
1165 #[test]
1166 fn bottom_border_has_step_indicator() {
1167 let lines = draw_panel("AtomCode", &[], "Step 1/3", 60, true);
1168 let plain = strip_sgr(lines.last().unwrap());
1169 assert!(plain.starts_with("└─ Step 1/3 "));
1170 assert!(plain.ends_with("─┘"));
1171 }
1172
1173 #[test]
1174 fn content_lines_are_padded_to_width() {
1175 let content = vec!["hello".to_string(), "".to_string()];
1176 let lines = draw_panel("X", &content, "Y", 30, true);
1177 // Lines 1 & 2 are content. Each must be exactly `width` wide
1178 // when measured by visible-grid columns.
1179 for line in &lines[1..=2] {
1180 let plain = strip_sgr(line);
1181 assert_eq!(
1182 UnicodeWidthStr::width(plain.as_str()),
1183 30,
1184 "line not padded to 30: {plain:?}"
1185 );
1186 }
1187 }
1188
1189 #[test]
1190 fn cjk_content_pads_correctly() {
1191 // Each CJK char is 2 cols. "中文" = 4 cols.
1192 let content = vec!["中文".to_string()];
1193 let lines = draw_panel("X", &content, "Y", 30, true);
1194 let plain = strip_sgr(&lines[1]);
1195 assert_eq!(UnicodeWidthStr::width(plain.as_str()), 30);
1196 }
1197
1198 #[test]
1199 fn narrow_terminal_does_not_panic() {
1200 // width=10 has inner_width=6 which won't fit "AtomCode" title;
1201 // saturating_sub guards against underflow. We just assert it
1202 // doesn't panic and produces *some* output.
1203 let lines = draw_panel("AtomCode", &["x".into()], "S", 10, true);
1204 assert_eq!(lines.len(), 3); // top + 1 content + bottom
1205 }
1206
1207 // ── state-machine transition tests ──
1208
1209 fn make_wizard() -> OnboardingWizard {
1210 OnboardingWizard::new()
1211 }
1212
1213 #[test]
1214 fn new_starts_at_intro() {
1215 let w = make_wizard();
1216 assert_eq!(w.step, Step::Intro);
1217 assert_eq!(w.setup_idx, 0);
1218 assert_eq!(w.language_idx, 0);
1219 assert!(!w.needs_confirm);
1220 }
1221
1222 #[test]
1223 fn new_with_confirm_starts_at_confirm_step() {
1224 let w = OnboardingWizard::new_with_confirm();
1225 assert_eq!(w.step, Step::Confirm);
1226 assert!(w.needs_confirm);
1227 }
1228
1229 #[test]
1230 fn with_initial_language_seeds_idx() {
1231 use atomcode_core::locale::Locale;
1232 assert_eq!(make_wizard().with_initial_language(None).language_idx, 0);
1233 assert_eq!(
1234 make_wizard()
1235 .with_initial_language(Some(Locale::En))
1236 .language_idx,
1237 1
1238 );
1239 assert_eq!(
1240 make_wizard()
1241 .with_initial_language(Some(Locale::ZhCn))
1242 .language_idx,
1243 2
1244 );
1245 }
1246
1247 #[test]
1248 fn intro_enter_advances_to_language() {
1249 let mut w = make_wizard();
1250 w.handle_key_for_test(KeyCode::Enter);
1251 assert_eq!(w.step, Step::Language);
1252 }
1253
1254 #[test]
1255 fn language_left_arrow_returns_to_intro() {
1256 let mut w = make_wizard();
1257 w.step = Step::Language;
1258 w.handle_key_for_test(KeyCode::Left);
1259 assert_eq!(w.step, Step::Intro);
1260 }
1261
1262 #[test]
1263 fn intro_left_arrow_is_noop() {
1264 let mut w = make_wizard();
1265 w.handle_key_for_test(KeyCode::Left);
1266 assert_eq!(w.step, Step::Intro);
1267 }
1268
1269 #[test]
1270 fn language_up_down_moves_idx() {
1271 let mut w = make_wizard();
1272 w.step = Step::Language;
1273 w.language_idx = 1;
1274 w.handle_key_for_test(KeyCode::Down);
1275 assert_eq!(w.language_idx, 2);
1276 w.handle_key_for_test(KeyCode::Down);
1277 assert_eq!(w.language_idx, 2, "should not exceed last index");
1278 w.handle_key_for_test(KeyCode::Up);
1279 assert_eq!(w.language_idx, 1);
1280 w.handle_key_for_test(KeyCode::Up);
1281 w.handle_key_for_test(KeyCode::Up);
1282 assert_eq!(w.language_idx, 0, "saturating_sub keeps idx at 0");
1283 }
1284
1285 #[test]
1286 fn setup_up_down_bounded() {
1287 let mut w = make_wizard();
1288 w.step = Step::Setup;
1289 w.setup_idx = 0;
1290 w.handle_key_for_test(KeyCode::Up);
1291 assert_eq!(w.setup_idx, 0);
1292 w.handle_key_for_test(KeyCode::Down);
1293 assert_eq!(w.setup_idx, 1);
1294 w.handle_key_for_test(KeyCode::Down);
1295 w.handle_key_for_test(KeyCode::Down);
1296 assert_eq!(w.setup_idx, 2);
1297 w.handle_key_for_test(KeyCode::Down);
1298 assert_eq!(w.setup_idx, 2);
1299 }
1300
1301 #[test]
1302 fn number_keys_jump_select() {
1303 let mut w = make_wizard();
1304 w.step = Step::Language;
1305 w.handle_key_for_test(KeyCode::Char('3'));
1306 assert_eq!(w.language_idx, 2);
1307 w.handle_key_for_test(KeyCode::Char('1'));
1308 assert_eq!(w.language_idx, 0);
1309 }
1310
1311 #[test]
1312 fn number_out_of_range_is_noop() {
1313 let mut w = make_wizard();
1314 w.step = Step::Setup;
1315 w.setup_idx = 1;
1316 w.handle_key_for_test(KeyCode::Char('5'));
1317 assert_eq!(w.setup_idx, 1);
1318 w.handle_key_for_test(KeyCode::Char('0'));
1319 assert_eq!(w.setup_idx, 1);
1320 }
1321
1322 #[test]
1323 fn confirm_y_advances_to_intro() {
1324 let mut w = OnboardingWizard::new_with_confirm();
1325 let outcome = w.handle_key_pure(KeyCode::Char('y'), KeyModifiers::NONE);
1326 assert_eq!(w.step, Step::Intro);
1327 assert_eq!(outcome, PureOutcome::ClearAndRedraw);
1328 }
1329
1330 #[test]
1331 fn confirm_capital_y_also_advances() {
1332 let mut w = OnboardingWizard::new_with_confirm();
1333 let outcome = w.handle_key_pure(KeyCode::Char('Y'), KeyModifiers::NONE);
1334 assert_eq!(w.step, Step::Intro);
1335 assert_eq!(outcome, PureOutcome::ClearAndRedraw);
1336 }
1337
1338 #[test]
1339 fn confirm_n_closes_without_advancing() {
1340 let mut w = OnboardingWizard::new_with_confirm();
1341 let outcome = w.handle_key_pure(KeyCode::Char('n'), KeyModifiers::NONE);
1342 assert_eq!(w.step, Step::Confirm, "n must NOT advance step");
1343 assert_eq!(outcome, PureOutcome::Close);
1344 }
1345
1346 #[test]
1347 fn intro_enter_outcome_is_clear_and_redraw() {
1348 let mut w = make_wizard();
1349 let outcome = w.handle_key_pure(KeyCode::Enter, KeyModifiers::NONE);
1350 assert_eq!(w.step, Step::Language);
1351 assert_eq!(outcome, PureOutcome::ClearAndRedraw);
1352 }
1353
1354 #[test]
1355 fn language_enter_outcome_is_apply_then_advance() {
1356 let mut w = make_wizard();
1357 w.step = Step::Language;
1358 w.language_idx = 2;
1359 let outcome = w.handle_key_pure(KeyCode::Enter, KeyModifiers::NONE);
1360 // step stays Language — Modal wrapper performs the apply +
1361 // advance based on the outcome variant. Pure handler only
1362 // reports the intent.
1363 assert_eq!(outcome, PureOutcome::ApplyLanguageThenAdvance);
1364 }
1365
1366 #[test]
1367 fn setup_enter_outcome_is_apply_then_close() {
1368 let mut w = make_wizard();
1369 w.step = Step::Setup;
1370 let outcome = w.handle_key_pure(KeyCode::Enter, KeyModifiers::NONE);
1371 assert_eq!(outcome, PureOutcome::ApplySetupThenClose);
1372 }
1373
1374 #[test]
1375 fn esc_at_any_step_closes() {
1376 for start in [Step::Intro, Step::Language, Step::Setup] {
1377 let mut w = make_wizard();
1378 w.step = start;
1379 let outcome = w.handle_key_pure(KeyCode::Esc, KeyModifiers::NONE);
1380 assert_eq!(outcome, PureOutcome::Close, "Esc at {start:?} must Close");
1381 }
1382 }
1383
1384 // ── Step 1 (Intro) draw tests ──
1385
1386 /// Full-height layout assertions: ASCII logo + version + all
1387 /// three bullets + press-enter + ctrl-c lines all present.
1388 #[test]
1389 fn intro_full_layout_has_all_pieces() {
1390 let _g = crate::i18n::test_lock();
1391 crate::i18n::set_locale(crate::i18n::Locale::En);
1392 let lines = OnboardingWizard::new().draw_intro_lines(80, 24, true);
1393 let joined: String = lines
1394 .iter()
1395 .map(|s| strip_sgr(s))
1396 .collect::<Vec<_>>()
1397 .join("\n");
1398 // ASCII logo signature: M's row 3 collapses to alternating
1399 // `█ █ █ █`, unique to the new pure-block design.
1400 assert!(
1401 joined.contains("█ █ █ █"),
1402 "logo missing: {joined}"
1403 );
1404 assert!(joined.contains("Version "));
1405 assert!(joined.contains("Multi-step agent loop"));
1406 assert!(joined.contains("Connects to any OpenAI"));
1407 assert!(joined.contains("Free tokens via CodingPlan"));
1408 assert!(joined.contains("Press Enter to continue"));
1409 assert!(joined.contains("Ctrl+C exits"));
1410 // Header above the box.
1411 assert!(joined.contains("Step 1/3 · Welcome"));
1412 // Box step indicator at bottom.
1413 assert!(joined.contains("Step 1/3"));
1414 }
1415
1416 /// `term_rows < 22` drops the logo + Ctrl+C lines. Bullets,
1417 /// version, and Press-Enter still render so the user can advance.
1418 #[test]
1419 fn intro_compact_drops_logo() {
1420 let _g = crate::i18n::test_lock();
1421 crate::i18n::set_locale(crate::i18n::Locale::En);
1422 let lines = OnboardingWizard::new().draw_intro_lines(80, 18, true);
1423 let joined: String = lines
1424 .iter()
1425 .map(|s| strip_sgr(s))
1426 .collect::<Vec<_>>()
1427 .join("\n");
1428 assert!(
1429 !joined.contains("█ █ █ █"),
1430 "logo should be hidden in compact mode: {joined}"
1431 );
1432 // Compact replaces the version block with a compact product
1433 // line `AtomCode vX.Y.Z` + tagline.
1434 assert!(joined.contains("AtomCode v"));
1435 assert!(joined.contains("AI coding agent that lives in your terminal"));
1436 assert!(joined.contains("Free tokens"));
1437 assert!(joined.contains("Press Enter to continue"));
1438 }
1439
1440 // ── Step 2 (Language) draw + apply tests ──
1441
1442 /// Bilingual title + 3 numbered options + nav hint all present in
1443 /// the rendered output.
1444 #[test]
1445 fn language_layout_has_three_options_with_numbers() {
1446 let _g = crate::i18n::test_lock();
1447 crate::i18n::set_locale(crate::i18n::Locale::En);
1448 let lines = OnboardingWizard::new().draw_language_lines(80, true);
1449 let joined: String = lines
1450 .iter()
1451 .map(|s| strip_sgr(s))
1452 .collect::<Vec<_>>()
1453 .join("\n");
1454 // Bilingual title (locale-independent).
1455 assert!(joined.contains("Choose your language / 选择语言"));
1456 // Three numbered options.
1457 assert!(joined.contains("[1] Auto-detect"));
1458 assert!(joined.contains("[2] English"));
1459 assert!(joined.contains("[3] 简体中文"));
1460 // Step header + indicator.
1461 assert!(joined.contains("Step 2/3 · Language"));
1462 // Nav hint.
1463 assert!(joined.contains("1-3 select"));
1464 }
1465
1466 /// Selected marker `●` sits on the row matching language_idx;
1467 /// the other rows get the hollow `○` marker.
1468 #[test]
1469 fn language_selected_marker_follows_idx() {
1470 let _g = crate::i18n::test_lock();
1471 crate::i18n::set_locale(crate::i18n::Locale::En);
1472 let mut w = OnboardingWizard::new();
1473 w.step = Step::Language;
1474 w.language_idx = 2;
1475 let lines = w.draw_language_lines(80, true);
1476 let joined: String = lines
1477 .iter()
1478 .map(|s| strip_sgr(s))
1479 .collect::<Vec<_>>()
1480 .join("\n");
1481 // `● [3] 简体中文` selected; `○ [2] English` unselected.
1482 let pos_filled = joined.find("● [3]").expect("filled marker missing");
1483 let pos_hollow = joined.find("○ [2]").expect("hollow marker missing");
1484 assert!(
1485 pos_hollow < pos_filled,
1486 "expected hollow before filled marker"
1487 );
1488 }
1489
1490 /// apply_language writes the picked locale into config + flips
1491 /// the global locale + persists to disk under an ATOMCODE_HOME
1492 /// override so tests don't touch real `~/.atomcode`.
1493 #[test]
1494 fn apply_language_writes_config_and_sets_locale() {
1495 use atomcode_core::locale::Locale;
1496 let _g = crate::i18n::test_lock();
1497 let tmp = tempfile::TempDir::new().unwrap();
1498 // ATOMCODE_HOME drives Config::config_dir() ahead of $HOME, so
1499 // the test's config.save lands in `<tmp>/config.toml` and not
1500 // the real home dir. Saved+restored around the test to keep
1501 // parallel tests from racing on the global env.
1502 let prev_atomcode_home = std::env::var("ATOMCODE_HOME").ok();
1503 std::env::set_var("ATOMCODE_HOME", tmp.path());
1504
1505 let mut cfg = blank_config_for_test();
1506 let mut w = OnboardingWizard::new();
1507 w.language_idx = 2;
1508 let applied = w.apply_language(&mut cfg).unwrap();
1509 assert_eq!(applied, Locale::ZhCn);
1510 assert_eq!(cfg.language, Some(Locale::ZhCn));
1511 assert_eq!(crate::i18n::current_locale(), Locale::ZhCn);
1512 // File must actually exist on disk.
1513 assert!(tmp.path().join("config.toml").exists());
1514
1515 // Restore env.
1516 match prev_atomcode_home {
1517 Some(v) => std::env::set_var("ATOMCODE_HOME", v),
1518 None => std::env::remove_var("ATOMCODE_HOME"),
1519 }
1520 }
1521
1522 /// Auto-detect (idx 0) blanks `config.language` so the next-launch
1523 /// resolver re-derives from env. Even when the prior config carried
1524 /// an explicit choice.
1525 #[test]
1526 fn apply_language_auto_clears_config_field() {
1527 use atomcode_core::locale::Locale;
1528 let _g = crate::i18n::test_lock();
1529 let tmp = tempfile::TempDir::new().unwrap();
1530 let prev = std::env::var("ATOMCODE_HOME").ok();
1531 std::env::set_var("ATOMCODE_HOME", tmp.path());
1532
1533 let mut cfg = blank_config_for_test();
1534 cfg.language = Some(Locale::En); // start with non-None
1535 let mut w = OnboardingWizard::new();
1536 w.language_idx = 0;
1537 w.apply_language(&mut cfg).unwrap();
1538 assert_eq!(cfg.language, None);
1539
1540 match prev {
1541 Some(v) => std::env::set_var("ATOMCODE_HOME", v),
1542 None => std::env::remove_var("ATOMCODE_HOME"),
1543 }
1544 }
1545
1546 /// Minimal Config used by the apply_language tests. Config has no
1547 /// Default impl (every field is intentionally required so adding
1548 /// a new field forces every test to update), so we mirror the
1549 /// blank_config_with_lsp helper from `core::config::tests` here.
1550 fn blank_config_for_test() -> atomcode_core::config::Config {
1551 atomcode_core::config::Config {
1552 default_provider: String::new(),
1553 default_workdir: None,
1554 providers: Default::default(),
1555 datalog: Default::default(),
1556 auto_update: true,
1557 notifications: Default::default(),
1558 telemetry: Default::default(),
1559 lsp: Default::default(),
1560 auto_commit: false,
1561 subagent: Default::default(),
1562 vision_preprocessor_provider: None,
1563 language: None,
1564 ui: Default::default(),
1565 plugin: Default::default(),
1566 }
1567 }
1568
1569 // ── Step 3 (Setup) draw tests ──
1570
1571 /// Setup panel renders 3 numbered options with localised
1572 /// CodingPlan / Manual / Skip labels (reusing WelcomeOption* Msg
1573 /// variants), the SetupTitle, and the nav hint.
1574 #[test]
1575 fn setup_layout_has_three_options() {
1576 let _g = crate::i18n::test_lock();
1577 crate::i18n::set_locale(crate::i18n::Locale::En);
1578 let lines = OnboardingWizard::new().draw_setup_lines(80, true);
1579 let joined: String = lines
1580 .iter()
1581 .map(|s| strip_sgr(s))
1582 .collect::<Vec<_>>()
1583 .join("\n");
1584 assert!(joined.contains("Step 3/3 · Setup"));
1585 assert!(joined.contains("How would you like to set up?"));
1586 assert!(joined.contains("[1] Set up CodingPlan"));
1587 assert!(joined.contains("[2] Configure manually"));
1588 assert!(joined.contains("[3] Skip for now"));
1589 // Hints sit after each option.
1590 assert!(joined.contains("Free tokens"));
1591 assert!(joined.contains("API key"));
1592 // Nav hint.
1593 assert!(joined.contains("1-3 select"));
1594 }
1595
1596 /// ZhCn locale flips every label + hint to the Chinese strings
1597 /// that the i18n shipped originally for WelcomeOption*.
1598 #[test]
1599 fn setup_zh_renders_chinese_labels() {
1600 let _g = crate::i18n::test_lock();
1601 crate::i18n::set_locale(crate::i18n::Locale::ZhCn);
1602 let lines = OnboardingWizard::new().draw_setup_lines(80, true);
1603 let joined: String = lines
1604 .iter()
1605 .map(|s| strip_sgr(s))
1606 .collect::<Vec<_>>()
1607 .join("\n");
1608 assert!(joined.contains("第 3/3 步 · 配置"));
1609 assert!(joined.contains("配置 CodingPlan"));
1610 assert!(joined.contains("手动配置"));
1611 assert!(joined.contains("暂时跳过"));
1612 }
1613
1614 /// CodingPlan must come first in the rendered step-3 list, then
1615 /// Manual, then Skip. Migrated from the deleted welcome_wizard.rs's
1616 /// `options_put_codingplan_first` test; pins option order so a
1617 /// reorder needs a deliberate test update.
1618 #[test]
1619 fn setup_options_put_codingplan_first() {
1620 let _g = crate::i18n::test_lock();
1621 crate::i18n::set_locale(crate::i18n::Locale::En);
1622 let lines = OnboardingWizard::new().draw_setup_lines(80, true);
1623 let joined: String = lines
1624 .iter()
1625 .map(|s| strip_sgr(s))
1626 .collect::<Vec<_>>()
1627 .join("\n");
1628 let pos_codingplan = joined
1629 .find("Set up CodingPlan")
1630 .expect("CodingPlan label missing");
1631 let pos_manual = joined
1632 .find("Configure manually")
1633 .expect("manual label missing");
1634 let pos_skip = joined.find("Skip for now").expect("skip label missing");
1635 assert!(pos_codingplan < pos_manual);
1636 assert!(pos_manual < pos_skip);
1637 }
1638
1639 /// Filled marker tracks setup_idx.
1640 #[test]
1641 fn setup_selected_marker_follows_idx() {
1642 let _g = crate::i18n::test_lock();
1643 crate::i18n::set_locale(crate::i18n::Locale::En);
1644 let mut w = OnboardingWizard::new();
1645 w.setup_idx = 1;
1646 let lines = w.draw_setup_lines(80, true);
1647 let joined: String = lines
1648 .iter()
1649 .map(|s| strip_sgr(s))
1650 .collect::<Vec<_>>()
1651 .join("\n");
1652 // Selected: idx 1 → ● [2]; others get ○.
1653 assert!(joined.contains("● [2]"));
1654 assert!(joined.contains("○ [1]"));
1655 assert!(joined.contains("○ [3]"));
1656 }
1657
1658 // ── VirtualTerminal snapshot tests ──
1659 //
1660 // These tests feed the wizard's emitted lines through a real
1661 // VirtualTerminal — same vt100 path the prod renderer drives — so
1662 // we catch ANSI/SGR mishandling that the strip_sgr unit tests
1663 // can't surface. Plan called for a higher-level `new_vterm` /
1664 // `ctx_for_modal_tests` helper pair, but those don't exist in
1665 // the current tree; the wizard's `draw_*_lines` already returns
1666 // standalone strings (it's only the Modal trait wrapper that
1667 // needs the renderer / ctx), so we can short-circuit straight
1668 // into the vterm by feeding `lines.join("\n")` as bytes.
1669
1670 /// Feed wizard lines into a fresh VirtualTerminal and return its
1671 /// post-paint screen dump. Each line gets a trailing `\r\n` so
1672 /// the vterm advances to column 0 of the next row — without the
1673 /// `\r`, lines would stack at whatever column the cursor happened
1674 /// to be at after the previous line's last SGR.
1675 fn paint_to_vterm(lines: Vec<String>, w: u16, h: u16) -> String {
1676 let mut vt = crate::test_term::VirtualTerminal::new(w, h);
1677 let mut bytes = Vec::new();
1678 for line in &lines {
1679 bytes.extend_from_slice(line.as_bytes());
1680 bytes.extend_from_slice(b"\r\n");
1681 }
1682 vt.feed(&bytes);
1683 vt.dump()
1684 }
1685
1686 #[test]
1687 fn vterm_step1_shows_box_borders_in_en() {
1688 let _g = crate::i18n::test_lock();
1689 crate::i18n::set_locale(crate::i18n::Locale::En);
1690 let lines = OnboardingWizard::new().draw_intro_lines(80, 24, true);
1691 let screen = paint_to_vterm(lines, 80, 24);
1692 // Top + bottom corner glyphs visible in the painted grid.
1693 assert!(screen.contains("┌─"), "top border missing: {screen}");
1694 assert!(screen.contains("└─"), "bottom border missing: {screen}");
1695 // Brand title, step header, key copy.
1696 assert!(screen.contains("AtomCode"));
1697 assert!(screen.contains("Step 1/3 · Welcome"));
1698 assert!(screen.contains("Press Enter to continue"));
1699 }
1700
1701 #[test]
1702 fn vterm_step2_zh_renders_bilingual_title_and_chinese_options() {
1703 let _g = crate::i18n::test_lock();
1704 crate::i18n::set_locale(crate::i18n::Locale::ZhCn);
1705 let mut w = OnboardingWizard::new();
1706 w.step = Step::Language;
1707 let lines = w.draw_language_lines(80, true);
1708 let screen = paint_to_vterm(lines, 80, 24);
1709 // vt100 places a CJK double-width char in one cell and leaves
1710 // the next cell blank, so `选择语言` reads back as
1711 // `选 择 语 言` in the grid dump.
1712 assert!(
1713 screen.contains("Choose your language / 选 择 语 言"),
1714 "bilingual title missing: {screen}"
1715 );
1716 assert!(
1717 screen.contains("自 动 检 测"),
1718 "Chinese auto-detect label missing: {screen}"
1719 );
1720 // Step header `第 2/3 步 · 语言` — the trailing-blank cell after
1721 // each CJK glyph collides with the source spaces around `2/3`
1722 // and `·`, doubling them.
1723 assert!(
1724 screen.contains("第 2/3 步 · 语 言"),
1725 "zh step header missing: {screen}"
1726 );
1727 }
1728
1729 #[test]
1730 fn vterm_step1_compact_below_22_rows_drops_logo() {
1731 let _g = crate::i18n::test_lock();
1732 crate::i18n::set_locale(crate::i18n::Locale::En);
1733 let lines = OnboardingWizard::new().draw_intro_lines(80, 18, true);
1734 let screen = paint_to_vterm(lines, 80, 18);
1735 assert!(
1736 !screen.contains("█ █ █ █"),
1737 "ASCII logo present in compact mode: {screen}"
1738 );
1739 // Compact substitutes `AtomCode vX.Y.Z`.
1740 assert!(screen.contains("AtomCode v"));
1741 assert!(screen.contains("Press Enter to continue"));
1742 }
1743
1744 /// Step 3 setup options align in the grid: bullet column, [N]
1745 /// column, label column all stay vertically aligned across the
1746 /// three rows. Pin via `any_row` since the wizard emits one row
1747 /// per option.
1748 #[test]
1749 fn vterm_step3_setup_options_align_vertically() {
1750 let _g = crate::i18n::test_lock();
1751 crate::i18n::set_locale(crate::i18n::Locale::En);
1752 let lines = OnboardingWizard::new().draw_setup_lines(80, true);
1753
1754 let mut vt = crate::test_term::VirtualTerminal::new(80, 24);
1755 let mut bytes = Vec::new();
1756 for line in &lines {
1757 bytes.extend_from_slice(line.as_bytes());
1758 bytes.extend_from_slice(b"\r\n");
1759 }
1760 vt.feed(&bytes);
1761
1762 // Each option's row starts with `│ ● [1]` or `│ ○ [N]`.
1763 // The bullet must sit at the same column across all three.
1764 let rows_with_bracket: Vec<String> = (0..24)
1765 .map(|r| vt.row_text(r))
1766 .filter(|r| r.contains("[1]") || r.contains("[2]") || r.contains("[3]"))
1767 .collect();
1768 assert_eq!(rows_with_bracket.len(), 3, "expected 3 option rows, got {rows_with_bracket:?}");
1769 // Bullet position (●/○) — all three rows must place it at
1770 // the same column index. Locate via find().
1771 let bullet_cols: Vec<Option<usize>> = rows_with_bracket
1772 .iter()
1773 .map(|r| r.find('●').or_else(|| r.find('○')))
1774 .collect();
1775 assert!(
1776 bullet_cols.iter().all(|c| c.is_some() && *c == bullet_cols[0]),
1777 "bullet column drift across rows: {bullet_cols:?}"
1778 );
1779 }
1780
1781 /// pad_to_width: short strings get right-padded to target; long
1782 /// strings pass through unchanged.
1783 #[test]
1784 fn pad_to_width_handles_cjk_and_short_strings() {
1785 assert_eq!(pad_to_width("hi", 6), "hi ");
1786 // CJK char = 2 cols, so "中文" is 4 cols + 2 pad = "中文 ".
1787 assert_eq!(pad_to_width("中文", 6), "中文 ");
1788 // Already wider — returned as-is, no truncation.
1789 assert_eq!(pad_to_width("hello world", 5), "hello world");
1790 }
1791
1792 /// Locale-driven copy lookup — boot in ZhCn, every string in the
1793 /// intro panel should be the Chinese translation.
1794 #[test]
1795 fn intro_renders_in_zh_cn() {
1796 let _g = crate::i18n::test_lock();
1797 crate::i18n::set_locale(crate::i18n::Locale::ZhCn);
1798 let lines = OnboardingWizard::new().draw_intro_lines(80, 24, true);
1799 let joined: String = lines
1800 .iter()
1801 .map(|s| strip_sgr(s))
1802 .collect::<Vec<_>>()
1803 .join("\n");
1804 assert!(joined.contains("第 1/3 步 · 欢迎"));
1805 assert!(joined.contains("版本 "));
1806 assert!(joined.contains("按 Enter 继续"));
1807 assert!(joined.contains("Ctrl+C 可随时退出"));
1808 // Brand title stays English on purpose.
1809 assert!(joined.contains("AtomCode"));
1810 }
1811
1812 // ── ASCII fallback (Windows legacy conhost / LANG=C / TERM=dumb) ──
1813
1814 /// `unicode_symbols=false` swaps the box-drawing glyphs and the
1815 /// decorative content chars for ASCII equivalents. Regression
1816 /// guard for the Windows 10 cmd report where the right `│`
1817 /// landed at a different column on every row that contained
1818 /// `●` / `·` / `←`, because `unicode-width` reports them as 1
1819 /// cell while conhost allocates a slightly wider glyph.
1820 #[test]
1821 fn draw_panel_ascii_fallback_uses_plus_dash_pipe() {
1822 let lines = draw_panel(
1823 "AtomCode",
1824 &["row one".into(), "row two".into()],
1825 "Step 3/3",
1826 30,
1827 false,
1828 );
1829 let joined: String = lines.iter().map(|l| strip_sgr(l)).collect::<Vec<_>>().join("\n");
1830 // Box-drawing glyphs gone, ASCII fallbacks in their place.
1831 assert!(!joined.contains('┌'), "U+250C leaked: {:?}", joined);
1832 assert!(!joined.contains('┐'), "U+2510 leaked: {:?}", joined);
1833 assert!(!joined.contains('└'), "U+2514 leaked: {:?}", joined);
1834 assert!(!joined.contains('┘'), "U+2518 leaked: {:?}", joined);
1835 assert!(!joined.contains('─'), "U+2500 leaked: {:?}", joined);
1836 assert!(!joined.contains('│'), "U+2502 leaked: {:?}", joined);
1837 assert!(joined.contains('+'), "no + corner: {:?}", joined);
1838 assert!(joined.contains('-'), "no - horizontal: {:?}", joined);
1839 assert!(joined.contains('|'), "no | vertical: {:?}", joined);
1840 }
1841
1842 /// `●`, `○`, `·`, `←`, `•` inside content rows must be substituted
1843 /// with width-equivalent ASCII so the right border stays
1844 /// column-aligned. We can't easily verify column alignment in a
1845 /// unit test (no real terminal), but we CAN assert the
1846 /// substitution happened.
1847 #[test]
1848 fn draw_panel_ascii_fallback_substitutes_decorative_chars_in_content() {
1849 let content = vec!["● filled".into(), "○ open · mid · ← back • bullet".into()];
1850 let lines = draw_panel("X", &content, "Y", 60, false);
1851 let joined: String = lines.iter().map(|l| strip_sgr(l)).collect::<Vec<_>>().join("\n");
1852 for bad in ['●', '○', '·', '←', '•'] {
1853 assert!(
1854 !joined.contains(bad),
1855 "Unicode {:?} leaked through ASCII fallback: {:?}",
1856 bad,
1857 joined
1858 );
1859 }
1860 assert!(joined.contains('*'), "● not replaced with *: {:?}", joined);
1861 assert!(joined.contains('o'), "○ not replaced with o: {:?}", joined);
1862 assert!(joined.contains('<'), "← not replaced with <: {:?}", joined);
1863 }
1864
1865 /// On Windows-legacy-console paths, `state.unicode_symbols == false`
1866 /// flows through `draw_setup_lines` → `draw_panel`, so the full
1867 /// step 3 render must come out ASCII-only.
1868 #[test]
1869 fn draw_setup_lines_ascii_fallback_produces_pure_ascii_box() {
1870 let _g = atomcode_core::i18n::test_lock();
1871 atomcode_core::i18n::set_locale(atomcode_core::i18n::Locale::En);
1872 let lines = OnboardingWizard::new().draw_setup_lines(80, false);
1873 let joined: String = lines.iter().map(|l| strip_sgr(l)).collect::<Vec<_>>().join("\n");
1874 for bad in ['┌', '┐', '└', '┘', '─', '│', '●', '○', '·', '←', '•'] {
1875 assert!(
1876 !joined.contains(bad),
1877 "Unicode {:?} leaked through Setup ASCII fallback: {:?}",
1878 bad,
1879 joined
1880 );
1881 }
1882 // Visible content is still readable in ASCII.
1883 assert!(joined.contains("Set up CodingPlan"));
1884 assert!(joined.contains("[1]") && joined.contains("[2]") && joined.contains("[3]"));
1885 }
1886
1887 /// Belt + braces: each row of an ASCII-fallback rendered Setup
1888 /// panel must end with `|` at the SAME column. This is the
1889 /// property that was visibly broken on Windows 10 cmd (right
1890 /// border zig-zagging).
1891 #[test]
1892 fn draw_panel_ascii_fallback_right_border_column_aligned() {
1893 let _g = atomcode_core::i18n::test_lock();
1894 atomcode_core::i18n::set_locale(atomcode_core::i18n::Locale::En);
1895 let lines = OnboardingWizard::new().draw_setup_lines(80, false);
1896 // Drop the step header row that sits ABOVE the panel — it's
1897 // not bordered.
1898 let bordered: Vec<String> = lines
1899 .iter()
1900 .map(|l| strip_sgr(l))
1901 .filter(|l| l.contains('|') || l.contains('+'))
1902 .collect();
1903 assert!(bordered.len() >= 3, "expected top + content + bottom: {:?}", bordered);
1904 let widths: std::collections::HashSet<usize> = bordered
1905 .iter()
1906 .map(|l| UnicodeWidthStr::width(l.as_str()))
1907 .collect();
1908 assert_eq!(
1909 widths.len(),
1910 1,
1911 "panel rows have different visible widths — right border would zig-zag: {:?}",
1912 bordered
1913 );
1914 }
1915
1916 // ── QrLogin (PR 1a) state-machine + draw tests ──────────────────
1917 //
1918 // start_login() is a real network call so these tests can't drive
1919 // it end-to-end. Instead we manually construct an OnboardingWizard
1920 // pinned to Step::QrLogin with synthetic url / error state, then
1921 // exercise the keystroke transitions + draw output.
1922
1923 fn qr_wizard_with_url(url: &str) -> OnboardingWizard {
1924 OnboardingWizard {
1925 step: Step::QrLogin,
1926 language_idx: 0,
1927 setup_idx: 0,
1928 needs_confirm: false,
1929 qr_login_url: Some(url.to_string()),
1930 qr_login_error: None,
1931 pending_session: None,
1932 }
1933 }
1934
1935 fn qr_wizard_with_error(msg: &str) -> OnboardingWizard {
1936 OnboardingWizard {
1937 step: Step::QrLogin,
1938 language_idx: 0,
1939 setup_idx: 0,
1940 needs_confirm: false,
1941 qr_login_url: None,
1942 qr_login_error: Some(msg.to_string()),
1943 pending_session: None,
1944 }
1945 }
1946
1947 #[test]
1948 fn qr_login_enter_when_url_present_opens_browser() {
1949 // Enter on the happy QR path now mirrors /codingplan's
1950 // `session.open_browser_best_effort()` — same platform
1951 // browser launch the CLI flow makes automatically, just
1952 // user-triggered. The historical Noop was a fix for a
1953 // duplicate-QR bug caused by re-running start_login on
1954 // Enter (ApplyQrLoginThenClose); the new outcome doesn't
1955 // touch start_login at all, so that bug stays gone.
1956 let mut w = qr_wizard_with_url("https://acs.atomgit.com/s/AbC123");
1957 let outcome = w.handle_key_pure(KeyCode::Enter, KeyModifiers::NONE);
1958 assert_eq!(outcome, PureOutcome::OpenQrUrlInBrowser);
1959 }
1960
1961 #[test]
1962 fn qr_login_enter_with_neither_url_nor_error_is_noop() {
1963 // Defensive: if construction landed in a state where neither
1964 // the URL nor the error is populated (shouldn't happen — the
1965 // constructor always produces exactly one), Enter must NOT
1966 // dispatch OpenQrUrlInBrowser (would try to open None) and
1967 // must NOT dispatch RetryQrLogin (no error to surface). Noop
1968 // keeps the modal inert until Esc.
1969 let mut w = qr_wizard_with_url("https://acs.atomgit.com/s/AbC123");
1970 w.qr_login_url = None;
1971 w.qr_login_error = None;
1972 let outcome = w.handle_key_pure(KeyCode::Enter, KeyModifiers::NONE);
1973 assert_eq!(outcome, PureOutcome::Noop);
1974 }
1975
1976 #[test]
1977 fn qr_login_enter_when_in_error_state_retries() {
1978 // start_login failed at construction. Enter re-runs it —
1979 // wrapper handles ctx mutations, the pure outcome just signals
1980 // intent.
1981 let mut w = qr_wizard_with_error("transport: connection refused");
1982 let outcome = w.handle_key_pure(KeyCode::Enter, KeyModifiers::NONE);
1983 assert_eq!(outcome, PureOutcome::RetryQrLogin);
1984 }
1985
1986 #[test]
1987 fn qr_login_esc_closes_without_codingplan_flag() {
1988 // Esc bails to the welcome banner — no pending_run_codingplan,
1989 // no setup_idx mutation. Pin against accidental future drift
1990 // into the Setup-step's ApplySetupThenClose flag-setting path.
1991 let mut w = qr_wizard_with_url("https://acs.atomgit.com/s/AbC123");
1992 let outcome = w.handle_key_pure(KeyCode::Esc, KeyModifiers::NONE);
1993 assert_eq!(outcome, PureOutcome::Close);
1994
1995 let mut w = qr_wizard_with_error("any");
1996 let outcome = w.handle_key_pure(KeyCode::Esc, KeyModifiers::NONE);
1997 assert_eq!(outcome, PureOutcome::Close);
1998 }
1999
2000 #[test]
2001 fn qr_login_random_keys_are_noop() {
2002 // Arrow keys / 1-3 / letters do nothing on QrLogin — pin so a
2003 // future copy-paste from the Setup-step arms doesn't acquire
2004 // unintended menu-navigation semantics on this single-page
2005 // screen.
2006 let mut w = qr_wizard_with_url("https://acs.atomgit.com/s/AbC123");
2007 for code in [KeyCode::Up, KeyCode::Down, KeyCode::Left,
2008 KeyCode::Char('1'), KeyCode::Char('a')] {
2009 assert_eq!(
2010 w.handle_key_pure(code, KeyModifiers::NONE),
2011 PureOutcome::Noop,
2012 "{:?} should be Noop on QrLogin",
2013 code
2014 );
2015 }
2016 }
2017
2018 #[test]
2019 fn qr_login_draw_with_url_includes_url_in_output() {
2020 let w = qr_wizard_with_url("https://acs.atomgit.com/s/AbC123");
2021 let lines = w.draw_qr_login_lines(80, true);
2022 let blob = lines.join("\n");
2023 assert!(
2024 blob.contains("https://acs.atomgit.com/s/AbC123"),
2025 "URL must be in render output as fallback for users who can't \
2026 scan: {:?}",
2027 blob
2028 );
2029 assert!(
2030 blob.contains("扫码登录"),
2031 "expected Chinese onboarding header text"
2032 );
2033 }
2034
2035 #[test]
2036 fn qr_login_draw_with_error_surfaces_reason() {
2037 let w = qr_wizard_with_error("transport: timeout after 10s");
2038 let lines = w.draw_qr_login_lines(80, true);
2039 let blob = lines.join("\n");
2040 assert!(blob.contains("无法生成登录链接"));
2041 assert!(blob.contains("transport: timeout after 10s"));
2042 assert!(blob.contains("按 Enter 重试"));
2043 }
2044
2045 #[test]
2046 fn qr_login_draw_surfaces_enter_to_open_hint() {
2047 // Users on a desktop terminal can press Enter to launch the
2048 // browser at the displayed URL — the modal must SHOW that
2049 // affordance, otherwise nobody knows it exists. Both Unicode
2050 // and ASCII renderings carry the hint since the action is
2051 // available in either layout.
2052 let w = qr_wizard_with_url("https://acs.atomgit.com/s/AbC123");
2053 let unicode_blob = w.draw_qr_login_lines(80, true).join("\n");
2054 assert!(
2055 unicode_blob.contains("Enter"),
2056 "Unicode QR step missing Enter-to-open affordance:\n{}",
2057 unicode_blob
2058 );
2059 let ascii_blob = w.draw_qr_login_lines(80, false).join("\n");
2060 assert!(
2061 ascii_blob.contains("Enter"),
2062 "ASCII QR step missing Enter-to-open affordance:\n{}",
2063 ascii_blob
2064 );
2065 }
2066
2067 #[test]
2068 fn qr_login_draw_ascii_fallback_drops_qr_keeps_url() {
2069 // Half-block glyphs render as tofu on ASCII-only terminals,
2070 // so the QR is dropped entirely and we tell the user to use
2071 // the URL instead. URL itself MUST stay — otherwise the
2072 // screen has nothing actionable.
2073 let w = qr_wizard_with_url("https://acs.atomgit.com/s/AbC123");
2074 let lines = w.draw_qr_login_lines(80, false);
2075 let blob = lines.join("\n");
2076 assert!(blob.contains("https://acs.atomgit.com/s/AbC123"));
2077 assert!(blob.contains("无法显示二维码"));
2078 // Half-block glyphs must NOT leak through the ASCII fallback.
2079 assert!(!blob.contains('▀'));
2080 assert!(!blob.contains('▄'));
2081 assert!(!blob.contains('█'));
2082 }
2083}