use std::path::{Path, PathBuf};
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Paragraph};
use ratatui_bubbletea_theme::BubbleTheme;
use toml_edit::{DocumentMut, value};
use crate::config::Config;
use crate::error::{AppError, Result};
use crate::theme::Theme;
use crate::tui::style::bubble_theme;
use crate::vendor::VendorId;
pub struct KeyVendor {
pub id: VendorId,
pub label: &'static str,
pub env: &'static str,
pub section: &'static str,
pub note: &'static str,
}
pub const KEY_VENDORS: &[KeyVendor] = &[
KeyVendor {
id: VendorId::AnthropicApi,
label: "Anthropic API",
env: "ANTHROPIC_ADMIN_KEY",
section: "anthropic_api",
note: "admin key — monthly spend",
},
KeyVendor {
id: VendorId::Zai,
label: "Z.AI",
env: "ZAI_API_KEY",
section: "zai",
note: "",
},
KeyVendor {
id: VendorId::Openrouter,
label: "OpenRouter",
env: "OPENROUTER_API_KEY",
section: "openrouter",
note: "",
},
KeyVendor {
id: VendorId::Deepseek,
label: "DeepSeek",
env: "DEEPSEEK_API_KEY",
section: "deepseek",
note: "",
},
KeyVendor {
id: VendorId::Kimi,
label: "Kimi",
env: "KIMI_API_KEY",
section: "kimi",
note: "coding-plan usage",
},
KeyVendor {
id: VendorId::Kilo,
label: "Kilo",
env: "KILO_API_KEY",
section: "kilo",
note: "",
},
KeyVendor {
id: VendorId::Novita,
label: "Novita",
env: "NOVITA_API_KEY",
section: "novita",
note: "",
},
KeyVendor {
id: VendorId::Moonshot,
label: "Moonshot",
env: "MOONSHOT_API_KEY",
section: "moonshot",
note: "account balance",
},
KeyVendor {
id: VendorId::Grok,
label: "Grok",
env: "XAI_MANAGEMENT_KEY",
section: "grok",
note: "management key, not the inference key",
},
];
fn config_inline_key<'a>(cfg: &'a Config, section: &str) -> Option<&'a str> {
match section {
"anthropic_api" => cfg.anthropic_api.api_key.as_deref(),
"zai" => cfg.zai.api_key.as_deref(),
"openrouter" => cfg.openrouter.api_key.as_deref(),
"deepseek" => cfg.deepseek.api_key.as_deref(),
"kimi" => cfg.kimi.api_key.as_deref(),
"kilo" => cfg.kilo.api_key.as_deref(),
"novita" => cfg.novita.api_key.as_deref(),
"moonshot" => cfg.moonshot.api_key.as_deref(),
"grok" => cfg.grok.api_key.as_deref(),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Primary,
Key(usize),
Save,
}
impl Focus {
pub fn next(self) -> Self {
match self {
Focus::Primary => Focus::Key(0),
Focus::Key(i) if i + 1 < KEY_VENDORS.len() => Focus::Key(i + 1),
Focus::Key(_) => Focus::Save,
Focus::Save => Focus::Primary,
}
}
pub fn prev(self) -> Self {
match self {
Focus::Primary => Focus::Save,
Focus::Key(0) => Focus::Primary,
Focus::Key(i) => Focus::Key(i - 1),
Focus::Save => Focus::Key(KEY_VENDORS.len() - 1),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct KeyInput {
pub buf: String,
pub cursor: usize,
pub revealed: bool,
pub dirty: bool,
}
impl KeyInput {
pub fn from_config(initial: Option<&str>) -> Self {
let buf = initial.unwrap_or("").to_string();
let cursor = buf.chars().count();
Self {
buf,
cursor,
revealed: false,
dirty: false,
}
}
pub fn insert_char(&mut self, c: char) {
let byte_idx = self.char_to_byte(self.cursor);
self.buf.insert(byte_idx, c);
self.cursor += 1;
self.dirty = true;
}
pub fn backspace(&mut self) {
if self.cursor == 0 {
return;
}
let prev_byte = self.char_to_byte(self.cursor - 1);
let cur_byte = self.char_to_byte(self.cursor);
self.buf.replace_range(prev_byte..cur_byte, "");
self.cursor -= 1;
self.dirty = true;
}
pub fn delete(&mut self) {
let n = self.buf.chars().count();
if self.cursor >= n {
return;
}
let cur_byte = self.char_to_byte(self.cursor);
let next_byte = self.char_to_byte(self.cursor + 1);
self.buf.replace_range(cur_byte..next_byte, "");
self.dirty = true;
}
pub fn move_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn move_right(&mut self) {
if self.cursor < self.buf.chars().count() {
self.cursor += 1;
}
}
pub fn move_home(&mut self) {
self.cursor = 0;
}
pub fn move_end(&mut self) {
self.cursor = self.buf.chars().count();
}
pub fn toggle_reveal(&mut self) {
self.revealed = !self.revealed;
}
pub fn display(&self) -> String {
if self.revealed {
self.buf.clone()
} else {
"•".repeat(self.buf.chars().count())
}
}
fn char_to_byte(&self, char_idx: usize) -> usize {
self.buf
.char_indices()
.map(|(b, _)| b)
.chain(std::iter::once(self.buf.len()))
.nth(char_idx)
.unwrap_or(self.buf.len())
}
}
#[derive(Debug, Clone)]
pub struct SettingsState {
pub focus: Focus,
pub primary_choices: Vec<VendorId>,
pub primary: VendorId,
pub keys: Vec<KeyInput>,
pub status: String,
}
impl SettingsState {
pub fn from_config(cfg: &Config) -> Self {
let keys = KEY_VENDORS
.iter()
.map(|kv| KeyInput::from_config(config_inline_key(cfg, kv.section)))
.collect();
let primary_choices = cfg.enabled_vendors();
let primary = cfg
.ui
.primary
.filter(|vendor| primary_choices.contains(vendor))
.or_else(|| primary_choices.first().copied())
.unwrap_or_else(|| cfg.ui.primary.unwrap_or(VendorId::Anthropic));
Self {
focus: Focus::Primary,
primary_choices,
primary,
keys,
status: String::new(),
}
}
fn focused_key_mut(&mut self) -> Option<&mut KeyInput> {
match self.focus {
Focus::Key(i) => self.keys.get_mut(i),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Continue,
Close,
SavedAndClose,
Quit,
}
#[cfg(unix)]
const PERMS_NOTE: &str = " (chmod 600)";
#[cfg(not(unix))]
const PERMS_NOTE: &str = "";
fn saved_status() -> String {
format!(
"saved to {}{}",
crate::config::config_path_hint(),
PERMS_NOTE
)
}
pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
if matches!(code, KeyCode::Esc) {
return Action::Close;
}
if matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL) {
return Action::Quit;
}
if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
return try_save(state);
}
if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
if let Some(input) = state.focused_key_mut() {
input.toggle_reveal();
}
return Action::Continue;
}
match code {
KeyCode::Tab | KeyCode::Down => {
state.focus = state.focus.next();
return Action::Continue;
}
KeyCode::BackTab | KeyCode::Up => {
state.focus = state.focus.prev();
return Action::Continue;
}
_ => {}
}
if matches!(code, KeyCode::Char(_))
&& mods.intersects(
KeyModifiers::CONTROL
| KeyModifiers::ALT
| KeyModifiers::SUPER
| KeyModifiers::HYPER
| KeyModifiers::META,
)
{
return Action::Continue;
}
match state.focus {
Focus::Primary => handle_primary(state, code),
Focus::Key(i) => {
if let Some(input) = state.keys.get_mut(i) {
handle_input(input, code);
}
}
Focus::Save => {
if matches!(code, KeyCode::Enter) {
return try_save(state);
}
}
}
Action::Continue
}
fn try_save(state: &mut SettingsState) -> Action {
match save_to_config_default(state) {
Ok(()) => {
state.status = saved_status();
Action::SavedAndClose
}
Err(e) => {
state.status = format!("save failed: {e}");
Action::Continue
}
}
}
fn handle_primary(state: &mut SettingsState, code: KeyCode) {
let choices = &state.primary_choices;
let Some(idx) = choices.iter().position(|v| *v == state.primary) else {
return;
};
let step = match code {
KeyCode::Left => -1,
KeyCode::Right | KeyCode::Char(' ') => 1,
_ => return,
};
state.primary = choices[((idx as i32 + step).rem_euclid(choices.len() as i32)) as usize];
}
fn handle_input(input: &mut KeyInput, code: KeyCode) {
match code {
KeyCode::Char(c) => input.insert_char(c),
KeyCode::Backspace => input.backspace(),
KeyCode::Delete => input.delete(),
KeyCode::Left => input.move_left(),
KeyCode::Right => input.move_right(),
KeyCode::Home => input.move_home(),
KeyCode::End => input.move_end(),
_ => {}
}
}
fn save_to_config_default(state: &SettingsState) -> Result<()> {
let path = default_config_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
}
save_to_path(state, &path)?;
crate::waybar::request_refresh();
Ok(())
}
pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
let original = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => return Err(AppError::io_at(path, error)),
};
let mut doc: DocumentMut = if original.trim().is_empty() {
DocumentMut::new()
} else {
original.parse().map_err(|e: toml_edit::TomlError| {
AppError::Other(format!("config.toml not parseable: {e}"))
})?
};
if state.primary_choices.contains(&state.primary) {
set_string(&mut doc, "ui", "primary", state.primary.slug())?;
}
for (i, kv) in KEY_VENDORS.iter().enumerate() {
let Some(input) = state.keys.get(i) else {
continue;
};
update_key(&mut doc, kv.section, input)?;
}
let bytes = doc.to_string();
crate::cache::atomic_write(path, bytes.as_bytes())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(path) {
let mut perms = meta.permissions();
perms.set_mode(0o600);
let _ = std::fs::set_permissions(path, perms);
}
}
Ok(())
}
fn update_key(doc: &mut DocumentMut, section: &str, input: &KeyInput) -> Result<()> {
if !input.dirty {
return Ok(());
}
if input.buf.is_empty() {
if let Some(table) = doc.get_mut(section).and_then(toml_edit::Item::as_table_mut) {
table.remove("api_key");
}
return Ok(());
}
set_string(doc, section, "api_key", &input.buf)?;
set_bool(doc, section, "enabled", true)
}
fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
let table = doc
.entry(section)
.or_insert_with(toml_edit::table)
.as_table_mut()
.ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
if let Some(item) = table.get_mut(key)
&& let Some(v) = item.as_value_mut()
{
*v = toml_edit::Value::from(new_value);
v.decor_mut().set_prefix(" ");
return Ok(());
}
table.insert(key, value(new_value));
Ok(())
}
fn set_bool(doc: &mut DocumentMut, section: &str, key: &str, new_value: bool) -> Result<()> {
let table = doc
.entry(section)
.or_insert_with(toml_edit::table)
.as_table_mut()
.ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
if let Some(item) = table.get_mut(key)
&& let Some(v) = item.as_value_mut()
{
*v = toml_edit::Value::from(new_value);
v.decor_mut().set_prefix(" ");
return Ok(());
}
table.insert(key, value(new_value));
Ok(())
}
fn default_config_path() -> Result<PathBuf> {
crate::config::resolved_path()
.ok_or_else(|| AppError::Other("could not resolve config dir".into()))
}
pub fn render(f: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
let modal = centered_rect(74, 88, area);
f.render_widget(Clear, modal);
let bubble = bubble_theme(theme);
let block = bubble.titled_modal_block(" Settings ");
let inner = block.inner(modal);
f.render_widget(block, modal);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(inner);
let mut lines: Vec<Line> = vec![
section_header("Primary vendor", "shown first on the bar / TUI", &bubble),
primary_line(state, &bubble),
Line::from(""),
section_header(
"API keys",
"pick a row, type the key, then Ctrl-S — Claude & OpenAI use CLI login",
&bubble,
),
];
for (i, kv) in KEY_VENDORS.iter().enumerate() {
let focused = state.focus == Focus::Key(i);
lines.push(key_row(kv, &state.keys[i], focused, &bubble));
}
lines.push(Line::from(""));
lines.push(save_line(state.focus == Focus::Save, &bubble));
if !state.status.is_empty() {
let ok = state.status.starts_with("saved");
let mark = if ok { " ✓ " } else { " ✗ " };
let style = if ok { bubble.accent } else { bubble.selected };
lines.push(Line::from(vec![
Span::styled(mark, style.add_modifier(Modifier::BOLD)),
Span::styled(state.status.clone(), bubble.muted),
]));
}
f.render_widget(Paragraph::new(lines), chunks[0]);
let hint = match state.focus {
Focus::Primary => bubble.help_line([
("↑↓/tab", "move"),
("←→", "change vendor"),
("^S", "save"),
("esc", "close"),
]),
Focus::Key(_) => bubble.help_line([
("↑↓/tab", "move"),
("type", "edit key"),
("^V", "reveal"),
("^S", "save"),
("esc", "close"),
]),
Focus::Save => {
bubble.help_line([("↑↓/tab", "move"), ("enter/^S", "save"), ("esc", "close")])
}
};
f.render_widget(Paragraph::new(hint), chunks[1]);
}
fn section_header(title: &str, sub: &str, theme: &BubbleTheme) -> Line<'static> {
Line::from(vec![
theme.span(" "),
Span::styled(title.to_string(), theme.title.add_modifier(Modifier::BOLD)),
theme.muted(format!(" — {sub}")),
])
}
fn primary_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
let focused = state.focus == Focus::Primary;
let name = vendor_label(state.primary).to_string();
if focused {
Line::from(vec![
theme.span(" "),
Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
Span::styled("◀ ", theme.accent),
Span::styled(
format!(" {name} "),
theme
.selected
.add_modifier(Modifier::REVERSED | Modifier::BOLD),
),
Span::styled(" ▶", theme.accent),
theme.muted(" ← → to change"),
])
} else {
Line::from(vec![theme.span(" "), Span::styled(name, theme.text)])
}
}
fn key_row(kv: &KeyVendor, input: &KeyInput, focused: bool, theme: &BubbleTheme) -> Line<'static> {
let label = format!("{:<11}", kv.label);
let value = value_text(input, focused);
let env_set = std::env::var(kv.env)
.map(|v| !v.is_empty())
.unwrap_or(false);
let mut suffix = format!(" {}", kv.env);
if env_set {
suffix.push_str(" · env set (overrides)");
}
if !kv.note.is_empty() {
suffix.push_str(&format!(" · {}", kv.note));
}
if focused {
let val_style = if input.buf.is_empty() {
theme.accent.add_modifier(Modifier::BOLD)
} else {
theme.selected.add_modifier(Modifier::REVERSED)
};
let mut spans = vec![
theme.span(" "),
Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
Span::styled(format!(" {value} "), val_style),
];
if input.revealed {
spans.push(theme.muted(" [revealed]"));
}
spans.push(theme.muted(suffix));
Line::from(spans)
} else {
let val_style = if input.buf.is_empty() {
theme.muted
} else {
theme.text
};
Line::from(vec![
theme.span(" "),
Span::styled(label, theme.text),
Span::styled(format!(" {value}"), val_style),
theme.muted(suffix),
])
}
}
fn value_text(input: &KeyInput, focused: bool) -> String {
if input.buf.is_empty() {
return if focused {
"‸".to_string()
} else {
"(empty)".to_string()
};
}
let base = input.display();
if !focused {
return base;
}
let mut chars: Vec<char> = base.chars().collect();
let pos = input.cursor.min(chars.len());
chars.insert(pos, '‸');
chars.into_iter().collect()
}
fn save_line(focused: bool, theme: &BubbleTheme) -> Line<'static> {
let style = if focused {
theme
.selected
.add_modifier(Modifier::REVERSED | Modifier::BOLD)
} else {
theme.accent.add_modifier(Modifier::BOLD)
};
let marker = if focused { "▸ " } else { " " };
Line::from(vec![
theme.span(" "),
Span::styled(marker, theme.accent.add_modifier(Modifier::BOLD)),
Span::styled(" Save (Ctrl-S) ", style),
])
}
fn vendor_label(v: VendorId) -> &'static str {
match v {
VendorId::Anthropic => "Anthropic",
VendorId::AnthropicApi => "Anthropic API",
VendorId::Openai => "OpenAI",
VendorId::Zai => "Z.AI",
VendorId::Openrouter => "OpenRouter",
VendorId::Deepseek => "DeepSeek",
VendorId::Kimi => "Kimi",
VendorId::Kilo => "Kilo",
VendorId::Novita => "Novita",
VendorId::Moonshot => "Moonshot",
VendorId::Grok => "Grok",
VendorId::Antigravity => "Antigravity",
}
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_h = (r.height * percent_y) / 100;
let popup_w = (r.width * percent_x) / 100;
Rect {
x: r.x + (r.width - popup_w) / 2,
y: r.y + (r.height - popup_h) / 2,
width: popup_w,
height: popup_h,
}
}
pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
crate::cache::closed_temp_file("config.toml", initial)
}
fn key_index(id: VendorId) -> usize {
KEY_VENDORS.iter().position(|kv| kv.id == id).unwrap()
}
fn blank_state(primary: VendorId) -> SettingsState {
SettingsState {
focus: Focus::Primary,
primary_choices: VendorId::all().to_vec(),
primary,
keys: KEY_VENDORS.iter().map(|_| KeyInput::default()).collect(),
status: String::new(),
}
}
fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
let mut s = blank_state(primary);
s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some(zai));
s.keys[key_index(VendorId::Zai)].dirty = true;
s.keys[key_index(VendorId::Openrouter)] = KeyInput::from_config(Some(opr));
s.keys[key_index(VendorId::Openrouter)].dirty = true;
s
}
#[test]
fn focus_cycles_through_primary_all_keys_and_save() {
let mut f = Focus::Primary;
let mut seen = vec![f];
for _ in 0..(KEY_VENDORS.len() + 2) {
f = f.next();
seen.push(f);
}
assert_eq!(seen.first(), Some(&Focus::Primary));
assert_eq!(seen.last(), Some(&Focus::Primary));
assert!(seen.contains(&Focus::Key(0)));
assert!(seen.contains(&Focus::Key(KEY_VENDORS.len() - 1)));
assert!(seen.contains(&Focus::Save));
assert_eq!(Focus::Primary.next().prev(), Focus::Primary);
assert_eq!(Focus::Save.prev().next(), Focus::Save);
assert_eq!(Focus::Primary.prev(), Focus::Save);
}
#[test]
fn every_key_vendor_has_a_field() {
for id in [
VendorId::Zai,
VendorId::Openrouter,
VendorId::Deepseek,
VendorId::Kilo,
VendorId::Novita,
VendorId::Moonshot,
VendorId::Grok,
] {
assert!(
KEY_VENDORS.iter().any(|kv| kv.id == id),
"{id:?} has no key field"
);
}
assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Anthropic));
assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Openai));
}
#[test]
fn from_config_prefills_existing_keys() {
let mut cfg = Config::default();
cfg.kilo.api_key = Some("sk-kilo".into());
let s = SettingsState::from_config(&cfg);
assert_eq!(s.keys[key_index(VendorId::Kilo)].buf, "sk-kilo");
assert!(!s.keys[key_index(VendorId::Kilo)].dirty);
}
#[test]
fn from_config_offers_enabled_vendors_only() {
let cfg = Config::default();
let s = SettingsState::from_config(&cfg);
assert_eq!(s.primary_choices, cfg.enabled_vendors());
assert!(!s.primary_choices.contains(&VendorId::Grok));
assert!(s.primary_choices.contains(&s.primary));
}
#[test]
fn from_config_falls_back_when_configured_primary_is_disabled() {
let mut cfg = Config::default();
cfg.ui.primary = Some(VendorId::Grok);
let s = SettingsState::from_config(&cfg);
assert_ne!(s.primary, VendorId::Grok);
assert_eq!(Some(s.primary), cfg.enabled_vendors().first().copied());
}
#[test]
fn key_input_insert_backspace_arrow() {
let mut k = KeyInput::default();
k.insert_char('a');
k.insert_char('b');
k.insert_char('c');
assert_eq!(k.buf, "abc");
assert_eq!(k.cursor, 3);
assert!(k.dirty);
k.move_left();
k.move_left();
assert_eq!(k.cursor, 1);
k.insert_char('x');
assert_eq!(k.buf, "axbc");
assert_eq!(k.cursor, 2);
k.backspace();
assert_eq!(k.buf, "abc");
assert_eq!(k.cursor, 1);
}
#[test]
fn key_input_masks_by_default_reveals_on_toggle() {
let mut k = KeyInput::default();
for c in "secret-key".chars() {
k.insert_char(c);
}
assert_eq!(k.display(), "•".repeat(10));
k.toggle_reveal();
assert_eq!(k.display(), "secret-key");
}
#[test]
fn key_input_handles_unicode() {
let mut k = KeyInput::default();
k.insert_char('a');
k.insert_char('→');
k.insert_char('b');
assert_eq!(k.buf, "a→b");
assert_eq!(k.cursor, 3);
k.move_left();
k.backspace();
assert_eq!(k.buf, "ab");
}
#[test]
fn value_text_shows_cursor_and_empty_states() {
let mut k = KeyInput::default();
assert_eq!(value_text(&k, false), "(empty)");
assert_eq!(value_text(&k, true), "‸");
k.insert_char('a');
k.insert_char('b');
assert_eq!(value_text(&k, true), "••‸");
assert_eq!(value_text(&k, false), "••");
}
#[test]
fn save_writes_key_and_enables_vendor() {
let (_dir, path) = temp_config(None);
let mut s = blank_state(VendorId::Kilo);
s.keys[key_index(VendorId::Kilo)] = KeyInput::from_config(Some("sk-kilo"));
s.keys[key_index(VendorId::Kilo)].dirty = true;
save_to_path(&s, &path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("primary = \"kilo\""));
assert!(raw.contains("[kilo]"));
assert!(raw.contains("api_key = \"sk-kilo\""));
assert!(raw.contains("enabled = true"));
}
#[test]
fn save_writes_minimal_toml_when_starting_empty() {
let (_dir, path) = temp_config(None);
let s = state_with("zk", "ok", VendorId::Zai);
save_to_path(&s, &path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("primary = \"zai\""));
assert!(raw.contains("[zai]"));
assert!(raw.contains("api_key = \"zk\""));
assert!(raw.contains("[openrouter]"));
assert!(raw.contains("api_key = \"ok\""));
}
#[test]
fn save_preserves_existing_comments_and_unrelated_fields() {
let (_dir, path) = temp_config(Some(
r##"# my comment
[ui]
# pre-existing comment
primary = "anthropic"
[zai]
enabled = true
api_key_env = "ZAI_API_KEY"
# tier comment
plan_tier = "pro"
[openrouter]
enabled = true
api_key_env = "OPENROUTER_API_KEY"
"##,
));
let s = state_with("zk2", "ok2", VendorId::Openrouter);
save_to_path(&s, &path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("# my comment"));
assert!(raw.contains("# pre-existing comment"));
assert!(raw.contains("# tier comment"));
assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
assert!(raw.contains("plan_tier = \"pro\""));
assert!(raw.contains("primary = \"openrouter\""));
assert!(raw.contains("api_key = \"zk2\""));
assert!(raw.contains("api_key = \"ok2\""));
}
#[test]
fn save_refuses_to_replace_an_unreadable_existing_config() {
let (_dir, path) = temp_config(None);
let original = [0xff, 0xfe, 0xfd];
std::fs::write(&path, original).unwrap();
let state = state_with("new-secret", "", VendorId::Zai);
assert!(save_to_path(&state, &path).is_err());
assert_eq!(std::fs::read(&path).unwrap(), original);
}
#[test]
fn save_does_not_write_empty_key_when_dirty_but_blank() {
let (_dir, path) = temp_config(None);
let mut s = blank_state(VendorId::Anthropic);
for k in &mut s.keys {
k.dirty = true;
}
save_to_path(&s, &path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(!raw.contains("api_key ="));
}
#[test]
#[cfg(unix)]
fn save_chmods_to_600() {
use std::os::unix::fs::PermissionsExt;
let (_dir, path) = temp_config(None);
let s = state_with("zk", "ok", VendorId::Zai);
save_to_path(&s, &path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn tab_cycles_focus_from_primary_to_first_key() {
let mut s = blank_state(VendorId::Anthropic);
assert_eq!(
handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
Action::Continue
);
assert_eq!(s.focus, Focus::Key(0));
assert_eq!(
handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
Action::Continue
);
assert_eq!(s.focus, Focus::Primary);
}
#[test]
fn esc_closes_without_saving() {
let mut s = blank_state(VendorId::Anthropic);
assert_eq!(
handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
Action::Close
);
}
#[test]
fn left_right_cycles_primary_vendor() {
let mut s = blank_state(VendorId::Anthropic);
handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
assert_eq!(s.primary, VendorId::AnthropicApi);
handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
assert_eq!(s.primary, VendorId::Openai);
handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
assert_eq!(s.primary, VendorId::AnthropicApi);
}
#[test]
fn left_right_offers_enabled_vendors_only() {
let mut s = blank_state(VendorId::Anthropic);
s.primary_choices = vec![VendorId::Anthropic, VendorId::Grok];
handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
assert_eq!(s.primary, VendorId::Grok);
handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
assert_eq!(s.primary, VendorId::Anthropic);
handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
assert_eq!(s.primary, VendorId::Grok);
}
#[test]
fn no_enabled_vendors_leaves_primary_selector_inert() {
let mut s = blank_state(VendorId::Anthropic);
s.primary_choices = vec![];
handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
assert_eq!(s.primary, VendorId::Anthropic);
}
#[test]
fn save_does_not_write_a_disabled_primary() {
let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
let mut s = state_with("zk", "ok", VendorId::Grok);
s.primary_choices = vec![VendorId::Anthropic];
save_to_path(&s, &path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("primary = \"anthropic\""));
assert!(!raw.contains("primary = \"grok\""));
assert!(raw.contains("zk"));
}
#[test]
fn save_removes_an_inline_key_the_user_cleared() {
let (_dir, path) = temp_config(Some(
"[zai]\nenabled = true\napi_key = \"old-secret\"\nplan_tier = \"pro\"\n",
));
let mut s = blank_state(VendorId::Zai);
s.primary_choices = vec![VendorId::Zai];
s.keys[key_index(VendorId::Zai)] = KeyInput::default();
s.keys[key_index(VendorId::Zai)].dirty = true;
save_to_path(&s, &path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(!raw.contains("old-secret"));
assert!(!raw.contains("api_key"));
assert!(raw.contains("plan_tier = \"pro\""));
}
#[test]
fn untouched_key_field_is_left_alone() {
let (_dir, path) = temp_config(Some("[zai]\napi_key = \"keep-me\"\n"));
let mut s = blank_state(VendorId::Zai);
s.primary_choices = vec![VendorId::Zai];
save_to_path(&s, &path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("keep-me"));
}
#[test]
fn typing_edits_the_focused_key_only() {
let mut s = blank_state(VendorId::Anthropic);
s.focus = Focus::Key(key_index(VendorId::Grok));
for c in "xai-abc".chars() {
handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
}
assert_eq!(s.keys[key_index(VendorId::Grok)].buf, "xai-abc");
assert!(s.keys[key_index(VendorId::Grok)].dirty);
assert!(s.keys[key_index(VendorId::Zai)].buf.is_empty());
}
#[test]
fn ctrl_v_toggles_reveal_on_focused_key_field() {
let mut s = blank_state(VendorId::Anthropic);
let zi = key_index(VendorId::Zai);
s.focus = Focus::Key(zi);
s.keys[zi] = KeyInput::from_config(Some("secret"));
assert!(!s.keys[zi].revealed);
handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
assert!(s.keys[zi].revealed);
handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
assert!(!s.keys[zi].revealed);
}
#[test]
fn control_chorded_chars_do_not_type_into_fields() {
let mut s = blank_state(VendorId::Anthropic);
s.focus = Focus::Key(0);
handle_key(&mut s, KeyCode::Char('a'), KeyModifiers::CONTROL);
assert!(s.keys[0].buf.is_empty());
assert!(!s.keys[0].dirty);
assert_eq!(
handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
Action::Quit
);
handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
assert_eq!(s.keys[0].buf, "x");
}
#[test]
fn ctrl_v_on_non_key_focus_is_noop() {
let mut s = blank_state(VendorId::Anthropic);
s.focus = Focus::Primary;
assert_eq!(
handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL),
Action::Continue
);
}
fn state_focused_on_zai() -> SettingsState {
let mut state = blank_state(VendorId::Anthropic);
state.focus = Focus::Key(key_index(VendorId::Zai));
state
}
#[test]
fn handle_key_ctrl_c_quits_without_typing_into_key_field() {
let mut s = state_focused_on_zai();
let zi = key_index(VendorId::Zai);
assert_eq!(
handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
Action::Quit
);
assert!(s.keys[zi].buf.is_empty());
assert!(!s.keys[zi].dirty);
}
#[test]
fn handle_key_alt_chord_does_not_type_into_key_field() {
let mut s = state_focused_on_zai();
let zi = key_index(VendorId::Zai);
handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::ALT);
assert!(s.keys[zi].buf.is_empty());
assert!(!s.keys[zi].dirty);
}
#[test]
fn handle_key_platform_modifier_chords_do_not_type_into_key_field() {
for modifier in [KeyModifiers::SUPER, KeyModifiers::HYPER, KeyModifiers::META] {
let mut s = state_focused_on_zai();
let zi = key_index(VendorId::Zai);
handle_key(&mut s, KeyCode::Char('x'), modifier);
assert!(s.keys[zi].buf.is_empty(), "modifier {modifier:?}");
assert!(!s.keys[zi].dirty, "modifier {modifier:?}");
}
}
#[test]
fn handle_key_shift_still_types_uppercase() {
let mut s = state_focused_on_zai();
let zi = key_index(VendorId::Zai);
handle_key(&mut s, KeyCode::Char('A'), KeyModifiers::SHIFT);
assert_eq!(s.keys[zi].buf, "A");
assert!(s.keys[zi].dirty);
}
#[test]
fn handle_key_plain_space_still_cycles_primary_vendor() {
let mut s = blank_state(VendorId::Anthropic);
handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
assert_eq!(s.primary, VendorId::AnthropicApi);
}
#[test]
fn handle_key_ctrl_s_attempts_save_from_any_field() {
let (_dir, path) = temp_config(None);
let s = state_with("zk", "ok", VendorId::Zai);
save_to_path(&s, &path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("api_key = \"zk\""));
}
#[test]
fn save_to_path_writes_kimi_key_when_dirty() {
let (_dir, path) = temp_config(None);
let mut s = blank_state(VendorId::Anthropic);
let kimi = key_index(VendorId::Kimi);
s.keys[kimi] = KeyInput::from_config(Some("kk"));
s.keys[kimi].dirty = true;
save_to_path(&s, &path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("[kimi]"));
assert!(raw.contains("api_key = \"kk\""));
}
#[test]
fn settings_save_uses_the_same_config_path_as_load() {
assert_eq!(
default_config_path().unwrap(),
crate::config::resolved_path().unwrap()
);
}
}