Skip to main content

ai_usagebar/tui/
settings.rs

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