Skip to main content

ai_usagebar/tui/
settings.rs

1//! Settings overlay — opened from the TUI by pressing `s`. Lets the user
2//! pick the primary vendor and set Z.AI / OpenRouter / DeepSeek / Kimi API keys
3//! without editing config.toml by hand.
4//!
5//! Persistence uses `toml_edit` so the existing config keeps its comments,
6//! whitespace, and unrelated fields. Files with inline keys are atomically
7//! written and `chmod 600`ed.
8
9use std::path::{Path, PathBuf};
10
11use ratatui::Frame;
12use ratatui::layout::{Constraint, Direction, Layout, Rect};
13use ratatui::style::Modifier;
14use ratatui::text::{Line, Span};
15use ratatui::widgets::{Clear, Paragraph};
16use ratatui_bubbletea_theme::BubbleTheme;
17use toml_edit::{DocumentMut, value};
18
19use crate::config::Config;
20use crate::error::{AppError, Result};
21use crate::theme::Theme;
22use crate::tui::style::bubble_theme;
23use crate::vendor::VendorId;
24
25/// Which input field has keyboard focus.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Focus {
28    Primary,
29    ZaiKey,
30    OpenrouterKey,
31    DeepseekKey,
32    KimiKey,
33    SaveButton,
34}
35
36impl Focus {
37    pub fn next(self) -> Self {
38        match self {
39            Focus::Primary => Focus::ZaiKey,
40            Focus::ZaiKey => Focus::OpenrouterKey,
41            Focus::OpenrouterKey => Focus::DeepseekKey,
42            Focus::DeepseekKey => Focus::KimiKey,
43            Focus::KimiKey => Focus::SaveButton,
44            Focus::SaveButton => Focus::Primary,
45        }
46    }
47    pub fn prev(self) -> Self {
48        match self {
49            Focus::Primary => Focus::SaveButton,
50            Focus::ZaiKey => Focus::Primary,
51            Focus::OpenrouterKey => Focus::ZaiKey,
52            Focus::DeepseekKey => Focus::OpenrouterKey,
53            Focus::KimiKey => Focus::DeepseekKey,
54            Focus::SaveButton => Focus::KimiKey,
55        }
56    }
57}
58
59/// Per-field text-input state — cursor + buffer + reveal flag.
60#[derive(Debug, Clone, Default)]
61pub struct KeyInput {
62    pub buf: String,
63    /// Char-index cursor position (0..=buf.chars().count()).
64    pub cursor: usize,
65    /// When true, the field renders the actual characters; otherwise `•`.
66    pub revealed: bool,
67    /// True after the user has typed/edited; only then does save write
68    /// the value back (avoids clobbering an existing key with the empty
69    /// placeholder the user opened the dialog with).
70    pub dirty: bool,
71}
72
73impl KeyInput {
74    pub fn from_config(initial: Option<&str>) -> Self {
75        let buf = initial.unwrap_or("").to_string();
76        let cursor = buf.chars().count();
77        Self {
78            buf,
79            cursor,
80            revealed: false,
81            dirty: false,
82        }
83    }
84
85    pub fn insert_char(&mut self, c: char) {
86        let byte_idx = self.char_to_byte(self.cursor);
87        self.buf.insert(byte_idx, c);
88        self.cursor += 1;
89        self.dirty = true;
90    }
91
92    pub fn backspace(&mut self) {
93        if self.cursor == 0 {
94            return;
95        }
96        let prev_byte = self.char_to_byte(self.cursor - 1);
97        let cur_byte = self.char_to_byte(self.cursor);
98        self.buf.replace_range(prev_byte..cur_byte, "");
99        self.cursor -= 1;
100        self.dirty = true;
101    }
102
103    pub fn delete(&mut self) {
104        let n = self.buf.chars().count();
105        if self.cursor >= n {
106            return;
107        }
108        let cur_byte = self.char_to_byte(self.cursor);
109        let next_byte = self.char_to_byte(self.cursor + 1);
110        self.buf.replace_range(cur_byte..next_byte, "");
111        self.dirty = true;
112    }
113
114    pub fn move_left(&mut self) {
115        if self.cursor > 0 {
116            self.cursor -= 1;
117        }
118    }
119    pub fn move_right(&mut self) {
120        if self.cursor < self.buf.chars().count() {
121            self.cursor += 1;
122        }
123    }
124    pub fn move_home(&mut self) {
125        self.cursor = 0;
126    }
127    pub fn move_end(&mut self) {
128        self.cursor = self.buf.chars().count();
129    }
130    pub fn toggle_reveal(&mut self) {
131        self.revealed = !self.revealed;
132    }
133
134    /// Render for display — bullets when masked, raw chars when revealed.
135    pub fn display(&self) -> String {
136        if self.revealed {
137            self.buf.clone()
138        } else {
139            "•".repeat(self.buf.chars().count())
140        }
141    }
142
143    fn char_to_byte(&self, char_idx: usize) -> usize {
144        self.buf
145            .char_indices()
146            .map(|(b, _)| b)
147            .chain(std::iter::once(self.buf.len()))
148            .nth(char_idx)
149            .unwrap_or(self.buf.len())
150    }
151}
152
153/// Mutable state of the overlay while open.
154#[derive(Debug, Clone)]
155pub struct SettingsState {
156    pub focus: Focus,
157    /// Enabled vendors only. The primary selector must not offer a value that
158    /// cannot actually be used by the widget or TUI.
159    pub primary_choices: Vec<VendorId>,
160    pub primary: VendorId,
161    pub zai: KeyInput,
162    pub openrouter: KeyInput,
163    pub deepseek: KeyInput,
164    pub kimi: KeyInput,
165    /// One-line status displayed in the footer ("Saved", "Error: ...", "").
166    pub status: String,
167}
168
169impl SettingsState {
170    pub fn from_config(cfg: &Config) -> Self {
171        let primary_choices = cfg.enabled_vendors();
172        // A configured but disabled primary is ineffective. Display the first
173        // enabled vendor instead; when none are enabled retain the historical
174        // Anthropic fallback in memory without inventing a persisted primary.
175        let primary = cfg
176            .ui
177            .primary
178            .filter(|vendor| primary_choices.contains(vendor))
179            .or_else(|| primary_choices.first().copied())
180            .unwrap_or_else(|| cfg.ui.primary.unwrap_or(VendorId::Anthropic));
181        Self {
182            focus: Focus::Primary,
183            primary_choices,
184            primary,
185            zai: KeyInput::from_config(cfg.zai.api_key.as_deref()),
186            openrouter: KeyInput::from_config(cfg.openrouter.api_key.as_deref()),
187            deepseek: KeyInput::from_config(cfg.deepseek.api_key.as_deref()),
188            kimi: KeyInput::from_config(cfg.kimi.api_key.as_deref()),
189            status: String::new(),
190        }
191    }
192}
193
194/// What the key handler asks the host app to do next.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum Action {
197    /// Stay open, keep listening for keys.
198    Continue,
199    /// Close the overlay (discard or save already happened).
200    Close,
201    /// Save just succeeded — caller should refresh affected vendors.
202    SavedAndClose,
203}
204
205/// Permission note appended to the "saved" status line. The overlay `chmod
206/// 600`s the file on Unix; Windows has no such step, so the note is empty there
207/// (keeps the message platform-honest).
208#[cfg(unix)]
209const PERMS_NOTE: &str = " (chmod 600)";
210#[cfg(not(unix))]
211const PERMS_NOTE: &str = "";
212
213/// Status line after a successful save: the platform-resolved config path plus
214/// the platform-appropriate permission note.
215fn saved_status() -> String {
216    format!(
217        "saved to {}{}",
218        crate::config::config_path_hint(),
219        PERMS_NOTE
220    )
221}
222
223/// Key map. Returns the action to perform after the keypress.
224pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
225    // Esc always closes without saving.
226    if matches!(code, KeyCode::Esc) {
227        return Action::Close;
228    }
229    // Ctrl-S triggers save from any field.
230    if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
231        return match save_to_config_default(state) {
232            Ok(()) => {
233                state.status = saved_status();
234                Action::SavedAndClose
235            }
236            Err(e) => {
237                state.status = format!("save failed: {e}");
238                Action::Continue
239            }
240        };
241    }
242    // Ctrl-V toggles reveal on the focused key field.
243    if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
244        match state.focus {
245            Focus::ZaiKey => state.zai.toggle_reveal(),
246            Focus::OpenrouterKey => state.openrouter.toggle_reveal(),
247            Focus::DeepseekKey => state.deepseek.toggle_reveal(),
248            Focus::KimiKey => state.kimi.toggle_reveal(),
249            _ => {}
250        }
251        return Action::Continue;
252    }
253
254    // Field navigation: Tab/Shift-Tab and Up/Down.
255    match code {
256        KeyCode::Tab => {
257            state.focus = state.focus.next();
258            return Action::Continue;
259        }
260        KeyCode::BackTab => {
261            state.focus = state.focus.prev();
262            return Action::Continue;
263        }
264        KeyCode::Down => {
265            state.focus = state.focus.next();
266            return Action::Continue;
267        }
268        KeyCode::Up => {
269            state.focus = state.focus.prev();
270            return Action::Continue;
271        }
272        _ => {}
273    }
274
275    // Field-specific handling.
276    match state.focus {
277        Focus::Primary => handle_primary(state, code),
278        Focus::ZaiKey => handle_input(&mut state.zai, code),
279        Focus::OpenrouterKey => handle_input(&mut state.openrouter, code),
280        Focus::DeepseekKey => handle_input(&mut state.deepseek, code),
281        Focus::KimiKey => handle_input(&mut state.kimi, code),
282        Focus::SaveButton => {
283            if matches!(code, KeyCode::Enter) {
284                return match save_to_config_default(state) {
285                    Ok(()) => {
286                        state.status = saved_status();
287                        Action::SavedAndClose
288                    }
289                    Err(e) => {
290                        state.status = format!("save failed: {e}");
291                        Action::Continue
292                    }
293                };
294            }
295        }
296    }
297    Action::Continue
298}
299
300fn handle_primary(state: &mut SettingsState, code: KeyCode) {
301    // Left/Right cycles the primary-vendor radio.
302    let choices = &state.primary_choices;
303    let Some(idx) = choices.iter().position(|v| *v == state.primary) else {
304        return;
305    };
306    let step = match code {
307        KeyCode::Left => -1,
308        KeyCode::Right | KeyCode::Char(' ') => 1,
309        _ => return,
310    };
311    state.primary = choices[((idx as i32 + step).rem_euclid(choices.len() as i32)) as usize];
312}
313
314fn handle_input(input: &mut KeyInput, code: KeyCode) {
315    match code {
316        KeyCode::Char(c) => input.insert_char(c),
317        KeyCode::Backspace => input.backspace(),
318        KeyCode::Delete => input.delete(),
319        KeyCode::Left => input.move_left(),
320        KeyCode::Right => input.move_right(),
321        KeyCode::Home => input.move_home(),
322        KeyCode::End => input.move_end(),
323        _ => {}
324    }
325}
326
327/// Save to `~/.config/ai-usagebar/config.toml` (or create it). On success,
328/// signal a running Waybar process (SIGRTMIN+13) so any module configured
329/// with `signal: 13` refreshes its exec output immediately — otherwise the
330/// bar text wouldn't reflect a new primary vendor until the next interval
331/// tick (up to 300s).
332fn save_to_config_default(state: &SettingsState) -> Result<()> {
333    let path = default_config_path()?;
334    if let Some(parent) = path.parent() {
335        std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
336    }
337    save_to_path(state, &path)?;
338    crate::waybar::request_refresh();
339    Ok(())
340}
341
342/// Same as `save_to_config_default` but with an explicit path — exposed
343/// for tests.
344pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
345    let original = std::fs::read_to_string(path).unwrap_or_default();
346    let mut doc: DocumentMut = if original.trim().is_empty() {
347        DocumentMut::new()
348    } else {
349        original.parse().map_err(|e: toml_edit::TomlError| {
350            AppError::Other(format!("config.toml not parseable: {e}"))
351        })?
352    };
353
354    // Do not write a disabled primary as a side effect of saving an API key.
355    // With no enabled vendors, leave any existing value alone so the legacy
356    // resolver's Anthropic fallback remains intact.
357    if state.primary_choices.contains(&state.primary) {
358        set_string(&mut doc, "ui", "primary", state.primary.slug())?;
359    }
360    update_key(&mut doc, "zai", &state.zai)?;
361    update_key(&mut doc, "openrouter", &state.openrouter)?;
362    update_key(&mut doc, "deepseek", &state.deepseek)?;
363    update_key(&mut doc, "kimi", &state.kimi)?;
364
365    let bytes = doc.to_string();
366    crate::cache::atomic_write(path, bytes.as_bytes())?;
367
368    // chmod 600 — only required when we wrote a secret, but always safe.
369    #[cfg(unix)]
370    {
371        use std::os::unix::fs::PermissionsExt;
372        if let Ok(meta) = std::fs::metadata(path) {
373            let mut perms = meta.permissions();
374            perms.set_mode(0o600);
375            let _ = std::fs::set_permissions(path, perms);
376        }
377    }
378    Ok(())
379}
380
381/// Set or update a string field in a TOML section, preserving comments and
382/// formatting of unaffected nodes. When the key already exists, we mutate its
383/// value in place (this keeps the leading comment attached to the key);
384/// otherwise we insert a new entry.
385fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
386    let table = doc
387        .entry(section)
388        .or_insert_with(toml_edit::table)
389        .as_table_mut()
390        .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
391
392    if let Some(item) = table.get_mut(key)
393        && let Some(v) = item.as_value_mut()
394    {
395        *v = toml_edit::Value::from(new_value);
396        // Restore the surrounding decor (a space before `=` and after the
397        // value, matching toml_edit's default output).
398        v.decor_mut().set_prefix(" ");
399        return Ok(());
400    }
401    table.insert(key, value(new_value));
402    Ok(())
403}
404
405/// Persist an intentionally edited key. A dirty empty buffer means the user
406/// explicitly cleared the key; an untouched empty buffer leaves TOML intact.
407fn update_key(doc: &mut DocumentMut, section: &str, input: &KeyInput) -> Result<()> {
408    if !input.dirty {
409        return Ok(());
410    }
411    if input.buf.is_empty() {
412        if let Some(table) = doc.get_mut(section).and_then(toml_edit::Item::as_table_mut) {
413            table.remove("api_key");
414        }
415        return Ok(());
416    }
417    set_string(doc, section, "api_key", &input.buf)
418}
419
420fn default_config_path() -> Result<PathBuf> {
421    crate::config::default_path()
422        .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
423}
424
425/// Render the modal overlay over `area`.
426pub fn render(f: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
427    let modal = settings_modal_rect(area);
428    // Clear underneath so the body is unreadable through us.
429    f.render_widget(Clear, modal);
430
431    let bubble = bubble_theme(theme);
432
433    let block = bubble.titled_modal_block(" Settings ");
434    let inner = block.inner(modal);
435    f.render_widget(block, modal);
436
437    let chunks = Layout::default()
438        .direction(Direction::Vertical)
439        .constraints([
440            Constraint::Length(1), // [0] primary label
441            Constraint::Length(2), // [1] primary radio row
442            Constraint::Length(1), // [2] spacer
443            Constraint::Length(1), // [3] zai label
444            Constraint::Length(2), // [4] zai input
445            Constraint::Length(1), // [5] openrouter label
446            Constraint::Length(2), // [6] openrouter input
447            Constraint::Length(1), // [7] deepseek label
448            Constraint::Length(2), // [8] deepseek input
449            Constraint::Length(1), // [9] kimi label
450            Constraint::Length(2), // [10] kimi input
451            Constraint::Length(1), // [11] spacer
452            Constraint::Length(1), // [12] save button
453            Constraint::Length(1), // [13] status
454            Constraint::Min(0),    // [14] hint
455        ])
456        .split(inner);
457
458    // Primary vendor.
459    f.render_widget(
460        Paragraph::new(label(
461            "Primary vendor",
462            state.focus == Focus::Primary,
463            &bubble,
464        )),
465        chunks[0],
466    );
467    f.render_widget(
468        Paragraph::new(render_radio(
469            &state.primary_choices,
470            &state.primary,
471            &bubble,
472        )),
473        chunks[1],
474    );
475
476    // Z.AI key.
477    f.render_widget(
478        Paragraph::new(label(
479            "Z.AI API key (environment key takes precedence)",
480            state.focus == Focus::ZaiKey,
481            &bubble,
482        )),
483        chunks[3],
484    );
485    f.render_widget(
486        Paragraph::new(render_input(
487            &state.zai,
488            state.focus == Focus::ZaiKey,
489            &bubble,
490        )),
491        chunks[4],
492    );
493
494    // OpenRouter key.
495    f.render_widget(
496        Paragraph::new(label(
497            "OpenRouter API key (environment key takes precedence)",
498            state.focus == Focus::OpenrouterKey,
499            &bubble,
500        )),
501        chunks[5],
502    );
503    f.render_widget(
504        Paragraph::new(render_input(
505            &state.openrouter,
506            state.focus == Focus::OpenrouterKey,
507            &bubble,
508        )),
509        chunks[6],
510    );
511
512    // DeepSeek key.
513    f.render_widget(
514        Paragraph::new(label(
515            "DeepSeek API key (environment key takes precedence)",
516            state.focus == Focus::DeepseekKey,
517            &bubble,
518        )),
519        chunks[7],
520    );
521    f.render_widget(
522        Paragraph::new(render_input(
523            &state.deepseek,
524            state.focus == Focus::DeepseekKey,
525            &bubble,
526        )),
527        chunks[8],
528    );
529
530    // Kimi key.
531    f.render_widget(
532        Paragraph::new(label(
533            "Kimi API key (environment key takes precedence)",
534            state.focus == Focus::KimiKey,
535            &bubble,
536        )),
537        chunks[9],
538    );
539    f.render_widget(
540        Paragraph::new(render_input(
541            &state.kimi,
542            state.focus == Focus::KimiKey,
543            &bubble,
544        )),
545        chunks[10],
546    );
547
548    // Save button.
549    let save_style = if state.focus == Focus::SaveButton {
550        bubble.selected.add_modifier(Modifier::REVERSED)
551    } else {
552        bubble.accent.add_modifier(Modifier::BOLD)
553    };
554    f.render_widget(
555        Paragraph::new(Line::from(Span::styled(
556            "   [ Save (Ctrl-S) ]   ",
557            save_style,
558        ))),
559        chunks[12],
560    );
561
562    // Status line.
563    if !state.status.is_empty() {
564        f.render_widget(
565            Paragraph::new(Line::from(Span::styled(state.status.clone(), bubble.muted))),
566            chunks[13],
567        );
568    }
569
570    // Hint footer.
571    let hint = bubble.help_line([
572        ("tab/up/down", "move"),
573        ("left/right", "pick"),
574        ("ctrl+v", "reveal"),
575        ("ctrl+s", "save"),
576        ("esc", "cancel"),
577    ]);
578    f.render_widget(Paragraph::new(hint), chunks[14]);
579}
580
581fn label(text: &str, focused: bool, theme: &BubbleTheme) -> Line<'static> {
582    let marker = if focused {
583        theme.symbols.selected
584    } else {
585        theme.symbols.bullet
586    };
587    let marker_style = if focused { theme.accent } else { theme.muted };
588    let text_style = if focused { theme.title } else { theme.text };
589    Line::from(vec![
590        theme.muted("  "),
591        Span::styled(marker, marker_style),
592        theme.span(" "),
593        Span::styled(text.to_string(), text_style),
594    ])
595}
596
597fn render_radio(choices: &[VendorId], selected: &VendorId, theme: &BubbleTheme) -> Line<'static> {
598    let mut spans = vec![theme.muted("    ")];
599    for v in choices {
600        let is_sel = v == selected;
601        let glyph = if is_sel {
602            theme.symbols.selected
603        } else {
604            theme.symbols.bullet
605        };
606        let style = if is_sel { theme.selected } else { theme.muted };
607        spans.push(Span::styled(
608            format!("{glyph} {}  ", vendor_label(*v)),
609            style,
610        ));
611    }
612    Line::from(spans)
613}
614
615fn vendor_label(v: VendorId) -> &'static str {
616    match v {
617        VendorId::Anthropic => "Anthropic",
618        VendorId::Openai => "OpenAI",
619        VendorId::Zai => "Z.AI",
620        VendorId::Openrouter => "OpenRouter",
621        VendorId::Deepseek => "DeepSeek",
622        VendorId::Kimi => "Kimi",
623    }
624}
625
626fn render_input(input: &KeyInput, focused: bool, theme: &BubbleTheme) -> Line<'static> {
627    let body = if input.buf.is_empty() {
628        "(empty)".to_string()
629    } else {
630        input.display()
631    };
632    let box_style = if focused {
633        theme.accent.add_modifier(Modifier::BOLD)
634    } else {
635        theme.text
636    };
637    let suffix_style = theme.muted;
638    let suffix = if input.revealed { "  [revealed]" } else { "" };
639    let cursor_hint = if focused {
640        format!("  ▏cur:{}", input.cursor)
641    } else {
642        String::new()
643    };
644    Line::from(vec![
645        theme.muted("    "),
646        Span::styled(body, box_style),
647        Span::styled(format!("{suffix}{cursor_hint}"), suffix_style),
648    ])
649}
650
651const SETTINGS_CONTENT_HEIGHT: u16 = 18;
652
653/// Keep every editable field and Save visible in a common 24-row terminal.
654fn settings_modal_rect(area: Rect) -> Rect {
655    let height = ((area.height * 92) / 100)
656        .max(SETTINGS_CONTENT_HEIGHT + 2)
657        .min(area.height);
658    let width = (area.width * 80) / 100;
659    Rect {
660        x: area.x + (area.width - width) / 2,
661        y: area.y + (area.height - height) / 2,
662        width,
663        height,
664    }
665}
666
667// crossterm types live behind ratatui; re-exported here for handle_key callers.
668pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673    use tempfile::TempDir;
674
675    /// A config path with no open handle on the file, so `save_to_path`'s
676    /// atomic rename-over-destination succeeds on Windows.
677    /// See [`crate::cache::closed_temp_file`].
678    fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
679        crate::cache::closed_temp_file("config.toml", initial)
680    }
681
682    fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
683        let mut s = SettingsState {
684            focus: Focus::Primary,
685            primary_choices: VendorId::all().to_vec(),
686            primary,
687            zai: KeyInput::from_config(Some(zai)),
688            openrouter: KeyInput::from_config(Some(opr)),
689            deepseek: KeyInput::default(),
690            kimi: KeyInput::default(),
691            status: String::new(),
692        };
693        // Mark dirty so save writes them.
694        s.zai.dirty = true;
695        s.openrouter.dirty = true;
696        s
697    }
698
699    #[test]
700    fn focus_cycles_forward_and_backward() {
701        let order = [
702            Focus::Primary,
703            Focus::ZaiKey,
704            Focus::OpenrouterKey,
705            Focus::DeepseekKey,
706            Focus::KimiKey,
707            Focus::SaveButton,
708        ];
709        let n = order.len();
710        for (i, f) in order.iter().enumerate() {
711            assert_eq!(f.next(), order[(i + 1) % n]);
712            assert_eq!(f.prev(), order[(i + n - 1) % n]);
713        }
714    }
715
716    #[test]
717    fn key_input_insert_backspace_arrow() {
718        let mut k = KeyInput::default();
719        k.insert_char('a');
720        k.insert_char('b');
721        k.insert_char('c');
722        assert_eq!(k.buf, "abc");
723        assert_eq!(k.cursor, 3);
724        assert!(k.dirty);
725        k.move_left();
726        k.move_left();
727        assert_eq!(k.cursor, 1);
728        k.insert_char('x'); // "axbc"
729        assert_eq!(k.buf, "axbc");
730        assert_eq!(k.cursor, 2);
731        k.backspace();
732        assert_eq!(k.buf, "abc");
733        assert_eq!(k.cursor, 1);
734    }
735
736    #[test]
737    fn key_input_masks_by_default_reveals_on_toggle() {
738        let mut k = KeyInput::default();
739        for c in "secret-key".chars() {
740            k.insert_char(c);
741        }
742        assert_eq!(k.display(), "•".repeat(10));
743        k.toggle_reveal();
744        assert_eq!(k.display(), "secret-key");
745    }
746
747    #[test]
748    fn key_input_handles_unicode() {
749        let mut k = KeyInput::default();
750        k.insert_char('a');
751        k.insert_char('→');
752        k.insert_char('b');
753        assert_eq!(k.buf, "a→b");
754        assert_eq!(k.cursor, 3);
755        k.move_left();
756        k.backspace(); // delete '→'
757        assert_eq!(k.buf, "ab");
758    }
759
760    #[test]
761    fn save_to_path_writes_minimal_toml_when_starting_empty() {
762        let (_dir, path) = temp_config(None);
763        let s = state_with("zk", "ok", VendorId::Zai);
764        save_to_path(&s, &path).unwrap();
765        let raw = std::fs::read_to_string(&path).unwrap();
766        assert!(raw.contains("primary = \"zai\""));
767        assert!(raw.contains("[zai]"));
768        assert!(raw.contains("api_key = \"zk\""));
769        assert!(raw.contains("[openrouter]"));
770        assert!(raw.contains("api_key = \"ok\""));
771    }
772
773    #[test]
774    fn save_to_path_preserves_existing_comments_and_unrelated_fields() {
775        let (_dir, path) = temp_config(Some(
776            r##"# my comment
777[ui]
778# pre-existing comment
779primary = "anthropic"
780
781[zai]
782enabled = true
783api_key_env = "ZAI_API_KEY"
784# tier comment
785plan_tier = "pro"
786
787[openrouter]
788enabled = true
789api_key_env = "OPENROUTER_API_KEY"
790"##,
791        ));
792
793        let s = state_with("zk2", "ok2", VendorId::Openrouter);
794        save_to_path(&s, &path).unwrap();
795
796        let raw = std::fs::read_to_string(&path).unwrap();
797        // Comments survive.
798        assert!(raw.contains("# my comment"));
799        assert!(raw.contains("# pre-existing comment"));
800        assert!(raw.contains("# tier comment"));
801        // Unrelated fields survive.
802        assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
803        assert!(raw.contains("plan_tier = \"pro\""));
804        // Primary updated.
805        assert!(raw.contains("primary = \"openrouter\""));
806        // Keys written.
807        assert!(raw.contains("api_key = \"zk2\""));
808        assert!(raw.contains("api_key = \"ok2\""));
809    }
810
811    #[test]
812    fn save_does_not_write_empty_key_when_dirty_but_blank() {
813        let (_dir, path) = temp_config(None);
814        let mut s = state_with("", "", VendorId::Anthropic);
815        // Mark dirty but leave buf empty (user opened dialog with empty
816        // field, focused it, did nothing).
817        s.zai.dirty = true;
818        s.openrouter.dirty = true;
819        save_to_path(&s, &path).unwrap();
820        let raw = std::fs::read_to_string(&path).unwrap();
821        // No `api_key = ""` lines should be written.
822        assert!(!raw.contains("api_key ="));
823    }
824
825    #[test]
826    fn save_dirty_empty_key_removes_existing_inline_key() {
827        let (_dir, path) = temp_config(Some(
828            r#"[zai]
829api_key = "old-secret"
830plan_tier = "pro"
831"#,
832        ));
833        let mut s = SettingsState::from_config(&Config::default());
834        s.zai.dirty = true;
835        save_to_path(&s, &path).unwrap();
836
837        let raw = std::fs::read_to_string(&path).unwrap();
838        assert!(!raw.contains("api_key"));
839        assert!(raw.contains("plan_tier = \"pro\""));
840    }
841
842    #[test]
843    fn save_untouched_empty_key_preserves_existing_inline_key() {
844        let (_dir, path) = temp_config(Some(
845            r#"[zai]
846api_key = "old-secret"
847"#,
848        ));
849        let s = SettingsState::from_config(&Config::default());
850        save_to_path(&s, &path).unwrap();
851
852        let raw = std::fs::read_to_string(&path).unwrap();
853        assert!(raw.contains("api_key = \"old-secret\""));
854    }
855
856    #[test]
857    fn settings_modal_fits_all_fields_and_save_in_24_rows() {
858        let modal = settings_modal_rect(Rect::new(0, 0, 100, 24));
859        // Two border rows leave at least the 18 fixed rows through Save.
860        assert!(modal.height >= SETTINGS_CONTENT_HEIGHT + 2);
861        assert!(modal.height <= 24);
862    }
863
864    #[test]
865    #[cfg(unix)]
866    fn save_chmods_to_600() {
867        use std::os::unix::fs::PermissionsExt;
868        let (_dir, path) = temp_config(None);
869        let s = state_with("zk", "ok", VendorId::Zai);
870        save_to_path(&s, &path).unwrap();
871        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
872        assert_eq!(mode & 0o777, 0o600);
873    }
874
875    #[test]
876    fn save_preserves_kimi_enabled_true_and_unrelated_comments() {
877        let (_dir, path) = temp_config(Some(
878            r##"[ui]
879primary = "anthropic"
880
881[kimi]
882# Kimi is opt-in
883enabled = true
884api_key_env = "KIMI_API_KEY"
885"##,
886        ));
887
888        // No keys dirty, primary unchanged — save should still rewrite
889        // primary in place but leave the [kimi] section untouched.
890        let s = SettingsState {
891            focus: Focus::Primary,
892            primary_choices: VendorId::all().to_vec(),
893            primary: VendorId::Anthropic,
894            zai: KeyInput::default(),
895            openrouter: KeyInput::default(),
896            deepseek: KeyInput::default(),
897            kimi: KeyInput::default(),
898            status: String::new(),
899        };
900        save_to_path(&s, &path).unwrap();
901
902        let raw = std::fs::read_to_string(&path).unwrap();
903        assert!(raw.contains("# Kimi is opt-in"));
904        assert!(raw.contains("enabled = true"));
905        assert!(raw.contains("api_key_env = \"KIMI_API_KEY\""));
906        assert!(raw.contains("primary = \"anthropic\""));
907    }
908
909    #[test]
910    fn handle_key_tab_cycles_focus() {
911        let mut s = SettingsState {
912            focus: Focus::Primary,
913            primary_choices: VendorId::all().to_vec(),
914            primary: VendorId::Anthropic,
915            zai: KeyInput::default(),
916            openrouter: KeyInput::default(),
917            deepseek: KeyInput::default(),
918            kimi: KeyInput::default(),
919            status: String::new(),
920        };
921        assert_eq!(
922            handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
923            Action::Continue
924        );
925        assert_eq!(s.focus, Focus::ZaiKey);
926        assert_eq!(
927            handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
928            Action::Continue
929        );
930        assert_eq!(s.focus, Focus::Primary);
931    }
932
933    #[test]
934    fn handle_key_esc_closes_without_saving() {
935        let mut s = SettingsState {
936            focus: Focus::Primary,
937            primary_choices: VendorId::all().to_vec(),
938            primary: VendorId::Anthropic,
939            zai: KeyInput::default(),
940            openrouter: KeyInput::default(),
941            deepseek: KeyInput::default(),
942            kimi: KeyInput::default(),
943            status: String::new(),
944        };
945        assert_eq!(
946            handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
947            Action::Close
948        );
949    }
950
951    #[test]
952    fn handle_key_left_right_cycles_primary_vendor() {
953        let mut s = SettingsState {
954            focus: Focus::Primary,
955            primary_choices: VendorId::all().to_vec(),
956            primary: VendorId::Anthropic,
957            zai: KeyInput::default(),
958            openrouter: KeyInput::default(),
959            deepseek: KeyInput::default(),
960            kimi: KeyInput::default(),
961            status: String::new(),
962        };
963        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
964        assert_eq!(s.primary, VendorId::Openai);
965        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
966        assert_eq!(s.primary, VendorId::Zai);
967        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
968        assert_eq!(s.primary, VendorId::Openai);
969    }
970
971    #[test]
972    fn primary_uses_enabled_vendors_for_display_and_cycling() {
973        let mut cfg = Config::default();
974        cfg.openai.enabled = false;
975        cfg.zai.enabled = false;
976        cfg.openrouter.enabled = false;
977        cfg.kimi.enabled = true;
978        cfg.ui.primary = Some(VendorId::Openai); // disabled and ineffective
979
980        let mut state = SettingsState::from_config(&cfg);
981        assert_eq!(
982            state.primary_choices,
983            vec![VendorId::Anthropic, VendorId::Kimi]
984        );
985        assert_eq!(state.primary, VendorId::Anthropic);
986        handle_key(&mut state, KeyCode::Right, KeyModifiers::NONE);
987        assert_eq!(state.primary, VendorId::Kimi);
988    }
989
990    #[test]
991    fn save_replaces_disabled_primary_with_effective_enabled_choice() {
992        let (_dir, path) = temp_config(Some(
993            r#"[ui]
994primary = "deepseek"
995"#,
996        ));
997        let mut cfg = Config::default();
998        cfg.ui.primary = Some(VendorId::Deepseek);
999        let mut state = SettingsState::from_config(&cfg);
1000        state.zai = KeyInput::from_config(Some("new-key"));
1001        state.zai.dirty = true;
1002
1003        save_to_path(&state, &path).unwrap();
1004        let raw = std::fs::read_to_string(&path).unwrap();
1005        assert!(raw.contains("primary = \"anthropic\""));
1006        assert!(!raw.contains("primary = \"deepseek\""));
1007    }
1008
1009    #[test]
1010    fn no_enabled_vendors_does_not_write_an_ineffective_primary() {
1011        let (_dir, path) = temp_config(Some(
1012            r#"[ui]
1013primary = "deepseek"
1014"#,
1015        ));
1016        let mut cfg = Config::default();
1017        cfg.anthropic.enabled = false;
1018        cfg.openai.enabled = false;
1019        cfg.zai.enabled = false;
1020        cfg.openrouter.enabled = false;
1021        cfg.deepseek.enabled = false;
1022        cfg.kimi.enabled = false;
1023        cfg.ui.primary = Some(VendorId::Deepseek);
1024        let mut state = SettingsState::from_config(&cfg);
1025        state.zai = KeyInput::from_config(Some("new-key"));
1026        state.zai.dirty = true;
1027
1028        assert!(state.primary_choices.is_empty());
1029        save_to_path(&state, &path).unwrap();
1030        let raw = std::fs::read_to_string(&path).unwrap();
1031        assert!(raw.contains("primary = \"deepseek\""));
1032    }
1033
1034    #[test]
1035    fn handle_key_ctrl_v_toggles_reveal_on_focused_key_field() {
1036        let mut s = SettingsState {
1037            focus: Focus::ZaiKey,
1038            primary_choices: VendorId::all().to_vec(),
1039            primary: VendorId::Anthropic,
1040            zai: KeyInput::from_config(Some("secret")),
1041            openrouter: KeyInput::default(),
1042            deepseek: KeyInput::default(),
1043            kimi: KeyInput::default(),
1044            status: String::new(),
1045        };
1046        assert!(!s.zai.revealed);
1047        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1048        assert!(s.zai.revealed);
1049        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1050        assert!(!s.zai.revealed);
1051    }
1052
1053    #[test]
1054    fn handle_key_ctrl_s_attempts_save_from_any_field() {
1055        let (_dir, path) = temp_config(None);
1056        // We can't easily redirect default_config_path() in the test, so we
1057        // exercise save_to_path directly instead.
1058        let s = state_with("zk", "ok", VendorId::Zai);
1059        save_to_path(&s, &path).unwrap();
1060        let raw = std::fs::read_to_string(&path).unwrap();
1061        assert!(raw.contains("api_key = \"zk\""));
1062    }
1063
1064    #[test]
1065    fn save_to_path_writes_kimi_key_when_dirty() {
1066        let (_dir, path) = temp_config(None);
1067        let mut s = SettingsState {
1068            focus: Focus::Primary,
1069            primary_choices: VendorId::all().to_vec(),
1070            primary: VendorId::Anthropic,
1071            zai: KeyInput::default(),
1072            openrouter: KeyInput::default(),
1073            deepseek: KeyInput::default(),
1074            kimi: KeyInput::from_config(Some("kk")),
1075            status: String::new(),
1076        };
1077        s.kimi.dirty = true;
1078        save_to_path(&s, &path).unwrap();
1079        let raw = std::fs::read_to_string(&path).unwrap();
1080        assert!(raw.contains("[kimi]"));
1081        assert!(raw.contains("api_key = \"kk\""));
1082    }
1083}