// crates/atomcode-tuix/src/modals/onboarding_wizard.rs
//
// Multi-step first-run / `/welcome` onboarding wizard. Three real
// steps (Intro / Language / Setup) plus one synthetic Confirm step
// that fires only when `/welcome` runs mid-session and would clobber
// existing scrollback.
//
// Replaces `welcome_wizard.rs` (deleted in Task 9). Same `LoopCtx`
// post-close flag side-channel (`pending_run_codingplan`,
// `pending_open_provider_wizard`) as before — only the in-modal flow
// changes.
//
// This file lands in slices across the plan tasks:
// * Task 2 (this slice): `draw_panel` box-drawing helper + tests.
// * Task 3: `OnboardingWizard` struct + Step enum + transitions.
// * Task 4-6: per-step `draw_*` + Modal trait impl.
use unicode_width::UnicodeWidthStr;
/// ASCII fallback set for the box-drawing glyphs and decorative
/// content chars. Switched on when `caps.unicode_symbols == false`
/// (Windows legacy conhost, `LANG=C`, `TERM=dumb`, etc.) so users on
/// fonts that miss the Unicode glyphs see a tidy ASCII box instead
/// of tofu + drifting borders. The drift is the real bug — `●`, `·`,
/// `←` all return width 1 from `unicode-width` but conhost allocates
/// them slightly wider in practice, so the right `│` lands at a
/// different column on every row that contains one.
fn box_chars(unicode_symbols: bool) -> (&'static str, &'static str, &'static str, &'static str, &'static str, &'static str) {
if unicode_symbols {
("┌", "┐", "└", "┘", "─", "│")
} else {
("+", "+", "+", "+", "-", "|")
}
}
/// Strip decorative Unicode glyphs out of `s` when running on a
/// terminal that lacks reliable Unicode rendering / cell-width
/// accounting (Windows legacy conhost et al). Each substitution
/// returns an ASCII string of EQUAL display width to what
/// `unicode-width` thought the original was — keeps the right border
/// pinned to the same column on every row.
fn ascii_fallback(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'●' | '•' => out.push('*'),
'○' => out.push('o'),
'·' => out.push('-'),
'←' => out.push('<'),
'→' => out.push('>'),
'↑' => out.push('^'),
'↓' => out.push('v'),
'█' => out.push('#'),
// Box-drawing glyphs in content (e.g. tables emitted by
// markdown into the panel) get the same swap as the
// outer panel border.
'┌' | '┐' | '└' | '┘' | '┬' | '┴' | '├' | '┤' | '┼' => out.push('+'),
'─' => out.push('-'),
'│' => out.push('|'),
other => out.push(other),
}
}
out
}
/// Build the lines of a Cyan-bordered panel.
///
/// Returns one string per terminal row: top border with title, content
/// lines with side borders + padding, bottom border with step indicator.
/// `width` is the total external width including both border columns;
/// inner content area is `width - 4` (2 padding cells on each side).
///
/// `unicode_symbols=false` swaps the box-drawing glyphs for `+ - |`
/// and substitutes the decorative chars (`●`, `○`, `·`, `←`, `•`,
/// `█`) inside each content line. Wired from `state.unicode_symbols`
/// so Windows legacy conhost / `LANG=C` / `TERM=dumb` users see a
/// clean ASCII box with the right border still column-aligned.
///
/// The returned strings include SGR colour codes so the renderer paints
/// the borders cyan and the title brand-magenta. Pass these strings to
/// `UiLine::CommandOutput`.
pub(super) fn draw_panel(
title: &str,
content: &[String],
step_indicator: &str,
width: usize,
unicode_symbols: bool,
) -> Vec<String> {
use crossterm::style::{Color, ResetColor, SetForegroundColor};
let border = Color::Cyan; // Palette::BORDER
let brand = Color::Magenta; // Palette::BRAND
let (tl, tr, bl, br_c, h, v) = box_chars(unicode_symbols);
// Sanitise content lines and the title/indicator strings when
// ASCII fallback is active. Done once here rather than at every
// call site so call sites stay readable.
let title_owned: String;
let title_seg_src: &str = if unicode_symbols {
title
} else {
title_owned = ascii_fallback(title);
&title_owned
};
let step_owned: String;
let step_src: &str = if unicode_symbols {
step_indicator
} else {
step_owned = ascii_fallback(step_indicator);
&step_owned
};
let mut out = Vec::with_capacity(content.len() + 2);
let inner_width = width.saturating_sub(4);
// Top border: ┌─ <title> ─...─┐
let title_seg = format!(" {title_seg_src} ");
let title_width = UnicodeWidthStr::width(title_seg.as_str());
let dashes_after = inner_width.saturating_sub(title_width);
let top = format!(
"{b}{tl}{h}{r}{br}{tt}{r}{b}{dash}{h}{tr}{r}",
b = SetForegroundColor(border),
br = SetForegroundColor(brand),
tl = tl,
tr = tr,
h = h,
tt = title_seg,
dash = h.repeat(dashes_after),
r = ResetColor,
);
out.push(top);
// Content rows: │ <2 sp pad> <line padded to inner_width-2> <2 sp pad> │
for raw in content {
let owned;
let line: &str = if unicode_symbols {
raw.as_str()
} else {
owned = ascii_fallback(raw);
&owned
};
let line_width = UnicodeWidthStr::width(line);
let pad = (inner_width.saturating_sub(2)).saturating_sub(line_width);
let row = format!(
"{b}{v}{r} {line}{pad} {b}{v}{r}",
b = SetForegroundColor(border),
r = ResetColor,
v = v,
line = line,
pad = " ".repeat(pad),
);
out.push(row);
}
// Bottom border: └─ <step_indicator> ─...─┘
let step_seg = format!(" {step_src} ");
let step_w = UnicodeWidthStr::width(step_seg.as_str());
let dashes_after_step = inner_width.saturating_sub(step_w);
let bot = format!(
"{b}{bl}{h}{step_seg}{dash}{h}{br_c}{r}",
b = SetForegroundColor(border),
bl = bl,
br_c = br_c,
h = h,
step_seg = step_seg,
dash = h.repeat(dashes_after_step),
r = ResetColor,
);
out.push(bot);
out
}
/// Right-pad `s` with spaces until its visible width reaches
/// `target`. Returns the input unchanged if it's already that wide
/// or wider. Used to align option-label columns in Setup step so
/// hints sit at the same x-position across all 3 rows.
fn pad_to_width(s: &str, target: usize) -> String {
let w = UnicodeWidthStr::width(s);
if w >= target {
return s.to_string();
}
format!("{s}{}", " ".repeat(target - w))
}
/// Footer rows the renderer reserves at the bottom (spinner +
/// top_rule + input + bot_rule + status). Used to compute how much
/// vertical body space is available for centering — the wizard
/// panel must not push into the footer.
const FOOTER_ROWS: usize = 5;
/// Wrap `lines` with top + bottom padding blanks and a left
/// indent so the wizard panel sits at the visual centre of the
/// visible body area. `panel_width` is the horizontal extent of
/// the widest line (typically the bordered panel — 80 cols
/// capped); callers pass this in rather than scanning every line
/// for SGR width because draw_panel-shaped output already commits
/// to a known width.
///
/// RetainedRenderer anchors body content to the body region's BOTTOM:
/// when total pushed rows < body_height, the auto-empty rows appear
/// ABOVE the content, not below. So top-padding alone just stacks
/// redundant blanks against the existing auto-empty strip while the
/// panel stays glued to the footer. Filling the region to exactly
/// body_height with top_blanks + content + bottom_blanks pushes the
/// panel into the actual middle row.
///
/// Each rendered line keeps its own SGR; we prepend bare spaces,
/// which contribute no styling, so colour spans on the original
/// line stay intact.
fn center_lines(
lines: Vec<String>,
panel_width: usize,
term_cols: u16,
term_rows: u16,
) -> Vec<String> {
let term_cols = term_cols as usize;
let term_rows = term_rows as usize;
let body_rows = term_rows.saturating_sub(FOOTER_ROWS);
let free = body_rows.saturating_sub(lines.len());
let top_blanks = free / 2;
let bottom_blanks = free - top_blanks;
let left_pad = term_cols.saturating_sub(panel_width) / 2;
let pad_str = " ".repeat(left_pad);
let mut out = Vec::with_capacity(lines.len() + free);
for _ in 0..top_blanks {
out.push(String::new());
}
for line in lines {
if line.is_empty() || left_pad == 0 {
out.push(line);
} else {
out.push(format!("{pad_str}{line}"));
}
}
for _ in 0..bottom_blanks {
out.push(String::new());
}
out
}
/// Mirror of `/clear`'s "fresh idle view" emission: clear has
/// already been issued by the caller, this pushes the AtomCode
/// banner + cwd + model + slash-hint tips that the renderer's
/// `UiLine::Welcome` handler paints. Used when OnboardingWizard
/// exits to drop the user onto the standard session view (same
/// frame the `/clear` slash command produces) instead of a blank
/// canvas with only the idle prompt. Does NOT emit the
/// `CmdNewSession` "新会话已开始" toast — onboarding-exit is not
/// the same intent as `/session` and the toast would be noise.
pub(crate) fn paint_welcome(
ctx: &crate::event_loop::LoopCtx,
renderer: &mut dyn crate::render::Renderer,
) {
let dir_display = crate::platform::collapse_home(&ctx.working_dir.to_string_lossy());
renderer.render(crate::render::UiLine::Welcome {
model: ctx.model_name.clone(),
working_dir: dir_display,
});
renderer.flush();
}
// ───────────────────────────────────────────────────────────────────
// State machine
// ───────────────────────────────────────────────────────────────────
//
// `OnboardingWizard` owns the selection indices and a `Step` cursor.
// Keyboard input flows through `handle_key_pure`, which mutates state
// and returns a `PureOutcome` describing what the Modal-trait wrapper
// (Task 6) should do with the world (apply locale, set pending_*
// flags, clear+redraw, etc.). Splitting the side effects out keeps
// state-machine tests trivially `LoopCtx`-free.
use crossterm::event::{KeyCode, KeyModifiers};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Step {
/// Synthetic pre-step shown only when `/welcome` is invoked
/// mid-session AND there's prior conversation. y/Y advances to
/// Intro after `clear_screen`; n/N/Esc closes without clearing.
Confirm,
Intro,
Language,
Setup,
/// One-shot CodingPlan fast path entered on first-launch only
/// (NOT from `/welcome`). Renders a QR for the AtomGit OAuth
/// short link + the raw URL fallback. Enter → close with
/// `pending_run_codingplan = true` so the existing `/codingplan`
/// driver picks up the just-completed login + claim flow.
/// Esc bails to the welcome banner with no auth changes.
///
/// PR 1a (this commit): user manually presses Enter after
/// scanning. PR 1b will spawn a polling task that closes the
/// modal automatically the moment AtomGit reports authorisation.
QrLogin,
}
pub struct OnboardingWizard {
pub(super) step: Step,
/// 0=Auto-detect, 1=English, 2=ZhCn
pub(super) language_idx: usize,
/// 0=CodingPlan, 1=Manual, 2=Skip
pub(super) setup_idx: usize,
/// Set when constructed via `/welcome` mid-session with non-empty
/// body. Read by Task 8's slash command path to decide cleanup
/// behaviour after Close (whether to skip the post-modal idle
/// redraw to avoid double-painting).
#[allow(dead_code)] // consumed in Task 8 (/welcome slash command)
pub(super) needs_confirm: bool,
/// Populated by `new_qr_fast_path` after a successful
/// `start_login()` round-trip. `None` for every other constructor.
/// Shown verbatim under the QR as the paste-into-browser fallback.
pub(super) qr_login_url: Option<String>,
/// Populated by `new_qr_fast_path` when `start_login()` itself
/// fails (network down, broker 5xx). Surfaced in place of the QR
/// so the user can read what went wrong instead of staring at a
/// blank panel; Esc bails, Enter retries by re-running
/// `start_login()` in `handle_key_pure`'s `Retry` outcome.
pub(super) qr_login_error: Option<String>,
/// Live `LoginSession` produced by the most recent `start_login()`
/// call. The event loop pulls this out via `take_pending_session`
/// right after constructing the wizard so a background poll
/// thread (see `event_loop::oauth_poll`) can watch for the user
/// completing the in-browser consent and auto-close the modal —
/// no manual Enter required. `None` after a take, after an Esc,
/// or when `start_login()` itself errored at construction.
pub(super) pending_session:
Option<atomcode_core::auth::oauth::LoginSession>,
}
impl OnboardingWizard {
/// Standard constructor — `/welcome` with empty body. The historic
/// 3-step (Intro → Language → Setup) flow stays here intact for
/// `/welcome` re-runs; first-launch onboarding now goes through
/// [`Self::new_qr_fast_path`] instead.
pub fn new() -> Self {
Self {
step: Step::Intro,
language_idx: 0,
setup_idx: 0,
needs_confirm: false,
qr_login_url: None,
qr_login_error: None,
pending_session: None,
}
}
/// Constructor for `/welcome` mid-session when body is non-empty.
/// Wizard opens at the synthetic Confirm step; user must press y
/// before any clear or further drawing happens.
pub fn new_with_confirm() -> Self {
Self {
step: Step::Confirm,
language_idx: 0,
setup_idx: 0,
needs_confirm: true,
qr_login_url: None,
qr_login_error: None,
pending_session: None,
}
}
/// First-launch fast path. Skips the old 3-step Intro / Language /
/// Setup flow and goes straight to a single QR screen for the
/// AtomGit OAuth short link — scan, log in, the background poll
/// thread auto-closes the modal and hands off to `/codingplan`
/// for the claim. Language defaults to auto-detect from `$LC_ALL`
/// / `$LANG` (i18n step gone); user can switch later via
/// `/language`.
///
/// Synchronously calls [`atomcode_core::auth::oauth::start_login`]
/// up front so the QR is paintable the moment the modal opens.
/// On network failure the error is stashed on the wizard and
/// rendered in place of the QR — Esc bails, Enter retries via
/// `handle_key_pure`'s `RetryQrLogin` outcome.
///
/// The successful `LoginSession` is held on `pending_session` so
/// the event loop can pull it out (see `take_pending_session`)
/// and hand it to a background poll thread. The wizard itself
/// doesn't know about polling — that plumbing stays in the event
/// loop.
pub fn new_qr_fast_path() -> Self {
let (qr_login_url, qr_login_error, pending_session) =
match atomcode_core::auth::oauth::start_login() {
Ok(session) => (Some(session.url().to_string()), None, Some(session)),
Err(e) => (None, Some(format!("{e:#}")), None),
};
Self {
step: Step::QrLogin,
language_idx: 0,
setup_idx: 0,
needs_confirm: false,
qr_login_url,
qr_login_error,
pending_session,
}
}
/// Pull the freshly-constructed `LoginSession` out so the event
/// loop can spawn a background poll thread against it. Returns
/// `None` if there is no session to take (Esc was hit, or
/// `start_login` errored at construction, or another caller
/// already took it). Called exactly once per QR session by
/// `event_loop::run_loop`'s first-launch setup; subsequent calls
/// return None and are harmless.
pub fn take_pending_session(
&mut self,
) -> Option<atomcode_core::auth::oauth::LoginSession> {
self.pending_session.take()
}
// (set_qr_login_error removed — the event-loop's Failed handler
// closes the modal instead of injecting state, so this is unused.
// Re-add if PR 1c lands a Modal trait extension + downcast path
// that keeps the wizard open on poll failure.)
/// Pre-select the language idx based on existing config. Used by
/// `/welcome` so a user who already picked ZhCn lands on row 3 of
/// step 2 instead of Auto-detect.
pub fn with_initial_language(
mut self,
config_lang: Option<atomcode_core::locale::Locale>,
) -> Self {
self.language_idx = match config_lang {
None => 0,
Some(atomcode_core::locale::Locale::En) => 1,
Some(atomcode_core::locale::Locale::ZhCn) => 2,
};
self
}
/// Test-only: dispatch a single key with no modifiers, ignoring
/// any side-effect outcome the pure handler returns. Used for
/// state-machine unit tests; real Modal::handle_key (Task 6) reads
/// the outcome and drives ctx mutations + redraws accordingly.
#[cfg(test)]
pub(super) fn handle_key_for_test(&mut self, code: KeyCode) {
let _ = self.handle_key_pure(code, KeyModifiers::NONE);
}
/// Pure key handling: only mutates `self`, no side effects against
/// the world. The Modal::handle_key wrapper (Task 6) calls this,
/// then performs the i18n / config / flag side effects based on
/// the returned `PureOutcome`.
pub(super) fn handle_key_pure(
&mut self,
code: KeyCode,
_mods: KeyModifiers,
) -> PureOutcome {
use Step::*;
match (self.step, code) {
// Confirm
(Confirm, KeyCode::Char('y')) | (Confirm, KeyCode::Char('Y')) => {
self.step = Intro;
PureOutcome::ClearAndRedraw
}
(Confirm, KeyCode::Char('n'))
| (Confirm, KeyCode::Char('N'))
| (Confirm, KeyCode::Esc) => PureOutcome::Close,
// Intro
(Intro, KeyCode::Enter) => {
self.step = Language;
PureOutcome::ClearAndRedraw
}
(Intro, KeyCode::Esc) => PureOutcome::Close,
// Left arrow at intro is no-op (no previous step).
// Language
(Language, KeyCode::Up) => {
self.language_idx = self.language_idx.saturating_sub(1);
PureOutcome::Redraw
}
(Language, KeyCode::Down) => {
if self.language_idx < 2 {
self.language_idx += 1;
}
PureOutcome::Redraw
}
// Number keys are shortcuts: pick AND commit in one
// keystroke. The arrow-key path still requires Enter,
// but typing a digit is unambiguous — the user already
// expressed intent, no second confirmation needed.
(Language, KeyCode::Char('1')) => {
self.language_idx = 0;
PureOutcome::ApplyLanguageThenAdvance
}
(Language, KeyCode::Char('2')) => {
self.language_idx = 1;
PureOutcome::ApplyLanguageThenAdvance
}
(Language, KeyCode::Char('3')) => {
self.language_idx = 2;
PureOutcome::ApplyLanguageThenAdvance
}
(Language, KeyCode::Enter) => PureOutcome::ApplyLanguageThenAdvance,
(Language, KeyCode::Left) => {
self.step = Intro;
PureOutcome::ClearAndRedraw
}
(Language, KeyCode::Esc) => PureOutcome::Close,
// Setup
(Setup, KeyCode::Up) => {
self.setup_idx = self.setup_idx.saturating_sub(1);
PureOutcome::Redraw
}
(Setup, KeyCode::Down) => {
if self.setup_idx < 2 {
self.setup_idx += 1;
}
PureOutcome::Redraw
}
(Setup, KeyCode::Char('1')) => {
self.setup_idx = 0;
PureOutcome::ApplySetupThenClose
}
(Setup, KeyCode::Char('2')) => {
self.setup_idx = 1;
PureOutcome::ApplySetupThenClose
}
(Setup, KeyCode::Char('3')) => {
self.setup_idx = 2;
PureOutcome::ApplySetupThenClose
}
(Setup, KeyCode::Enter) => PureOutcome::ApplySetupThenClose,
(Setup, KeyCode::Left) => {
self.step = Language;
PureOutcome::ClearAndRedraw
}
(Setup, KeyCode::Esc) => PureOutcome::Close,
// QrLogin (fast path, first-launch only).
// - start_login failed → Enter retries, Esc bails.
// - start_login ok → Enter mirrors the
// `session.open_browser_best_effort()` call that
// `/codingplan`'s `run_oauth_with_renderer` makes
// automatically, so a user who'd rather click than scan
// gets a one-key path into the consent page. We
// deliberately do NOT re-run start_login here — that's
// what the old ApplyQrLoginThenClose did, and it raced
// the background poll's auth.toml write, painting a
// duplicate QR + URL block into scrollback. The new
// outcome only spawns the platform browser command;
// failures (xdg-open missing on Linux, no $DISPLAY,
// etc.) are silently swallowed and the on-screen QR
// + URL stay as fallbacks. The in-flight poll still
// auto-closes the modal on its own.
(QrLogin, KeyCode::Enter) => {
if self.qr_login_error.is_some() {
PureOutcome::RetryQrLogin
} else if self.qr_login_url.is_some() {
PureOutcome::OpenQrUrlInBrowser
} else {
PureOutcome::Noop
}
}
(QrLogin, KeyCode::Esc) => PureOutcome::Close,
_ => PureOutcome::Noop,
}
}
}
impl OnboardingWizard {
/// Build all output lines for step 1 (Intro). `term_cols` /
/// `term_rows` are taken from `crossterm::terminal::size()` at the
/// caller; passed in so tests don't need a real terminal.
/// Returns SGR-laced strings ready for `UiLine::CommandOutput`.
///
/// `term_rows < 22` triggers the compact fallback — drops the
/// 5-line ASCII logo + Ctrl+C hint so the box fits 18-row
/// terminals. Spec threshold: full layout needs 18 rows (16 box +
/// 2 header); compact needs 13 (11 box + 2 header).
pub(super) fn draw_intro_lines(
&self,
term_cols: u16,
term_rows: u16,
unicode_symbols: bool,
) -> Vec<String> {
use crate::i18n::{t, Msg};
let compact = term_rows < 22;
// Step header (above box)
let mut out = Vec::new();
out.push(t(Msg::OnboardingStepHeaderWelcome).into_owned());
out.push(String::new()); // blank line between header and box
// Build content lines
let mut content: Vec<String> = Vec::new();
content.push(String::new()); // top padding
if !compact {
// 5-line pure block-glyph logo. Uses only `█` and spaces
// so it renders uniformly in every monospaced font —
// mixing solid blocks with thin box-drawing chars (the
// ANSI Shadow style) broke in fonts that draw `█` at
// 100% cell coverage while keeping `╔═` at line weight,
// leaving the shadow outline floating disjointly from
// the letter bodies. Each row is 49 cells; 12-col
// leading pad centres the 49-wide logo inside
// draw_panel's 74-col content area (12 + 49 + 13 = 74).
content.push(" ███ █████ ███ █ █ ████ ███ ████ █████".to_string());
content.push(" █ █ █ █ █ ██ ██ █ █ █ █ █ █ ".to_string());
content.push(" █████ █ █ █ █ █ █ █ █ █ █ █ █ ████ ".to_string());
content.push(" █ █ █ █ █ █ █ █ █ █ █ █ █ █ ".to_string());
content.push(" █ █ █ ███ █ █ ████ ███ ████ █████".to_string());
content.push(String::new());
content.push(
t(Msg::OnboardingIntroVersionLine {
v: env!("CARGO_PKG_VERSION"),
})
.into_owned(),
);
content.push(String::new());
content.push(t(Msg::OnboardingIntroBullet1).into_owned());
content.push(t(Msg::OnboardingIntroBullet2).into_owned());
content.push(t(Msg::OnboardingIntroBullet3).into_owned());
content.push(String::new());
content.push(t(Msg::OnboardingIntroPressEnter).into_owned());
content.push(t(Msg::OnboardingIntroCtrlC).into_owned());
} else {
// Compact: no logo + no Ctrl+C hint. Just product line +
// tagline + bullets + press-enter.
content.push(format!("AtomCode v{}", env!("CARGO_PKG_VERSION")));
content.push(t(Msg::OnboardingIntroCompactTagline).into_owned());
content.push(String::new());
content.push(t(Msg::OnboardingIntroBullet1).into_owned());
content.push(t(Msg::OnboardingIntroBullet2).into_owned());
content.push(t(Msg::OnboardingIntroBullet3).into_owned());
content.push(String::new());
content.push(t(Msg::OnboardingIntroPressEnter).into_owned());
}
content.push(String::new()); // bottom padding
out.extend(draw_panel(
&t(Msg::OnboardingPanelTitle),
&content,
"Step 1/3",
(term_cols as usize).min(80),
unicode_symbols,
));
ascii_fallback_step(out, unicode_symbols)
}
/// Build all output lines for step 2 (Language). Bilingual title
/// is locale-independent (it IS the moment the user picks
/// locale); the prompt + option labels + nav hint follow the
/// current global locale.
pub(super) fn draw_language_lines(&self, term_cols: u16, unicode_symbols: bool) -> Vec<String> {
use crate::i18n::{t, Msg};
let mut out = Vec::new();
out.push(t(Msg::OnboardingStepHeaderLanguage).into_owned());
out.push(String::new());
let options = [
t(Msg::OnboardingLanguageOptionAuto).into_owned(),
t(Msg::OnboardingLanguageOptionEn).into_owned(),
t(Msg::OnboardingLanguageOptionZhCn).into_owned(),
];
let mut content: Vec<String> = Vec::new();
content.push(String::new());
content.push(t(Msg::OnboardingLanguageTitleBilingual).into_owned());
content.push(String::new());
content.push(t(Msg::OnboardingLanguagePrompt).into_owned());
content.push(String::new());
for (i, label) in options.iter().enumerate() {
let bullet = if i == self.language_idx { '●' } else { '○' };
content.push(format!("{bullet} [{}] {}", i + 1, label));
}
content.push(String::new());
content.push(t(Msg::OnboardingNavHint).into_owned());
content.push(String::new());
out.extend(draw_panel(
&t(Msg::OnboardingPanelTitle),
&content,
"Step 2/3",
(term_cols as usize).min(80),
unicode_symbols,
));
ascii_fallback_step(out, unicode_symbols)
}
/// Apply the user's language choice — called when Enter pressed
/// in step 2. Mutates `config.language`, flips the global locale,
/// and persists the config to disk. Returns the locale that was
/// applied so the caller can also surface a confirmation message.
///
/// Auto-detect (`language_idx == 0`) clears `config.language` so
/// the resolver re-derives from env on next launch; the running
/// session also re-resolves immediately so the next redraw uses
/// the env-detected locale.
pub(super) fn apply_language(
&self,
config: &mut atomcode_core::config::Config,
) -> anyhow::Result<atomcode_core::locale::Locale> {
use atomcode_core::locale::Locale;
let new_locale = match self.language_idx {
0 => {
// Auto-detect: clear config field, re-resolve from env.
config.language = None;
crate::i18n::resolve_initial_locale(None, None)
}
1 => {
config.language = Some(Locale::En);
Locale::En
}
2 => {
config.language = Some(Locale::ZhCn);
Locale::ZhCn
}
_ => unreachable!("language_idx is bounded 0..=2"),
};
crate::i18n::set_locale(new_locale);
config.save(&atomcode_core::config::Config::default_path())?;
Ok(new_locale)
}
/// Build all output lines for step 3 (Setup). Reuses the existing
/// `WelcomeOption*` Msg variants from the old wizard so the
/// already-translated CodingPlan / Manual / Skip labels stay
/// consistent. Labels are right-padded to 22 visible cols so the
/// hint column lines up across rows even when one label is
/// English ("Configure manually") and another is Chinese
/// ("配置 CodingPlan") that takes fewer chars but more grid cells.
pub(super) fn draw_setup_lines(&self, term_cols: u16, unicode_symbols: bool) -> Vec<String> {
use crate::i18n::{t, Msg};
let mut out = Vec::new();
out.push(t(Msg::OnboardingStepHeaderSetup).into_owned());
out.push(String::new());
let options = [
(
t(Msg::WelcomeOptionCodingPlan).into_owned(),
t(Msg::WelcomeOptionCodingPlanHint).into_owned(),
),
(
t(Msg::WelcomeOptionConfigureManually).into_owned(),
t(Msg::WelcomeOptionConfigureManuallyHint).into_owned(),
),
(
t(Msg::WelcomeOptionSkip).into_owned(),
t(Msg::WelcomeOptionSkipHint).into_owned(),
),
];
let mut content: Vec<String> = Vec::new();
content.push(String::new());
content.push(t(Msg::OnboardingSetupTitle).into_owned());
content.push(String::new());
for (i, (label, hint)) in options.iter().enumerate() {
let bullet = if i == self.setup_idx { '●' } else { '○' };
let label_padded = pad_to_width(label, 22);
content.push(format!("{bullet} [{}] {} {}", i + 1, label_padded, hint));
}
content.push(String::new());
content.push(t(Msg::OnboardingNavHint).into_owned());
content.push(String::new());
out.extend(draw_panel(
&t(Msg::OnboardingPanelTitle),
&content,
"Step 3/3",
(term_cols as usize).min(80),
unicode_symbols,
));
ascii_fallback_step(out, unicode_symbols)
}
/// QR fast-path (single-page first-launch onboarding).
///
/// Layout (when `start_login` succeeded):
/// ```text
/// Step 1/1 · 扫码登录
/// ┌─ AtomCode ──────────────────────────────────┐
/// │ 扫码登录,自动领取 CodingPlan 免费额度 │
/// │ │
/// │ <QR block> │
/// │ │
/// │ 手机扫码,或在浏览器打开: │
/// │ https://acs.atomgit.com/s/AbC123 │
/// │ │
/// │ 扫码完成后按 Enter 继续 │
/// │ │
/// │ Esc 跳过 · /login 重试 · /provider … │
/// └─ Step 1/1 ─────────────────────────────────┘
/// ```
///
/// Failure layout (`start_login` errored at construction time):
/// the QR block is replaced with the error message, and the
/// instruction line below reads "按 Enter 重试" — handled by
/// `RetryQrLogin` in `handle_key_pure`.
///
/// ASCII-only terminals (`unicode_symbols == false`): QR is
/// omitted entirely; URL is shown as the only login affordance
/// so the user can paste it into a browser on a different machine.
/// QR glyphs render as `□` tofu on Windows legacy conhost / `LANG=C`
/// and a tofu QR is silently unscannable — better to show nothing.
pub(super) fn draw_qr_login_lines(
&self,
term_cols: u16,
unicode_symbols: bool,
) -> Vec<String> {
let panel_width = (term_cols as usize).min(80);
let inner_width = panel_width.saturating_sub(4);
// Cells available for content inside the panel's `│ <2sp> ... <2sp> │`
// padding. Centring is leading-space prefix; draw_panel adds the
// trailing pad to inner_width-2.
let cell_w = inner_width.saturating_sub(2);
let center = |s: &str| -> String {
let w = UnicodeWidthStr::width(s);
let pad = cell_w.saturating_sub(w) / 2;
format!("{}{}", " ".repeat(pad), s)
};
let mut content: Vec<String> = Vec::new();
content.push(String::new());
content.push(center("微信扫码登录,自动领取 CodingPlan 免费额度"));
content.push(String::new());
if let Some(reason) = &self.qr_login_error {
// start_login failed at construction. Surface the cause
// so the user knows whether to check network, broker, or
// their own clock; offer Enter-to-retry below.
content.push(center("× 无法生成登录链接"));
content.push(String::new());
// Error reason may be long; just left-align with indent
// rather than centre — easier to scan.
content.push(format!(" {}", reason));
content.push(String::new());
content.push(center("按 Enter 重试 · Esc 跳过"));
} else if let Some(url) = &self.qr_login_url {
// Render QR block (Unicode mode) or skip it (ASCII).
if let Some(qr_rows) = super::qr::render_for_terminal(url, unicode_symbols) {
for row in qr_rows {
content.push(center(&row));
}
content.push(String::new());
}
content.push(center(if unicode_symbols {
"或在浏览器打开:"
} else {
"无法显示二维码 — 请在浏览器打开:"
}));
content.push(center(url));
content.push(center("(按 Enter 自动打开)"));
content.push(String::new());
// Polling thread auto-closes the modal the moment AtomGit
// reports authorisation, so no force-continue Enter is
// needed. Enter is wired to a best-effort browser launch
// on the URL above (mirrors what /codingplan does); see
// handle_key_pure's QrLogin Enter arm for the rationale
// and the historical duplicate-QR bug that gates it.
content.push(center("扫码完成后自动跳转"));
} else {
// Shouldn't happen — `new_qr_fast_path` always populates
// exactly one of url / error. Defensive fallback so a
// broken constructor doesn't paint a blank panel.
content.push(center("(状态未初始化)"));
}
content.push(String::new());
content.push(center("Esc 跳过 · /login 重试 · /provider 手动配置"));
content.push(String::new());
let mut out = Vec::new();
out.push("扫码登录 · 领取CodingPlan".to_string());
out.push(String::new());
// Panel title carries the running atomcode version so users
// reporting a screenshot tell us the build their bug landed
// in without having to /status first. CARGO_PKG_VERSION is
// workspace-bound (e.g. "4.23.2") — matches the convention
// used by the Step::Intro version line above.
let panel_title = format!("AtomCode · v{}", env!("CARGO_PKG_VERSION"));
out.extend(draw_panel(
&panel_title,
&content,
"Step 1/1",
panel_width,
unicode_symbols,
));
ascii_fallback_step(out, unicode_symbols)
}
}
/// Trailing pass over a step's full output (header + box + footer
/// blanks). `draw_panel` already substitutes Unicode inside its
/// boxed rows, but the step header rows pushed BEFORE the box don't
/// go through it — so e.g. "Step 3/3 · Setup" would still carry the
/// middle dot on a Windows-legacy-console session. Running the
/// fallback over the whole vec catches those; it's a no-op on rows
/// the panel already sanitised (none of `+ - | * o < > ^ v #` are in
/// the substitution set, so they pass through unchanged).
fn ascii_fallback_step(lines: Vec<String>, unicode_symbols: bool) -> Vec<String> {
if unicode_symbols {
return lines;
}
lines.into_iter().map(|l| ascii_fallback(&l)).collect()
}
impl Default for OnboardingWizard {
fn default() -> Self {
Self::new()
}
}
impl crate::modals::Modal for OnboardingWizard {
fn handle_key(
&mut self,
code: KeyCode,
mods: KeyModifiers,
buf: &mut crate::event_loop::Buffer,
state: &mut crate::state::UiState,
ctx: &mut crate::event_loop::LoopCtx,
renderer: &mut dyn crate::render::Renderer,
) -> anyhow::Result<crate::modals::ModalAction> {
use crate::modals::ModalAction;
let outcome = self.handle_key_pure(code, mods);
match outcome {
PureOutcome::Noop => Ok(ModalAction::Continue),
PureOutcome::Redraw | PureOutcome::ClearAndRedraw => {
// Both within-step navigation (1/2/3, ↑/↓) and step
// transitions need to wipe the previous panel before
// repainting. The wizard's draw() pushes CommandOutput
// rows that the retained renderer appends to
// scrollback — without clear_screen, every keystroke
// would stack another full panel below the last one.
// The body context above is already gone by the time
// any non-Confirm step runs (Confirm→Intro is itself
// a clear-and-redraw), so reclearing here is free.
renderer.clear_screen();
self.draw(buf, state, ctx, renderer);
Ok(ModalAction::Continue)
}
PureOutcome::ApplyLanguageThenAdvance => {
if let Err(e) = self.apply_language(&mut ctx.config) {
let msg = crate::i18n::t(crate::i18n::Msg::ConfigSaveFailed {
error: &e.to_string(),
});
renderer.render(crate::render::UiLine::CommandOutput(
format!("{}\n", msg),
));
}
self.step = Step::Setup;
renderer.clear_screen();
self.draw(buf, state, ctx, renderer);
Ok(ModalAction::Continue)
}
PureOutcome::OpenQrUrlInBrowser => {
// Same call /codingplan's run_oauth_with_renderer
// makes after rendering the QR. Best-effort: failures
// (xdg-open missing on a minimal Linux image, no
// $DISPLAY in an SSH session, etc.) are swallowed so
// the modal stays put and the user falls back to
// scanning the QR or copying the URL. No redraw —
// panel content is unchanged; the browser launch is
// a pure side effect.
if let Some(url) = &self.qr_login_url {
let _ = atomcode_core::auth::oauth::open_browser(url);
}
Ok(ModalAction::Continue)
}
PureOutcome::RetryQrLogin => {
// Re-run start_login() in-place so the user can recover
// from a transient network blip without restarting
// atomcode. Mirrors the constructor — synchronous
// round-trip, store either url or error, AND on success
// spawn a fresh background poll thread so the new
// session auto-completes the way the original did.
match atomcode_core::auth::oauth::start_login() {
Ok(session) => {
self.qr_login_url = Some(session.url().to_string());
self.qr_login_error = None;
// session is consumed by `spawn_oauth_poll`;
// `pending_session` stays None because the
// task owns it now.
crate::event_loop::oauth_poll::spawn_oauth_poll(
session,
Some(std::sync::Arc::clone(&ctx.telemetry)),
ctx.oauth_event_tx.clone(),
ctx.wake_tx.clone(),
);
}
Err(e) => {
self.qr_login_url = None;
self.qr_login_error = Some(format!("{e:#}"));
}
}
renderer.clear_screen();
self.draw(buf, state, ctx, renderer);
Ok(ModalAction::Continue)
}
PureOutcome::ApplySetupThenClose => {
match self.setup_idx {
0 => ctx.pending_run_login_setup = true,
1 => ctx.pending_open_provider_wizard = true,
_ => { /* Skip — no flag */ }
}
// Setup always runs on a wizard-owned screen
// (Confirm→Intro and every subsequent transition is
// a clear-and-redraw). Wipe the panel before
// returning Close so the next view starts on a clean
// canvas. For Skip (no follow-up flag) we also
// render the welcome banner here so the user lands
// on the regular idle session view (AtomCode banner
// + cwd + model + tips), not a blank screen with
// just an input prompt. CodingPlan and Provider
// takeovers paint their own UI, so we only emit
// Welcome for the Skip branch.
renderer.clear_screen();
if self.setup_idx == 2 {
paint_welcome(ctx, renderer);
}
Ok(ModalAction::Close)
}
PureOutcome::Close => {
// Esc/N from Confirm preserves the body context —
// clearing there would wipe the conversation the
// user just declined to discard. Esc from any other
// step bails out of onboarding entirely; render the
// welcome banner so the user sees the standard idle
// session view instead of a blank canvas.
if self.step != Step::Confirm {
renderer.clear_screen();
paint_welcome(ctx, renderer);
}
Ok(ModalAction::Close)
}
}
}
fn draw(
&self,
_buf: &crate::event_loop::Buffer,
state: &crate::state::UiState,
ctx: &crate::event_loop::LoopCtx,
renderer: &mut dyn crate::render::Renderer,
) {
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
// The wizard panel is capped at 80 cols by draw_panel; use that
// as the centering anchor so the bordered box stays at the
// canvas middle in wide terminals. Confirm is deliberately
// left uncentred — it's an inline scrollback message that
// shares space with the preserved body context.
let panel_width = (cols as usize).min(80);
// Mirror of TerminalCaps::unicode_symbols — false on Windows
// legacy conhost / LANG=C / TERM=dumb. Threaded into the
// panel + content rendering so those terminals get an ASCII
// box (`+ - |`) with `●·←` substituted out, which keeps the
// right border column-aligned (the chief visible Win10 bug).
let unicode = state.unicode_symbols;
let lines = match self.step {
Step::Confirm => {
// No box for the y/N prompt — one inline line.
let msg = crate::i18n::t(crate::i18n::Msg::OnboardingConfirmClear).into_owned();
vec![if unicode { msg } else { ascii_fallback(&msg) }]
}
Step::Intro => center_lines(self.draw_intro_lines(cols, rows, unicode), panel_width, cols, rows),
Step::Language => center_lines(self.draw_language_lines(cols, unicode), panel_width, cols, rows),
Step::Setup => center_lines(self.draw_setup_lines(cols, unicode), panel_width, cols, rows),
Step::QrLogin => center_lines(self.draw_qr_login_lines(cols, unicode), panel_width, cols, rows),
};
for line in lines {
// No trailing `\n` — the retained renderer's
// push_body_text splits on `\n` and treats the empty
// chunk after a trailing newline as ANOTHER blank row,
// so `"foo\n"` produced two rows (foo + blank) and the
// wizard's letterforms ended up with a blank line
// between every glyph row. Empty strings already in
// `lines` (top/bottom pad, between-section blanks) emit
// their own blank rows by themselves.
renderer.render(crate::render::UiLine::CommandOutput(line));
}
// Reset the footer's cached input/menu state. The retained
// renderer stores `input_buf`/`menu` separately from
// scrollback; without an explicit InputPrompt re-render here,
// a user who triggered `/welcome` from the slash menu (typed
// `/w`, hit Enter on the highlighted `/welcome`) would still
// see `❯ /w` plus the slash-menu dropdown lingering under the
// wizard — and Backspace wouldn't budge it because the
// in-memory buffer was already cleared by the dispatch path.
// Empty buf + no menu wipes both visuals; the modal owns key
// input until it closes, so the bare `❯ ` underneath is
// purely cosmetic.
renderer.render(crate::render::UiLine::InputPrompt {
buf: String::new(),
cursor_byte: 0,
menu: None,
status: crate::event_loop::build_status(state, ctx),
attachments: Vec::new(),
});
renderer.flush();
}
}
/// Outcome of `handle_key_pure` — what the Modal-trait wrapper should
/// do with the world after the pure transition. Splitting this out
/// keeps state-machine tests free of LoopCtx / renderer mocks.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PureOutcome {
/// Modal stays open; redraw on next tick (selection moved within
/// step, no step transition).
Redraw,
/// Modal stays open; clear screen + redraw (step transitioned).
ClearAndRedraw,
/// Apply language pick (i18n::set_locale + persist), then
/// transition to Setup + ClearAndRedraw.
ApplyLanguageThenAdvance,
/// Set the appropriate `pending_*` flag based on `setup_idx`, then
/// close.
ApplySetupThenClose,
/// QR step's `start_login()` previously errored; user pressed
/// Enter to retry. Wrapper re-runs `start_login()` and resets
/// the wizard's `qr_login_url` / `qr_login_error` fields, then
/// ClearAndRedraw.
RetryQrLogin,
/// QR step Enter on the happy path — launch the platform browser
/// at the already-displayed login URL, mirroring the
/// `session.open_browser_best_effort()` call `/codingplan` makes
/// automatically. Failures are silently swallowed (xdg-open
/// missing, headless Linux, etc.); the QR + URL remain on screen
/// as fallbacks, so the modal layout doesn't change.
OpenQrUrlInBrowser,
/// Close modal, no side effect.
Close,
/// Ignore the key.
Noop,
}
#[cfg(test)]
mod tests {
use super::*;
/// Strip every SGR escape so we can assert on the visible glyphs.
fn strip_sgr(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' && chars.peek() == Some(&'[') {
chars.next(); // consume '['
while let Some(&n) = chars.peek() {
chars.next();
if n == 'm' || n.is_alphabetic() {
break;
}
}
continue;
}
out.push(c);
}
out
}
#[test]
fn top_border_has_title() {
let lines = draw_panel("AtomCode", &[], "Step 1/3", 60, true);
let plain = strip_sgr(&lines[0]);
assert!(plain.starts_with("┌─ AtomCode "));
assert!(plain.ends_with("─┐"));
}
#[test]
fn bottom_border_has_step_indicator() {
let lines = draw_panel("AtomCode", &[], "Step 1/3", 60, true);
let plain = strip_sgr(lines.last().unwrap());
assert!(plain.starts_with("└─ Step 1/3 "));
assert!(plain.ends_with("─┘"));
}
#[test]
fn content_lines_are_padded_to_width() {
let content = vec!["hello".to_string(), "".to_string()];
let lines = draw_panel("X", &content, "Y", 30, true);
// Lines 1 & 2 are content. Each must be exactly `width` wide
// when measured by visible-grid columns.
for line in &lines[1..=2] {
let plain = strip_sgr(line);
assert_eq!(
UnicodeWidthStr::width(plain.as_str()),
30,
"line not padded to 30: {plain:?}"
);
}
}
#[test]
fn cjk_content_pads_correctly() {
// Each CJK char is 2 cols. "中文" = 4 cols.
let content = vec!["中文".to_string()];
let lines = draw_panel("X", &content, "Y", 30, true);
let plain = strip_sgr(&lines[1]);
assert_eq!(UnicodeWidthStr::width(plain.as_str()), 30);
}
#[test]
fn narrow_terminal_does_not_panic() {
// width=10 has inner_width=6 which won't fit "AtomCode" title;
// saturating_sub guards against underflow. We just assert it
// doesn't panic and produces *some* output.
let lines = draw_panel("AtomCode", &["x".into()], "S", 10, true);
assert_eq!(lines.len(), 3); // top + 1 content + bottom
}
// ── state-machine transition tests ──
fn make_wizard() -> OnboardingWizard {
OnboardingWizard::new()
}
#[test]
fn new_starts_at_intro() {
let w = make_wizard();
assert_eq!(w.step, Step::Intro);
assert_eq!(w.setup_idx, 0);
assert_eq!(w.language_idx, 0);
assert!(!w.needs_confirm);
}
#[test]
fn new_with_confirm_starts_at_confirm_step() {
let w = OnboardingWizard::new_with_confirm();
assert_eq!(w.step, Step::Confirm);
assert!(w.needs_confirm);
}
#[test]
fn with_initial_language_seeds_idx() {
use atomcode_core::locale::Locale;
assert_eq!(make_wizard().with_initial_language(None).language_idx, 0);
assert_eq!(
make_wizard()
.with_initial_language(Some(Locale::En))
.language_idx,
1
);
assert_eq!(
make_wizard()
.with_initial_language(Some(Locale::ZhCn))
.language_idx,
2
);
}
#[test]
fn intro_enter_advances_to_language() {
let mut w = make_wizard();
w.handle_key_for_test(KeyCode::Enter);
assert_eq!(w.step, Step::Language);
}
#[test]
fn language_left_arrow_returns_to_intro() {
let mut w = make_wizard();
w.step = Step::Language;
w.handle_key_for_test(KeyCode::Left);
assert_eq!(w.step, Step::Intro);
}
#[test]
fn intro_left_arrow_is_noop() {
let mut w = make_wizard();
w.handle_key_for_test(KeyCode::Left);
assert_eq!(w.step, Step::Intro);
}
#[test]
fn language_up_down_moves_idx() {
let mut w = make_wizard();
w.step = Step::Language;
w.language_idx = 1;
w.handle_key_for_test(KeyCode::Down);
assert_eq!(w.language_idx, 2);
w.handle_key_for_test(KeyCode::Down);
assert_eq!(w.language_idx, 2, "should not exceed last index");
w.handle_key_for_test(KeyCode::Up);
assert_eq!(w.language_idx, 1);
w.handle_key_for_test(KeyCode::Up);
w.handle_key_for_test(KeyCode::Up);
assert_eq!(w.language_idx, 0, "saturating_sub keeps idx at 0");
}
#[test]
fn setup_up_down_bounded() {
let mut w = make_wizard();
w.step = Step::Setup;
w.setup_idx = 0;
w.handle_key_for_test(KeyCode::Up);
assert_eq!(w.setup_idx, 0);
w.handle_key_for_test(KeyCode::Down);
assert_eq!(w.setup_idx, 1);
w.handle_key_for_test(KeyCode::Down);
w.handle_key_for_test(KeyCode::Down);
assert_eq!(w.setup_idx, 2);
w.handle_key_for_test(KeyCode::Down);
assert_eq!(w.setup_idx, 2);
}
#[test]
fn number_keys_jump_select() {
let mut w = make_wizard();
w.step = Step::Language;
w.handle_key_for_test(KeyCode::Char('3'));
assert_eq!(w.language_idx, 2);
w.handle_key_for_test(KeyCode::Char('1'));
assert_eq!(w.language_idx, 0);
}
#[test]
fn number_out_of_range_is_noop() {
let mut w = make_wizard();
w.step = Step::Setup;
w.setup_idx = 1;
w.handle_key_for_test(KeyCode::Char('5'));
assert_eq!(w.setup_idx, 1);
w.handle_key_for_test(KeyCode::Char('0'));
assert_eq!(w.setup_idx, 1);
}
#[test]
fn confirm_y_advances_to_intro() {
let mut w = OnboardingWizard::new_with_confirm();
let outcome = w.handle_key_pure(KeyCode::Char('y'), KeyModifiers::NONE);
assert_eq!(w.step, Step::Intro);
assert_eq!(outcome, PureOutcome::ClearAndRedraw);
}
#[test]
fn confirm_capital_y_also_advances() {
let mut w = OnboardingWizard::new_with_confirm();
let outcome = w.handle_key_pure(KeyCode::Char('Y'), KeyModifiers::NONE);
assert_eq!(w.step, Step::Intro);
assert_eq!(outcome, PureOutcome::ClearAndRedraw);
}
#[test]
fn confirm_n_closes_without_advancing() {
let mut w = OnboardingWizard::new_with_confirm();
let outcome = w.handle_key_pure(KeyCode::Char('n'), KeyModifiers::NONE);
assert_eq!(w.step, Step::Confirm, "n must NOT advance step");
assert_eq!(outcome, PureOutcome::Close);
}
#[test]
fn intro_enter_outcome_is_clear_and_redraw() {
let mut w = make_wizard();
let outcome = w.handle_key_pure(KeyCode::Enter, KeyModifiers::NONE);
assert_eq!(w.step, Step::Language);
assert_eq!(outcome, PureOutcome::ClearAndRedraw);
}
#[test]
fn language_enter_outcome_is_apply_then_advance() {
let mut w = make_wizard();
w.step = Step::Language;
w.language_idx = 2;
let outcome = w.handle_key_pure(KeyCode::Enter, KeyModifiers::NONE);
// step stays Language — Modal wrapper performs the apply +
// advance based on the outcome variant. Pure handler only
// reports the intent.
assert_eq!(outcome, PureOutcome::ApplyLanguageThenAdvance);
}
#[test]
fn setup_enter_outcome_is_apply_then_close() {
let mut w = make_wizard();
w.step = Step::Setup;
let outcome = w.handle_key_pure(KeyCode::Enter, KeyModifiers::NONE);
assert_eq!(outcome, PureOutcome::ApplySetupThenClose);
}
#[test]
fn esc_at_any_step_closes() {
for start in [Step::Intro, Step::Language, Step::Setup] {
let mut w = make_wizard();
w.step = start;
let outcome = w.handle_key_pure(KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(outcome, PureOutcome::Close, "Esc at {start:?} must Close");
}
}
// ── Step 1 (Intro) draw tests ──
/// Full-height layout assertions: ASCII logo + version + all
/// three bullets + press-enter + ctrl-c lines all present.
#[test]
fn intro_full_layout_has_all_pieces() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let lines = OnboardingWizard::new().draw_intro_lines(80, 24, true);
let joined: String = lines
.iter()
.map(|s| strip_sgr(s))
.collect::<Vec<_>>()
.join("\n");
// ASCII logo signature: M's row 3 collapses to alternating
// `█ █ █ █`, unique to the new pure-block design.
assert!(
joined.contains("█ █ █ █"),
"logo missing: {joined}"
);
assert!(joined.contains("Version "));
assert!(joined.contains("Multi-step agent loop"));
assert!(joined.contains("Connects to any OpenAI"));
assert!(joined.contains("Free tokens via CodingPlan"));
assert!(joined.contains("Press Enter to continue"));
assert!(joined.contains("Ctrl+C exits"));
// Header above the box.
assert!(joined.contains("Step 1/3 · Welcome"));
// Box step indicator at bottom.
assert!(joined.contains("Step 1/3"));
}
/// `term_rows < 22` drops the logo + Ctrl+C lines. Bullets,
/// version, and Press-Enter still render so the user can advance.
#[test]
fn intro_compact_drops_logo() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let lines = OnboardingWizard::new().draw_intro_lines(80, 18, true);
let joined: String = lines
.iter()
.map(|s| strip_sgr(s))
.collect::<Vec<_>>()
.join("\n");
assert!(
!joined.contains("█ █ █ █"),
"logo should be hidden in compact mode: {joined}"
);
// Compact replaces the version block with a compact product
// line `AtomCode vX.Y.Z` + tagline.
assert!(joined.contains("AtomCode v"));
assert!(joined.contains("AI coding agent that lives in your terminal"));
assert!(joined.contains("Free tokens"));
assert!(joined.contains("Press Enter to continue"));
}
// ── Step 2 (Language) draw + apply tests ──
/// Bilingual title + 3 numbered options + nav hint all present in
/// the rendered output.
#[test]
fn language_layout_has_three_options_with_numbers() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let lines = OnboardingWizard::new().draw_language_lines(80, true);
let joined: String = lines
.iter()
.map(|s| strip_sgr(s))
.collect::<Vec<_>>()
.join("\n");
// Bilingual title (locale-independent).
assert!(joined.contains("Choose your language / 选择语言"));
// Three numbered options.
assert!(joined.contains("[1] Auto-detect"));
assert!(joined.contains("[2] English"));
assert!(joined.contains("[3] 简体中文"));
// Step header + indicator.
assert!(joined.contains("Step 2/3 · Language"));
// Nav hint.
assert!(joined.contains("1-3 select"));
}
/// Selected marker `●` sits on the row matching language_idx;
/// the other rows get the hollow `○` marker.
#[test]
fn language_selected_marker_follows_idx() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let mut w = OnboardingWizard::new();
w.step = Step::Language;
w.language_idx = 2;
let lines = w.draw_language_lines(80, true);
let joined: String = lines
.iter()
.map(|s| strip_sgr(s))
.collect::<Vec<_>>()
.join("\n");
// `● [3] 简体中文` selected; `○ [2] English` unselected.
let pos_filled = joined.find("● [3]").expect("filled marker missing");
let pos_hollow = joined.find("○ [2]").expect("hollow marker missing");
assert!(
pos_hollow < pos_filled,
"expected hollow before filled marker"
);
}
/// apply_language writes the picked locale into config + flips
/// the global locale + persists to disk under an ATOMCODE_HOME
/// override so tests don't touch real `~/.atomcode`.
#[test]
fn apply_language_writes_config_and_sets_locale() {
use atomcode_core::locale::Locale;
let _g = crate::i18n::test_lock();
let tmp = tempfile::TempDir::new().unwrap();
// ATOMCODE_HOME drives Config::config_dir() ahead of $HOME, so
// the test's config.save lands in `<tmp>/config.toml` and not
// the real home dir. Saved+restored around the test to keep
// parallel tests from racing on the global env.
let prev_atomcode_home = std::env::var("ATOMCODE_HOME").ok();
std::env::set_var("ATOMCODE_HOME", tmp.path());
let mut cfg = blank_config_for_test();
let mut w = OnboardingWizard::new();
w.language_idx = 2;
let applied = w.apply_language(&mut cfg).unwrap();
assert_eq!(applied, Locale::ZhCn);
assert_eq!(cfg.language, Some(Locale::ZhCn));
assert_eq!(crate::i18n::current_locale(), Locale::ZhCn);
// File must actually exist on disk.
assert!(tmp.path().join("config.toml").exists());
// Restore env.
match prev_atomcode_home {
Some(v) => std::env::set_var("ATOMCODE_HOME", v),
None => std::env::remove_var("ATOMCODE_HOME"),
}
}
/// Auto-detect (idx 0) blanks `config.language` so the next-launch
/// resolver re-derives from env. Even when the prior config carried
/// an explicit choice.
#[test]
fn apply_language_auto_clears_config_field() {
use atomcode_core::locale::Locale;
let _g = crate::i18n::test_lock();
let tmp = tempfile::TempDir::new().unwrap();
let prev = std::env::var("ATOMCODE_HOME").ok();
std::env::set_var("ATOMCODE_HOME", tmp.path());
let mut cfg = blank_config_for_test();
cfg.language = Some(Locale::En); // start with non-None
let mut w = OnboardingWizard::new();
w.language_idx = 0;
w.apply_language(&mut cfg).unwrap();
assert_eq!(cfg.language, None);
match prev {
Some(v) => std::env::set_var("ATOMCODE_HOME", v),
None => std::env::remove_var("ATOMCODE_HOME"),
}
}
/// Minimal Config used by the apply_language tests. Config has no
/// Default impl (every field is intentionally required so adding
/// a new field forces every test to update), so we mirror the
/// blank_config_with_lsp helper from `core::config::tests` here.
fn blank_config_for_test() -> atomcode_core::config::Config {
atomcode_core::config::Config {
default_provider: String::new(),
default_workdir: None,
providers: Default::default(),
datalog: Default::default(),
auto_update: true,
notifications: Default::default(),
telemetry: Default::default(),
lsp: Default::default(),
auto_commit: false,
subagent: Default::default(),
vision_preprocessor_provider: None,
language: None,
ui: Default::default(),
plugin: Default::default(),
}
}
// ── Step 3 (Setup) draw tests ──
/// Setup panel renders 3 numbered options with localised
/// CodingPlan / Manual / Skip labels (reusing WelcomeOption* Msg
/// variants), the SetupTitle, and the nav hint.
#[test]
fn setup_layout_has_three_options() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let lines = OnboardingWizard::new().draw_setup_lines(80, true);
let joined: String = lines
.iter()
.map(|s| strip_sgr(s))
.collect::<Vec<_>>()
.join("\n");
assert!(joined.contains("Step 3/3 · Setup"));
assert!(joined.contains("How would you like to set up?"));
assert!(joined.contains("[1] Set up CodingPlan"));
assert!(joined.contains("[2] Configure manually"));
assert!(joined.contains("[3] Skip for now"));
// Hints sit after each option.
assert!(joined.contains("Free tokens"));
assert!(joined.contains("API key"));
// Nav hint.
assert!(joined.contains("1-3 select"));
}
/// ZhCn locale flips every label + hint to the Chinese strings
/// that the i18n shipped originally for WelcomeOption*.
#[test]
fn setup_zh_renders_chinese_labels() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::ZhCn);
let lines = OnboardingWizard::new().draw_setup_lines(80, true);
let joined: String = lines
.iter()
.map(|s| strip_sgr(s))
.collect::<Vec<_>>()
.join("\n");
assert!(joined.contains("第 3/3 步 · 配置"));
assert!(joined.contains("配置 CodingPlan"));
assert!(joined.contains("手动配置"));
assert!(joined.contains("暂时跳过"));
}
/// CodingPlan must come first in the rendered step-3 list, then
/// Manual, then Skip. Migrated from the deleted welcome_wizard.rs's
/// `options_put_codingplan_first` test; pins option order so a
/// reorder needs a deliberate test update.
#[test]
fn setup_options_put_codingplan_first() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let lines = OnboardingWizard::new().draw_setup_lines(80, true);
let joined: String = lines
.iter()
.map(|s| strip_sgr(s))
.collect::<Vec<_>>()
.join("\n");
let pos_codingplan = joined
.find("Set up CodingPlan")
.expect("CodingPlan label missing");
let pos_manual = joined
.find("Configure manually")
.expect("manual label missing");
let pos_skip = joined.find("Skip for now").expect("skip label missing");
assert!(pos_codingplan < pos_manual);
assert!(pos_manual < pos_skip);
}
/// Filled marker tracks setup_idx.
#[test]
fn setup_selected_marker_follows_idx() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let mut w = OnboardingWizard::new();
w.setup_idx = 1;
let lines = w.draw_setup_lines(80, true);
let joined: String = lines
.iter()
.map(|s| strip_sgr(s))
.collect::<Vec<_>>()
.join("\n");
// Selected: idx 1 → ● [2]; others get ○.
assert!(joined.contains("● [2]"));
assert!(joined.contains("○ [1]"));
assert!(joined.contains("○ [3]"));
}
// ── VirtualTerminal snapshot tests ──
//
// These tests feed the wizard's emitted lines through a real
// VirtualTerminal — same vt100 path the prod renderer drives — so
// we catch ANSI/SGR mishandling that the strip_sgr unit tests
// can't surface. Plan called for a higher-level `new_vterm` /
// `ctx_for_modal_tests` helper pair, but those don't exist in
// the current tree; the wizard's `draw_*_lines` already returns
// standalone strings (it's only the Modal trait wrapper that
// needs the renderer / ctx), so we can short-circuit straight
// into the vterm by feeding `lines.join("\n")` as bytes.
/// Feed wizard lines into a fresh VirtualTerminal and return its
/// post-paint screen dump. Each line gets a trailing `\r\n` so
/// the vterm advances to column 0 of the next row — without the
/// `\r`, lines would stack at whatever column the cursor happened
/// to be at after the previous line's last SGR.
fn paint_to_vterm(lines: Vec<String>, w: u16, h: u16) -> String {
let mut vt = crate::test_term::VirtualTerminal::new(w, h);
let mut bytes = Vec::new();
for line in &lines {
bytes.extend_from_slice(line.as_bytes());
bytes.extend_from_slice(b"\r\n");
}
vt.feed(&bytes);
vt.dump()
}
#[test]
fn vterm_step1_shows_box_borders_in_en() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let lines = OnboardingWizard::new().draw_intro_lines(80, 24, true);
let screen = paint_to_vterm(lines, 80, 24);
// Top + bottom corner glyphs visible in the painted grid.
assert!(screen.contains("┌─"), "top border missing: {screen}");
assert!(screen.contains("└─"), "bottom border missing: {screen}");
// Brand title, step header, key copy.
assert!(screen.contains("AtomCode"));
assert!(screen.contains("Step 1/3 · Welcome"));
assert!(screen.contains("Press Enter to continue"));
}
#[test]
fn vterm_step2_zh_renders_bilingual_title_and_chinese_options() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::ZhCn);
let mut w = OnboardingWizard::new();
w.step = Step::Language;
let lines = w.draw_language_lines(80, true);
let screen = paint_to_vterm(lines, 80, 24);
// vt100 places a CJK double-width char in one cell and leaves
// the next cell blank, so `选择语言` reads back as
// `选 择 语 言` in the grid dump.
assert!(
screen.contains("Choose your language / 选 择 语 言"),
"bilingual title missing: {screen}"
);
assert!(
screen.contains("自 动 检 测"),
"Chinese auto-detect label missing: {screen}"
);
// Step header `第 2/3 步 · 语言` — the trailing-blank cell after
// each CJK glyph collides with the source spaces around `2/3`
// and `·`, doubling them.
assert!(
screen.contains("第 2/3 步 · 语 言"),
"zh step header missing: {screen}"
);
}
#[test]
fn vterm_step1_compact_below_22_rows_drops_logo() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let lines = OnboardingWizard::new().draw_intro_lines(80, 18, true);
let screen = paint_to_vterm(lines, 80, 18);
assert!(
!screen.contains("█ █ █ █"),
"ASCII logo present in compact mode: {screen}"
);
// Compact substitutes `AtomCode vX.Y.Z`.
assert!(screen.contains("AtomCode v"));
assert!(screen.contains("Press Enter to continue"));
}
/// Step 3 setup options align in the grid: bullet column, [N]
/// column, label column all stay vertically aligned across the
/// three rows. Pin via `any_row` since the wizard emits one row
/// per option.
#[test]
fn vterm_step3_setup_options_align_vertically() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::En);
let lines = OnboardingWizard::new().draw_setup_lines(80, true);
let mut vt = crate::test_term::VirtualTerminal::new(80, 24);
let mut bytes = Vec::new();
for line in &lines {
bytes.extend_from_slice(line.as_bytes());
bytes.extend_from_slice(b"\r\n");
}
vt.feed(&bytes);
// Each option's row starts with `│ ● [1]` or `│ ○ [N]`.
// The bullet must sit at the same column across all three.
let rows_with_bracket: Vec<String> = (0..24)
.map(|r| vt.row_text(r))
.filter(|r| r.contains("[1]") || r.contains("[2]") || r.contains("[3]"))
.collect();
assert_eq!(rows_with_bracket.len(), 3, "expected 3 option rows, got {rows_with_bracket:?}");
// Bullet position (●/○) — all three rows must place it at
// the same column index. Locate via find().
let bullet_cols: Vec<Option<usize>> = rows_with_bracket
.iter()
.map(|r| r.find('●').or_else(|| r.find('○')))
.collect();
assert!(
bullet_cols.iter().all(|c| c.is_some() && *c == bullet_cols[0]),
"bullet column drift across rows: {bullet_cols:?}"
);
}
/// pad_to_width: short strings get right-padded to target; long
/// strings pass through unchanged.
#[test]
fn pad_to_width_handles_cjk_and_short_strings() {
assert_eq!(pad_to_width("hi", 6), "hi ");
// CJK char = 2 cols, so "中文" is 4 cols + 2 pad = "中文 ".
assert_eq!(pad_to_width("中文", 6), "中文 ");
// Already wider — returned as-is, no truncation.
assert_eq!(pad_to_width("hello world", 5), "hello world");
}
/// Locale-driven copy lookup — boot in ZhCn, every string in the
/// intro panel should be the Chinese translation.
#[test]
fn intro_renders_in_zh_cn() {
let _g = crate::i18n::test_lock();
crate::i18n::set_locale(crate::i18n::Locale::ZhCn);
let lines = OnboardingWizard::new().draw_intro_lines(80, 24, true);
let joined: String = lines
.iter()
.map(|s| strip_sgr(s))
.collect::<Vec<_>>()
.join("\n");
assert!(joined.contains("第 1/3 步 · 欢迎"));
assert!(joined.contains("版本 "));
assert!(joined.contains("按 Enter 继续"));
assert!(joined.contains("Ctrl+C 可随时退出"));
// Brand title stays English on purpose.
assert!(joined.contains("AtomCode"));
}
// ── ASCII fallback (Windows legacy conhost / LANG=C / TERM=dumb) ──
/// `unicode_symbols=false` swaps the box-drawing glyphs and the
/// decorative content chars for ASCII equivalents. Regression
/// guard for the Windows 10 cmd report where the right `│`
/// landed at a different column on every row that contained
/// `●` / `·` / `←`, because `unicode-width` reports them as 1
/// cell while conhost allocates a slightly wider glyph.
#[test]
fn draw_panel_ascii_fallback_uses_plus_dash_pipe() {
let lines = draw_panel(
"AtomCode",
&["row one".into(), "row two".into()],
"Step 3/3",
30,
false,
);
let joined: String = lines.iter().map(|l| strip_sgr(l)).collect::<Vec<_>>().join("\n");
// Box-drawing glyphs gone, ASCII fallbacks in their place.
assert!(!joined.contains('┌'), "U+250C leaked: {:?}", joined);
assert!(!joined.contains('┐'), "U+2510 leaked: {:?}", joined);
assert!(!joined.contains('└'), "U+2514 leaked: {:?}", joined);
assert!(!joined.contains('┘'), "U+2518 leaked: {:?}", joined);
assert!(!joined.contains('─'), "U+2500 leaked: {:?}", joined);
assert!(!joined.contains('│'), "U+2502 leaked: {:?}", joined);
assert!(joined.contains('+'), "no + corner: {:?}", joined);
assert!(joined.contains('-'), "no - horizontal: {:?}", joined);
assert!(joined.contains('|'), "no | vertical: {:?}", joined);
}
/// `●`, `○`, `·`, `←`, `•` inside content rows must be substituted
/// with width-equivalent ASCII so the right border stays
/// column-aligned. We can't easily verify column alignment in a
/// unit test (no real terminal), but we CAN assert the
/// substitution happened.
#[test]
fn draw_panel_ascii_fallback_substitutes_decorative_chars_in_content() {
let content = vec!["● filled".into(), "○ open · mid · ← back • bullet".into()];
let lines = draw_panel("X", &content, "Y", 60, false);
let joined: String = lines.iter().map(|l| strip_sgr(l)).collect::<Vec<_>>().join("\n");
for bad in ['●', '○', '·', '←', '•'] {
assert!(
!joined.contains(bad),
"Unicode {:?} leaked through ASCII fallback: {:?}",
bad,
joined
);
}
assert!(joined.contains('*'), "● not replaced with *: {:?}", joined);
assert!(joined.contains('o'), "○ not replaced with o: {:?}", joined);
assert!(joined.contains('<'), "← not replaced with <: {:?}", joined);
}
/// On Windows-legacy-console paths, `state.unicode_symbols == false`
/// flows through `draw_setup_lines` → `draw_panel`, so the full
/// step 3 render must come out ASCII-only.
#[test]
fn draw_setup_lines_ascii_fallback_produces_pure_ascii_box() {
let _g = atomcode_core::i18n::test_lock();
atomcode_core::i18n::set_locale(atomcode_core::i18n::Locale::En);
let lines = OnboardingWizard::new().draw_setup_lines(80, false);
let joined: String = lines.iter().map(|l| strip_sgr(l)).collect::<Vec<_>>().join("\n");
for bad in ['┌', '┐', '└', '┘', '─', '│', '●', '○', '·', '←', '•'] {
assert!(
!joined.contains(bad),
"Unicode {:?} leaked through Setup ASCII fallback: {:?}",
bad,
joined
);
}
// Visible content is still readable in ASCII.
assert!(joined.contains("Set up CodingPlan"));
assert!(joined.contains("[1]") && joined.contains("[2]") && joined.contains("[3]"));
}
/// Belt + braces: each row of an ASCII-fallback rendered Setup
/// panel must end with `|` at the SAME column. This is the
/// property that was visibly broken on Windows 10 cmd (right
/// border zig-zagging).
#[test]
fn draw_panel_ascii_fallback_right_border_column_aligned() {
let _g = atomcode_core::i18n::test_lock();
atomcode_core::i18n::set_locale(atomcode_core::i18n::Locale::En);
let lines = OnboardingWizard::new().draw_setup_lines(80, false);
// Drop the step header row that sits ABOVE the panel — it's
// not bordered.
let bordered: Vec<String> = lines
.iter()
.map(|l| strip_sgr(l))
.filter(|l| l.contains('|') || l.contains('+'))
.collect();
assert!(bordered.len() >= 3, "expected top + content + bottom: {:?}", bordered);
let widths: std::collections::HashSet<usize> = bordered
.iter()
.map(|l| UnicodeWidthStr::width(l.as_str()))
.collect();
assert_eq!(
widths.len(),
1,
"panel rows have different visible widths — right border would zig-zag: {:?}",
bordered
);
}
// ── QrLogin (PR 1a) state-machine + draw tests ──────────────────
//
// start_login() is a real network call so these tests can't drive
// it end-to-end. Instead we manually construct an OnboardingWizard
// pinned to Step::QrLogin with synthetic url / error state, then
// exercise the keystroke transitions + draw output.
fn qr_wizard_with_url(url: &str) -> OnboardingWizard {
OnboardingWizard {
step: Step::QrLogin,
language_idx: 0,
setup_idx: 0,
needs_confirm: false,
qr_login_url: Some(url.to_string()),
qr_login_error: None,
pending_session: None,
}
}
fn qr_wizard_with_error(msg: &str) -> OnboardingWizard {
OnboardingWizard {
step: Step::QrLogin,
language_idx: 0,
setup_idx: 0,
needs_confirm: false,
qr_login_url: None,
qr_login_error: Some(msg.to_string()),
pending_session: None,
}
}
#[test]
fn qr_login_enter_when_url_present_opens_browser() {
// Enter on the happy QR path now mirrors /codingplan's
// `session.open_browser_best_effort()` — same platform
// browser launch the CLI flow makes automatically, just
// user-triggered. The historical Noop was a fix for a
// duplicate-QR bug caused by re-running start_login on
// Enter (ApplyQrLoginThenClose); the new outcome doesn't
// touch start_login at all, so that bug stays gone.
let mut w = qr_wizard_with_url("https://acs.atomgit.com/s/AbC123");
let outcome = w.handle_key_pure(KeyCode::Enter, KeyModifiers::NONE);
assert_eq!(outcome, PureOutcome::OpenQrUrlInBrowser);
}
#[test]
fn qr_login_enter_with_neither_url_nor_error_is_noop() {
// Defensive: if construction landed in a state where neither
// the URL nor the error is populated (shouldn't happen — the
// constructor always produces exactly one), Enter must NOT
// dispatch OpenQrUrlInBrowser (would try to open None) and
// must NOT dispatch RetryQrLogin (no error to surface). Noop
// keeps the modal inert until Esc.
let mut w = qr_wizard_with_url("https://acs.atomgit.com/s/AbC123");
w.qr_login_url = None;
w.qr_login_error = None;
let outcome = w.handle_key_pure(KeyCode::Enter, KeyModifiers::NONE);
assert_eq!(outcome, PureOutcome::Noop);
}
#[test]
fn qr_login_enter_when_in_error_state_retries() {
// start_login failed at construction. Enter re-runs it —
// wrapper handles ctx mutations, the pure outcome just signals
// intent.
let mut w = qr_wizard_with_error("transport: connection refused");
let outcome = w.handle_key_pure(KeyCode::Enter, KeyModifiers::NONE);
assert_eq!(outcome, PureOutcome::RetryQrLogin);
}
#[test]
fn qr_login_esc_closes_without_codingplan_flag() {
// Esc bails to the welcome banner — no pending_run_codingplan,
// no setup_idx mutation. Pin against accidental future drift
// into the Setup-step's ApplySetupThenClose flag-setting path.
let mut w = qr_wizard_with_url("https://acs.atomgit.com/s/AbC123");
let outcome = w.handle_key_pure(KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(outcome, PureOutcome::Close);
let mut w = qr_wizard_with_error("any");
let outcome = w.handle_key_pure(KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(outcome, PureOutcome::Close);
}
#[test]
fn qr_login_random_keys_are_noop() {
// Arrow keys / 1-3 / letters do nothing on QrLogin — pin so a
// future copy-paste from the Setup-step arms doesn't acquire
// unintended menu-navigation semantics on this single-page
// screen.
let mut w = qr_wizard_with_url("https://acs.atomgit.com/s/AbC123");
for code in [KeyCode::Up, KeyCode::Down, KeyCode::Left,
KeyCode::Char('1'), KeyCode::Char('a')] {
assert_eq!(
w.handle_key_pure(code, KeyModifiers::NONE),
PureOutcome::Noop,
"{:?} should be Noop on QrLogin",
code
);
}
}
#[test]
fn qr_login_draw_with_url_includes_url_in_output() {
let w = qr_wizard_with_url("https://acs.atomgit.com/s/AbC123");
let lines = w.draw_qr_login_lines(80, true);
let blob = lines.join("\n");
assert!(
blob.contains("https://acs.atomgit.com/s/AbC123"),
"URL must be in render output as fallback for users who can't \
scan: {:?}",
blob
);
assert!(
blob.contains("扫码登录"),
"expected Chinese onboarding header text"
);
}
#[test]
fn qr_login_draw_with_error_surfaces_reason() {
let w = qr_wizard_with_error("transport: timeout after 10s");
let lines = w.draw_qr_login_lines(80, true);
let blob = lines.join("\n");
assert!(blob.contains("无法生成登录链接"));
assert!(blob.contains("transport: timeout after 10s"));
assert!(blob.contains("按 Enter 重试"));
}
#[test]
fn qr_login_draw_surfaces_enter_to_open_hint() {
// Users on a desktop terminal can press Enter to launch the
// browser at the displayed URL — the modal must SHOW that
// affordance, otherwise nobody knows it exists. Both Unicode
// and ASCII renderings carry the hint since the action is
// available in either layout.
let w = qr_wizard_with_url("https://acs.atomgit.com/s/AbC123");
let unicode_blob = w.draw_qr_login_lines(80, true).join("\n");
assert!(
unicode_blob.contains("Enter"),
"Unicode QR step missing Enter-to-open affordance:\n{}",
unicode_blob
);
let ascii_blob = w.draw_qr_login_lines(80, false).join("\n");
assert!(
ascii_blob.contains("Enter"),
"ASCII QR step missing Enter-to-open affordance:\n{}",
ascii_blob
);
}
#[test]
fn qr_login_draw_ascii_fallback_drops_qr_keeps_url() {
// Half-block glyphs render as tofu on ASCII-only terminals,
// so the QR is dropped entirely and we tell the user to use
// the URL instead. URL itself MUST stay — otherwise the
// screen has nothing actionable.
let w = qr_wizard_with_url("https://acs.atomgit.com/s/AbC123");
let lines = w.draw_qr_login_lines(80, false);
let blob = lines.join("\n");
assert!(blob.contains("https://acs.atomgit.com/s/AbC123"));
assert!(blob.contains("无法显示二维码"));
// Half-block glyphs must NOT leak through the ASCII fallback.
assert!(!blob.contains('▀'));
assert!(!blob.contains('▄'));
assert!(!blob.contains('█'));
}
}