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
184#[cfg(unix)]
188const PERMS_NOTE: &str = " (chmod 600)";
189#[cfg(not(unix))]
190const PERMS_NOTE: &str = "";
191
192fn saved_status() -> String {
195 format!(
196 "saved to {}{}",
197 crate::config::config_path_hint(),
198 PERMS_NOTE
199 )
200}
201
202pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
204 if matches!(code, KeyCode::Esc) {
206 return Action::Close;
207 }
208 if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
210 return match save_to_config_default(state) {
211 Ok(()) => {
212 state.status = saved_status();
213 Action::SavedAndClose
214 }
215 Err(e) => {
216 state.status = format!("save failed: {e}");
217 Action::Continue
218 }
219 };
220 }
221 if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
223 match state.focus {
224 Focus::ZaiKey => state.zai.toggle_reveal(),
225 Focus::OpenrouterKey => state.openrouter.toggle_reveal(),
226 Focus::DeepseekKey => state.deepseek.toggle_reveal(),
227 _ => {}
228 }
229 return Action::Continue;
230 }
231
232 match code {
234 KeyCode::Tab => {
235 state.focus = state.focus.next();
236 return Action::Continue;
237 }
238 KeyCode::BackTab => {
239 state.focus = state.focus.prev();
240 return Action::Continue;
241 }
242 KeyCode::Down => {
243 state.focus = state.focus.next();
244 return Action::Continue;
245 }
246 KeyCode::Up => {
247 state.focus = state.focus.prev();
248 return Action::Continue;
249 }
250 _ => {}
251 }
252
253 match state.focus {
255 Focus::Primary => handle_primary(state, code),
256 Focus::ZaiKey => handle_input(&mut state.zai, code),
257 Focus::OpenrouterKey => handle_input(&mut state.openrouter, code),
258 Focus::DeepseekKey => handle_input(&mut state.deepseek, code),
259 Focus::SaveButton => {
260 if matches!(code, KeyCode::Enter) {
261 return match save_to_config_default(state) {
262 Ok(()) => {
263 state.status = saved_status();
264 Action::SavedAndClose
265 }
266 Err(e) => {
267 state.status = format!("save failed: {e}");
268 Action::Continue
269 }
270 };
271 }
272 }
273 }
274 Action::Continue
275}
276
277fn handle_primary(state: &mut SettingsState, code: KeyCode) {
278 let all = VendorId::all();
280 let idx = all.iter().position(|v| *v == state.primary).unwrap_or(0) as i32;
281 let len = all.len() as i32;
282 let step = match code {
283 KeyCode::Left => -1,
284 KeyCode::Right | KeyCode::Char(' ') => 1,
285 _ => return,
286 };
287 state.primary = all[((idx + step).rem_euclid(len)) as usize];
288}
289
290fn handle_input(input: &mut KeyInput, code: KeyCode) {
291 match code {
292 KeyCode::Char(c) => input.insert_char(c),
293 KeyCode::Backspace => input.backspace(),
294 KeyCode::Delete => input.delete(),
295 KeyCode::Left => input.move_left(),
296 KeyCode::Right => input.move_right(),
297 KeyCode::Home => input.move_home(),
298 KeyCode::End => input.move_end(),
299 _ => {}
300 }
301}
302
303fn save_to_config_default(state: &SettingsState) -> Result<()> {
309 let path = default_config_path()?;
310 if let Some(parent) = path.parent() {
311 std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
312 }
313 save_to_path(state, &path)?;
314 crate::waybar::request_refresh();
315 Ok(())
316}
317
318pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
321 let original = std::fs::read_to_string(path).unwrap_or_default();
322 let mut doc: DocumentMut = if original.trim().is_empty() {
323 DocumentMut::new()
324 } else {
325 original.parse().map_err(|e: toml_edit::TomlError| {
326 AppError::Other(format!("config.toml not parseable: {e}"))
327 })?
328 };
329
330 set_string(&mut doc, "ui", "primary", state.primary.slug())?;
332 if state.zai.dirty && !state.zai.buf.is_empty() {
334 set_string(&mut doc, "zai", "api_key", &state.zai.buf)?;
335 }
336 if state.openrouter.dirty && !state.openrouter.buf.is_empty() {
338 set_string(&mut doc, "openrouter", "api_key", &state.openrouter.buf)?;
339 }
340 if state.deepseek.dirty && !state.deepseek.buf.is_empty() {
342 set_string(&mut doc, "deepseek", "api_key", &state.deepseek.buf)?;
343 }
344
345 let bytes = doc.to_string();
346 crate::cache::atomic_write(path, bytes.as_bytes())?;
347
348 #[cfg(unix)]
350 {
351 use std::os::unix::fs::PermissionsExt;
352 if let Ok(meta) = std::fs::metadata(path) {
353 let mut perms = meta.permissions();
354 perms.set_mode(0o600);
355 let _ = std::fs::set_permissions(path, perms);
356 }
357 }
358 Ok(())
359}
360
361fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
366 let table = doc
367 .entry(section)
368 .or_insert_with(toml_edit::table)
369 .as_table_mut()
370 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
371
372 if let Some(item) = table.get_mut(key) {
373 if let Some(v) = item.as_value_mut() {
374 *v = toml_edit::Value::from(new_value);
375 v.decor_mut().set_prefix(" ");
378 return Ok(());
379 }
380 }
381 table.insert(key, value(new_value));
382 Ok(())
383}
384
385fn default_config_path() -> Result<PathBuf> {
386 crate::config::default_path()
387 .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
388}
389
390pub fn render(f: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
392 let modal = centered_rect(60, 60, area);
393 f.render_widget(Clear, modal);
395
396 let accent = parse_hex(&theme.blue).unwrap_or(Color::Cyan);
397 let fg = parse_hex(&theme.fg).unwrap_or(Color::White);
398 let dim = parse_hex(&theme.dim).unwrap_or(Color::DarkGray);
399
400 let block = Block::default()
401 .title(" Settings ")
402 .borders(Borders::ALL)
403 .border_style(Style::default().fg(accent).add_modifier(Modifier::BOLD));
404 let inner = block.inner(modal);
405 f.render_widget(block, modal);
406
407 let chunks = Layout::default()
408 .direction(Direction::Vertical)
409 .constraints([
410 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), ])
424 .split(inner);
425
426 f.render_widget(
428 Paragraph::new(label(
429 "Primary vendor",
430 state.focus == Focus::Primary,
431 fg,
432 accent,
433 )),
434 chunks[0],
435 );
436 f.render_widget(
437 Paragraph::new(render_radio(&state.primary, accent, dim)),
438 chunks[1],
439 );
440
441 f.render_widget(
443 Paragraph::new(label(
444 "Z.AI API key (ZAI_API_KEY env wins if set)",
445 state.focus == Focus::ZaiKey,
446 fg,
447 accent,
448 )),
449 chunks[3],
450 );
451 f.render_widget(
452 Paragraph::new(render_input(
453 &state.zai,
454 state.focus == Focus::ZaiKey,
455 fg,
456 accent,
457 dim,
458 )),
459 chunks[4],
460 );
461
462 f.render_widget(
464 Paragraph::new(label(
465 "OpenRouter API key (OPENROUTER_API_KEY env wins if set)",
466 state.focus == Focus::OpenrouterKey,
467 fg,
468 accent,
469 )),
470 chunks[5],
471 );
472 f.render_widget(
473 Paragraph::new(render_input(
474 &state.openrouter,
475 state.focus == Focus::OpenrouterKey,
476 fg,
477 accent,
478 dim,
479 )),
480 chunks[6],
481 );
482
483 f.render_widget(
485 Paragraph::new(label(
486 "DeepSeek API key (DEEPSEEK_API_KEY env wins if set)",
487 state.focus == Focus::DeepseekKey,
488 fg,
489 accent,
490 )),
491 chunks[7],
492 );
493 f.render_widget(
494 Paragraph::new(render_input(
495 &state.deepseek,
496 state.focus == Focus::DeepseekKey,
497 fg,
498 accent,
499 dim,
500 )),
501 chunks[8],
502 );
503
504 let save_style = if state.focus == Focus::SaveButton {
506 Style::default()
507 .fg(accent)
508 .add_modifier(Modifier::BOLD | Modifier::REVERSED)
509 } else {
510 Style::default().fg(accent)
511 };
512 f.render_widget(
513 Paragraph::new(Line::from(Span::styled(
514 " [ Save (Ctrl-S) ] ",
515 save_style,
516 ))),
517 chunks[10],
518 );
519
520 if !state.status.is_empty() {
522 f.render_widget(
523 Paragraph::new(Line::from(Span::styled(
524 state.status.clone(),
525 Style::default().fg(dim),
526 ))),
527 chunks[11],
528 );
529 }
530
531 let hint = Line::from(vec![Span::styled(
533 " Tab/↑↓ move · ←→ pick vendor · Ctrl-V reveal · Ctrl-S save · Esc cancel",
534 Style::default().fg(dim),
535 )]);
536 f.render_widget(Paragraph::new(hint), chunks[12]);
537}
538
539fn label(text: &str, focused: bool, fg: Color, accent: Color) -> Line<'static> {
540 let mut style = Style::default().fg(fg);
541 if focused {
542 style = style.fg(accent).add_modifier(Modifier::BOLD);
543 }
544 Line::from(Span::styled(format!(" {text}"), style))
545}
546
547fn render_radio(selected: &VendorId, accent: Color, dim: Color) -> Line<'static> {
548 let mut spans = vec![Span::raw(" ")];
549 for v in VendorId::all() {
550 let is_sel = v == selected;
551 let glyph = if is_sel { "●" } else { "○" };
552 let style = if is_sel {
553 Style::default().fg(accent).add_modifier(Modifier::BOLD)
554 } else {
555 Style::default().fg(dim)
556 };
557 spans.push(Span::styled(
558 format!("{glyph} {} ", vendor_label(*v)),
559 style,
560 ));
561 }
562 Line::from(spans)
563}
564
565fn vendor_label(v: VendorId) -> &'static str {
566 match v {
567 VendorId::Anthropic => "Anthropic",
568 VendorId::Openai => "OpenAI",
569 VendorId::Zai => "Z.AI",
570 VendorId::Openrouter => "OpenRouter",
571 VendorId::Deepseek => "DeepSeek",
572 }
573}
574
575fn render_input(
576 input: &KeyInput,
577 focused: bool,
578 fg: Color,
579 accent: Color,
580 dim: Color,
581) -> Line<'static> {
582 let body = if input.buf.is_empty() {
583 "(empty)".to_string()
584 } else {
585 input.display()
586 };
587 let box_style = if focused {
588 Style::default().fg(accent).add_modifier(Modifier::BOLD)
589 } else {
590 Style::default().fg(fg)
591 };
592 let suffix_style = Style::default().fg(dim);
593 let suffix = if input.revealed { " [revealed]" } else { "" };
594 let cursor_hint = if focused {
595 format!(" ▏cur:{}", input.cursor)
596 } else {
597 String::new()
598 };
599 Line::from(vec![
600 Span::styled(format!(" {body}"), box_style),
601 Span::styled(format!("{suffix}{cursor_hint}"), suffix_style),
602 ])
603}
604
605fn parse_hex(s: &str) -> Option<Color> {
606 let (r, g, b) = crate::theme::parse_hex_rgb(s)?;
607 Some(Color::Rgb(r, g, b))
608}
609
610fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
612 let popup_h = (r.height * percent_y) / 100;
613 let popup_w = (r.width * percent_x) / 100;
614 Rect {
615 x: r.x + (r.width - popup_w) / 2,
616 y: r.y + (r.height - popup_h) / 2,
617 width: popup_w,
618 height: popup_h,
619 }
620}
621
622pub use crossterm::event::{KeyCode, KeyModifiers};
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use tempfile::TempDir;
629
630 fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
636 let dir = TempDir::new().unwrap();
637 let path = dir.path().join("config.toml");
638 if let Some(contents) = initial {
639 std::fs::write(&path, contents).unwrap();
640 }
641 (dir, path)
642 }
643
644 fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
645 let mut s = SettingsState {
646 focus: Focus::Primary,
647 primary,
648 zai: KeyInput::from_config(Some(zai)),
649 openrouter: KeyInput::from_config(Some(opr)),
650 deepseek: KeyInput::default(),
651 status: String::new(),
652 };
653 s.zai.dirty = true;
655 s.openrouter.dirty = true;
656 s
657 }
658
659 #[test]
660 fn focus_cycles_forward_and_backward() {
661 let order = [
662 Focus::Primary,
663 Focus::ZaiKey,
664 Focus::OpenrouterKey,
665 Focus::DeepseekKey,
666 Focus::SaveButton,
667 ];
668 let n = order.len();
669 for (i, f) in order.iter().enumerate() {
670 assert_eq!(f.next(), order[(i + 1) % n]);
671 assert_eq!(f.prev(), order[(i + n - 1) % n]);
672 }
673 }
674
675 #[test]
676 fn key_input_insert_backspace_arrow() {
677 let mut k = KeyInput::default();
678 k.insert_char('a');
679 k.insert_char('b');
680 k.insert_char('c');
681 assert_eq!(k.buf, "abc");
682 assert_eq!(k.cursor, 3);
683 assert!(k.dirty);
684 k.move_left();
685 k.move_left();
686 assert_eq!(k.cursor, 1);
687 k.insert_char('x'); assert_eq!(k.buf, "axbc");
689 assert_eq!(k.cursor, 2);
690 k.backspace();
691 assert_eq!(k.buf, "abc");
692 assert_eq!(k.cursor, 1);
693 }
694
695 #[test]
696 fn key_input_masks_by_default_reveals_on_toggle() {
697 let mut k = KeyInput::default();
698 for c in "secret-key".chars() {
699 k.insert_char(c);
700 }
701 assert_eq!(k.display(), "•".repeat(10));
702 k.toggle_reveal();
703 assert_eq!(k.display(), "secret-key");
704 }
705
706 #[test]
707 fn key_input_handles_unicode() {
708 let mut k = KeyInput::default();
709 k.insert_char('a');
710 k.insert_char('→');
711 k.insert_char('b');
712 assert_eq!(k.buf, "a→b");
713 assert_eq!(k.cursor, 3);
714 k.move_left();
715 k.backspace(); assert_eq!(k.buf, "ab");
717 }
718
719 #[test]
720 fn save_to_path_writes_minimal_toml_when_starting_empty() {
721 let (_dir, path) = temp_config(None);
722 let s = state_with("zk", "ok", VendorId::Zai);
723 save_to_path(&s, &path).unwrap();
724 let raw = std::fs::read_to_string(&path).unwrap();
725 assert!(raw.contains("primary = \"zai\""));
726 assert!(raw.contains("[zai]"));
727 assert!(raw.contains("api_key = \"zk\""));
728 assert!(raw.contains("[openrouter]"));
729 assert!(raw.contains("api_key = \"ok\""));
730 }
731
732 #[test]
733 fn save_to_path_preserves_existing_comments_and_unrelated_fields() {
734 let (_dir, path) = temp_config(Some(
735 r##"# my comment
736[ui]
737# pre-existing comment
738primary = "anthropic"
739
740[zai]
741enabled = true
742api_key_env = "ZAI_API_KEY"
743# tier comment
744plan_tier = "pro"
745
746[openrouter]
747enabled = true
748api_key_env = "OPENROUTER_API_KEY"
749"##,
750 ));
751
752 let s = state_with("zk2", "ok2", VendorId::Openrouter);
753 save_to_path(&s, &path).unwrap();
754
755 let raw = std::fs::read_to_string(&path).unwrap();
756 assert!(raw.contains("# my comment"));
758 assert!(raw.contains("# pre-existing comment"));
759 assert!(raw.contains("# tier comment"));
760 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
762 assert!(raw.contains("plan_tier = \"pro\""));
763 assert!(raw.contains("primary = \"openrouter\""));
765 assert!(raw.contains("api_key = \"zk2\""));
767 assert!(raw.contains("api_key = \"ok2\""));
768 }
769
770 #[test]
771 fn save_does_not_write_empty_key_when_dirty_but_blank() {
772 let (_dir, path) = temp_config(None);
773 let mut s = state_with("", "", VendorId::Anthropic);
774 s.zai.dirty = true;
777 s.openrouter.dirty = true;
778 save_to_path(&s, &path).unwrap();
779 let raw = std::fs::read_to_string(&path).unwrap();
780 assert!(!raw.contains("api_key ="));
782 }
783
784 #[test]
785 #[cfg(unix)]
786 fn save_chmods_to_600() {
787 use std::os::unix::fs::PermissionsExt;
788 let (_dir, path) = temp_config(None);
789 let s = state_with("zk", "ok", VendorId::Zai);
790 save_to_path(&s, &path).unwrap();
791 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
792 assert_eq!(mode & 0o777, 0o600);
793 }
794
795 #[test]
796 fn handle_key_tab_cycles_focus() {
797 let mut s = SettingsState {
798 focus: Focus::Primary,
799 primary: VendorId::Anthropic,
800 zai: KeyInput::default(),
801 openrouter: KeyInput::default(),
802 deepseek: KeyInput::default(),
803 status: String::new(),
804 };
805 assert_eq!(
806 handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
807 Action::Continue
808 );
809 assert_eq!(s.focus, Focus::ZaiKey);
810 assert_eq!(
811 handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
812 Action::Continue
813 );
814 assert_eq!(s.focus, Focus::Primary);
815 }
816
817 #[test]
818 fn handle_key_esc_closes_without_saving() {
819 let mut s = SettingsState {
820 focus: Focus::Primary,
821 primary: VendorId::Anthropic,
822 zai: KeyInput::default(),
823 openrouter: KeyInput::default(),
824 deepseek: KeyInput::default(),
825 status: String::new(),
826 };
827 assert_eq!(
828 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
829 Action::Close
830 );
831 }
832
833 #[test]
834 fn handle_key_left_right_cycles_primary_vendor() {
835 let mut s = SettingsState {
836 focus: Focus::Primary,
837 primary: VendorId::Anthropic,
838 zai: KeyInput::default(),
839 openrouter: KeyInput::default(),
840 deepseek: KeyInput::default(),
841 status: String::new(),
842 };
843 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
844 assert_eq!(s.primary, VendorId::Openai);
845 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
846 assert_eq!(s.primary, VendorId::Zai);
847 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
848 assert_eq!(s.primary, VendorId::Openai);
849 }
850
851 #[test]
852 fn handle_key_ctrl_v_toggles_reveal_on_focused_key_field() {
853 let mut s = SettingsState {
854 focus: Focus::ZaiKey,
855 primary: VendorId::Anthropic,
856 zai: KeyInput::from_config(Some("secret")),
857 openrouter: KeyInput::default(),
858 deepseek: KeyInput::default(),
859 status: String::new(),
860 };
861 assert!(!s.zai.revealed);
862 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
863 assert!(s.zai.revealed);
864 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
865 assert!(!s.zai.revealed);
866 }
867
868 #[test]
869 fn handle_key_ctrl_s_attempts_save_from_any_field() {
870 let (_dir, path) = temp_config(None);
871 let s = state_with("zk", "ok", VendorId::Zai);
874 save_to_path(&s, &path).unwrap();
875 let raw = std::fs::read_to_string(&path).unwrap();
876 assert!(raw.contains("api_key = \"zk\""));
877 }
878}