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        // Copilot and the key vendors above are offered while disabled, but
425        // only as an explicit pick: shown as the current primary, an untouched
426        // save would write their `enabled = true` back.
427        let primary = cfg
428            .ui
429            .primary
430            .filter(|vendor| primary_choices.contains(vendor) && cfg.is_enabled(*vendor))
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    /// Switching off the vendor that is still the primary is a state a save
2199    /// can produce. A key vendor that keeps its key stays on offer, so the
2200    /// overlay must not reopen with it selected: the next save, whatever it
2201    /// changed, would take that as the user's pick and switch it back on.
2202    #[test]
2203    fn an_unrelated_save_leaves_a_disabled_primary_switched_off() {
2204        let (_dir, path) = temp_config(Some(
2205            "[ui]\nprimary = \"openrouter\"\n[openrouter]\nenabled = false\napi_key = \"test\"\n",
2206        ));
2207        let cfg = Config::load_from(&path).unwrap();
2208        let mut s = SettingsState::from_config_with(&cfg, |_| false);
2209        assert!(s.primary_choices.contains(&VendorId::Openrouter));
2210        assert_ne!(s.primary, VendorId::Openrouter);
2211
2212        s.notify_enabled = !s.notify_enabled;
2213        s.notify_enabled_dirty = true;
2214        save_to_path(&s, &path).unwrap();
2215
2216        let saved = Config::load_from(&path).unwrap();
2217        assert!(!saved.is_enabled(VendorId::Openrouter));
2218    }
2219
2220    #[test]
2221    fn key_input_insert_backspace_arrow() {
2222        let mut k = KeyInput::default();
2223        k.insert_char('a');
2224        k.insert_char('b');
2225        k.insert_char('c');
2226        assert_eq!(k.buf, "abc");
2227        assert_eq!(k.cursor, 3);
2228        assert!(k.dirty);
2229        k.move_left();
2230        k.move_left();
2231        assert_eq!(k.cursor, 1);
2232        k.insert_char('x');
2233        assert_eq!(k.buf, "axbc");
2234        assert_eq!(k.cursor, 2);
2235        k.backspace();
2236        assert_eq!(k.buf, "abc");
2237        assert_eq!(k.cursor, 1);
2238    }
2239
2240    #[test]
2241    fn key_input_masks_by_default_reveals_on_toggle() {
2242        let mut k = KeyInput::default();
2243        for c in "secret-key".chars() {
2244            k.insert_char(c);
2245        }
2246        assert_eq!(k.display(), "•".repeat(10));
2247        k.toggle_reveal();
2248        assert_eq!(k.display(), "secret-key");
2249    }
2250
2251    #[test]
2252    fn key_input_handles_unicode() {
2253        let mut k = KeyInput::default();
2254        k.insert_char('a');
2255        k.insert_char('→');
2256        k.insert_char('b');
2257        assert_eq!(k.buf, "a→b");
2258        assert_eq!(k.cursor, 3);
2259        k.move_left();
2260        k.backspace();
2261        assert_eq!(k.buf, "ab");
2262    }
2263
2264    #[test]
2265    fn value_text_shows_cursor_and_empty_states() {
2266        let mut k = KeyInput::default();
2267        assert_eq!(value_text(&k, false), "(empty)");
2268        assert_eq!(value_text(&k, true), "‸");
2269        k.insert_char('a');
2270        k.insert_char('b');
2271        // masked + cursor at end
2272        assert_eq!(value_text(&k, true), "••‸");
2273        assert_eq!(value_text(&k, false), "••");
2274    }
2275
2276    #[test]
2277    fn save_writes_key_and_enables_vendor() {
2278        let (_dir, path) = temp_config(None);
2279        let mut s = blank_state(VendorId::Kilo);
2280        s.keys[key_index(VendorId::Kilo)] = KeyInput::from_config(Some("sk-kilo"));
2281        s.keys[key_index(VendorId::Kilo)].dirty = true;
2282        save_to_path(&s, &path).unwrap();
2283        let raw = std::fs::read_to_string(&path).unwrap();
2284        assert!(raw.contains("primary = \"kilo\""));
2285        assert!(raw.contains("[kilo]"));
2286        assert!(raw.contains("api_key = \"sk-kilo\""));
2287        assert!(raw.contains("enabled = true"));
2288    }
2289
2290    #[test]
2291    fn save_writes_minimal_toml_when_starting_empty() {
2292        let (_dir, path) = temp_config(None);
2293        let s = state_with("zk", "ok", VendorId::Zai);
2294        save_to_path(&s, &path).unwrap();
2295        let raw = std::fs::read_to_string(&path).unwrap();
2296        assert!(raw.contains("primary = \"zai\""));
2297        assert!(raw.contains("[zai]"));
2298        assert!(raw.contains("api_key = \"zk\""));
2299        assert!(raw.contains("[openrouter]"));
2300        assert!(raw.contains("api_key = \"ok\""));
2301    }
2302
2303    #[test]
2304    fn save_preserves_existing_comments_and_unrelated_fields() {
2305        let (_dir, path) = temp_config(Some(
2306            r##"# my comment
2307[ui]
2308# pre-existing comment
2309primary = "anthropic"
2310
2311[zai]
2312enabled = true
2313api_key_env = "ZAI_API_KEY"
2314# tier comment
2315plan_tier = "pro"
2316
2317[openrouter]
2318enabled = true
2319api_key_env = "OPENROUTER_API_KEY"
2320
2321[[openrouter.accounts]]
2322label = "work"
2323api_key_env = "OPENROUTER_WORK_API_KEY"
2324"##,
2325        ));
2326
2327        let s = state_with("zk2", "ok2", VendorId::Openrouter);
2328        save_to_path(&s, &path).unwrap();
2329
2330        let raw = std::fs::read_to_string(&path).unwrap();
2331        assert!(raw.contains("# my comment"));
2332        assert!(raw.contains("# pre-existing comment"));
2333        assert!(raw.contains("# tier comment"));
2334        assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
2335        assert!(raw.contains("[[openrouter.accounts]]"));
2336        assert!(raw.contains("api_key_env = \"OPENROUTER_WORK_API_KEY\""));
2337        assert!(raw.contains("plan_tier = \"pro\""));
2338        assert!(raw.contains("primary = \"openrouter\""));
2339        assert!(raw.contains("api_key = \"zk2\""));
2340        assert!(raw.contains("api_key = \"ok2\""));
2341    }
2342
2343    #[test]
2344    fn save_refuses_to_replace_an_unreadable_existing_config() {
2345        let (_dir, path) = temp_config(None);
2346        let original = [0xff, 0xfe, 0xfd];
2347        std::fs::write(&path, original).unwrap();
2348        let state = state_with("new-secret", "", VendorId::Zai);
2349
2350        assert!(save_to_path(&state, &path).is_err());
2351        assert_eq!(std::fs::read(&path).unwrap(), original);
2352    }
2353
2354    #[test]
2355    fn save_does_not_write_empty_key_when_dirty_but_blank() {
2356        let (_dir, path) = temp_config(None);
2357        let mut s = blank_state(VendorId::Anthropic);
2358        // Focus each key, do nothing but mark dirty (blank).
2359        for k in &mut s.keys {
2360            k.dirty = true;
2361        }
2362        save_to_path(&s, &path).unwrap();
2363        let raw = std::fs::read_to_string(&path).unwrap();
2364        assert!(!raw.contains("api_key ="));
2365    }
2366
2367    #[test]
2368    #[cfg(unix)]
2369    fn save_chmods_to_600() {
2370        use std::os::unix::fs::PermissionsExt;
2371        let (_dir, path) = temp_config(None);
2372        let s = state_with("zk", "ok", VendorId::Zai);
2373        save_to_path(&s, &path).unwrap();
2374        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2375        assert_eq!(mode & 0o777, 0o600);
2376    }
2377
2378    #[test]
2379    fn tab_cycles_focus_from_primary_to_first_key() {
2380        let mut s = blank_state(VendorId::Anthropic);
2381        assert_eq!(
2382            handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
2383            Action::Continue
2384        );
2385        assert_eq!(s.focus, Focus::Key(0));
2386        assert_eq!(
2387            handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
2388            Action::Continue
2389        );
2390        assert_eq!(s.focus, Focus::Primary);
2391    }
2392
2393    #[test]
2394    fn esc_closes_without_saving() {
2395        let mut s = blank_state(VendorId::Anthropic);
2396        assert_eq!(
2397            handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
2398            Action::Close
2399        );
2400    }
2401
2402    #[test]
2403    fn left_right_cycles_primary_vendor() {
2404        // Canonical order (VendorId::all): Anthropic, AnthropicApi, Openai, …
2405        let mut s = blank_state(VendorId::Anthropic);
2406        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2407        assert_eq!(s.primary, VendorId::AnthropicApi);
2408        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2409        assert_eq!(s.primary, VendorId::Openai);
2410        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
2411        assert_eq!(s.primary, VendorId::AnthropicApi);
2412    }
2413
2414    #[test]
2415    fn left_right_offers_enabled_vendors_only() {
2416        // The selector must never land on a vendor the widget cannot use.
2417        let mut s = blank_state(VendorId::Anthropic);
2418        s.primary_choices = vec![VendorId::Anthropic, VendorId::Grok];
2419        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2420        assert_eq!(s.primary, VendorId::Grok);
2421        // Wraps within the enabled set rather than walking into disabled ones.
2422        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2423        assert_eq!(s.primary, VendorId::Anthropic);
2424        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
2425        assert_eq!(s.primary, VendorId::Grok);
2426    }
2427
2428    #[test]
2429    fn no_enabled_vendors_leaves_primary_selector_inert() {
2430        let mut s = blank_state(VendorId::Anthropic);
2431        s.primary_choices = vec![];
2432        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2433        assert_eq!(s.primary, VendorId::Anthropic);
2434    }
2435
2436    #[test]
2437    fn disabled_copilot_is_offered_but_not_shown_as_the_current_primary() {
2438        let mut cfg = Config::default();
2439        cfg.ui.primary = Some(VendorId::Copilot);
2440        let state = SettingsState::from_config(&cfg);
2441
2442        assert!(state.primary_choices.contains(&VendorId::Copilot));
2443        assert_eq!(state.primary, VendorId::Anthropic);
2444    }
2445
2446    #[test]
2447    fn save_does_not_write_a_disabled_primary() {
2448        // Saving an API key must not persist a primary the resolver would
2449        // ignore; an existing value in the file stays untouched.
2450        let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
2451        let mut s = state_with("zk", "ok", VendorId::Grok);
2452        s.primary_choices = vec![VendorId::Anthropic];
2453        save_to_path(&s, &path).unwrap();
2454        let raw = std::fs::read_to_string(&path).unwrap();
2455        assert!(raw.contains("primary = \"anthropic\""));
2456        assert!(!raw.contains("primary = \"grok\""));
2457        // The keys still saved.
2458        assert!(raw.contains("zk"));
2459    }
2460
2461    #[test]
2462    fn save_removes_an_inline_key_the_user_cleared() {
2463        // Clearing the field in the overlay must delete the secret from the
2464        // file — otherwise there is no way to remove it short of hand-editing.
2465        let (_dir, path) = temp_config(Some(
2466            "[zai]\nenabled = true\napi_key = \"old-secret\"\nplan_tier = \"pro\"\n",
2467        ));
2468        let mut s = blank_state(VendorId::Zai);
2469        s.primary_choices = vec![VendorId::Zai];
2470        s.keys[key_index(VendorId::Zai)] = KeyInput::default();
2471        s.keys[key_index(VendorId::Zai)].dirty = true;
2472        save_to_path(&s, &path).unwrap();
2473        let raw = std::fs::read_to_string(&path).unwrap();
2474        assert!(!raw.contains("old-secret"));
2475        assert!(!raw.contains("api_key"));
2476        // Unrelated fields in the same section survive.
2477        assert!(raw.contains("plan_tier = \"pro\""));
2478    }
2479
2480    #[test]
2481    fn untouched_key_field_is_left_alone() {
2482        // Not dirty => the file's existing secret must survive a save.
2483        let (_dir, path) = temp_config(Some("[zai]\napi_key = \"keep-me\"\n"));
2484        let mut s = blank_state(VendorId::Zai);
2485        s.primary_choices = vec![VendorId::Zai];
2486        save_to_path(&s, &path).unwrap();
2487        let raw = std::fs::read_to_string(&path).unwrap();
2488        assert!(raw.contains("keep-me"));
2489    }
2490
2491    #[test]
2492    fn typing_edits_the_focused_key_only() {
2493        let mut s = blank_state(VendorId::Anthropic);
2494        s.focus = Focus::Key(key_index(VendorId::Grok));
2495        for c in "xai-abc".chars() {
2496            handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
2497        }
2498        assert_eq!(s.keys[key_index(VendorId::Grok)].buf, "xai-abc");
2499        assert!(s.keys[key_index(VendorId::Grok)].dirty);
2500        // No other field was touched.
2501        assert!(s.keys[key_index(VendorId::Zai)].buf.is_empty());
2502    }
2503
2504    #[test]
2505    fn ctrl_v_toggles_reveal_on_focused_key_field() {
2506        let mut s = blank_state(VendorId::Anthropic);
2507        let zi = key_index(VendorId::Zai);
2508        s.focus = Focus::Key(zi);
2509        s.keys[zi] = KeyInput::from_config(Some("secret"));
2510        assert!(!s.keys[zi].revealed);
2511        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
2512        assert!(s.keys[zi].revealed);
2513        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
2514        assert!(!s.keys[zi].revealed);
2515    }
2516
2517    #[test]
2518    fn control_chorded_chars_do_not_type_into_fields() {
2519        let mut s = blank_state(VendorId::Anthropic);
2520        s.focus = Focus::Key(0);
2521        // Ctrl-A must NOT insert a literal 'a' or mark the field dirty.
2522        handle_key(&mut s, KeyCode::Char('a'), KeyModifiers::CONTROL);
2523        assert!(s.keys[0].buf.is_empty());
2524        assert!(!s.keys[0].dirty);
2525        // Ctrl-C quits the host TUI even while the overlay owns focus.
2526        assert_eq!(
2527            handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
2528            Action::Quit
2529        );
2530        // A plain char still types normally.
2531        handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
2532        assert_eq!(s.keys[0].buf, "x");
2533    }
2534
2535    #[test]
2536    fn ctrl_v_on_non_key_focus_is_noop() {
2537        let mut s = blank_state(VendorId::Anthropic);
2538        s.focus = Focus::Primary;
2539        // Must not panic when no key field is focused.
2540        assert_eq!(
2541            handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL),
2542            Action::Continue
2543        );
2544    }
2545
2546    fn state_focused_on_zai() -> SettingsState {
2547        let mut state = blank_state(VendorId::Anthropic);
2548        state.focus = Focus::Key(key_index(VendorId::Zai));
2549        state
2550    }
2551
2552    #[test]
2553    fn handle_key_ctrl_c_quits_without_typing_into_key_field() {
2554        let mut s = state_focused_on_zai();
2555        let zi = key_index(VendorId::Zai);
2556        assert_eq!(
2557            handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
2558            Action::Quit
2559        );
2560        assert!(s.keys[zi].buf.is_empty());
2561        // Untouched means save still leaves an existing key on disk alone.
2562        assert!(!s.keys[zi].dirty);
2563    }
2564
2565    #[test]
2566    fn handle_key_alt_chord_does_not_type_into_key_field() {
2567        let mut s = state_focused_on_zai();
2568        let zi = key_index(VendorId::Zai);
2569        handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::ALT);
2570        assert!(s.keys[zi].buf.is_empty());
2571        assert!(!s.keys[zi].dirty);
2572    }
2573
2574    #[test]
2575    fn handle_key_platform_modifier_chords_do_not_type_into_key_field() {
2576        for modifier in [KeyModifiers::SUPER, KeyModifiers::HYPER, KeyModifiers::META] {
2577            let mut s = state_focused_on_zai();
2578            let zi = key_index(VendorId::Zai);
2579            handle_key(&mut s, KeyCode::Char('x'), modifier);
2580            assert!(s.keys[zi].buf.is_empty(), "modifier {modifier:?}");
2581            assert!(!s.keys[zi].dirty, "modifier {modifier:?}");
2582        }
2583    }
2584
2585    #[test]
2586    fn handle_key_shift_still_types_uppercase() {
2587        let mut s = state_focused_on_zai();
2588        let zi = key_index(VendorId::Zai);
2589        handle_key(&mut s, KeyCode::Char('A'), KeyModifiers::SHIFT);
2590        assert_eq!(s.keys[zi].buf, "A");
2591        assert!(s.keys[zi].dirty);
2592    }
2593
2594    #[test]
2595    fn handle_key_plain_space_still_cycles_primary_vendor() {
2596        let mut s = blank_state(VendorId::Anthropic);
2597        handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
2598        assert_eq!(s.primary, VendorId::AnthropicApi);
2599    }
2600
2601    #[test]
2602    fn handle_key_ctrl_s_attempts_save_from_any_field() {
2603        let (_dir, path) = temp_config(None);
2604        let s = state_with("zk", "ok", VendorId::Zai);
2605        save_to_path(&s, &path).unwrap();
2606        let raw = std::fs::read_to_string(&path).unwrap();
2607        assert!(raw.contains("api_key = \"zk\""));
2608    }
2609    #[test]
2610    fn save_to_path_writes_kimi_key_when_dirty() {
2611        let (_dir, path) = temp_config(None);
2612        let mut s = blank_state(VendorId::Anthropic);
2613        let kimi = key_index(VendorId::Kimi);
2614        s.keys[kimi] = KeyInput::from_config(Some("kk"));
2615        s.keys[kimi].dirty = true;
2616        save_to_path(&s, &path).unwrap();
2617        let raw = std::fs::read_to_string(&path).unwrap();
2618        assert!(raw.contains("[kimi]"));
2619        assert!(raw.contains("api_key = \"kk\""));
2620    }
2621
2622    #[test]
2623    fn settings_save_uses_the_same_config_path_as_load() {
2624        assert_eq!(
2625            default_config_path().unwrap(),
2626            crate::config::resolved_path().unwrap()
2627        );
2628    }
2629
2630    #[test]
2631    fn native_snapshot_reports_key_state_without_serializing_secrets() {
2632        let mut cfg = Config::default();
2633        cfg.zai.api_key = Some("never-leak-this-key".into());
2634        cfg.zai.api_key_env = "CUSTOM_ZAI_KEY".into();
2635        let raw = settings_snapshot_json_with(&cfg, |name| name == "CUSTOM_ZAI_KEY").unwrap();
2636        let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
2637
2638        assert_eq!(parsed["schema_version"], 1);
2639        assert_eq!(parsed["primary"], "anthropic");
2640        let zai = parsed["keys"]
2641            .as_array()
2642            .unwrap()
2643            .iter()
2644            .find(|row| row["id"] == "zai")
2645            .unwrap();
2646        assert_eq!(zai["configured"], true);
2647        assert_eq!(zai["inline_configured"], true);
2648        assert_eq!(zai["environment_configured"], true);
2649        assert_eq!(zai["environment"], "CUSTOM_ZAI_KEY");
2650        assert!(!raw.contains("never-leak-this-key"));
2651        assert!(parsed.get("api_key").is_none());
2652    }
2653
2654    #[test]
2655    fn native_snapshot_offers_copilot_primary_without_a_token_field() {
2656        let cfg = Config::default();
2657        let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
2658        let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
2659        assert!(
2660            parsed["primary_choices"]
2661                .as_array()
2662                .unwrap()
2663                .iter()
2664                .any(|row| row["id"] == "copilot")
2665        );
2666        assert!(
2667            !parsed["keys"]
2668                .as_array()
2669                .unwrap()
2670                .iter()
2671                .any(|row| row["id"] == "copilot")
2672        );
2673    }
2674
2675    #[test]
2676    fn native_key_only_patch_does_not_require_or_replace_primary() {
2677        let cfg = Config::default();
2678        let original_primary = SettingsState::from_config(&cfg).primary;
2679        let request = serde_json::json!({
2680            "schema_version": 1,
2681            "keys": {"kimi": {"action": "set", "value": "new-kimi-key"}}
2682        });
2683
2684        let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
2685        assert_eq!(state.primary, original_primary);
2686        let kimi_index = KEY_VENDORS
2687            .iter()
2688            .position(|vendor| vendor.id == VendorId::Kimi)
2689            .unwrap();
2690        assert!(state.keys[kimi_index].dirty);
2691        assert_eq!(state.keys[kimi_index].buf, "new-kimi-key");
2692    }
2693
2694    #[test]
2695    fn native_patch_reuses_tui_persistence_and_preserves_existing_config() {
2696        let (_dir, path) = temp_config(Some(
2697            r#"# keep this comment
2698[ui]
2699primary = "anthropic"
2700
2701[zai]
2702enabled = true
2703api_key_env = "ZAI_API_KEY"
2704plan_tier = "pro"
2705
2706[openrouter]
2707enabled = true
2708"#,
2709        ));
2710        let cfg = Config::load_from(&path).unwrap();
2711        let request = serde_json::json!({
2712            "schema_version": 1,
2713            "primary": "openrouter",
2714            "keys": {
2715                "zai": {"action": "set", "value": "new-zai-key"}
2716            }
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("# keep this comment"));
2722        assert!(raw.contains("plan_tier = \"pro\""));
2723        assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
2724        assert!(raw.contains("primary = \"openrouter\""));
2725        assert!(raw.contains("api_key = \"new-zai-key\""));
2726    }
2727
2728    #[test]
2729    fn native_patch_distinguishes_clear_from_unchanged() {
2730        let (_dir, path) = temp_config(Some(
2731            "[zai]\nenabled = true\napi_key = \"remove-me\"\n\
2732             [openrouter]\nenabled = true\napi_key = \"keep-me\"\n",
2733        ));
2734        let cfg = Config::load_from(&path).unwrap();
2735        let request = serde_json::json!({
2736            "schema_version": 1,
2737            "primary": "zai",
2738            "keys": {"zai": {"action": "clear"}}
2739        });
2740
2741        apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
2742        let raw = std::fs::read_to_string(&path).unwrap();
2743        assert!(!raw.contains("remove-me"));
2744        assert!(raw.contains("keep-me"));
2745    }
2746
2747    #[test]
2748    fn native_primary_selection_enables_copilot_without_writing_a_token() {
2749        let (_dir, path) = temp_config(Some(
2750            "[copilot]\nenabled = false\ntoken = \"legacy-value\"\ntoken_env = \"OLD_TOKEN\"\n",
2751        ));
2752        let cfg = Config::load_from(&path).unwrap();
2753        let select = serde_json::json!({
2754            "schema_version": 1,
2755            "primary": "copilot"
2756        });
2757        apply_settings_json_to_path(&cfg, &select.to_string(), &path).unwrap();
2758        let raw = std::fs::read_to_string(&path).unwrap();
2759        assert!(raw.contains("enabled = true"));
2760        assert!(raw.contains("primary = \"copilot\""));
2761        assert!(!raw.contains("token ="));
2762        assert!(!raw.contains("token_env ="));
2763    }
2764
2765    #[test]
2766    fn native_patch_errors_never_echo_key_values() {
2767        let raw = serde_json::json!({
2768            "schema_version": 1,
2769            "primary": "anthropic",
2770            "keys": {
2771                "zai": {"action": "set", "value": "secret\nwith-control"}
2772            }
2773        })
2774        .to_string();
2775        let error = state_from_apply_request(&Config::default(), &raw)
2776            .unwrap_err()
2777            .to_string();
2778        assert!(!error.contains("secret"));
2779        assert!(error.contains("control characters"));
2780    }
2781
2782    #[test]
2783    fn native_patch_input_is_bounded_before_json_parsing() {
2784        let oversized = vec![b'x'; MAX_SETTINGS_REQUEST_BYTES as usize + 1];
2785        let error = read_settings_request(std::io::Cursor::new(oversized))
2786            .unwrap_err()
2787            .to_string();
2788        assert!(error.contains("exceeds"));
2789    }
2790
2791    // ─── Provider on/off switches (#244) ─────────────────────────────────────
2792
2793    #[test]
2794    fn native_snapshot_lists_every_provider_with_its_enabled_state() {
2795        let mut cfg = Config::default();
2796        cfg.grok.enabled = true;
2797        let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
2798        let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
2799
2800        let vendors = parsed["vendors"].as_array().unwrap();
2801        assert_eq!(vendors.len(), VendorId::all().len());
2802        let by_id = |slug: &str| {
2803            vendors
2804                .iter()
2805                .find(|row| row["id"] == slug)
2806                .unwrap_or_else(|| panic!("no {slug} row: {vendors:?}"))
2807                .clone()
2808        };
2809        assert_eq!(by_id("anthropic")["enabled"], true);
2810        assert_eq!(by_id("grok")["enabled"], true);
2811        assert_eq!(by_id("kimi")["enabled"], false);
2812        // Labels come from the shared display-name source, not a second table.
2813        assert_eq!(by_id("anthropic")["label"], "Claude");
2814        assert_eq!(by_id("opencode-go")["label"], "OpenCode Go");
2815    }
2816
2817    #[test]
2818    fn native_patch_toggles_providers_and_preserves_the_rest_of_the_file() {
2819        let (_dir, path) = temp_config(Some("# keep\n[zai]\nenabled = true\napi_key = \"k\"\n"));
2820        let cfg = Config::load_from(&path).unwrap();
2821        let request = serde_json::json!({
2822            "schema_version": 1,
2823            "vendors": {"grok": true, "zai": false}
2824        });
2825
2826        apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
2827
2828        let raw = std::fs::read_to_string(&path).unwrap();
2829        assert!(raw.starts_with("# keep\n"), "{raw}");
2830        assert!(raw.contains("api_key = \"k\""), "{raw}");
2831        let reloaded = Config::load_from(&path).unwrap();
2832        assert!(reloaded.is_enabled(VendorId::Grok));
2833        assert!(!reloaded.is_enabled(VendorId::Zai));
2834    }
2835
2836    #[test]
2837    fn native_patch_refuses_an_unknown_provider_slug_without_writing() {
2838        let (_dir, path) = temp_config(Some("[zai]\nenabled = true\n"));
2839        let cfg = Config::load_from(&path).unwrap();
2840        for bad in ["", "mytool", "not-a-vendor"] {
2841            let request = serde_json::json!({
2842                "schema_version": 1,
2843                "vendors": {bad: true}
2844            });
2845            let error = state_from_apply_request(&cfg, &request.to_string())
2846                .unwrap_err()
2847                .to_string();
2848            assert!(error.contains("unknown provider"), "{bad}: {error}");
2849        }
2850        // A rejected patch leaves the file untouched.
2851        assert_eq!(
2852            std::fs::read_to_string(&path).unwrap(),
2853            "[zai]\nenabled = true\n"
2854        );
2855    }
2856
2857    /// A vendor-toggle-only patch needs neither a primary nor a key change.
2858    #[test]
2859    fn native_vendor_toggle_alone_is_a_valid_patch() {
2860        let cfg = Config::default();
2861        let request = serde_json::json!({
2862            "schema_version": 1,
2863            "vendors": {"kimi": true}
2864        });
2865        let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
2866        let kimi = vendor_index(VendorId::Kimi);
2867        assert_eq!(
2868            state.vendors[kimi],
2869            ProviderSwitch {
2870                enabled: true,
2871                dirty: true
2872            }
2873        );
2874    }
2875
2876    // ─── Overlay scroll (#244: the body outgrew small terminals) ────────────
2877
2878    #[test]
2879    fn body_layout_positions_every_focusable_row_without_overlap() {
2880        let keys = 5;
2881        let vendors = 7;
2882        let layout = body_layout(keys, vendors);
2883        // Credentials: header at 3, rows 4..9, blank 9.
2884        assert_eq!(layout.primary, 1);
2885        assert_eq!(layout.first_key, 4);
2886        // Providers: blank after keys, header, then rows.
2887        assert_eq!(layout.first_vendor, layout.first_key + keys + 2);
2888        assert_eq!(layout.notify_enabled, layout.first_vendor + vendors + 2);
2889        assert_eq!(layout.notify_threshold, layout.notify_enabled + 1);
2890        assert_eq!(layout.save, layout.notify_threshold + 2);
2891        assert_eq!(layout.rows, layout.save + 1);
2892        // No two focusable rows share a line.
2893        let mut rows = vec![
2894            layout.primary,
2895            layout.notify_enabled,
2896            layout.notify_threshold,
2897            layout.save,
2898        ];
2899        rows.extend(layout.first_key..layout.first_key + keys);
2900        rows.extend(layout.first_vendor..layout.first_vendor + vendors);
2901        rows.sort_unstable();
2902        let mut unique = rows.clone();
2903        unique.dedup();
2904        assert_eq!(rows, unique);
2905        assert!(rows.iter().all(|row| *row < layout.rows));
2906    }
2907
2908    #[test]
2909    fn focus_line_matches_the_body_layout_for_every_focus_variant() {
2910        let state = blank_state(VendorId::Anthropic);
2911        let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
2912        let cases = [
2913            (Focus::Primary, layout.primary),
2914            (Focus::Key(0), layout.first_key),
2915            (
2916                Focus::Key(KEY_VENDORS.len() - 1),
2917                layout.first_key + KEY_VENDORS.len() - 1,
2918            ),
2919            (Focus::Vendor(0), layout.first_vendor),
2920            (
2921                Focus::Vendor(state.vendors.len() - 1),
2922                layout.first_vendor + state.vendors.len() - 1,
2923            ),
2924            (Focus::NotifyEnabled, layout.notify_enabled),
2925            (Focus::NotifyThreshold, layout.notify_threshold),
2926            (Focus::Save, layout.save),
2927        ];
2928        for (focus, want) in cases {
2929            let mut s = state.clone();
2930            s.focus = focus;
2931            assert_eq!(focus_line(&s), want, "{focus:?}");
2932        }
2933    }
2934
2935    #[test]
2936    fn follow_focus_scroll_sticks_jumps_and_clamps() {
2937        // Already visible: unchanged.
2938        assert_eq!(follow_focus_scroll(3, 50, 10, 0), 0);
2939        assert_eq!(follow_focus_scroll(9, 50, 10, 0), 0);
2940        // Below the viewport: pinned to the bottom edge.
2941        assert_eq!(follow_focus_scroll(10, 50, 10, 0), 1);
2942        assert_eq!(follow_focus_scroll(25, 50, 10, 5), 16);
2943        // Above the window: jumps up to the focused line.
2944        assert_eq!(follow_focus_scroll(2, 50, 10, 16), 2);
2945        // Never past the scrollable range.
2946        assert_eq!(follow_focus_scroll(49, 50, 10, 0), 40);
2947        assert_eq!(follow_focus_scroll(60, 50, 10, 0), 40);
2948        // Degenerate viewport/total: no scrolling.
2949        assert_eq!(follow_focus_scroll(5, 50, 0, 3), 0);
2950        assert_eq!(follow_focus_scroll(5, 0, 10, 3), 0);
2951    }
2952
2953    /// Actionable hint segments record `HintKey` click rects on the hint row;
2954    /// pure key hints (move/type/digits) never do.
2955    #[test]
2956    fn render_records_clickable_hint_links_for_the_focused_control() {
2957        use ratatui::Terminal;
2958        use ratatui::backend::TestBackend;
2959
2960        let theme = crate::theme::Theme::default();
2961        let mut state = blank_state(VendorId::Anthropic);
2962        state.focus = Focus::Vendor(vendor_index(VendorId::Grok));
2963        let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
2964        let mut hits = Vec::new();
2965        terminal
2966            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
2967            .unwrap();
2968
2969        let hint_keys: Vec<(KeyCode, KeyModifiers)> = hits
2970            .iter()
2971            .filter_map(|(row, _)| match row {
2972                SettingsRow::HintKey(code, mods) => Some((*code, *mods)),
2973                _ => None,
2974            })
2975            .collect();
2976        // Toggle (space), save (^S) and close (esc) are links, recorded in
2977        // segment order on a Vendor row.
2978        assert_eq!(
2979            hint_keys,
2980            vec![
2981                (KeyCode::Char(' '), KeyModifiers::NONE),
2982                (KeyCode::Char('s'), KeyModifiers::CONTROL),
2983                (KeyCode::Esc, KeyModifiers::NONE),
2984            ]
2985        );
2986        // Every link lives on the single hint row, below the body.
2987        let ys: Vec<u16> = hits
2988            .iter()
2989            .filter(|(row, _)| matches!(row, SettingsRow::HintKey(..)))
2990            .map(|(_, rect)| rect.y)
2991            .collect();
2992        assert!(ys.windows(2).all(|pair| pair[0] == pair[1]));
2993
2994        // On the Primary row the change-vendor link is a Right key instead of
2995        // the toggle, and pure hints never become clickable anywhere.
2996        let mut state = blank_state(VendorId::Anthropic);
2997        state.focus = Focus::Primary;
2998        let mut hits = Vec::new();
2999        terminal
3000            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3001            .unwrap();
3002        let hint_keys: Vec<(KeyCode, KeyModifiers)> = hits
3003            .iter()
3004            .filter_map(|(row, _)| match row {
3005                SettingsRow::HintKey(code, mods) => Some((*code, *mods)),
3006                _ => None,
3007            })
3008            .collect();
3009        assert!(hint_keys.contains(&(KeyCode::Right, KeyModifiers::NONE)));
3010        assert!(!hint_keys.contains(&(KeyCode::Char(' '), KeyModifiers::NONE)));
3011    }
3012
3013    /// Every provider row records a switch-cell click target right of the
3014    /// label columns, on the same row as its focus target; the quota-alerts
3015    /// row records one too.
3016    #[test]
3017    fn render_records_switch_cells_right_of_the_labels() {
3018        use ratatui::Terminal;
3019        use ratatui::backend::TestBackend;
3020
3021        let theme = crate::theme::Theme::default();
3022        let mut state = blank_state(VendorId::Anthropic);
3023        state.focus = Focus::Vendor(0);
3024        let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3025        let mut hits = Vec::new();
3026        terminal
3027            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3028            .unwrap();
3029
3030        let focus_of = |i: usize| {
3031            hits.iter().find_map(|(row, rect)| match row {
3032                SettingsRow::Focus(Focus::Vendor(v)) if *v == i => Some(*rect),
3033                _ => None,
3034            })
3035        };
3036        for (i, id) in VendorId::all().iter().enumerate() {
3037            let focus_rect = focus_of(i).unwrap_or_else(|| panic!("vendor {i} has no focus row"));
3038            let switch = hits
3039                .iter()
3040                .find_map(|(row, rect)| match row {
3041                    SettingsRow::Switch(Focus::Vendor(v), KeyCode::Char(' '), _) if *v == i => {
3042                        Some(*rect)
3043                    }
3044                    _ => None,
3045                })
3046                .unwrap_or_else(|| panic!("vendor {i} has no switch cell"));
3047            // The value cell starts after the 5-cell prefix + the padded
3048            // label (names past 11 columns push it right) and covers only
3049            // the rendered value — never the name tail or the trailing
3050            // empty row space.
3051            let expected_x =
3052                5 + crate::display::text_width(&format!("{:<11}", id.display_name())) as u16;
3053            let value_len = if state.vendors[i].enabled { 2 } else { 3 }; // "on" / "off"
3054            let focused = state.focus == Focus::Vendor(i);
3055            assert_eq!(switch.y, focus_rect.y);
3056            assert_eq!(switch.x, focus_rect.x + expected_x);
3057            assert_eq!(
3058                switch.width,
3059                (if focused { 6 } else { 2 }) + value_len,
3060                "rendered value segment"
3061            );
3062            assert!(switch.x + switch.width <= focus_rect.x + focus_rect.width);
3063        }
3064        // The quota-alerts row has a switch cell as well.
3065        assert!(hits.iter().any(|(row, _)| matches!(
3066            row,
3067            SettingsRow::Switch(Focus::NotifyEnabled, KeyCode::Char(' '), _)
3068        )));
3069    }
3070
3071    /// The focused primary radio renders ◀ name ▶: those arrow cells become
3072    /// click targets that step the vendor radio both ways.
3073    #[test]
3074    fn render_records_primary_arrow_cells_when_focused() {
3075        use ratatui::Terminal;
3076        use ratatui::backend::TestBackend;
3077
3078        let theme = crate::theme::Theme::default();
3079        let mut state = blank_state(VendorId::Anthropic);
3080        state.focus = Focus::Primary;
3081        let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3082        let mut hits = Vec::new();
3083        terminal
3084            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3085            .unwrap();
3086
3087        let focus_rect = hits
3088            .iter()
3089            .find_map(|(row, rect)| match row {
3090                SettingsRow::Focus(Focus::Primary) => Some(*rect),
3091                _ => None,
3092            })
3093            .expect("primary row has a focus target");
3094        let prev = hits.iter().find_map(|(row, rect)| match row {
3095            SettingsRow::Switch(Focus::Primary, KeyCode::Left, _) => Some(*rect),
3096            _ => None,
3097        });
3098        let next = hits.iter().find_map(|(row, rect)| match row {
3099            SettingsRow::Switch(Focus::Primary, KeyCode::Right, _) => Some(*rect),
3100            _ => None,
3101        });
3102        let prev = prev.expect("focused primary has a ◀ cell");
3103        let next = next.expect("focused primary has a ▶ cell");
3104        assert_eq!(prev.x, focus_rect.x + 5);
3105        assert_eq!(prev.width, 2);
3106        assert!(next.x > prev.x + 2, "▶ sits right of the vendor name");
3107        assert_eq!(next.width, 2);
3108        assert_eq!(prev.y, next.y);
3109
3110        // Unfocused (arrows only render on the focused radio), the row shows
3111        // no switch cells: only the focus target exists.
3112        let mut state = blank_state(VendorId::Anthropic);
3113        state.focus = Focus::Vendor(0);
3114        let mut unfocused_hits = Vec::new();
3115        terminal
3116            .draw(|f| render(f, f.area(), &mut state, &theme, &mut unfocused_hits))
3117            .unwrap();
3118        assert!(
3119            !unfocused_hits
3120                .iter()
3121                .any(|(row, _)| matches!(row, SettingsRow::Switch(Focus::Primary, _, _)))
3122        );
3123    }
3124
3125    /// Regression: ▶ used to sit at 9 + the current name's width, so it
3126    /// moved on every cycle. The name pads to the widest choice now — the
3127    /// arrow columns are identical whichever vendor is picked.
3128    #[test]
3129    fn primary_arrow_cells_sit_at_fixed_columns() {
3130        use ratatui::Terminal;
3131        use ratatui::backend::TestBackend;
3132
3133        let theme = crate::theme::Theme::default();
3134        let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3135        let mut arrow_x = |primary: VendorId| {
3136            let mut state = blank_state(primary);
3137            state.focus = Focus::Primary;
3138            let mut hits = Vec::new();
3139            terminal
3140                .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3141                .unwrap();
3142            hits.iter()
3143                .find_map(|(row, rect)| match row {
3144                    SettingsRow::Switch(Focus::Primary, KeyCode::Right, _) => Some(rect.x),
3145                    _ => None,
3146                })
3147                .expect("focused primary has a ▶ cell")
3148        };
3149        assert_eq!(arrow_x(VendorId::Anthropic), arrow_x(VendorId::Copilot));
3150    }
3151
3152    /// The picker consumes keys while open: ↑/↓ move the cursor (wrapping),
3153    /// Enter/space select and close, Esc closes without selecting — and with
3154    /// the picker closed, Esc closes the modal again.
3155    #[test]
3156    fn picker_key_handling_selects_and_closes() {
3157        let mut s = blank_state(VendorId::Anthropic);
3158        s.focus = Focus::Primary;
3159        s.picker = Some(PrimaryPicker {
3160            cursor: 0,
3161            scroll: 0,
3162        });
3163        let count = s.primary_choices.len();
3164        assert!(count > 1);
3165
3166        assert_eq!(
3167            handle_key(&mut s, KeyCode::Down, KeyModifiers::NONE),
3168            Action::Continue
3169        );
3170        assert_eq!(s.picker.as_ref().unwrap().cursor, 1);
3171        // Wraps at the top.
3172        handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
3173        handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
3174        assert_eq!(s.picker.as_ref().unwrap().cursor, count - 1);
3175
3176        // Enter selects the cursor's choice and closes the popup.
3177        let expected = s.primary_choices[s.picker.as_ref().unwrap().cursor];
3178        assert_eq!(
3179            handle_key(&mut s, KeyCode::Enter, KeyModifiers::NONE),
3180            Action::Continue
3181        );
3182        assert_eq!(s.primary, expected);
3183        assert!(s.picker.is_none());
3184
3185        // Esc closes the picker without selecting; the next Esc closes the
3186        // modal.
3187        s.picker = Some(PrimaryPicker {
3188            cursor: 2,
3189            scroll: 0,
3190        });
3191        assert_eq!(
3192            handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
3193            Action::Continue
3194        );
3195        assert!(s.picker.is_none());
3196        assert_eq!(
3197            handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
3198            Action::Close
3199        );
3200    }
3201
3202    /// The open picker records one click row per choice.
3203    #[test]
3204    fn open_picker_records_one_click_row_per_choice() {
3205        use ratatui::Terminal;
3206        use ratatui::backend::TestBackend;
3207
3208        let theme = crate::theme::Theme::default();
3209        let mut state = blank_state(VendorId::Anthropic);
3210        state.focus = Focus::Primary;
3211        state.picker = Some(PrimaryPicker {
3212            cursor: 0,
3213            scroll: 0,
3214        });
3215        let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3216        let mut hits = Vec::new();
3217        terminal
3218            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3219            .unwrap();
3220
3221        let picks: Vec<usize> = hits
3222            .iter()
3223            .filter_map(|(row, _)| match row {
3224                SettingsRow::Pick(i) => Some(*i),
3225                _ => None,
3226            })
3227            .collect();
3228        assert_eq!(picks.len(), state.primary_choices.len());
3229        assert_eq!(picks.first().copied(), Some(0));
3230        assert_eq!(picks.last().copied(), Some(state.primary_choices.len() - 1));
3231    }
3232
3233    /// End-to-end through the real renderer: on a terminal shorter than the
3234    /// overlay body, focusing a late provider switch keeps it and the Save
3235    /// button on screen — the reason the scroll exists.
3236    #[test]
3237    fn render_scrolls_the_body_so_a_late_row_and_save_stay_visible() {
3238        use ratatui::Terminal;
3239        use ratatui::backend::TestBackend;
3240
3241        let backend = TestBackend::new(100, 24);
3242        let mut terminal = Terminal::new(backend).unwrap();
3243        let theme = crate::theme::Theme::default();
3244        let mut state = blank_state(VendorId::Anthropic);
3245        state.focus = Focus::Vendor(vendor_index(VendorId::Grok));
3246        let mut hits = Vec::new();
3247
3248        terminal
3249            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3250            .unwrap();
3251
3252        let rendered = buffer_text(terminal.backend().buffer());
3253        assert!(
3254            rendered.iter().any(|line| line.contains("Grok")),
3255            "focused provider row must be visible: {rendered:?}"
3256        );
3257        // The Save control is the last thing focus moves to; keep it on
3258        // screen too (it renders after the provider rows).
3259        let mut state = blank_state(VendorId::Anthropic);
3260        state.focus = Focus::Save;
3261        let mut hits = Vec::new();
3262        terminal
3263            .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3264            .unwrap();
3265        let rendered = buffer_text(terminal.backend().buffer());
3266        assert!(
3267            rendered.iter().any(|line| line.contains("Save")),
3268            "Save must stay visible: {rendered:?}"
3269        );
3270    }
3271
3272    /// Every line of a TestBackend buffer as a String, for substring checks.
3273    fn buffer_text(buffer: &ratatui::buffer::Buffer) -> Vec<String> {
3274        let area = buffer.area;
3275        let mut lines = Vec::new();
3276        for y in area.top()..area.bottom() {
3277            let mut line = String::new();
3278            for x in area.left()..area.right() {
3279                line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
3280            }
3281            lines.push(line.trim_end().to_string());
3282        }
3283        lines
3284    }
3285}