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 a credential for any API-key-authenticated vendor
3//! (including Z.AI, Kimi, MiniMax, and the balance vendors) without hand-editing
4//! config.toml. Anthropic, OpenAI, GitHub Copilot, Cursor, Kiro, Antigravity,
5//! Grok Bot, and Command Code authenticate through official or local product
6//! state, so they have no credential field here — there is nothing to paste,
7//! and a field would only imply otherwise. Kimi keeps
8//! its credential field because a platform key is still one of its two credentials, but
9//! a subscriber whose credential is the Kimi Code CLI login has nothing to paste
10//! and enables `[kimi]` in config.toml instead.
11//!
12//! Below the credentials sits the Providers section (#244): one on/off switch
13//! per known vendor, writing `enabled = true/false` under its own config
14//! section. Below that are the `[notifications]` fields: a toggle for the
15//! quota-threshold alerts and their threshold in percent. Everything persists
16//! through `toml_edit` so the existing config keeps its comments,
17//! whitespace, and unrelated fields. Writing a key also flips that vendor's
18//! `enabled = true` (the opt-in vendors are disabled by default), so "paste the
19//! credential and save" is all it takes — an explicit off switch toggled in
20//! the same save wins. Files with inline credentials are atomically written
21//! and `chmod 600`ed.
22
23use std::collections::BTreeMap;
24use std::io::BufRead;
25use std::path::{Path, PathBuf};
26
27use ratatui::Frame;
28use ratatui::layout::{Constraint, Direction, Layout, Rect};
29use ratatui::style::Modifier;
30use ratatui::text::{Line, Span};
31use ratatui::widgets::{Clear, Paragraph};
32use ratatui_bubbletea_theme::BubbleTheme;
33use serde::{Deserialize, Serialize};
34use toml_edit::{DocumentMut, value};
35
36use crate::config::{
37    Config, edit_config_document, is_valid_env_var_name, read_config_document, set_bool, set_value,
38};
39use crate::error::{AppError, Result};
40use crate::theme::Theme;
41use crate::tui::style::bubble_theme;
42use crate::vendor::VendorId;
43
44/// A vendor that authenticates with an inline credential. The order of this
45/// table is the tab order of the credential fields and the layout of the
46/// state's `keys` vec.
47pub struct KeyVendor {
48    pub id: VendorId,
49    pub label: &'static str,
50    pub section: &'static str,
51    /// Config field that stores the credential (`api_key` for most vendors).
52    pub config_key: &'static str,
53    /// Human-readable name shown in the native settings form.
54    pub secret_label: &'static str,
55    /// Extra hint after the env var (e.g. "management key"). Empty for none.
56    pub note: &'static str,
57}
58
59pub const KEY_VENDORS: &[KeyVendor] = &[
60    KeyVendor {
61        id: VendorId::AnthropicApi,
62        label: "Anthropic API",
63        section: VendorId::AnthropicApi.config_section(),
64        config_key: "api_key",
65        secret_label: "API key",
66        note: "admin key — monthly spend",
67    },
68    KeyVendor {
69        id: VendorId::Zai,
70        label: "Z.AI",
71        section: VendorId::Zai.config_section(),
72        config_key: "api_key",
73        secret_label: "API key",
74        note: "",
75    },
76    KeyVendor {
77        id: VendorId::Openrouter,
78        label: "OpenRouter",
79        section: VendorId::Openrouter.config_section(),
80        config_key: "api_key",
81        secret_label: "API key",
82        note: "",
83    },
84    KeyVendor {
85        id: VendorId::Deepseek,
86        label: "DeepSeek",
87        section: VendorId::Deepseek.config_section(),
88        config_key: "api_key",
89        secret_label: "API key",
90        note: "",
91    },
92    KeyVendor {
93        id: VendorId::Deepinfra,
94        label: "DeepInfra",
95        section: VendorId::Deepinfra.config_section(),
96        config_key: "api_key",
97        secret_label: "API key",
98        note: "billing balance and monthly spend",
99    },
100    KeyVendor {
101        id: VendorId::Kimi,
102        label: "Kimi",
103        section: VendorId::Kimi.config_section(),
104        config_key: "api_key",
105        secret_label: "API key",
106        note: "coding-plan usage",
107    },
108    KeyVendor {
109        id: VendorId::Kilo,
110        label: "Kilo",
111        section: VendorId::Kilo.config_section(),
112        config_key: "api_key",
113        secret_label: "API key",
114        note: "",
115    },
116    KeyVendor {
117        id: VendorId::Novita,
118        label: "Novita",
119        section: VendorId::Novita.config_section(),
120        config_key: "api_key",
121        secret_label: "API key",
122        note: "",
123    },
124    KeyVendor {
125        id: VendorId::Synthetic,
126        label: "Synthetic",
127        section: VendorId::Synthetic.config_section(),
128        config_key: "api_key",
129        secret_label: "API key",
130        note: "weekly credit quota",
131    },
132    KeyVendor {
133        id: VendorId::DeepRelay,
134        label: "DeepRelay",
135        section: VendorId::DeepRelay.config_section(),
136        config_key: "api_key",
137        secret_label: "API key",
138        note: "key needs the billing:read scope",
139    },
140    KeyVendor {
141        id: VendorId::Moonshot,
142        label: "Moonshot",
143        section: VendorId::Moonshot.config_section(),
144        config_key: "api_key",
145        secret_label: "API key",
146        note: "account balance",
147    },
148    KeyVendor {
149        id: VendorId::Grok,
150        label: "Grok",
151        section: VendorId::Grok.config_section(),
152        config_key: "api_key",
153        secret_label: "API key",
154        note: "management key, not the inference key",
155    },
156    KeyVendor {
157        id: VendorId::Minimax,
158        label: "MiniMax",
159        section: VendorId::Minimax.config_section(),
160        config_key: "api_key",
161        secret_label: "API key",
162        note: "Token Plan subscription key",
163    },
164    KeyVendor {
165        id: VendorId::OpenCodeGo,
166        label: "OpenCode Go",
167        section: VendorId::OpenCodeGo.config_section(),
168        config_key: "api_key",
169        secret_label: "API key",
170        note: "usage quota",
171    },
172    KeyVendor {
173        id: VendorId::Ollama,
174        label: "Ollama Cloud",
175        section: VendorId::Ollama.config_section(),
176        config_key: "api_key",
177        secret_label: "API key",
178        note: "ollama.com/settings/keys",
179    },
180    KeyVendor {
181        id: VendorId::OrcaRouter,
182        label: "OrcaRouter",
183        section: VendorId::OrcaRouter.config_section(),
184        config_key: "api_key",
185        secret_label: "API key",
186        note: "credit balance",
187    },
188    KeyVendor {
189        id: VendorId::Lyceum,
190        label: "Lyceum",
191        section: VendorId::Lyceum.config_section(),
192        config_key: "api_key",
193        secret_label: "API key",
194        note: "billing credits",
195    },
196];
197
198/// How many providers the on/off section lists: every known vendor, in
199/// `VendorId::all()` order (#244). A new vendor grows this automatically; a
200/// guard test pins the focus ring to the real enumeration.
201pub const PROVIDER_SWITCH_COUNT: usize = VendorId::all().len();
202
203/// Which control has keyboard focus. `Key(i)` indexes into [`KEY_VENDORS`];
204/// `Vendor(i)` indexes the provider on/off rows, parallel to
205/// `SettingsState::vendors` / `VendorId::all()`.
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum Focus {
208    Primary,
209    Key(usize),
210    Vendor(usize),
211    NotifyEnabled,
212    NotifyThreshold,
213    Save,
214}
215
216/// An interactive row recorded for mouse hit-testing during the settings draw.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum SettingsRow {
219    /// A focusable control (primary picker, a key field, or the save row).
220    Focus(Focus),
221    /// A value cell that focuses its row and sends the synthetic key through
222    /// [`handle_key`]: space toggles provider / quota-alerts switches,
223    /// `←`/`→` step the primary-vendor radio over its ◀ ▶ arrows.
224    Switch(Focus, KeyCode, KeyModifiers),
225    /// The primary vendor's name cell: clicking opens (or the popup is open
226    /// and a click outside closes) the picker popup.
227    OpenPicker,
228    /// A primary-vendor choice row in the open picker popup; clicking selects
229    /// that vendor.
230    Pick(usize),
231    /// A hint-footer "link": clicking sends the synthetic key through
232    /// [`handle_key`], so save/close/toggle behave exactly as if pressed.
233    HintKey(KeyCode, KeyModifiers),
234}
235
236impl Focus {
237    pub fn next(self) -> Self {
238        match self {
239            Focus::Primary => Focus::Key(0),
240            Focus::Key(i) if i + 1 < KEY_VENDORS.len() => Focus::Key(i + 1),
241            Focus::Key(_) => Focus::Vendor(0),
242            Focus::Vendor(i) if i + 1 < PROVIDER_SWITCH_COUNT => Focus::Vendor(i + 1),
243            Focus::Vendor(_) => Focus::NotifyEnabled,
244            Focus::NotifyEnabled => Focus::NotifyThreshold,
245            Focus::NotifyThreshold => Focus::Save,
246            Focus::Save => Focus::Primary,
247        }
248    }
249    pub fn prev(self) -> Self {
250        match self {
251            Focus::Primary => Focus::Save,
252            Focus::Key(0) => Focus::Primary,
253            Focus::Key(i) => Focus::Key(i - 1),
254            Focus::Vendor(0) => Focus::Key(KEY_VENDORS.len() - 1),
255            Focus::Vendor(i) => Focus::Vendor(i - 1),
256            Focus::NotifyEnabled => Focus::Vendor(PROVIDER_SWITCH_COUNT - 1),
257            Focus::NotifyThreshold => Focus::NotifyEnabled,
258            Focus::Save => Focus::NotifyThreshold,
259        }
260    }
261}
262
263/// Open state of the primary-vendor picker popup: `cursor` indexes
264/// [`SettingsState::primary_choices`], `scroll` is the list's scroll offset.
265#[derive(Debug, Clone, Copy, Default)]
266pub struct PrimaryPicker {
267    pub cursor: usize,
268    pub scroll: u16,
269}
270
271/// Per-field text-input state — cursor + buffer + reveal flag.
272#[derive(Debug, Clone, Default)]
273pub struct KeyInput {
274    pub buf: String,
275    /// Char-index cursor position (0..=buf.chars().count()).
276    pub cursor: usize,
277    /// When true, the field renders the actual characters; otherwise `•`.
278    pub revealed: bool,
279    /// True after the user has typed/edited; only then does save write the
280    /// value back (avoids clobbering an existing key with the empty
281    /// placeholder the user opened the dialog with).
282    pub dirty: bool,
283}
284
285impl KeyInput {
286    pub fn from_config(initial: Option<&str>) -> Self {
287        let buf = initial.unwrap_or("").to_string();
288        let cursor = buf.chars().count();
289        Self {
290            buf,
291            cursor,
292            revealed: false,
293            dirty: false,
294        }
295    }
296
297    pub fn insert_char(&mut self, c: char) {
298        let byte_idx = self.char_to_byte(self.cursor);
299        self.buf.insert(byte_idx, c);
300        self.cursor += 1;
301        self.dirty = true;
302    }
303
304    pub fn backspace(&mut self) {
305        if self.cursor == 0 {
306            return;
307        }
308        let prev_byte = self.char_to_byte(self.cursor - 1);
309        let cur_byte = self.char_to_byte(self.cursor);
310        self.buf.replace_range(prev_byte..cur_byte, "");
311        self.cursor -= 1;
312        self.dirty = true;
313    }
314
315    pub fn delete(&mut self) {
316        let n = self.buf.chars().count();
317        if self.cursor >= n {
318            return;
319        }
320        let cur_byte = self.char_to_byte(self.cursor);
321        let next_byte = self.char_to_byte(self.cursor + 1);
322        self.buf.replace_range(cur_byte..next_byte, "");
323        self.dirty = true;
324    }
325
326    pub fn move_left(&mut self) {
327        if self.cursor > 0 {
328            self.cursor -= 1;
329        }
330    }
331    pub fn move_right(&mut self) {
332        if self.cursor < self.buf.chars().count() {
333            self.cursor += 1;
334        }
335    }
336    pub fn move_home(&mut self) {
337        self.cursor = 0;
338    }
339    pub fn move_end(&mut self) {
340        self.cursor = self.buf.chars().count();
341    }
342    pub fn toggle_reveal(&mut self) {
343        self.revealed = !self.revealed;
344    }
345
346    /// Render for display — bullets when masked, raw chars when revealed.
347    pub fn display(&self) -> String {
348        if self.revealed {
349            self.buf.clone()
350        } else {
351            "•".repeat(self.buf.chars().count())
352        }
353    }
354
355    fn char_to_byte(&self, char_idx: usize) -> usize {
356        self.buf
357            .char_indices()
358            .map(|(b, _)| b)
359            .chain(std::iter::once(self.buf.len()))
360            .nth(char_idx)
361            .unwrap_or(self.buf.len())
362    }
363}
364
365/// One provider's on/off switch (#244), parallel to `VendorId::all()`.
366/// `dirty` follows the same only-write-what-the-user-touched rule the
367/// notification fields use.
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
369pub struct ProviderSwitch {
370    pub enabled: bool,
371    pub dirty: bool,
372}
373
374/// Mutable state of the overlay while open.
375#[derive(Debug, Clone)]
376pub struct SettingsState {
377    pub focus: Focus,
378    /// Enabled vendors only. The primary selector must not offer a value that
379    /// cannot actually be used by the widget or TUI.
380    pub primary_choices: Vec<VendorId>,
381    pub primary: VendorId,
382    /// One input per [`KEY_VENDORS`] entry, same order.
383    pub keys: Vec<KeyInput>,
384    /// One on/off switch per known vendor ([`VendorId::all()` order), so any
385    /// provider can be turned on or off from the overlay (#244).
386    pub vendors: Vec<ProviderSwitch>,
387    /// `[notifications] enabled` toggle. Written back only once toggled.
388    pub notify_enabled: bool,
389    pub notify_enabled_dirty: bool,
390    /// `[notifications] threshold`, edited as digits only (1..=100 at save).
391    pub notify_threshold: KeyInput,
392    /// One-line status displayed in the footer ("saved …", "save failed …").
393    pub status: String,
394    /// First body line scrolled into view. The overlay body outgrew every
395    /// terminal once every provider got a row, so rendering follows focus.
396    pub scroll: u16,
397    /// Open picker popup for the primary-vendor radio; `None` while closed.
398    pub picker: Option<PrimaryPicker>,
399}
400
401impl SettingsState {
402    pub fn from_config(cfg: &Config) -> Self {
403        Self::from_config_with(cfg, |name| {
404            std::env::var_os(name).is_some_and(|v| !v.is_empty())
405        })
406    }
407
408    /// [`Self::from_config`] with an injected environment lookup.
409    ///
410    /// The overlay offers a key-only vendor whose env var is already exported,
411    /// so a user need not hand-edit `config.toml` to select it. That is a read
412    /// of ambient state, which a test must never depend on: the AUR `check()`
413    /// runs `cargo test` during `makepkg`, so a test that branched on the real
414    /// environment would fail the install for anyone who exports, say,
415    /// `OLLAMA_API_KEY`. Tests pass their own lookup here.
416    pub fn from_config_with(cfg: &Config, env_set: impl Fn(&str) -> bool) -> Self {
417        let keys = KEY_VENDORS
418            .iter()
419            .map(|kv| KeyInput::from_config(cfg.inline_api_key(kv.id)))
420            .collect();
421        let mut primary_choices = cfg.enabled_vendors();
422        // Copilot credentials belong to GitHub CLI, so a login cannot write a
423        // local key that would also opt it in. Offer it explicitly instead:
424        // selecting it persists both the primary and `enabled = true`.
425        if !primary_choices.contains(&VendorId::Copilot) {
426            primary_choices.push(VendorId::Copilot);
427        }
428        // Key-only vendors are opt-in: without an inline `api_key` and with
429        // the env var empty, the fetch would fail on the first cycle. A user
430        // who already exported the env var (e.g. `OLLAMA_API_KEY`) and wants
431        // to flip `enabled = true` from inside the TUI has to be able to
432        // select the vendor here — otherwise they'd have to hand-edit
433        // `config.toml`, which is the workflow this overlay exists to avoid.
434        // We treat a non-empty env var as a sufficient signal of reachability.
435        for kv in KEY_VENDORS {
436            if primary_choices.contains(&kv.id) {
437                continue;
438            }
439            let env = cfg.api_key_env_for(kv.id);
440            let exported = is_valid_env_var_name(env) && env_set(env);
441            if cfg.inline_api_key(kv.id).is_some() || exported {
442                primary_choices.push(kv.id);
443            }
444        }
445        // A configured but disabled primary is ineffective. Display the first
446        // enabled vendor instead; when none are enabled retain the historical
447        // Anthropic fallback in memory without inventing a persisted primary.
448        // Copilot and the key vendors above are offered while disabled, but
449        // only as an explicit pick: shown as the current primary, an untouched
450        // save would write their `enabled = true` back.
451        let primary = cfg
452            .ui
453            .primary
454            .filter(|vendor| primary_choices.contains(vendor) && cfg.is_enabled(*vendor))
455            .or_else(|| primary_choices.first().copied())
456            .unwrap_or_else(|| cfg.ui.primary.unwrap_or(VendorId::Anthropic));
457        Self {
458            focus: Focus::Primary,
459            primary_choices,
460            primary,
461            keys,
462            vendors: VendorId::all()
463                .iter()
464                .map(|id| ProviderSwitch {
465                    enabled: cfg.is_enabled(*id),
466                    dirty: false,
467                })
468                .collect(),
469            notify_enabled: cfg.notifications.enabled,
470            notify_enabled_dirty: false,
471            notify_threshold: KeyInput::from_config(Some(&cfg.notifications.threshold.to_string())),
472            status: String::new(),
473            scroll: 0,
474            picker: None,
475        }
476    }
477
478    /// The focused key input, if a key row is focused.
479    fn focused_key_mut(&mut self) -> Option<&mut KeyInput> {
480        match self.focus {
481            Focus::Key(i) => self.keys.get_mut(i),
482            _ => None,
483        }
484    }
485
486    /// Move focus through the ring. [`Focus::next`]/[`Focus::prev`] own the
487    /// order; these wrappers keep the call sites reading as state methods.
488    fn next_focus(&self) -> Focus {
489        self.focus.next()
490    }
491
492    fn prev_focus(&self) -> Focus {
493        self.focus.prev()
494    }
495}
496
497/// What the key handler asks the host app to do next.
498#[derive(Debug, Clone, Copy, PartialEq, Eq)]
499pub enum Action {
500    /// Stay open, keep listening for keys.
501    Continue,
502    /// Close the overlay (discard or save already happened).
503    Close,
504    /// Save just succeeded — caller should refresh affected vendors.
505    SavedAndClose,
506    /// Quit the host TUI. Ctrl-C remains global even while the overlay owns
507    /// keyboard focus.
508    Quit,
509}
510
511/// Permission note appended to the "saved" status line. The overlay `chmod
512/// 600`s the file on Unix; Windows has no such step, so the note is empty there.
513#[cfg(unix)]
514const PERMS_NOTE: &str = " (chmod 600)";
515#[cfg(not(unix))]
516const PERMS_NOTE: &str = "";
517
518fn saved_status() -> String {
519    format!(
520        "saved to {}{}",
521        crate::config::config_path_hint(),
522        PERMS_NOTE
523    )
524}
525
526/// Key map. Returns the action to perform after the keypress.
527pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
528    // The vendor picker consumes every key while open (Ctrl-C stays global),
529    // so Esc closes the popup before the modal's Esc arm can.
530    if state.picker.is_some()
531        && !(matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL))
532    {
533        return handle_picker_key(state, code);
534    }
535    if matches!(code, KeyCode::Esc) {
536        return Action::Close;
537    }
538    if matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL) {
539        return Action::Quit;
540    }
541    // Ctrl-S triggers save from any field.
542    if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
543        return try_save(state);
544    }
545    if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
546        if let Some(input) = state.focused_key_mut() {
547            input.toggle_reveal();
548        }
549        return Action::Continue;
550    }
551    match code {
552        KeyCode::Tab | KeyCode::Down => {
553            state.focus = state.next_focus();
554            return Action::Continue;
555        }
556        KeyCode::BackTab | KeyCode::Up => {
557            state.focus = state.prev_focus();
558            return Action::Continue;
559        }
560        _ => {}
561    }
562
563    // A modifier chord is not text. The overlay swallows every key while open,
564    // so every unhandled chord must be ignored rather than corrupting the
565    // secret silently. SHIFT is deliberately not rejected — it is how
566    // uppercase arrives. Ctrl-C was handled above because it is a global quit.
567    if matches!(code, KeyCode::Char(_))
568        && mods.intersects(
569            KeyModifiers::CONTROL
570                | KeyModifiers::ALT
571                | KeyModifiers::SUPER
572                | KeyModifiers::HYPER
573                | KeyModifiers::META,
574        )
575    {
576        return Action::Continue;
577    }
578
579    // Field-specific handling.
580    match state.focus {
581        Focus::Primary => handle_primary(state, code),
582        Focus::Key(i) => {
583            if let Some(input) = state.keys.get_mut(i) {
584                handle_input(input, code);
585            }
586        }
587        Focus::Vendor(i) => handle_provider_switch(state, i, code),
588        Focus::NotifyEnabled => handle_notify_enabled(state, code),
589        Focus::NotifyThreshold => handle_threshold_input(&mut state.notify_threshold, code),
590        Focus::Save => {
591            if matches!(code, KeyCode::Enter) {
592                return try_save(state);
593            }
594        }
595    }
596    Action::Continue
597}
598
599fn try_save(state: &mut SettingsState) -> Action {
600    match save_to_config_default(state) {
601        Ok(()) => {
602            state.status = saved_status();
603            Action::SavedAndClose
604        }
605        Err(e) => {
606            state.status = format!("save failed: {e}");
607            Action::Continue
608        }
609    }
610}
611
612/// Key handling while the primary-vendor picker is open: ↑/↓ move the cursor
613/// (wrapping), Enter/space select and close, Esc closes without selecting.
614fn handle_picker_key(state: &mut SettingsState, code: KeyCode) -> Action {
615    let count = state.primary_choices.len();
616    if count == 0 {
617        state.picker = None;
618        return Action::Continue;
619    }
620    let Some(picker) = state.picker.as_mut() else {
621        return Action::Continue;
622    };
623    match code {
624        KeyCode::Up => picker.cursor = (picker.cursor + count - 1) % count,
625        KeyCode::Down => picker.cursor = (picker.cursor + 1) % count,
626        KeyCode::Enter | KeyCode::Char(' ') => {
627            state.primary = state.primary_choices[picker.cursor];
628            state.picker = None;
629        }
630        KeyCode::Esc => state.picker = None,
631        _ => {}
632    }
633    Action::Continue
634}
635
636fn handle_primary(state: &mut SettingsState, code: KeyCode) {
637    // Left/Right cycles the primary-vendor radio over enabled vendors only.
638    let choices = &state.primary_choices;
639    let Some(idx) = choices.iter().position(|v| *v == state.primary) else {
640        return;
641    };
642    let step = match code {
643        KeyCode::Left => -1,
644        KeyCode::Right | KeyCode::Char(' ') => 1,
645        _ => return,
646    };
647    state.primary = choices[((idx as i32 + step).rem_euclid(choices.len() as i32)) as usize];
648}
649
650fn handle_input(input: &mut KeyInput, code: KeyCode) {
651    match code {
652        KeyCode::Char(c) => input.insert_char(c),
653        KeyCode::Backspace => input.backspace(),
654        KeyCode::Delete => input.delete(),
655        KeyCode::Left => input.move_left(),
656        KeyCode::Right => input.move_right(),
657        KeyCode::Home => input.move_home(),
658        KeyCode::End => input.move_end(),
659        _ => {}
660    }
661}
662
663/// The `[notifications] enabled` toggle. Left/Right/Space flip it; any other
664/// key leaves it alone.
665fn handle_notify_enabled(state: &mut SettingsState, code: KeyCode) {
666    if matches!(code, KeyCode::Left | KeyCode::Right | KeyCode::Char(' ')) {
667        state.notify_enabled = !state.notify_enabled;
668        state.notify_enabled_dirty = true;
669    }
670}
671
672/// One `[<vendor>] enabled` row (#244): Left/Right/Space flip the switch;
673/// any other key leaves it — and its dirty flag — alone, so an untouched
674/// overlay save never writes a section the config never had.
675fn handle_provider_switch(state: &mut SettingsState, index: usize, code: KeyCode) {
676    if matches!(code, KeyCode::Left | KeyCode::Right | KeyCode::Char(' '))
677        && let Some(switch) = state.vendors.get_mut(index)
678    {
679        switch.enabled = !switch.enabled;
680        switch.dirty = true;
681    }
682}
683
684/// The `[notifications] threshold` field: an edit buffer that only accepts
685/// digits, so the value can never be something save would have to reject as
686/// non-numeric (range is still checked at save).
687fn handle_threshold_input(input: &mut KeyInput, code: KeyCode) {
688    match code {
689        KeyCode::Char(c) if c.is_ascii_digit() => input.insert_char(c),
690        KeyCode::Backspace => input.backspace(),
691        KeyCode::Delete => input.delete(),
692        KeyCode::Left => input.move_left(),
693        KeyCode::Right => input.move_right(),
694        KeyCode::Home => input.move_home(),
695        KeyCode::End => input.move_end(),
696        _ => {}
697    }
698}
699
700/// Save to the platform config path (creating it). On success, signal a running
701/// Waybar (`SIGRTMIN+13`) so a `signal: 13` module refreshes immediately.
702fn save_to_config_default(state: &SettingsState) -> Result<()> {
703    let path = default_config_path()?;
704    if let Some(parent) = path.parent() {
705        std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
706    }
707    save_to_path(state, &path)?;
708    crate::waybar::request_refresh();
709    Ok(())
710}
711
712/// Same as `save_to_config_default` but with an explicit path — exposed for
713/// tests. Writing a non-empty credential also sets that vendor's `enabled = true`.
714pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
715    // One locked read-modify-write: a key check's candidate write and this
716    // save queue on the same lock instead of overwriting each other.
717    edit_config_document(path, |doc| {
718        // Remove fields written by the short-lived inline Copilot-token design.
719        // GitHub CLI owns the OAuth credential now; retaining a secret this app
720        // neither reads nor supports would be misleading and unsafe.
721        if let Some(table) = doc
722            .get_mut("copilot")
723            .and_then(toml_edit::Item::as_table_like_mut)
724        {
725            table.remove("token");
726            table.remove("token_env");
727        }
728
729        // Only Copilot is deliberately offered before it is enabled: choosing it
730        // is the explicit opt-in after the GitHub CLI login. The env-only key
731        // vendors (any `KEY_VENDORS` entry whose credential the user reached via
732        // `OLLAMA_API_KEY` or another env var) are also offered before they are
733        // enabled, and selecting them is the explicit opt-in for that vendor —
734        // otherwise a user could pick a vendor in the overlay and the fetch
735        // would still fail because `enabled = false`. Every other choice remains
736        // enabled-only, so no failed provider is persisted as primary.
737        if state.primary_choices.contains(&state.primary) {
738            set_string(doc, "ui", "primary", state.primary.slug())?;
739            if state.primary == VendorId::Copilot
740                || KEY_VENDORS.iter().any(|kv| kv.id == state.primary)
741            {
742                set_bool(doc, state.primary.config_section(), "enabled", true)?;
743            }
744        }
745
746        for (i, kv) in KEY_VENDORS.iter().enumerate() {
747            let Some(input) = state.keys.get(i) else {
748                continue;
749            };
750            update_key(doc, kv, input)?;
751        }
752
753        // Provider on/off switches (#244): written after the credential fields so
754        // an explicit off wins over the enable-a-pasted-key-implied rule above,
755        // and each one goes through the same whitelisted setter the native
756        // settings bridge uses. Only toggled rows are written, so an untouched
757        // overlay save never creates a `[<vendor>]` section the config never had.
758        for (i, id) in VendorId::all().iter().enumerate() {
759            let Some(switch) = state.vendors.get(i) else {
760                continue;
761            };
762            if switch.dirty {
763                crate::config::set_vendor_enabled_in_doc(doc, id.slug(), switch.enabled)?;
764            }
765        }
766
767        // [notifications]: each field is written only when the user touched it,
768        // so an untouched overlay save leaves an absent/commented section alone.
769        if state.notify_enabled_dirty {
770            set_bool(doc, "notifications", "enabled", state.notify_enabled)?;
771        }
772        if state.notify_threshold.dirty {
773            let raw = state.notify_threshold.buf.trim();
774            let threshold = raw.parse::<u8>();
775            match threshold {
776                Ok(threshold) if (1..=100).contains(&threshold) => {
777                    set_value(
778                        doc,
779                        "notifications",
780                        "threshold",
781                        Some(toml_edit::Value::from(i64::from(threshold))),
782                    )?;
783                }
784                _ => {
785                    return Err(AppError::Other(format!(
786                        "[notifications] threshold must be a whole number between 1 and 100, got {raw:?}"
787                    )));
788                }
789            }
790        }
791
792        Ok(())
793    })
794}
795
796/// The credential rules every apply path enforces: the TUI's `settings
797/// apply` and the tray's `set-api-key` check must refuse the same keys, so
798/// the rule lives here instead of in each caller.
799fn reject_invalid_key(secret_label: &str, vendor_slug: &str, value: &str) -> Result<()> {
800    if value.is_empty() {
801        return Err(AppError::Other(format!(
802            "{secret_label} for {vendor_slug:?} is empty; use the clear action to remove it"
803        )));
804    }
805    if value.len() > MAX_API_KEY_BYTES {
806        return Err(AppError::Other(format!(
807            "{secret_label} for {vendor_slug:?} exceeds {MAX_API_KEY_BYTES} bytes"
808        )));
809    }
810    if value.chars().any(char::is_control) {
811        return Err(AppError::Other(format!(
812            "{secret_label} for {vendor_slug:?} contains control characters"
813        )));
814    }
815    Ok(())
816}
817
818/// The key vendor a credential request names, by `VendorId::slug()`. The
819/// tray's `set-api-key` sends the slug its provider row carries; anything
820/// else — including the OAuth vendors' slugs — has no credential field.
821pub(crate) fn key_vendor_for_slug(slug: &str) -> Option<&'static KeyVendor> {
822    KEY_VENDORS.iter().find(|vendor| vendor.id.slug() == slug)
823}
824
825/// Validate one credential the way [`state_from_apply_request`] does, so
826/// the tray's `set-api-key` check and the TUI's save refuse the same keys.
827pub(crate) fn validate_key_value(vendor: &KeyVendor, value: &str) -> Result<()> {
828    reject_invalid_key(vendor.secret_label, vendor.id.slug(), value)
829}
830
831/// One credential field as found in the config: the value with the
832/// decoration `toml_edit` attached to it. Cloned out of the parsed
833/// document, so a restore puts it back byte for byte — comments and all.
834/// No `Debug`: the value is a secret, and a backtrace of the snapshot must
835/// not print it.
836pub(crate) struct KeyItemSnapshot {
837    key_decor: toml_edit::Decor,
838    value: toml_edit::Value,
839}
840
841/// The config state the `set-api-key` check must be able to give back when
842/// the provider refuses the new key: whether the file existed, whether the
843/// vendor's section did, and the `api_key` and `enabled` fields exactly as
844/// found. A restore touches only those two fields — everything else in the
845/// document is left as the document holds it at restore time.
846pub(crate) struct KeySnapshot {
847    /// Recorded for the tests and for the snapshot's own contract; the
848    /// restore's file-removal step now keys off the document being left
849    /// empty by [`crate::config::edit_config_document`], not off this flag.
850    #[cfg_attr(not(test), allow(dead_code))]
851    pub file_existed: bool,
852    pub section_existed: bool,
853    pub key: Option<KeyItemSnapshot>,
854    pub enabled: Option<KeyItemSnapshot>,
855}
856
857/// Capture one credential field — its value and its key decoration — from
858/// the vendor's section, when it holds a value. A field that holds a table
859/// is not a credential: it captures as absent, and the restore removes it.
860fn capture_key_item(table: &dyn toml_edit::TableLike, name: &str) -> Option<KeyItemSnapshot> {
861    let (key, item) = table.get_key_value(name)?;
862    let value = item.as_value()?.clone();
863    Some(KeyItemSnapshot {
864        key_decor: key.leaf_decor().clone(),
865        value,
866    })
867}
868
869/// Snapshot the credential state the `set-api-key` check must be able to
870/// give back: read the config with `toml_edit` (comments intact) and clone
871/// the vendor's `api_key` and `enabled` fields out of it. A missing file
872/// is an empty document, so the snapshot simply records that nothing was
873/// there; an unparseable config is an error rather than a guess.
874pub(crate) fn snapshot_inline_key(path: &Path, vendor: &KeyVendor) -> Result<KeySnapshot> {
875    let file_existed = path.exists();
876    let doc = read_config_document(path)?;
877    Ok(key_snapshot_of_doc(&doc, file_existed, vendor))
878}
879
880/// [`snapshot_inline_key`] over a document already in hand — the capture
881/// the locked check write runs inside [`edit_config_document`], where the
882/// file's state is settled under the lock.
883fn key_snapshot_of_doc(
884    doc: &toml_edit::DocumentMut,
885    file_existed: bool,
886    vendor: &KeyVendor,
887) -> KeySnapshot {
888    match doc
889        .get(vendor.section)
890        .and_then(toml_edit::Item::as_table_like)
891    {
892        Some(table) => KeySnapshot {
893            file_existed,
894            section_existed: true,
895            key: capture_key_item(table, vendor.config_key),
896            enabled: capture_key_item(table, "enabled"),
897        },
898        None => KeySnapshot {
899            file_existed,
900            section_existed: false,
901            key: None,
902            enabled: None,
903        },
904    }
905}
906
907/// The verdict the key check publishes when a non-empty key is already
908/// saved: the dialog only exists for a provider without an inline
909/// credential, and a saved key is replaced in the TUI settings, not
910/// checked here.
911pub(crate) const KEY_ALREADY_SAVED_MESSAGE: &str =
912    "A key is already saved in config.toml; replace it in the TUI settings.";
913
914/// The vendor's saved inline key by the catalog's rule — the same one
915/// `Config::inline_api_key` applies: an absent field, or a field that holds
916/// the empty string, is no credential. An `api_key = ''` left by a cleared
917/// field must not block the dialog the catalog offers for it.
918pub(crate) fn saved_inline_key(snapshot: &KeySnapshot) -> Option<String> {
919    snapshot
920        .key
921        .as_ref()
922        .and_then(|item| item.value.as_str())
923        .filter(|value| !value.is_empty())
924        .map(str::to_string)
925}
926
927/// Write one inline credential the way the overlay's save does: `toml_edit`,
928/// the vendor's own section, `chmod 600` through the locked
929/// [`edit_config_document`]. A non-empty credential also opts the vendor in,
930/// like `update_key`: a key that stays disabled is a key the fetch never
931/// sees. The value is validated first, so a refused key never touches the
932/// file. The key check itself uses
933/// [`snapshot_and_write_inline_key_if_absent`], which re-checks the
934/// "no key yet" precondition under the same lock; this unconditional write
935/// is what a user-driven save does, and the tests stand in for it.
936#[cfg_attr(not(test), allow(dead_code))]
937pub(crate) fn write_inline_key(path: &Path, vendor: &KeyVendor, value: &str) -> Result<()> {
938    validate_key_value(vendor, value)?;
939    edit_config_document(path, |doc| {
940        set_string(doc, vendor.section, vendor.config_key, value)?;
941        set_bool(doc, vendor.section, "enabled", true)
942    })
943}
944
945/// The key check's whole read-modify-write in one locked step: snapshot the
946/// credential state, and only while no key is still saved, write `value` —
947/// plus the vendor's opt-in — and return the snapshot the later restore
948/// compares against. The snapshot, the "no key yet" condition, and the
949/// write happen under the same config lock the TUI's save takes, so a TUI
950/// save that lands between any two of the steps is either fully before
951/// (the condition sees its key and the check refuses with
952/// [`KEY_ALREADY_SAVED_MESSAGE`], leaving the file alone) or fully after
953/// (its write is the later decision and wins) — the candidate can no longer
954/// overwrite a key the TUI saved in the window between deciding and
955/// writing. An empty value counts as absent, by the catalog's rule; the
956/// value is validated first, so a refused key never touches the file.
957pub(crate) fn snapshot_and_write_inline_key_if_absent(
958    path: &Path,
959    vendor: &KeyVendor,
960    value: &str,
961) -> Result<KeySnapshot> {
962    validate_key_value(vendor, value)?;
963    edit_config_document(path, |doc| {
964        let snapshot = key_snapshot_of_doc(doc, path.exists(), vendor);
965        if saved_inline_key(&snapshot).is_some() {
966            return Err(AppError::Other(KEY_ALREADY_SAVED_MESSAGE.to_string()));
967        }
968        set_string(doc, vendor.section, vendor.config_key, value)?;
969        set_bool(doc, vendor.section, "enabled", true)?;
970        Ok(snapshot)
971    })
972}
973
974/// Give the config back the state [`snapshot_inline_key`] captured,
975/// compare-and-restore: the check wrote `candidate` as the key and `true`
976/// as the vendor's `enabled` switch, so each field is put back — with its
977/// captured value and decoration, or removed when it was absent — only
978/// while it still holds exactly what the check wrote. A later decision on
979/// the same field, made while the check waited for the provider, belongs
980/// to the user now and survives. No other field in the file is touched,
981/// so edits made to it in the meantime survive the restore. A section the
982/// check created is removed when nothing else is left in it, and a file
983/// the check created is removed when the document is left empty —
984/// [`edit_config_document`] owns that step. No validation runs: the
985/// captured values already lived in the user's config. The whole
986/// compare-and-restore is one locked read-modify-write under the same lock
987/// the TUI's save takes, so a save made while the check waited is seen
988/// here, not clobbered by it.
989pub(crate) fn restore_inline_key_snapshot(
990    path: &Path,
991    vendor: &KeyVendor,
992    snapshot: &KeySnapshot,
993    candidate: &str,
994) -> Result<()> {
995    edit_config_document(path, |doc| {
996        let table = doc
997            .entry(vendor.section)
998            .or_insert_with(toml_edit::table)
999            .as_table_like_mut()
1000            .ok_or_else(|| {
1001                AppError::Other(format!("config.toml: [{}] is not a table", vendor.section))
1002            })?;
1003        // Compare-and-restore: the check wrote `candidate` as the key and
1004        // `true` as the vendor's `enabled` switch, so each field goes back —
1005        // with its captured value and decoration, or out when it was absent —
1006        // only while it still holds exactly what the check wrote. A later
1007        // decision on the same field, made while the check waited for the
1008        // provider (a key saved, the vendor switched off), belongs to the user
1009        // now and survives.
1010        let current_key = table
1011            .get(vendor.config_key)
1012            .and_then(toml_edit::Item::as_str);
1013        match &snapshot.key {
1014            Some(captured) if current_key == Some(candidate) => {
1015                if let Some((mut key, item)) = table.get_key_value_mut(vendor.config_key) {
1016                    *key.leaf_decor_mut() = captured.key_decor.clone();
1017                    *item = toml_edit::Item::Value(captured.value.clone());
1018                }
1019            }
1020            None if current_key == Some(candidate) => {
1021                table.remove(vendor.config_key);
1022            }
1023            // The field holds someone else's value: leave it.
1024            _ => {}
1025        }
1026        let current_enabled = table.get("enabled").and_then(toml_edit::Item::as_bool);
1027        match &snapshot.enabled {
1028            Some(captured) if current_enabled == Some(true) => {
1029                if let Some((mut key, item)) = table.get_key_value_mut("enabled") {
1030                    *key.leaf_decor_mut() = captured.key_decor.clone();
1031                    *item = toml_edit::Item::Value(captured.value.clone());
1032                }
1033            }
1034            None if current_enabled == Some(true) => {
1035                table.remove("enabled");
1036            }
1037            // The user switched the vendor: leave their decision.
1038            _ => {}
1039        }
1040        // A section the check created must not survive the restore as an
1041        // empty husk — but one that holds anything the check did not write
1042        // does.
1043        if !snapshot.section_existed
1044            && doc
1045                .get(vendor.section)
1046                .and_then(toml_edit::Item::as_table_like)
1047                .is_some_and(|table| table.is_empty())
1048        {
1049            doc.remove(vendor.section);
1050        }
1051        Ok(())
1052    })
1053}
1054
1055/// Apply one credential field to the document. Untouched fields are left
1056/// alone; a field the user cleared is *removed*, so an inline secret can be
1057/// deleted from the overlay rather than lingering in the file. Writing a
1058/// non-empty credential also opts the vendor in — the opt-in vendors would
1059/// otherwise never fetch.
1060fn update_key(doc: &mut DocumentMut, vendor: &KeyVendor, input: &KeyInput) -> Result<()> {
1061    if !input.dirty {
1062        return Ok(());
1063    }
1064    if input.buf.is_empty() {
1065        if let Some(table) = doc
1066            .get_mut(vendor.section)
1067            .and_then(toml_edit::Item::as_table_like_mut)
1068        {
1069            table.remove(vendor.config_key);
1070        }
1071        return Ok(());
1072    }
1073    set_string(doc, vendor.section, vendor.config_key, &input.buf)?;
1074    set_bool(doc, vendor.section, "enabled", true)
1075}
1076
1077/// Set or update a string field in a TOML section, preserving comments and
1078/// formatting of unaffected nodes.
1079fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
1080    let table = doc
1081        .entry(section)
1082        .or_insert_with(toml_edit::table)
1083        .as_table_like_mut()
1084        .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
1085
1086    if let Some(item) = table.get_mut(key)
1087        && let Some(v) = item.as_value_mut()
1088    {
1089        *v = toml_edit::Value::from(new_value);
1090        v.decor_mut().set_prefix(" ");
1091        return Ok(());
1092    }
1093    table.insert(key, value(new_value));
1094    Ok(())
1095}
1096
1097fn default_config_path() -> Result<PathBuf> {
1098    // Save back to the same file Config::load() selected. On macOS this may be
1099    // the legacy ~/.config path when the canonical Application Support file is
1100    // absent; writing a new canonical file would shadow the existing config on
1101    // the next load and silently discard all settings the overlay did not copy.
1102    crate::config::resolved_path()
1103        .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
1104}
1105
1106// ─── Native frontend bridge ───────────────────────────────────────────────
1107
1108/// Versioned, non-secret description consumed by native desktop frontends.
1109/// Inline key values are deliberately represented only as booleans: a
1110/// long-lived shell process never needs to receive credentials just to draw a
1111/// settings form.
1112#[derive(Debug, Serialize)]
1113struct SettingsSnapshot {
1114    schema_version: u8,
1115    primary: String,
1116    primary_choices: Vec<PrimaryChoice>,
1117    keys: Vec<KeyStatus>,
1118    /// Every known provider's on/off switch (#244) — enabled ones and
1119    /// disabled ones, because turning a provider on is the point.
1120    vendors: Vec<VendorStatus>,
1121}
1122
1123#[derive(Debug, Serialize)]
1124struct PrimaryChoice {
1125    id: String,
1126    label: String,
1127}
1128
1129#[derive(Debug, Serialize)]
1130struct KeyStatus {
1131    id: String,
1132    label: String,
1133    environment: String,
1134    secret_label: String,
1135    note: String,
1136    configured: bool,
1137    inline_configured: bool,
1138    environment_configured: bool,
1139}
1140
1141/// One provider row of the native settings form: its shared display name and
1142/// whether config.toml currently enables it (#244).
1143#[derive(Debug, Serialize)]
1144struct VendorStatus {
1145    id: String,
1146    label: String,
1147    enabled: bool,
1148}
1149
1150/// Additive patch accepted on stdin by `ai-usagebar settings apply`.
1151/// Missing keys remain byte-for-byte untouched. `clear` explicitly removes an
1152/// inline key, matching the TUI overlay's existing empty-dirty-field behavior.
1153/// `vendors` carries provider on/off toggles (#244); its slugs are validated
1154/// against the built-in vendor list before anything is written.
1155#[derive(Debug, Deserialize)]
1156#[serde(deny_unknown_fields)]
1157struct ApplyRequest {
1158    schema_version: u8,
1159    primary: Option<String>,
1160    #[serde(default)]
1161    keys: BTreeMap<String, KeyMutation>,
1162    #[serde(default)]
1163    vendors: BTreeMap<String, bool>,
1164}
1165
1166#[derive(Debug, Deserialize)]
1167#[serde(tag = "action", rename_all = "lowercase", deny_unknown_fields)]
1168enum KeyMutation {
1169    Set { value: String },
1170    Clear,
1171}
1172
1173const SETTINGS_SCHEMA_VERSION: u8 = 1;
1174const MAX_SETTINGS_REQUEST_BYTES: u64 = 64 * 1024;
1175/// `pub(crate)`: the tray's `set-api-key` check enforces the same rule.
1176pub(crate) const MAX_API_KEY_BYTES: usize = 16 * 1024;
1177
1178fn snapshot_from_config_with(
1179    cfg: &Config,
1180    environment_configured: impl Fn(&str) -> bool,
1181) -> SettingsSnapshot {
1182    let state = SettingsState::from_config(cfg);
1183    let primary_choices = state
1184        .primary_choices
1185        .iter()
1186        .map(|id| PrimaryChoice {
1187            id: id.slug().to_string(),
1188            label: id.display_name().to_string(),
1189        })
1190        .collect();
1191    let keys = KEY_VENDORS
1192        .iter()
1193        .map(|vendor| {
1194            let environment = cfg.api_key_env_for(vendor.id);
1195            let inline_configured = cfg.inline_api_key(vendor.id).is_some();
1196            let environment_configured = environment_configured(environment);
1197            KeyStatus {
1198                id: vendor.id.slug().to_string(),
1199                label: vendor.label.to_string(),
1200                environment: environment.to_string(),
1201                secret_label: vendor.secret_label.to_string(),
1202                note: vendor.note.to_string(),
1203                configured: inline_configured || environment_configured,
1204                inline_configured,
1205                environment_configured,
1206            }
1207        })
1208        .collect();
1209    let vendors = VendorId::all()
1210        .iter()
1211        .map(|id| VendorStatus {
1212            id: id.slug().to_string(),
1213            label: id.display_name().to_string(),
1214            enabled: cfg.is_enabled(*id),
1215        })
1216        .collect();
1217    SettingsSnapshot {
1218        schema_version: SETTINGS_SCHEMA_VERSION,
1219        primary: state.primary.slug().to_string(),
1220        primary_choices,
1221        keys,
1222        vendors,
1223    }
1224}
1225
1226fn settings_snapshot_json(cfg: &Config) -> Result<String> {
1227    Ok(serde_json::to_string(&snapshot_from_config_with(
1228        cfg,
1229        |environment| std::env::var_os(environment).is_some_and(|value| !value.is_empty()),
1230    ))?)
1231}
1232
1233#[cfg(test)]
1234fn settings_snapshot_json_with(
1235    cfg: &Config,
1236    environment_configured: impl Fn(&str) -> bool,
1237) -> Result<String> {
1238    Ok(serde_json::to_string(&snapshot_from_config_with(
1239        cfg,
1240        environment_configured,
1241    ))?)
1242}
1243
1244fn vendor_from_slug(slug: &str) -> Option<VendorId> {
1245    VendorId::from_slug(slug)
1246}
1247
1248fn state_from_apply_request(cfg: &Config, raw: &str) -> Result<SettingsState> {
1249    let request: ApplyRequest = serde_json::from_str(raw)?;
1250    if request.schema_version != SETTINGS_SCHEMA_VERSION {
1251        return Err(AppError::Other(format!(
1252            "unsupported settings schema version {}",
1253            request.schema_version
1254        )));
1255    }
1256
1257    let mut state = SettingsState::from_config(cfg);
1258    if let Some(primary) = request.primary {
1259        let id = vendor_from_slug(&primary)
1260            .ok_or_else(|| AppError::Other(format!("unknown primary vendor {primary:?}")))?;
1261        if !state.primary_choices.contains(&id) {
1262            return Err(AppError::Other(format!(
1263                "primary vendor {primary:?} is not enabled"
1264            )));
1265        }
1266        state.primary = id;
1267    }
1268
1269    for (id, mutation) in request.keys {
1270        let index = KEY_VENDORS
1271            .iter()
1272            .position(|vendor| vendor.id.slug() == id)
1273            .ok_or_else(|| AppError::Other(format!("unknown credential vendor {id:?}")))?;
1274        let input = &mut state.keys[index];
1275        match mutation {
1276            KeyMutation::Set { value } => {
1277                reject_invalid_key(KEY_VENDORS[index].secret_label, &id, &value)?;
1278                input.buf = value;
1279            }
1280            KeyMutation::Clear => input.buf.clear(),
1281        }
1282        input.cursor = input.buf.chars().count();
1283        input.dirty = true;
1284        input.revealed = false;
1285    }
1286
1287    // Provider on/off toggles (#244): the slug is the whitelist — anything
1288    // that names no built-in vendor is refused, so a patch can never create
1289    // or flip an arbitrary config section.
1290    for (slug, enabled) in request.vendors {
1291        let index = VendorId::all()
1292            .iter()
1293            .position(|id| id.slug() == slug)
1294            .ok_or_else(|| AppError::Other(format!("unknown provider {slug:?}")))?;
1295        state.vendors[index] = ProviderSwitch {
1296            enabled,
1297            dirty: true,
1298        };
1299    }
1300    Ok(state)
1301}
1302
1303#[cfg(test)]
1304fn apply_settings_json_to_path(cfg: &Config, raw: &str, path: &Path) -> Result<()> {
1305    let state = state_from_apply_request(cfg, raw)?;
1306    save_to_path(&state, path)
1307}
1308
1309fn read_settings_request<R: BufRead>(reader: R) -> Result<String> {
1310    let mut limited = reader.take(MAX_SETTINGS_REQUEST_BYTES + 1);
1311    let mut bytes = Vec::new();
1312    limited.read_until(b'\n', &mut bytes)?;
1313    if bytes.len() as u64 > MAX_SETTINGS_REQUEST_BYTES {
1314        return Err(AppError::Other(format!(
1315            "settings request exceeds {MAX_SETTINGS_REQUEST_BYTES} bytes"
1316        )));
1317    }
1318    if bytes.last() == Some(&b'\n') {
1319        bytes.pop();
1320        if bytes.last() == Some(&b'\r') {
1321            bytes.pop();
1322        }
1323    }
1324    String::from_utf8(bytes)
1325        .map_err(|_| AppError::Other("settings request is not valid UTF-8".into()))
1326}
1327
1328fn apply_settings_from_stdin() -> Result<()> {
1329    let raw = read_settings_request(std::io::stdin().lock())?;
1330    let cfg = Config::load()?;
1331    let state = state_from_apply_request(&cfg, &raw)?;
1332    save_to_config_default(&state)
1333}
1334
1335/// Explicit user opt-in, unlike discovery which respects an existing false.
1336fn enable_vendor_at(path: &Path, vendor: VendorId) -> Result<()> {
1337    edit_config_document(path, |doc| {
1338        set_bool(doc, vendor.config_section(), "enabled", true)
1339    })
1340}
1341
1342/// Administrative settings bridge for native frontends. `show` never emits a
1343/// secret; `apply` accepts its patch only over stdin so keys do not appear in
1344/// argv or the process environment.
1345pub fn run_cli(action: &crate::widget::cli::SettingsAction) -> i32 {
1346    let result = match action {
1347        crate::widget::cli::SettingsAction::Enable { vendor } => default_config_path()
1348            .and_then(|path| enable_vendor_at(&path, vendor.to_id()))
1349            .map(|()| {
1350                crate::waybar::request_refresh();
1351                println!(r#"{{"ok":true}}"#);
1352            }),
1353        crate::widget::cli::SettingsAction::Show => Config::load()
1354            .and_then(|cfg| settings_snapshot_json(&cfg))
1355            .map(|json| println!("{json}")),
1356        crate::widget::cli::SettingsAction::Apply => {
1357            apply_settings_from_stdin().map(|()| println!(r#"{{"ok":true}}"#))
1358        }
1359    };
1360    match result {
1361        Ok(()) => 0,
1362        Err(error) => {
1363            eprintln!("settings: {error}");
1364            1
1365        }
1366    }
1367}
1368
1369// ─── Render ────────────────────────────────────────────────────────────────
1370
1371/// Line positions inside the rendered overlay body. `render` builds its lines
1372/// in exactly this order and `focus_line` reads them back, so the two cannot
1373/// drift apart silently — the guard tests pin them to each other.
1374struct BodyLayout {
1375    primary: usize,
1376    first_key: usize,
1377    first_vendor: usize,
1378    notify_enabled: usize,
1379    notify_threshold: usize,
1380    save: usize,
1381    /// Body line count without the optional status row.
1382    rows: usize,
1383}
1384
1385fn body_layout(keys: usize, vendors: usize) -> BodyLayout {
1386    let first_key = 4;
1387    let first_vendor = first_key + keys + 2;
1388    let notify_enabled = first_vendor + vendors + 2;
1389    BodyLayout {
1390        primary: 1,
1391        first_key,
1392        first_vendor,
1393        notify_enabled,
1394        notify_threshold: notify_enabled + 1,
1395        save: notify_enabled + 3,
1396        rows: notify_enabled + 4,
1397    }
1398}
1399
1400/// The body line the focused control renders on.
1401fn focus_line(state: &SettingsState) -> usize {
1402    let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
1403    match state.focus {
1404        Focus::Primary => layout.primary,
1405        Focus::Key(i) => layout.first_key + i,
1406        Focus::Vendor(i) => layout.first_vendor + i,
1407        Focus::NotifyEnabled => layout.notify_enabled,
1408        Focus::NotifyThreshold => layout.notify_threshold,
1409        Focus::Save => layout.save,
1410    }
1411}
1412
1413/// Scroll offset keeping `focused` on screen: unchanged while it is already
1414/// visible, the smallest jump that reveals it when not, clamped to the
1415/// scrollable range. Pure over its inputs.
1416fn follow_focus_scroll(focused: usize, total: usize, viewport: u16, current: u16) -> u16 {
1417    if viewport == 0 || total == 0 {
1418        return 0;
1419    }
1420    let max_offset = (total as u16).saturating_sub(viewport);
1421    let focused = focused.min(total - 1) as u16;
1422    let wanted = if focused < current {
1423        focused
1424    } else if focused >= current.saturating_add(viewport) {
1425        // Pin the newly focused line to the bottom edge so what follows it
1426        // (another row, the Save button) still peeks into view.
1427        (focused + 1).saturating_sub(viewport)
1428    } else {
1429        current
1430    };
1431    wanted.min(max_offset)
1432}
1433
1434/// Render the modal overlay over `area`.
1435pub fn render(
1436    f: &mut Frame,
1437    area: Rect,
1438    state: &mut SettingsState,
1439    theme: &Theme,
1440    hits: &mut Vec<(SettingsRow, Rect)>,
1441) {
1442    let modal = centered_rect(74, 88, area);
1443    f.render_widget(Clear, modal);
1444
1445    let bubble = bubble_theme(theme);
1446    let block = bubble.titled_modal_block(" Settings ");
1447    let inner = block.inner(modal);
1448    f.render_widget(block, modal);
1449
1450    // Body (everything but the pinned hint) + a 1-line hint footer.
1451    let chunks = Layout::default()
1452        .direction(Direction::Vertical)
1453        .constraints([Constraint::Min(0), Constraint::Length(1)])
1454        .split(inner);
1455
1456    // The body long outgrew every terminal once each provider got a row, so
1457    // the view follows the focus ring. Compute the offset up front: hit rects
1458    // must be recorded at the *rendered* row (unscrolled position minus the
1459    // scroll offset), or a click selects the control `scroll` rows away from
1460    // the one the user sees.
1461    let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
1462    let total = layout.rows + usize::from(!state.status.is_empty());
1463    let scroll = follow_focus_scroll(focus_line(state), total, chunks[0].height, state.scroll);
1464    state.scroll = scroll;
1465
1466    // A row's on-screen y is the body top plus its line index minus the scroll
1467    // offset (each rendered line is exactly one row). Recorded alongside each
1468    // interactive row so a mouse click maps back to a focus target. Rows
1469    // scrolled past either edge of the body are not rendered and get no rect.
1470    let row_at = |line: usize| {
1471        let top = chunks[0].y;
1472        let bottom = top.saturating_add(chunks[0].height);
1473        let y = top.saturating_add(line as u16).checked_sub(scroll)?;
1474        (y >= top && y < bottom).then_some(Rect::new(inner.x, y, inner.width, 1))
1475    };
1476
1477    // — Primary vendor + API keys header —
1478    let mut lines: Vec<Line> = vec![
1479        section_header("Primary vendor", "shown first on the bar / TUI", &bubble),
1480        primary_line(state, &bubble),
1481        Line::from(""),
1482        section_header("API keys", "all key providers", &bubble),
1483    ];
1484    if let Some(r) = row_at(1) {
1485        // The focused radio renders "◀ name ▶" over a name padded to the
1486        // widest choice, so the arrows sit at fixed columns however long the
1487        // current pick is. The arrow cells click-step the radio; the name
1488        // cell opens the picker popup. Unfocused rows show no arrows — the
1489        // first click only focuses.
1490        if state.focus == Focus::Primary {
1491            let pad = primary_name_pad(state);
1492            hits.push((
1493                SettingsRow::Switch(Focus::Primary, KeyCode::Left, KeyModifiers::NONE),
1494                Rect::new(r.x.saturating_add(5), r.y, 2, 1),
1495            ));
1496            hits.push((
1497                SettingsRow::Switch(Focus::Primary, KeyCode::Right, KeyModifiers::NONE),
1498                Rect::new(r.x.saturating_add(9 + pad), r.y, 2, 1),
1499            ));
1500            hits.push((
1501                SettingsRow::OpenPicker,
1502                Rect::new(r.x.saturating_add(7), r.y, pad + 2, 1),
1503            ));
1504        }
1505        hits.push((SettingsRow::Focus(Focus::Primary), r));
1506    }
1507
1508    for (i, kv) in KEY_VENDORS.iter().enumerate() {
1509        let focused = state.focus == Focus::Key(i);
1510        if let Some(r) = row_at(lines.len()) {
1511            hits.push((SettingsRow::Focus(Focus::Key(i)), r));
1512        }
1513        lines.push(key_row(kv, &state.keys[i], focused, &bubble));
1514    }
1515
1516    lines.push(Line::from(""));
1517
1518    // — Providers on/off (#244) —
1519    lines.push(section_header(
1520        "Providers",
1521        "which vendors the report, bar and TUI fetch at all",
1522        &bubble,
1523    ));
1524    for (i, id) in VendorId::all().iter().enumerate() {
1525        let switch = state.vendors.get(i).copied().unwrap_or(ProviderSwitch {
1526            enabled: false,
1527            dirty: false,
1528        });
1529        let focused = state.focus == Focus::Vendor(i);
1530        if let Some(r) = row_at(lines.len()) {
1531            // The switch cell is pushed first so a click on the on/off value
1532            // toggles the row instead of only focusing it. Its rect covers
1533            // exactly the rendered value segment: not the name tail (a name
1534            // past the 11-column padding pushes the value right) and not the
1535            // empty space right of the row.
1536            let (value_x, value_w) =
1537                switch_cell(id.display_name(), switch_value(switch.enabled), focused);
1538            hits.push((
1539                SettingsRow::Switch(Focus::Vendor(i), KeyCode::Char(' '), KeyModifiers::NONE),
1540                Rect::new(r.x.saturating_add(value_x), r.y, value_w, 1),
1541            ));
1542            hits.push((SettingsRow::Focus(Focus::Vendor(i)), r));
1543        }
1544        lines.push(provider_row(id, switch, focused, &bubble));
1545    }
1546    lines.push(Line::from(""));
1547
1548    // — Notifications —
1549    lines.push(section_header(
1550        "Notifications",
1551        "desktop alert when a quota window crosses the threshold",
1552        &bubble,
1553    ));
1554    if let Some(r) = row_at(lines.len()) {
1555        let (value_x, value_w) = switch_cell(
1556            "Quota alerts",
1557            switch_value(state.notify_enabled),
1558            state.focus == Focus::NotifyEnabled,
1559        );
1560        hits.push((
1561            SettingsRow::Switch(Focus::NotifyEnabled, KeyCode::Char(' '), KeyModifiers::NONE),
1562            Rect::new(r.x.saturating_add(value_x), r.y, value_w, 1),
1563        ));
1564        hits.push((SettingsRow::Focus(Focus::NotifyEnabled), r));
1565    }
1566    lines.push(notify_enabled_line(state, &bubble));
1567    if let Some(r) = row_at(lines.len()) {
1568        hits.push((SettingsRow::Focus(Focus::NotifyThreshold), r));
1569    }
1570    lines.push(notify_threshold_line(state, &bubble));
1571    lines.push(Line::from(""));
1572
1573    // — Save + status —
1574    if let Some(r) = row_at(lines.len()) {
1575        hits.push((SettingsRow::Focus(Focus::Save), r));
1576    }
1577    lines.push(save_line(state.focus == Focus::Save, &bubble));
1578    if !state.status.is_empty() {
1579        let ok = state.status.starts_with("saved");
1580        let mark = if ok { "  ✓ " } else { "  ✗ " };
1581        let style = if ok { bubble.accent } else { bubble.selected };
1582        lines.push(Line::from(vec![
1583            Span::styled(mark, style.add_modifier(Modifier::BOLD)),
1584            Span::styled(state.status.clone(), bubble.muted),
1585        ]));
1586    }
1587
1588    // The view follows the focus ring; `scroll` was computed (and persisted
1589    // into the state) before the hit rects were recorded.
1590    f.render_widget(Paragraph::new(lines).scroll((scroll, 0)), chunks[0]);
1591
1592    // Context-aware hint footer.
1593    /// One hint segment: the key label, its description, and the synthetic key a
1594    /// click sends through [`handle_key`] (`None` for pure key hints).
1595    type HintSegment<'a> = (&'a str, &'a str, Option<(KeyCode, KeyModifiers)>);
1596
1597    // Context-aware hint footer. Each segment is (key, description, click):
1598    // segments with a key payload are actionable "links" — clicking one sends
1599    // that synthetic key through `handle_key`, so save/close/toggle stay in
1600    // one place. Pure key hints (move/type/digits) get no click rect.
1601    let segments: &[HintSegment] = match state.focus {
1602        Focus::Primary => &[
1603            ("↑↓/tab", "move", None),
1604            (
1605                "←→",
1606                "change vendor",
1607                Some((KeyCode::Right, KeyModifiers::NONE)),
1608            ),
1609            (
1610                "^S",
1611                "save",
1612                Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1613            ),
1614            ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1615        ],
1616        Focus::Key(_) => &[
1617            ("↑↓/tab", "move", None),
1618            ("type", "edit key", None),
1619            (
1620                "^V",
1621                "reveal",
1622                Some((KeyCode::Char('v'), KeyModifiers::CONTROL)),
1623            ),
1624            (
1625                "^S",
1626                "save",
1627                Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1628            ),
1629            ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1630        ],
1631        Focus::Vendor(_) | Focus::NotifyEnabled => &[
1632            ("↑↓/tab", "move", None),
1633            (
1634                "←→/space",
1635                "toggle",
1636                Some((KeyCode::Char(' '), KeyModifiers::NONE)),
1637            ),
1638            (
1639                "^S",
1640                "save",
1641                Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1642            ),
1643            ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1644        ],
1645        Focus::NotifyThreshold => &[
1646            ("↑↓/tab", "move", None),
1647            ("type", "digits 1-100", None),
1648            (
1649                "^S",
1650                "save",
1651                Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1652            ),
1653            ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1654        ],
1655        Focus::Save => &[
1656            ("↑↓/tab", "move", None),
1657            (
1658                "enter/^S",
1659                "save",
1660                Some((KeyCode::Enter, KeyModifiers::NONE)),
1661            ),
1662            ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1663        ],
1664    };
1665    let hint = bubble.help_line(segments.iter().map(|(key, desc, _)| (*key, *desc)));
1666    f.render_widget(Paragraph::new(hint), chunks[1]);
1667
1668    // Help draws each segment as "key description" joined by " • " (3 cells,
1669    // same separator the main footer replicates for its click targets), left
1670    // aligned from the row's first cell. Record the same cells for segments
1671    // with a click payload, clipped to the visible row width.
1672    let right = chunks[1].x.saturating_add(chunks[1].width);
1673    let mut x = chunks[1].x;
1674    for (key, desc, action) in segments {
1675        let width = (crate::display::text_width(key) as u16)
1676            .saturating_add(1)
1677            .saturating_add(crate::display::text_width(desc) as u16);
1678        if let Some((code, mods)) = action
1679            && x < right
1680        {
1681            let visible = width.min(right.saturating_sub(x));
1682            if visible > 0 {
1683                hits.push((
1684                    SettingsRow::HintKey(*code, *mods),
1685                    Rect::new(x, chunks[1].y, visible, 1),
1686                ));
1687            }
1688        }
1689        x = x.saturating_add(width).saturating_add(3);
1690    }
1691
1692    // The primary-vendor picker floats over the body when open, drawn last so
1693    // it lands on top of everything already rendered.
1694    if state.picker.is_some() {
1695        render_primary_picker(f, state, &bubble, chunks[0], hits);
1696    }
1697}
1698
1699/// The primary-vendor dropdown: anchored under the radio row, listing
1700/// [`SettingsState::primary_choices`] with the cursor scrolled into view.
1701/// Rows are recorded as [`SettingsRow::Pick`] click targets.
1702fn render_primary_picker(
1703    f: &mut Frame,
1704    state: &mut SettingsState,
1705    theme: &BubbleTheme,
1706    body: Rect,
1707    hits: &mut Vec<(SettingsRow, Rect)>,
1708) {
1709    let count = state.primary_choices.len();
1710    if count == 0 {
1711        return;
1712    }
1713    let pad = primary_name_pad(state) as usize;
1714    let width = u16::try_from(pad + 6).unwrap_or(u16::MAX).min(body.width);
1715    if width < 4 {
1716        return;
1717    }
1718    // Anchor under the radio row (body line 1), capped to the body bottom.
1719    let height = (count as u16 + 2)
1720        .min(body.bottom().saturating_sub(body.y + 2))
1721        .max(3);
1722    let area = Rect::new(
1723        body.x.saturating_add(2),
1724        body.y.saturating_add(2),
1725        width,
1726        height,
1727    );
1728
1729    f.render_widget(Clear, area);
1730    let block = theme.titled_modal_block(" Primary vendor ");
1731    let inner = block.inner(area);
1732    f.render_widget(block, area);
1733
1734    let Some(picker) = state.picker.as_mut() else {
1735        return;
1736    };
1737    let viewport = inner.height;
1738    picker.scroll = follow_focus_scroll(picker.cursor, count, viewport, picker.scroll);
1739    for (index, vendor) in state
1740        .primary_choices
1741        .iter()
1742        .enumerate()
1743        .skip(picker.scroll as usize)
1744        .take(viewport as usize)
1745    {
1746        let y = inner.y + u16::try_from(index - picker.scroll as usize).unwrap_or(u16::MAX);
1747        let cursor = picker.cursor == index;
1748        let current = *vendor == state.primary;
1749        let style = if cursor {
1750            theme.accent.add_modifier(Modifier::BOLD)
1751        } else if current {
1752            theme.title
1753        } else {
1754            theme.text
1755        };
1756        let line = Line::from(vec![
1757            theme.span(if cursor { "▸ " } else { "  " }),
1758            Span::styled(format!("{:<pad$}", vendor.display_name(), pad = pad), style),
1759            theme.span(if current { "  ✓" } else { "" }),
1760        ]);
1761        f.render_widget(line, Rect::new(inner.x, y, inner.width, 1));
1762        hits.push((
1763            SettingsRow::Pick(index),
1764            Rect::new(inner.x, y, inner.width, 1),
1765        ));
1766    }
1767}
1768
1769fn section_header(title: &str, sub: &str, theme: &BubbleTheme) -> Line<'static> {
1770    Line::from(vec![
1771        theme.span(" "),
1772        Span::styled(title.to_string(), theme.title.add_modifier(Modifier::BOLD)),
1773        theme.muted(format!("   — {sub}")),
1774    ])
1775}
1776
1777/// Display width the primary radio pads its name to: the widest choice, so
1778/// the ◀/▶ arrows sit at fixed columns however long the current pick is.
1779fn primary_name_pad(state: &SettingsState) -> u16 {
1780    state
1781        .primary_choices
1782        .iter()
1783        .map(|v| crate::display::text_width(v.display_name()) as u16)
1784        .max()
1785        .unwrap_or(1)
1786}
1787
1788fn primary_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1789    let focused = state.focus == Focus::Primary;
1790    let name = state.primary.display_name().to_string();
1791    if focused {
1792        let pad = primary_name_pad(state) as usize;
1793        Line::from(vec![
1794            theme.span("   "),
1795            Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1796            Span::styled("◀ ", theme.accent),
1797            Span::styled(
1798                format!(" {:<pad$} ", name, pad = pad),
1799                theme
1800                    .selected
1801                    .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1802            ),
1803            Span::styled(" ▶", theme.accent),
1804            theme.muted("    ← → or click to change"),
1805        ])
1806    } else {
1807        Line::from(vec![theme.span("     "), Span::styled(name, theme.text)])
1808    }
1809}
1810
1811fn key_row(kv: &KeyVendor, input: &KeyInput, focused: bool, theme: &BubbleTheme) -> Line<'static> {
1812    let label = format!("{:<11}", kv.label);
1813    let value = value_text(input, focused);
1814
1815    // Env / status suffix: env-var name, whether an env override is set, note.
1816    let env_name = kv.id.api_key_env();
1817    let env_set = std::env::var(env_name)
1818        .map(|v| !v.is_empty())
1819        .unwrap_or(false);
1820    let mut suffix = format!("   {env_name}");
1821    if env_set {
1822        suffix.push_str(" · env set (overrides)");
1823    }
1824    if !kv.note.is_empty() {
1825        suffix.push_str(&format!(" · {}", kv.note));
1826    }
1827
1828    if focused {
1829        let val_style = if input.buf.is_empty() {
1830            theme.accent.add_modifier(Modifier::BOLD)
1831        } else {
1832            theme.selected.add_modifier(Modifier::REVERSED)
1833        };
1834        let mut spans = vec![
1835            theme.span("  "),
1836            Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1837            Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1838            Span::styled(format!(" {value} "), val_style),
1839        ];
1840        if input.revealed {
1841            spans.push(theme.muted("  [revealed]"));
1842        }
1843        spans.push(theme.muted(suffix));
1844        Line::from(spans)
1845    } else {
1846        let val_style = if input.buf.is_empty() {
1847            theme.muted
1848        } else {
1849            theme.text
1850        };
1851        Line::from(vec![
1852            theme.span("    "),
1853            Span::styled(label, theme.text),
1854            Span::styled(format!(" {value}"), val_style),
1855            theme.muted(suffix),
1856        ])
1857    }
1858}
1859
1860/// The value column: `(empty)` / a cursor when focused-empty / masked or
1861/// revealed buffer with a cursor mark inserted when focused.
1862fn value_text(input: &KeyInput, focused: bool) -> String {
1863    if input.buf.is_empty() {
1864        return if focused {
1865            "‸".to_string()
1866        } else {
1867            "(empty)".to_string()
1868        };
1869    }
1870    let base = input.display();
1871    if !focused {
1872        return base;
1873    }
1874    let mut chars: Vec<char> = base.chars().collect();
1875    let pos = input.cursor.min(chars.len());
1876    chars.insert(pos, '‸');
1877    chars.into_iter().collect()
1878}
1879
1880/// Labels in provider / notification rows are padded to at least 11 columns;
1881/// the renderers and the mouse hit-test share this so a click lands where the
1882/// value actually renders.
1883fn padded_label(label: &str) -> String {
1884    format!("{:<11}", label)
1885}
1886
1887/// The on/off text a provider / quota switch renders.
1888fn switch_value(enabled: bool) -> &'static str {
1889    if enabled { "on" } else { "off" }
1890}
1891
1892/// The value cell's offset and rendered width. Mirrors provider_row /
1893/// notify_enabled_line: a 5-cell focus prefix, the padded label, then either
1894/// `" ◀ on ▶ "` when focused or `"  on"` when unfocused. Names past 11
1895/// columns push the cell right — never assume a fixed column.
1896fn switch_cell(label: &str, value: &str, focused: bool) -> (u16, u16) {
1897    let value_x = 5 + crate::display::text_width(&padded_label(label)) as u16;
1898    let decoration_w = if focused { 6 } else { 2 };
1899    let value_w = decoration_w + crate::display::text_width(value) as u16;
1900    (value_x, value_w)
1901}
1902
1903/// One `[<vendor>] enabled` row (#244): the shared display name plus an
1904/// on/off value styled like the notification toggle.
1905fn provider_row(
1906    id: &VendorId,
1907    switch: ProviderSwitch,
1908    focused: bool,
1909    theme: &BubbleTheme,
1910) -> Line<'static> {
1911    let label = padded_label(id.display_name());
1912    let value = switch_value(switch.enabled);
1913    if focused {
1914        Line::from(vec![
1915            theme.span("   "),
1916            Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1917            Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1918            Span::styled(
1919                format!(" ◀ {value} ▶ "),
1920                theme
1921                    .selected
1922                    .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1923            ),
1924        ])
1925    } else {
1926        Line::from(vec![
1927            theme.span("     "),
1928            Span::styled(label, theme.text),
1929            Span::styled(format!("  {value}"), theme.muted),
1930        ])
1931    }
1932}
1933
1934/// `[notifications] enabled` row — a plain on/off toggle styled like the
1935/// primary selector.
1936fn notify_enabled_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1937    let focused = state.focus == Focus::NotifyEnabled;
1938    let label = padded_label("Quota alerts");
1939    let value = switch_value(state.notify_enabled);
1940    if focused {
1941        Line::from(vec![
1942            theme.span("   "),
1943            Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1944            Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1945            Span::styled(
1946                format!(" ◀ {value} ▶ "),
1947                theme
1948                    .selected
1949                    .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1950            ),
1951        ])
1952    } else {
1953        Line::from(vec![
1954            theme.span("     "),
1955            Span::styled(label, theme.text),
1956            Span::styled(format!("  {value}"), theme.muted),
1957        ])
1958    }
1959}
1960
1961/// `[notifications] threshold` row — the digits are never masked, so the
1962/// number is readable at a glance.
1963fn notify_threshold_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1964    let focused = state.focus == Focus::NotifyThreshold;
1965    let label = format!("{:<11}", "Threshold %");
1966    let input = &state.notify_threshold;
1967    let value = if input.buf.is_empty() {
1968        if focused {
1969            "‸".to_string()
1970        } else {
1971            "(97)".to_string()
1972        }
1973    } else {
1974        let mut chars: Vec<char> = input.buf.chars().collect();
1975        if focused {
1976            let pos = input.cursor.min(chars.len());
1977            chars.insert(pos, '‸');
1978        }
1979        chars.into_iter().collect()
1980    };
1981    if focused {
1982        Line::from(vec![
1983            theme.span("  "),
1984            Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1985            Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1986            Span::styled(
1987                format!(" {value} "),
1988                theme
1989                    .selected
1990                    .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1991            ),
1992            theme.muted("   1-100"),
1993        ])
1994    } else {
1995        Line::from(vec![
1996            theme.span("    "),
1997            Span::styled(label, theme.text),
1998            Span::styled(format!(" {value}"), theme.text),
1999            theme.muted("   1-100"),
2000        ])
2001    }
2002}
2003
2004fn save_line(focused: bool, theme: &BubbleTheme) -> Line<'static> {
2005    let style = if focused {
2006        theme
2007            .selected
2008            .add_modifier(Modifier::REVERSED | Modifier::BOLD)
2009    } else {
2010        theme.accent.add_modifier(Modifier::BOLD)
2011    };
2012    let marker = if focused { "▸ " } else { "  " };
2013    Line::from(vec![
2014        theme.span("   "),
2015        Span::styled(marker, theme.accent.add_modifier(Modifier::BOLD)),
2016        Span::styled("  Save  (Ctrl-S)  ", style),
2017    ])
2018}
2019
2020/// Center a rectangle of `percent_x * percent_y` over `r`.
2021fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
2022    let popup_h = (r.height * percent_y) / 100;
2023    let popup_w = (r.width * percent_x) / 100;
2024    Rect {
2025        x: r.x + (r.width - popup_w) / 2,
2026        y: r.y + (r.height - popup_h) / 2,
2027        width: popup_w,
2028        height: popup_h,
2029    }
2030}
2031
2032// crossterm types live behind ratatui; re-exported here for handle_key callers.
2033pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
2034
2035#[cfg(test)]
2036mod tests {
2037    use super::*;
2038    use tempfile::TempDir;
2039
2040    fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
2041        crate::cache::closed_temp_file("config.toml", initial)
2042    }
2043
2044    fn key_index(id: VendorId) -> usize {
2045        KEY_VENDORS.iter().position(|kv| kv.id == id).unwrap()
2046    }
2047
2048    #[test]
2049    fn explicit_enable_preserves_other_settings_and_is_idempotent() {
2050        let dir = tempfile::tempdir().unwrap();
2051        let path = dir.path().join("config.toml");
2052        let original = "# keep me\n[anthropic]\nenabled = false # intentional\n[openrouter]\napi_key = \"test-key\"\nenabled = false\n";
2053        std::fs::write(&path, original).unwrap();
2054        enable_vendor_at(&path, VendorId::Anthropic).unwrap();
2055        let expected = original.replacen("enabled = false", "enabled = true", 1);
2056        assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
2057        enable_vendor_at(&path, VendorId::Anthropic).unwrap();
2058        assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
2059    }
2060
2061    #[test]
2062    fn explicit_enable_creates_missing_config_and_rejects_malformed_config() {
2063        let dir = tempfile::tempdir().unwrap();
2064        let path = dir.path().join("nested/config.toml");
2065        enable_vendor_at(&path, VendorId::Anthropic).unwrap();
2066        assert!(
2067            std::fs::read_to_string(&path)
2068                .unwrap()
2069                .contains("enabled = true")
2070        );
2071        let broken = "[anthropic\n";
2072        std::fs::write(&path, broken).unwrap();
2073        assert!(enable_vendor_at(&path, VendorId::Anthropic).is_err());
2074        assert_eq!(std::fs::read_to_string(&path).unwrap(), broken);
2075    }
2076
2077    fn blank_state(primary: VendorId) -> SettingsState {
2078        SettingsState {
2079            focus: Focus::Primary,
2080            primary_choices: VendorId::all().to_vec(),
2081            primary,
2082            keys: KEY_VENDORS.iter().map(|_| KeyInput::default()).collect(),
2083            vendors: VendorId::all()
2084                .iter()
2085                .map(|id| ProviderSwitch {
2086                    enabled: matches!(id.slug(), "anthropic" | "openai" | "zai" | "openrouter"),
2087                    dirty: false,
2088                })
2089                .collect(),
2090            notify_enabled: true,
2091            notify_enabled_dirty: false,
2092            notify_threshold: KeyInput::from_config(Some("97")),
2093            status: String::new(),
2094            scroll: 0,
2095            picker: None,
2096        }
2097    }
2098
2099    /// State with a Z.AI key and an OpenRouter key, both marked dirty.
2100    fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
2101        let mut s = blank_state(primary);
2102        s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some(zai));
2103        s.keys[key_index(VendorId::Zai)].dirty = true;
2104        s.keys[key_index(VendorId::Openrouter)] = KeyInput::from_config(Some(opr));
2105        s.keys[key_index(VendorId::Openrouter)].dirty = true;
2106        s
2107    }
2108
2109    #[test]
2110    fn focus_cycles_through_primary_all_keys_notifications_and_save() {
2111        let mut f = Focus::Primary;
2112        let mut seen = vec![f];
2113        // Full cycle = Primary + N key rows + every provider switch + both
2114        // notification fields + Save.
2115        for _ in 0..(KEY_VENDORS.len() + PROVIDER_SWITCH_COUNT + 4) {
2116            f = f.next();
2117            seen.push(f);
2118        }
2119        // Primary, Key(0..n), Vendor(0..m), NotifyEnabled, NotifyThreshold,
2120        // Save, Primary.
2121        assert_eq!(seen.first(), Some(&Focus::Primary));
2122        assert_eq!(seen.last(), Some(&Focus::Primary));
2123        assert!(seen.contains(&Focus::Key(0)));
2124        assert!(seen.contains(&Focus::Key(KEY_VENDORS.len() - 1)));
2125        assert!(seen.contains(&Focus::Vendor(0)));
2126        assert!(seen.contains(&Focus::Vendor(PROVIDER_SWITCH_COUNT - 1)));
2127        assert!(seen.contains(&Focus::NotifyEnabled));
2128        assert!(seen.contains(&Focus::NotifyThreshold));
2129        assert!(seen.contains(&Focus::Save));
2130        // prev() is the inverse of next() at every joint of the ring (#244
2131        // inserted the provider rows between the credentials and alerts).
2132        assert_eq!(Focus::Primary.next().prev(), Focus::Primary);
2133        assert_eq!(Focus::Save.prev().next(), Focus::Save);
2134        assert_eq!(
2135            Focus::Key(KEY_VENDORS.len() - 1).next(),
2136            Focus::Vendor(0),
2137            "the first provider row follows the last credential"
2138        );
2139        assert_eq!(Focus::Vendor(0).prev(), Focus::Key(KEY_VENDORS.len() - 1));
2140        assert_eq!(
2141            Focus::Vendor(PROVIDER_SWITCH_COUNT - 1).next(),
2142            Focus::NotifyEnabled
2143        );
2144        assert_eq!(
2145            Focus::NotifyEnabled.prev(),
2146            Focus::Vendor(PROVIDER_SWITCH_COUNT - 1)
2147        );
2148        assert_eq!(Focus::NotifyThreshold.next(), Focus::Save);
2149        assert_eq!(Focus::Primary.prev(), Focus::Save);
2150    }
2151
2152    /// The focus ring must track the real vendor enumeration, not a stale
2153    /// copy: a vendor added without this notice would make the last row
2154    /// unreachable.
2155    #[test]
2156    fn provider_switch_count_matches_the_vendor_enumeration() {
2157        assert_eq!(PROVIDER_SWITCH_COUNT, VendorId::all().len());
2158    }
2159
2160    #[test]
2161    fn from_config_prefills_provider_switches_from_enabled_vendors() {
2162        let mut cfg = Config::default();
2163        cfg.grok.enabled = true;
2164        cfg.anthropic.enabled = false;
2165        let s = SettingsState::from_config_with(&cfg, |_| false);
2166        for (i, id) in VendorId::all().iter().enumerate() {
2167            assert_eq!(
2168                s.vendors[i],
2169                ProviderSwitch {
2170                    enabled: cfg.is_enabled(*id),
2171                    dirty: false
2172                },
2173                "{}",
2174                id.slug()
2175            );
2176        }
2177        assert!(!s.vendors[vendor_index(VendorId::Anthropic)].enabled);
2178        assert!(s.vendors[vendor_index(VendorId::Grok)].enabled);
2179    }
2180
2181    fn vendor_index(id: VendorId) -> usize {
2182        VendorId::all().iter().position(|v| *v == id).unwrap()
2183    }
2184
2185    #[test]
2186    fn provider_switch_flips_on_left_right_and_space() {
2187        let mut s = blank_state(VendorId::Anthropic);
2188        let grok = vendor_index(VendorId::Grok);
2189        s.focus = Focus::Vendor(grok);
2190        assert!(!s.vendors[grok].enabled);
2191        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2192        assert_eq!(
2193            s.vendors[grok],
2194            ProviderSwitch {
2195                enabled: true,
2196                dirty: true
2197            }
2198        );
2199        handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
2200        assert!(!s.vendors[grok].enabled);
2201        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
2202        assert!(s.vendors[grok].enabled);
2203        // Any other key leaves the switch (and its dirty flag) alone.
2204        handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
2205        assert!(s.vendors[grok].enabled);
2206        assert!(s.vendors[grok].dirty);
2207        // A control chord never toggles.
2208        handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::CONTROL);
2209        assert!(s.vendors[grok].enabled);
2210        // Tab moves focus without touching the switch.
2211        let before = s.vendors[grok];
2212        handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE);
2213        assert_ne!(s.focus, Focus::Vendor(grok));
2214        assert_eq!(s.vendors[grok], before);
2215    }
2216
2217    #[test]
2218    fn save_writes_only_toggled_providers_and_round_trips() {
2219        let (_dir, path) = temp_config(Some("# keep\n[anthropic]\nenabled = true\n"));
2220        let mut s = blank_state(VendorId::Anthropic);
2221        s.primary_choices = vec![VendorId::Anthropic];
2222        // Turn Grok on and Anthropic off; leave every other switch untouched.
2223        s.vendors[vendor_index(VendorId::Grok)].enabled = true;
2224        s.vendors[vendor_index(VendorId::Grok)].dirty = true;
2225        s.vendors[vendor_index(VendorId::Anthropic)].enabled = false;
2226        s.vendors[vendor_index(VendorId::Anthropic)].dirty = true;
2227        save_to_path(&s, &path).unwrap();
2228
2229        let raw = std::fs::read_to_string(&path).unwrap();
2230        assert!(raw.starts_with("# keep\n"), "{raw}");
2231        assert!(raw.contains("[anthropic]\nenabled = false"), "{raw}");
2232        assert!(raw.contains("[grok]\nenabled = true"), "{raw}");
2233        // A vendor nobody touched keeps whatever the file had: no new
2234        // `[zai] enabled = true` materializes, and a default-on vendor the
2235        // file never mentioned stays unwritten.
2236        assert!(!raw.contains("[zai]"), "{raw}");
2237        assert!(!raw.contains("[openrouter]"), "{raw}");
2238
2239        let reloaded = Config::load_from(&path).unwrap();
2240        assert!(reloaded.is_enabled(VendorId::Grok));
2241        assert!(!reloaded.is_enabled(VendorId::Anthropic));
2242        assert!(
2243            reloaded.is_enabled(VendorId::Zai),
2244            "untouched default stays on"
2245        );
2246    }
2247
2248    #[test]
2249    fn an_untouched_overlay_save_writes_no_provider_sections() {
2250        let (_dir, path) = temp_config(None);
2251        let mut s = blank_state(VendorId::Anthropic);
2252        s.primary_choices = vec![VendorId::Anthropic];
2253        save_to_path(&s, &path).unwrap();
2254        let raw = std::fs::read_to_string(&path).unwrap();
2255        assert!(!raw.contains("enabled ="), "{raw}");
2256        assert!(!raw.contains("[grok]"), "{raw}");
2257    }
2258
2259    /// The explicit switch is the user's word for it: pasting a credential
2260    /// auto-enables its vendor, and an off toggled in the same save wins.
2261    #[test]
2262    fn an_explicit_provider_off_wins_over_a_pasted_credential() {
2263        let (_dir, path) = temp_config(None);
2264        let mut s = blank_state(VendorId::Zai);
2265        s.primary_choices = vec![VendorId::Zai];
2266        s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some("sk-zai"));
2267        s.keys[key_index(VendorId::Zai)].dirty = true;
2268        s.vendors[vendor_index(VendorId::Zai)].enabled = false;
2269        s.vendors[vendor_index(VendorId::Zai)].dirty = true;
2270        save_to_path(&s, &path).unwrap();
2271        let raw = std::fs::read_to_string(&path).unwrap();
2272        assert!(raw.contains("api_key = \"sk-zai\""), "{raw}");
2273        assert!(raw.contains("enabled = false"), "{raw}");
2274        assert!(!Config::load_from(&path).unwrap().is_enabled(VendorId::Zai));
2275    }
2276
2277    #[test]
2278    fn every_key_vendor_has_a_field() {
2279        // Every enabled-by-key vendor must be reachable in the form.
2280        for id in [
2281            VendorId::Zai,
2282            VendorId::Openrouter,
2283            VendorId::Deepseek,
2284            VendorId::Deepinfra,
2285            VendorId::Kilo,
2286            VendorId::Novita,
2287            VendorId::Synthetic,
2288            VendorId::Moonshot,
2289            VendorId::Grok,
2290        ] {
2291            assert!(
2292                KEY_VENDORS.iter().any(|kv| kv.id == id),
2293                "{id:?} has no key field"
2294            );
2295        }
2296        // OAuth vendors are intentionally absent.
2297        assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Anthropic));
2298        assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Openai));
2299    }
2300
2301    #[test]
2302    fn from_config_prefills_existing_keys() {
2303        let mut cfg = Config::default();
2304        cfg.kilo.api_key = Some("sk-kilo".into());
2305        let s = SettingsState::from_config(&cfg);
2306        assert_eq!(s.keys[key_index(VendorId::Kilo)].buf, "sk-kilo");
2307        assert!(!s.keys[key_index(VendorId::Kilo)].dirty);
2308    }
2309
2310    #[test]
2311    fn from_config_prefills_the_notification_fields() {
2312        let mut cfg = Config::default();
2313        cfg.notifications.enabled = false;
2314        cfg.notifications.threshold = 100;
2315        let s = SettingsState::from_config_with(&cfg, |_| false);
2316        assert!(!s.notify_enabled);
2317        assert!(!s.notify_enabled_dirty);
2318        assert_eq!(s.notify_threshold.buf, "100");
2319        assert!(!s.notify_threshold.dirty);
2320    }
2321
2322    #[test]
2323    fn notification_toggle_flips_on_left_right_and_space() {
2324        let mut s = blank_state(VendorId::Anthropic);
2325        s.focus = Focus::NotifyEnabled;
2326        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2327        assert!(!s.notify_enabled);
2328        assert!(s.notify_enabled_dirty);
2329        handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
2330        assert!(s.notify_enabled);
2331        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
2332        assert!(!s.notify_enabled);
2333        // Any other key leaves the toggle (and its dirty flag) alone.
2334        handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
2335        assert!(!s.notify_enabled);
2336    }
2337
2338    #[test]
2339    fn threshold_edits_accept_digits_and_reject_everything_else() {
2340        let mut s = blank_state(VendorId::Anthropic);
2341        s.focus = Focus::NotifyThreshold;
2342        // Start from an empty buffer to observe each accepted char.
2343        s.notify_threshold = KeyInput::default();
2344        for c in ['9', 'a', '.', '-', '→'] {
2345            handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
2346        }
2347        assert_eq!(s.notify_threshold.buf, "9");
2348        assert!(s.notify_threshold.dirty);
2349        handle_key(&mut s, KeyCode::Char('8'), KeyModifiers::NONE);
2350        assert_eq!(s.notify_threshold.buf, "98");
2351        handle_key(&mut s, KeyCode::Backspace, KeyModifiers::NONE);
2352        assert_eq!(s.notify_threshold.buf, "9");
2353    }
2354
2355    #[test]
2356    fn save_writes_the_notification_fields_and_round_trips() {
2357        let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
2358        let mut s = blank_state(VendorId::Anthropic);
2359        s.notify_enabled = false;
2360        s.notify_enabled_dirty = true;
2361        // Start from an empty buffer so the written value is exactly "90".
2362        s.notify_threshold = KeyInput::default();
2363        for c in "90".chars() {
2364            s.notify_threshold.insert_char(c);
2365        }
2366        save_to_path(&s, &path).unwrap();
2367
2368        let raw = std::fs::read_to_string(&path).unwrap();
2369        assert!(raw.contains("[notifications]"), "{raw}");
2370        assert!(raw.contains("enabled = false"), "{raw}");
2371        assert!(raw.contains("threshold = 90"), "{raw}");
2372        // The written file parses back through the same config path.
2373        let reloaded = Config::load_from(&path).unwrap();
2374        assert!(!reloaded.notifications.enabled);
2375        assert_eq!(reloaded.notifications.threshold, 90);
2376    }
2377
2378    #[test]
2379    fn save_leaves_an_untouched_notification_section_alone() {
2380        let (_dir, path) = temp_config(None);
2381        let s = blank_state(VendorId::Anthropic);
2382        save_to_path(&s, &path).unwrap();
2383        let raw = std::fs::read_to_string(&path).unwrap();
2384        assert!(!raw.contains("[notifications]"), "{raw}");
2385    }
2386
2387    #[test]
2388    fn save_rejects_an_out_of_range_threshold_without_writing() {
2389        let (_dir, path) = temp_config(None);
2390        let mut s = blank_state(VendorId::Anthropic);
2391        for c in "300".chars() {
2392            s.notify_threshold.insert_char(c);
2393        }
2394        let err = save_to_path(&s, &path).unwrap_err().to_string();
2395        assert!(
2396            err.contains("[notifications] threshold must be a whole number between 1 and 100"),
2397            "{err}"
2398        );
2399        // A failed save must not leave a partial file behind.
2400        assert!(!path.exists());
2401
2402        // An empty dirty buffer is the same refusal.
2403        let (_dir, path) = temp_config(None);
2404        let mut s = blank_state(VendorId::Anthropic);
2405        s.notify_threshold = KeyInput::default();
2406        s.notify_threshold.dirty = true;
2407        assert!(save_to_path(&s, &path).is_err());
2408        assert!(!path.exists());
2409    }
2410
2411    #[test]
2412    fn copilot_has_no_editable_credential_field() {
2413        assert!(
2414            !KEY_VENDORS
2415                .iter()
2416                .any(|vendor| vendor.id == VendorId::Copilot)
2417        );
2418    }
2419
2420    /// The exported-env-var path is real behaviour and deserves a test of its
2421    /// own — just not one that reads the machine it runs on.
2422    #[test]
2423    fn a_key_vendor_with_its_env_var_exported_is_offered() {
2424        let cfg = Config::default();
2425        let env = cfg.api_key_env_for(VendorId::Ollama).to_string();
2426
2427        let without = SettingsState::from_config_with(&cfg, |_| false);
2428        assert!(!without.primary_choices.contains(&VendorId::Ollama));
2429
2430        let with = SettingsState::from_config_with(&cfg, |name| name == env);
2431        assert!(
2432            with.primary_choices.contains(&VendorId::Ollama),
2433            "a key vendor whose env var is exported must be selectable: {:?}",
2434            with.primary_choices
2435        );
2436    }
2437
2438    #[test]
2439    fn from_config_offers_enabled_vendors_only() {
2440        let cfg = Config::default();
2441        // No ambient environment: this must not depend on whether the machine
2442        // running `cargo test` happens to export OLLAMA_API_KEY or friends.
2443        let s = SettingsState::from_config_with(&cfg, |_| false);
2444        let mut expected = cfg.enabled_vendors();
2445        expected.push(VendorId::Copilot);
2446        assert_eq!(s.primary_choices, expected);
2447        // API-key opt-in vendors are disabled by default and must not be
2448        // offered. Copilot is the exception: choosing it enables it safely.
2449        assert!(!s.primary_choices.contains(&VendorId::Grok));
2450        assert!(s.primary_choices.contains(&s.primary));
2451        assert!(s.primary_choices.contains(&VendorId::Copilot));
2452    }
2453
2454    #[test]
2455    fn from_config_falls_back_when_configured_primary_is_disabled() {
2456        // Grok is opt-in; a config naming it as primary without enabling it
2457        // must display the first enabled vendor instead.
2458        let mut cfg = Config::default();
2459        cfg.ui.primary = Some(VendorId::Grok);
2460        let s = SettingsState::from_config(&cfg);
2461        assert_ne!(s.primary, VendorId::Grok);
2462        assert_eq!(Some(s.primary), cfg.enabled_vendors().first().copied());
2463    }
2464
2465    /// Switching off the vendor that is still the primary is a state a save
2466    /// can produce. A key vendor that keeps its key stays on offer, so the
2467    /// overlay must not reopen with it selected: the next save, whatever it
2468    /// changed, would take that as the user's pick and switch it back on.
2469    #[test]
2470    fn an_unrelated_save_leaves_a_disabled_primary_switched_off() {
2471        let (_dir, path) = temp_config(Some(
2472            "[ui]\nprimary = \"openrouter\"\n[openrouter]\nenabled = false\napi_key = \"test\"\n",
2473        ));
2474        let cfg = Config::load_from(&path).unwrap();
2475        let mut s = SettingsState::from_config_with(&cfg, |_| false);
2476        assert!(s.primary_choices.contains(&VendorId::Openrouter));
2477        assert_ne!(s.primary, VendorId::Openrouter);
2478
2479        s.notify_enabled = !s.notify_enabled;
2480        s.notify_enabled_dirty = true;
2481        save_to_path(&s, &path).unwrap();
2482
2483        let saved = Config::load_from(&path).unwrap();
2484        assert!(!saved.is_enabled(VendorId::Openrouter));
2485    }
2486
2487    #[test]
2488    fn key_input_insert_backspace_arrow() {
2489        let mut k = KeyInput::default();
2490        k.insert_char('a');
2491        k.insert_char('b');
2492        k.insert_char('c');
2493        assert_eq!(k.buf, "abc");
2494        assert_eq!(k.cursor, 3);
2495        assert!(k.dirty);
2496        k.move_left();
2497        k.move_left();
2498        assert_eq!(k.cursor, 1);
2499        k.insert_char('x');
2500        assert_eq!(k.buf, "axbc");
2501        assert_eq!(k.cursor, 2);
2502        k.backspace();
2503        assert_eq!(k.buf, "abc");
2504        assert_eq!(k.cursor, 1);
2505    }
2506
2507    #[test]
2508    fn key_input_masks_by_default_reveals_on_toggle() {
2509        let mut k = KeyInput::default();
2510        for c in "secret-key".chars() {
2511            k.insert_char(c);
2512        }
2513        assert_eq!(k.display(), "•".repeat(10));
2514        k.toggle_reveal();
2515        assert_eq!(k.display(), "secret-key");
2516    }
2517
2518    #[test]
2519    fn key_input_handles_unicode() {
2520        let mut k = KeyInput::default();
2521        k.insert_char('a');
2522        k.insert_char('→');
2523        k.insert_char('b');
2524        assert_eq!(k.buf, "a→b");
2525        assert_eq!(k.cursor, 3);
2526        k.move_left();
2527        k.backspace();
2528        assert_eq!(k.buf, "ab");
2529    }
2530
2531    #[test]
2532    fn value_text_shows_cursor_and_empty_states() {
2533        let mut k = KeyInput::default();
2534        assert_eq!(value_text(&k, false), "(empty)");
2535        assert_eq!(value_text(&k, true), "‸");
2536        k.insert_char('a');
2537        k.insert_char('b');
2538        // masked + cursor at end
2539        assert_eq!(value_text(&k, true), "••‸");
2540        assert_eq!(value_text(&k, false), "••");
2541    }
2542
2543    #[test]
2544    fn save_writes_key_and_enables_vendor() {
2545        let (_dir, path) = temp_config(None);
2546        let mut s = blank_state(VendorId::Kilo);
2547        s.keys[key_index(VendorId::Kilo)] = KeyInput::from_config(Some("sk-kilo"));
2548        s.keys[key_index(VendorId::Kilo)].dirty = true;
2549        save_to_path(&s, &path).unwrap();
2550        let raw = std::fs::read_to_string(&path).unwrap();
2551        assert!(raw.contains("primary = \"kilo\""));
2552        assert!(raw.contains("[kilo]"));
2553        assert!(raw.contains("api_key = \"sk-kilo\""));
2554        assert!(raw.contains("enabled = true"));
2555    }
2556
2557    #[test]
2558    fn save_writes_minimal_toml_when_starting_empty() {
2559        let (_dir, path) = temp_config(None);
2560        let s = state_with("zk", "ok", VendorId::Zai);
2561        save_to_path(&s, &path).unwrap();
2562        let raw = std::fs::read_to_string(&path).unwrap();
2563        assert!(raw.contains("primary = \"zai\""));
2564        assert!(raw.contains("[zai]"));
2565        assert!(raw.contains("api_key = \"zk\""));
2566        assert!(raw.contains("[openrouter]"));
2567        assert!(raw.contains("api_key = \"ok\""));
2568    }
2569
2570    #[test]
2571    fn save_preserves_existing_comments_and_unrelated_fields() {
2572        let (_dir, path) = temp_config(Some(
2573            r##"# my comment
2574[ui]
2575# pre-existing comment
2576primary = "anthropic"
2577
2578[zai]
2579enabled = true
2580api_key_env = "ZAI_API_KEY"
2581# tier comment
2582plan_tier = "pro"
2583
2584[openrouter]
2585enabled = true
2586api_key_env = "OPENROUTER_API_KEY"
2587
2588[[openrouter.accounts]]
2589label = "work"
2590api_key_env = "OPENROUTER_WORK_API_KEY"
2591"##,
2592        ));
2593
2594        let s = state_with("zk2", "ok2", VendorId::Openrouter);
2595        save_to_path(&s, &path).unwrap();
2596
2597        let raw = std::fs::read_to_string(&path).unwrap();
2598        assert!(raw.contains("# my comment"));
2599        assert!(raw.contains("# pre-existing comment"));
2600        assert!(raw.contains("# tier comment"));
2601        assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
2602        assert!(raw.contains("[[openrouter.accounts]]"));
2603        assert!(raw.contains("api_key_env = \"OPENROUTER_WORK_API_KEY\""));
2604        assert!(raw.contains("plan_tier = \"pro\""));
2605        assert!(raw.contains("primary = \"openrouter\""));
2606        assert!(raw.contains("api_key = \"zk2\""));
2607        assert!(raw.contains("api_key = \"ok2\""));
2608    }
2609
2610    #[test]
2611    fn save_refuses_to_replace_an_unreadable_existing_config() {
2612        let (_dir, path) = temp_config(None);
2613        let original = [0xff, 0xfe, 0xfd];
2614        std::fs::write(&path, original).unwrap();
2615        let state = state_with("new-secret", "", VendorId::Zai);
2616
2617        assert!(save_to_path(&state, &path).is_err());
2618        assert_eq!(std::fs::read(&path).unwrap(), original);
2619    }
2620
2621    #[test]
2622    fn save_does_not_write_empty_key_when_dirty_but_blank() {
2623        let (_dir, path) = temp_config(None);
2624        let mut s = blank_state(VendorId::Anthropic);
2625        // Focus each key, do nothing but mark dirty (blank).
2626        for k in &mut s.keys {
2627            k.dirty = true;
2628        }
2629        save_to_path(&s, &path).unwrap();
2630        let raw = std::fs::read_to_string(&path).unwrap();
2631        assert!(!raw.contains("api_key ="));
2632    }
2633
2634    #[test]
2635    #[cfg(unix)]
2636    fn save_chmods_to_600() {
2637        use std::os::unix::fs::PermissionsExt;
2638        let (_dir, path) = temp_config(None);
2639        let s = state_with("zk", "ok", VendorId::Zai);
2640        save_to_path(&s, &path).unwrap();
2641        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2642        assert_eq!(mode & 0o777, 0o600);
2643    }
2644
2645    #[test]
2646    fn key_vendor_for_slug_finds_only_the_key_table() {
2647        // Every key vendor resolves by slug...
2648        for vendor in KEY_VENDORS {
2649            assert_eq!(
2650                key_vendor_for_slug(vendor.id.slug()).map(|kv| kv.id),
2651                Some(vendor.id),
2652                "{} must resolve",
2653                vendor.id.slug()
2654            );
2655        }
2656        // ...and no vendor outside the table does: the OAuth vendors' slugs
2657        // and the custom ids have no credential field to fill.
2658        for id in VendorId::all() {
2659            if KEY_VENDORS.iter().any(|kv| kv.id == *id) {
2660                continue;
2661            }
2662            assert!(key_vendor_for_slug(id.slug()).is_none(), "{:?}", id);
2663        }
2664        assert!(key_vendor_for_slug("no-such-vendor").is_none());
2665    }
2666
2667    #[test]
2668    fn validate_key_value_refuses_empty_long_and_controlled() {
2669        let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2670        assert!(validate_key_value(vendor, "test-key-value").is_ok());
2671        let empty = validate_key_value(vendor, "");
2672        assert!(empty.is_err());
2673        assert!(empty.unwrap_err().to_string().contains("is empty"));
2674        let long = "x".repeat(MAX_API_KEY_BYTES + 1);
2675        let over = validate_key_value(vendor, &long);
2676        assert!(over.is_err());
2677        assert!(
2678            over.unwrap_err()
2679                .to_string()
2680                .contains(&format!("exceeds {MAX_API_KEY_BYTES}"))
2681        );
2682        let controlled = validate_key_value(vendor, "a\u{1b}[31mb");
2683        assert!(controlled.is_err());
2684        assert!(
2685            controlled
2686                .unwrap_err()
2687                .to_string()
2688                .contains("control characters")
2689        );
2690        // The apply request enforces the same rule through the same code:
2691        // one accepted by the helper must be accepted by the request, and
2692        // one refused by the helper must be refused there.
2693        let cfg = crate::config::Config::default();
2694        let bad = serde_json::json!({
2695            "schema_version": 1,
2696            "keys": { VendorId::Zai.slug(): { "action": "set", "value": "a\u{1b}[31mb" } }
2697        })
2698        .to_string();
2699        assert!(state_from_apply_request(&cfg, &bad).is_err());
2700        let good = serde_json::json!({
2701            "schema_version": 1,
2702            "keys": { VendorId::Zai.slug(): { "action": "set", "value": "test-key-value" } }
2703        })
2704        .to_string();
2705        assert!(state_from_apply_request(&cfg, &good).is_ok());
2706    }
2707
2708    #[test]
2709    fn inline_key_write_leaves_the_value_in_the_right_section() {
2710        let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2711        let (_dir, path) = temp_config(Some("# keep me\n[ui]\nprimary = \"zai\"\n"));
2712        write_inline_key(&path, vendor, "test-key-value").unwrap();
2713        let raw = std::fs::read_to_string(&path).unwrap();
2714        assert!(raw.contains("[zai]"), "{raw}");
2715        assert!(raw.contains(r#"api_key = "test-key-value""#), "{raw}");
2716        // A non-empty credential opts the vendor in, as the overlay does.
2717        assert!(raw.contains("enabled = true"), "{raw}");
2718        // And everything else of the config survives.
2719        assert!(raw.contains("# keep me"), "{raw}");
2720        assert!(raw.contains(r#"primary = "zai""#), "{raw}");
2721        // The file is back to a parseable config with the value captured.
2722        let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2723        assert!(snapshot.key.is_some());
2724        assert!(snapshot.enabled.is_some());
2725    }
2726
2727    #[test]
2728    #[cfg(unix)]
2729    fn inline_key_write_chmods_the_config_to_600() {
2730        use std::os::unix::fs::PermissionsExt;
2731        let vendor = key_vendor_for_slug(VendorId::Openrouter.slug()).unwrap();
2732        let (_dir, path) = temp_config(None);
2733        write_inline_key(&path, vendor, "test-key-value").unwrap();
2734        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2735        assert_eq!(mode & 0o777, 0o600);
2736    }
2737
2738    #[test]
2739    fn inline_key_write_refuses_invalid_values_without_touching_the_file() {
2740        let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2741        let original = "[zai]\napi_key = \"old-key-value\"\n";
2742        let (_dir, path) = temp_config(Some(original));
2743        let long = "x".repeat(MAX_API_KEY_BYTES + 1);
2744        for bad in ["", long.as_str(), "a\u{1b}[31mb"] {
2745            assert!(write_inline_key(&path, vendor, bad).is_err(), "{bad:?}");
2746            assert_eq!(
2747                std::fs::read_to_string(&path).unwrap(),
2748                original,
2749                "the file must not move for {bad:?}"
2750            );
2751        }
2752        // A missing config is created only by a valid value.
2753        let dir = tempfile::tempdir().unwrap();
2754        let empty = dir.path().join("config.toml");
2755        assert!(write_inline_key(&empty, vendor, "").is_err());
2756        assert!(!empty.exists());
2757    }
2758
2759    #[test]
2760    fn inline_key_snapshot_restores_the_exact_previous_state() {
2761        let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2762        // An old key with the vendor off, and a comment on the key's line:
2763        // the restore must give all of it back, byte for byte.
2764        let original = "[ui]\nprimary = \"zai\"\n[zai]\n# my old key\napi_key = \"old-key-value\"\nenabled = false\n";
2765        let (_dir, path) = temp_config(Some(original));
2766        let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2767        assert!(snapshot.file_existed);
2768        assert!(snapshot.section_existed);
2769        write_inline_key(&path, vendor, "test-key-value").unwrap();
2770        assert_ne!(std::fs::read_to_string(&path).unwrap(), original);
2771        restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2772        assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
2773    }
2774
2775    #[test]
2776    fn inline_key_snapshot_restore_keeps_a_later_user_decision() {
2777        let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2778        let original = "[ui]\nprimary = \"zai\"\n[zai]\napi_key = \"old-key-value\"\n";
2779        let (_dir, path) = temp_config(Some(original));
2780        let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2781        write_inline_key(&path, vendor, "test-key-value").unwrap();
2782        // While the check waits: the user saves another key and switches
2783        // the vendor off, with the real writers.
2784        write_inline_key(&path, vendor, "another-key-value").unwrap();
2785        crate::config::set_vendor_enabled(&path, vendor.id.slug(), false).unwrap();
2786        restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2787        let raw = std::fs::read_to_string(&path).unwrap();
2788        // Both fields hold the user's later decisions, not the snapshot.
2789        assert!(raw.contains(r#"api_key = "another-key-value""#), "{raw}");
2790        assert!(raw.contains("enabled = false"), "{raw}");
2791        assert!(!raw.contains("test-key-value"), "{raw}");
2792        assert!(!raw.contains("old-key-value"), "{raw}");
2793    }
2794
2795    #[test]
2796    fn saved_inline_key_counts_an_empty_field_as_absent() {
2797        let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2798        // The catalog's rule: `Config::inline_api_key` filters the empty
2799        // string, so a cleared field offers the key dialog again.
2800        let (_dir, path) = temp_config(Some("[zai]\napi_key = ''\n"));
2801        assert!(saved_inline_key(&snapshot_inline_key(&path, vendor).unwrap()).is_none());
2802        let (_dir, path) = temp_config(Some("[zai]\napi_key = \"k\"\n"));
2803        let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2804        assert_eq!(saved_inline_key(&snapshot).as_deref(), Some("k"));
2805    }
2806
2807    #[test]
2808    fn check_write_refuses_a_saved_nonempty_key() {
2809        let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2810        let original = "[ui]\nprimary = \"zai\"\n[zai]\napi_key = \"old-key-value\"\n";
2811        let (_dir, path) = temp_config(Some(original));
2812        let error = match snapshot_and_write_inline_key_if_absent(&path, vendor, "test-key-value") {
2813            Ok(_) => panic!("a saved key must not be replaced by the check's write"),
2814            Err(error) => error,
2815        };
2816        assert_eq!(error.user_message(), KEY_ALREADY_SAVED_MESSAGE);
2817        // The refusal is inside the locked read-modify-write: nothing was
2818        // written.
2819        assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
2820    }
2821
2822    #[test]
2823    fn check_write_accepts_an_empty_field_and_returns_the_pre_state() {
2824        let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2825        let (_dir, path) = temp_config(Some("[zai]\napi_key = ''\nenabled = false\n"));
2826        // The catalog offers the dialog for this file, so the conditional
2827        // write must too: the empty value counts as absent.
2828        let snapshot =
2829            snapshot_and_write_inline_key_if_absent(&path, vendor, "test-key-value").unwrap();
2830        assert!(snapshot.file_existed);
2831        assert!(snapshot.section_existed);
2832        // The snapshot is the state before the write: the empty field, and
2833        // the vendor still off — what a later restore compares against.
2834        assert_eq!(
2835            saved_inline_key(&snapshot),
2836            None,
2837            "an empty field is no credential, even when captured"
2838        );
2839        let raw = std::fs::read_to_string(&path).unwrap();
2840        assert!(raw.contains(r#"api_key = "test-key-value""#), "{raw}");
2841        assert!(raw.contains("enabled = true"), "{raw}");
2842    }
2843
2844    /// F6's scenario under the lock: the TUI's save and the check's
2845    /// conditional write both wait on the lock the test holds from the
2846    /// outside, and once it is released they run one after the other — in
2847    /// either order the key the TUI saved is never overwritten by the
2848    /// candidate.
2849    #[test]
2850    fn tui_save_and_check_write_serialize_on_the_config_lock() {
2851        use std::sync::mpsc;
2852        let (dir, path) = temp_config(Some("[ui]\nprimary = \"zai\"\n"));
2853        let _ = dir;
2854        let lock_path = crate::config::config_lock_path(&path);
2855
2856        // Both real writers, queued on the lock the test holds: the TUI's
2857        // `save_to_path` and the check's locked snapshot—write.
2858        let waiters =
2859            |path: &std::path::Path, tx: mpsc::Sender<()>, tx_check: mpsc::Sender<Result<()>>| {
2860                let tui_path = path.to_path_buf();
2861                let tui = std::thread::spawn(move || {
2862                    let state = state_with("tui-key-value", "openrouter-key-value", VendorId::Zai);
2863                    tx.send(()).unwrap();
2864                    save_to_path(&state, &tui_path).unwrap();
2865                });
2866                let check_path = path.to_path_buf();
2867                let check = std::thread::spawn(move || {
2868                    let vendor = key_vendor_for_slug("zai").unwrap();
2869                    tx_check
2870                        .send(
2871                            snapshot_and_write_inline_key_if_absent(
2872                                &check_path,
2873                                vendor,
2874                                "candidate-key-value",
2875                            )
2876                            .map(|_| ()),
2877                        )
2878                        .unwrap();
2879                });
2880                (tui, check)
2881            };
2882
2883        // Round 1: no key is saved yet. Both writers wait, the lock is
2884        // released, and whatever order the lock grants, the TUI's key is
2885        // what is left — the candidate either refused or got replaced by
2886        // the TUI's later save.
2887        let held = crate::cache::acquire_lock(&lock_path, std::time::Duration::from_secs(30))
2888            .expect("the test takes the config lock");
2889        let (tx, rx) = mpsc::channel();
2890        let (tx_check, rx_check) = mpsc::channel();
2891        let (tui, check) = waiters(&path, tx, tx_check);
2892        rx.recv().unwrap();
2893        drop(held);
2894        let check_result = rx_check.recv().unwrap();
2895        tui.join().unwrap();
2896        check.join().unwrap();
2897        let raw = std::fs::read_to_string(&path).unwrap();
2898        assert!(
2899            raw.contains(r#"api_key = "tui-key-value""#),
2900            "the TUI's key must survive: {raw}"
2901        );
2902        assert!(
2903            !raw.contains("candidate-key-value"),
2904            "the candidate must not overwrite the TUI's key: {raw}"
2905        );
2906        if let Err(error) = check_result {
2907            // The TUI's save ran first: the check saw its key and refused
2908            // with the existing saved-key message.
2909            assert_eq!(error.user_message(), KEY_ALREADY_SAVED_MESSAGE);
2910        }
2911
2912        // Round 2: the TUI's key is saved now. The same two writers wait
2913        // again; whatever order the lock grants, the check must refuse and
2914        // the file must not change.
2915        let before = std::fs::read_to_string(&path).unwrap();
2916        let held = crate::cache::acquire_lock(&lock_path, std::time::Duration::from_secs(30))
2917            .expect("the test takes the config lock again");
2918        let (tx, rx) = mpsc::channel();
2919        let (tx_check, rx_check) = mpsc::channel();
2920        let (tui, check) = waiters(&path, tx, tx_check);
2921        rx.recv().unwrap();
2922        drop(held);
2923        let error = rx_check
2924            .recv()
2925            .unwrap()
2926            .expect_err("a saved key must refuse the candidate");
2927        tui.join().unwrap();
2928        check.join().unwrap();
2929        assert_eq!(error.user_message(), KEY_ALREADY_SAVED_MESSAGE);
2930        assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
2931    }
2932    #[test]
2933    fn inline_key_snapshot_removes_the_key_when_there_was_none() {
2934        let vendor = key_vendor_for_slug(VendorId::Deepseek.slug()).unwrap();
2935        let original = "[ui]\nprimary = \"deepseek\"\n";
2936        let (_dir, path) = temp_config(Some(original));
2937        let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2938        assert!(!snapshot.section_existed);
2939        write_inline_key(&path, vendor, "test-key-value").unwrap();
2940        restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2941        let raw = std::fs::read_to_string(&path).unwrap();
2942        // The key is gone, the section the check created is gone, and
2943        // everything else of the file is intact.
2944        assert!(!raw.contains("api_key"), "the key must be gone: {raw}");
2945        assert!(
2946            !raw.contains("[deepseek]"),
2947            "the section must be gone: {raw}"
2948        );
2949        assert!(raw.contains(r#"primary = "deepseek""#), "{raw}");
2950    }
2951
2952    #[test]
2953    fn inline_key_snapshot_of_a_missing_file_removes_it_on_restore() {
2954        let vendor = key_vendor_for_slug(VendorId::Kimi.slug()).unwrap();
2955        let (dir, path) = temp_config(None);
2956        let _ = dir;
2957        let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2958        assert!(!snapshot.file_existed);
2959        assert!(!snapshot.section_existed);
2960        assert!(snapshot.key.is_none());
2961        assert!(snapshot.enabled.is_none());
2962        write_inline_key(&path, vendor, "test-key-value").unwrap();
2963        restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2964        assert!(!path.exists(), "the file the check created must be gone");
2965    }
2966
2967    #[test]
2968    fn inline_key_snapshot_keeps_edits_made_elsewhere_in_the_meantime() {
2969        let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2970        let original = "[ui]\nprimary = \"zai\"\n[zai]\napi_key = \"old-key-value\"\n";
2971        let (_dir, path) = temp_config(Some(original));
2972        let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2973        write_inline_key(&path, vendor, "test-key-value").unwrap();
2974        // An edit the check did not make: a new field in the same section.
2975        let mut doc = crate::config::read_config_document(&path).unwrap();
2976        set_string(&mut doc, "zai", "api_key_env", "SOME_ENV_VAR").unwrap();
2977        crate::config::write_config_document(&path, &doc).unwrap();
2978        restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2979        let raw = std::fs::read_to_string(&path).unwrap();
2980        // The snapshot's state is back...
2981        assert!(raw.contains(r#"api_key = "old-key-value""#), "{raw}");
2982        // ...and the foreign edit survived, untouched by the snapshot.
2983        assert!(raw.contains(r#"api_key_env = "SOME_ENV_VAR""#), "{raw}");
2984    }
2985
2986    #[test]
2987    fn tab_cycles_focus_from_primary_to_first_key() {
2988        let mut s = blank_state(VendorId::Anthropic);
2989        assert_eq!(
2990            handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
2991            Action::Continue
2992        );
2993        assert_eq!(s.focus, Focus::Key(0));
2994        assert_eq!(
2995            handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
2996            Action::Continue
2997        );
2998        assert_eq!(s.focus, Focus::Primary);
2999    }
3000
3001    #[test]
3002    fn esc_closes_without_saving() {
3003        let mut s = blank_state(VendorId::Anthropic);
3004        assert_eq!(
3005            handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
3006            Action::Close
3007        );
3008    }
3009
3010    #[test]
3011    fn left_right_cycles_primary_vendor() {
3012        // Canonical order (VendorId::all): Anthropic, AnthropicApi, Openai, …
3013        let mut s = blank_state(VendorId::Anthropic);
3014        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3015        assert_eq!(s.primary, VendorId::AnthropicApi);
3016        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3017        assert_eq!(s.primary, VendorId::Openai);
3018        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
3019        assert_eq!(s.primary, VendorId::AnthropicApi);
3020    }
3021
3022    #[test]
3023    fn left_right_offers_enabled_vendors_only() {
3024        // The selector must never land on a vendor the widget cannot use.
3025        let mut s = blank_state(VendorId::Anthropic);
3026        s.primary_choices = vec![VendorId::Anthropic, VendorId::Grok];
3027        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3028        assert_eq!(s.primary, VendorId::Grok);
3029        // Wraps within the enabled set rather than walking into disabled ones.
3030        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3031        assert_eq!(s.primary, VendorId::Anthropic);
3032        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
3033        assert_eq!(s.primary, VendorId::Grok);
3034    }
3035
3036    #[test]
3037    fn no_enabled_vendors_leaves_primary_selector_inert() {
3038        let mut s = blank_state(VendorId::Anthropic);
3039        s.primary_choices = vec![];
3040        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3041        assert_eq!(s.primary, VendorId::Anthropic);
3042    }
3043
3044    #[test]
3045    fn disabled_copilot_is_offered_but_not_shown_as_the_current_primary() {
3046        let mut cfg = Config::default();
3047        cfg.ui.primary = Some(VendorId::Copilot);
3048        let state = SettingsState::from_config(&cfg);
3049
3050        assert!(state.primary_choices.contains(&VendorId::Copilot));
3051        assert_eq!(state.primary, VendorId::Anthropic);
3052    }
3053
3054    #[test]
3055    fn save_does_not_write_a_disabled_primary() {
3056        // Saving an API key must not persist a primary the resolver would
3057        // ignore; an existing value in the file stays untouched.
3058        let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
3059        let mut s = state_with("zk", "ok", VendorId::Grok);
3060        s.primary_choices = vec![VendorId::Anthropic];
3061        save_to_path(&s, &path).unwrap();
3062        let raw = std::fs::read_to_string(&path).unwrap();
3063        assert!(raw.contains("primary = \"anthropic\""));
3064        assert!(!raw.contains("primary = \"grok\""));
3065        // The keys still saved.
3066        assert!(raw.contains("zk"));
3067    }
3068
3069    #[test]
3070    fn save_removes_an_inline_key_the_user_cleared() {
3071        // Clearing the field in the overlay must delete the secret from the
3072        // file — otherwise there is no way to remove it short of hand-editing.
3073        let (_dir, path) = temp_config(Some(
3074            "[zai]\nenabled = true\napi_key = \"old-secret\"\nplan_tier = \"pro\"\n",
3075        ));
3076        let mut s = blank_state(VendorId::Zai);
3077        s.primary_choices = vec![VendorId::Zai];
3078        s.keys[key_index(VendorId::Zai)] = KeyInput::default();
3079        s.keys[key_index(VendorId::Zai)].dirty = true;
3080        save_to_path(&s, &path).unwrap();
3081        let raw = std::fs::read_to_string(&path).unwrap();
3082        assert!(!raw.contains("old-secret"));
3083        assert!(!raw.contains("api_key"));
3084        // Unrelated fields in the same section survive.
3085        assert!(raw.contains("plan_tier = \"pro\""));
3086    }
3087
3088    #[test]
3089    fn save_clears_a_key_from_a_toml_inline_table() {
3090        let (_dir, path) = temp_config(Some(
3091            "zai = { enabled = false, api_key = \"remove-me\", plan_tier = \"pro\" } # keep me\n\
3092             [ui]\nprimary = \"anthropic\"\n",
3093        ));
3094        let cfg = Config::load_from(&path).unwrap();
3095        let mut state = SettingsState::from_config_with(&cfg, |_| false);
3096        let input = &mut state.keys[key_index(VendorId::Zai)];
3097        *input = KeyInput::default();
3098        input.dirty = true;
3099
3100        save_to_path(&state, &path).unwrap();
3101
3102        let reloaded = Config::load_from(&path).unwrap();
3103        assert!(reloaded.zai.api_key.is_none());
3104        assert!(!reloaded.zai.enabled);
3105        assert_eq!(reloaded.zai.plan_tier.as_deref(), Some("pro"));
3106        let raw = std::fs::read_to_string(&path).unwrap();
3107        assert!(!raw.contains("remove-me"));
3108        assert!(raw.contains("zai = {"));
3109        assert!(raw.contains("# keep me"));
3110    }
3111
3112    #[test]
3113    fn save_updates_a_key_and_enables_a_toml_inline_table() {
3114        let (_dir, path) = temp_config(Some(
3115            "zai = { enabled = false, api_key = \"old-secret\", plan_tier = \"pro\" } # keep me\n\
3116             [ui]\nprimary = \"anthropic\"\n",
3117        ));
3118        let cfg = Config::load_from(&path).unwrap();
3119        let mut state = SettingsState::from_config_with(&cfg, |_| false);
3120        let input = &mut state.keys[key_index(VendorId::Zai)];
3121        input.buf = "new-secret".into();
3122        input.dirty = true;
3123
3124        save_to_path(&state, &path).unwrap();
3125
3126        let reloaded = Config::load_from(&path).unwrap();
3127        assert_eq!(reloaded.zai.api_key.as_deref(), Some("new-secret"));
3128        assert!(reloaded.zai.enabled);
3129        assert_eq!(reloaded.zai.plan_tier.as_deref(), Some("pro"));
3130        let raw = std::fs::read_to_string(&path).unwrap();
3131        assert!(!raw.contains("old-secret"));
3132        assert!(raw.contains("new-secret"));
3133        assert!(raw.contains("zai = {"));
3134        assert!(raw.contains("# keep me"));
3135    }
3136
3137    #[test]
3138    fn enable_vendor_at_enables_a_toml_inline_table() {
3139        let (_dir, path) = temp_config(Some(
3140            "zai = { enabled = false, plan_tier = \"pro\" } # keep me\n\
3141             [ui]\nprimary = \"anthropic\"\n",
3142        ));
3143        enable_vendor_at(&path, VendorId::Zai).unwrap();
3144
3145        let reloaded = Config::load_from(&path).unwrap();
3146        assert!(reloaded.zai.enabled);
3147        assert_eq!(reloaded.zai.plan_tier.as_deref(), Some("pro"));
3148        let raw = std::fs::read_to_string(&path).unwrap();
3149        assert!(raw.contains("zai = {"));
3150        assert!(raw.contains("# keep me"));
3151    }
3152
3153    #[test]
3154    fn untouched_key_field_is_left_alone() {
3155        // Not dirty => the file's existing secret must survive a save.
3156        let (_dir, path) = temp_config(Some("[zai]\napi_key = \"keep-me\"\n"));
3157        let mut s = blank_state(VendorId::Zai);
3158        s.primary_choices = vec![VendorId::Zai];
3159        save_to_path(&s, &path).unwrap();
3160        let raw = std::fs::read_to_string(&path).unwrap();
3161        assert!(raw.contains("keep-me"));
3162    }
3163
3164    #[test]
3165    fn typing_edits_the_focused_key_only() {
3166        let mut s = blank_state(VendorId::Anthropic);
3167        s.focus = Focus::Key(key_index(VendorId::Grok));
3168        for c in "xai-abc".chars() {
3169            handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
3170        }
3171        assert_eq!(s.keys[key_index(VendorId::Grok)].buf, "xai-abc");
3172        assert!(s.keys[key_index(VendorId::Grok)].dirty);
3173        // No other field was touched.
3174        assert!(s.keys[key_index(VendorId::Zai)].buf.is_empty());
3175    }
3176
3177    #[test]
3178    fn ctrl_v_toggles_reveal_on_focused_key_field() {
3179        let mut s = blank_state(VendorId::Anthropic);
3180        let zi = key_index(VendorId::Zai);
3181        s.focus = Focus::Key(zi);
3182        s.keys[zi] = KeyInput::from_config(Some("secret"));
3183        assert!(!s.keys[zi].revealed);
3184        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
3185        assert!(s.keys[zi].revealed);
3186        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
3187        assert!(!s.keys[zi].revealed);
3188    }
3189
3190    #[test]
3191    fn control_chorded_chars_do_not_type_into_fields() {
3192        let mut s = blank_state(VendorId::Anthropic);
3193        s.focus = Focus::Key(0);
3194        // Ctrl-A must NOT insert a literal 'a' or mark the field dirty.
3195        handle_key(&mut s, KeyCode::Char('a'), KeyModifiers::CONTROL);
3196        assert!(s.keys[0].buf.is_empty());
3197        assert!(!s.keys[0].dirty);
3198        // Ctrl-C quits the host TUI even while the overlay owns focus.
3199        assert_eq!(
3200            handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
3201            Action::Quit
3202        );
3203        // A plain char still types normally.
3204        handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
3205        assert_eq!(s.keys[0].buf, "x");
3206    }
3207
3208    #[test]
3209    fn ctrl_v_on_non_key_focus_is_noop() {
3210        let mut s = blank_state(VendorId::Anthropic);
3211        s.focus = Focus::Primary;
3212        // Must not panic when no key field is focused.
3213        assert_eq!(
3214            handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL),
3215            Action::Continue
3216        );
3217    }
3218
3219    fn state_focused_on_zai() -> SettingsState {
3220        let mut state = blank_state(VendorId::Anthropic);
3221        state.focus = Focus::Key(key_index(VendorId::Zai));
3222        state
3223    }
3224
3225    #[test]
3226    fn handle_key_ctrl_c_quits_without_typing_into_key_field() {
3227        let mut s = state_focused_on_zai();
3228        let zi = key_index(VendorId::Zai);
3229        assert_eq!(
3230            handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
3231            Action::Quit
3232        );
3233        assert!(s.keys[zi].buf.is_empty());
3234        // Untouched means save still leaves an existing key on disk alone.
3235        assert!(!s.keys[zi].dirty);
3236    }
3237
3238    #[test]
3239    fn handle_key_alt_chord_does_not_type_into_key_field() {
3240        let mut s = state_focused_on_zai();
3241        let zi = key_index(VendorId::Zai);
3242        handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::ALT);
3243        assert!(s.keys[zi].buf.is_empty());
3244        assert!(!s.keys[zi].dirty);
3245    }
3246
3247    #[test]
3248    fn handle_key_platform_modifier_chords_do_not_type_into_key_field() {
3249        for modifier in [KeyModifiers::SUPER, KeyModifiers::HYPER, KeyModifiers::META] {
3250            let mut s = state_focused_on_zai();
3251            let zi = key_index(VendorId::Zai);
3252            handle_key(&mut s, KeyCode::Char('x'), modifier);
3253            assert!(s.keys[zi].buf.is_empty(), "modifier {modifier:?}");
3254            assert!(!s.keys[zi].dirty, "modifier {modifier:?}");
3255        }
3256    }
3257
3258    #[test]
3259    fn handle_key_shift_still_types_uppercase() {
3260        let mut s = state_focused_on_zai();
3261        let zi = key_index(VendorId::Zai);
3262        handle_key(&mut s, KeyCode::Char('A'), KeyModifiers::SHIFT);
3263        assert_eq!(s.keys[zi].buf, "A");
3264        assert!(s.keys[zi].dirty);
3265    }
3266
3267    #[test]
3268    fn handle_key_plain_space_still_cycles_primary_vendor() {
3269        let mut s = blank_state(VendorId::Anthropic);
3270        handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
3271        assert_eq!(s.primary, VendorId::AnthropicApi);
3272    }
3273
3274    #[test]
3275    fn handle_key_ctrl_s_attempts_save_from_any_field() {
3276        let (_dir, path) = temp_config(None);
3277        let s = state_with("zk", "ok", VendorId::Zai);
3278        save_to_path(&s, &path).unwrap();
3279        let raw = std::fs::read_to_string(&path).unwrap();
3280        assert!(raw.contains("api_key = \"zk\""));
3281    }
3282    #[test]
3283    fn save_to_path_writes_kimi_key_when_dirty() {
3284        let (_dir, path) = temp_config(None);
3285        let mut s = blank_state(VendorId::Anthropic);
3286        let kimi = key_index(VendorId::Kimi);
3287        s.keys[kimi] = KeyInput::from_config(Some("kk"));
3288        s.keys[kimi].dirty = true;
3289        save_to_path(&s, &path).unwrap();
3290        let raw = std::fs::read_to_string(&path).unwrap();
3291        assert!(raw.contains("[kimi]"));
3292        assert!(raw.contains("api_key = \"kk\""));
3293    }
3294
3295    #[test]
3296    fn settings_save_uses_the_same_config_path_as_load() {
3297        assert_eq!(
3298            default_config_path().unwrap(),
3299            crate::config::resolved_path().unwrap()
3300        );
3301    }
3302
3303    #[test]
3304    fn native_snapshot_reports_key_state_without_serializing_secrets() {
3305        let mut cfg = Config::default();
3306        cfg.zai.api_key = Some("never-leak-this-key".into());
3307        cfg.zai.api_key_env = "CUSTOM_ZAI_KEY".into();
3308        let raw = settings_snapshot_json_with(&cfg, |name| name == "CUSTOM_ZAI_KEY").unwrap();
3309        let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
3310
3311        assert_eq!(parsed["schema_version"], 1);
3312        assert_eq!(parsed["primary"], "anthropic");
3313        let zai = parsed["keys"]
3314            .as_array()
3315            .unwrap()
3316            .iter()
3317            .find(|row| row["id"] == "zai")
3318            .unwrap();
3319        assert_eq!(zai["configured"], true);
3320        assert_eq!(zai["inline_configured"], true);
3321        assert_eq!(zai["environment_configured"], true);
3322        assert_eq!(zai["environment"], "CUSTOM_ZAI_KEY");
3323        assert!(!raw.contains("never-leak-this-key"));
3324        assert!(parsed.get("api_key").is_none());
3325    }
3326
3327    #[test]
3328    fn native_snapshot_offers_copilot_primary_without_a_token_field() {
3329        let cfg = Config::default();
3330        let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
3331        let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
3332        assert!(
3333            parsed["primary_choices"]
3334                .as_array()
3335                .unwrap()
3336                .iter()
3337                .any(|row| row["id"] == "copilot")
3338        );
3339        assert!(
3340            !parsed["keys"]
3341                .as_array()
3342                .unwrap()
3343                .iter()
3344                .any(|row| row["id"] == "copilot")
3345        );
3346    }
3347
3348    #[test]
3349    fn native_key_only_patch_does_not_require_or_replace_primary() {
3350        let cfg = Config::default();
3351        let original_primary = SettingsState::from_config(&cfg).primary;
3352        let request = serde_json::json!({
3353            "schema_version": 1,
3354            "keys": {"kimi": {"action": "set", "value": "new-kimi-key"}}
3355        });
3356
3357        let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
3358        assert_eq!(state.primary, original_primary);
3359        let kimi_index = KEY_VENDORS
3360            .iter()
3361            .position(|vendor| vendor.id == VendorId::Kimi)
3362            .unwrap();
3363        assert!(state.keys[kimi_index].dirty);
3364        assert_eq!(state.keys[kimi_index].buf, "new-kimi-key");
3365    }
3366
3367    #[test]
3368    fn native_patch_reuses_tui_persistence_and_preserves_existing_config() {
3369        let (_dir, path) = temp_config(Some(
3370            r#"# keep this comment
3371[ui]
3372primary = "anthropic"
3373
3374[zai]
3375enabled = true
3376api_key_env = "ZAI_API_KEY"
3377plan_tier = "pro"
3378
3379[openrouter]
3380enabled = true
3381"#,
3382        ));
3383        let cfg = Config::load_from(&path).unwrap();
3384        let request = serde_json::json!({
3385            "schema_version": 1,
3386            "primary": "openrouter",
3387            "keys": {
3388                "zai": {"action": "set", "value": "new-zai-key"}
3389            }
3390        });
3391
3392        apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
3393        let raw = std::fs::read_to_string(&path).unwrap();
3394        assert!(raw.contains("# keep this comment"));
3395        assert!(raw.contains("plan_tier = \"pro\""));
3396        assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
3397        assert!(raw.contains("primary = \"openrouter\""));
3398        assert!(raw.contains("api_key = \"new-zai-key\""));
3399    }
3400
3401    #[test]
3402    fn native_patch_distinguishes_clear_from_unchanged() {
3403        let (_dir, path) = temp_config(Some(
3404            "[zai]\nenabled = true\napi_key = \"remove-me\"\n\
3405             [openrouter]\nenabled = true\napi_key = \"keep-me\"\n",
3406        ));
3407        let cfg = Config::load_from(&path).unwrap();
3408        let request = serde_json::json!({
3409            "schema_version": 1,
3410            "primary": "zai",
3411            "keys": {"zai": {"action": "clear"}}
3412        });
3413
3414        apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
3415        let raw = std::fs::read_to_string(&path).unwrap();
3416        assert!(!raw.contains("remove-me"));
3417        assert!(raw.contains("keep-me"));
3418    }
3419
3420    #[test]
3421    fn native_primary_selection_enables_copilot_without_writing_a_token() {
3422        let (_dir, path) = temp_config(Some(
3423            "[copilot]\nenabled = false\ntoken = \"legacy-value\"\ntoken_env = \"OLD_TOKEN\"\n",
3424        ));
3425        let cfg = Config::load_from(&path).unwrap();
3426        let select = serde_json::json!({
3427            "schema_version": 1,
3428            "primary": "copilot"
3429        });
3430        apply_settings_json_to_path(&cfg, &select.to_string(), &path).unwrap();
3431        let raw = std::fs::read_to_string(&path).unwrap();
3432        assert!(raw.contains("enabled = true"));
3433        assert!(raw.contains("primary = \"copilot\""));
3434        assert!(!raw.contains("token ="));
3435        assert!(!raw.contains("token_env ="));
3436    }
3437
3438    #[test]
3439    fn native_patch_errors_never_echo_key_values() {
3440        let raw = serde_json::json!({
3441            "schema_version": 1,
3442            "primary": "anthropic",
3443            "keys": {
3444                "zai": {"action": "set", "value": "secret\nwith-control"}
3445            }
3446        })
3447        .to_string();
3448        let error = state_from_apply_request(&Config::default(), &raw)
3449            .unwrap_err()
3450            .to_string();
3451        assert!(!error.contains("secret"));
3452        assert!(error.contains("control characters"));
3453    }
3454
3455    #[test]
3456    fn native_patch_input_is_bounded_before_json_parsing() {
3457        let oversized = vec![b'x'; MAX_SETTINGS_REQUEST_BYTES as usize + 1];
3458        let error = read_settings_request(std::io::Cursor::new(oversized))
3459            .unwrap_err()
3460            .to_string();
3461        assert!(error.contains("exceeds"));
3462    }
3463
3464    // ─── Provider on/off switches (#244) ─────────────────────────────────────
3465
3466    #[test]
3467    fn native_snapshot_lists_every_provider_with_its_enabled_state() {
3468        let mut cfg = Config::default();
3469        cfg.grok.enabled = true;
3470        let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
3471        let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
3472
3473        let vendors = parsed["vendors"].as_array().unwrap();
3474        assert_eq!(vendors.len(), VendorId::all().len());
3475        let by_id = |slug: &str| {
3476            vendors
3477                .iter()
3478                .find(|row| row["id"] == slug)
3479                .unwrap_or_else(|| panic!("no {slug} row: {vendors:?}"))
3480                .clone()
3481        };
3482        assert_eq!(by_id("anthropic")["enabled"], true);
3483        assert_eq!(by_id("grok")["enabled"], true);
3484        assert_eq!(by_id("kimi")["enabled"], false);
3485        // Labels come from the shared display-name source, not a second table.
3486        assert_eq!(by_id("anthropic")["label"], "Claude");
3487        assert_eq!(by_id("opencode-go")["label"], "OpenCode Go");
3488    }
3489
3490    #[test]
3491    fn native_patch_toggles_providers_and_preserves_the_rest_of_the_file() {
3492        let (_dir, path) = temp_config(Some("# keep\n[zai]\nenabled = true\napi_key = \"k\"\n"));
3493        let cfg = Config::load_from(&path).unwrap();
3494        let request = serde_json::json!({
3495            "schema_version": 1,
3496            "vendors": {"grok": true, "zai": false}
3497        });
3498
3499        apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
3500
3501        let raw = std::fs::read_to_string(&path).unwrap();
3502        assert!(raw.starts_with("# keep\n"), "{raw}");
3503        assert!(raw.contains("api_key = \"k\""), "{raw}");
3504        let reloaded = Config::load_from(&path).unwrap();
3505        assert!(reloaded.is_enabled(VendorId::Grok));
3506        assert!(!reloaded.is_enabled(VendorId::Zai));
3507    }
3508
3509    #[test]
3510    fn native_patch_refuses_an_unknown_provider_slug_without_writing() {
3511        let (_dir, path) = temp_config(Some("[zai]\nenabled = true\n"));
3512        let cfg = Config::load_from(&path).unwrap();
3513        for bad in ["", "mytool", "not-a-vendor"] {
3514            let request = serde_json::json!({
3515                "schema_version": 1,
3516                "vendors": {bad: true}
3517            });
3518            let error = state_from_apply_request(&cfg, &request.to_string())
3519                .unwrap_err()
3520                .to_string();
3521            assert!(error.contains("unknown provider"), "{bad}: {error}");
3522        }
3523        // A rejected patch leaves the file untouched.
3524        assert_eq!(
3525            std::fs::read_to_string(&path).unwrap(),
3526            "[zai]\nenabled = true\n"
3527        );
3528    }
3529
3530    /// A vendor-toggle-only patch needs neither a primary nor a key change.
3531    #[test]
3532    fn native_vendor_toggle_alone_is_a_valid_patch() {
3533        let cfg = Config::default();
3534        let request = serde_json::json!({
3535            "schema_version": 1,
3536            "vendors": {"kimi": true}
3537        });
3538        let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
3539        let kimi = vendor_index(VendorId::Kimi);
3540        assert_eq!(
3541            state.vendors[kimi],
3542            ProviderSwitch {
3543                enabled: true,
3544                dirty: true
3545            }
3546        );
3547    }
3548
3549    // ─── Overlay scroll (#244: the body outgrew small terminals) ────────────
3550
3551    #[test]
3552    fn body_layout_positions_every_focusable_row_without_overlap() {
3553        let keys = 5;
3554        let vendors = 7;
3555        let layout = body_layout(keys, vendors);
3556        // Credentials: header at 3, rows 4..9, blank 9.
3557        assert_eq!(layout.primary, 1);
3558        assert_eq!(layout.first_key, 4);
3559        // Providers: blank after keys, header, then rows.
3560        assert_eq!(layout.first_vendor, layout.first_key + keys + 2);
3561        assert_eq!(layout.notify_enabled, layout.first_vendor + vendors + 2);
3562        assert_eq!(layout.notify_threshold, layout.notify_enabled + 1);
3563        assert_eq!(layout.save, layout.notify_threshold + 2);
3564        assert_eq!(layout.rows, layout.save + 1);
3565        // No two focusable rows share a line.
3566        let mut rows = vec![
3567            layout.primary,
3568            layout.notify_enabled,
3569            layout.notify_threshold,
3570            layout.save,
3571        ];
3572        rows.extend(layout.first_key..layout.first_key + keys);
3573        rows.extend(layout.first_vendor..layout.first_vendor + vendors);
3574        rows.sort_unstable();
3575        let mut unique = rows.clone();
3576        unique.dedup();
3577        assert_eq!(rows, unique);
3578        assert!(rows.iter().all(|row| *row < layout.rows));
3579    }
3580
3581    #[test]
3582    fn focus_line_matches_the_body_layout_for_every_focus_variant() {
3583        let state = blank_state(VendorId::Anthropic);
3584        let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
3585        let cases = [
3586            (Focus::Primary, layout.primary),
3587            (Focus::Key(0), layout.first_key),
3588            (
3589                Focus::Key(KEY_VENDORS.len() - 1),
3590                layout.first_key + KEY_VENDORS.len() - 1,
3591            ),
3592            (Focus::Vendor(0), layout.first_vendor),
3593            (
3594                Focus::Vendor(state.vendors.len() - 1),
3595                layout.first_vendor + state.vendors.len() - 1,
3596            ),
3597            (Focus::NotifyEnabled, layout.notify_enabled),
3598            (Focus::NotifyThreshold, layout.notify_threshold),
3599            (Focus::Save, layout.save),
3600        ];
3601        for (focus, want) in cases {
3602            let mut s = state.clone();
3603            s.focus = focus;
3604            assert_eq!(focus_line(&s), want, "{focus:?}");
3605        }
3606    }
3607
3608    #[test]
3609    fn follow_focus_scroll_sticks_jumps_and_clamps() {
3610        // Already visible: unchanged.
3611        assert_eq!(follow_focus_scroll(3, 50, 10, 0), 0);
3612        assert_eq!(follow_focus_scroll(9, 50, 10, 0), 0);
3613        // Below the viewport: pinned to the bottom edge.
3614        assert_eq!(follow_focus_scroll(10, 50, 10, 0), 1);
3615        assert_eq!(follow_focus_scroll(25, 50, 10, 5), 16);
3616        // Above the window: jumps up to the focused line.
3617        assert_eq!(follow_focus_scroll(2, 50, 10, 16), 2);
3618        // Never past the scrollable range.
3619        assert_eq!(follow_focus_scroll(49, 50, 10, 0), 40);
3620        assert_eq!(follow_focus_scroll(60, 50, 10, 0), 40);
3621        // Degenerate viewport/total: no scrolling.
3622        assert_eq!(follow_focus_scroll(5, 50, 0, 3), 0);
3623        assert_eq!(follow_focus_scroll(5, 0, 10, 3), 0);
3624    }
3625
3626    /// Actionable hint segments record `HintKey` click rects on the hint row;
3627    /// pure key hints (move/type/digits) never do.
3628    #[test]
3629    fn render_records_clickable_hint_links_for_the_focused_control() {
3630        use ratatui::Terminal;
3631        use ratatui::backend::TestBackend;
3632
3633        let theme = crate::theme::Theme::default();
3634        let mut state = blank_state(VendorId::Anthropic);
3635        state.focus = Focus::Vendor(vendor_index(VendorId::Grok));
3636        let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
3637        let mut hits = Vec::new();
3638        terminal
3639            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3640            .unwrap();
3641
3642        let hint_keys: Vec<(KeyCode, KeyModifiers)> = hits
3643            .iter()
3644            .filter_map(|(row, _)| match row {
3645                SettingsRow::HintKey(code, mods) => Some((*code, *mods)),
3646                _ => None,
3647            })
3648            .collect();
3649        // Toggle (space), save (^S) and close (esc) are links, recorded in
3650        // segment order on a Vendor row.
3651        assert_eq!(
3652            hint_keys,
3653            vec![
3654                (KeyCode::Char(' '), KeyModifiers::NONE),
3655                (KeyCode::Char('s'), KeyModifiers::CONTROL),
3656                (KeyCode::Esc, KeyModifiers::NONE),
3657            ]
3658        );
3659        // Every link lives on the single hint row, below the body.
3660        let ys: Vec<u16> = hits
3661            .iter()
3662            .filter(|(row, _)| matches!(row, SettingsRow::HintKey(..)))
3663            .map(|(_, rect)| rect.y)
3664            .collect();
3665        assert!(ys.windows(2).all(|pair| pair[0] == pair[1]));
3666
3667        // On the Primary row the change-vendor link is a Right key instead of
3668        // the toggle, and pure hints never become clickable anywhere.
3669        let mut state = blank_state(VendorId::Anthropic);
3670        state.focus = Focus::Primary;
3671        let mut hits = Vec::new();
3672        terminal
3673            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3674            .unwrap();
3675        let hint_keys: Vec<(KeyCode, KeyModifiers)> = hits
3676            .iter()
3677            .filter_map(|(row, _)| match row {
3678                SettingsRow::HintKey(code, mods) => Some((*code, *mods)),
3679                _ => None,
3680            })
3681            .collect();
3682        assert!(hint_keys.contains(&(KeyCode::Right, KeyModifiers::NONE)));
3683        assert!(!hint_keys.contains(&(KeyCode::Char(' '), KeyModifiers::NONE)));
3684    }
3685
3686    /// Every provider row records a switch-cell click target right of the
3687    /// label columns, on the same row as its focus target; the quota-alerts
3688    /// row records one too.
3689    #[test]
3690    fn render_records_switch_cells_right_of_the_labels() {
3691        use ratatui::Terminal;
3692        use ratatui::backend::TestBackend;
3693
3694        let theme = crate::theme::Theme::default();
3695        let mut state = blank_state(VendorId::Anthropic);
3696        state.focus = Focus::Vendor(0);
3697        let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3698        let mut hits = Vec::new();
3699        terminal
3700            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3701            .unwrap();
3702
3703        let focus_of = |i: usize| {
3704            hits.iter().find_map(|(row, rect)| match row {
3705                SettingsRow::Focus(Focus::Vendor(v)) if *v == i => Some(*rect),
3706                _ => None,
3707            })
3708        };
3709        for (i, id) in VendorId::all().iter().enumerate() {
3710            let focus_rect = focus_of(i).unwrap_or_else(|| panic!("vendor {i} has no focus row"));
3711            let switch = hits
3712                .iter()
3713                .find_map(|(row, rect)| match row {
3714                    SettingsRow::Switch(Focus::Vendor(v), KeyCode::Char(' '), _) if *v == i => {
3715                        Some(*rect)
3716                    }
3717                    _ => None,
3718                })
3719                .unwrap_or_else(|| panic!("vendor {i} has no switch cell"));
3720            // The value cell starts after the 5-cell prefix + the padded
3721            // label (names past 11 columns push it right) and covers only
3722            // the rendered value — never the name tail or the trailing
3723            // empty row space.
3724            let expected_x =
3725                5 + crate::display::text_width(&format!("{:<11}", id.display_name())) as u16;
3726            let value_len = if state.vendors[i].enabled { 2 } else { 3 }; // "on" / "off"
3727            let focused = state.focus == Focus::Vendor(i);
3728            assert_eq!(switch.y, focus_rect.y);
3729            assert_eq!(switch.x, focus_rect.x + expected_x);
3730            assert_eq!(
3731                switch.width,
3732                (if focused { 6 } else { 2 }) + value_len,
3733                "rendered value segment"
3734            );
3735            assert!(switch.x + switch.width <= focus_rect.x + focus_rect.width);
3736        }
3737        // The quota-alerts row has a switch cell as well.
3738        assert!(hits.iter().any(|(row, _)| matches!(
3739            row,
3740            SettingsRow::Switch(Focus::NotifyEnabled, KeyCode::Char(' '), _)
3741        )));
3742    }
3743
3744    /// The focused primary radio renders ◀ name ▶: those arrow cells become
3745    /// click targets that step the vendor radio both ways.
3746    #[test]
3747    fn render_records_primary_arrow_cells_when_focused() {
3748        use ratatui::Terminal;
3749        use ratatui::backend::TestBackend;
3750
3751        let theme = crate::theme::Theme::default();
3752        let mut state = blank_state(VendorId::Anthropic);
3753        state.focus = Focus::Primary;
3754        let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3755        let mut hits = Vec::new();
3756        terminal
3757            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3758            .unwrap();
3759
3760        let focus_rect = hits
3761            .iter()
3762            .find_map(|(row, rect)| match row {
3763                SettingsRow::Focus(Focus::Primary) => Some(*rect),
3764                _ => None,
3765            })
3766            .expect("primary row has a focus target");
3767        let prev = hits.iter().find_map(|(row, rect)| match row {
3768            SettingsRow::Switch(Focus::Primary, KeyCode::Left, _) => Some(*rect),
3769            _ => None,
3770        });
3771        let next = hits.iter().find_map(|(row, rect)| match row {
3772            SettingsRow::Switch(Focus::Primary, KeyCode::Right, _) => Some(*rect),
3773            _ => None,
3774        });
3775        let prev = prev.expect("focused primary has a ◀ cell");
3776        let next = next.expect("focused primary has a ▶ cell");
3777        assert_eq!(prev.x, focus_rect.x + 5);
3778        assert_eq!(prev.width, 2);
3779        assert!(next.x > prev.x + 2, "▶ sits right of the vendor name");
3780        assert_eq!(next.width, 2);
3781        assert_eq!(prev.y, next.y);
3782
3783        // Unfocused (arrows only render on the focused radio), the row shows
3784        // no switch cells: only the focus target exists.
3785        let mut state = blank_state(VendorId::Anthropic);
3786        state.focus = Focus::Vendor(0);
3787        let mut unfocused_hits = Vec::new();
3788        terminal
3789            .draw(|f| render(f, f.area(), &mut state, &theme, &mut unfocused_hits))
3790            .unwrap();
3791        assert!(
3792            !unfocused_hits
3793                .iter()
3794                .any(|(row, _)| matches!(row, SettingsRow::Switch(Focus::Primary, _, _)))
3795        );
3796    }
3797
3798    /// Regression: ▶ used to sit at 9 + the current name's width, so it
3799    /// moved on every cycle. The name pads to the widest choice now — the
3800    /// arrow columns are identical whichever vendor is picked.
3801    #[test]
3802    fn primary_arrow_cells_sit_at_fixed_columns() {
3803        use ratatui::Terminal;
3804        use ratatui::backend::TestBackend;
3805
3806        let theme = crate::theme::Theme::default();
3807        let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3808        let mut arrow_x = |primary: VendorId| {
3809            let mut state = blank_state(primary);
3810            state.focus = Focus::Primary;
3811            let mut hits = Vec::new();
3812            terminal
3813                .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3814                .unwrap();
3815            hits.iter()
3816                .find_map(|(row, rect)| match row {
3817                    SettingsRow::Switch(Focus::Primary, KeyCode::Right, _) => Some(rect.x),
3818                    _ => None,
3819                })
3820                .expect("focused primary has a ▶ cell")
3821        };
3822        assert_eq!(arrow_x(VendorId::Anthropic), arrow_x(VendorId::Copilot));
3823    }
3824
3825    /// The picker consumes keys while open: ↑/↓ move the cursor (wrapping),
3826    /// Enter/space select and close, Esc closes without selecting — and with
3827    /// the picker closed, Esc closes the modal again.
3828    #[test]
3829    fn picker_key_handling_selects_and_closes() {
3830        let mut s = blank_state(VendorId::Anthropic);
3831        s.focus = Focus::Primary;
3832        s.picker = Some(PrimaryPicker {
3833            cursor: 0,
3834            scroll: 0,
3835        });
3836        let count = s.primary_choices.len();
3837        assert!(count > 1);
3838
3839        assert_eq!(
3840            handle_key(&mut s, KeyCode::Down, KeyModifiers::NONE),
3841            Action::Continue
3842        );
3843        assert_eq!(s.picker.as_ref().unwrap().cursor, 1);
3844        // Wraps at the top.
3845        handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
3846        handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
3847        assert_eq!(s.picker.as_ref().unwrap().cursor, count - 1);
3848
3849        // Enter selects the cursor's choice and closes the popup.
3850        let expected = s.primary_choices[s.picker.as_ref().unwrap().cursor];
3851        assert_eq!(
3852            handle_key(&mut s, KeyCode::Enter, KeyModifiers::NONE),
3853            Action::Continue
3854        );
3855        assert_eq!(s.primary, expected);
3856        assert!(s.picker.is_none());
3857
3858        // Esc closes the picker without selecting; the next Esc closes the
3859        // modal.
3860        s.picker = Some(PrimaryPicker {
3861            cursor: 2,
3862            scroll: 0,
3863        });
3864        assert_eq!(
3865            handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
3866            Action::Continue
3867        );
3868        assert!(s.picker.is_none());
3869        assert_eq!(
3870            handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
3871            Action::Close
3872        );
3873    }
3874
3875    /// The open picker records one click row per choice.
3876    #[test]
3877    fn open_picker_records_one_click_row_per_choice() {
3878        use ratatui::Terminal;
3879        use ratatui::backend::TestBackend;
3880
3881        let theme = crate::theme::Theme::default();
3882        let mut state = blank_state(VendorId::Anthropic);
3883        state.focus = Focus::Primary;
3884        state.picker = Some(PrimaryPicker {
3885            cursor: 0,
3886            scroll: 0,
3887        });
3888        let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3889        let mut hits = Vec::new();
3890        terminal
3891            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3892            .unwrap();
3893
3894        let picks: Vec<usize> = hits
3895            .iter()
3896            .filter_map(|(row, _)| match row {
3897                SettingsRow::Pick(i) => Some(*i),
3898                _ => None,
3899            })
3900            .collect();
3901        assert_eq!(picks.len(), state.primary_choices.len());
3902        assert_eq!(picks.first().copied(), Some(0));
3903        assert_eq!(picks.last().copied(), Some(state.primary_choices.len() - 1));
3904    }
3905
3906    /// End-to-end through the real renderer: on a terminal shorter than the
3907    /// overlay body, focusing a late provider switch keeps it and the Save
3908    /// button on screen — the reason the scroll exists.
3909    #[test]
3910    fn render_scrolls_the_body_so_a_late_row_and_save_stay_visible() {
3911        use ratatui::Terminal;
3912        use ratatui::backend::TestBackend;
3913
3914        let backend = TestBackend::new(100, 24);
3915        let mut terminal = Terminal::new(backend).unwrap();
3916        let theme = crate::theme::Theme::default();
3917        let mut state = blank_state(VendorId::Anthropic);
3918        state.focus = Focus::Vendor(vendor_index(VendorId::Grok));
3919        let mut hits = Vec::new();
3920
3921        terminal
3922            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3923            .unwrap();
3924
3925        let rendered = buffer_text(terminal.backend().buffer());
3926        assert!(
3927            rendered.iter().any(|line| line.contains("Grok")),
3928            "focused provider row must be visible: {rendered:?}"
3929        );
3930        // The Save control is the last thing focus moves to; keep it on
3931        // screen too (it renders after the provider rows).
3932        let mut state = blank_state(VendorId::Anthropic);
3933        state.focus = Focus::Save;
3934        let mut hits = Vec::new();
3935        terminal
3936            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3937            .unwrap();
3938        let rendered = buffer_text(terminal.backend().buffer());
3939        assert!(
3940            rendered.iter().any(|line| line.contains("Save")),
3941            "Save must stay visible: {rendered:?}"
3942        );
3943    }
3944
3945    /// Every line of a TestBackend buffer as a String, for substring checks.
3946    fn buffer_text(buffer: &ratatui::buffer::Buffer) -> Vec<String> {
3947        let area = buffer.area;
3948        let mut lines = Vec::new();
3949        for y in area.top()..area.bottom() {
3950            let mut line = String::new();
3951            for x in area.left()..area.right() {
3952                line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
3953            }
3954            lines.push(line.trim_end().to_string());
3955        }
3956        lines
3957    }
3958}