1use 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#[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#[derive(Debug, Clone, Default)]
56pub struct KeyInput {
57 pub buf: String,
58 pub cursor: usize,
60 pub revealed: bool,
62 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 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#[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum Action {
176 Continue,
178 Close,
180 SavedAndClose,
182}
183
184pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
186 if matches!(code, KeyCode::Esc) {
188 return Action::Close;
189 }
190 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 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 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 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 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
286fn 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
301pub 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 set_string(&mut doc, "ui", "primary", state.primary.slug())?;
315 if state.zai.dirty && !state.zai.buf.is_empty() {
317 set_string(&mut doc, "zai", "api_key", &state.zai.buf)?;
318 }
319 if state.openrouter.dirty && !state.openrouter.buf.is_empty() {
321 set_string(&mut doc, "openrouter", "api_key", &state.openrouter.buf)?;
322 }
323 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 #[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
344fn 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 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
374pub fn render(f: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
376 let modal = centered_rect(60, 60, area);
377 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), Constraint::Length(2), Constraint::Length(1), Constraint::Length(1), Constraint::Length(2), Constraint::Length(1), Constraint::Length(2), Constraint::Length(1), Constraint::Length(2), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
408 .split(inner);
409
410 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 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 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 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 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 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 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
594fn 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
606pub 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 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'); 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(); 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 assert!(raw.contains("# my comment"));
731 assert!(raw.contains("# pre-existing comment"));
732 assert!(raw.contains("# tier comment"));
733 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
735 assert!(raw.contains("plan_tier = \"pro\""));
736 assert!(raw.contains("primary = \"openrouter\""));
738 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 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 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 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}