1use std::path::{Path, PathBuf};
10
11use ratatui::Frame;
12use ratatui::layout::{Constraint, Direction, Layout, Rect};
13use ratatui::style::Modifier;
14use ratatui::text::{Line, Span};
15use ratatui::widgets::{Clear, Paragraph};
16use ratatui_bubbletea_theme::BubbleTheme;
17use toml_edit::{DocumentMut, value};
18
19use crate::config::Config;
20use crate::error::{AppError, Result};
21use crate::theme::Theme;
22use crate::tui::style::bubble_theme;
23use crate::vendor::VendorId;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Focus {
28 Primary,
29 ZaiKey,
30 OpenrouterKey,
31 DeepseekKey,
32 SaveButton,
33}
34
35impl Focus {
36 pub fn next(self) -> Self {
37 match self {
38 Focus::Primary => Focus::ZaiKey,
39 Focus::ZaiKey => Focus::OpenrouterKey,
40 Focus::OpenrouterKey => Focus::DeepseekKey,
41 Focus::DeepseekKey => Focus::SaveButton,
42 Focus::SaveButton => Focus::Primary,
43 }
44 }
45 pub fn prev(self) -> Self {
46 match self {
47 Focus::Primary => Focus::SaveButton,
48 Focus::ZaiKey => Focus::Primary,
49 Focus::OpenrouterKey => Focus::ZaiKey,
50 Focus::DeepseekKey => Focus::OpenrouterKey,
51 Focus::SaveButton => Focus::DeepseekKey,
52 }
53 }
54}
55
56#[derive(Debug, Clone, Default)]
58pub struct KeyInput {
59 pub buf: String,
60 pub cursor: usize,
62 pub revealed: bool,
64 pub dirty: bool,
68}
69
70impl KeyInput {
71 pub fn from_config(initial: Option<&str>) -> Self {
72 let buf = initial.unwrap_or("").to_string();
73 let cursor = buf.chars().count();
74 Self {
75 buf,
76 cursor,
77 revealed: false,
78 dirty: false,
79 }
80 }
81
82 pub fn insert_char(&mut self, c: char) {
83 let byte_idx = self.char_to_byte(self.cursor);
84 self.buf.insert(byte_idx, c);
85 self.cursor += 1;
86 self.dirty = true;
87 }
88
89 pub fn backspace(&mut self) {
90 if self.cursor == 0 {
91 return;
92 }
93 let prev_byte = self.char_to_byte(self.cursor - 1);
94 let cur_byte = self.char_to_byte(self.cursor);
95 self.buf.replace_range(prev_byte..cur_byte, "");
96 self.cursor -= 1;
97 self.dirty = true;
98 }
99
100 pub fn delete(&mut self) {
101 let n = self.buf.chars().count();
102 if self.cursor >= n {
103 return;
104 }
105 let cur_byte = self.char_to_byte(self.cursor);
106 let next_byte = self.char_to_byte(self.cursor + 1);
107 self.buf.replace_range(cur_byte..next_byte, "");
108 self.dirty = true;
109 }
110
111 pub fn move_left(&mut self) {
112 if self.cursor > 0 {
113 self.cursor -= 1;
114 }
115 }
116 pub fn move_right(&mut self) {
117 if self.cursor < self.buf.chars().count() {
118 self.cursor += 1;
119 }
120 }
121 pub fn move_home(&mut self) {
122 self.cursor = 0;
123 }
124 pub fn move_end(&mut self) {
125 self.cursor = self.buf.chars().count();
126 }
127 pub fn toggle_reveal(&mut self) {
128 self.revealed = !self.revealed;
129 }
130
131 pub fn display(&self) -> String {
133 if self.revealed {
134 self.buf.clone()
135 } else {
136 "•".repeat(self.buf.chars().count())
137 }
138 }
139
140 fn char_to_byte(&self, char_idx: usize) -> usize {
141 self.buf
142 .char_indices()
143 .map(|(b, _)| b)
144 .chain(std::iter::once(self.buf.len()))
145 .nth(char_idx)
146 .unwrap_or(self.buf.len())
147 }
148}
149
150#[derive(Debug, Clone)]
152pub struct SettingsState {
153 pub focus: Focus,
154 pub primary: VendorId,
155 pub zai: KeyInput,
156 pub openrouter: KeyInput,
157 pub deepseek: KeyInput,
158 pub status: String,
160}
161
162impl SettingsState {
163 pub fn from_config(cfg: &Config) -> Self {
164 Self {
165 focus: Focus::Primary,
166 primary: cfg.ui.primary.unwrap_or(VendorId::Anthropic),
167 zai: KeyInput::from_config(cfg.zai.api_key.as_deref()),
168 openrouter: KeyInput::from_config(cfg.openrouter.api_key.as_deref()),
169 deepseek: KeyInput::from_config(cfg.deepseek.api_key.as_deref()),
170 status: String::new(),
171 }
172 }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum Action {
178 Continue,
180 Close,
182 SavedAndClose,
184}
185
186#[cfg(unix)]
190const PERMS_NOTE: &str = " (chmod 600)";
191#[cfg(not(unix))]
192const PERMS_NOTE: &str = "";
193
194fn saved_status() -> String {
197 format!(
198 "saved to {}{}",
199 crate::config::config_path_hint(),
200 PERMS_NOTE
201 )
202}
203
204pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
206 if matches!(code, KeyCode::Esc) {
208 return Action::Close;
209 }
210 if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
212 return match save_to_config_default(state) {
213 Ok(()) => {
214 state.status = saved_status();
215 Action::SavedAndClose
216 }
217 Err(e) => {
218 state.status = format!("save failed: {e}");
219 Action::Continue
220 }
221 };
222 }
223 if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
225 match state.focus {
226 Focus::ZaiKey => state.zai.toggle_reveal(),
227 Focus::OpenrouterKey => state.openrouter.toggle_reveal(),
228 Focus::DeepseekKey => state.deepseek.toggle_reveal(),
229 _ => {}
230 }
231 return Action::Continue;
232 }
233
234 match code {
236 KeyCode::Tab => {
237 state.focus = state.focus.next();
238 return Action::Continue;
239 }
240 KeyCode::BackTab => {
241 state.focus = state.focus.prev();
242 return Action::Continue;
243 }
244 KeyCode::Down => {
245 state.focus = state.focus.next();
246 return Action::Continue;
247 }
248 KeyCode::Up => {
249 state.focus = state.focus.prev();
250 return Action::Continue;
251 }
252 _ => {}
253 }
254
255 match state.focus {
257 Focus::Primary => handle_primary(state, code),
258 Focus::ZaiKey => handle_input(&mut state.zai, code),
259 Focus::OpenrouterKey => handle_input(&mut state.openrouter, code),
260 Focus::DeepseekKey => handle_input(&mut state.deepseek, code),
261 Focus::SaveButton => {
262 if matches!(code, KeyCode::Enter) {
263 return match save_to_config_default(state) {
264 Ok(()) => {
265 state.status = saved_status();
266 Action::SavedAndClose
267 }
268 Err(e) => {
269 state.status = format!("save failed: {e}");
270 Action::Continue
271 }
272 };
273 }
274 }
275 }
276 Action::Continue
277}
278
279fn handle_primary(state: &mut SettingsState, code: KeyCode) {
280 let all = VendorId::all();
282 let idx = all.iter().position(|v| *v == state.primary).unwrap_or(0) as i32;
283 let len = all.len() as i32;
284 let step = match code {
285 KeyCode::Left => -1,
286 KeyCode::Right | KeyCode::Char(' ') => 1,
287 _ => return,
288 };
289 state.primary = all[((idx + step).rem_euclid(len)) as usize];
290}
291
292fn handle_input(input: &mut KeyInput, code: KeyCode) {
293 match code {
294 KeyCode::Char(c) => input.insert_char(c),
295 KeyCode::Backspace => input.backspace(),
296 KeyCode::Delete => input.delete(),
297 KeyCode::Left => input.move_left(),
298 KeyCode::Right => input.move_right(),
299 KeyCode::Home => input.move_home(),
300 KeyCode::End => input.move_end(),
301 _ => {}
302 }
303}
304
305fn save_to_config_default(state: &SettingsState) -> Result<()> {
311 let path = default_config_path()?;
312 if let Some(parent) = path.parent() {
313 std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
314 }
315 save_to_path(state, &path)?;
316 crate::waybar::request_refresh();
317 Ok(())
318}
319
320pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
323 let original = std::fs::read_to_string(path).unwrap_or_default();
324 let mut doc: DocumentMut = if original.trim().is_empty() {
325 DocumentMut::new()
326 } else {
327 original.parse().map_err(|e: toml_edit::TomlError| {
328 AppError::Other(format!("config.toml not parseable: {e}"))
329 })?
330 };
331
332 set_string(&mut doc, "ui", "primary", state.primary.slug())?;
334 if state.zai.dirty && !state.zai.buf.is_empty() {
336 set_string(&mut doc, "zai", "api_key", &state.zai.buf)?;
337 }
338 if state.openrouter.dirty && !state.openrouter.buf.is_empty() {
340 set_string(&mut doc, "openrouter", "api_key", &state.openrouter.buf)?;
341 }
342 if state.deepseek.dirty && !state.deepseek.buf.is_empty() {
344 set_string(&mut doc, "deepseek", "api_key", &state.deepseek.buf)?;
345 }
346
347 let bytes = doc.to_string();
348 crate::cache::atomic_write(path, bytes.as_bytes())?;
349
350 #[cfg(unix)]
352 {
353 use std::os::unix::fs::PermissionsExt;
354 if let Ok(meta) = std::fs::metadata(path) {
355 let mut perms = meta.permissions();
356 perms.set_mode(0o600);
357 let _ = std::fs::set_permissions(path, perms);
358 }
359 }
360 Ok(())
361}
362
363fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
368 let table = doc
369 .entry(section)
370 .or_insert_with(toml_edit::table)
371 .as_table_mut()
372 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
373
374 if let Some(item) = table.get_mut(key)
375 && let Some(v) = item.as_value_mut()
376 {
377 *v = toml_edit::Value::from(new_value);
378 v.decor_mut().set_prefix(" ");
381 return Ok(());
382 }
383 table.insert(key, value(new_value));
384 Ok(())
385}
386
387fn default_config_path() -> Result<PathBuf> {
388 crate::config::default_path()
389 .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
390}
391
392pub fn render(f: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
394 let modal = centered_rect(60, 60, area);
395 f.render_widget(Clear, modal);
397
398 let bubble = bubble_theme(theme);
399
400 let block = bubble.titled_modal_block(" Settings ");
401 let inner = block.inner(modal);
402 f.render_widget(block, modal);
403
404 let chunks = Layout::default()
405 .direction(Direction::Vertical)
406 .constraints([
407 Constraint::Length(1), 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), ])
421 .split(inner);
422
423 f.render_widget(
425 Paragraph::new(label(
426 "Primary vendor",
427 state.focus == Focus::Primary,
428 &bubble,
429 )),
430 chunks[0],
431 );
432 f.render_widget(
433 Paragraph::new(render_radio(&state.primary, &bubble)),
434 chunks[1],
435 );
436
437 f.render_widget(
439 Paragraph::new(label(
440 "Z.AI API key (ZAI_API_KEY env wins if set)",
441 state.focus == Focus::ZaiKey,
442 &bubble,
443 )),
444 chunks[3],
445 );
446 f.render_widget(
447 Paragraph::new(render_input(
448 &state.zai,
449 state.focus == Focus::ZaiKey,
450 &bubble,
451 )),
452 chunks[4],
453 );
454
455 f.render_widget(
457 Paragraph::new(label(
458 "OpenRouter API key (OPENROUTER_API_KEY env wins if set)",
459 state.focus == Focus::OpenrouterKey,
460 &bubble,
461 )),
462 chunks[5],
463 );
464 f.render_widget(
465 Paragraph::new(render_input(
466 &state.openrouter,
467 state.focus == Focus::OpenrouterKey,
468 &bubble,
469 )),
470 chunks[6],
471 );
472
473 f.render_widget(
475 Paragraph::new(label(
476 "DeepSeek API key (DEEPSEEK_API_KEY env wins if set)",
477 state.focus == Focus::DeepseekKey,
478 &bubble,
479 )),
480 chunks[7],
481 );
482 f.render_widget(
483 Paragraph::new(render_input(
484 &state.deepseek,
485 state.focus == Focus::DeepseekKey,
486 &bubble,
487 )),
488 chunks[8],
489 );
490
491 let save_style = if state.focus == Focus::SaveButton {
493 bubble.selected.add_modifier(Modifier::REVERSED)
494 } else {
495 bubble.accent.add_modifier(Modifier::BOLD)
496 };
497 f.render_widget(
498 Paragraph::new(Line::from(Span::styled(
499 " [ Save (Ctrl-S) ] ",
500 save_style,
501 ))),
502 chunks[10],
503 );
504
505 if !state.status.is_empty() {
507 f.render_widget(
508 Paragraph::new(Line::from(Span::styled(state.status.clone(), bubble.muted))),
509 chunks[11],
510 );
511 }
512
513 let hint = bubble.help_line([
515 ("tab/up/down", "move"),
516 ("left/right", "pick"),
517 ("ctrl+v", "reveal"),
518 ("ctrl+s", "save"),
519 ("esc", "cancel"),
520 ]);
521 f.render_widget(Paragraph::new(hint), chunks[12]);
522}
523
524fn label(text: &str, focused: bool, theme: &BubbleTheme) -> Line<'static> {
525 let marker = if focused {
526 theme.symbols.selected
527 } else {
528 theme.symbols.bullet
529 };
530 let marker_style = if focused { theme.accent } else { theme.muted };
531 let text_style = if focused { theme.title } else { theme.text };
532 Line::from(vec![
533 theme.muted(" "),
534 Span::styled(marker, marker_style),
535 theme.span(" "),
536 Span::styled(text.to_string(), text_style),
537 ])
538}
539
540fn render_radio(selected: &VendorId, theme: &BubbleTheme) -> Line<'static> {
541 let mut spans = vec![theme.muted(" ")];
542 for v in VendorId::all() {
543 let is_sel = v == selected;
544 let glyph = if is_sel {
545 theme.symbols.selected
546 } else {
547 theme.symbols.bullet
548 };
549 let style = if is_sel { theme.selected } else { theme.muted };
550 spans.push(Span::styled(
551 format!("{glyph} {} ", vendor_label(*v)),
552 style,
553 ));
554 }
555 Line::from(spans)
556}
557
558fn vendor_label(v: VendorId) -> &'static str {
559 match v {
560 VendorId::Anthropic => "Anthropic",
561 VendorId::Openai => "OpenAI",
562 VendorId::Zai => "Z.AI",
563 VendorId::Openrouter => "OpenRouter",
564 VendorId::Deepseek => "DeepSeek",
565 }
566}
567
568fn render_input(input: &KeyInput, focused: bool, theme: &BubbleTheme) -> Line<'static> {
569 let body = if input.buf.is_empty() {
570 "(empty)".to_string()
571 } else {
572 input.display()
573 };
574 let box_style = if focused {
575 theme.accent.add_modifier(Modifier::BOLD)
576 } else {
577 theme.text
578 };
579 let suffix_style = theme.muted;
580 let suffix = if input.revealed { " [revealed]" } else { "" };
581 let cursor_hint = if focused {
582 format!(" ▏cur:{}", input.cursor)
583 } else {
584 String::new()
585 };
586 Line::from(vec![
587 theme.muted(" "),
588 Span::styled(body, box_style),
589 Span::styled(format!("{suffix}{cursor_hint}"), suffix_style),
590 ])
591}
592
593fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
595 let popup_h = (r.height * percent_y) / 100;
596 let popup_w = (r.width * percent_x) / 100;
597 Rect {
598 x: r.x + (r.width - popup_w) / 2,
599 y: r.y + (r.height - popup_h) / 2,
600 width: popup_w,
601 height: popup_h,
602 }
603}
604
605pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use tempfile::TempDir;
612
613 fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
619 let dir = TempDir::new().unwrap();
620 let path = dir.path().join("config.toml");
621 if let Some(contents) = initial {
622 std::fs::write(&path, contents).unwrap();
623 }
624 (dir, path)
625 }
626
627 fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
628 let mut s = SettingsState {
629 focus: Focus::Primary,
630 primary,
631 zai: KeyInput::from_config(Some(zai)),
632 openrouter: KeyInput::from_config(Some(opr)),
633 deepseek: KeyInput::default(),
634 status: String::new(),
635 };
636 s.zai.dirty = true;
638 s.openrouter.dirty = true;
639 s
640 }
641
642 #[test]
643 fn focus_cycles_forward_and_backward() {
644 let order = [
645 Focus::Primary,
646 Focus::ZaiKey,
647 Focus::OpenrouterKey,
648 Focus::DeepseekKey,
649 Focus::SaveButton,
650 ];
651 let n = order.len();
652 for (i, f) in order.iter().enumerate() {
653 assert_eq!(f.next(), order[(i + 1) % n]);
654 assert_eq!(f.prev(), order[(i + n - 1) % n]);
655 }
656 }
657
658 #[test]
659 fn key_input_insert_backspace_arrow() {
660 let mut k = KeyInput::default();
661 k.insert_char('a');
662 k.insert_char('b');
663 k.insert_char('c');
664 assert_eq!(k.buf, "abc");
665 assert_eq!(k.cursor, 3);
666 assert!(k.dirty);
667 k.move_left();
668 k.move_left();
669 assert_eq!(k.cursor, 1);
670 k.insert_char('x'); assert_eq!(k.buf, "axbc");
672 assert_eq!(k.cursor, 2);
673 k.backspace();
674 assert_eq!(k.buf, "abc");
675 assert_eq!(k.cursor, 1);
676 }
677
678 #[test]
679 fn key_input_masks_by_default_reveals_on_toggle() {
680 let mut k = KeyInput::default();
681 for c in "secret-key".chars() {
682 k.insert_char(c);
683 }
684 assert_eq!(k.display(), "•".repeat(10));
685 k.toggle_reveal();
686 assert_eq!(k.display(), "secret-key");
687 }
688
689 #[test]
690 fn key_input_handles_unicode() {
691 let mut k = KeyInput::default();
692 k.insert_char('a');
693 k.insert_char('→');
694 k.insert_char('b');
695 assert_eq!(k.buf, "a→b");
696 assert_eq!(k.cursor, 3);
697 k.move_left();
698 k.backspace(); assert_eq!(k.buf, "ab");
700 }
701
702 #[test]
703 fn save_to_path_writes_minimal_toml_when_starting_empty() {
704 let (_dir, path) = temp_config(None);
705 let s = state_with("zk", "ok", VendorId::Zai);
706 save_to_path(&s, &path).unwrap();
707 let raw = std::fs::read_to_string(&path).unwrap();
708 assert!(raw.contains("primary = \"zai\""));
709 assert!(raw.contains("[zai]"));
710 assert!(raw.contains("api_key = \"zk\""));
711 assert!(raw.contains("[openrouter]"));
712 assert!(raw.contains("api_key = \"ok\""));
713 }
714
715 #[test]
716 fn save_to_path_preserves_existing_comments_and_unrelated_fields() {
717 let (_dir, path) = temp_config(Some(
718 r##"# my comment
719[ui]
720# pre-existing comment
721primary = "anthropic"
722
723[zai]
724enabled = true
725api_key_env = "ZAI_API_KEY"
726# tier comment
727plan_tier = "pro"
728
729[openrouter]
730enabled = true
731api_key_env = "OPENROUTER_API_KEY"
732"##,
733 ));
734
735 let s = state_with("zk2", "ok2", VendorId::Openrouter);
736 save_to_path(&s, &path).unwrap();
737
738 let raw = std::fs::read_to_string(&path).unwrap();
739 assert!(raw.contains("# my comment"));
741 assert!(raw.contains("# pre-existing comment"));
742 assert!(raw.contains("# tier comment"));
743 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
745 assert!(raw.contains("plan_tier = \"pro\""));
746 assert!(raw.contains("primary = \"openrouter\""));
748 assert!(raw.contains("api_key = \"zk2\""));
750 assert!(raw.contains("api_key = \"ok2\""));
751 }
752
753 #[test]
754 fn save_does_not_write_empty_key_when_dirty_but_blank() {
755 let (_dir, path) = temp_config(None);
756 let mut s = state_with("", "", VendorId::Anthropic);
757 s.zai.dirty = true;
760 s.openrouter.dirty = true;
761 save_to_path(&s, &path).unwrap();
762 let raw = std::fs::read_to_string(&path).unwrap();
763 assert!(!raw.contains("api_key ="));
765 }
766
767 #[test]
768 #[cfg(unix)]
769 fn save_chmods_to_600() {
770 use std::os::unix::fs::PermissionsExt;
771 let (_dir, path) = temp_config(None);
772 let s = state_with("zk", "ok", VendorId::Zai);
773 save_to_path(&s, &path).unwrap();
774 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
775 assert_eq!(mode & 0o777, 0o600);
776 }
777
778 #[test]
779 fn handle_key_tab_cycles_focus() {
780 let mut s = SettingsState {
781 focus: Focus::Primary,
782 primary: VendorId::Anthropic,
783 zai: KeyInput::default(),
784 openrouter: KeyInput::default(),
785 deepseek: KeyInput::default(),
786 status: String::new(),
787 };
788 assert_eq!(
789 handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
790 Action::Continue
791 );
792 assert_eq!(s.focus, Focus::ZaiKey);
793 assert_eq!(
794 handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
795 Action::Continue
796 );
797 assert_eq!(s.focus, Focus::Primary);
798 }
799
800 #[test]
801 fn handle_key_esc_closes_without_saving() {
802 let mut s = SettingsState {
803 focus: Focus::Primary,
804 primary: VendorId::Anthropic,
805 zai: KeyInput::default(),
806 openrouter: KeyInput::default(),
807 deepseek: KeyInput::default(),
808 status: String::new(),
809 };
810 assert_eq!(
811 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
812 Action::Close
813 );
814 }
815
816 #[test]
817 fn handle_key_left_right_cycles_primary_vendor() {
818 let mut s = SettingsState {
819 focus: Focus::Primary,
820 primary: VendorId::Anthropic,
821 zai: KeyInput::default(),
822 openrouter: KeyInput::default(),
823 deepseek: KeyInput::default(),
824 status: String::new(),
825 };
826 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
827 assert_eq!(s.primary, VendorId::Openai);
828 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
829 assert_eq!(s.primary, VendorId::Zai);
830 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
831 assert_eq!(s.primary, VendorId::Openai);
832 }
833
834 #[test]
835 fn handle_key_ctrl_v_toggles_reveal_on_focused_key_field() {
836 let mut s = SettingsState {
837 focus: Focus::ZaiKey,
838 primary: VendorId::Anthropic,
839 zai: KeyInput::from_config(Some("secret")),
840 openrouter: KeyInput::default(),
841 deepseek: KeyInput::default(),
842 status: String::new(),
843 };
844 assert!(!s.zai.revealed);
845 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
846 assert!(s.zai.revealed);
847 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
848 assert!(!s.zai.revealed);
849 }
850
851 #[test]
852 fn handle_key_ctrl_s_attempts_save_from_any_field() {
853 let (_dir, path) = temp_config(None);
854 let s = state_with("zk", "ok", VendorId::Zai);
857 save_to_path(&s, &path).unwrap();
858 let raw = std::fs::read_to_string(&path).unwrap();
859 assert!(raw.contains("api_key = \"zk\""));
860 }
861}