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