From 96ef51ae14ebe4471cfcf3539018bc21a3b2bc07 Mon Sep 17 00:00:00 2001 From: Duyet Le Date: Fri, 21 Aug 2026 16:11:20 +0700 Subject: [PATCH 1/4] feat(cli): pin model collapses claude alias slots; add fable slot A concrete --model now takes over every unset Claude Code alias slot (haiku/sonnet/opus/fable + CLAUDE_CODE_SUBAGENT_MODEL) so subagents and automatic fallback (fable on third-party providers) cannot silently use a different model. Explicit --haiku/--sonnet/--opus/--fable flags or saved profile values still win; --model auto keeps independent aliases. Adds --fable as a first-class slot: launch flag, models use, pickers, config get JSON, whoami status, claude_fable config key with round-trip. Picker entries get role descriptions via ANTHROPIC_DEFAULT_*_MODEL_DESCRIPTION. Co-Authored-By: Claude Co-authored-by: Duyet Le Co-authored-by: duyetbot --- src/commands.rs | 23 +++++++++- src/config.rs | 10 +++++ src/help.rs | 10 +++-- src/parse.rs | 1 + src/spawn.rs | 109 +++++++++++++++++++++++++++++++++++++----------- tests/cli.rs | 88 ++++++++++++++++++++++++++++++++++---- 6 files changed, 205 insertions(+), 36 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index bf8f5fb..2941a43 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -85,6 +85,7 @@ const LAUNCH_FLAGS: &[&str] = &[ "haiku", "sonnet", "opus", + "fable", "profile", "preset", "key", @@ -158,6 +159,7 @@ fn allowed_flags(command: &str) -> Option<&'static [&'static str]> { ], "models" => &[ "profile", "config", "json", "key", "base-url", "pick", "haiku", "sonnet", "opus", + "fable", ], "usage" => &["profile", "base-url", "config", "json", "key", "no-detail"], "whoami" => &["profile", "config", "json"], @@ -405,7 +407,7 @@ pub fn run(argv: Vec, env: HashMap) -> i32 { let command = parsed.command.as_str(); if command == "--version" || command == "-v" { - println!("{VERSION}"); + println!("{VERSION} (built {})", crate::buildinfo::display_time()); return 0; } @@ -550,6 +552,7 @@ fn persist_login( profile.claude_haiku = prev.claude_haiku.clone(); profile.claude_sonnet = prev.claude_sonnet.clone(); profile.claude_opus = prev.claude_opus.clone(); + profile.claude_fable = prev.claude_fable.clone(); } let mut cfg = upsert_profile(existing.unwrap_or_default(), &name, profile); cfg.active_profile = name.clone(); @@ -651,6 +654,7 @@ fn set_model_slot(profile: &mut Profile, slot: &str, id: String) { "haiku" => profile.claude_haiku = Some(id), "sonnet" => profile.claude_sonnet = Some(id), "opus" => profile.claude_opus = Some(id), + "fable" => profile.claude_fable = Some(id), _ => profile.default_model = Some(id), } } @@ -661,11 +665,13 @@ fn pick_claude_slot(profile: &Profile) -> Result<&'static str, String> { format!("Haiku · {}", profile.claude_haiku()), format!("Sonnet · {}", profile.claude_sonnet()), format!("Opus · {}", profile.claude_opus()), + format!("Fable · {}", profile.claude_fable()), ]; Ok(match term::pick("Which Claude model?", &items, Some(0))? { 1 => "haiku", 2 => "sonnet", 3 => "opus", + 4 => "fable", _ => "default", }) } @@ -675,6 +681,7 @@ fn slot_title(slot: &str) -> &'static str { "haiku" => "Haiku model", "sonnet" => "Sonnet model", "opus" => "Opus model", + "fable" => "Fable model", _ => "Default model", } } @@ -684,6 +691,7 @@ fn slot_current<'a>(profile: &'a Profile, slot: &str) -> &'a str { "haiku" => profile.claude_haiku(), "sonnet" => profile.claude_sonnet(), "opus" => profile.claude_opus(), + "fable" => profile.claude_fable(), _ => profile.default_model(), } } @@ -702,6 +710,10 @@ fn apply_claude_alias_flags(profile: &mut Profile, parsed: &ParsedArgs) -> bool profile.claude_opus = Some(v); changed = true; } + if let Some(v) = get_string_flag(&parsed.flags, "fable") { + profile.claude_fable = Some(v); + changed = true; + } changed } @@ -726,6 +738,7 @@ fn save_model_slot( "haiku" => "haiku", "sonnet" => "sonnet", "opus" => "opus", + "fable" => "fable", _ => "default model", }; println!("{} {} {}", term::ok("Saved"), label, term::model_id(id)); @@ -746,6 +759,7 @@ fn run_models(parsed: &ParsedArgs, env: &BTreeMap) -> Result) -> Result, pub claude_sonnet: Option, pub claude_opus: Option, + pub claude_fable: Option, pub timeout_ms: Option, pub extra: BTreeMap, } @@ -87,6 +89,10 @@ impl Profile { nonempty(&self.claude_opus).unwrap_or(DEFAULT_CLAUDE_OPUS) } + pub fn claude_fable(&self) -> &str { + nonempty(&self.claude_fable).unwrap_or(DEFAULT_CLAUDE_FABLE) + } + pub fn timeout_ms(&self) -> i64 { self.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS) } @@ -355,6 +361,7 @@ fn profile_from_map(map: &BTreeMap) -> Profile { p.claude_sonnet = Some(v.as_string_lossy()).filter(|s| !s.is_empty()) } "claude_opus" => p.claude_opus = Some(v.as_string_lossy()).filter(|s| !s.is_empty()), + "claude_fable" => p.claude_fable = Some(v.as_string_lossy()).filter(|s| !s.is_empty()), "timeout_ms" => { p.timeout_ms = match v { YamlValue::Int(n) => Some(*n), @@ -465,6 +472,9 @@ pub fn serialize_config(config: &Config) -> String { if let Some(m) = &profile.claude_opus { lines.push(format!(" claude_opus: {}", yaml_scalar(m))); } + if let Some(m) = &profile.claude_fable { + lines.push(format!(" claude_fable: {}", yaml_scalar(m))); + } if let Some(t) = profile.timeout_ms { lines.push(format!(" timeout_ms: {t}")); } diff --git a/src/help.rs b/src/help.rs index c2f5524..6a45402 100644 --- a/src/help.rs +++ b/src/help.rs @@ -18,6 +18,7 @@ Options: --haiku Claude /model haiku and subagents --sonnet Claude /model sonnet --opus Claude /model opus + --fable Claude /model fable (also the auto-fallback target) --effort Reasoning effort: minimal | low | medium | high | xhigh | max --hub Load a hub: sync ~/.anyrouter/hubs + claude --plugin-dir --profile Use a named profile @@ -327,18 +328,19 @@ const MODELS: &str = "\ Usage: {bin} models [options] {bin} models use - {bin} models use --haiku|--sonnet|--opus + {bin} models use --haiku|--sonnet|--opus|--fable {bin} models --pick Lists every model id usable with --model. `use` / `--pick` persist the session -default, or Claude Code's opus / sonnet / haiku aliases. +default, or Claude Code's opus / sonnet / haiku / fable aliases. Options: --json Print as JSON - --pick Interactive picker (TTY) — default / haiku / sonnet / opus + --pick Interactive picker (TTY) — default / haiku / sonnet / opus / fable --haiku Persist Claude haiku alias --sonnet Persist Claude sonnet alias --opus Persist Claude opus alias + --fable Persist Claude fable alias (auto-fallback target) --key sk-ar-v1-… Optional inference key "; @@ -352,7 +354,7 @@ USAGE {bin} config use Switch the active account On a TTY, `{bin} config` opens a Ratatui list until you pick Done: switch -key, account, model (default / haiku / sonnet / opus), view credits, sign +key, account, model (default / haiku / sonnet / opus / fable), view credits, sign in, or log out. `--dump-tui` prints one plain frame and exits. "; diff --git a/src/parse.rs b/src/parse.rs index 397e89b..4cc4394 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -29,6 +29,7 @@ pub static VALUE_FLAGS: LazyLock> = LazyLock::new(|| { "haiku", "sonnet", "opus", + "fable", ]) }); diff --git a/src/spawn.rs b/src/spawn.rs index b01f509..a96c86e 100644 --- a/src/spawn.rs +++ b/src/spawn.rs @@ -378,24 +378,50 @@ pub fn build_tool_env(input: BuildToolEnvInput<'_>) -> BTreeMap } .into(), ); - // Keep opus/sonnet/haiku independent so Claude Code's /model picker - // lists all three instead of collapsing onto the session model. - env.insert( - "ANTHROPIC_DEFAULT_HAIKU_MODEL".into(), - input.profile.claude_haiku().to_string(), - ); + // A concrete pinned model takes over every Claude Code alias slot + // (haiku / sonnet / opus / fable and subagents) so nothing — including + // automatic model fallback, which rides the fable alias on third-party + // providers — falls back to a different model. Slots set explicitly + // (--haiku/--sonnet/--opus/--fable or the profile config) still win. + let pinned = (!is_auto_model(input.model)).then_some(input.model); + let alias = |slot: &Option, default: &str| -> String { + let explicit = slot.as_deref().map(str::trim).filter(|s| !s.is_empty()); + match (pinned, explicit) { + (Some(id), None) => id.to_string(), + _ => default.to_string(), + } + }; + let haiku = alias(&input.profile.claude_haiku, input.profile.claude_haiku()); + env.insert("ANTHROPIC_DEFAULT_HAIKU_MODEL".into(), haiku.clone()); env.insert( "ANTHROPIC_DEFAULT_SONNET_MODEL".into(), - input.profile.claude_sonnet().to_string(), + alias(&input.profile.claude_sonnet, input.profile.claude_sonnet()), ); env.insert( "ANTHROPIC_DEFAULT_OPUS_MODEL".into(), - input.profile.claude_opus().to_string(), + alias(&input.profile.claude_opus, input.profile.claude_opus()), ); env.insert( - "CLAUDE_CODE_SUBAGENT_MODEL".into(), - input.profile.claude_haiku().to_string(), + "ANTHROPIC_DEFAULT_FABLE_MODEL".into(), + alias(&input.profile.claude_fable, input.profile.claude_fable()), ); + env.insert("CLAUDE_CODE_SUBAGENT_MODEL".into(), haiku); + // Label each picker entry with its role; otherwise four identical IDs + // all render as "Custom model". + for (key, value) in [ + ( + "ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION", + "Background & subagents", + ), + ("ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION", "Sonnet alias"), + ("ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION", "Opus alias"), + ( + "ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION", + "Fable alias + fallback", + ), + ] { + env.insert(key.into(), value.into()); + } if let Some(effort) = harness_effort("claude", input.effort) { if let Some((_, tokens)) = CLAUDE_EFFORT_TOKENS.iter().find(|(k, _)| *k == effort) { env.insert("MAX_THINKING_TOKENS".into(), tokens.to_string()); @@ -701,35 +727,70 @@ mod tests { } #[test] - fn claude_pinned_model_does_not_collapse_aliases() { + fn claude_pinned_model_collapses_unset_aliases() { let tool = builtin("claude").unwrap(); let env = build_tool_env(BuildToolEnvInput { tool_name: "claude", tool: &tool, profile: &profile(), api_key: "sk-ar-v1-secret", - model: "anyrouter/free", + model: "stealth/ox-alpha", effort: None, context_window: None, model_map: None, }); assert_eq!( env.get("ANTHROPIC_MODEL").map(String::as_str), - Some("anyrouter/free") + Some("stealth/ox-alpha") ); + // Every unset alias slot follows the pinned model so nothing + // (subagents, automatic fallback) silently falls back to another model. + for key in [ + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_FABLE_MODEL", + "CLAUDE_CODE_SUBAGENT_MODEL", + ] { + assert_eq!( + env.get(key).map(String::as_str), + Some("stealth/ox-alpha"), + "{key} should follow the pinned model" + ); + } + } + + #[test] + fn claude_explicit_alias_beats_pinned_model() { + let tool = builtin("claude").unwrap(); + let mut p = profile(); + p.claude_sonnet = Some("z-ai/glm-4.7-flash".into()); + let env = build_tool_env(BuildToolEnvInput { + tool_name: "claude", + tool: &tool, + profile: &p, + api_key: "sk-ar-v1-secret", + model: "stealth/ox-alpha", + effort: None, + context_window: None, + model_map: None, + }); assert_eq!( - env.get("ANTHROPIC_DEFAULT_SONNET_MODEL") - .map(String::as_str), - Some("anthropic/claude-sonnet-4.6") - ); - assert_ne!( - env.get("ANTHROPIC_DEFAULT_HAIKU_MODEL").map(String::as_str), - Some("anyrouter/free") - ); - assert_ne!( - env.get("ANTHROPIC_DEFAULT_OPUS_MODEL").map(String::as_str), - Some("anyrouter/free") + env.get("ANTHROPIC_DEFAULT_SONNET_MODEL").map(String::as_str), + Some("z-ai/glm-4.7-flash") ); + for key in [ + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_FABLE_MODEL", + "CLAUDE_CODE_SUBAGENT_MODEL", + ] { + assert_eq!( + env.get(key).map(String::as_str), + Some("stealth/ox-alpha"), + "{key} should follow the pinned model" + ); + } } #[test] diff --git a/tests/cli.rs b/tests/cli.rs index ff176a2..4f932b6 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -399,7 +399,7 @@ fn claude_dry_run_with_key_prints_base_and_redacts_secret() { } #[test] -fn claude_dry_run_pinned_model_keeps_distinct_aliases() { +fn claude_dry_run_pinned_model_collapses_aliases() { let key = "sk-ar-v1-testkey"; let (code, stdout, stderr) = { let out = anyr() @@ -410,7 +410,7 @@ fn claude_dry_run_pinned_model_keeps_distinct_aliases() { "--key", key, "--model", - "anyrouter/free", + "stealth/ox-alpha", ]) .env_remove("ANYROUTER_API_KEY") .output() @@ -423,21 +423,95 @@ fn claude_dry_run_pinned_model_keeps_distinct_aliases() { }; assert_eq!(code, 0, "stderr={stderr}"); assert!( - stdout.contains("ANTHROPIC_MODEL=anyrouter/free"), + stdout.contains("ANTHROPIC_MODEL=stealth/ox-alpha"), "{stdout}" ); + // Unset alias slots follow the pinned model so nothing falls back to + // haiku/sonnet/opus behind the user's back. + for key_line in [ + "ANTHROPIC_DEFAULT_HAIKU_MODEL=stealth/ox-alpha", + "ANTHROPIC_DEFAULT_SONNET_MODEL=stealth/ox-alpha", + "ANTHROPIC_DEFAULT_OPUS_MODEL=stealth/ox-alpha", + "CLAUDE_CODE_SUBAGENT_MODEL=stealth/ox-alpha", + ] { + assert!(stdout.contains(key_line), "missing {key_line}:\n{stdout}"); + } +} + +#[test] +fn claude_dry_run_haiku_flag_beats_pinned_model() { + let key = "sk-ar-v1-testkey"; + let (code, stdout, stderr) = { + let out = anyr() + .args([ + "claude", + "--dry-run", + "--yes", + "--key", + key, + "--model", + "stealth/ox-alpha", + "--haiku", + "z-ai/glm-4.7-flash", + ]) + .env_remove("ANYROUTER_API_KEY") + .output() + .expect("dry-run"); + ( + out.status.code().unwrap_or(1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) + }; + assert_eq!(code, 0, "stderr={stderr}"); assert!( - stdout.contains("ANTHROPIC_DEFAULT_SONNET_MODEL=anthropic/claude-sonnet-4.6"), - "pinned session model must not overwrite sonnet:\n{stdout}" + stdout.contains("ANTHROPIC_DEFAULT_HAIKU_MODEL=z-ai/glm-4.7-flash"), + "{stdout}" ); assert!( - stdout.contains("ANTHROPIC_DEFAULT_HAIKU_MODEL=anthropic/claude-haiku-4.5"), + stdout.contains("ANTHROPIC_DEFAULT_SONNET_MODEL=stealth/ox-alpha"), "{stdout}" ); +} + +#[test] +fn claude_dry_run_fable_flag_beats_pinned_model() { + let key = "sk-ar-v1-testkey"; + let (code, stdout, stderr) = { + let out = anyr() + .args([ + "claude", + "--dry-run", + "--yes", + "--key", + key, + "--model", + "stealth/ox-alpha", + "--fable", + "anthropic/claude-fable-5", + ]) + .env_remove("ANYROUTER_API_KEY") + .output() + .expect("dry-run"); + ( + out.status.code().unwrap_or(1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) + }; + assert_eq!(code, 0, "stderr={stderr}"); + // Explicit --fable wins over the pinned session model... assert!( - stdout.contains("ANTHROPIC_DEFAULT_OPUS_MODEL=anthropic/claude-opus-4.6"), + stdout.contains("ANTHROPIC_DEFAULT_FABLE_MODEL=anthropic/claude-fable-5"), "{stdout}" ); + // ...while every other unset slot still follows the pin. + for key_line in [ + "ANTHROPIC_DEFAULT_SONNET_MODEL=stealth/ox-alpha", + "ANTHROPIC_DEFAULT_OPUS_MODEL=stealth/ox-alpha", + ] { + assert!(stdout.contains(key_line), "missing {key_line}:\n{stdout}"); + } } #[test] From 2fd669dd8af37123a994932ce04f71024dd5e9d7 Mon Sep 17 00:00:00 2001 From: Duyet Le Date: Fri, 21 Aug 2026 16:11:51 +0700 Subject: [PATCH 2/4] feat(cli): embed build time, shown in local timezone build.rs stamps the compile time in UTC (std-only); the CLI renders it in the viewer's local timezone via localtime_r at runtime. Shown by --version and ar update. libc was already linked transitively. Co-Authored-By: Claude Co-authored-by: Duyet Le Co-authored-by: duyetbot --- Cargo.lock | 1 + Cargo.toml | 1 + build.rs | 34 +++++++++++++++++++ src/buildinfo.rs | 88 ++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/upgrade.rs | 2 +- 6 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 build.rs create mode 100644 src/buildinfo.rs diff --git a/Cargo.lock b/Cargo.lock index 625c7b0..296894c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,7 @@ name = "anyr-cli" version = "0.1.10" dependencies = [ "crossterm", + "libc", "ratatui", "serde_json", "ureq", diff --git a/Cargo.toml b/Cargo.toml index 916ad55..41eb140 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ native = ["dep:ureq", "dep:ratatui", "dep:crossterm"] [dependencies] serde_json = "1" +libc = "0.2" ureq = { version = "2.12", optional = true } ratatui = { version = "=0.29.0", default-features = false, features = ["crossterm"], optional = true } crossterm = { version = "=0.28.1", optional = true } diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..9eb29fa --- /dev/null +++ b/build.rs @@ -0,0 +1,34 @@ +fn main() { + println!("cargo:rustc-env=ANYR_BUILD_TIME_UTC={}", utc_now()); +} + +/// Build time as `YYYY-MM-DDTHH:MM:SSZ` (UTC), std-only — civil-from-days per +/// Howard Hinnant's algorithm. Stored in UTC; the CLI renders it in the +/// viewer's local timezone at runtime. +fn utc_now() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let (y, mo, d, h, mi, s) = civil_from_unix(secs); + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z") +} + +/// Split a unix timestamp into `(year, month, day, hour, minute, second)` in UTC. +pub fn civil_from_unix(secs: i64) -> (i64, u32, u32, u32, u32, u32) { + let days = secs.div_euclid(86_400); + let rem = secs.rem_euclid(86_400); + let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60); + // Shift the civil epoch so March is month 3; the year then starts in March. + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let mo = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if mo <= 2 { y + 1 } else { y }; + (y, mo as u32, d as u32, h as u32, mi as u32, s as u32) +} diff --git a/src/buildinfo.rs b/src/buildinfo.rs new file mode 100644 index 0000000..fd1ad7d --- /dev/null +++ b/src/buildinfo.rs @@ -0,0 +1,88 @@ +//! Build metadata: embeds the compile timestamp (UTC, stamped by build.rs) +//! and renders it in the viewer's local timezone. + +pub const BUILD_TIME: &str = match option_env!("ANYR_BUILD_TIME_UTC") { + Some(t) => t, + None => "unknown", +}; + +/// Build time formatted for display: local timezone when available, +/// otherwise the embedded UTC stamp. +pub fn display_time() -> String { + #[cfg(unix)] + if let Some(local) = local_time_from_utc(BUILD_TIME) { + return local; + } + BUILD_TIME.to_string() +} + +/// Parse `YYYY-MM-DDTHH:MM:SSZ` and render it in the machine's local +/// timezone via `localtime_r`. Returns `None` when parsing fails. +#[cfg(unix)] +fn local_time_from_utc(stamp: &str) -> Option { + let b = stamp.as_bytes(); + if b.len() != 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' { + return None; + } + let num = |r: std::ops::Range| stamp.get(r)?.parse::().ok(); + let (y, mo, d) = (num(0..4)?, num(5..7)?, num(8..10)?); + let (h, mi, s) = (num(11..13)?, num(14..16)?, num(17..19)?); + let epoch = days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + s; + + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + let t: libc::time_t = epoch; + if unsafe { libc::localtime_r(&t, &mut tm) }.is_null() { + return None; + } + Some(format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", + tm.tm_year + 1900, + tm.tm_mon + 1, + tm.tm_mday, + tm.tm_hour, + tm.tm_min, + tm.tm_sec + )) +} + +/// Days since 1970-01-01 from a civil date (Howard Hinnant's algorithm). +#[cfg(unix)] +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = y - i64::from(m <= 2); + let era = y.div_euclid(400); + let yoe = y - era * 400; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_time_is_stamped() { + assert_ne!(BUILD_TIME, "unknown", "build.rs must stamp ANYR_BUILD_TIME_UTC"); + assert!(BUILD_TIME.ends_with('Z'), "{BUILD_TIME}"); + } + + #[test] + fn civil_roundtrip_known_dates() { + // (civil date, expected epoch seconds) pairs verified with `date -u`. + for (y, mo, d, h, mi, s, epoch) in [ + (1970, 1, 1, 0, 0, 0, 0), + (2026, 8, 21, 9, 2, 36, 1_787_302_956), + (2000, 2, 29, 12, 0, 0, 951_825_600), + ] { + let computed = days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + s; + assert_eq!(computed, epoch, "{y}-{mo}-{d} {h}:{mi}:{s}"); + } + } + + #[test] + fn display_time_falls_back_to_raw_stamp_on_bad_input() { + // display_time never panics even if the stamp were malformed. + let _ = display_time(); + } +} diff --git a/src/lib.rs b/src/lib.rs index eddc9af..f80fd69 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ //! AnyRouter native CLI library. Shared by the `anyr` binary and tests. pub mod auth; +pub mod buildinfo; pub mod channel; pub mod commands; pub mod config; diff --git a/src/upgrade.rs b/src/upgrade.rs index 0b6395e..de105c2 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -498,7 +498,7 @@ pub fn run(parsed: &ParsedArgs, env: &BTreeMap) -> Result Date: Fri, 21 Aug 2026 16:42:15 +0700 Subject: [PATCH 3/4] feat(cli): two-pane launcher with icons and cached credits - Launcher renders info panel (left) + action list (right) on wide terminals; stacks vertically when narrow. Pickers unchanged. - Icons derived from row labels (launch/config/switch/credits/login/...) with per-category accent colors. - Credits fetched once per 5-minute TTL in the launcher loop instead of per frame; dump mode and pipes stay offline-deterministic ("credits -"). - Plain dumps keep the same header lines and stay ANSI-free for CI. Co-Authored-By: Claude Co-authored-by: Duyet Le Co-authored-by: duyetbot --- src/commands.rs | 58 ++++++++++++++++++- src/tui/view.rs | 147 ++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 184 insertions(+), 21 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index 2941a43..dba6919 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1620,20 +1620,21 @@ fn run_menu(parsed: &ParsedArgs, env: &BTreeMap) -> Result>, + fetched_at: Option, +} + +const CREDITS_TTL: std::time::Duration = std::time::Duration::from_secs(300); + +impl CreditsCache { + fn fresh() -> Self { + Self { + value: None, + fetched_at: None, + } + } + + /// Return the cached display string, refreshing when stale. + fn get(&mut self, base_url: &str, api_key: Option<&str>) -> String { + let expired = self + .fetched_at + .map(|t| t.elapsed() > CREDITS_TTL) + .unwrap_or(true); + if expired { + self.value = match api_key { + Some(key) => Some( + fetch_credits(base_url, key) + .map(|c| crate::http::format_usd(c["balance"].as_f64().unwrap_or(0.0))) + .map_err(|_| ()), + ), + None => Some(Err(())), + }; + self.fetched_at = Some(std::time::Instant::now()); + } + match &self.value { + Some(Ok(s)) => s.clone(), + _ => "(unknown)".into(), + } + } +} + fn launcher_frame( path: &PathBuf, parsed: &ParsedArgs, env: &BTreeMap, + credits: &mut CreditsCache, ) -> (Vec, Vec) { let cfg = load_config_if_present(path).unwrap_or_default(); let profile = cfg.profiles.get(&cfg.active_profile); let signed_in = launcher_signed_in(path, parsed, env); let last = launcher_last_tool(path, parsed, env); + let base = resolve_base_url(&parsed.flags, profile); + let key = resolve_api_key(&parsed.flags, env, profile); + let credits_line = if tui_wants_dump(parsed, env) || !term::is_interactive() { + // Dump mode and pipes must stay offline-deterministic. + "credits -".to_string() + } else { + format!("credits {}", credits.get(&base, key.as_deref())) + }; let header = vec![ format!( "account {} {}", @@ -1701,6 +1752,7 @@ fn launcher_frame( display_model_id(profile.map(|p| p.default_model()).unwrap_or("auto")) ), format!("agent {last}"), + credits_line, ]; let mut items = Vec::new(); diff --git a/src/tui/view.rs b/src/tui/view.rs index cde881e..2410dad 100644 --- a/src/tui/view.rs +++ b/src/tui/view.rs @@ -1,6 +1,11 @@ //! Ratatui widgets + ANSI-free plain frames for dump / tests. +//! +//! The launcher renders as a two-pane layout on wide terminals (info panel +//! left, action list right) and stacks vertically when narrow. Pickers stay +//! single-pane. All plain_* dumps remain ANSI-free for CI. use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; use ratatui::Frame; @@ -23,11 +28,16 @@ pub fn render_picker(frame: &mut Frame, state: &PickerState) { render_header(frame, chunks[0], &state.title, &state.header); let search = Paragraph::new(Line::from(vec![ - Span::styled("search: ", theme::muted()), + Span::styled("⌕ ", theme::accent()), Span::styled(state.query.as_str(), theme::white()), Span::styled("█", theme::accent()), ])) - .block(Block::default().borders(Borders::ALL).border_style(theme::muted())); + .block( + Block::default() + .borders(Borders::ALL) + .border_style(theme::muted()) + .title(Span::styled(" search ", theme::muted())), + ); frame.render_widget(search, chunks[1]); let filtered = state.filtered(); @@ -35,13 +45,16 @@ pub fn render_picker(frame: &mut Frame, state: &PickerState) { .iter() .enumerate() .map(|(i, (_, label))| { - let marker = if i == state.cursor { "◆ " } else { " " }; let style = if i == state.cursor { theme::selected() } else { theme::white() }; - ListItem::new(Line::from(Span::styled(format!("{marker}{label}"), style))) + ListItem::new(Line::from(vec![ + Span::styled(if i == state.cursor { "❯ " } else { " " }, theme::accent()), + Span::styled(item_icon(label), item_icon_style(label)), + Span::styled(format!("{label}"), style), + ])) }) .collect(); @@ -65,46 +78,89 @@ pub fn render_picker(frame: &mut Frame, state: &PickerState) { pub fn render_menu(frame: &mut Frame, state: &MenuState) { let area = frame.area(); - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1 + state.header.len() as u16), - Constraint::Min(3), - Constraint::Length(1), - ]) - .split(area); + // Two panes side by side only when there is room; stack otherwise. + let wide = area.width >= 60 && state.header.len() <= 6; + let (header_area, body_area) = if wide { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(42), Constraint::Percentage(58)]) + .split(area); + (cols[0], cols[1]) + } else { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2 + state.header.len() as u16), + Constraint::Min(3), + ]) + .split(area); + (rows[0], rows[1]) + }; - render_header(frame, chunks[0], &state.title, &state.header); + if wide { + render_info_panel(frame, header_area, &state.title, &state.header); + } else { + render_header(frame, header_area, &state.title, &state.header); + } let items: Vec = state .items .iter() .enumerate() .map(|(i, label)| { - let marker = if i == state.cursor { "◆ " } else { " " }; let style = if i == state.cursor { theme::selected() } else { theme::white() }; - ListItem::new(Line::from(Span::styled(format!("{marker}{label}"), style))) + ListItem::new(Line::from(vec![ + Span::styled(if i == state.cursor { "❯ " } else { " " }, theme::accent()), + Span::styled(item_icon(label), item_icon_style(label)), + Span::styled(label.as_str(), style), + ])) }) .collect(); let list = List::new(items).block( Block::default() .borders(Borders::ALL) - .border_style(theme::muted()) + .border_style(if wide { theme::brand() } else { theme::muted() }) .title(Span::styled( format!(" {} ", state.title), theme::title(), )), ); let mut list_state = ListState::default().with_selected(Some(state.cursor)); - frame.render_stateful_widget(list, chunks[1], &mut list_state); + frame.render_stateful_widget(list, body_area, &mut list_state); + let footer_area = Rect::new(area.x, area.bottom().saturating_sub(1), area.width, 1); let footer = Paragraph::new(Span::styled(state.hint(), theme::muted())); - frame.render_widget(footer, chunks[2]); + frame.render_widget(footer, footer_area); +} + +/// Bordered panel showing account / model / agent / credits lines. +fn render_info_panel(frame: &mut Frame, area: Rect, title: &str, header: &[String]) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme::muted()) + .title(Span::styled(format!(" {title} "), theme::brand())); + let inner = block.inner(area); + frame.render_widget(block, area); + + let mut lines: Vec = Vec::new(); + lines.push(Line::from(Span::styled("▲", theme::brand()))); + lines.push(Line::from("")); + for h in header { + let Some((key, rest)) = h.split_once(" ") else { + lines.push(Line::from(Span::styled(h.clone(), theme::white()))); + continue; + }; + lines.push(Line::from(vec![ + Span::styled(format!("{key:<9}"), theme::muted()), + Span::styled(rest.to_string(), theme::white()), + ])); + } + frame.render_widget(Paragraph::new(lines), inner); } fn render_header(frame: &mut Frame, area: Rect, title: &str, header: &[String]) { @@ -118,6 +174,45 @@ fn render_header(frame: &mut Frame, area: Rect, title: &str, header: &[String]) frame.render_widget(Paragraph::new(lines), area); } +/// Icon for a launcher/picker row, derived from its label. +pub fn item_icon(label: &str) -> &'static str { + let l = label.to_ascii_lowercase(); + if label.starts_with("Launch") || l.contains("claude") || l.contains("codex") { + "⚡ " + } else if l.contains("config") { + "⚙ " + } else if l.contains("switch") || l.contains("account") { + "⇄ " + } else if l.contains("credit") { + "¤ " + } else if l.contains("login") || l.contains("sign in") { + "🔑 " + } else if l.contains("logout") || l.contains("log out") { + "🚪 " + } else if l.contains("onboard") { + "📋 " + } else if l.contains("quit") { + "✕ " + } else if l.contains("model") { + "◆ " + } else { + "· " + } +} + +fn item_icon_style(label: &str) -> Style { + let l = label.to_ascii_lowercase(); + if label.starts_with("Launch") { + theme::success() + } else if l.contains("quit") { + theme::muted() + } else if l.contains("credit") { + theme::model() + } else { + theme::accent() + } +} + /// ANSI-free plain frame for `--dump-tui` and unit tests. pub fn plain_picker_lines(state: &PickerState, cols: usize) -> Vec { let width = cols.max(40); @@ -208,4 +303,20 @@ mod tests { assert!(frame.contains("search: a")); assert!(!frame.contains('\u{1b}')); } + + #[test] + fn icons_cover_launcher_actions() { + for (label, icon) in [ + ("Launch claude", "⚡"), + ("Config", "⚙"), + ("Switch model", "⇄"), + ("Credits", "¤"), + ("Login / sign in", "🔑"), + ("Log out", "🚪"), + ("Agent onboard prompt…", "📋"), + ("Quit", "✕"), + ] { + assert_eq!(item_icon(label).trim(), icon, "icon for {label}"); + } + } } From 276098d459d3d5576b7a1533738b96bab2312ba9 Mon Sep 17 00:00:00 2001 From: Duyet Le Date: Fri, 21 Aug 2026 16:49:07 +0700 Subject: [PATCH 4/4] fix(cli): gate days_from_civil test to unix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test calls days_from_civil, which is #[cfg(unix)] — Windows cargo test failed with E0425. Co-Authored-By: Claude Co-authored-by: Duyet Le Co-authored-by: duyetbot --- src/buildinfo.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/buildinfo.rs b/src/buildinfo.rs index fd1ad7d..bebda79 100644 --- a/src/buildinfo.rs +++ b/src/buildinfo.rs @@ -68,6 +68,7 @@ mod tests { } #[test] + #[cfg(unix)] // days_from_civil is unix-only (its only caller is) fn civil_roundtrip_known_dates() { // (civil date, expected epoch seconds) pairs verified with `date -u`. for (y, mo, d, h, mi, s, epoch) in [