Skip to main content

ai_usagebar/tui/
settings.rs

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