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