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