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, and
5//! Command Code authenticate through official or local product state, so they have
6//! no credential field here — there is nothing to paste, and a field would only
7//! 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//! Persistence uses `toml_edit` so the existing config keeps its comments,
13//! whitespace, and unrelated fields. Writing a key also flips that vendor's
14//! `enabled = true` (the opt-in vendors are disabled by default), so "paste the
15//! credential and save" is all it takes. Files with inline credentials are atomically written
16//! and `chmod 600`ed.
17
18use std::collections::BTreeMap;
19use std::io::BufRead;
20use std::path::{Path, PathBuf};
21
22use ratatui::Frame;
23use ratatui::layout::{Constraint, Direction, Layout, Rect};
24use ratatui::style::Modifier;
25use ratatui::text::{Line, Span};
26use ratatui::widgets::{Clear, Paragraph};
27use ratatui_bubbletea_theme::BubbleTheme;
28use serde::{Deserialize, Serialize};
29use toml_edit::{DocumentMut, value};
30
31use crate::config::{
32    Config, is_valid_env_var_name, read_config_document, set_bool, write_config_document,
33};
34use crate::error::{AppError, Result};
35use crate::theme::Theme;
36use crate::tui::style::bubble_theme;
37use crate::vendor::VendorId;
38
39/// A vendor that authenticates with an inline credential. The order of this
40/// table is the tab order of the credential fields and the layout of the
41/// state's `keys` vec.
42pub struct KeyVendor {
43    pub id: VendorId,
44    pub label: &'static str,
45    pub section: &'static str,
46    /// Config field that stores the credential (`api_key` for most vendors).
47    pub config_key: &'static str,
48    /// Human-readable name shown in the native settings form.
49    pub secret_label: &'static str,
50    /// Extra hint after the env var (e.g. "management key"). Empty for none.
51    pub note: &'static str,
52}
53
54pub const KEY_VENDORS: &[KeyVendor] = &[
55    KeyVendor {
56        id: VendorId::AnthropicApi,
57        label: "Anthropic API",
58        section: VendorId::AnthropicApi.config_section(),
59        config_key: "api_key",
60        secret_label: "API key",
61        note: "admin key — monthly spend",
62    },
63    KeyVendor {
64        id: VendorId::Zai,
65        label: "Z.AI",
66        section: VendorId::Zai.config_section(),
67        config_key: "api_key",
68        secret_label: "API key",
69        note: "",
70    },
71    KeyVendor {
72        id: VendorId::Openrouter,
73        label: "OpenRouter",
74        section: VendorId::Openrouter.config_section(),
75        config_key: "api_key",
76        secret_label: "API key",
77        note: "",
78    },
79    KeyVendor {
80        id: VendorId::Deepseek,
81        label: "DeepSeek",
82        section: VendorId::Deepseek.config_section(),
83        config_key: "api_key",
84        secret_label: "API key",
85        note: "",
86    },
87    KeyVendor {
88        id: VendorId::Kimi,
89        label: "Kimi",
90        section: VendorId::Kimi.config_section(),
91        config_key: "api_key",
92        secret_label: "API key",
93        note: "coding-plan usage",
94    },
95    KeyVendor {
96        id: VendorId::Kilo,
97        label: "Kilo",
98        section: VendorId::Kilo.config_section(),
99        config_key: "api_key",
100        secret_label: "API key",
101        note: "",
102    },
103    KeyVendor {
104        id: VendorId::Novita,
105        label: "Novita",
106        section: VendorId::Novita.config_section(),
107        config_key: "api_key",
108        secret_label: "API key",
109        note: "",
110    },
111    KeyVendor {
112        id: VendorId::Moonshot,
113        label: "Moonshot",
114        section: VendorId::Moonshot.config_section(),
115        config_key: "api_key",
116        secret_label: "API key",
117        note: "account balance",
118    },
119    KeyVendor {
120        id: VendorId::Grok,
121        label: "Grok",
122        section: VendorId::Grok.config_section(),
123        config_key: "api_key",
124        secret_label: "API key",
125        note: "management key, not the inference key",
126    },
127    KeyVendor {
128        id: VendorId::Minimax,
129        label: "MiniMax",
130        section: VendorId::Minimax.config_section(),
131        config_key: "api_key",
132        secret_label: "API key",
133        note: "Token Plan subscription key",
134    },
135    KeyVendor {
136        id: VendorId::OpenCodeGo,
137        label: "OpenCode Go",
138        section: VendorId::OpenCodeGo.config_section(),
139        config_key: "api_key",
140        secret_label: "API key",
141        note: "usage quota",
142    },
143    KeyVendor {
144        id: VendorId::Ollama,
145        label: "Ollama Cloud",
146        section: VendorId::Ollama.config_section(),
147        config_key: "api_key",
148        secret_label: "API key",
149        note: "ollama.com/settings/keys",
150    },
151];
152
153/// Which control has keyboard focus. `Key(i)` indexes into [`KEY_VENDORS`].
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum Focus {
156    Primary,
157    Key(usize),
158    Save,
159}
160
161impl Focus {
162    pub fn next(self) -> Self {
163        match self {
164            Focus::Primary => Focus::Key(0),
165            Focus::Key(i) if i + 1 < KEY_VENDORS.len() => Focus::Key(i + 1),
166            Focus::Key(_) => Focus::Save,
167            Focus::Save => Focus::Primary,
168        }
169    }
170    pub fn prev(self) -> Self {
171        match self {
172            Focus::Primary => Focus::Save,
173            Focus::Key(0) => Focus::Primary,
174            Focus::Key(i) => Focus::Key(i - 1),
175            Focus::Save => Focus::Key(KEY_VENDORS.len() - 1),
176        }
177    }
178}
179
180/// Per-field text-input state — cursor + buffer + reveal flag.
181#[derive(Debug, Clone, Default)]
182pub struct KeyInput {
183    pub buf: String,
184    /// Char-index cursor position (0..=buf.chars().count()).
185    pub cursor: usize,
186    /// When true, the field renders the actual characters; otherwise `•`.
187    pub revealed: bool,
188    /// True after the user has typed/edited; only then does save write the
189    /// value back (avoids clobbering an existing key with the empty
190    /// placeholder the user opened the dialog with).
191    pub dirty: bool,
192}
193
194impl KeyInput {
195    pub fn from_config(initial: Option<&str>) -> Self {
196        let buf = initial.unwrap_or("").to_string();
197        let cursor = buf.chars().count();
198        Self {
199            buf,
200            cursor,
201            revealed: false,
202            dirty: false,
203        }
204    }
205
206    pub fn insert_char(&mut self, c: char) {
207        let byte_idx = self.char_to_byte(self.cursor);
208        self.buf.insert(byte_idx, c);
209        self.cursor += 1;
210        self.dirty = true;
211    }
212
213    pub fn backspace(&mut self) {
214        if self.cursor == 0 {
215            return;
216        }
217        let prev_byte = self.char_to_byte(self.cursor - 1);
218        let cur_byte = self.char_to_byte(self.cursor);
219        self.buf.replace_range(prev_byte..cur_byte, "");
220        self.cursor -= 1;
221        self.dirty = true;
222    }
223
224    pub fn delete(&mut self) {
225        let n = self.buf.chars().count();
226        if self.cursor >= n {
227            return;
228        }
229        let cur_byte = self.char_to_byte(self.cursor);
230        let next_byte = self.char_to_byte(self.cursor + 1);
231        self.buf.replace_range(cur_byte..next_byte, "");
232        self.dirty = true;
233    }
234
235    pub fn move_left(&mut self) {
236        if self.cursor > 0 {
237            self.cursor -= 1;
238        }
239    }
240    pub fn move_right(&mut self) {
241        if self.cursor < self.buf.chars().count() {
242            self.cursor += 1;
243        }
244    }
245    pub fn move_home(&mut self) {
246        self.cursor = 0;
247    }
248    pub fn move_end(&mut self) {
249        self.cursor = self.buf.chars().count();
250    }
251    pub fn toggle_reveal(&mut self) {
252        self.revealed = !self.revealed;
253    }
254
255    /// Render for display — bullets when masked, raw chars when revealed.
256    pub fn display(&self) -> String {
257        if self.revealed {
258            self.buf.clone()
259        } else {
260            "•".repeat(self.buf.chars().count())
261        }
262    }
263
264    fn char_to_byte(&self, char_idx: usize) -> usize {
265        self.buf
266            .char_indices()
267            .map(|(b, _)| b)
268            .chain(std::iter::once(self.buf.len()))
269            .nth(char_idx)
270            .unwrap_or(self.buf.len())
271    }
272}
273
274/// Mutable state of the overlay while open.
275#[derive(Debug, Clone)]
276pub struct SettingsState {
277    pub focus: Focus,
278    /// Enabled vendors only. The primary selector must not offer a value that
279    /// cannot actually be used by the widget or TUI.
280    pub primary_choices: Vec<VendorId>,
281    pub primary: VendorId,
282    /// One input per [`KEY_VENDORS`] entry, same order.
283    pub keys: Vec<KeyInput>,
284    /// One-line status displayed in the footer ("saved …", "save failed …").
285    pub status: String,
286}
287
288impl SettingsState {
289    pub fn from_config(cfg: &Config) -> Self {
290        Self::from_config_with(cfg, |name| {
291            std::env::var_os(name).is_some_and(|v| !v.is_empty())
292        })
293    }
294
295    /// [`Self::from_config`] with an injected environment lookup.
296    ///
297    /// The overlay offers a key-only vendor whose env var is already exported,
298    /// so a user need not hand-edit `config.toml` to select it. That is a read
299    /// of ambient state, which a test must never depend on: the AUR `check()`
300    /// runs `cargo test` during `makepkg`, so a test that branched on the real
301    /// environment would fail the install for anyone who exports, say,
302    /// `OLLAMA_API_KEY`. Tests pass their own lookup here.
303    pub fn from_config_with(cfg: &Config, env_set: impl Fn(&str) -> bool) -> Self {
304        let keys = KEY_VENDORS
305            .iter()
306            .map(|kv| KeyInput::from_config(cfg.inline_api_key(kv.id)))
307            .collect();
308        let mut primary_choices = cfg.enabled_vendors();
309        // Copilot credentials belong to GitHub CLI, so a login cannot write a
310        // local key that would also opt it in. Offer it explicitly instead:
311        // selecting it persists both the primary and `enabled = true`.
312        if !primary_choices.contains(&VendorId::Copilot) {
313            primary_choices.push(VendorId::Copilot);
314        }
315        // Key-only vendors are opt-in: without an inline `api_key` and with
316        // the env var empty, the fetch would fail on the first cycle. A user
317        // who already exported the env var (e.g. `OLLAMA_API_KEY`) and wants
318        // to flip `enabled = true` from inside the TUI has to be able to
319        // select the vendor here — otherwise they'd have to hand-edit
320        // `config.toml`, which is the workflow this overlay exists to avoid.
321        // We treat a non-empty env var as a sufficient signal of reachability.
322        for kv in KEY_VENDORS {
323            if primary_choices.contains(&kv.id) {
324                continue;
325            }
326            let env = cfg.api_key_env_for(kv.id);
327            let exported = is_valid_env_var_name(env) && env_set(env);
328            if cfg.inline_api_key(kv.id).is_some() || exported {
329                primary_choices.push(kv.id);
330            }
331        }
332        // A configured but disabled primary is ineffective. Display the first
333        // enabled vendor instead; when none are enabled retain the historical
334        // Anthropic fallback in memory without inventing a persisted primary.
335        let primary = cfg
336            .ui
337            .primary
338            .filter(|vendor| {
339                primary_choices.contains(vendor)
340                    && (*vendor != VendorId::Copilot || cfg.copilot.enabled)
341            })
342            .or_else(|| primary_choices.first().copied())
343            .unwrap_or_else(|| cfg.ui.primary.unwrap_or(VendorId::Anthropic));
344        Self {
345            focus: Focus::Primary,
346            primary_choices,
347            primary,
348            keys,
349            status: String::new(),
350        }
351    }
352
353    /// The focused key input, if a key row is focused.
354    fn focused_key_mut(&mut self) -> Option<&mut KeyInput> {
355        match self.focus {
356            Focus::Key(i) => self.keys.get_mut(i),
357            _ => None,
358        }
359    }
360}
361
362/// What the key handler asks the host app to do next.
363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
364pub enum Action {
365    /// Stay open, keep listening for keys.
366    Continue,
367    /// Close the overlay (discard or save already happened).
368    Close,
369    /// Save just succeeded — caller should refresh affected vendors.
370    SavedAndClose,
371    /// Quit the host TUI. Ctrl-C remains global even while the overlay owns
372    /// keyboard focus.
373    Quit,
374}
375
376/// Permission note appended to the "saved" status line. The overlay `chmod
377/// 600`s the file on Unix; Windows has no such step, so the note is empty there.
378#[cfg(unix)]
379const PERMS_NOTE: &str = " (chmod 600)";
380#[cfg(not(unix))]
381const PERMS_NOTE: &str = "";
382
383fn saved_status() -> String {
384    format!(
385        "saved to {}{}",
386        crate::config::config_path_hint(),
387        PERMS_NOTE
388    )
389}
390
391/// Key map. Returns the action to perform after the keypress.
392pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
393    if matches!(code, KeyCode::Esc) {
394        return Action::Close;
395    }
396    if matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL) {
397        return Action::Quit;
398    }
399    // Ctrl-S triggers save from any field.
400    if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
401        return try_save(state);
402    }
403    if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
404        if let Some(input) = state.focused_key_mut() {
405            input.toggle_reveal();
406        }
407        return Action::Continue;
408    }
409    match code {
410        KeyCode::Tab | KeyCode::Down => {
411            state.focus = state.focus.next();
412            return Action::Continue;
413        }
414        KeyCode::BackTab | KeyCode::Up => {
415            state.focus = state.focus.prev();
416            return Action::Continue;
417        }
418        _ => {}
419    }
420
421    // A modifier chord is not text. The overlay swallows every key while open,
422    // so every unhandled chord must be ignored rather than corrupting the
423    // secret silently. SHIFT is deliberately not rejected — it is how
424    // uppercase arrives. Ctrl-C was handled above because it is a global quit.
425    if matches!(code, KeyCode::Char(_))
426        && mods.intersects(
427            KeyModifiers::CONTROL
428                | KeyModifiers::ALT
429                | KeyModifiers::SUPER
430                | KeyModifiers::HYPER
431                | KeyModifiers::META,
432        )
433    {
434        return Action::Continue;
435    }
436
437    // Field-specific handling.
438    match state.focus {
439        Focus::Primary => handle_primary(state, code),
440        Focus::Key(i) => {
441            if let Some(input) = state.keys.get_mut(i) {
442                handle_input(input, code);
443            }
444        }
445        Focus::Save => {
446            if matches!(code, KeyCode::Enter) {
447                return try_save(state);
448            }
449        }
450    }
451    Action::Continue
452}
453
454fn try_save(state: &mut SettingsState) -> Action {
455    match save_to_config_default(state) {
456        Ok(()) => {
457            state.status = saved_status();
458            Action::SavedAndClose
459        }
460        Err(e) => {
461            state.status = format!("save failed: {e}");
462            Action::Continue
463        }
464    }
465}
466
467fn handle_primary(state: &mut SettingsState, code: KeyCode) {
468    // Left/Right cycles the primary-vendor radio over enabled vendors only.
469    let choices = &state.primary_choices;
470    let Some(idx) = choices.iter().position(|v| *v == state.primary) else {
471        return;
472    };
473    let step = match code {
474        KeyCode::Left => -1,
475        KeyCode::Right | KeyCode::Char(' ') => 1,
476        _ => return,
477    };
478    state.primary = choices[((idx as i32 + step).rem_euclid(choices.len() as i32)) as usize];
479}
480
481fn handle_input(input: &mut KeyInput, code: KeyCode) {
482    match code {
483        KeyCode::Char(c) => input.insert_char(c),
484        KeyCode::Backspace => input.backspace(),
485        KeyCode::Delete => input.delete(),
486        KeyCode::Left => input.move_left(),
487        KeyCode::Right => input.move_right(),
488        KeyCode::Home => input.move_home(),
489        KeyCode::End => input.move_end(),
490        _ => {}
491    }
492}
493
494/// Save to the platform config path (creating it). On success, signal a running
495/// Waybar (`SIGRTMIN+13`) so a `signal: 13` module refreshes immediately.
496fn save_to_config_default(state: &SettingsState) -> Result<()> {
497    let path = default_config_path()?;
498    if let Some(parent) = path.parent() {
499        std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
500    }
501    save_to_path(state, &path)?;
502    crate::waybar::request_refresh();
503    Ok(())
504}
505
506/// Same as `save_to_config_default` but with an explicit path — exposed for
507/// tests. Writing a non-empty credential also sets that vendor's `enabled = true`.
508pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
509    let mut doc = read_config_document(path)?;
510
511    // Remove fields written by the short-lived inline Copilot-token design.
512    // GitHub CLI owns the OAuth credential now; retaining a secret this app
513    // neither reads nor supports would be misleading and unsafe.
514    if let Some(table) = doc
515        .get_mut("copilot")
516        .and_then(toml_edit::Item::as_table_mut)
517    {
518        table.remove("token");
519        table.remove("token_env");
520    }
521
522    // Only Copilot is deliberately offered before it is enabled: choosing it
523    // is the explicit opt-in after the GitHub CLI login. The env-only key
524    // vendors (any `KEY_VENDORS` entry whose credential the user reached via
525    // `OLLAMA_API_KEY` or another env var) are also offered before they are
526    // enabled, and selecting them is the explicit opt-in for that vendor —
527    // otherwise a user could pick a vendor in the overlay and the fetch
528    // would still fail because `enabled = false`. Every other choice remains
529    // enabled-only, so no failed provider is persisted as primary.
530    if state.primary_choices.contains(&state.primary) {
531        set_string(&mut doc, "ui", "primary", state.primary.slug())?;
532        if state.primary == VendorId::Copilot || KEY_VENDORS.iter().any(|kv| kv.id == state.primary)
533        {
534            set_bool(&mut doc, state.primary.config_section(), "enabled", true)?;
535        }
536    }
537
538    for (i, kv) in KEY_VENDORS.iter().enumerate() {
539        let Some(input) = state.keys.get(i) else {
540            continue;
541        };
542        update_key(&mut doc, kv, input)?;
543    }
544
545    write_config_document(path, &doc)
546}
547
548/// Apply one credential field to the document. Untouched fields are left
549/// alone; a field the user cleared is *removed*, so an inline secret can be
550/// deleted from the overlay rather than lingering in the file. Writing a
551/// non-empty credential also opts the vendor in — the opt-in vendors would
552/// otherwise never fetch.
553fn update_key(doc: &mut DocumentMut, vendor: &KeyVendor, input: &KeyInput) -> Result<()> {
554    if !input.dirty {
555        return Ok(());
556    }
557    if input.buf.is_empty() {
558        if let Some(table) = doc
559            .get_mut(vendor.section)
560            .and_then(toml_edit::Item::as_table_mut)
561        {
562            table.remove(vendor.config_key);
563        }
564        return Ok(());
565    }
566    set_string(doc, vendor.section, vendor.config_key, &input.buf)?;
567    set_bool(doc, vendor.section, "enabled", true)
568}
569
570/// Set or update a string field in a TOML section, preserving comments and
571/// formatting of unaffected nodes.
572fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
573    let table = doc
574        .entry(section)
575        .or_insert_with(toml_edit::table)
576        .as_table_mut()
577        .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
578
579    if let Some(item) = table.get_mut(key)
580        && let Some(v) = item.as_value_mut()
581    {
582        *v = toml_edit::Value::from(new_value);
583        v.decor_mut().set_prefix(" ");
584        return Ok(());
585    }
586    table.insert(key, value(new_value));
587    Ok(())
588}
589
590fn default_config_path() -> Result<PathBuf> {
591    // Save back to the same file Config::load() selected. On macOS this may be
592    // the legacy ~/.config path when the canonical Application Support file is
593    // absent; writing a new canonical file would shadow the existing config on
594    // the next load and silently discard all settings the overlay did not copy.
595    crate::config::resolved_path()
596        .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
597}
598
599// ─── Native frontend bridge ───────────────────────────────────────────────
600
601/// Versioned, non-secret description consumed by native desktop frontends.
602/// Inline key values are deliberately represented only as booleans: a
603/// long-lived shell process never needs to receive credentials just to draw a
604/// settings form.
605#[derive(Debug, Serialize)]
606struct SettingsSnapshot {
607    schema_version: u8,
608    primary: String,
609    primary_choices: Vec<PrimaryChoice>,
610    keys: Vec<KeyStatus>,
611}
612
613#[derive(Debug, Serialize)]
614struct PrimaryChoice {
615    id: String,
616    label: String,
617}
618
619#[derive(Debug, Serialize)]
620struct KeyStatus {
621    id: String,
622    label: String,
623    environment: String,
624    secret_label: String,
625    note: String,
626    configured: bool,
627    inline_configured: bool,
628    environment_configured: bool,
629}
630
631/// Additive patch accepted on stdin by `ai-usagebar settings apply`.
632/// Missing keys remain byte-for-byte untouched. `clear` explicitly removes an
633/// inline key, matching the TUI overlay's existing empty-dirty-field behavior.
634#[derive(Debug, Deserialize)]
635#[serde(deny_unknown_fields)]
636struct ApplyRequest {
637    schema_version: u8,
638    primary: Option<String>,
639    #[serde(default)]
640    keys: BTreeMap<String, KeyMutation>,
641}
642
643#[derive(Debug, Deserialize)]
644#[serde(tag = "action", rename_all = "lowercase", deny_unknown_fields)]
645enum KeyMutation {
646    Set { value: String },
647    Clear,
648}
649
650const SETTINGS_SCHEMA_VERSION: u8 = 1;
651const MAX_SETTINGS_REQUEST_BYTES: u64 = 64 * 1024;
652const MAX_API_KEY_BYTES: usize = 16 * 1024;
653
654fn snapshot_from_config_with(
655    cfg: &Config,
656    environment_configured: impl Fn(&str) -> bool,
657) -> SettingsSnapshot {
658    let state = SettingsState::from_config(cfg);
659    let primary_choices = state
660        .primary_choices
661        .iter()
662        .map(|id| PrimaryChoice {
663            id: id.slug().to_string(),
664            label: id.display_name().to_string(),
665        })
666        .collect();
667    let keys = KEY_VENDORS
668        .iter()
669        .map(|vendor| {
670            let environment = cfg.api_key_env_for(vendor.id);
671            let inline_configured = cfg.inline_api_key(vendor.id).is_some();
672            let environment_configured = environment_configured(environment);
673            KeyStatus {
674                id: vendor.id.slug().to_string(),
675                label: vendor.label.to_string(),
676                environment: environment.to_string(),
677                secret_label: vendor.secret_label.to_string(),
678                note: vendor.note.to_string(),
679                configured: inline_configured || environment_configured,
680                inline_configured,
681                environment_configured,
682            }
683        })
684        .collect();
685    SettingsSnapshot {
686        schema_version: SETTINGS_SCHEMA_VERSION,
687        primary: state.primary.slug().to_string(),
688        primary_choices,
689        keys,
690    }
691}
692
693fn settings_snapshot_json(cfg: &Config) -> Result<String> {
694    Ok(serde_json::to_string(&snapshot_from_config_with(
695        cfg,
696        |environment| std::env::var_os(environment).is_some_and(|value| !value.is_empty()),
697    ))?)
698}
699
700#[cfg(test)]
701fn settings_snapshot_json_with(
702    cfg: &Config,
703    environment_configured: impl Fn(&str) -> bool,
704) -> Result<String> {
705    Ok(serde_json::to_string(&snapshot_from_config_with(
706        cfg,
707        environment_configured,
708    ))?)
709}
710
711fn vendor_from_slug(slug: &str) -> Option<VendorId> {
712    VendorId::all().iter().copied().find(|id| id.slug() == slug)
713}
714
715fn state_from_apply_request(cfg: &Config, raw: &str) -> Result<SettingsState> {
716    let request: ApplyRequest = serde_json::from_str(raw)?;
717    if request.schema_version != SETTINGS_SCHEMA_VERSION {
718        return Err(AppError::Other(format!(
719            "unsupported settings schema version {}",
720            request.schema_version
721        )));
722    }
723
724    let mut state = SettingsState::from_config(cfg);
725    if let Some(primary) = request.primary {
726        let id = vendor_from_slug(&primary)
727            .ok_or_else(|| AppError::Other(format!("unknown primary vendor {primary:?}")))?;
728        if !state.primary_choices.contains(&id) {
729            return Err(AppError::Other(format!(
730                "primary vendor {primary:?} is not enabled"
731            )));
732        }
733        state.primary = id;
734    }
735
736    for (id, mutation) in request.keys {
737        let index = KEY_VENDORS
738            .iter()
739            .position(|vendor| vendor.id.slug() == id)
740            .ok_or_else(|| AppError::Other(format!("unknown credential vendor {id:?}")))?;
741        let input = &mut state.keys[index];
742        match mutation {
743            KeyMutation::Set { value } => {
744                if value.is_empty() {
745                    return Err(AppError::Other(format!(
746                        "{} for {id:?} is empty; use the clear action to remove it",
747                        KEY_VENDORS[index].secret_label
748                    )));
749                }
750                if value.len() > MAX_API_KEY_BYTES {
751                    return Err(AppError::Other(format!(
752                        "{} for {id:?} exceeds {MAX_API_KEY_BYTES} bytes",
753                        KEY_VENDORS[index].secret_label
754                    )));
755                }
756                if value.chars().any(char::is_control) {
757                    return Err(AppError::Other(format!(
758                        "{} for {id:?} contains control characters",
759                        KEY_VENDORS[index].secret_label
760                    )));
761                }
762                input.buf = value;
763            }
764            KeyMutation::Clear => input.buf.clear(),
765        }
766        input.cursor = input.buf.chars().count();
767        input.dirty = true;
768        input.revealed = false;
769    }
770    Ok(state)
771}
772
773#[cfg(test)]
774fn apply_settings_json_to_path(cfg: &Config, raw: &str, path: &Path) -> Result<()> {
775    let state = state_from_apply_request(cfg, raw)?;
776    save_to_path(&state, path)
777}
778
779fn read_settings_request<R: BufRead>(reader: R) -> Result<String> {
780    let mut limited = reader.take(MAX_SETTINGS_REQUEST_BYTES + 1);
781    let mut bytes = Vec::new();
782    limited.read_until(b'\n', &mut bytes)?;
783    if bytes.len() as u64 > MAX_SETTINGS_REQUEST_BYTES {
784        return Err(AppError::Other(format!(
785            "settings request exceeds {MAX_SETTINGS_REQUEST_BYTES} bytes"
786        )));
787    }
788    if bytes.last() == Some(&b'\n') {
789        bytes.pop();
790        if bytes.last() == Some(&b'\r') {
791            bytes.pop();
792        }
793    }
794    String::from_utf8(bytes)
795        .map_err(|_| AppError::Other("settings request is not valid UTF-8".into()))
796}
797
798fn apply_settings_from_stdin() -> Result<()> {
799    let raw = read_settings_request(std::io::stdin().lock())?;
800    let cfg = Config::load()?;
801    let state = state_from_apply_request(&cfg, &raw)?;
802    save_to_config_default(&state)
803}
804
805/// Administrative settings bridge for native frontends. `show` never emits a
806/// secret; `apply` accepts its patch only over stdin so keys do not appear in
807/// argv or the process environment.
808pub fn run_cli(action: &crate::widget::cli::SettingsAction) -> i32 {
809    let result = match action {
810        crate::widget::cli::SettingsAction::Show => Config::load()
811            .and_then(|cfg| settings_snapshot_json(&cfg))
812            .map(|json| println!("{json}")),
813        crate::widget::cli::SettingsAction::Apply => {
814            apply_settings_from_stdin().map(|()| println!(r#"{{"ok":true}}"#))
815        }
816    };
817    match result {
818        Ok(()) => 0,
819        Err(error) => {
820            eprintln!("settings: {error}");
821            1
822        }
823    }
824}
825
826// ─── Render ────────────────────────────────────────────────────────────────
827
828/// Render the modal overlay over `area`.
829pub fn render(f: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
830    let modal = centered_rect(74, 88, area);
831    f.render_widget(Clear, modal);
832
833    let bubble = bubble_theme(theme);
834    let block = bubble.titled_modal_block(" Settings ");
835    let inner = block.inner(modal);
836    f.render_widget(block, modal);
837
838    // Body (everything but the pinned hint) + a 1-line hint footer.
839    let chunks = Layout::default()
840        .direction(Direction::Vertical)
841        .constraints([Constraint::Min(0), Constraint::Length(1)])
842        .split(inner);
843
844    // — Primary vendor + credentials header —
845    let mut lines: Vec<Line> = vec![
846        section_header("Primary vendor", "shown first on the bar / TUI", &bubble),
847        primary_line(state, &bubble),
848        Line::from(""),
849        section_header(
850            "Credentials",
851            "pick a row, type the credential, then Ctrl-S — Claude & Codex use CLI login",
852            &bubble,
853        ),
854    ];
855    for (i, kv) in KEY_VENDORS.iter().enumerate() {
856        let focused = state.focus == Focus::Key(i);
857        lines.push(key_row(kv, &state.keys[i], focused, &bubble));
858    }
859    lines.push(Line::from(""));
860
861    // — Save + status —
862    lines.push(save_line(state.focus == Focus::Save, &bubble));
863    if !state.status.is_empty() {
864        let ok = state.status.starts_with("saved");
865        let mark = if ok { "  ✓ " } else { "  ✗ " };
866        let style = if ok { bubble.accent } else { bubble.selected };
867        lines.push(Line::from(vec![
868            Span::styled(mark, style.add_modifier(Modifier::BOLD)),
869            Span::styled(state.status.clone(), bubble.muted),
870        ]));
871    }
872
873    f.render_widget(Paragraph::new(lines), chunks[0]);
874
875    // Context-aware hint footer.
876    let hint = match state.focus {
877        Focus::Primary => bubble.help_line([
878            ("↑↓/tab", "move"),
879            ("←→", "change vendor"),
880            ("^S", "save"),
881            ("esc", "close"),
882        ]),
883        Focus::Key(_) => bubble.help_line([
884            ("↑↓/tab", "move"),
885            ("type", "edit key"),
886            ("^V", "reveal"),
887            ("^S", "save"),
888            ("esc", "close"),
889        ]),
890        Focus::Save => {
891            bubble.help_line([("↑↓/tab", "move"), ("enter/^S", "save"), ("esc", "close")])
892        }
893    };
894    f.render_widget(Paragraph::new(hint), chunks[1]);
895}
896
897fn section_header(title: &str, sub: &str, theme: &BubbleTheme) -> Line<'static> {
898    Line::from(vec![
899        theme.span(" "),
900        Span::styled(title.to_string(), theme.title.add_modifier(Modifier::BOLD)),
901        theme.muted(format!("   — {sub}")),
902    ])
903}
904
905fn primary_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
906    let focused = state.focus == Focus::Primary;
907    let name = state.primary.display_name().to_string();
908    if focused {
909        Line::from(vec![
910            theme.span("   "),
911            Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
912            Span::styled("◀ ", theme.accent),
913            Span::styled(
914                format!(" {name} "),
915                theme
916                    .selected
917                    .add_modifier(Modifier::REVERSED | Modifier::BOLD),
918            ),
919            Span::styled(" ▶", theme.accent),
920            theme.muted("    ← → to change"),
921        ])
922    } else {
923        Line::from(vec![theme.span("     "), Span::styled(name, theme.text)])
924    }
925}
926
927fn key_row(kv: &KeyVendor, input: &KeyInput, focused: bool, theme: &BubbleTheme) -> Line<'static> {
928    let label = format!("{:<11}", kv.label);
929    let value = value_text(input, focused);
930
931    // Env / status suffix: env-var name, whether an env override is set, note.
932    let env_name = kv.id.api_key_env();
933    let env_set = std::env::var(env_name)
934        .map(|v| !v.is_empty())
935        .unwrap_or(false);
936    let mut suffix = format!("   {env_name}");
937    if env_set {
938        suffix.push_str(" · env set (overrides)");
939    }
940    if !kv.note.is_empty() {
941        suffix.push_str(&format!(" · {}", kv.note));
942    }
943
944    if focused {
945        let val_style = if input.buf.is_empty() {
946            theme.accent.add_modifier(Modifier::BOLD)
947        } else {
948            theme.selected.add_modifier(Modifier::REVERSED)
949        };
950        let mut spans = vec![
951            theme.span("  "),
952            Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
953            Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
954            Span::styled(format!(" {value} "), val_style),
955        ];
956        if input.revealed {
957            spans.push(theme.muted("  [revealed]"));
958        }
959        spans.push(theme.muted(suffix));
960        Line::from(spans)
961    } else {
962        let val_style = if input.buf.is_empty() {
963            theme.muted
964        } else {
965            theme.text
966        };
967        Line::from(vec![
968            theme.span("    "),
969            Span::styled(label, theme.text),
970            Span::styled(format!(" {value}"), val_style),
971            theme.muted(suffix),
972        ])
973    }
974}
975
976/// The value column: `(empty)` / a cursor when focused-empty / masked or
977/// revealed buffer with a cursor mark inserted when focused.
978fn value_text(input: &KeyInput, focused: bool) -> String {
979    if input.buf.is_empty() {
980        return if focused {
981            "‸".to_string()
982        } else {
983            "(empty)".to_string()
984        };
985    }
986    let base = input.display();
987    if !focused {
988        return base;
989    }
990    let mut chars: Vec<char> = base.chars().collect();
991    let pos = input.cursor.min(chars.len());
992    chars.insert(pos, '‸');
993    chars.into_iter().collect()
994}
995
996fn save_line(focused: bool, theme: &BubbleTheme) -> Line<'static> {
997    let style = if focused {
998        theme
999            .selected
1000            .add_modifier(Modifier::REVERSED | Modifier::BOLD)
1001    } else {
1002        theme.accent.add_modifier(Modifier::BOLD)
1003    };
1004    let marker = if focused { "▸ " } else { "  " };
1005    Line::from(vec![
1006        theme.span("   "),
1007        Span::styled(marker, theme.accent.add_modifier(Modifier::BOLD)),
1008        Span::styled("  Save  (Ctrl-S)  ", style),
1009    ])
1010}
1011
1012/// Center a rectangle of `percent_x * percent_y` over `r`.
1013fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1014    let popup_h = (r.height * percent_y) / 100;
1015    let popup_w = (r.width * percent_x) / 100;
1016    Rect {
1017        x: r.x + (r.width - popup_w) / 2,
1018        y: r.y + (r.height - popup_h) / 2,
1019        width: popup_w,
1020        height: popup_h,
1021    }
1022}
1023
1024// crossterm types live behind ratatui; re-exported here for handle_key callers.
1025pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
1026
1027#[cfg(test)]
1028mod tests {
1029    use super::*;
1030    use tempfile::TempDir;
1031
1032    fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
1033        crate::cache::closed_temp_file("config.toml", initial)
1034    }
1035
1036    fn key_index(id: VendorId) -> usize {
1037        KEY_VENDORS.iter().position(|kv| kv.id == id).unwrap()
1038    }
1039
1040    fn blank_state(primary: VendorId) -> SettingsState {
1041        SettingsState {
1042            focus: Focus::Primary,
1043            primary_choices: VendorId::all().to_vec(),
1044            primary,
1045            keys: KEY_VENDORS.iter().map(|_| KeyInput::default()).collect(),
1046            status: String::new(),
1047        }
1048    }
1049
1050    /// State with a Z.AI key and an OpenRouter key, both marked dirty.
1051    fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
1052        let mut s = blank_state(primary);
1053        s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some(zai));
1054        s.keys[key_index(VendorId::Zai)].dirty = true;
1055        s.keys[key_index(VendorId::Openrouter)] = KeyInput::from_config(Some(opr));
1056        s.keys[key_index(VendorId::Openrouter)].dirty = true;
1057        s
1058    }
1059
1060    #[test]
1061    fn focus_cycles_through_primary_all_keys_and_save() {
1062        let mut f = Focus::Primary;
1063        let mut seen = vec![f];
1064        // Full cycle = Primary + N key rows + Save.
1065        for _ in 0..(KEY_VENDORS.len() + 2) {
1066            f = f.next();
1067            seen.push(f);
1068        }
1069        // Primary, Key(0..n), Save, back to Primary.
1070        assert_eq!(seen.first(), Some(&Focus::Primary));
1071        assert_eq!(seen.last(), Some(&Focus::Primary));
1072        assert!(seen.contains(&Focus::Key(0)));
1073        assert!(seen.contains(&Focus::Key(KEY_VENDORS.len() - 1)));
1074        assert!(seen.contains(&Focus::Save));
1075        // prev() is the inverse of next().
1076        assert_eq!(Focus::Primary.next().prev(), Focus::Primary);
1077        assert_eq!(Focus::Save.prev().next(), Focus::Save);
1078        assert_eq!(Focus::Primary.prev(), Focus::Save);
1079    }
1080
1081    #[test]
1082    fn every_key_vendor_has_a_field() {
1083        // Every enabled-by-key vendor must be reachable in the form.
1084        for id in [
1085            VendorId::Zai,
1086            VendorId::Openrouter,
1087            VendorId::Deepseek,
1088            VendorId::Kilo,
1089            VendorId::Novita,
1090            VendorId::Moonshot,
1091            VendorId::Grok,
1092        ] {
1093            assert!(
1094                KEY_VENDORS.iter().any(|kv| kv.id == id),
1095                "{id:?} has no key field"
1096            );
1097        }
1098        // OAuth vendors are intentionally absent.
1099        assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Anthropic));
1100        assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Openai));
1101    }
1102
1103    #[test]
1104    fn from_config_prefills_existing_keys() {
1105        let mut cfg = Config::default();
1106        cfg.kilo.api_key = Some("sk-kilo".into());
1107        let s = SettingsState::from_config(&cfg);
1108        assert_eq!(s.keys[key_index(VendorId::Kilo)].buf, "sk-kilo");
1109        assert!(!s.keys[key_index(VendorId::Kilo)].dirty);
1110    }
1111
1112    #[test]
1113    fn copilot_has_no_editable_credential_field() {
1114        assert!(
1115            !KEY_VENDORS
1116                .iter()
1117                .any(|vendor| vendor.id == VendorId::Copilot)
1118        );
1119    }
1120
1121    /// The exported-env-var path is real behaviour and deserves a test of its
1122    /// own — just not one that reads the machine it runs on.
1123    #[test]
1124    fn a_key_vendor_with_its_env_var_exported_is_offered() {
1125        let cfg = Config::default();
1126        let env = cfg.api_key_env_for(VendorId::Ollama).to_string();
1127
1128        let without = SettingsState::from_config_with(&cfg, |_| false);
1129        assert!(!without.primary_choices.contains(&VendorId::Ollama));
1130
1131        let with = SettingsState::from_config_with(&cfg, |name| name == env);
1132        assert!(
1133            with.primary_choices.contains(&VendorId::Ollama),
1134            "a key vendor whose env var is exported must be selectable: {:?}",
1135            with.primary_choices
1136        );
1137    }
1138
1139    #[test]
1140    fn from_config_offers_enabled_vendors_only() {
1141        let cfg = Config::default();
1142        // No ambient environment: this must not depend on whether the machine
1143        // running `cargo test` happens to export OLLAMA_API_KEY or friends.
1144        let s = SettingsState::from_config_with(&cfg, |_| false);
1145        let mut expected = cfg.enabled_vendors();
1146        expected.push(VendorId::Copilot);
1147        assert_eq!(s.primary_choices, expected);
1148        // API-key opt-in vendors are disabled by default and must not be
1149        // offered. Copilot is the exception: choosing it enables it safely.
1150        assert!(!s.primary_choices.contains(&VendorId::Grok));
1151        assert!(s.primary_choices.contains(&s.primary));
1152        assert!(s.primary_choices.contains(&VendorId::Copilot));
1153    }
1154
1155    #[test]
1156    fn from_config_falls_back_when_configured_primary_is_disabled() {
1157        // Grok is opt-in; a config naming it as primary without enabling it
1158        // must display the first enabled vendor instead.
1159        let mut cfg = Config::default();
1160        cfg.ui.primary = Some(VendorId::Grok);
1161        let s = SettingsState::from_config(&cfg);
1162        assert_ne!(s.primary, VendorId::Grok);
1163        assert_eq!(Some(s.primary), cfg.enabled_vendors().first().copied());
1164    }
1165
1166    #[test]
1167    fn key_input_insert_backspace_arrow() {
1168        let mut k = KeyInput::default();
1169        k.insert_char('a');
1170        k.insert_char('b');
1171        k.insert_char('c');
1172        assert_eq!(k.buf, "abc");
1173        assert_eq!(k.cursor, 3);
1174        assert!(k.dirty);
1175        k.move_left();
1176        k.move_left();
1177        assert_eq!(k.cursor, 1);
1178        k.insert_char('x');
1179        assert_eq!(k.buf, "axbc");
1180        assert_eq!(k.cursor, 2);
1181        k.backspace();
1182        assert_eq!(k.buf, "abc");
1183        assert_eq!(k.cursor, 1);
1184    }
1185
1186    #[test]
1187    fn key_input_masks_by_default_reveals_on_toggle() {
1188        let mut k = KeyInput::default();
1189        for c in "secret-key".chars() {
1190            k.insert_char(c);
1191        }
1192        assert_eq!(k.display(), "•".repeat(10));
1193        k.toggle_reveal();
1194        assert_eq!(k.display(), "secret-key");
1195    }
1196
1197    #[test]
1198    fn key_input_handles_unicode() {
1199        let mut k = KeyInput::default();
1200        k.insert_char('a');
1201        k.insert_char('→');
1202        k.insert_char('b');
1203        assert_eq!(k.buf, "a→b");
1204        assert_eq!(k.cursor, 3);
1205        k.move_left();
1206        k.backspace();
1207        assert_eq!(k.buf, "ab");
1208    }
1209
1210    #[test]
1211    fn value_text_shows_cursor_and_empty_states() {
1212        let mut k = KeyInput::default();
1213        assert_eq!(value_text(&k, false), "(empty)");
1214        assert_eq!(value_text(&k, true), "‸");
1215        k.insert_char('a');
1216        k.insert_char('b');
1217        // masked + cursor at end
1218        assert_eq!(value_text(&k, true), "••‸");
1219        assert_eq!(value_text(&k, false), "••");
1220    }
1221
1222    #[test]
1223    fn save_writes_key_and_enables_vendor() {
1224        let (_dir, path) = temp_config(None);
1225        let mut s = blank_state(VendorId::Kilo);
1226        s.keys[key_index(VendorId::Kilo)] = KeyInput::from_config(Some("sk-kilo"));
1227        s.keys[key_index(VendorId::Kilo)].dirty = true;
1228        save_to_path(&s, &path).unwrap();
1229        let raw = std::fs::read_to_string(&path).unwrap();
1230        assert!(raw.contains("primary = \"kilo\""));
1231        assert!(raw.contains("[kilo]"));
1232        assert!(raw.contains("api_key = \"sk-kilo\""));
1233        assert!(raw.contains("enabled = true"));
1234    }
1235
1236    #[test]
1237    fn save_writes_minimal_toml_when_starting_empty() {
1238        let (_dir, path) = temp_config(None);
1239        let s = state_with("zk", "ok", VendorId::Zai);
1240        save_to_path(&s, &path).unwrap();
1241        let raw = std::fs::read_to_string(&path).unwrap();
1242        assert!(raw.contains("primary = \"zai\""));
1243        assert!(raw.contains("[zai]"));
1244        assert!(raw.contains("api_key = \"zk\""));
1245        assert!(raw.contains("[openrouter]"));
1246        assert!(raw.contains("api_key = \"ok\""));
1247    }
1248
1249    #[test]
1250    fn save_preserves_existing_comments_and_unrelated_fields() {
1251        let (_dir, path) = temp_config(Some(
1252            r##"# my comment
1253[ui]
1254# pre-existing comment
1255primary = "anthropic"
1256
1257[zai]
1258enabled = true
1259api_key_env = "ZAI_API_KEY"
1260# tier comment
1261plan_tier = "pro"
1262
1263[openrouter]
1264enabled = true
1265api_key_env = "OPENROUTER_API_KEY"
1266
1267[[openrouter.accounts]]
1268label = "work"
1269api_key_env = "OPENROUTER_WORK_API_KEY"
1270"##,
1271        ));
1272
1273        let s = state_with("zk2", "ok2", VendorId::Openrouter);
1274        save_to_path(&s, &path).unwrap();
1275
1276        let raw = std::fs::read_to_string(&path).unwrap();
1277        assert!(raw.contains("# my comment"));
1278        assert!(raw.contains("# pre-existing comment"));
1279        assert!(raw.contains("# tier comment"));
1280        assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
1281        assert!(raw.contains("[[openrouter.accounts]]"));
1282        assert!(raw.contains("api_key_env = \"OPENROUTER_WORK_API_KEY\""));
1283        assert!(raw.contains("plan_tier = \"pro\""));
1284        assert!(raw.contains("primary = \"openrouter\""));
1285        assert!(raw.contains("api_key = \"zk2\""));
1286        assert!(raw.contains("api_key = \"ok2\""));
1287    }
1288
1289    #[test]
1290    fn save_refuses_to_replace_an_unreadable_existing_config() {
1291        let (_dir, path) = temp_config(None);
1292        let original = [0xff, 0xfe, 0xfd];
1293        std::fs::write(&path, original).unwrap();
1294        let state = state_with("new-secret", "", VendorId::Zai);
1295
1296        assert!(save_to_path(&state, &path).is_err());
1297        assert_eq!(std::fs::read(&path).unwrap(), original);
1298    }
1299
1300    #[test]
1301    fn save_does_not_write_empty_key_when_dirty_but_blank() {
1302        let (_dir, path) = temp_config(None);
1303        let mut s = blank_state(VendorId::Anthropic);
1304        // Focus each key, do nothing but mark dirty (blank).
1305        for k in &mut s.keys {
1306            k.dirty = true;
1307        }
1308        save_to_path(&s, &path).unwrap();
1309        let raw = std::fs::read_to_string(&path).unwrap();
1310        assert!(!raw.contains("api_key ="));
1311    }
1312
1313    #[test]
1314    #[cfg(unix)]
1315    fn save_chmods_to_600() {
1316        use std::os::unix::fs::PermissionsExt;
1317        let (_dir, path) = temp_config(None);
1318        let s = state_with("zk", "ok", VendorId::Zai);
1319        save_to_path(&s, &path).unwrap();
1320        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1321        assert_eq!(mode & 0o777, 0o600);
1322    }
1323
1324    #[test]
1325    fn tab_cycles_focus_from_primary_to_first_key() {
1326        let mut s = blank_state(VendorId::Anthropic);
1327        assert_eq!(
1328            handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
1329            Action::Continue
1330        );
1331        assert_eq!(s.focus, Focus::Key(0));
1332        assert_eq!(
1333            handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
1334            Action::Continue
1335        );
1336        assert_eq!(s.focus, Focus::Primary);
1337    }
1338
1339    #[test]
1340    fn esc_closes_without_saving() {
1341        let mut s = blank_state(VendorId::Anthropic);
1342        assert_eq!(
1343            handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
1344            Action::Close
1345        );
1346    }
1347
1348    #[test]
1349    fn left_right_cycles_primary_vendor() {
1350        // Canonical order (VendorId::all): Anthropic, AnthropicApi, Openai, …
1351        let mut s = blank_state(VendorId::Anthropic);
1352        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1353        assert_eq!(s.primary, VendorId::AnthropicApi);
1354        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1355        assert_eq!(s.primary, VendorId::Openai);
1356        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1357        assert_eq!(s.primary, VendorId::AnthropicApi);
1358    }
1359
1360    #[test]
1361    fn left_right_offers_enabled_vendors_only() {
1362        // The selector must never land on a vendor the widget cannot use.
1363        let mut s = blank_state(VendorId::Anthropic);
1364        s.primary_choices = vec![VendorId::Anthropic, VendorId::Grok];
1365        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1366        assert_eq!(s.primary, VendorId::Grok);
1367        // Wraps within the enabled set rather than walking into disabled ones.
1368        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1369        assert_eq!(s.primary, VendorId::Anthropic);
1370        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1371        assert_eq!(s.primary, VendorId::Grok);
1372    }
1373
1374    #[test]
1375    fn no_enabled_vendors_leaves_primary_selector_inert() {
1376        let mut s = blank_state(VendorId::Anthropic);
1377        s.primary_choices = vec![];
1378        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1379        assert_eq!(s.primary, VendorId::Anthropic);
1380    }
1381
1382    #[test]
1383    fn disabled_copilot_is_offered_but_not_shown_as_the_current_primary() {
1384        let mut cfg = Config::default();
1385        cfg.ui.primary = Some(VendorId::Copilot);
1386        let state = SettingsState::from_config(&cfg);
1387
1388        assert!(state.primary_choices.contains(&VendorId::Copilot));
1389        assert_eq!(state.primary, VendorId::Anthropic);
1390    }
1391
1392    #[test]
1393    fn save_does_not_write_a_disabled_primary() {
1394        // Saving an API key must not persist a primary the resolver would
1395        // ignore; an existing value in the file stays untouched.
1396        let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
1397        let mut s = state_with("zk", "ok", VendorId::Grok);
1398        s.primary_choices = vec![VendorId::Anthropic];
1399        save_to_path(&s, &path).unwrap();
1400        let raw = std::fs::read_to_string(&path).unwrap();
1401        assert!(raw.contains("primary = \"anthropic\""));
1402        assert!(!raw.contains("primary = \"grok\""));
1403        // The keys still saved.
1404        assert!(raw.contains("zk"));
1405    }
1406
1407    #[test]
1408    fn save_removes_an_inline_key_the_user_cleared() {
1409        // Clearing the field in the overlay must delete the secret from the
1410        // file — otherwise there is no way to remove it short of hand-editing.
1411        let (_dir, path) = temp_config(Some(
1412            "[zai]\nenabled = true\napi_key = \"old-secret\"\nplan_tier = \"pro\"\n",
1413        ));
1414        let mut s = blank_state(VendorId::Zai);
1415        s.primary_choices = vec![VendorId::Zai];
1416        s.keys[key_index(VendorId::Zai)] = KeyInput::default();
1417        s.keys[key_index(VendorId::Zai)].dirty = true;
1418        save_to_path(&s, &path).unwrap();
1419        let raw = std::fs::read_to_string(&path).unwrap();
1420        assert!(!raw.contains("old-secret"));
1421        assert!(!raw.contains("api_key"));
1422        // Unrelated fields in the same section survive.
1423        assert!(raw.contains("plan_tier = \"pro\""));
1424    }
1425
1426    #[test]
1427    fn untouched_key_field_is_left_alone() {
1428        // Not dirty => the file's existing secret must survive a save.
1429        let (_dir, path) = temp_config(Some("[zai]\napi_key = \"keep-me\"\n"));
1430        let mut s = blank_state(VendorId::Zai);
1431        s.primary_choices = vec![VendorId::Zai];
1432        save_to_path(&s, &path).unwrap();
1433        let raw = std::fs::read_to_string(&path).unwrap();
1434        assert!(raw.contains("keep-me"));
1435    }
1436
1437    #[test]
1438    fn typing_edits_the_focused_key_only() {
1439        let mut s = blank_state(VendorId::Anthropic);
1440        s.focus = Focus::Key(key_index(VendorId::Grok));
1441        for c in "xai-abc".chars() {
1442            handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
1443        }
1444        assert_eq!(s.keys[key_index(VendorId::Grok)].buf, "xai-abc");
1445        assert!(s.keys[key_index(VendorId::Grok)].dirty);
1446        // No other field was touched.
1447        assert!(s.keys[key_index(VendorId::Zai)].buf.is_empty());
1448    }
1449
1450    #[test]
1451    fn ctrl_v_toggles_reveal_on_focused_key_field() {
1452        let mut s = blank_state(VendorId::Anthropic);
1453        let zi = key_index(VendorId::Zai);
1454        s.focus = Focus::Key(zi);
1455        s.keys[zi] = KeyInput::from_config(Some("secret"));
1456        assert!(!s.keys[zi].revealed);
1457        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1458        assert!(s.keys[zi].revealed);
1459        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1460        assert!(!s.keys[zi].revealed);
1461    }
1462
1463    #[test]
1464    fn control_chorded_chars_do_not_type_into_fields() {
1465        let mut s = blank_state(VendorId::Anthropic);
1466        s.focus = Focus::Key(0);
1467        // Ctrl-A must NOT insert a literal 'a' or mark the field dirty.
1468        handle_key(&mut s, KeyCode::Char('a'), KeyModifiers::CONTROL);
1469        assert!(s.keys[0].buf.is_empty());
1470        assert!(!s.keys[0].dirty);
1471        // Ctrl-C quits the host TUI even while the overlay owns focus.
1472        assert_eq!(
1473            handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
1474            Action::Quit
1475        );
1476        // A plain char still types normally.
1477        handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
1478        assert_eq!(s.keys[0].buf, "x");
1479    }
1480
1481    #[test]
1482    fn ctrl_v_on_non_key_focus_is_noop() {
1483        let mut s = blank_state(VendorId::Anthropic);
1484        s.focus = Focus::Primary;
1485        // Must not panic when no key field is focused.
1486        assert_eq!(
1487            handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL),
1488            Action::Continue
1489        );
1490    }
1491
1492    fn state_focused_on_zai() -> SettingsState {
1493        let mut state = blank_state(VendorId::Anthropic);
1494        state.focus = Focus::Key(key_index(VendorId::Zai));
1495        state
1496    }
1497
1498    #[test]
1499    fn handle_key_ctrl_c_quits_without_typing_into_key_field() {
1500        let mut s = state_focused_on_zai();
1501        let zi = key_index(VendorId::Zai);
1502        assert_eq!(
1503            handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
1504            Action::Quit
1505        );
1506        assert!(s.keys[zi].buf.is_empty());
1507        // Untouched means save still leaves an existing key on disk alone.
1508        assert!(!s.keys[zi].dirty);
1509    }
1510
1511    #[test]
1512    fn handle_key_alt_chord_does_not_type_into_key_field() {
1513        let mut s = state_focused_on_zai();
1514        let zi = key_index(VendorId::Zai);
1515        handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::ALT);
1516        assert!(s.keys[zi].buf.is_empty());
1517        assert!(!s.keys[zi].dirty);
1518    }
1519
1520    #[test]
1521    fn handle_key_platform_modifier_chords_do_not_type_into_key_field() {
1522        for modifier in [KeyModifiers::SUPER, KeyModifiers::HYPER, KeyModifiers::META] {
1523            let mut s = state_focused_on_zai();
1524            let zi = key_index(VendorId::Zai);
1525            handle_key(&mut s, KeyCode::Char('x'), modifier);
1526            assert!(s.keys[zi].buf.is_empty(), "modifier {modifier:?}");
1527            assert!(!s.keys[zi].dirty, "modifier {modifier:?}");
1528        }
1529    }
1530
1531    #[test]
1532    fn handle_key_shift_still_types_uppercase() {
1533        let mut s = state_focused_on_zai();
1534        let zi = key_index(VendorId::Zai);
1535        handle_key(&mut s, KeyCode::Char('A'), KeyModifiers::SHIFT);
1536        assert_eq!(s.keys[zi].buf, "A");
1537        assert!(s.keys[zi].dirty);
1538    }
1539
1540    #[test]
1541    fn handle_key_plain_space_still_cycles_primary_vendor() {
1542        let mut s = blank_state(VendorId::Anthropic);
1543        handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
1544        assert_eq!(s.primary, VendorId::AnthropicApi);
1545    }
1546
1547    #[test]
1548    fn handle_key_ctrl_s_attempts_save_from_any_field() {
1549        let (_dir, path) = temp_config(None);
1550        let s = state_with("zk", "ok", VendorId::Zai);
1551        save_to_path(&s, &path).unwrap();
1552        let raw = std::fs::read_to_string(&path).unwrap();
1553        assert!(raw.contains("api_key = \"zk\""));
1554    }
1555    #[test]
1556    fn save_to_path_writes_kimi_key_when_dirty() {
1557        let (_dir, path) = temp_config(None);
1558        let mut s = blank_state(VendorId::Anthropic);
1559        let kimi = key_index(VendorId::Kimi);
1560        s.keys[kimi] = KeyInput::from_config(Some("kk"));
1561        s.keys[kimi].dirty = true;
1562        save_to_path(&s, &path).unwrap();
1563        let raw = std::fs::read_to_string(&path).unwrap();
1564        assert!(raw.contains("[kimi]"));
1565        assert!(raw.contains("api_key = \"kk\""));
1566    }
1567
1568    #[test]
1569    fn settings_save_uses_the_same_config_path_as_load() {
1570        assert_eq!(
1571            default_config_path().unwrap(),
1572            crate::config::resolved_path().unwrap()
1573        );
1574    }
1575
1576    #[test]
1577    fn native_snapshot_reports_key_state_without_serializing_secrets() {
1578        let mut cfg = Config::default();
1579        cfg.zai.api_key = Some("never-leak-this-key".into());
1580        cfg.zai.api_key_env = "CUSTOM_ZAI_KEY".into();
1581        let raw = settings_snapshot_json_with(&cfg, |name| name == "CUSTOM_ZAI_KEY").unwrap();
1582        let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
1583
1584        assert_eq!(parsed["schema_version"], 1);
1585        assert_eq!(parsed["primary"], "anthropic");
1586        let zai = parsed["keys"]
1587            .as_array()
1588            .unwrap()
1589            .iter()
1590            .find(|row| row["id"] == "zai")
1591            .unwrap();
1592        assert_eq!(zai["configured"], true);
1593        assert_eq!(zai["inline_configured"], true);
1594        assert_eq!(zai["environment_configured"], true);
1595        assert_eq!(zai["environment"], "CUSTOM_ZAI_KEY");
1596        assert!(!raw.contains("never-leak-this-key"));
1597        assert!(parsed.get("api_key").is_none());
1598    }
1599
1600    #[test]
1601    fn native_snapshot_offers_copilot_primary_without_a_token_field() {
1602        let cfg = Config::default();
1603        let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
1604        let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
1605        assert!(
1606            parsed["primary_choices"]
1607                .as_array()
1608                .unwrap()
1609                .iter()
1610                .any(|row| row["id"] == "copilot")
1611        );
1612        assert!(
1613            !parsed["keys"]
1614                .as_array()
1615                .unwrap()
1616                .iter()
1617                .any(|row| row["id"] == "copilot")
1618        );
1619    }
1620
1621    #[test]
1622    fn native_key_only_patch_does_not_require_or_replace_primary() {
1623        let cfg = Config::default();
1624        let original_primary = SettingsState::from_config(&cfg).primary;
1625        let request = serde_json::json!({
1626            "schema_version": 1,
1627            "keys": {"kimi": {"action": "set", "value": "new-kimi-key"}}
1628        });
1629
1630        let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
1631        assert_eq!(state.primary, original_primary);
1632        let kimi_index = KEY_VENDORS
1633            .iter()
1634            .position(|vendor| vendor.id == VendorId::Kimi)
1635            .unwrap();
1636        assert!(state.keys[kimi_index].dirty);
1637        assert_eq!(state.keys[kimi_index].buf, "new-kimi-key");
1638    }
1639
1640    #[test]
1641    fn native_patch_reuses_tui_persistence_and_preserves_existing_config() {
1642        let (_dir, path) = temp_config(Some(
1643            r#"# keep this comment
1644[ui]
1645primary = "anthropic"
1646
1647[zai]
1648enabled = true
1649api_key_env = "ZAI_API_KEY"
1650plan_tier = "pro"
1651
1652[openrouter]
1653enabled = true
1654"#,
1655        ));
1656        let cfg = Config::load_from(&path).unwrap();
1657        let request = serde_json::json!({
1658            "schema_version": 1,
1659            "primary": "openrouter",
1660            "keys": {
1661                "zai": {"action": "set", "value": "new-zai-key"}
1662            }
1663        });
1664
1665        apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
1666        let raw = std::fs::read_to_string(&path).unwrap();
1667        assert!(raw.contains("# keep this comment"));
1668        assert!(raw.contains("plan_tier = \"pro\""));
1669        assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
1670        assert!(raw.contains("primary = \"openrouter\""));
1671        assert!(raw.contains("api_key = \"new-zai-key\""));
1672    }
1673
1674    #[test]
1675    fn native_patch_distinguishes_clear_from_unchanged() {
1676        let (_dir, path) = temp_config(Some(
1677            "[zai]\nenabled = true\napi_key = \"remove-me\"\n\
1678             [openrouter]\nenabled = true\napi_key = \"keep-me\"\n",
1679        ));
1680        let cfg = Config::load_from(&path).unwrap();
1681        let request = serde_json::json!({
1682            "schema_version": 1,
1683            "primary": "zai",
1684            "keys": {"zai": {"action": "clear"}}
1685        });
1686
1687        apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
1688        let raw = std::fs::read_to_string(&path).unwrap();
1689        assert!(!raw.contains("remove-me"));
1690        assert!(raw.contains("keep-me"));
1691    }
1692
1693    #[test]
1694    fn native_primary_selection_enables_copilot_without_writing_a_token() {
1695        let (_dir, path) = temp_config(Some(
1696            "[copilot]\nenabled = false\ntoken = \"legacy-value\"\ntoken_env = \"OLD_TOKEN\"\n",
1697        ));
1698        let cfg = Config::load_from(&path).unwrap();
1699        let select = serde_json::json!({
1700            "schema_version": 1,
1701            "primary": "copilot"
1702        });
1703        apply_settings_json_to_path(&cfg, &select.to_string(), &path).unwrap();
1704        let raw = std::fs::read_to_string(&path).unwrap();
1705        assert!(raw.contains("enabled = true"));
1706        assert!(raw.contains("primary = \"copilot\""));
1707        assert!(!raw.contains("token ="));
1708        assert!(!raw.contains("token_env ="));
1709    }
1710
1711    #[test]
1712    fn native_patch_errors_never_echo_key_values() {
1713        let raw = serde_json::json!({
1714            "schema_version": 1,
1715            "primary": "anthropic",
1716            "keys": {
1717                "zai": {"action": "set", "value": "secret\nwith-control"}
1718            }
1719        })
1720        .to_string();
1721        let error = state_from_apply_request(&Config::default(), &raw)
1722            .unwrap_err()
1723            .to_string();
1724        assert!(!error.contains("secret"));
1725        assert!(error.contains("control characters"));
1726    }
1727
1728    #[test]
1729    fn native_patch_input_is_bounded_before_json_parsing() {
1730        let oversized = vec![b'x'; MAX_SETTINGS_REQUEST_BYTES as usize + 1];
1731        let error = read_settings_request(std::io::Cursor::new(oversized))
1732            .unwrap_err()
1733            .to_string();
1734        assert!(error.contains("exceeds"));
1735    }
1736}