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