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