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    }
748}
749
750/// Center a rectangle of `percent_x * percent_y` over `r`.
751fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
752    let popup_h = (r.height * percent_y) / 100;
753    let popup_w = (r.width * percent_x) / 100;
754    Rect {
755        x: r.x + (r.width - popup_w) / 2,
756        y: r.y + (r.height - popup_h) / 2,
757        width: popup_w,
758        height: popup_h,
759    }
760}
761
762// crossterm types live behind ratatui; re-exported here for handle_key callers.
763pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768    use tempfile::TempDir;
769
770    fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
771        crate::cache::closed_temp_file("config.toml", initial)
772    }
773
774    fn key_index(id: VendorId) -> usize {
775        KEY_VENDORS.iter().position(|kv| kv.id == id).unwrap()
776    }
777
778    fn blank_state(primary: VendorId) -> SettingsState {
779        SettingsState {
780            focus: Focus::Primary,
781            primary_choices: VendorId::all().to_vec(),
782            primary,
783            keys: KEY_VENDORS.iter().map(|_| KeyInput::default()).collect(),
784            status: String::new(),
785        }
786    }
787
788    /// State with a Z.AI key and an OpenRouter key, both marked dirty.
789    fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
790        let mut s = blank_state(primary);
791        s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some(zai));
792        s.keys[key_index(VendorId::Zai)].dirty = true;
793        s.keys[key_index(VendorId::Openrouter)] = KeyInput::from_config(Some(opr));
794        s.keys[key_index(VendorId::Openrouter)].dirty = true;
795        s
796    }
797
798    #[test]
799    fn focus_cycles_through_primary_all_keys_and_save() {
800        let mut f = Focus::Primary;
801        let mut seen = vec![f];
802        // Full cycle = Primary + N key rows + Save.
803        for _ in 0..(KEY_VENDORS.len() + 2) {
804            f = f.next();
805            seen.push(f);
806        }
807        // Primary, Key(0..n), Save, back to Primary.
808        assert_eq!(seen.first(), Some(&Focus::Primary));
809        assert_eq!(seen.last(), Some(&Focus::Primary));
810        assert!(seen.contains(&Focus::Key(0)));
811        assert!(seen.contains(&Focus::Key(KEY_VENDORS.len() - 1)));
812        assert!(seen.contains(&Focus::Save));
813        // prev() is the inverse of next().
814        assert_eq!(Focus::Primary.next().prev(), Focus::Primary);
815        assert_eq!(Focus::Save.prev().next(), Focus::Save);
816        assert_eq!(Focus::Primary.prev(), Focus::Save);
817    }
818
819    #[test]
820    fn every_key_vendor_has_a_field() {
821        // Every enabled-by-key vendor must be reachable in the form.
822        for id in [
823            VendorId::Zai,
824            VendorId::Openrouter,
825            VendorId::Deepseek,
826            VendorId::Kilo,
827            VendorId::Novita,
828            VendorId::Moonshot,
829            VendorId::Grok,
830        ] {
831            assert!(
832                KEY_VENDORS.iter().any(|kv| kv.id == id),
833                "{id:?} has no key field"
834            );
835        }
836        // OAuth vendors are intentionally absent.
837        assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Anthropic));
838        assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Openai));
839    }
840
841    #[test]
842    fn from_config_prefills_existing_keys() {
843        let mut cfg = Config::default();
844        cfg.kilo.api_key = Some("sk-kilo".into());
845        let s = SettingsState::from_config(&cfg);
846        assert_eq!(s.keys[key_index(VendorId::Kilo)].buf, "sk-kilo");
847        assert!(!s.keys[key_index(VendorId::Kilo)].dirty);
848    }
849
850    #[test]
851    fn from_config_offers_enabled_vendors_only() {
852        let cfg = Config::default();
853        let s = SettingsState::from_config(&cfg);
854        assert_eq!(s.primary_choices, cfg.enabled_vendors());
855        // Opt-in vendors are disabled by default and must not be offered.
856        assert!(!s.primary_choices.contains(&VendorId::Grok));
857        assert!(s.primary_choices.contains(&s.primary));
858    }
859
860    #[test]
861    fn from_config_falls_back_when_configured_primary_is_disabled() {
862        // Grok is opt-in; a config naming it as primary without enabling it
863        // must display the first enabled vendor instead.
864        let mut cfg = Config::default();
865        cfg.ui.primary = Some(VendorId::Grok);
866        let s = SettingsState::from_config(&cfg);
867        assert_ne!(s.primary, VendorId::Grok);
868        assert_eq!(Some(s.primary), cfg.enabled_vendors().first().copied());
869    }
870
871    #[test]
872    fn key_input_insert_backspace_arrow() {
873        let mut k = KeyInput::default();
874        k.insert_char('a');
875        k.insert_char('b');
876        k.insert_char('c');
877        assert_eq!(k.buf, "abc");
878        assert_eq!(k.cursor, 3);
879        assert!(k.dirty);
880        k.move_left();
881        k.move_left();
882        assert_eq!(k.cursor, 1);
883        k.insert_char('x');
884        assert_eq!(k.buf, "axbc");
885        assert_eq!(k.cursor, 2);
886        k.backspace();
887        assert_eq!(k.buf, "abc");
888        assert_eq!(k.cursor, 1);
889    }
890
891    #[test]
892    fn key_input_masks_by_default_reveals_on_toggle() {
893        let mut k = KeyInput::default();
894        for c in "secret-key".chars() {
895            k.insert_char(c);
896        }
897        assert_eq!(k.display(), "•".repeat(10));
898        k.toggle_reveal();
899        assert_eq!(k.display(), "secret-key");
900    }
901
902    #[test]
903    fn key_input_handles_unicode() {
904        let mut k = KeyInput::default();
905        k.insert_char('a');
906        k.insert_char('→');
907        k.insert_char('b');
908        assert_eq!(k.buf, "a→b");
909        assert_eq!(k.cursor, 3);
910        k.move_left();
911        k.backspace();
912        assert_eq!(k.buf, "ab");
913    }
914
915    #[test]
916    fn value_text_shows_cursor_and_empty_states() {
917        let mut k = KeyInput::default();
918        assert_eq!(value_text(&k, false), "(empty)");
919        assert_eq!(value_text(&k, true), "‸");
920        k.insert_char('a');
921        k.insert_char('b');
922        // masked + cursor at end
923        assert_eq!(value_text(&k, true), "••‸");
924        assert_eq!(value_text(&k, false), "••");
925    }
926
927    #[test]
928    fn save_writes_key_and_enables_vendor() {
929        let (_dir, path) = temp_config(None);
930        let mut s = blank_state(VendorId::Kilo);
931        s.keys[key_index(VendorId::Kilo)] = KeyInput::from_config(Some("sk-kilo"));
932        s.keys[key_index(VendorId::Kilo)].dirty = true;
933        save_to_path(&s, &path).unwrap();
934        let raw = std::fs::read_to_string(&path).unwrap();
935        assert!(raw.contains("primary = \"kilo\""));
936        assert!(raw.contains("[kilo]"));
937        assert!(raw.contains("api_key = \"sk-kilo\""));
938        assert!(raw.contains("enabled = true"));
939    }
940
941    #[test]
942    fn save_writes_minimal_toml_when_starting_empty() {
943        let (_dir, path) = temp_config(None);
944        let s = state_with("zk", "ok", VendorId::Zai);
945        save_to_path(&s, &path).unwrap();
946        let raw = std::fs::read_to_string(&path).unwrap();
947        assert!(raw.contains("primary = \"zai\""));
948        assert!(raw.contains("[zai]"));
949        assert!(raw.contains("api_key = \"zk\""));
950        assert!(raw.contains("[openrouter]"));
951        assert!(raw.contains("api_key = \"ok\""));
952    }
953
954    #[test]
955    fn save_preserves_existing_comments_and_unrelated_fields() {
956        let (_dir, path) = temp_config(Some(
957            r##"# my comment
958[ui]
959# pre-existing comment
960primary = "anthropic"
961
962[zai]
963enabled = true
964api_key_env = "ZAI_API_KEY"
965# tier comment
966plan_tier = "pro"
967
968[openrouter]
969enabled = true
970api_key_env = "OPENROUTER_API_KEY"
971"##,
972        ));
973
974        let s = state_with("zk2", "ok2", VendorId::Openrouter);
975        save_to_path(&s, &path).unwrap();
976
977        let raw = std::fs::read_to_string(&path).unwrap();
978        assert!(raw.contains("# my comment"));
979        assert!(raw.contains("# pre-existing comment"));
980        assert!(raw.contains("# tier comment"));
981        assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
982        assert!(raw.contains("plan_tier = \"pro\""));
983        assert!(raw.contains("primary = \"openrouter\""));
984        assert!(raw.contains("api_key = \"zk2\""));
985        assert!(raw.contains("api_key = \"ok2\""));
986    }
987
988    #[test]
989    fn save_refuses_to_replace_an_unreadable_existing_config() {
990        let (_dir, path) = temp_config(None);
991        let original = [0xff, 0xfe, 0xfd];
992        std::fs::write(&path, original).unwrap();
993        let state = state_with("new-secret", "", VendorId::Zai);
994
995        assert!(save_to_path(&state, &path).is_err());
996        assert_eq!(std::fs::read(&path).unwrap(), original);
997    }
998
999    #[test]
1000    fn save_does_not_write_empty_key_when_dirty_but_blank() {
1001        let (_dir, path) = temp_config(None);
1002        let mut s = blank_state(VendorId::Anthropic);
1003        // Focus each key, do nothing but mark dirty (blank).
1004        for k in &mut s.keys {
1005            k.dirty = true;
1006        }
1007        save_to_path(&s, &path).unwrap();
1008        let raw = std::fs::read_to_string(&path).unwrap();
1009        assert!(!raw.contains("api_key ="));
1010    }
1011
1012    #[test]
1013    #[cfg(unix)]
1014    fn save_chmods_to_600() {
1015        use std::os::unix::fs::PermissionsExt;
1016        let (_dir, path) = temp_config(None);
1017        let s = state_with("zk", "ok", VendorId::Zai);
1018        save_to_path(&s, &path).unwrap();
1019        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1020        assert_eq!(mode & 0o777, 0o600);
1021    }
1022
1023    #[test]
1024    fn tab_cycles_focus_from_primary_to_first_key() {
1025        let mut s = blank_state(VendorId::Anthropic);
1026        assert_eq!(
1027            handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
1028            Action::Continue
1029        );
1030        assert_eq!(s.focus, Focus::Key(0));
1031        assert_eq!(
1032            handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
1033            Action::Continue
1034        );
1035        assert_eq!(s.focus, Focus::Primary);
1036    }
1037
1038    #[test]
1039    fn esc_closes_without_saving() {
1040        let mut s = blank_state(VendorId::Anthropic);
1041        assert_eq!(
1042            handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
1043            Action::Close
1044        );
1045    }
1046
1047    #[test]
1048    fn left_right_cycles_primary_vendor() {
1049        // Canonical order (VendorId::all): Anthropic, AnthropicApi, Openai, …
1050        let mut s = blank_state(VendorId::Anthropic);
1051        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1052        assert_eq!(s.primary, VendorId::AnthropicApi);
1053        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1054        assert_eq!(s.primary, VendorId::Openai);
1055        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1056        assert_eq!(s.primary, VendorId::AnthropicApi);
1057    }
1058
1059    #[test]
1060    fn left_right_offers_enabled_vendors_only() {
1061        // The selector must never land on a vendor the widget cannot use.
1062        let mut s = blank_state(VendorId::Anthropic);
1063        s.primary_choices = vec![VendorId::Anthropic, VendorId::Grok];
1064        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1065        assert_eq!(s.primary, VendorId::Grok);
1066        // Wraps within the enabled set rather than walking into disabled ones.
1067        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1068        assert_eq!(s.primary, VendorId::Anthropic);
1069        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1070        assert_eq!(s.primary, VendorId::Grok);
1071    }
1072
1073    #[test]
1074    fn no_enabled_vendors_leaves_primary_selector_inert() {
1075        let mut s = blank_state(VendorId::Anthropic);
1076        s.primary_choices = vec![];
1077        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1078        assert_eq!(s.primary, VendorId::Anthropic);
1079    }
1080
1081    #[test]
1082    fn save_does_not_write_a_disabled_primary() {
1083        // Saving an API key must not persist a primary the resolver would
1084        // ignore; an existing value in the file stays untouched.
1085        let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
1086        let mut s = state_with("zk", "ok", VendorId::Grok);
1087        s.primary_choices = vec![VendorId::Anthropic];
1088        save_to_path(&s, &path).unwrap();
1089        let raw = std::fs::read_to_string(&path).unwrap();
1090        assert!(raw.contains("primary = \"anthropic\""));
1091        assert!(!raw.contains("primary = \"grok\""));
1092        // The keys still saved.
1093        assert!(raw.contains("zk"));
1094    }
1095
1096    #[test]
1097    fn save_removes_an_inline_key_the_user_cleared() {
1098        // Clearing the field in the overlay must delete the secret from the
1099        // file — otherwise there is no way to remove it short of hand-editing.
1100        let (_dir, path) = temp_config(Some(
1101            "[zai]\nenabled = true\napi_key = \"old-secret\"\nplan_tier = \"pro\"\n",
1102        ));
1103        let mut s = blank_state(VendorId::Zai);
1104        s.primary_choices = vec![VendorId::Zai];
1105        s.keys[key_index(VendorId::Zai)] = KeyInput::default();
1106        s.keys[key_index(VendorId::Zai)].dirty = true;
1107        save_to_path(&s, &path).unwrap();
1108        let raw = std::fs::read_to_string(&path).unwrap();
1109        assert!(!raw.contains("old-secret"));
1110        assert!(!raw.contains("api_key"));
1111        // Unrelated fields in the same section survive.
1112        assert!(raw.contains("plan_tier = \"pro\""));
1113    }
1114
1115    #[test]
1116    fn untouched_key_field_is_left_alone() {
1117        // Not dirty => the file's existing secret must survive a save.
1118        let (_dir, path) = temp_config(Some("[zai]\napi_key = \"keep-me\"\n"));
1119        let mut s = blank_state(VendorId::Zai);
1120        s.primary_choices = vec![VendorId::Zai];
1121        save_to_path(&s, &path).unwrap();
1122        let raw = std::fs::read_to_string(&path).unwrap();
1123        assert!(raw.contains("keep-me"));
1124    }
1125
1126    #[test]
1127    fn typing_edits_the_focused_key_only() {
1128        let mut s = blank_state(VendorId::Anthropic);
1129        s.focus = Focus::Key(key_index(VendorId::Grok));
1130        for c in "xai-abc".chars() {
1131            handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
1132        }
1133        assert_eq!(s.keys[key_index(VendorId::Grok)].buf, "xai-abc");
1134        assert!(s.keys[key_index(VendorId::Grok)].dirty);
1135        // No other field was touched.
1136        assert!(s.keys[key_index(VendorId::Zai)].buf.is_empty());
1137    }
1138
1139    #[test]
1140    fn ctrl_v_toggles_reveal_on_focused_key_field() {
1141        let mut s = blank_state(VendorId::Anthropic);
1142        let zi = key_index(VendorId::Zai);
1143        s.focus = Focus::Key(zi);
1144        s.keys[zi] = KeyInput::from_config(Some("secret"));
1145        assert!(!s.keys[zi].revealed);
1146        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1147        assert!(s.keys[zi].revealed);
1148        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1149        assert!(!s.keys[zi].revealed);
1150    }
1151
1152    #[test]
1153    fn control_chorded_chars_do_not_type_into_fields() {
1154        let mut s = blank_state(VendorId::Anthropic);
1155        s.focus = Focus::Key(0);
1156        // Ctrl-A must NOT insert a literal 'a' or mark the field dirty.
1157        handle_key(&mut s, KeyCode::Char('a'), KeyModifiers::CONTROL);
1158        assert!(s.keys[0].buf.is_empty());
1159        assert!(!s.keys[0].dirty);
1160        // Ctrl-C quits the host TUI even while the overlay owns focus.
1161        assert_eq!(
1162            handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
1163            Action::Quit
1164        );
1165        // A plain char still types normally.
1166        handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
1167        assert_eq!(s.keys[0].buf, "x");
1168    }
1169
1170    #[test]
1171    fn ctrl_v_on_non_key_focus_is_noop() {
1172        let mut s = blank_state(VendorId::Anthropic);
1173        s.focus = Focus::Primary;
1174        // Must not panic when no key field is focused.
1175        assert_eq!(
1176            handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL),
1177            Action::Continue
1178        );
1179    }
1180
1181    fn state_focused_on_zai() -> SettingsState {
1182        let mut state = blank_state(VendorId::Anthropic);
1183        state.focus = Focus::Key(key_index(VendorId::Zai));
1184        state
1185    }
1186
1187    #[test]
1188    fn handle_key_ctrl_c_quits_without_typing_into_key_field() {
1189        let mut s = state_focused_on_zai();
1190        let zi = key_index(VendorId::Zai);
1191        assert_eq!(
1192            handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
1193            Action::Quit
1194        );
1195        assert!(s.keys[zi].buf.is_empty());
1196        // Untouched means save still leaves an existing key on disk alone.
1197        assert!(!s.keys[zi].dirty);
1198    }
1199
1200    #[test]
1201    fn handle_key_alt_chord_does_not_type_into_key_field() {
1202        let mut s = state_focused_on_zai();
1203        let zi = key_index(VendorId::Zai);
1204        handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::ALT);
1205        assert!(s.keys[zi].buf.is_empty());
1206        assert!(!s.keys[zi].dirty);
1207    }
1208
1209    #[test]
1210    fn handle_key_platform_modifier_chords_do_not_type_into_key_field() {
1211        for modifier in [KeyModifiers::SUPER, KeyModifiers::HYPER, KeyModifiers::META] {
1212            let mut s = state_focused_on_zai();
1213            let zi = key_index(VendorId::Zai);
1214            handle_key(&mut s, KeyCode::Char('x'), modifier);
1215            assert!(s.keys[zi].buf.is_empty(), "modifier {modifier:?}");
1216            assert!(!s.keys[zi].dirty, "modifier {modifier:?}");
1217        }
1218    }
1219
1220    #[test]
1221    fn handle_key_shift_still_types_uppercase() {
1222        let mut s = state_focused_on_zai();
1223        let zi = key_index(VendorId::Zai);
1224        handle_key(&mut s, KeyCode::Char('A'), KeyModifiers::SHIFT);
1225        assert_eq!(s.keys[zi].buf, "A");
1226        assert!(s.keys[zi].dirty);
1227    }
1228
1229    #[test]
1230    fn handle_key_plain_space_still_cycles_primary_vendor() {
1231        let mut s = blank_state(VendorId::Anthropic);
1232        handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
1233        assert_eq!(s.primary, VendorId::AnthropicApi);
1234    }
1235
1236    #[test]
1237    fn handle_key_ctrl_s_attempts_save_from_any_field() {
1238        let (_dir, path) = temp_config(None);
1239        let s = state_with("zk", "ok", VendorId::Zai);
1240        save_to_path(&s, &path).unwrap();
1241        let raw = std::fs::read_to_string(&path).unwrap();
1242        assert!(raw.contains("api_key = \"zk\""));
1243    }
1244    #[test]
1245    fn save_to_path_writes_kimi_key_when_dirty() {
1246        let (_dir, path) = temp_config(None);
1247        let mut s = blank_state(VendorId::Anthropic);
1248        let kimi = key_index(VendorId::Kimi);
1249        s.keys[kimi] = KeyInput::from_config(Some("kk"));
1250        s.keys[kimi].dirty = true;
1251        save_to_path(&s, &path).unwrap();
1252        let raw = std::fs::read_to_string(&path).unwrap();
1253        assert!(raw.contains("[kimi]"));
1254        assert!(raw.contains("api_key = \"kk\""));
1255    }
1256
1257    #[test]
1258    fn settings_save_uses_the_same_config_path_as_load() {
1259        assert_eq!(
1260            default_config_path().unwrap(),
1261            crate::config::resolved_path().unwrap()
1262        );
1263    }
1264}