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