use unicode_width::UnicodeWidthStr;
fn box_chars(unicode_symbols: bool) -> (&'static str, &'static str, &'static str, &'static str, &'static str, &'static str) {
if unicode_symbols {
("┌", "┐", "└", "┘", "─", "│")
} else {
("+", "+", "+", "+", "-", "|")
}
}
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('#'),
'┌' | '┐' | '└' | '┘' | '┬' | '┴' | '├' | '┤' | '┼' => out.push('+'),
'─' => out.push('-'),
'│' => out.push('|'),
other => out.push(other),
}
}
out
}
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; let brand = Color::Magenta; let (tl, tr, bl, br_c, h, v) = box_chars(unicode_symbols);
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);
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);
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);
}
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
}
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))
}
const FOOTER_ROWS: usize = 5;
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
}
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();
}
use crossterm::event::{KeyCode, KeyModifiers};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Step {
Confirm,
Intro,
Language,
Setup,
QrLogin,
}
pub struct OnboardingWizard {
pub(super) step: Step,
pub(super) language_idx: usize,
pub(super) setup_idx: usize,
#[allow(dead_code)] pub(super) needs_confirm: bool,
pub(super) qr_login_url: Option<String>,
pub(super) qr_login_error: Option<String>,
pub(super) pending_session:
Option<atomcode_core::auth::oauth::LoginSession>,
}
impl OnboardingWizard {
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,
}
}
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,
}
}
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,
}
}
pub fn take_pending_session(
&mut self,
) -> Option<atomcode_core::auth::oauth::LoginSession> {
self.pending_session.take()
}
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
}
#[cfg(test)]
pub(super) fn handle_key_for_test(&mut self, code: KeyCode) {
let _ = self.handle_key_pure(code, KeyModifiers::NONE);
}
pub(super) fn handle_key_pure(
&mut self,
code: KeyCode,
_mods: KeyModifiers,
) -> PureOutcome {
use Step::*;
match (self.step, code) {
(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, KeyCode::Enter) => {
self.step = Language;
PureOutcome::ClearAndRedraw
}
(Intro, KeyCode::Esc) => PureOutcome::Close,
(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
}
(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, 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, 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 {
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;
let mut out = Vec::new();
out.push(t(Msg::OnboardingStepHeaderWelcome).into_owned());
out.push(String::new());
let mut content: Vec<String> = Vec::new();
content.push(String::new());
if !compact {
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 {
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());
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)
}
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)
}
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 => {
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)
}
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)
}
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);
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 {
content.push(center("✗ 无法生成登录链接"));
content.push(String::new());
content.push(format!(" {}", reason));
content.push(String::new());
content.push(center("按 Enter 重试 · Esc 跳过"));
} else if let Some(url) = &self.qr_login_url {
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());
content.push(center("扫码完成后自动跳转"));
} else {
content.push(center("(状态未初始化)"));
}
content.push(String::new());
content.push(center("Esc 跳过 · /codingplan 重试 · /provider 手动配置"));
content.push(String::new());
let mut out = Vec::new();
out.push("扫码登录 · 领取CodingPlan".to_string());
out.push(String::new());
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)
}
}
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 => {
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 => {
if let Some(url) = &self.qr_login_url {
let _ = atomcode_core::auth::oauth::open_browser(url);
}
Ok(ModalAction::Continue)
}
PureOutcome::RetryQrLogin => {
match atomcode_core::auth::oauth::start_login() {
Ok(session) => {
self.qr_login_url = Some(session.url().to_string());
self.qr_login_error = None;
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_codingplan = true,
1 => ctx.pending_open_provider_wizard = true,
_ => { }
}
renderer.clear_screen();
if self.setup_idx == 2 {
paint_welcome(ctx, renderer);
}
Ok(ModalAction::Close)
}
PureOutcome::Close => {
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));
let panel_width = (cols as usize).min(80);
let unicode = state.unicode_symbols;
let lines = match self.step {
Step::Confirm => {
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 {
renderer.render(crate::render::UiLine::CommandOutput(line));
}
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();
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PureOutcome {
Redraw,
ClearAndRedraw,
ApplyLanguageThenAdvance,
ApplySetupThenClose,
RetryQrLogin,
OpenQrUrlInBrowser,
Close,
Noop,
}
#[cfg(test)]
mod tests {
use super::*;
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(); 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);
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() {
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() {
let lines = draw_panel("AtomCode", &["x".into()], "S", 10, true);
assert_eq!(lines.len(), 3); }
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);
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");
}
}
#[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");
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"));
assert!(joined.contains("Step 1/3 · Welcome"));
assert!(joined.contains("Step 1/3"));
}
#[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}"
);
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"));
}
#[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");
assert!(joined.contains("Choose your language / 选择语言"));
assert!(joined.contains("[1] Auto-detect"));
assert!(joined.contains("[2] English"));
assert!(joined.contains("[3] 简体中文"));
assert!(joined.contains("Step 2/3 · Language"));
assert!(joined.contains("1-3 select"));
}
#[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");
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"
);
}
#[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();
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);
assert!(tmp.path().join("config.toml").exists());
match prev_atomcode_home {
Some(v) => std::env::set_var("ATOMCODE_HOME", v),
None => std::env::remove_var("ATOMCODE_HOME"),
}
}
#[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); 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"),
}
}
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(),
}
}
#[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"));
assert!(joined.contains("Free tokens"));
assert!(joined.contains("API key"));
assert!(joined.contains("1-3 select"));
}
#[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("暂时跳过"));
}
#[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);
}
#[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");
assert!(joined.contains("● [2]"));
assert!(joined.contains("○ [1]"));
assert!(joined.contains("○ [3]"));
}
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);
assert!(screen.contains("┌─"), "top border missing: {screen}");
assert!(screen.contains("└─"), "bottom border missing: {screen}");
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);
assert!(
screen.contains("Choose your language / 选 择 语 言"),
"bilingual title missing: {screen}"
);
assert!(
screen.contains("自 动 检 测"),
"Chinese auto-detect label missing: {screen}"
);
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}"
);
assert!(screen.contains("AtomCode v"));
assert!(screen.contains("Press Enter to continue"));
}
#[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);
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:?}");
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:?}"
);
}
#[test]
fn pad_to_width_handles_cjk_and_short_strings() {
assert_eq!(pad_to_width("hi", 6), "hi ");
assert_eq!(pad_to_width("中文", 6), "中文 ");
assert_eq!(pad_to_width("hello world", 5), "hello world");
}
#[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 可随时退出"));
assert!(joined.contains("AtomCode"));
}
#[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");
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);
}
#[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);
}
#[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
);
}
assert!(joined.contains("Set up CodingPlan"));
assert!(joined.contains("[1]") && joined.contains("[2]") && joined.contains("[3]"));
}
#[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);
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
);
}
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() {
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() {
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() {
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() {
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() {
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() {
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() {
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("无法显示二维码"));
assert!(!blob.contains('▀'));
assert!(!blob.contains('▄'));
assert!(!blob.contains('█'));
}
}