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