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