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