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