Skip to main content

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}