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