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 API keys without
3//! 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    SaveButton,
33}
34
35impl Focus {
36    pub fn next(self) -> Self {
37        match self {
38            Focus::Primary => Focus::ZaiKey,
39            Focus::ZaiKey => Focus::OpenrouterKey,
40            Focus::OpenrouterKey => Focus::DeepseekKey,
41            Focus::DeepseekKey => Focus::SaveButton,
42            Focus::SaveButton => Focus::Primary,
43        }
44    }
45    pub fn prev(self) -> Self {
46        match self {
47            Focus::Primary => Focus::SaveButton,
48            Focus::ZaiKey => Focus::Primary,
49            Focus::OpenrouterKey => Focus::ZaiKey,
50            Focus::DeepseekKey => Focus::OpenrouterKey,
51            Focus::SaveButton => Focus::DeepseekKey,
52        }
53    }
54}
55
56/// Per-field text-input state — cursor + buffer + reveal flag.
57#[derive(Debug, Clone, Default)]
58pub struct KeyInput {
59    pub buf: String,
60    /// Char-index cursor position (0..=buf.chars().count()).
61    pub cursor: usize,
62    /// When true, the field renders the actual characters; otherwise `•`.
63    pub revealed: bool,
64    /// True after the user has typed/edited; only then does save write
65    /// the value back (avoids clobbering an existing key with the empty
66    /// placeholder the user opened the dialog with).
67    pub dirty: bool,
68}
69
70impl KeyInput {
71    pub fn from_config(initial: Option<&str>) -> Self {
72        let buf = initial.unwrap_or("").to_string();
73        let cursor = buf.chars().count();
74        Self {
75            buf,
76            cursor,
77            revealed: false,
78            dirty: false,
79        }
80    }
81
82    pub fn insert_char(&mut self, c: char) {
83        let byte_idx = self.char_to_byte(self.cursor);
84        self.buf.insert(byte_idx, c);
85        self.cursor += 1;
86        self.dirty = true;
87    }
88
89    pub fn backspace(&mut self) {
90        if self.cursor == 0 {
91            return;
92        }
93        let prev_byte = self.char_to_byte(self.cursor - 1);
94        let cur_byte = self.char_to_byte(self.cursor);
95        self.buf.replace_range(prev_byte..cur_byte, "");
96        self.cursor -= 1;
97        self.dirty = true;
98    }
99
100    pub fn delete(&mut self) {
101        let n = self.buf.chars().count();
102        if self.cursor >= n {
103            return;
104        }
105        let cur_byte = self.char_to_byte(self.cursor);
106        let next_byte = self.char_to_byte(self.cursor + 1);
107        self.buf.replace_range(cur_byte..next_byte, "");
108        self.dirty = true;
109    }
110
111    pub fn move_left(&mut self) {
112        if self.cursor > 0 {
113            self.cursor -= 1;
114        }
115    }
116    pub fn move_right(&mut self) {
117        if self.cursor < self.buf.chars().count() {
118            self.cursor += 1;
119        }
120    }
121    pub fn move_home(&mut self) {
122        self.cursor = 0;
123    }
124    pub fn move_end(&mut self) {
125        self.cursor = self.buf.chars().count();
126    }
127    pub fn toggle_reveal(&mut self) {
128        self.revealed = !self.revealed;
129    }
130
131    /// Render for display — bullets when masked, raw chars when revealed.
132    pub fn display(&self) -> String {
133        if self.revealed {
134            self.buf.clone()
135        } else {
136            "•".repeat(self.buf.chars().count())
137        }
138    }
139
140    fn char_to_byte(&self, char_idx: usize) -> usize {
141        self.buf
142            .char_indices()
143            .map(|(b, _)| b)
144            .chain(std::iter::once(self.buf.len()))
145            .nth(char_idx)
146            .unwrap_or(self.buf.len())
147    }
148}
149
150/// Mutable state of the overlay while open.
151#[derive(Debug, Clone)]
152pub struct SettingsState {
153    pub focus: Focus,
154    pub primary: VendorId,
155    pub zai: KeyInput,
156    pub openrouter: KeyInput,
157    pub deepseek: KeyInput,
158    /// One-line status displayed in the footer ("Saved", "Error: ...", "").
159    pub status: String,
160}
161
162impl SettingsState {
163    pub fn from_config(cfg: &Config) -> Self {
164        Self {
165            focus: Focus::Primary,
166            primary: cfg.ui.primary.unwrap_or(VendorId::Anthropic),
167            zai: KeyInput::from_config(cfg.zai.api_key.as_deref()),
168            openrouter: KeyInput::from_config(cfg.openrouter.api_key.as_deref()),
169            deepseek: KeyInput::from_config(cfg.deepseek.api_key.as_deref()),
170            status: String::new(),
171        }
172    }
173}
174
175/// What the key handler asks the host app to do next.
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum Action {
178    /// Stay open, keep listening for keys.
179    Continue,
180    /// Close the overlay (discard or save already happened).
181    Close,
182    /// Save just succeeded — caller should refresh affected vendors.
183    SavedAndClose,
184}
185
186/// Permission note appended to the "saved" status line. The overlay `chmod
187/// 600`s the file on Unix; Windows has no such step, so the note is empty there
188/// (keeps the message platform-honest).
189#[cfg(unix)]
190const PERMS_NOTE: &str = " (chmod 600)";
191#[cfg(not(unix))]
192const PERMS_NOTE: &str = "";
193
194/// Status line after a successful save: the platform-resolved config path plus
195/// the platform-appropriate permission note.
196fn saved_status() -> String {
197    format!(
198        "saved to {}{}",
199        crate::config::config_path_hint(),
200        PERMS_NOTE
201    )
202}
203
204/// Key map. Returns the action to perform after the keypress.
205pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
206    // Esc always closes without saving.
207    if matches!(code, KeyCode::Esc) {
208        return Action::Close;
209    }
210    // Ctrl-S triggers save from any field.
211    if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
212        return match save_to_config_default(state) {
213            Ok(()) => {
214                state.status = saved_status();
215                Action::SavedAndClose
216            }
217            Err(e) => {
218                state.status = format!("save failed: {e}");
219                Action::Continue
220            }
221        };
222    }
223    // Ctrl-V toggles reveal on the focused key field.
224    if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
225        match state.focus {
226            Focus::ZaiKey => state.zai.toggle_reveal(),
227            Focus::OpenrouterKey => state.openrouter.toggle_reveal(),
228            Focus::DeepseekKey => state.deepseek.toggle_reveal(),
229            _ => {}
230        }
231        return Action::Continue;
232    }
233
234    // Field navigation: Tab/Shift-Tab and Up/Down.
235    match code {
236        KeyCode::Tab => {
237            state.focus = state.focus.next();
238            return Action::Continue;
239        }
240        KeyCode::BackTab => {
241            state.focus = state.focus.prev();
242            return Action::Continue;
243        }
244        KeyCode::Down => {
245            state.focus = state.focus.next();
246            return Action::Continue;
247        }
248        KeyCode::Up => {
249            state.focus = state.focus.prev();
250            return Action::Continue;
251        }
252        _ => {}
253    }
254
255    // Field-specific handling.
256    match state.focus {
257        Focus::Primary => handle_primary(state, code),
258        Focus::ZaiKey => handle_input(&mut state.zai, code),
259        Focus::OpenrouterKey => handle_input(&mut state.openrouter, code),
260        Focus::DeepseekKey => handle_input(&mut state.deepseek, code),
261        Focus::SaveButton => {
262            if matches!(code, KeyCode::Enter) {
263                return match save_to_config_default(state) {
264                    Ok(()) => {
265                        state.status = saved_status();
266                        Action::SavedAndClose
267                    }
268                    Err(e) => {
269                        state.status = format!("save failed: {e}");
270                        Action::Continue
271                    }
272                };
273            }
274        }
275    }
276    Action::Continue
277}
278
279fn handle_primary(state: &mut SettingsState, code: KeyCode) {
280    // Left/Right cycles the primary-vendor radio.
281    let all = VendorId::all();
282    let idx = all.iter().position(|v| *v == state.primary).unwrap_or(0) as i32;
283    let len = all.len() as i32;
284    let step = match code {
285        KeyCode::Left => -1,
286        KeyCode::Right | KeyCode::Char(' ') => 1,
287        _ => return,
288    };
289    state.primary = all[((idx + step).rem_euclid(len)) as usize];
290}
291
292fn handle_input(input: &mut KeyInput, code: KeyCode) {
293    match code {
294        KeyCode::Char(c) => input.insert_char(c),
295        KeyCode::Backspace => input.backspace(),
296        KeyCode::Delete => input.delete(),
297        KeyCode::Left => input.move_left(),
298        KeyCode::Right => input.move_right(),
299        KeyCode::Home => input.move_home(),
300        KeyCode::End => input.move_end(),
301        _ => {}
302    }
303}
304
305/// Save to `~/.config/ai-usagebar/config.toml` (or create it). On success,
306/// signal a running Waybar process (SIGRTMIN+13) so any module configured
307/// with `signal: 13` refreshes its exec output immediately — otherwise the
308/// bar text wouldn't reflect a new primary vendor until the next interval
309/// tick (up to 300s).
310fn save_to_config_default(state: &SettingsState) -> Result<()> {
311    let path = default_config_path()?;
312    if let Some(parent) = path.parent() {
313        std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
314    }
315    save_to_path(state, &path)?;
316    crate::waybar::request_refresh();
317    Ok(())
318}
319
320/// Same as `save_to_config_default` but with an explicit path — exposed
321/// for tests.
322pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
323    let original = std::fs::read_to_string(path).unwrap_or_default();
324    let mut doc: DocumentMut = if original.trim().is_empty() {
325        DocumentMut::new()
326    } else {
327        original.parse().map_err(|e: toml_edit::TomlError| {
328            AppError::Other(format!("config.toml not parseable: {e}"))
329        })?
330    };
331
332    // [ui].primary
333    set_string(&mut doc, "ui", "primary", state.primary.slug())?;
334    // [zai].api_key — only write if dirty AND non-empty
335    if state.zai.dirty && !state.zai.buf.is_empty() {
336        set_string(&mut doc, "zai", "api_key", &state.zai.buf)?;
337    }
338    // [openrouter].api_key — same
339    if state.openrouter.dirty && !state.openrouter.buf.is_empty() {
340        set_string(&mut doc, "openrouter", "api_key", &state.openrouter.buf)?;
341    }
342    // [deepseek].api_key — same
343    if state.deepseek.dirty && !state.deepseek.buf.is_empty() {
344        set_string(&mut doc, "deepseek", "api_key", &state.deepseek.buf)?;
345    }
346
347    let bytes = doc.to_string();
348    crate::cache::atomic_write(path, bytes.as_bytes())?;
349
350    // chmod 600 — only required when we wrote a secret, but always safe.
351    #[cfg(unix)]
352    {
353        use std::os::unix::fs::PermissionsExt;
354        if let Ok(meta) = std::fs::metadata(path) {
355            let mut perms = meta.permissions();
356            perms.set_mode(0o600);
357            let _ = std::fs::set_permissions(path, perms);
358        }
359    }
360    Ok(())
361}
362
363/// Set or update a string field in a TOML section, preserving comments and
364/// formatting of unaffected nodes. When the key already exists, we mutate its
365/// value in place (this keeps the leading comment attached to the key);
366/// otherwise we insert a new entry.
367fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
368    let table = doc
369        .entry(section)
370        .or_insert_with(toml_edit::table)
371        .as_table_mut()
372        .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
373
374    if let Some(item) = table.get_mut(key)
375        && let Some(v) = item.as_value_mut()
376    {
377        *v = toml_edit::Value::from(new_value);
378        // Restore the surrounding decor (a space before `=` and after the
379        // value, matching toml_edit's default output).
380        v.decor_mut().set_prefix(" ");
381        return Ok(());
382    }
383    table.insert(key, value(new_value));
384    Ok(())
385}
386
387fn default_config_path() -> Result<PathBuf> {
388    crate::config::default_path()
389        .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
390}
391
392/// Render the modal overlay over `area`.
393pub fn render(f: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
394    let modal = centered_rect(60, 60, area);
395    // Clear underneath so the body is unreadable through us.
396    f.render_widget(Clear, modal);
397
398    let bubble = bubble_theme(theme);
399
400    let block = bubble.titled_modal_block(" Settings ");
401    let inner = block.inner(modal);
402    f.render_widget(block, modal);
403
404    let chunks = Layout::default()
405        .direction(Direction::Vertical)
406        .constraints([
407            Constraint::Length(1), // [0] primary label
408            Constraint::Length(2), // [1] primary radio row
409            Constraint::Length(1), // [2] spacer
410            Constraint::Length(1), // [3] zai label
411            Constraint::Length(2), // [4] zai input
412            Constraint::Length(1), // [5] openrouter label
413            Constraint::Length(2), // [6] openrouter input
414            Constraint::Length(1), // [7] deepseek label
415            Constraint::Length(2), // [8] deepseek input
416            Constraint::Length(1), // [9] spacer
417            Constraint::Length(1), // [10] save button
418            Constraint::Length(1), // [11] status
419            Constraint::Min(0),    // [12] hint
420        ])
421        .split(inner);
422
423    // Primary vendor.
424    f.render_widget(
425        Paragraph::new(label(
426            "Primary vendor",
427            state.focus == Focus::Primary,
428            &bubble,
429        )),
430        chunks[0],
431    );
432    f.render_widget(
433        Paragraph::new(render_radio(&state.primary, &bubble)),
434        chunks[1],
435    );
436
437    // Z.AI key.
438    f.render_widget(
439        Paragraph::new(label(
440            "Z.AI API key (ZAI_API_KEY env wins if set)",
441            state.focus == Focus::ZaiKey,
442            &bubble,
443        )),
444        chunks[3],
445    );
446    f.render_widget(
447        Paragraph::new(render_input(
448            &state.zai,
449            state.focus == Focus::ZaiKey,
450            &bubble,
451        )),
452        chunks[4],
453    );
454
455    // OpenRouter key.
456    f.render_widget(
457        Paragraph::new(label(
458            "OpenRouter API key (OPENROUTER_API_KEY env wins if set)",
459            state.focus == Focus::OpenrouterKey,
460            &bubble,
461        )),
462        chunks[5],
463    );
464    f.render_widget(
465        Paragraph::new(render_input(
466            &state.openrouter,
467            state.focus == Focus::OpenrouterKey,
468            &bubble,
469        )),
470        chunks[6],
471    );
472
473    // DeepSeek key.
474    f.render_widget(
475        Paragraph::new(label(
476            "DeepSeek API key (DEEPSEEK_API_KEY env wins if set)",
477            state.focus == Focus::DeepseekKey,
478            &bubble,
479        )),
480        chunks[7],
481    );
482    f.render_widget(
483        Paragraph::new(render_input(
484            &state.deepseek,
485            state.focus == Focus::DeepseekKey,
486            &bubble,
487        )),
488        chunks[8],
489    );
490
491    // Save button.
492    let save_style = if state.focus == Focus::SaveButton {
493        bubble.selected.add_modifier(Modifier::REVERSED)
494    } else {
495        bubble.accent.add_modifier(Modifier::BOLD)
496    };
497    f.render_widget(
498        Paragraph::new(Line::from(Span::styled(
499            "   [ Save (Ctrl-S) ]   ",
500            save_style,
501        ))),
502        chunks[10],
503    );
504
505    // Status line.
506    if !state.status.is_empty() {
507        f.render_widget(
508            Paragraph::new(Line::from(Span::styled(state.status.clone(), bubble.muted))),
509            chunks[11],
510        );
511    }
512
513    // Hint footer.
514    let hint = bubble.help_line([
515        ("tab/up/down", "move"),
516        ("left/right", "pick"),
517        ("ctrl+v", "reveal"),
518        ("ctrl+s", "save"),
519        ("esc", "cancel"),
520    ]);
521    f.render_widget(Paragraph::new(hint), chunks[12]);
522}
523
524fn label(text: &str, focused: bool, theme: &BubbleTheme) -> Line<'static> {
525    let marker = if focused {
526        theme.symbols.selected
527    } else {
528        theme.symbols.bullet
529    };
530    let marker_style = if focused { theme.accent } else { theme.muted };
531    let text_style = if focused { theme.title } else { theme.text };
532    Line::from(vec![
533        theme.muted("  "),
534        Span::styled(marker, marker_style),
535        theme.span(" "),
536        Span::styled(text.to_string(), text_style),
537    ])
538}
539
540fn render_radio(selected: &VendorId, theme: &BubbleTheme) -> Line<'static> {
541    let mut spans = vec![theme.muted("    ")];
542    for v in VendorId::all() {
543        let is_sel = v == selected;
544        let glyph = if is_sel {
545            theme.symbols.selected
546        } else {
547            theme.symbols.bullet
548        };
549        let style = if is_sel { theme.selected } else { theme.muted };
550        spans.push(Span::styled(
551            format!("{glyph} {}  ", vendor_label(*v)),
552            style,
553        ));
554    }
555    Line::from(spans)
556}
557
558fn vendor_label(v: VendorId) -> &'static str {
559    match v {
560        VendorId::Anthropic => "Anthropic",
561        VendorId::Openai => "OpenAI",
562        VendorId::Zai => "Z.AI",
563        VendorId::Openrouter => "OpenRouter",
564        VendorId::Deepseek => "DeepSeek",
565    }
566}
567
568fn render_input(input: &KeyInput, focused: bool, theme: &BubbleTheme) -> Line<'static> {
569    let body = if input.buf.is_empty() {
570        "(empty)".to_string()
571    } else {
572        input.display()
573    };
574    let box_style = if focused {
575        theme.accent.add_modifier(Modifier::BOLD)
576    } else {
577        theme.text
578    };
579    let suffix_style = theme.muted;
580    let suffix = if input.revealed { "  [revealed]" } else { "" };
581    let cursor_hint = if focused {
582        format!("  ▏cur:{}", input.cursor)
583    } else {
584        String::new()
585    };
586    Line::from(vec![
587        theme.muted("    "),
588        Span::styled(body, box_style),
589        Span::styled(format!("{suffix}{cursor_hint}"), suffix_style),
590    ])
591}
592
593/// Center a rectangle of `percent_x * percent_y` over `r`.
594fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
595    let popup_h = (r.height * percent_y) / 100;
596    let popup_w = (r.width * percent_x) / 100;
597    Rect {
598        x: r.x + (r.width - popup_w) / 2,
599        y: r.y + (r.height - popup_h) / 2,
600        width: popup_w,
601        height: popup_h,
602    }
603}
604
605// crossterm types live behind ratatui; re-exported here for handle_key callers.
606pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use tempfile::TempDir;
612
613    /// A config path with no open handle on the file, so `save_to_path`'s
614    /// atomic rename-over-destination succeeds on Windows.
615    /// See [`crate::cache::closed_temp_file`].
616    fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
617        crate::cache::closed_temp_file("config.toml", initial)
618    }
619
620    fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
621        let mut s = SettingsState {
622            focus: Focus::Primary,
623            primary,
624            zai: KeyInput::from_config(Some(zai)),
625            openrouter: KeyInput::from_config(Some(opr)),
626            deepseek: KeyInput::default(),
627            status: String::new(),
628        };
629        // Mark dirty so save writes them.
630        s.zai.dirty = true;
631        s.openrouter.dirty = true;
632        s
633    }
634
635    #[test]
636    fn focus_cycles_forward_and_backward() {
637        let order = [
638            Focus::Primary,
639            Focus::ZaiKey,
640            Focus::OpenrouterKey,
641            Focus::DeepseekKey,
642            Focus::SaveButton,
643        ];
644        let n = order.len();
645        for (i, f) in order.iter().enumerate() {
646            assert_eq!(f.next(), order[(i + 1) % n]);
647            assert_eq!(f.prev(), order[(i + n - 1) % n]);
648        }
649    }
650
651    #[test]
652    fn key_input_insert_backspace_arrow() {
653        let mut k = KeyInput::default();
654        k.insert_char('a');
655        k.insert_char('b');
656        k.insert_char('c');
657        assert_eq!(k.buf, "abc");
658        assert_eq!(k.cursor, 3);
659        assert!(k.dirty);
660        k.move_left();
661        k.move_left();
662        assert_eq!(k.cursor, 1);
663        k.insert_char('x'); // "axbc"
664        assert_eq!(k.buf, "axbc");
665        assert_eq!(k.cursor, 2);
666        k.backspace();
667        assert_eq!(k.buf, "abc");
668        assert_eq!(k.cursor, 1);
669    }
670
671    #[test]
672    fn key_input_masks_by_default_reveals_on_toggle() {
673        let mut k = KeyInput::default();
674        for c in "secret-key".chars() {
675            k.insert_char(c);
676        }
677        assert_eq!(k.display(), "•".repeat(10));
678        k.toggle_reveal();
679        assert_eq!(k.display(), "secret-key");
680    }
681
682    #[test]
683    fn key_input_handles_unicode() {
684        let mut k = KeyInput::default();
685        k.insert_char('a');
686        k.insert_char('→');
687        k.insert_char('b');
688        assert_eq!(k.buf, "a→b");
689        assert_eq!(k.cursor, 3);
690        k.move_left();
691        k.backspace(); // delete '→'
692        assert_eq!(k.buf, "ab");
693    }
694
695    #[test]
696    fn save_to_path_writes_minimal_toml_when_starting_empty() {
697        let (_dir, path) = temp_config(None);
698        let s = state_with("zk", "ok", VendorId::Zai);
699        save_to_path(&s, &path).unwrap();
700        let raw = std::fs::read_to_string(&path).unwrap();
701        assert!(raw.contains("primary = \"zai\""));
702        assert!(raw.contains("[zai]"));
703        assert!(raw.contains("api_key = \"zk\""));
704        assert!(raw.contains("[openrouter]"));
705        assert!(raw.contains("api_key = \"ok\""));
706    }
707
708    #[test]
709    fn save_to_path_preserves_existing_comments_and_unrelated_fields() {
710        let (_dir, path) = temp_config(Some(
711            r##"# my comment
712[ui]
713# pre-existing comment
714primary = "anthropic"
715
716[zai]
717enabled = true
718api_key_env = "ZAI_API_KEY"
719# tier comment
720plan_tier = "pro"
721
722[openrouter]
723enabled = true
724api_key_env = "OPENROUTER_API_KEY"
725"##,
726        ));
727
728        let s = state_with("zk2", "ok2", VendorId::Openrouter);
729        save_to_path(&s, &path).unwrap();
730
731        let raw = std::fs::read_to_string(&path).unwrap();
732        // Comments survive.
733        assert!(raw.contains("# my comment"));
734        assert!(raw.contains("# pre-existing comment"));
735        assert!(raw.contains("# tier comment"));
736        // Unrelated fields survive.
737        assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
738        assert!(raw.contains("plan_tier = \"pro\""));
739        // Primary updated.
740        assert!(raw.contains("primary = \"openrouter\""));
741        // Keys written.
742        assert!(raw.contains("api_key = \"zk2\""));
743        assert!(raw.contains("api_key = \"ok2\""));
744    }
745
746    #[test]
747    fn save_does_not_write_empty_key_when_dirty_but_blank() {
748        let (_dir, path) = temp_config(None);
749        let mut s = state_with("", "", VendorId::Anthropic);
750        // Mark dirty but leave buf empty (user opened dialog with empty
751        // field, focused it, did nothing).
752        s.zai.dirty = true;
753        s.openrouter.dirty = true;
754        save_to_path(&s, &path).unwrap();
755        let raw = std::fs::read_to_string(&path).unwrap();
756        // No `api_key = ""` lines should be written.
757        assert!(!raw.contains("api_key ="));
758    }
759
760    #[test]
761    #[cfg(unix)]
762    fn save_chmods_to_600() {
763        use std::os::unix::fs::PermissionsExt;
764        let (_dir, path) = temp_config(None);
765        let s = state_with("zk", "ok", VendorId::Zai);
766        save_to_path(&s, &path).unwrap();
767        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
768        assert_eq!(mode & 0o777, 0o600);
769    }
770
771    #[test]
772    fn handle_key_tab_cycles_focus() {
773        let mut s = SettingsState {
774            focus: Focus::Primary,
775            primary: VendorId::Anthropic,
776            zai: KeyInput::default(),
777            openrouter: KeyInput::default(),
778            deepseek: KeyInput::default(),
779            status: String::new(),
780        };
781        assert_eq!(
782            handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
783            Action::Continue
784        );
785        assert_eq!(s.focus, Focus::ZaiKey);
786        assert_eq!(
787            handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
788            Action::Continue
789        );
790        assert_eq!(s.focus, Focus::Primary);
791    }
792
793    #[test]
794    fn handle_key_esc_closes_without_saving() {
795        let mut s = SettingsState {
796            focus: Focus::Primary,
797            primary: VendorId::Anthropic,
798            zai: KeyInput::default(),
799            openrouter: KeyInput::default(),
800            deepseek: KeyInput::default(),
801            status: String::new(),
802        };
803        assert_eq!(
804            handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
805            Action::Close
806        );
807    }
808
809    #[test]
810    fn handle_key_left_right_cycles_primary_vendor() {
811        let mut s = SettingsState {
812            focus: Focus::Primary,
813            primary: VendorId::Anthropic,
814            zai: KeyInput::default(),
815            openrouter: KeyInput::default(),
816            deepseek: KeyInput::default(),
817            status: String::new(),
818        };
819        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
820        assert_eq!(s.primary, VendorId::Openai);
821        handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
822        assert_eq!(s.primary, VendorId::Zai);
823        handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
824        assert_eq!(s.primary, VendorId::Openai);
825    }
826
827    #[test]
828    fn handle_key_ctrl_v_toggles_reveal_on_focused_key_field() {
829        let mut s = SettingsState {
830            focus: Focus::ZaiKey,
831            primary: VendorId::Anthropic,
832            zai: KeyInput::from_config(Some("secret")),
833            openrouter: KeyInput::default(),
834            deepseek: KeyInput::default(),
835            status: String::new(),
836        };
837        assert!(!s.zai.revealed);
838        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
839        assert!(s.zai.revealed);
840        handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
841        assert!(!s.zai.revealed);
842    }
843
844    #[test]
845    fn handle_key_ctrl_s_attempts_save_from_any_field() {
846        let (_dir, path) = temp_config(None);
847        // We can't easily redirect default_config_path() in the test, so we
848        // exercise save_to_path directly instead.
849        let s = state_with("zk", "ok", VendorId::Zai);
850        save_to_path(&s, &path).unwrap();
851        let raw = std::fs::read_to_string(&path).unwrap();
852        assert!(raw.contains("api_key = \"zk\""));
853    }
854}