Skip to main content

ai_usagebar/tui/
settings.rs

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