From 2b4e86ad76162b9abec99ad565ca2742da0a2b49 Mon Sep 17 00:00:00 2001 From: Duyet Le Date: Mon, 24 Aug 2026 18:20:25 +0700 Subject: [PATCH] feat(tui): show installed agents and per-agent settings tabs Launcher launch rows are only agents on PATH (or ANYR_AGENTS). If none are detected, one empty-state row opens the install dialog. Config is tabbed: general plus a tab per coding agent with status, command, install, and env mapping. Claude keeps haiku/sonnet/opus/fable on its tab. Tab / [ ] switch agents. Co-authored-by: Duyet Le Co-authored-by: duyetbot --- src/commands.rs | 565 +++++++++++++++++++++++++++++++++++++++-------- src/install.rs | 94 ++++++++ src/tui/keys.rs | 52 ++++- src/tui/live.rs | 1 + src/tui/state.rs | 28 ++- src/tui/view.rs | 48 +++- tests/cli.rs | 39 +++- 7 files changed, 726 insertions(+), 101 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index c25c08c..a3038cc 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -11,7 +11,9 @@ use crate::http::{ create_key, delete_key, fetch_credits, fetch_keys, fetch_models, format_models_list, format_usage_report, is_active_key_row, reveal_key, validate_key, }; -use crate::install::ensure_tool_installed; +use crate::install::{ + agent_available, available_agents, ensure_tool_installed, missing_agents, KNOWN_AGENTS, +}; use crate::key::{ load_config_if_present, mask_api_key, no_key_error, resolve_api_key, resolve_base_url, }; @@ -150,11 +152,8 @@ fn tui_settings_select( return Ok(None); } match crate::tui::run_settings_live(state)? { - outcome - @ (crate::tui::SettingsOutcome::Edit(_) | crate::tui::SettingsOutcome::Reset(_)) => { - Ok(Some(outcome)) - } crate::tui::SettingsOutcome::Close | crate::tui::SettingsOutcome::Stay => Ok(None), + outcome => Ok(Some(outcome)), } } @@ -1108,6 +1107,11 @@ enum SettingKind { Agent, AutoUpdate, Channel, + Install(&'static str), + ToolCommand(&'static str), + GatewayDiscovery, + /// Read-only mapping row. + Mapping, } fn run_config_tui(parsed: &ParsedArgs, env: &BTreeMap) -> Result { @@ -1123,8 +1127,14 @@ fn run_config_tui(parsed: &ParsedArgs, env: &BTreeMap) -> Result #[cfg(feature = "native")] { if tui_wants_dump(parsed, env) { - let (state, _) = - config_settings_frame(parsed, env, &path, false, &mut CreditsCache::fresh()); + let (state, _) = config_settings_frame( + parsed, + env, + &path, + false, + &mut CreditsCache::fresh(), + 0, + ); print!("{}", tui_dump_settings(state, env)); return Ok(0); } @@ -1137,6 +1147,20 @@ fn run_config_tui(parsed: &ParsedArgs, env: &BTreeMap) -> Result /// Build one settings frame: grouped rows with current values, plus a parallel /// list mapping row index → edit kind. `online` gates network (dump stays /// offline-deterministic). +#[cfg(feature = "native")] +fn settings_tab_names() -> Vec { + let mut tabs = vec!["general".to_string()]; + tabs.extend(KNOWN_AGENTS.iter().map(|(id, _)| (*id).to_string())); + tabs +} + +fn tool_command_for(path: &PathBuf, id: &str) -> String { + let cfg = load_config_if_present(path); + resolve_tool(cfg.as_ref(), id) + .map(|t| t.command) + .unwrap_or_else(|_| id.to_string()) +} + #[cfg(feature = "native")] fn config_settings_frame( parsed: &ParsedArgs, @@ -1144,6 +1168,7 @@ fn config_settings_frame( path: &PathBuf, online: bool, cache: &mut CreditsCache, + tab: usize, ) -> (crate::tui::SettingsState, Vec>) { use crate::tui::{SettingRow, SettingsState, Tone}; @@ -1181,6 +1206,49 @@ fn config_settings_frame( let mut rows: Vec = Vec::new(); let mut kinds: Vec> = Vec::new(); + + let tabs = settings_tab_names(); + let tab = tab.min(tabs.len().saturating_sub(1)); + if tab == 0 { + fill_general_settings( + &mut rows, + &mut kinds, + path, + parsed, + env, + profile, + signed_in, + key.as_deref(), + account_value.clone(), + account_tone, + &cfg, + ); + } else if let Some((id, label)) = KNOWN_AGENTS.get(tab - 1).copied() { + fill_agent_settings(&mut rows, &mut kinds, path, env, profile, id, label); + } + + ( + SettingsState::new("Config", header, rows).with_tabs(tabs, tab), + kinds, + ) +} + +#[cfg(feature = "native")] +#[allow(clippy::too_many_arguments)] +fn fill_general_settings( + rows: &mut Vec, + kinds: &mut Vec>, + path: &PathBuf, + parsed: &ParsedArgs, + env: &BTreeMap, + profile: Option<&Profile>, + signed_in: bool, + key: Option<&str>, + account_value: String, + account_tone: crate::tui::Tone, + cfg: &crate::config::Config, +) { + use crate::tui::{SettingRow, Tone}; fn section(rows: &mut Vec, kinds: &mut Vec>, name: &str) { if !rows.is_empty() { rows.push(SettingRow::Gap); @@ -1205,81 +1273,45 @@ fn config_settings_frame( kinds.push(Some(kind)); } - section(&mut rows, &mut kinds, "Account"); + section(rows, kinds, "Account"); entry( - &mut rows, - &mut kinds, + rows, + kinds, "account", - account_value.clone(), + account_value, account_tone, SettingKind::Account, ); let key_value = if signed_in { - mask_api_key(key.as_deref()) + mask_api_key(key) } else { "(not set)".into() }; entry( - &mut rows, - &mut kinds, + rows, + kinds, "api key", key_value, if signed_in { Tone::Normal } else { Tone::Muted }, SettingKind::ApiKey, ); - section(&mut rows, &mut kinds, "Model"); + section(rows, kinds, "Model"); entry( - &mut rows, - &mut kinds, + rows, + kinds, "default", display_model_id(profile.map(|p| p.default_model()).unwrap_or("auto")).into(), Tone::Model, SettingKind::Model("default"), ); - for (label, slot) in [ - ("haiku", "haiku"), - ("sonnet", "sonnet"), - ("opus", "opus"), - ("fable", "fable"), - ] { - let pinned = match slot { - "haiku" => nonempty_slot(&profile.and_then(|p| p.claude_haiku.clone())), - "sonnet" => nonempty_slot(&profile.and_then(|p| p.claude_sonnet.clone())), - "opus" => nonempty_slot(&profile.and_then(|p| p.claude_opus.clone())), - _ => nonempty_slot(&profile.and_then(|p| p.claude_fable.clone())), - }; - let value = match &pinned { - Some(id) => display_model_id(id).to_string(), - None => format!("{} · default", slot_current_opt(profile, slot)), - }; - let tone = if pinned.is_some() { - Tone::Model - } else { - Tone::Muted - }; - let slot_static: &'static str = match slot { - "haiku" => "haiku", - "sonnet" => "sonnet", - "opus" => "opus", - _ => "fable", - }; - entry( - &mut rows, - &mut kinds, - label, - value, - tone, - SettingKind::Model(slot_static), - ); - } - section(&mut rows, &mut kinds, "Agent"); + section(rows, kinds, "Agent"); let agent = launcher_last_tool(path, parsed, env); let agent_pinned = profile.and_then(|p| p.default_tool.clone()).is_some(); entry( - &mut rows, - &mut kinds, + rows, + kinds, "coding agent", agent, if agent_pinned { @@ -1289,11 +1321,28 @@ fn config_settings_frame( }, SettingKind::Agent, ); + let present = available_agents(env, |id| tool_command_for(path, id)); + entry( + rows, + kinds, + "on PATH", + if present.is_empty() { + "none detected".into() + } else { + present.iter().map(|(id, _)| *id).collect::>().join(", ") + }, + if present.is_empty() { + Tone::Warn + } else { + Tone::Good + }, + SettingKind::Mapping, + ); - section(&mut rows, &mut kinds, "General"); + section(rows, kinds, "General"); entry( - &mut rows, - &mut kinds, + rows, + kinds, "auto-update", if cfg.auto_update() { "enabled".into() @@ -1308,15 +1357,185 @@ fn config_settings_frame( SettingKind::AutoUpdate, ); entry( - &mut rows, - &mut kinds, + rows, + kinds, "update channel", cfg.channel().into(), Tone::Normal, SettingKind::Channel, ); +} + +#[cfg(feature = "native")] +fn fill_agent_settings( + rows: &mut Vec, + kinds: &mut Vec>, + path: &PathBuf, + env: &BTreeMap, + profile: Option<&Profile>, + id: &'static str, + label: &str, +) { + use crate::tui::{SettingRow, Tone}; + fn section(rows: &mut Vec, kinds: &mut Vec>, name: &str) { + if !rows.is_empty() { + rows.push(SettingRow::Gap); + kinds.push(None); + } + rows.push(SettingRow::Section(name.into())); + kinds.push(None); + } + fn entry( + rows: &mut Vec, + kinds: &mut Vec>, + label: &str, + value: String, + tone: Tone, + kind: SettingKind, + ) { + rows.push(SettingRow::Entry { + label: label.into(), + value, + tone, + }); + kinds.push(Some(kind)); + } + + let cfg = load_config_if_present(path); + let tool = resolve_tool(cfg.as_ref(), id).unwrap_or_else(|_| { + crate::spawn::resolve_tool(None, id).expect("known agent") + }); + let present = agent_available(id, &tool.command, env); + + section(rows, kinds, label); + entry( + rows, + kinds, + "status", + if present { + "on PATH".into() + } else { + "not installed".into() + }, + if present { Tone::Good } else { Tone::Warn }, + SettingKind::Install(id), + ); + entry( + rows, + kinds, + "command", + tool.command.clone(), + Tone::Normal, + SettingKind::ToolCommand(id), + ); + let hint = crate::install::tool_hint(id); + entry( + rows, + kinds, + "install", + if present { + "reinstall / update".into() + } else { + hint.map(|h| h.install.to_string()) + .unwrap_or_else(|| "install".into()) + }, + if present { Tone::Muted } else { Tone::Warn }, + SettingKind::Install(id), + ); + + section(rows, kinds, "Mapping"); + entry( + rows, + kinds, + "base URL env", + tool.base_url_env.clone(), + Tone::Muted, + SettingKind::Mapping, + ); + entry( + rows, + kinds, + "auth env", + tool.auth_env.clone(), + Tone::Muted, + SettingKind::Mapping, + ); + entry( + rows, + kinds, + "model env", + tool.model_env.clone().unwrap_or_else(|| "(none)".into()), + Tone::Muted, + SettingKind::Mapping, + ); + entry( + rows, + kinds, + "URL suffix", + if tool.base_suffix.is_empty() { + "(none)".into() + } else { + tool.base_suffix.clone() + }, + Tone::Muted, + SettingKind::Mapping, + ); - (SettingsState::new("Config", header, rows), kinds) + if id == "claude" { + section(rows, kinds, "Claude aliases"); + for (slot_label, slot) in [ + ("haiku", "haiku"), + ("sonnet", "sonnet"), + ("opus", "opus"), + ("fable", "fable"), + ] { + let pinned = match slot { + "haiku" => nonempty_slot(&profile.and_then(|p| p.claude_haiku.clone())), + "sonnet" => nonempty_slot(&profile.and_then(|p| p.claude_sonnet.clone())), + "opus" => nonempty_slot(&profile.and_then(|p| p.claude_opus.clone())), + _ => nonempty_slot(&profile.and_then(|p| p.claude_fable.clone())), + }; + let value = match &pinned { + Some(mid) => display_model_id(mid).to_string(), + None => format!("{} · default", slot_current_opt(profile, slot)), + }; + let tone = if pinned.is_some() { + Tone::Model + } else { + Tone::Muted + }; + let slot_static: &'static str = match slot { + "haiku" => "haiku", + "sonnet" => "sonnet", + "opus" => "opus", + _ => "fable", + }; + entry( + rows, + kinds, + slot_label, + value, + tone, + SettingKind::Model(slot_static), + ); + } + entry( + rows, + kinds, + "gateway discovery", + if tool.enable_gateway_model_discovery { + "on".into() + } else { + "off".into() + }, + if tool.enable_gateway_model_discovery { + Tone::Good + } else { + Tone::Muted + }, + SettingKind::GatewayDiscovery, + ); + } } #[cfg_attr(not(feature = "native"), allow(dead_code))] @@ -1342,8 +1561,10 @@ fn config_settings_loop( path: &PathBuf, ) -> Result { let mut cache = CreditsCache::fresh(); + let mut tab = 0usize; + let n_tabs = settings_tab_names().len().max(1); loop { - let (state, kinds) = config_settings_frame(parsed, env, path, true, &mut cache); + let (state, kinds) = config_settings_frame(parsed, env, path, true, &mut cache, tab); let Some(outcome) = tui_settings_select(state)? else { return Ok(0); }; @@ -1358,6 +1579,14 @@ fn config_settings_loop( .copied() .flatten() .map(|kind| config_reset_row(path, kind)), + crate::tui::SettingsOutcome::NextTab => { + tab = (tab + 1) % n_tabs; + None + } + crate::tui::SettingsOutcome::PrevTab => { + tab = (tab + n_tabs - 1) % n_tabs; + None + } crate::tui::SettingsOutcome::Close | crate::tui::SettingsOutcome::Stay => None, }; if let Some(Err(err)) = result { @@ -1400,15 +1629,15 @@ fn config_edit_row( } SettingKind::Agent => { let last = launcher_last_tool(path, parsed, env); - let labels: Vec = LAUNCH_AGENTS + let labels: Vec = KNOWN_AGENTS .iter() .map(|(id, label)| format!("{id} — {label}")) .collect(); - let current = LAUNCH_AGENTS + let current = KNOWN_AGENTS .iter() .position(|(id, _)| *id == last.as_str()); let idx = term::pick("Coding agent", &labels, current)?; - let (tool, label) = LAUNCH_AGENTS[idx]; + let (tool, label) = KNOWN_AGENTS[idx]; let mut cfg = load_config_if_present(path).unwrap_or_default(); if let Some(p) = cfg.profiles.get_mut(&cfg.active_profile) { p.default_tool = Some(tool.into()); @@ -1436,6 +1665,52 @@ fn config_edit_row( ); Ok(0) } + SettingKind::Install(id) => { + let command = tool_command_for(path, id); + match ensure_tool_installed(id, &command, true) { + Ok(resolved) => { + persist_tool_command(path, id, &resolved)?; + println!( + "{} {} {}", + term::ok("Installed"), + id, + term::dim(&resolved) + ); + Ok(0) + } + Err(err) => Err(err), + } + } + SettingKind::ToolCommand(id) => { + let current = tool_command_for(path, id); + let next = term::prompt(&format!("Command path for {id} (Enter keeps {current}): "))?; + let next = next.trim(); + if next.is_empty() { + return Ok(0); + } + let mut cfg = load_config_if_present(path).unwrap_or_default(); + let mut tool = resolve_tool(Some(&cfg), id)?; + tool.command = next.to_string(); + cfg.tools.insert(id.to_string(), tool); + write_config(&cfg, path)?; + println!("{} {id} command {next}", term::ok("Saved")); + Ok(0) + } + SettingKind::GatewayDiscovery => { + let mut cfg = load_config_if_present(path).unwrap_or_default(); + let mut tool = resolve_tool(Some(&cfg), "claude")?; + tool.enable_gateway_model_discovery = !tool.enable_gateway_model_discovery; + let on = tool.enable_gateway_model_discovery; + cfg.tools.insert("claude".into(), tool); + write_config(&cfg, path)?; + println!( + "{} gateway discovery {}", + term::ok("Saved"), + if on { "on" } else { "off" } + ); + Ok(0) + } + SettingKind::Mapping => Ok(0), SettingKind::Channel => { let choices = ["stable", "beta"]; let current = choices.iter().position(|c| *c == cfg_channel(path)); @@ -1544,7 +1819,20 @@ fn config_reset_row(path: &std::path::Path, kind: SettingKind) -> Result Ok(0), + SettingKind::Account + | SettingKind::ApiKey + | SettingKind::Install(_) + | SettingKind::ToolCommand(_) + | SettingKind::Mapping => Ok(0), + SettingKind::GatewayDiscovery => { + let mut cfg = load_config_if_present(path).unwrap_or_default(); + if let Some(t) = cfg.tools.get_mut("claude") { + t.enable_gateway_model_discovery = true; + } + write_config(&cfg, path)?; + println!("{} gateway discovery reset to on", term::ok("Saved")); + Ok(0) + } SettingKind::Model(_) | SettingKind::Agent => { let name = cfg.active_profile.clone(); let Some(p) = cfg.profiles.get_mut(&name) else { @@ -2303,16 +2591,14 @@ fn launcher_palette( use crate::tui::PaletteEntry; let mut entries = Vec::new(); if signed_in { - entries.push(PaletteEntry::new(last.clone(), model_line, "launch", format!("Launch {last}"))); - for (id, label) in LAUNCH_AGENTS.iter().filter(|(id, _)| *id != last.as_str()) { - entries.push(PaletteEntry::new(*id, *label, "launch", format!("Launch {id}"))); - } + push_launch_entries(&mut entries, path, env, &last, model_line); } else { entries.push(PaletteEntry::new("login", "sign in / add key", "account", "Login / sign in")); } entries.push(PaletteEntry::new("model…", "switch session default", "configure", "Switch model")); entries.push(PaletteEntry::new("account…", "switch profile", "configure", "Switch account")); entries.push(PaletteEntry::new("key…", "switch API key", "configure", "Switch key")); + entries.push(PaletteEntry::new("install…", "install a coding agent", "configure", "Install agent")); entries.push(PaletteEntry::new("config…", "accounts · keys · agent", "configure", "Config")); entries.push(PaletteEntry::new("quit", "esc works too", "configure", "Quit")); (header, entries) @@ -2347,9 +2633,29 @@ fn launcher_palette( ]; let mut entries = Vec::new(); if signed_in { - entries.push(InlineEntry::new(last.clone(), model_line, "launch", format!("Launch {last}"))); - for (id, label) in LAUNCH_AGENTS.iter().filter(|(id, _)| *id != last.as_str()) { - entries.push(InlineEntry::new(*id, *label, "launch", format!("Launch {id}"))); + let present = available_agents(env, |id| tool_command_for(path, id)); + if present.is_empty() { + entries.push(InlineEntry::new( + "install an agent…", + "none detected on PATH", + "launch", + "Install agent", + )); + } else { + let last = if present.iter().any(|(id, _)| *id == last) { + last.clone() + } else { + present[0].0.to_string() + }; + entries.push(InlineEntry::new( + last.clone(), + model_line, + "launch", + format!("Launch {last}"), + )); + for (id, label) in present.into_iter().filter(|(id, _)| *id != last.as_str()) { + entries.push(InlineEntry::new(id, label, "launch", format!("Launch {id}"))); + } } } else { entries.push(InlineEntry::new("login", "sign in / add key", "account", "Login / sign in")); @@ -2357,6 +2663,7 @@ fn launcher_palette( entries.push(InlineEntry::new("model…", "switch session default", "configure", "Switch model")); entries.push(InlineEntry::new("account…", "switch profile", "configure", "Switch account")); entries.push(InlineEntry::new("key…", "switch API key", "configure", "Switch key")); + entries.push(InlineEntry::new("install…", "install a coding agent", "configure", "Install agent")); entries.push(InlineEntry::new("config…", "accounts · keys · agent", "configure", "Config")); entries.push(InlineEntry::new("quit", "esc works too", "configure", "Quit")); (header, entries) @@ -2430,14 +2737,77 @@ enum LauncherNext { Exit(i32), } -const LAUNCH_AGENTS: &[(&str, &str)] = &[ - ("claude", "Claude Code"), - ("codex", "Codex"), - ("grok", "Grok Build"), - ("opencode", "OpenCode"), - ("pi", "Pi"), - ("pool", "Poolside"), -]; +#[cfg(feature = "native")] +fn push_launch_entries( + entries: &mut Vec, + path: &PathBuf, + env: &BTreeMap, + 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…", + "none detected on PATH", + "launch", + "Install agent", + )); + return; + } + let last = if present.iter().any(|(id, _)| *id == last) { + last.to_string() + } else { + present[0].0.to_string() + }; + entries.push(PaletteEntry::new( + last.clone(), + model_line, + "launch", + format!("Launch {last}"), + )); + for (id, label) in present.into_iter().filter(|(id, _)| *id != last.as_str()) { + entries.push(PaletteEntry::new(id, label, "launch", format!("Launch {id}"))); + } +} + +fn install_agent_dialog(path: &PathBuf, env: &BTreeMap) -> Result { + let missing = missing_agents(env, |id| tool_command_for(path, id)); + if missing.is_empty() { + println!("{}", term::dim("Every known coding agent is already on PATH.")); + return Ok(0); + } + let labels: Vec = missing + .iter() + .map(|(id, label)| { + let cmd = crate::install::tool_hint(id) + .map(|h| h.install.to_string()) + .unwrap_or_default(); + format!("{id} — {label} {cmd}") + }) + .collect(); + let idx = pick_list( + "Install coding agent", + &["none of these are on PATH · enter to install".into()], + &labels, + Some(0), + )?; + let (id, _) = missing[idx]; + let command = tool_command_for(path, id); + let resolved = ensure_tool_installed(id, &command, true)?; + persist_tool_command(path, id, &resolved)?; + println!("{} {id} {}", term::ok("Installed"), term::dim(&resolved)); + Ok(0) +} + +fn persist_tool_command(path: &PathBuf, id: &str, command: &str) -> Result<(), String> { + let mut cfg = load_config_if_present(path).unwrap_or_default(); + let mut tool = resolve_tool(Some(&cfg), id)?; + tool.command = command.to_string(); + cfg.tools.insert(id.to_string(), tool); + write_config(&cfg, path) +} fn launcher_last_tool( path: &PathBuf, @@ -2451,8 +2821,10 @@ fn launcher_last_tool( .or_else(|| profile.and_then(|p| p.default_tool.clone())) .or_else(|| get_string_flag(&parsed.flags, "tool")) .unwrap_or_else(|| { - let _ = env; - "claude".into() + available_agents(env, |id| tool_command_for(path, id)) + .first() + .map(|(id, _)| (*id).to_string()) + .unwrap_or_else(|| "claude".into()) }) } @@ -2558,6 +2930,14 @@ fn launcher_dispatch( } return Ok(LauncherNext::Continue); } + if action == "Install agent" { + match install_agent_dialog(path, env) { + Ok(_) => {} + Err(err) if err == "Cancelled." => {} + Err(err) => eprintln!("{}", term::err(&err)), + } + return Ok(LauncherNext::Continue); + } if action == "Switch model" { if !launcher_signed_in(path, parsed, env) { eprintln!("{}", term::err("Sign in first (Login / sign in).")); @@ -2634,13 +3014,20 @@ fn launch_agent_picker( } } let last = launcher_last_tool(path, parsed, env); - let labels: Vec = LAUNCH_AGENTS + let present = available_agents(env, |id| tool_command_for(path, id)); + if present.is_empty() { + if let Err(err) = install_agent_dialog(path, env) { + if err != "Cancelled." { + eprintln!("{}", term::err(&err)); + } + } + return Ok(LauncherNext::Continue); + } + let labels: Vec = present .iter() .map(|(id, label)| format!("{id} — {label}")) .collect(); - let current = LAUNCH_AGENTS - .iter() - .position(|(id, _)| *id == last.as_str()); + let current = present.iter().position(|(id, _)| *id == last.as_str()); let idx = match term::pick("Launch coding agent", &labels, current) { Ok(i) => i, Err(err) if err == "Cancelled." => return Ok(LauncherNext::Continue), @@ -2649,7 +3036,7 @@ fn launch_agent_picker( return Ok(LauncherNext::Continue); } }; - let tool = LAUNCH_AGENTS[idx].0; + let tool = present[idx].0; Ok(LauncherNext::Exit(run_launch(tool, parsed, env)?)) } diff --git a/src/install.rs b/src/install.rs index 3b1c333..a00054b 100644 --- a/src/install.rs +++ b/src/install.rs @@ -14,6 +14,77 @@ pub struct ToolHint { pub env: &'static str, } +/// Agents the launcher/settings know about, in display order. +pub const KNOWN_AGENTS: &[(&str, &str)] = &[ + ("claude", "Claude Code"), + ("codex", "Codex"), + ("grok", "Grok Build"), + ("opencode", "OpenCode"), + ("pi", "Pi"), + ("pool", "Poolside"), +]; + +/// `ANYR_AGENTS` overrides PATH detection (tests / dump). Unset = probe PATH. +/// Empty / `-` / `none` = nothing installed. Comma list = those ids. +pub fn agents_override(env: &std::collections::BTreeMap) -> Option> { + let raw = env.get("ANYR_AGENTS")?; + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed == "-" || trimmed.eq_ignore_ascii_case("none") { + return Some(Vec::new()); + } + Some( + trimmed + .split(',') + .map(|s| crate::spawn::canonical_tool(s.trim()).to_string()) + .filter(|s| !s.is_empty()) + .collect(), + ) +} + +/// Whether this agent can be launched: override list, `ANYROUTER_*_PATH`, or PATH. +pub fn agent_available( + id: &str, + command: &str, + env: &std::collections::BTreeMap, +) -> bool { + if let Some(list) = agents_override(env) { + let id = crate::spawn::canonical_tool(id); + return list.iter().any(|s| s == id); + } + if let Some(hint) = tool_hint(id) { + if env + .get(hint.env) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false) + { + return true; + } + } + resolve_executable(command).is_some() +} + +pub fn available_agents( + env: &std::collections::BTreeMap, + command_for: impl Fn(&str) -> String, +) -> Vec<(&'static str, &'static str)> { + KNOWN_AGENTS + .iter() + .copied() + .filter(|(id, _)| agent_available(id, &command_for(id), env)) + .collect() +} + +pub fn missing_agents( + env: &std::collections::BTreeMap, + command_for: impl Fn(&str) -> String, +) -> Vec<(&'static str, &'static str)> { + KNOWN_AGENTS + .iter() + .copied() + .filter(|(id, _)| !agent_available(id, &command_for(id), env)) + .collect() +} + pub fn tool_hint(tool: &str) -> Option { Some(match canonical_tool(tool) { "claude" => ToolHint { @@ -176,4 +247,27 @@ mod tests { Some("./bin/claude") ); } + + #[test] + fn agents_override_empty_means_none() { + let mut env = std::collections::BTreeMap::new(); + assert!(agents_override(&env).is_none()); + env.insert("ANYR_AGENTS".into(), "".into()); + assert_eq!(agents_override(&env), Some(vec![])); + env.insert("ANYR_AGENTS".into(), "none".into()); + assert_eq!(agents_override(&env), Some(vec![])); + env.insert("ANYR_AGENTS".into(), "claude, grok".into()); + assert_eq!( + agents_override(&env), + Some(vec!["claude".into(), "grok".into()]) + ); + } + + #[test] + fn agent_available_honors_override() { + let mut env = std::collections::BTreeMap::new(); + env.insert("ANYR_AGENTS".into(), "codex".into()); + assert!(!agent_available("claude", "claude", &env)); + assert!(agent_available("codex", "codex", &env)); + } } diff --git a/src/tui/keys.rs b/src/tui/keys.rs index 83ba3dc..aaf65e0 100644 --- a/src/tui/keys.rs +++ b/src/tui/keys.rs @@ -14,6 +14,10 @@ pub enum Action { Enter, Up, Down, + /// Settings: next coding-agent tab. + NextTab, + /// Settings: previous coding-agent tab. + PrevTab, /// Reset the focused settings row to its default (`x`). Unset, Backspace, @@ -26,6 +30,7 @@ pub enum Action { pub struct KeyEvent { pub code: KeyCode, pub ctrl: bool, + pub shift: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -54,12 +59,18 @@ pub fn map_key(surface: Surface, key: KeyEvent) -> Action { KeyCode::Esc => Action::Esc, KeyCode::Up => Action::Up, KeyCode::Down => Action::Down, - KeyCode::Tab => Action::Down, + KeyCode::Tab => match (surface, key.shift) { + (Surface::Settings, false) => Action::NextTab, + (Surface::Settings, true) => Action::PrevTab, + _ => Action::Down, + }, KeyCode::Backspace | KeyCode::Delete => Action::Backspace, KeyCode::Char(c) => match (surface, c) { (Surface::Launcher, 'q' | 'x' | 'Q' | 'X') => Action::Quit, (Surface::Settings, 'q') => Action::Quit, (Surface::Settings, 'x' | 'X') => Action::Unset, + (Surface::Settings, '[') => Action::PrevTab, + (Surface::Settings, ']') => Action::NextTab, // The palette is type-first: every printable char — including // q / j / k — goes into the query. (Surface::Palette, _) => Action::Char(c), @@ -73,7 +84,7 @@ pub fn map_key(surface: Surface, key: KeyEvent) -> Action { pub fn hint_line(surface: Surface) -> &'static str { match surface { Surface::Launcher => "↑↓ move ↵ select q/esc quit", - Surface::Settings => "↑↓ move ↵ edit x reset q/esc close", + Surface::Settings => "tab agent ↑↓ move ↵ edit x reset q close", Surface::Picker => "type to search ↑↓ move ↵ select esc cancel", Surface::Palette => "type to filter ↑↓ move ↵ run esc quit", } @@ -90,6 +101,7 @@ mod tests { KeyEvent { code: KeyCode::Char('q'), ctrl: false, + shift: false, }, ); assert_eq!(a, Action::Quit); @@ -102,6 +114,7 @@ mod tests { KeyEvent { code: KeyCode::Char('q'), ctrl: false, + shift: false, }, ); assert_eq!(a, Action::Char('q')); @@ -114,6 +127,7 @@ mod tests { KeyEvent { code: KeyCode::Char('c'), ctrl: true, + shift: false, }, ); assert_eq!(a, Action::Quit); @@ -126,6 +140,7 @@ mod tests { KeyEvent { code: KeyCode::Char('q'), ctrl: false, + shift: false, }, ); assert_eq!(a, Action::Char('q')); @@ -138,6 +153,7 @@ mod tests { KeyEvent { code: KeyCode::Char('x'), ctrl: false, + shift: false, }, ); assert_eq!(x, Action::Unset); @@ -146,8 +162,40 @@ mod tests { KeyEvent { code: KeyCode::Char('q'), ctrl: false, + shift: false, }, ); assert_eq!(q, Action::Quit); } + + #[test] + fn settings_tab_cycles_agents() { + let next = map_key( + Surface::Settings, + KeyEvent { + code: KeyCode::Tab, + ctrl: false, + shift: false, + }, + ); + assert_eq!(next, Action::NextTab); + let prev = map_key( + Surface::Settings, + KeyEvent { + code: KeyCode::Tab, + ctrl: false, + shift: true, + }, + ); + assert_eq!(prev, Action::PrevTab); + let brack = map_key( + Surface::Settings, + KeyEvent { + code: KeyCode::Char(']'), + ctrl: false, + shift: false, + }, + ); + assert_eq!(brack, Action::NextTab); + } } diff --git a/src/tui/live.rs b/src/tui/live.rs index 84bbab2..379051a 100644 --- a/src/tui/live.rs +++ b/src/tui/live.rs @@ -41,6 +41,7 @@ fn translate_key(ev: crossterm::event::KeyEvent) -> Option { Some(KeyEvent { code, ctrl: ev.modifiers.contains(KeyModifiers::CONTROL), + shift: ev.modifiers.contains(KeyModifiers::SHIFT), }) } diff --git a/src/tui/state.rs b/src/tui/state.rs index 61a281c..44eaf2d 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -110,7 +110,7 @@ impl PickerState { self.cursor = 0; Outcome::Continue } - Action::Unset => Outcome::Continue, + Action::Unset | Action::NextTab | Action::PrevTab => Outcome::Continue, } } @@ -170,7 +170,9 @@ impl MenuState { Outcome::Continue } Action::Backspace => Outcome::Continue, - Action::Char(_) | Action::Unset => Outcome::Continue, + Action::Char(_) | Action::Unset | Action::NextTab | Action::PrevTab => { + Outcome::Continue + } } } @@ -221,6 +223,8 @@ pub enum SettingsOutcome { Edit(usize), /// Reset the entry at the given row index to its default (`x`). Reset(usize), + NextTab, + PrevTab, Close, } @@ -231,6 +235,8 @@ pub struct SettingsState { pub rows: Vec, /// Cursor index into `rows`; always points at an Entry. pub cursor: usize, + pub tabs: Vec, + pub tab: usize, } impl SettingsState { @@ -240,11 +246,23 @@ impl SettingsState { header, rows, cursor: 0, + tabs: Vec::new(), + tab: 0, }; state.cursor = state.rows.iter().position(|r| r.selectable()).unwrap_or(0); state } + pub fn with_tabs(mut self, tabs: Vec, tab: usize) -> Self { + self.tab = if tabs.is_empty() { + 0 + } else { + tab.min(tabs.len() - 1) + }; + self.tabs = tabs; + self + } + /// Indices of selectable (Entry) rows. pub fn entries(&self) -> Vec { self.rows @@ -260,6 +278,8 @@ impl SettingsState { if entries.is_empty() { return match action { Action::Quit | Action::Esc => SettingsOutcome::Close, + Action::NextTab => SettingsOutcome::NextTab, + Action::PrevTab => SettingsOutcome::PrevTab, _ => SettingsOutcome::Stay, }; } @@ -268,6 +288,8 @@ impl SettingsState { Action::Quit | Action::Esc => SettingsOutcome::Close, Action::Enter => SettingsOutcome::Edit(self.cursor), Action::Unset => SettingsOutcome::Reset(self.cursor), + Action::NextTab => SettingsOutcome::NextTab, + Action::PrevTab => SettingsOutcome::PrevTab, Action::Up => { let prev = if pos == 0 { entries.len() - 1 } else { pos - 1 }; self.cursor = entries[prev]; @@ -413,7 +435,7 @@ impl PaletteState { self.cursor = 0; Outcome::Continue } - Action::Unset => Outcome::Continue, + Action::Unset | Action::NextTab | Action::PrevTab => Outcome::Continue, } } diff --git a/src/tui/view.rs b/src/tui/view.rs index 5d6d375..25eaf94 100644 --- a/src/tui/view.rs +++ b/src/tui/view.rs @@ -317,11 +317,12 @@ pub fn render_settings(frame: &mut Frame, state: &SettingsState) { frame.render_widget(block, dialog); let status_h = state.header.len().max(1) as u16; + let tab_h = if state.tabs.len() > 1 { 2 } else { 0 }; let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(status_h), - Constraint::Length(1), + Constraint::Length(tab_h), Constraint::Length(1), Constraint::Length(1), Constraint::Min(state.rows.len().max(1) as u16), @@ -331,6 +332,9 @@ pub fn render_settings(frame: &mut Frame, state: &SettingsState) { .split(inner); render_status_lines(frame, chunks[0], &state.header); + if tab_h > 0 { + frame.render_widget(Paragraph::new(tab_bar_line(state)), chunks[1]); + } let rule = Paragraph::new(Span::styled( "─".repeat(chunks[2].width as usize), @@ -351,11 +355,27 @@ pub fn render_settings(frame: &mut Frame, state: &SettingsState) { frame.render_widget(footer, chunks[6]); } +fn tab_bar_line(state: &SettingsState) -> Line<'static> { + let mut spans: Vec = Vec::new(); + for (i, name) in state.tabs.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" ", theme::muted())); + } + if i == state.tab { + spans.push(Span::styled(format!("[{name}]"), theme::brand())); + } else { + spans.push(Span::styled(name.clone(), theme::muted())); + } + } + Line::from(spans) +} + fn settings_dialog_height(state: &SettingsState) -> u16 { - // borders(2) + inset(2) + status + pads(3) + rule(1) + rows + hint(1) + // borders(2) + inset(2) + status + pads(3) + rule(1) + rows + hint(1) + tabs let status = state.header.len().max(1) as u16; let rows = state.rows.len().max(1) as u16; - 2 + 2 + status + 3 + 1 + rows + 1 + let tabs = if state.tabs.len() > 1 { 2 } else { 0 }; + 2 + 2 + status + tabs + 3 + 1 + rows + 1 } fn tone_style(tone: Tone) -> Style { @@ -684,6 +704,23 @@ pub fn plain_settings_lines(state: &SettingsState, cols: usize) -> Vec { } } lines.push(format!("{pad_s}│{}│", pad_content("", content_w))); + if state.tabs.len() > 1 { + let bar = state + .tabs + .iter() + .enumerate() + .map(|(i, name)| { + if i == state.tab { + format!("[{name}]") + } else { + name.clone() + } + }) + .collect::>() + .join(" "); + lines.push(format!("{pad_s}│{}│", pad_content(&bar, content_w))); + lines.push(format!("{pad_s}│{}│", pad_content("", content_w))); + } lines.push(format!("{pad_s}├{}┤", "─".repeat(content_w))); lines.push(format!("{pad_s}│{}│", pad_content("", content_w))); @@ -828,10 +865,15 @@ mod tests { tone: Tone::Model, }, ], + ) + .with_tabs( + vec!["general".into(), "claude".into(), "codex".into()], + 0, ); let frame = plain_settings_frame(&state, 80); assert!(!frame.contains('\u{1b}'), "must be ANSI-free: {frame}"); assert!(frame.contains("▲ Config"), "{frame}"); + assert!(frame.contains("[general]"), "{frame}"); assert!(frame.contains("ACCOUNT"), "{frame}"); assert!(frame.contains("MODEL"), "{frame}"); assert!(frame.contains("◆ account"), "{frame}"); diff --git a/tests/cli.rs b/tests/cli.rs index 2eb70dc..7b632b5 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1168,6 +1168,7 @@ profiles: .unwrap(); let out = anyr() .args(["menu", "--dump-tui", "--config", path.to_str().unwrap()]) + .env("ANYR_AGENTS", "claude,codex") .output() .expect("menu dump"); let stdout = String::from_utf8_lossy(&out.stdout); @@ -1186,6 +1187,7 @@ profiles: assert!(stdout.contains("account…"), "{stdout}"); assert!(stdout.contains("key…"), "{stdout}"); assert!(stdout.contains("model…"), "{stdout}"); + assert!(stdout.contains("install…"), "{stdout}"); assert!(stdout.contains("quit"), "{stdout}"); assert!(stdout.contains('❯'), "palette must show the input line: {stdout}"); assert!( @@ -1199,6 +1201,36 @@ profiles: let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn menu_dump_tui_empty_agents_shows_install() { + let dir = std::env::temp_dir().join(format!("anyr-cli-menu-empty-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.yaml"); + std::fs::write( + &path, + "\ +active_profile: default +profiles: + default: + api_key: sk-ar-v1-empty-agents-secret-abcdef + default_model: auto +", + ) + .unwrap(); + let out = anyr() + .args(["menu", "--dump-tui", "--config", path.to_str().unwrap()]) + .env("ANYR_AGENTS", "none") + .output() + .expect("menu dump empty"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code().unwrap_or(1), 0, "{stdout}{stderr}"); + assert!(stdout.contains("install an agent…"), "{stdout}"); + assert!(stdout.contains("none detected"), "{stdout}"); + assert!(!stdout.contains("◆ claude"), "{stdout}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn config_dump_tui_prints_plain_frame() { let dir = std::env::temp_dir().join(format!("anyr-cli-config-dump-{}", std::process::id())); @@ -1251,13 +1283,12 @@ profiles: "account", "api key", "default", - "haiku", - "sonnet", - "opus", - "fable", "coding agent", "auto-update", "update channel", + "[general]", + "claude", + "codex", ] { assert!(stdout.contains(row), "missing row \"{row}\" in:\n{stdout}"); }