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