From c7342321c6a26d3b55911cb48520b9b314091a1e Mon Sep 17 00:00:00 2001 From: Duyet Le Date: Mon, 24 Aug 2026 19:11:40 +0700 Subject: [PATCH] perf(tui): open the launcher without blocking on network or which Paint the palette first. Credits and identity load on a background thread and fill the header when ready. PATH is walked in-process instead of spawning which/where for every agent. Fullscreen detection no longer enters raw mode as a probe. Co-Authored-By: duyetbot Co-authored-by: Duyet Le Co-authored-by: duyetbot --- src/commands.rs | 173 ++++++++++++++++++++++++++++++++++++------------ src/http.rs | 14 ++-- src/install.rs | 93 +++++++++++++++++++++++--- src/tui/live.rs | 26 +++++--- src/tui/mod.rs | 13 +++- 5 files changed, 251 insertions(+), 68 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index 1b805ca..129244b 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,5 +1,6 @@ use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; +use std::sync::{Arc, Mutex}; use crate::auth::acquire_api_key; use crate::config::{ @@ -83,12 +84,42 @@ impl InlineEntry { fn tui_palette_select( header: Vec, entries: Vec, + on_idle: impl FnMut(&mut crate::tui::PaletteState), ) -> Result, String> { - crate::tui::run_palette_select(header, entries) + crate::tui::run_palette_select_idle(header, entries, on_idle) } /// No fullscreen TUI (non-native build): the palette degrades to the inline /// numbered-list picker — same entries, plain prompts. +#[cfg(feature = "native")] +fn pick_palette_action( + header: Vec, + entries: Vec, + cache: &Arc>, +) -> Result, String> { + let tick_cache = cache.clone(); + let mut seen = 0u64; + tui_palette_select(header, entries, move |state| { + let Ok(credits) = tick_cache.lock() else { + return; + }; + if credits.gen <= seen { + return; + } + seen = credits.gen; + patch_palette_header(state, &credits); + }) +} + +#[cfg(not(feature = "native"))] +fn pick_palette_action( + header: Vec, + entries: Vec, + _cache: &Arc>, +) -> Result, String> { + tui_palette_select(header, entries) +} + #[cfg(not(feature = "native"))] fn tui_palette_select( _header: Vec, @@ -2623,19 +2654,28 @@ fn launcher_palette( ) -> (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 signed_in = resolve_api_key(&parsed.flags, env, profile).is_some(); + let command_for = |id: &str| { + crate::spawn::resolve_tool(Some(&cfg), id) + .map(|t| t.command) + .unwrap_or_else(|_| id.to_string()) + }; + let present = available_agents(env, command_for); + let last = cfg + .last_tool + .clone() + .or_else(|| profile.and_then(|p| p.default_tool.clone())) + .or_else(|| get_string_flag(&parsed.flags, "tool")) + .or_else(|| present.first().map(|(id, _)| (*id).to_string())) + .unwrap_or_else(|| "claude".into()); let model_line = session_model_label(profile.map(|p| p.default_model()).unwrap_or("auto")); - // Status header — same lines as the dialog card, reused as palette header. - let base = resolve_base_url(&parsed.flags, profile); + // Status header — local data only. Credits/identity fill in from the + // background fetch via the palette idle tick (do not block first paint). let key = resolve_api_key(&parsed.flags, env, profile); - let credits_line = if tui_wants_dump(parsed, env) || !term::is_interactive() { - "credits -".to_string() - } else { - format!("credits {}", credits.get(&base, key.as_deref())) - }; - let account_line = if tui_wants_dump(parsed, env) || !term::is_interactive() { + let dump_or_pipe = tui_wants_dump(parsed, env) || !term::is_interactive(); + let credits_line = format!("credits {}", credits.peek_credits()); + let account_line = if dump_or_pipe { format!( "account {} {}", cfg.active_profile, @@ -2645,22 +2685,18 @@ fn launcher_palette( "(not signed in)".into() } ) + } else if let Some(label) = credits.peek_identity().map(|me| me.display_label()) { + format!("account {label}") } else { - let identity = credits - .identity(&base, key.as_deref()) - .map(|me| me.display_label()); - match identity { - Some(label) => format!("account {label}"), - None => format!( - "account {} {}", - cfg.active_profile, - if signed_in { - mask_api_key(profile.and_then(|p| p.api_key.as_deref())) - } else { - "(not signed in)".into() - } - ), - } + format!( + "account {} {}", + cfg.active_profile, + if signed_in { + mask_api_key(key.as_deref()) + } else { + "(not signed in)".into() + } + ) }; let header = vec![ account_line, @@ -2673,7 +2709,7 @@ fn launcher_palette( use crate::tui::PaletteEntry; let mut entries = Vec::new(); if signed_in { - push_launch_entries(&mut entries, path, env, &last, &model_line); + push_launch_entries(&mut entries, &present, &last, &model_line); } else { entries.push(PaletteEntry::new( "login", @@ -2852,10 +2888,21 @@ fn run_menu(parsed: &ParsedArgs, env: &BTreeMap) -> Result = entries .iter() @@ -2873,7 +2920,7 @@ fn run_menu(parsed: &ParsedArgs, env: &BTreeMap) -> Result return Err(err), } } else { - tui_palette_select(header, entries.clone())? + pick_palette_action(header, entries.clone(), &cache)? }; let Some(idx) = idx else { return Ok(0); @@ -2895,13 +2942,11 @@ enum LauncherNext { #[cfg(feature = "native")] fn push_launch_entries( entries: &mut Vec, - path: &PathBuf, - env: &BTreeMap, + present: &[(&'static str, &'static str)], last: &str, model_line: &str, ) { use crate::tui::PaletteEntry; - let present = available_agents(env, |id| tool_command_for(path, id)); if present.is_empty() { entries.push(PaletteEntry::new( "install an agent…", @@ -2922,7 +2967,11 @@ fn push_launch_entries( "launch", format!("Launch {last}"), )); - for (id, label) in present.into_iter().filter(|(id, _)| *id != last.as_str()) { + for (id, label) in present + .iter() + .copied() + .filter(|(id, _)| *id != last.as_str()) + { entries.push(PaletteEntry::new( id, label, @@ -3001,6 +3050,7 @@ struct CreditsCache { value: Option>, me: Option>, fetched_at: Option, + gen: u64, } const CREDITS_TTL: std::time::Duration = std::time::Duration::from_secs(300); @@ -3011,6 +3061,22 @@ impl CreditsCache { value: None, me: None, fetched_at: None, + gen: 0, + } + } + + fn peek_credits(&self) -> String { + match &self.value { + Some(Ok(s)) => s.clone(), + Some(Err(())) => "(unknown)".into(), + None => "-".into(), + } + } + + fn peek_identity(&self) -> Option { + match &self.me { + Some(Ok(me)) => Some(me.clone()), + _ => None, } } @@ -3045,25 +3111,50 @@ impl CreditsCache { } } self.fetched_at = Some(std::time::Instant::now()); + self.gen = self.gen.saturating_add(1); } /// Return the cached credits display string, refreshing when stale. fn get(&mut self, base_url: &str, api_key: Option<&str>) -> String { self.refresh(base_url, api_key); - match &self.value { - Some(Ok(s)) => s.clone(), - _ => "(unknown)".into(), - } + self.peek_credits() } /// Cached identity, refreshing when stale. `None` when unknown. fn identity(&mut self, base_url: &str, api_key: Option<&str>) -> Option { self.refresh(base_url, api_key); - match &self.me { - Some(Ok(me)) => Some(me.clone()), - _ => None, + self.peek_identity() + } +} + +#[cfg(feature = "native")] +fn kick_credits_refresh(cache: &Arc>, base: String, key: Option) { + let Some(key) = key.filter(|s| !s.is_empty()) else { + return; + }; + let cache = cache.clone(); + let _ = std::thread::Builder::new() + .name("anyr-credits".into()) + .spawn(move || { + let mut tmp = CreditsCache::fresh(); + tmp.refresh(&base, Some(&key)); + if let Ok(mut slot) = cache.lock() { + *slot = tmp; + } + }); +} + +#[cfg(feature = "native")] +fn patch_palette_header(state: &mut crate::tui::PaletteState, credits: &CreditsCache) { + if let Some(me) = credits.peek_identity() { + if let Some(line) = state.header.first_mut() { + *line = format!("account {}", me.display_label()); } } + let shown = credits.peek_credits(); + if let Some(line) = state.header.iter_mut().find(|l| l.starts_with("credits")) { + *line = format!("credits {shown}"); + } } fn launcher_dispatch( diff --git a/src/http.rs b/src/http.rs index 2a08f66..058c102 100644 --- a/src/http.rs +++ b/src/http.rs @@ -3,10 +3,16 @@ use crate::VERSION; #[cfg(feature = "native")] fn agent() -> ureq::Agent { - ureq::AgentBuilder::new() - .timeout(std::time::Duration::from_secs(30)) - .user_agent(&format!("anyr-cli/{VERSION}")) - .build() + use std::sync::OnceLock; + static AGENT: OnceLock = OnceLock::new(); + AGENT + .get_or_init(|| { + ureq::AgentBuilder::new() + .timeout(std::time::Duration::from_secs(30)) + .user_agent(&format!("anyr-cli/{VERSION}")) + .build() + }) + .clone() } /// Join a CLI profile `base_url` (`https://anyrouter.dev/api`, no `/v1`) with diff --git a/src/install.rs b/src/install.rs index a00054b..3edf72c 100644 --- a/src/install.rs +++ b/src/install.rs @@ -149,17 +149,78 @@ pub fn resolve_executable(command: &str) -> Option { } #[cfg(feature = "native")] { - let finder = if cfg!(windows) { "where" } else { "which" }; - let output = Command::new(finder).arg(command).output().ok()?; - if !output.status.success() { - return None; + thread_local! { + static HITS: std::cell::RefCell>> = + std::cell::RefCell::new(std::collections::HashMap::new()); } - String::from_utf8_lossy(&output.stdout) - .lines() - .next() - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) + HITS.with(|hits| { + if let Some(cached) = hits.borrow().get(command) { + return cached.clone(); + } + let found = find_on_path(command); + hits.borrow_mut().insert(command.to_string(), found.clone()); + found + }) + } +} + +/// Walk `PATH` in-process. Spawning `which`/`where` six times delayed the launcher. +#[cfg(feature = "native")] +fn find_on_path(command: &str) -> Option { + let path = std::env::var_os("PATH")?; + let exts: Vec = if cfg!(windows) { + std::env::var_os("PATHEXT") + .map(|v| { + v.to_string_lossy() + .split(';') + .map(|s| s.to_string()) + .collect() + }) + .unwrap_or_else(|| { + [".EXE", ".CMD", ".BAT", ".COM"] + .into_iter() + .map(str::to_string) + .collect() + }) + } else { + Vec::new() + }; + for dir in std::env::split_paths(&path) { + let direct = dir.join(command); + if is_runnable(&direct) { + return Some(direct.to_string_lossy().into_owned()); + } + for ext in &exts { + if command.rsplit('.').next().is_some_and(|e| { + !e.is_empty() && e.eq_ignore_ascii_case(ext.trim_start_matches('.')) + }) { + continue; + } + let candidate = dir.join(format!("{command}{ext}")); + if is_runnable(&candidate) { + return Some(candidate.to_string_lossy().into_owned()); + } + } + } + None +} + +#[cfg(feature = "native")] +fn is_runnable(path: &std::path::Path) -> bool { + let Ok(meta) = path.metadata() else { + return false; + }; + if !meta.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true } } @@ -248,6 +309,18 @@ mod tests { ); } + #[test] + fn resolve_executable_finds_a_real_binary() { + #[cfg(unix)] + { + let found = resolve_executable("true"); + assert!( + found.as_deref().is_some_and(|p| p.ends_with("true")), + "expected true on PATH, got {found:?}" + ); + } + } + #[test] fn agents_override_empty_means_none() { let mut env = std::collections::BTreeMap::new(); diff --git a/src/tui/live.rs b/src/tui/live.rs index 379051a..f5714d4 100644 --- a/src/tui/live.rs +++ b/src/tui/live.rs @@ -133,32 +133,36 @@ pub fn run_menu_live(mut state: MenuState) -> Result { } } -/// Whether the fullscreen TUI can run here: raw mode must be enterable and -/// the terminal must not be `dumb`. Palette falls back to inline prompts on -/// false — same contract as the non-native readline pickers. +/// Whether the fullscreen TUI can run here. Do not enter raw mode as a probe — +/// that freezes the terminal until the first draw if anything else blocks. pub fn can_use_fullscreen() -> bool { use std::io::IsTerminal; if !io::stdin().is_terminal() || !io::stdout().is_terminal() { return false; } let term = std::env::var("TERM").unwrap_or_default(); - let dumb = matches!( + !matches!( term.as_str(), "" | "dumb" | "linux" | "vt100" | "vt102" | "vt220" | "ansi" - ); - if dumb { - return false; - } - enable_raw_mode().is_ok() + ) } -pub fn run_palette_live(mut state: PaletteState) -> Result { +pub fn run_palette_live(state: PaletteState) -> Result { + run_palette_live_with(state, |_| {}) +} + +/// Same as `run_palette_live`, plus an idle tick (credits header, etc.). +pub fn run_palette_live_with( + mut state: PaletteState, + mut on_idle: impl FnMut(&mut PaletteState), +) -> Result { let mut live = LiveTerminal::start()?; loop { live.terminal .draw(|f| render_palette(f, &state)) .map_err(|e| e.to_string())?; - if !event::poll(Duration::from_millis(200)).map_err(|e| e.to_string())? { + if !event::poll(Duration::from_millis(50)).map_err(|e| e.to_string())? { + on_idle(&mut state); continue; } match event::read().map_err(|e| e.to_string())? { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 4c38627..9793221 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -18,7 +18,7 @@ use crate::parse::ParsedArgs; pub use keys::Action; pub use live::{ can_use_fullscreen, dump_menu, dump_palette, dump_picker, dump_settings, is_interactive, - run_menu_live, run_palette_live, run_picker_live, run_settings_live, + run_menu_live, run_palette_live, run_palette_live_with, run_picker_live, run_settings_live, }; pub use state::{ drive_menu, drive_palette, drive_picker, MenuState, Outcome, PaletteEntry, PaletteState, @@ -102,6 +102,15 @@ pub fn dump_menu_select( pub fn run_palette_select( header: Vec, entries: Vec, +) -> Result, String> { + run_palette_select_idle(header, entries, |_| {}) +} + +/// Palette with an idle tick so the header can fill in after a background fetch. +pub fn run_palette_select_idle( + header: Vec, + entries: Vec, + on_idle: impl FnMut(&mut PaletteState), ) -> Result, String> { if entries.is_empty() { return Ok(None); @@ -110,7 +119,7 @@ pub fn run_palette_select( if !is_interactive() { return Ok(None); } - match run_palette_live(state)? { + match run_palette_live_with(state, on_idle)? { Outcome::Selected(i) => Ok(Some(i)), Outcome::Quit | Outcome::Cancelled | Outcome::Continue => Ok(None), }