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