Skip to main content

ai_usagebar/tui/
settings.rs

1//! Settings overlay — opened from the TUI by pressing `s`. Lets the user pick
2//! the primary vendor and paste an API key for any key-authenticated vendor
3//! (including Z.AI, Kimi, MiniMax, and the balance vendors) without hand-editing
4//! config.toml. Anthropic, OpenAI, Cursor, and Antigravity authenticate through
5//! local product state, so they have no key field here.
6//!
7//! Persistence uses `toml_edit` so the existing config keeps its comments,
8//! whitespace, and unrelated fields. Writing a key also flips that vendor's
9//! `enabled = true` (the opt-in vendors are disabled by default), so "paste the
10//! key and save" is all it takes. Files with inline keys are atomically written
11//! and `chmod 600`ed.
12
13use std::path::{Path, PathBuf};
14
15use ratatui::Frame;
16use ratatui::layout::{Constraint, Direction, Layout, Rect};
17use ratatui::style::Modifier;
18use ratatui::text::{Line, Span};
19use ratatui::widgets::{Clear, Paragraph};
20use ratatui_bubbletea_theme::BubbleTheme;
21use toml_edit::{DocumentMut, value};
22
23use crate::config::Config;
24use crate::error::{AppError, Result};
25use crate::theme::Theme;
26use crate::tui::style::bubble_theme;
27use crate::vendor::VendorId;
28
29/// A vendor that authenticates with an inline API key (vs. OAuth). The order of
30/// this table is the tab order of the key fields and the layout of the state's
31/// `keys` vec.
32pub struct KeyVendor {
33    pub id: VendorId,
34    pub label: &'static str,
35    pub env: &'static str,
36    pub section: &'static str,
37    /// Extra hint after the env var (e.g. "management key"). Empty for none.
38    pub note: &'static str,
39}
40
41pub const KEY_VENDORS: &[KeyVendor] = &[
42    KeyVendor {
43        id: VendorId::AnthropicApi,
44        label: "Anthropic API",
45        env: "ANTHROPIC_ADMIN_KEY",
46        section: "anthropic_api",
47        note: "admin key — monthly spend",
48    },
49    KeyVendor {
50        id: VendorId::Zai,
51        label: "Z.AI",
52        env: "ZAI_API_KEY",
53        section: "zai",
54        note: "",
55    },
56    KeyVendor {
57        id: VendorId::Openrouter,
58        label: "OpenRouter",
59        env: "OPENROUTER_API_KEY",
60        section: "openrouter",
61        note: "",
62    },
63    KeyVendor {
64        id: VendorId::Deepseek,
65        label: "DeepSeek",
66        env: "DEEPSEEK_API_KEY",
67        section: "deepseek",
68        note: "",
69    },
70    KeyVendor {
71        id: VendorId::Kimi,
72        label: "Kimi",
73        env: "KIMI_API_KEY",
74        section: "kimi",
75        note: "coding-plan usage",
76    },
77    KeyVendor {
78        id: VendorId::Kilo,
79        label: "Kilo",
80        env: "KILO_API_KEY",
81        section: "kilo",
82        note: "",
83    },
84    KeyVendor {
85        id: VendorId::Novita,
86        label: "Novita",
87        env: "NOVITA_API_KEY",
88        section: "novita",
89        note: "",
90    },
91    KeyVendor {
92        id: VendorId::Moonshot,
93        label: "Moonshot",
94        env: "MOONSHOT_API_KEY",
95        section: "moonshot",
96        note: "account balance",
97    },
98    KeyVendor {
99        id: VendorId::Grok,
100        label: "Grok",
101        env: "XAI_MANAGEMENT_KEY",
102        section: "grok",
103        note: "management key, not the inference key",
104    },
105    KeyVendor {
106        id: VendorId::Minimax,
107        label: "MiniMax",
108        env: "MINIMAX_API_KEY",
109        section: "minimax",
110        note: "Token Plan subscription key",
111    },
112];
113
114/// Read the inline `api_key` currently in config for a given section, so the
115/// field opens pre-filled (masked) when one is already set.
116fn config_inline_key<'a>(cfg: &'a Config, section: &str) -> Option<&'a str> {
117    match section {
118        "anthropic_api" => cfg.anthropic_api.api_key.as_deref(),
119        "zai" => cfg.zai.api_key.as_deref(),
120        "openrouter" => cfg.openrouter.api_key.as_deref(),
121        "deepseek" => cfg.deepseek.api_key.as_deref(),
122        "kimi" => cfg.kimi.api_key.as_deref(),
123        "kilo" => cfg.kilo.api_key.as_deref(),
124        "novita" => cfg.novita.api_key.as_deref(),
125        "moonshot" => cfg.moonshot.api_key.as_deref(),
126        "grok" => cfg.grok.api_key.as_deref(),
127        "minimax" => cfg.minimax.api_key.as_deref(),
128        _ => None,
129    }
130}
131
132/// Which control has keyboard focus. `Key(i)` indexes into [`KEY_VENDORS`].
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum Focus {
135    Primary,
136    Key(usize),
137    Save,
138}
139
140impl Focus {
141    pub fn next(self) -> Self {
142        match self {
143            Focus::Primary => Focus::Key(0),
144            Focus::Key(i) if i + 1 < KEY_VENDORS.len() => Focus::Key(i + 1),
145            Focus::Key(_) => Focus::Save,
146            Focus::Save => Focus::Primary,
147        }
148    }
149    pub fn prev(self) -> Self {
150        match self {
151            Focus::Primary => Focus::Save,
152            Focus::Key(0) => Focus::Primary,
153            Focus::Key(i) => Focus::Key(i - 1),
154            Focus::Save => Focus::Key(KEY_VENDORS.len() - 1),
155        }
156    }
157}
158
159/// Per-field text-input state — cursor + buffer + reveal flag.
160#[derive(Debug, Clone, Default)]
161pub struct KeyInput {
162    pub buf: String,
163    /// Char-index cursor position (0..=buf.chars().count()).
164    pub cursor: usize,
165    /// When true, the field renders the actual characters; otherwise `•`.
166    pub revealed: bool,
167    /// True after the user has typed/edited; only then does save write the
168    /// value back (avoids clobbering an existing key with the empty
169    /// placeholder the user opened the dialog with).
170    pub dirty: bool,
171}
172
173impl KeyInput {
174    pub fn from_config(initial: Option<&str>) -> Self {
175        let buf = initial.unwrap_or("").to_string();
176        let cursor = buf.chars().count();
177        Self {
178            buf,
179            cursor,
180            revealed: false,
181            dirty: false,
182        }
183    }
184
185    pub fn insert_char(&mut self, c: char) {
186        let byte_idx = self.char_to_byte(self.cursor);
187        self.buf.insert(byte_idx, c);
188        self.cursor += 1;
189        self.dirty = true;
190    }
191
192    pub fn backspace(&mut self) {
193        if self.cursor == 0 {
194            return;
195        }
196        let prev_byte = self.char_to_byte(self.cursor - 1);
197        let cur_byte = self.char_to_byte(self.cursor);
198        self.buf.replace_range(prev_byte..cur_byte, "");
199        self.cursor -= 1;
200        self.dirty = true;
201    }
202
203    pub fn delete(&mut self) {
204        let n = self.buf.chars().count();
205        if self.cursor >= n {
206            return;
207        }
208        let cur_byte = self.char_to_byte(self.cursor);
209        let next_byte = self.char_to_byte(self.cursor + 1);
210        self.buf.replace_range(cur_byte..next_byte, "");
211        self.dirty = true;
212    }
213
214    pub fn move_left(&mut self) {
215        if self.cursor > 0 {
216            self.cursor -= 1;
217        }
218    }
219    pub fn move_right(&mut self) {
220        if self.cursor < self.buf.chars().count() {
221            self.cursor += 1;
222        }
223    }
224    pub fn move_home(&mut self) {
225        self.cursor = 0;
226    }
227    pub fn move_end(&mut self) {
228        self.cursor = self.buf.chars().count();
229    }
230    pub fn toggle_reveal(&mut self) {
231        self.revealed = !self.revealed;
232    }
233
234    /// Render for display — bullets when masked, raw chars when revealed.
235    pub fn display(&self) -> String {
236        if self.revealed {
237            self.buf.clone()
238        } else {
239            "•".repeat(self.buf.chars().count())
240        }
241    }
242
243    fn char_to_byte(&self, char_idx: usize) -> usize {
244        self.buf
245            .char_indices()
246            .map(|(b, _)| b)
247            .chain(std::iter::once(self.buf.len()))
248            .nth(char_idx)
249            .unwrap_or(self.buf.len())
250    }
251}
252
253/// Mutable state of the overlay while open.
254#[derive(Debug, Clone)]
255pub struct SettingsState {
256    pub focus: Focus,
257    /// Enabled vendors only. The primary selector must not offer a value that
258    /// cannot actually be used by the widget or TUI.
259    pub primary_choices: Vec<VendorId>,
260    pub primary: VendorId,
261    /// One input per [`KEY_VENDORS`] entry, same order.
262    pub keys: Vec<KeyInput>,
263    /// One-line status displayed in the footer ("saved …", "save failed …").
264    pub status: String,
265}
266
267impl SettingsState {
268    pub fn from_config(cfg: &Config) -> Self {
269        let keys = KEY_VENDORS
270            .iter()
271            .map(|kv| KeyInput::from_config(config_inline_key(cfg, kv.section)))
272            .collect();
273        let primary_choices = cfg.enabled_vendors();
274        // A configured but disabled primary is ineffective. Display the first
275        // enabled vendor instead; when none are enabled retain the historical
276        // Anthropic fallback in memory without inventing a persisted primary.
277        let primary = cfg
278            .ui
279            .primary
280            .filter(|vendor| primary_choices.contains(vendor))
281            .or_else(|| primary_choices.first().copied())
282            .unwrap_or_else(|| cfg.ui.primary.unwrap_or(VendorId::Anthropic));
283        Self {
284            focus: Focus::Primary,
285            primary_choices,
286            primary,
287            keys,
288            status: String::new(),
289        }
290    }
291
292    /// The focused key input, if a key row is focused.
293    fn focused_key_mut(&mut self) -> Option<&mut KeyInput> {
294        match self.focus {
295            Focus::Key(i) => self.keys.get_mut(i),
296            _ => None,
297        }
298    }
299}
300
301/// What the key handler asks the host app to do next.
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub enum Action {
304    /// Stay open, keep listening for keys.
305    Continue,
306    /// Close the overlay (discard or save already happened).
307    Close,
308    /// Save just succeeded — caller should refresh affected vendors.
309    SavedAndClose,
310    /// Quit the host TUI. Ctrl-C remains global even while the overlay owns
311    /// keyboard focus.
312    Quit,
313}
314
315/// Permission note appended to the "saved" status line. The overlay `chmod
316/// 600`s the file on Unix; Windows has no such step, so the note is empty there.
317#[cfg(unix)]
318const PERMS_NOTE: &str = " (chmod 600)";
319#[cfg(not(unix))]
320const PERMS_NOTE: &str = "";
321
322fn saved_status() -> String {
323    format!(
324        "saved to {}{}",
325        crate::config::config_path_hint(),
326        PERMS_NOTE
327    )
328}
329
330/// Key map. Returns the action to perform after the keypress.
331pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
332    if matches!(code, KeyCode::Esc) {
333        return Action::Close;
334    }
335    if matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL) {
336        return Action::Quit;
337    }
338    // Ctrl-S triggers save from any field.
339    if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
340        return try_save(state);
341    }
342    if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
343        if let Some(input) = state.focused_key_mut() {
344            input.toggle_reveal();
345        }
346        return Action::Continue;
347    }
348    match code {
349        KeyCode::Tab | KeyCode::Down => {
350            state.focus = state.focus.next();
351            return Action::Continue;
352        }
353        KeyCode::BackTab | KeyCode::Up => {
354            state.focus = state.focus.prev();
355            return Action::Continue;
356        }
357        _ => {}
358    }
359
360    // A modifier chord is not text. The overlay swallows every key while open,
361    // so every unhandled chord must be ignored rather than corrupting the
362    // secret silently. SHIFT is deliberately not rejected — it is how
363    // uppercase arrives. Ctrl-C was handled above because it is a global quit.
364    if matches!(code, KeyCode::Char(_))
365        && mods.intersects(
366            KeyModifiers::CONTROL
367                | KeyModifiers::ALT
368                | KeyModifiers::SUPER
369                | KeyModifiers::HYPER
370                | KeyModifiers::META,
371        )
372    {
373        return Action::Continue;
374    }
375
376    // Field-specific handling.
377    match state.focus {
378        Focus::Primary => handle_primary(state, code),
379        Focus::Key(i) => {
380            if let Some(input) = state.keys.get_mut(i) {
381                handle_input(input, code);
382            }
383        }
384        Focus::Save => {
385            if matches!(code, KeyCode::Enter) {
386                return try_save(state);
387            }
388        }
389    }
390    Action::Continue
391}
392
393fn try_save(state: &mut SettingsState) -> Action {
394    match save_to_config_default(state) {
395        Ok(()) => {
396            state.status = saved_status();
397            Action::SavedAndClose
398        }
399        Err(e) => {
400            state.status = format!("save failed: {e}");
401            Action::Continue
402        }
403    }
404}
405
406fn handle_primary(state: &mut SettingsState, code: KeyCode) {
407    // Left/Right cycles the primary-vendor radio over enabled vendors only.
408    let choices = &state.primary_choices;
409    let Some(idx) = choices.iter().position(|v| *v == state.primary) else {
410        return;
411    };
412    let step = match code {
413        KeyCode::Left => -1,
414        KeyCode::Right | KeyCode::Char(' ') => 1,
415        _ => return,
416    };
417    state.primary = choices[((idx as i32 + step).rem_euclid(choices.len() as i32)) as usize];
418}
419
420fn handle_input(input: &mut KeyInput, code: KeyCode) {
421    match code {
422        KeyCode::Char(c) => input.insert_char(c),
423        KeyCode::Backspace => input.backspace(),
424        KeyCode::Delete => input.delete(),
425        KeyCode::Left => input.move_left(),
426        KeyCode::Right => input.move_right(),
427        KeyCode::Home => input.move_home(),
428        KeyCode::End => input.move_end(),
429        _ => {}
430    }
431}
432
433/// Save to the platform config path (creating it). On success, signal a running
434/// Waybar (`SIGRTMIN+13`) so a `signal: 13` module refreshes immediately.
435fn save_to_config_default(state: &SettingsState) -> Result<()> {
436    let path = default_config_path()?;
437    if let Some(parent) = path.parent() {
438        std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
439    }
440    save_to_path(state, &path)?;
441    crate::waybar::request_refresh();
442    Ok(())
443}
444
445/// Same as `save_to_config_default` but with an explicit path — exposed for
446/// tests. Writing a non-empty key also sets that vendor's `enabled = true`.
447pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
448    let original = match std::fs::read_to_string(path) {
449        Ok(contents) => contents,
450        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
451        Err(error) => return Err(AppError::io_at(path, error)),
452    };
453    let mut doc: DocumentMut = if original.trim().is_empty() {
454        DocumentMut::new()
455    } else {
456        original.parse().map_err(|e: toml_edit::TomlError| {
457            AppError::Other(format!("config.toml not parseable: {e}"))
458        })?
459    };
460
461    // Do not write a disabled primary as a side effect of saving an API key.
462    // With no enabled vendors, leave any existing value alone so the legacy
463    // resolver's Anthropic fallback remains intact.
464    if state.primary_choices.contains(&state.primary) {
465        set_string(&mut doc, "ui", "primary", state.primary.slug())?;
466    }
467
468    for (i, kv) in KEY_VENDORS.iter().enumerate() {
469        let Some(input) = state.keys.get(i) else {
470            continue;
471        };
472        update_key(&mut doc, kv.section, input)?;
473    }
474
475    let bytes = doc.to_string();
476    crate::cache::atomic_write(path, bytes.as_bytes())?;
477
478    #[cfg(unix)]
479    {
480        use std::os::unix::fs::PermissionsExt;
481        if let Ok(meta) = std::fs::metadata(path) {
482            let mut perms = meta.permissions();
483            perms.set_mode(0o600);
484            let _ = std::fs::set_permissions(path, perms);
485        }
486    }
487    Ok(())
488}
489
490/// Apply one key field to the document. Untouched fields are left alone; a
491/// field the user cleared is *removed*, so an inline secret can be deleted
492/// from the overlay rather than lingering in the file. Writing a non-empty key
493/// also opts the vendor in — the opt-in vendors would otherwise never fetch.
494fn update_key(doc: &mut DocumentMut, section: &str, input: &KeyInput) -> Result<()> {
495    if !input.dirty {
496        return Ok(());
497    }
498    if input.buf.is_empty() {
499        if let Some(table) = doc.get_mut(section).and_then(toml_edit::Item::as_table_mut) {
500            table.remove("api_key");
501        }
502        return Ok(());
503    }
504    set_string(doc, section, "api_key", &input.buf)?;
505    set_bool(doc, section, "enabled", true)
506}
507
508/// Set or update a string field in a TOML section, preserving comments and
509/// formatting of unaffected nodes.
510fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
511    let table = doc
512        .entry(section)
513        .or_insert_with(toml_edit::table)
514        .as_table_mut()
515        .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
516
517    if let Some(item) = table.get_mut(key)
518        && let Some(v) = item.as_value_mut()
519    {
520        *v = toml_edit::Value::from(new_value);
521        v.decor_mut().set_prefix(" ");
522        return Ok(());
523    }
524    table.insert(key, value(new_value));
525    Ok(())
526}
527
528/// Same as [`set_string`] for a boolean field.
529fn set_bool(doc: &mut DocumentMut, section: &str, key: &str, new_value: bool) -> Result<()> {
530    let table = doc
531        .entry(section)
532        .or_insert_with(toml_edit::table)
533        .as_table_mut()
534        .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
535
536    if let Some(item) = table.get_mut(key)
537        && let Some(v) = item.as_value_mut()
538    {
539        *v = toml_edit::Value::from(new_value);
540        v.decor_mut().set_prefix(" ");
541        return Ok(());
542    }
543    table.insert(key, value(new_value));
544    Ok(())
545}
546
547fn default_config_path() -> Result<PathBuf> {
548    // Save back to the same file Config::load() selected. On macOS this may be
549    // the legacy ~/.config path when the canonical Application Support file is
550    // absent; writing a new canonical file would shadow the existing config on
551    // the next load and silently discard all settings the overlay did not copy.
552    crate::config::resolved_path()
553        .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
554}
555
556// ─── Render ────────────────────────────────────────────────────────────────
557
558/// Render the modal overlay over `area`.
559pub fn render(f: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
560    let modal = centered_rect(74, 88, area);
561    f.render_widget(Clear, modal);
562
563    let bubble = bubble_theme(theme);
564    let block = bubble.titled_modal_block(" Settings ");
565    let inner = block.inner(modal);
566    f.render_widget(block, modal);
567
568    // Body (everything but the pinned hint) + a 1-line hint footer.
569    let chunks = Layout::default()
570        .direction(Direction::Vertical)
571        .constraints([Constraint::Min(0), Constraint::Length(1)])
572        .split(inner);
573
574    // — Primary vendor + API keys header —
575    let mut lines: Vec<Line> = vec![
576        section_header("Primary vendor", "shown first on the bar / TUI", &bubble),
577        primary_line(state, &bubble),
578        Line::from(""),
579        section_header(
580            "API keys",
581            "pick a row, type the key, then Ctrl-S — Claude & OpenAI use CLI login",
582            &bubble,
583        ),
584    ];
585    for (i, kv) in KEY_VENDORS.iter().enumerate() {
586        let focused = state.focus == Focus::Key(i);
587        lines.push(key_row(kv, &state.keys[i], focused, &bubble));
588    }
589    lines.push(Line::from(""));
590
591    // — Save + status —
592    lines.push(save_line(state.focus == Focus::Save, &bubble));
593    if !state.status.is_empty() {
594        let ok = state.status.starts_with("saved");
595        let mark = if ok { "  ✓ " } else { "  ✗ " };
596        let style = if ok { bubble.accent } else { bubble.selected };
597        lines.push(Line::from(vec![
598            Span::styled(mark, style.add_modifier(Modifier::BOLD)),
599            Span::styled(state.status.clone(), bubble.muted),
600        ]));
601    }
602
603    f.render_widget(Paragraph::new(lines), chunks[0]);
604
605    // Context-aware hint footer.
606    let hint = match state.focus {
607        Focus::Primary => bubble.help_line([
608            ("↑↓/tab", "move"),
609            ("←→", "change vendor"),
610            ("^S", "save"),
611            ("esc", "close"),
612        ]),
613        Focus::Key(_) => bubble.help_line([
614            ("↑↓/tab", "move"),
615            ("type", "edit key"),
616            ("^V", "reveal"),
617            ("^S", "save"),
618            ("esc", "close"),
619        ]),
620        Focus::Save => {
621            bubble.help_line([("↑↓/tab", "move"), ("enter/^S", "save"), ("esc", "close")])
622        }
623    };
624    f.render_widget(Paragraph::new(hint), chunks[1]);
625}
626
627fn section_header(title: &str, sub: &str, theme: &BubbleTheme) -> Line<'static> {
628    Line::from(vec![
629        theme.span(" "),
630        Span::styled(title.to_string(), theme.title.add_modifier(Modifier::BOLD)),
631        theme.muted(format!("   — {sub}")),
632    ])
633}
634
635fn primary_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
636    let focused = state.focus == Focus::Primary;
637    let name = vendor_label(state.primary).to_string();
638    if focused {
639        Line::from(vec![
640            theme.span("   "),
641            Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
642            Span::styled("◀ ", theme.accent),
643            Span::styled(
644                format!(" {name} "),
645                theme
646                    .selected
647                    .add_modifier(Modifier::REVERSED | Modifier::BOLD),
648            ),
649            Span::styled(" ▶", theme.accent),
650            theme.muted("    ← → to change"),
651        ])
652    } else {
653        Line::from(vec![theme.span("     "), Span::styled(name, theme.text)])
654    }
655}
656
657fn key_row(kv: &KeyVendor, input: &KeyInput, focused: bool, theme: &BubbleTheme) -> Line<'static> {
658    let label = format!("{:<11}", kv.label);
659    let value = value_text(input, focused);
660
661    // Env / status suffix: env-var name, whether an env override is set, note.
662    let env_set = std::env::var(kv.env)
663        .map(|v| !v.is_empty())
664        .unwrap_or(false);
665    let mut suffix = format!("   {}", kv.env);
666    if env_set {
667        suffix.push_str(" · env set (overrides)");
668    }
669    if !kv.note.is_empty() {
670        suffix.push_str(&format!(" · {}", kv.note));
671    }
672
673    if focused {
674        let val_style = if input.buf.is_empty() {
675            theme.accent.add_modifier(Modifier::BOLD)
676        } else {
677            theme.selected.add_modifier(Modifier::REVERSED)
678        };
679        let mut spans = vec![
680            theme.span("  "),
681            Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
682            Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
683            Span::styled(format!(" {value} "), val_style),
684        ];
685        if input.revealed {
686            spans.push(theme.muted("  [revealed]"));
687        }
688        spans.push(theme.muted(suffix));
689        Line::from(spans)
690    } else {
691        let val_style = if input.buf.is_empty() {
692            theme.muted
693        } else {
694            theme.text
695        };
696        Line::from(vec![
697            theme.span("    "),
698            Span::styled(label, theme.text),
699            Span::styled(format!(" {value}"), val_style),
700            theme.muted(suffix),
701        ])
702    }
703}
704
705/// The value column: `(empty)` / a cursor when focused-empty / masked or
706/// revealed buffer with a cursor mark inserted when focused.
707fn value_text(input: &KeyInput, focused: bool) -> String {
708    if input.buf.is_empty() {
709        return if focused {
710            "‸".to_string()
711        } else {
712            "(empty)".to_string()
713        };
714    }
715    let base = input.display();
716    if !focused {
717        return base;
718    }
719    let mut chars: Vec<char> = base.chars().collect();
720    let pos = input.cursor.min(chars.len());
721    chars.insert(pos, '‸');
722    chars.into_iter().collect()
723}
724
725fn save_line(focused: bool, theme: &BubbleTheme) -> Line<'static> {
726    let style = if focused {
727        theme
728            .selected
729            .add_modifier(Modifier::REVERSED | Modifier::BOLD)
730    } else {
731        theme.accent.add_modifier(Modifier::BOLD)
732    };
733    let marker = if focused { "▸ " } else { "  " };
734    Line::from(vec![
735        theme.span("   "),
736        Span::styled(marker, theme.accent.add_modifier(Modifier::BOLD)),
737        Span::styled("  Save  (Ctrl-S)  ", style),
738    ])
739}
740
741fn vendor_label(v: VendorId) -> &'static str {
742    match v {
743        VendorId::Anthropic => "Anthropic",
744        VendorId::AnthropicApi => "Anthropic API",
745        VendorId::Openai => "OpenAI",
746        VendorId::Zai => "Z.AI",
747        VendorId::Openrouter => "OpenRouter",
748        VendorId::Deepseek => "DeepSeek",
749        VendorId::Kimi => "Kimi",
750        VendorId::Kilo => "Kilo",
751        VendorId::Novita => "Novita",
752        VendorId::Moonshot => "Moonshot",
753        VendorId::Grok => "Grok",
754        VendorId::Supergrok => "SuperGrok",
755        VendorId::Antigravity => "Antigravity",
756        VendorId::Cursor => "Cursor",
757        VendorId::Minimax => "MiniMax",
758        VendorId::Kiro => "Kiro",
759    }
760}
761
762/// Center a rectangle of `percent_x * percent_y` over `r`.
763fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
764    let popup_h = (r.height * percent_y) / 100;
765    let popup_w = (r.width * percent_x) / 100;
766    Rect {
767        x: r.x + (r.width - popup_w) / 2,
768        y: r.y + (r.height - popup_h) / 2,
769        width: popup_w,
770        height: popup_h,
771    }
772}
773
774// crossterm types live behind ratatui; re-exported here for handle_key callers.
775pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780    use tempfile::TempDir;
781
782    fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
783        crate::cache::closed_temp_file("config.toml", initial)
784    }
785
786    fn key_index(id: VendorId) -> usize {
787        KEY_VENDORS.iter().position(|kv| kv.id == id).unwrap()
788    }
789
790    fn blank_state(primary: VendorId) -> SettingsState {
791        SettingsState {
792            focus: Focus::Primary,
793            primary_choices: VendorId::all().to_vec(),
794            primary,
795            keys: KEY_VENDORS.iter().map(|_| KeyInput::default()).collect(),
796            status: String::new(),
797        }
798    }
799
800    /// State with a Z.AI key and an OpenRouter key, both marked dirty.
801    fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
802        let mut s = blank_state(primary);
803        s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some(zai));
804        s.keys[key_index(VendorId::Zai)].dirty = true;
805        s.keys[key_index(VendorId::Openrouter)] = KeyInput::from_config(Some(opr));
806        s.keys[key_index(VendorId::Openrouter)].dirty = true;
807        s
808    }
809
810    #[test]
811    fn focus_cycles_through_primary_all_keys_and_save() {
812        let mut f = Focus::Primary;
813        let mut seen = vec![f];
814        // Full cycle = Primary + N key rows + Save.
815        for _ in 0..(KEY_VENDORS.len() + 2) {
816            f = f.next();
817            seen.push(f);
818        }
819        // Primary, Key(0..n), Save, back to Primary.
820        assert_eq!(seen.first(), Some(&Focus::Primary));
821        assert_eq!(seen.last(), Some(&Focus::Primary));
822        assert!(seen.contains(&Focus::Key(0)));
823        assert!(seen.contains(&Focus::Key(KEY_VENDORS.len() - 1)));
824        assert!(seen.contains(&Focus::Save));
825        // prev() is the inverse of next().
826        assert_eq!(Focus::Primary.next().prev(), Focus::Primary);
827        assert_eq!(Focus::Save.prev().next(), Focus::Save);
828        assert_eq!(Focus::Primary.prev(), Focus::Save);
829    }
830
831    #[test]
832    fn every_key_vendor_has_a_field() {
833        // Every enabled-by-key vendor must be reachable in the form.
834        for id in [
835            VendorId::Zai,
836            VendorId::Openrouter,
837            VendorId::Deepseek,
838            VendorId::Kilo,
839            VendorId::Novita,
840            VendorId::Moonshot,
841            VendorId::Grok,
842        ] {
843            assert!(
844                KEY_VENDORS.iter().any(|kv| kv.id == id),
845                "{id:?} has no key field"
846            );
847        }
848        // OAuth vendors are intentionally absent.
849        assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Anthropic));
850        assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Openai));
851    }
852
853    #[test]
854    fn from_config_prefills_existing_keys() {
855        let mut cfg = Config::default();
856        cfg.kilo.api_key = Some("sk-kilo".into());
857        let s = SettingsState::from_config(&cfg);
858        assert_eq!(s.keys[key_index(VendorId::Kilo)].buf, "sk-kilo");
859        assert!(!s.keys[key_index(VendorId::Kilo)].dirty);
860    }
861
862    #[test]
863    fn from_config_offers_enabled_vendors_only() {
864        let cfg = Config::default();
865        let s = SettingsState::from_config(&cfg);
866        assert_eq!(s.primary_choices, cfg.enabled_vendors());
867        // Opt-in vendors are disabled by default and must not be offered.
868        assert!(!s.primary_choices.contains(&VendorId::Grok));
869        assert!(s.primary_choices.contains(&s.primary));
870    }
871
872    #[test]
873    fn from_config_falls_back_when_configured_primary_is_disabled() {
874        // Grok is opt-in; a config naming it as primary without enabling it
875        // must display the first enabled vendor instead.
876        let mut cfg = Config::default();
877        cfg.ui.primary = Some(VendorId::Grok);
878        let s = SettingsState::from_config(&cfg);
879        assert_ne!(s.primary, VendorId::Grok);
880        assert_eq!(Some(s.primary), cfg.enabled_vendors().first().copied());
881    }
882
883    #[test]
884    fn key_input_insert_backspace_arrow() {
885        let mut k = KeyInput::default();
886        k.insert_char('a');
887        k.insert_char('b');
888        k.insert_char('c');
889        assert_eq!(k.buf, "abc");
890        assert_eq!(k.cursor, 3);
891        assert!(k.dirty);
892        k.move_left();
893        k.move_left();
894        assert_eq!(k.cursor, 1);
895        k.insert_char('x');
896        assert_eq!(k.buf, "axbc");
897        assert_eq!(k.cursor, 2);
898        k.backspace();
899        assert_eq!(k.buf, "abc");
900        assert_eq!(k.cursor, 1);
901    }
902
903    #[test]
904    fn key_input_masks_by_default_reveals_on_toggle() {
905        let mut k = KeyInput::default();
906        for c in "secret-key".chars() {
907            k.insert_char(c);
908        }
909        assert_eq!(k.display(), "•".repeat(10));
910        k.toggle_reveal();
911        assert_eq!(k.display(), "secret-key");
912    }
913
914    #[test]
915    fn key_input_handles_unicode() {
916        let mut k = KeyInput::default();
917        k.insert_char('a');
918        k.insert_char('→');
919        k.insert_char('b');
920        assert_eq!(k.buf, "a→b");
921        assert_eq!(k.cursor, 3);
922        k.move_left();
923        k.backspace();
924        assert_eq!(k.buf, "ab");
925    }
926
927    #[test]
928    fn value_text_shows_cursor_and_empty_states() {
929        let mut k = KeyInput::default();
930        assert_eq!(value_text(&k, false), "(empty)");
931        assert_eq!(value_text(&k, true), "‸");
932        k.insert_char('a');
933        k.insert_char('b');
934        // masked + cursor at end
935        assert_eq!(value_text(&k, true), "••‸");
936        assert_eq!(value_text(&k, false), "••");
937    }
938
939    #[test]
940    fn save_writes_key_and_enables_vendor() {
941        let (_dir, path) = temp_config(None);
942        let mut s = blank_state(VendorId::Kilo);
943        s.keys[key_index(VendorId::Kilo)] = KeyInput::from_config(Some("sk-kilo"));
944        s.keys[key_index(VendorId::Kilo)].dirty = true;
945        save_to_path(&s, &path).unwrap();
946        let raw = std::fs::read_to_string(&path).unwrap();
947        assert!(raw.contains("primary = \"kilo\""));
948        assert!(raw.contains("[kilo]"));
949        assert!(raw.contains("api_key = \"sk-kilo\""));
950        assert!(raw.contains("enabled = true"));
951    }
952
953    #[test]
954    fn save_writes_minimal_toml_when_starting_empty() {
955        let (_dir, path) = temp_config(None);
956        let s = state_with("zk", "ok", VendorId::Zai);
957        save_to_path(&s, &path).unwrap();
958        let raw = std::fs::read_to_string(&path).unwrap();
959        assert!(raw.contains("primary = \"zai\""));
960        assert!(raw.contains("[zai]"));
961        assert!(raw.contains("api_key = \"zk\""));
962        assert!(raw.contains("[openrouter]"));
963        assert!(raw.contains("api_key = \"ok\""));
964    }
965
966    #[test]
967    fn save_preserves_existing_comments_and_unrelated_fields() {
968        let (_dir, path) = temp_config(Some(
969            r##"# my comment
970[ui]
971# pre-existing comment
972primary = "anthropic"
973
974[zai]
975enabled = true
976api_key_env = "ZAI_API_KEY"
977# tier comment
978plan_tier = "pro"
979
980[openrouter]
981enabled = true
982api_key_env = "OPENROUTER_API_KEY"
983"##,
984        ));
985
986        let s = state_with("zk2", "ok2", VendorId::Openrouter);
987        save_to_path(&s, &path).unwrap();
988
989        let raw = std::fs::read_to_string(&path).unwrap();
990        assert!(raw.contains("# my comment"));
991        assert!(raw.contains("# pre-existing comment"));
992        assert!(raw.contains("# tier comment"));
993        assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
994        assert!(raw.contains("plan_tier = \"pro\""));
995        assert!(raw.contains("primary = \"openrouter\""));
996        assert!(raw.contains("api_key = \"zk2\""));
997        assert!(raw.contains("api_key = \"ok2\""));
998    }
999
1000    #[test]
1001    fn save_refuses_to_replace_an_unreadable_existing_config() {
1002        let (_dir, path) = temp_config(None);
1003        let original = [0xff, 0xfe, 0xfd];
1004        std::fs::write(&path, original).unwrap();
1005        let state = state_with("new-secret", "", VendorId::Zai);
1006
1007        assert!(save_to_path(&state, &path).is_err());
1008        assert_eq!(std::fs::read(&path).unwrap(), original);
1009    }
1010
1011    #[test]
1012    fn save_does_not_write_empty_key_when_dirty_but_blank() {
1013        let (_dir, path) = temp_config(None);
1014        let mut s = blank_state(VendorId::Anthropic);
1015        // Focus each key, do nothing but mark dirty (blank).
1016        for k in &mut s.keys {
1017            k.dirty = true;
1018        }
1019        save_to_path(&s, &path).unwrap();
1020        let raw = std::fs::read_to_string(&path).unwrap();
1021        assert!(!raw.contains("api_key ="));
1022    }
1023
1024    #[test]
1025    #[cfg(unix)]
1026    fn save_chmods_to_600() {
1027        use std::os::unix::fs::PermissionsExt;
1028        let (_dir, path) = temp_config(None);
1029        let s = state_with("zk", "ok", VendorId::Zai);
1030        save_to_path(&s, &path).unwrap();
1031        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1032        assert_eq!(mode & 0o777, 0o600);
1033    }
1034
1035    #[test]
1036    fn tab_cycles_focus_from_primary_to_first_key() {
1037        let mut s = blank_state(VendorId::Anthropic);
1038        assert_eq!(
1039            handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
1040            Action::Continue
1041        );
1042        assert_eq!(s.focus, Focus::Key(0));
1043        assert_eq!(
1044            handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
1045            Action::Continue
1046        );
1047        assert_eq!(s.focus, Focus::Primary);
1048    }
1049
1050    #[test]
1051    fn esc_closes_without_saving() {
1052        let mut s = blank_state(VendorId::Anthropic);
1053        assert_eq!(
1054            handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
1055            Action::Close
1056        );
1057    }
1058
1059    #[test]
1060    fn left_right_cycles_primary_vendor() {
1061        // Canonical order (VendorId::all): Anthropic, AnthropicApi, Openai, …
1062        let mut s = blank_state(VendorId::Anthropic);
1063        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1064        assert_eq!(s.primary, VendorId::AnthropicApi);
1065        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1066        assert_eq!(s.primary, VendorId::Openai);
1067        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1068        assert_eq!(s.primary, VendorId::AnthropicApi);
1069    }
1070
1071    #[test]
1072    fn left_right_offers_enabled_vendors_only() {
1073        // The selector must never land on a vendor the widget cannot use.
1074        let mut s = blank_state(VendorId::Anthropic);
1075        s.primary_choices = vec![VendorId::Anthropic, VendorId::Grok];
1076        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1077        assert_eq!(s.primary, VendorId::Grok);
1078        // Wraps within the enabled set rather than walking into disabled ones.
1079        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1080        assert_eq!(s.primary, VendorId::Anthropic);
1081        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1082        assert_eq!(s.primary, VendorId::Grok);
1083    }
1084
1085    #[test]
1086    fn no_enabled_vendors_leaves_primary_selector_inert() {
1087        let mut s = blank_state(VendorId::Anthropic);
1088        s.primary_choices = vec![];
1089        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1090        assert_eq!(s.primary, VendorId::Anthropic);
1091    }
1092
1093    #[test]
1094    fn save_does_not_write_a_disabled_primary() {
1095        // Saving an API key must not persist a primary the resolver would
1096        // ignore; an existing value in the file stays untouched.
1097        let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
1098        let mut s = state_with("zk", "ok", VendorId::Grok);
1099        s.primary_choices = vec![VendorId::Anthropic];
1100        save_to_path(&s, &path).unwrap();
1101        let raw = std::fs::read_to_string(&path).unwrap();
1102        assert!(raw.contains("primary = \"anthropic\""));
1103        assert!(!raw.contains("primary = \"grok\""));
1104        // The keys still saved.
1105        assert!(raw.contains("zk"));
1106    }
1107
1108    #[test]
1109    fn save_removes_an_inline_key_the_user_cleared() {
1110        // Clearing the field in the overlay must delete the secret from the
1111        // file — otherwise there is no way to remove it short of hand-editing.
1112        let (_dir, path) = temp_config(Some(
1113            "[zai]\nenabled = true\napi_key = \"old-secret\"\nplan_tier = \"pro\"\n",
1114        ));
1115        let mut s = blank_state(VendorId::Zai);
1116        s.primary_choices = vec![VendorId::Zai];
1117        s.keys[key_index(VendorId::Zai)] = KeyInput::default();
1118        s.keys[key_index(VendorId::Zai)].dirty = true;
1119        save_to_path(&s, &path).unwrap();
1120        let raw = std::fs::read_to_string(&path).unwrap();
1121        assert!(!raw.contains("old-secret"));
1122        assert!(!raw.contains("api_key"));
1123        // Unrelated fields in the same section survive.
1124        assert!(raw.contains("plan_tier = \"pro\""));
1125    }
1126
1127    #[test]
1128    fn untouched_key_field_is_left_alone() {
1129        // Not dirty => the file's existing secret must survive a save.
1130        let (_dir, path) = temp_config(Some("[zai]\napi_key = \"keep-me\"\n"));
1131        let mut s = blank_state(VendorId::Zai);
1132        s.primary_choices = vec![VendorId::Zai];
1133        save_to_path(&s, &path).unwrap();
1134        let raw = std::fs::read_to_string(&path).unwrap();
1135        assert!(raw.contains("keep-me"));
1136    }
1137
1138    #[test]
1139    fn typing_edits_the_focused_key_only() {
1140        let mut s = blank_state(VendorId::Anthropic);
1141        s.focus = Focus::Key(key_index(VendorId::Grok));
1142        for c in "xai-abc".chars() {
1143            handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
1144        }
1145        assert_eq!(s.keys[key_index(VendorId::Grok)].buf, "xai-abc");
1146        assert!(s.keys[key_index(VendorId::Grok)].dirty);
1147        // No other field was touched.
1148        assert!(s.keys[key_index(VendorId::Zai)].buf.is_empty());
1149    }
1150
1151    #[test]
1152    fn ctrl_v_toggles_reveal_on_focused_key_field() {
1153        let mut s = blank_state(VendorId::Anthropic);
1154        let zi = key_index(VendorId::Zai);
1155        s.focus = Focus::Key(zi);
1156        s.keys[zi] = KeyInput::from_config(Some("secret"));
1157        assert!(!s.keys[zi].revealed);
1158        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1159        assert!(s.keys[zi].revealed);
1160        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1161        assert!(!s.keys[zi].revealed);
1162    }
1163
1164    #[test]
1165    fn control_chorded_chars_do_not_type_into_fields() {
1166        let mut s = blank_state(VendorId::Anthropic);
1167        s.focus = Focus::Key(0);
1168        // Ctrl-A must NOT insert a literal 'a' or mark the field dirty.
1169        handle_key(&mut s, KeyCode::Char('a'), KeyModifiers::CONTROL);
1170        assert!(s.keys[0].buf.is_empty());
1171        assert!(!s.keys[0].dirty);
1172        // Ctrl-C quits the host TUI even while the overlay owns focus.
1173        assert_eq!(
1174            handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
1175            Action::Quit
1176        );
1177        // A plain char still types normally.
1178        handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
1179        assert_eq!(s.keys[0].buf, "x");
1180    }
1181
1182    #[test]
1183    fn ctrl_v_on_non_key_focus_is_noop() {
1184        let mut s = blank_state(VendorId::Anthropic);
1185        s.focus = Focus::Primary;
1186        // Must not panic when no key field is focused.
1187        assert_eq!(
1188            handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL),
1189            Action::Continue
1190        );
1191    }
1192
1193    fn state_focused_on_zai() -> SettingsState {
1194        let mut state = blank_state(VendorId::Anthropic);
1195        state.focus = Focus::Key(key_index(VendorId::Zai));
1196        state
1197    }
1198
1199    #[test]
1200    fn handle_key_ctrl_c_quits_without_typing_into_key_field() {
1201        let mut s = state_focused_on_zai();
1202        let zi = key_index(VendorId::Zai);
1203        assert_eq!(
1204            handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
1205            Action::Quit
1206        );
1207        assert!(s.keys[zi].buf.is_empty());
1208        // Untouched means save still leaves an existing key on disk alone.
1209        assert!(!s.keys[zi].dirty);
1210    }
1211
1212    #[test]
1213    fn handle_key_alt_chord_does_not_type_into_key_field() {
1214        let mut s = state_focused_on_zai();
1215        let zi = key_index(VendorId::Zai);
1216        handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::ALT);
1217        assert!(s.keys[zi].buf.is_empty());
1218        assert!(!s.keys[zi].dirty);
1219    }
1220
1221    #[test]
1222    fn handle_key_platform_modifier_chords_do_not_type_into_key_field() {
1223        for modifier in [KeyModifiers::SUPER, KeyModifiers::HYPER, KeyModifiers::META] {
1224            let mut s = state_focused_on_zai();
1225            let zi = key_index(VendorId::Zai);
1226            handle_key(&mut s, KeyCode::Char('x'), modifier);
1227            assert!(s.keys[zi].buf.is_empty(), "modifier {modifier:?}");
1228            assert!(!s.keys[zi].dirty, "modifier {modifier:?}");
1229        }
1230    }
1231
1232    #[test]
1233    fn handle_key_shift_still_types_uppercase() {
1234        let mut s = state_focused_on_zai();
1235        let zi = key_index(VendorId::Zai);
1236        handle_key(&mut s, KeyCode::Char('A'), KeyModifiers::SHIFT);
1237        assert_eq!(s.keys[zi].buf, "A");
1238        assert!(s.keys[zi].dirty);
1239    }
1240
1241    #[test]
1242    fn handle_key_plain_space_still_cycles_primary_vendor() {
1243        let mut s = blank_state(VendorId::Anthropic);
1244        handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
1245        assert_eq!(s.primary, VendorId::AnthropicApi);
1246    }
1247
1248    #[test]
1249    fn handle_key_ctrl_s_attempts_save_from_any_field() {
1250        let (_dir, path) = temp_config(None);
1251        let s = state_with("zk", "ok", VendorId::Zai);
1252        save_to_path(&s, &path).unwrap();
1253        let raw = std::fs::read_to_string(&path).unwrap();
1254        assert!(raw.contains("api_key = \"zk\""));
1255    }
1256    #[test]
1257    fn save_to_path_writes_kimi_key_when_dirty() {
1258        let (_dir, path) = temp_config(None);
1259        let mut s = blank_state(VendorId::Anthropic);
1260        let kimi = key_index(VendorId::Kimi);
1261        s.keys[kimi] = KeyInput::from_config(Some("kk"));
1262        s.keys[kimi].dirty = true;
1263        save_to_path(&s, &path).unwrap();
1264        let raw = std::fs::read_to_string(&path).unwrap();
1265        assert!(raw.contains("[kimi]"));
1266        assert!(raw.contains("api_key = \"kk\""));
1267    }
1268
1269    #[test]
1270    fn settings_save_uses_the_same_config_path_as_load() {
1271        assert_eq!(
1272            default_config_path().unwrap(),
1273            crate::config::resolved_path().unwrap()
1274        );
1275    }
1276}