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