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