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 KimiKey,
33 SaveButton,
34}
35
36impl Focus {
37 pub fn next(self) -> Self {
38 match self {
39 Focus::Primary => Focus::ZaiKey,
40 Focus::ZaiKey => Focus::OpenrouterKey,
41 Focus::OpenrouterKey => Focus::DeepseekKey,
42 Focus::DeepseekKey => Focus::KimiKey,
43 Focus::KimiKey => Focus::SaveButton,
44 Focus::SaveButton => Focus::Primary,
45 }
46 }
47 pub fn prev(self) -> Self {
48 match self {
49 Focus::Primary => Focus::SaveButton,
50 Focus::ZaiKey => Focus::Primary,
51 Focus::OpenrouterKey => Focus::ZaiKey,
52 Focus::DeepseekKey => Focus::OpenrouterKey,
53 Focus::KimiKey => Focus::DeepseekKey,
54 Focus::SaveButton => Focus::KimiKey,
55 }
56 }
57}
58
59#[derive(Debug, Clone, Default)]
61pub struct KeyInput {
62 pub buf: String,
63 pub cursor: usize,
65 pub revealed: bool,
67 pub dirty: bool,
71}
72
73impl KeyInput {
74 pub fn from_config(initial: Option<&str>) -> Self {
75 let buf = initial.unwrap_or("").to_string();
76 let cursor = buf.chars().count();
77 Self {
78 buf,
79 cursor,
80 revealed: false,
81 dirty: false,
82 }
83 }
84
85 pub fn insert_char(&mut self, c: char) {
86 let byte_idx = self.char_to_byte(self.cursor);
87 self.buf.insert(byte_idx, c);
88 self.cursor += 1;
89 self.dirty = true;
90 }
91
92 pub fn backspace(&mut self) {
93 if self.cursor == 0 {
94 return;
95 }
96 let prev_byte = self.char_to_byte(self.cursor - 1);
97 let cur_byte = self.char_to_byte(self.cursor);
98 self.buf.replace_range(prev_byte..cur_byte, "");
99 self.cursor -= 1;
100 self.dirty = true;
101 }
102
103 pub fn delete(&mut self) {
104 let n = self.buf.chars().count();
105 if self.cursor >= n {
106 return;
107 }
108 let cur_byte = self.char_to_byte(self.cursor);
109 let next_byte = self.char_to_byte(self.cursor + 1);
110 self.buf.replace_range(cur_byte..next_byte, "");
111 self.dirty = true;
112 }
113
114 pub fn move_left(&mut self) {
115 if self.cursor > 0 {
116 self.cursor -= 1;
117 }
118 }
119 pub fn move_right(&mut self) {
120 if self.cursor < self.buf.chars().count() {
121 self.cursor += 1;
122 }
123 }
124 pub fn move_home(&mut self) {
125 self.cursor = 0;
126 }
127 pub fn move_end(&mut self) {
128 self.cursor = self.buf.chars().count();
129 }
130 pub fn toggle_reveal(&mut self) {
131 self.revealed = !self.revealed;
132 }
133
134 pub fn display(&self) -> String {
136 if self.revealed {
137 self.buf.clone()
138 } else {
139 "•".repeat(self.buf.chars().count())
140 }
141 }
142
143 fn char_to_byte(&self, char_idx: usize) -> usize {
144 self.buf
145 .char_indices()
146 .map(|(b, _)| b)
147 .chain(std::iter::once(self.buf.len()))
148 .nth(char_idx)
149 .unwrap_or(self.buf.len())
150 }
151}
152
153#[derive(Debug, Clone)]
155pub struct SettingsState {
156 pub focus: Focus,
157 pub primary_choices: Vec<VendorId>,
160 pub primary: VendorId,
161 pub zai: KeyInput,
162 pub openrouter: KeyInput,
163 pub deepseek: KeyInput,
164 pub kimi: KeyInput,
165 pub status: String,
167}
168
169impl SettingsState {
170 pub fn from_config(cfg: &Config) -> Self {
171 let primary_choices = cfg.enabled_vendors();
172 let primary = cfg
176 .ui
177 .primary
178 .filter(|vendor| primary_choices.contains(vendor))
179 .or_else(|| primary_choices.first().copied())
180 .unwrap_or_else(|| cfg.ui.primary.unwrap_or(VendorId::Anthropic));
181 Self {
182 focus: Focus::Primary,
183 primary_choices,
184 primary,
185 zai: KeyInput::from_config(cfg.zai.api_key.as_deref()),
186 openrouter: KeyInput::from_config(cfg.openrouter.api_key.as_deref()),
187 deepseek: KeyInput::from_config(cfg.deepseek.api_key.as_deref()),
188 kimi: KeyInput::from_config(cfg.kimi.api_key.as_deref()),
189 status: String::new(),
190 }
191 }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum Action {
197 Continue,
199 Close,
201 SavedAndClose,
203}
204
205#[cfg(unix)]
209const PERMS_NOTE: &str = " (chmod 600)";
210#[cfg(not(unix))]
211const PERMS_NOTE: &str = "";
212
213fn saved_status() -> String {
216 format!(
217 "saved to {}{}",
218 crate::config::config_path_hint(),
219 PERMS_NOTE
220 )
221}
222
223pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
225 if matches!(code, KeyCode::Esc) {
227 return Action::Close;
228 }
229 if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
231 return match save_to_config_default(state) {
232 Ok(()) => {
233 state.status = saved_status();
234 Action::SavedAndClose
235 }
236 Err(e) => {
237 state.status = format!("save failed: {e}");
238 Action::Continue
239 }
240 };
241 }
242 if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
244 match state.focus {
245 Focus::ZaiKey => state.zai.toggle_reveal(),
246 Focus::OpenrouterKey => state.openrouter.toggle_reveal(),
247 Focus::DeepseekKey => state.deepseek.toggle_reveal(),
248 Focus::KimiKey => state.kimi.toggle_reveal(),
249 _ => {}
250 }
251 return Action::Continue;
252 }
253
254 match code {
256 KeyCode::Tab => {
257 state.focus = state.focus.next();
258 return Action::Continue;
259 }
260 KeyCode::BackTab => {
261 state.focus = state.focus.prev();
262 return Action::Continue;
263 }
264 KeyCode::Down => {
265 state.focus = state.focus.next();
266 return Action::Continue;
267 }
268 KeyCode::Up => {
269 state.focus = state.focus.prev();
270 return Action::Continue;
271 }
272 _ => {}
273 }
274
275 match state.focus {
277 Focus::Primary => handle_primary(state, code),
278 Focus::ZaiKey => handle_input(&mut state.zai, code),
279 Focus::OpenrouterKey => handle_input(&mut state.openrouter, code),
280 Focus::DeepseekKey => handle_input(&mut state.deepseek, code),
281 Focus::KimiKey => handle_input(&mut state.kimi, code),
282 Focus::SaveButton => {
283 if matches!(code, KeyCode::Enter) {
284 return match save_to_config_default(state) {
285 Ok(()) => {
286 state.status = saved_status();
287 Action::SavedAndClose
288 }
289 Err(e) => {
290 state.status = format!("save failed: {e}");
291 Action::Continue
292 }
293 };
294 }
295 }
296 }
297 Action::Continue
298}
299
300fn handle_primary(state: &mut SettingsState, code: KeyCode) {
301 let choices = &state.primary_choices;
303 let Some(idx) = choices.iter().position(|v| *v == state.primary) else {
304 return;
305 };
306 let step = match code {
307 KeyCode::Left => -1,
308 KeyCode::Right | KeyCode::Char(' ') => 1,
309 _ => return,
310 };
311 state.primary = choices[((idx as i32 + step).rem_euclid(choices.len() as i32)) as usize];
312}
313
314fn handle_input(input: &mut KeyInput, code: KeyCode) {
315 match code {
316 KeyCode::Char(c) => input.insert_char(c),
317 KeyCode::Backspace => input.backspace(),
318 KeyCode::Delete => input.delete(),
319 KeyCode::Left => input.move_left(),
320 KeyCode::Right => input.move_right(),
321 KeyCode::Home => input.move_home(),
322 KeyCode::End => input.move_end(),
323 _ => {}
324 }
325}
326
327fn save_to_config_default(state: &SettingsState) -> Result<()> {
333 let path = default_config_path()?;
334 if let Some(parent) = path.parent() {
335 std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
336 }
337 save_to_path(state, &path)?;
338 crate::waybar::request_refresh();
339 Ok(())
340}
341
342pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
345 let original = std::fs::read_to_string(path).unwrap_or_default();
346 let mut doc: DocumentMut = if original.trim().is_empty() {
347 DocumentMut::new()
348 } else {
349 original.parse().map_err(|e: toml_edit::TomlError| {
350 AppError::Other(format!("config.toml not parseable: {e}"))
351 })?
352 };
353
354 if state.primary_choices.contains(&state.primary) {
358 set_string(&mut doc, "ui", "primary", state.primary.slug())?;
359 }
360 update_key(&mut doc, "zai", &state.zai)?;
361 update_key(&mut doc, "openrouter", &state.openrouter)?;
362 update_key(&mut doc, "deepseek", &state.deepseek)?;
363 update_key(&mut doc, "kimi", &state.kimi)?;
364
365 let bytes = doc.to_string();
366 crate::cache::atomic_write(path, bytes.as_bytes())?;
367
368 #[cfg(unix)]
370 {
371 use std::os::unix::fs::PermissionsExt;
372 if let Ok(meta) = std::fs::metadata(path) {
373 let mut perms = meta.permissions();
374 perms.set_mode(0o600);
375 let _ = std::fs::set_permissions(path, perms);
376 }
377 }
378 Ok(())
379}
380
381fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
386 let table = doc
387 .entry(section)
388 .or_insert_with(toml_edit::table)
389 .as_table_mut()
390 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
391
392 if let Some(item) = table.get_mut(key)
393 && let Some(v) = item.as_value_mut()
394 {
395 *v = toml_edit::Value::from(new_value);
396 v.decor_mut().set_prefix(" ");
399 return Ok(());
400 }
401 table.insert(key, value(new_value));
402 Ok(())
403}
404
405fn update_key(doc: &mut DocumentMut, section: &str, input: &KeyInput) -> Result<()> {
408 if !input.dirty {
409 return Ok(());
410 }
411 if input.buf.is_empty() {
412 if let Some(table) = doc.get_mut(section).and_then(toml_edit::Item::as_table_mut) {
413 table.remove("api_key");
414 }
415 return Ok(());
416 }
417 set_string(doc, section, "api_key", &input.buf)
418}
419
420fn default_config_path() -> Result<PathBuf> {
421 crate::config::default_path()
422 .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
423}
424
425pub fn render(f: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
427 let modal = settings_modal_rect(area);
428 f.render_widget(Clear, modal);
430
431 let bubble = bubble_theme(theme);
432
433 let block = bubble.titled_modal_block(" Settings ");
434 let inner = block.inner(modal);
435 f.render_widget(block, modal);
436
437 let chunks = Layout::default()
438 .direction(Direction::Vertical)
439 .constraints([
440 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(2), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
456 .split(inner);
457
458 f.render_widget(
460 Paragraph::new(label(
461 "Primary vendor",
462 state.focus == Focus::Primary,
463 &bubble,
464 )),
465 chunks[0],
466 );
467 f.render_widget(
468 Paragraph::new(render_radio(
469 &state.primary_choices,
470 &state.primary,
471 &bubble,
472 )),
473 chunks[1],
474 );
475
476 f.render_widget(
478 Paragraph::new(label(
479 "Z.AI API key (environment key takes precedence)",
480 state.focus == Focus::ZaiKey,
481 &bubble,
482 )),
483 chunks[3],
484 );
485 f.render_widget(
486 Paragraph::new(render_input(
487 &state.zai,
488 state.focus == Focus::ZaiKey,
489 &bubble,
490 )),
491 chunks[4],
492 );
493
494 f.render_widget(
496 Paragraph::new(label(
497 "OpenRouter API key (environment key takes precedence)",
498 state.focus == Focus::OpenrouterKey,
499 &bubble,
500 )),
501 chunks[5],
502 );
503 f.render_widget(
504 Paragraph::new(render_input(
505 &state.openrouter,
506 state.focus == Focus::OpenrouterKey,
507 &bubble,
508 )),
509 chunks[6],
510 );
511
512 f.render_widget(
514 Paragraph::new(label(
515 "DeepSeek API key (environment key takes precedence)",
516 state.focus == Focus::DeepseekKey,
517 &bubble,
518 )),
519 chunks[7],
520 );
521 f.render_widget(
522 Paragraph::new(render_input(
523 &state.deepseek,
524 state.focus == Focus::DeepseekKey,
525 &bubble,
526 )),
527 chunks[8],
528 );
529
530 f.render_widget(
532 Paragraph::new(label(
533 "Kimi API key (environment key takes precedence)",
534 state.focus == Focus::KimiKey,
535 &bubble,
536 )),
537 chunks[9],
538 );
539 f.render_widget(
540 Paragraph::new(render_input(
541 &state.kimi,
542 state.focus == Focus::KimiKey,
543 &bubble,
544 )),
545 chunks[10],
546 );
547
548 let save_style = if state.focus == Focus::SaveButton {
550 bubble.selected.add_modifier(Modifier::REVERSED)
551 } else {
552 bubble.accent.add_modifier(Modifier::BOLD)
553 };
554 f.render_widget(
555 Paragraph::new(Line::from(Span::styled(
556 " [ Save (Ctrl-S) ] ",
557 save_style,
558 ))),
559 chunks[12],
560 );
561
562 if !state.status.is_empty() {
564 f.render_widget(
565 Paragraph::new(Line::from(Span::styled(state.status.clone(), bubble.muted))),
566 chunks[13],
567 );
568 }
569
570 let hint = bubble.help_line([
572 ("tab/up/down", "move"),
573 ("left/right", "pick"),
574 ("ctrl+v", "reveal"),
575 ("ctrl+s", "save"),
576 ("esc", "cancel"),
577 ]);
578 f.render_widget(Paragraph::new(hint), chunks[14]);
579}
580
581fn label(text: &str, focused: bool, theme: &BubbleTheme) -> Line<'static> {
582 let marker = if focused {
583 theme.symbols.selected
584 } else {
585 theme.symbols.bullet
586 };
587 let marker_style = if focused { theme.accent } else { theme.muted };
588 let text_style = if focused { theme.title } else { theme.text };
589 Line::from(vec![
590 theme.muted(" "),
591 Span::styled(marker, marker_style),
592 theme.span(" "),
593 Span::styled(text.to_string(), text_style),
594 ])
595}
596
597fn render_radio(choices: &[VendorId], selected: &VendorId, theme: &BubbleTheme) -> Line<'static> {
598 let mut spans = vec![theme.muted(" ")];
599 for v in choices {
600 let is_sel = v == selected;
601 let glyph = if is_sel {
602 theme.symbols.selected
603 } else {
604 theme.symbols.bullet
605 };
606 let style = if is_sel { theme.selected } else { theme.muted };
607 spans.push(Span::styled(
608 format!("{glyph} {} ", vendor_label(*v)),
609 style,
610 ));
611 }
612 Line::from(spans)
613}
614
615fn vendor_label(v: VendorId) -> &'static str {
616 match v {
617 VendorId::Anthropic => "Anthropic",
618 VendorId::Openai => "OpenAI",
619 VendorId::Zai => "Z.AI",
620 VendorId::Openrouter => "OpenRouter",
621 VendorId::Deepseek => "DeepSeek",
622 VendorId::Kimi => "Kimi",
623 }
624}
625
626fn render_input(input: &KeyInput, focused: bool, theme: &BubbleTheme) -> Line<'static> {
627 let body = if input.buf.is_empty() {
628 "(empty)".to_string()
629 } else {
630 input.display()
631 };
632 let box_style = if focused {
633 theme.accent.add_modifier(Modifier::BOLD)
634 } else {
635 theme.text
636 };
637 let suffix_style = theme.muted;
638 let suffix = if input.revealed { " [revealed]" } else { "" };
639 let cursor_hint = if focused {
640 format!(" ▏cur:{}", input.cursor)
641 } else {
642 String::new()
643 };
644 Line::from(vec![
645 theme.muted(" "),
646 Span::styled(body, box_style),
647 Span::styled(format!("{suffix}{cursor_hint}"), suffix_style),
648 ])
649}
650
651const SETTINGS_CONTENT_HEIGHT: u16 = 18;
652
653fn settings_modal_rect(area: Rect) -> Rect {
655 let height = ((area.height * 92) / 100)
656 .max(SETTINGS_CONTENT_HEIGHT + 2)
657 .min(area.height);
658 let width = (area.width * 80) / 100;
659 Rect {
660 x: area.x + (area.width - width) / 2,
661 y: area.y + (area.height - height) / 2,
662 width,
663 height,
664 }
665}
666
667pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673 use tempfile::TempDir;
674
675 fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
679 crate::cache::closed_temp_file("config.toml", initial)
680 }
681
682 fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
683 let mut s = SettingsState {
684 focus: Focus::Primary,
685 primary_choices: VendorId::all().to_vec(),
686 primary,
687 zai: KeyInput::from_config(Some(zai)),
688 openrouter: KeyInput::from_config(Some(opr)),
689 deepseek: KeyInput::default(),
690 kimi: KeyInput::default(),
691 status: String::new(),
692 };
693 s.zai.dirty = true;
695 s.openrouter.dirty = true;
696 s
697 }
698
699 #[test]
700 fn focus_cycles_forward_and_backward() {
701 let order = [
702 Focus::Primary,
703 Focus::ZaiKey,
704 Focus::OpenrouterKey,
705 Focus::DeepseekKey,
706 Focus::KimiKey,
707 Focus::SaveButton,
708 ];
709 let n = order.len();
710 for (i, f) in order.iter().enumerate() {
711 assert_eq!(f.next(), order[(i + 1) % n]);
712 assert_eq!(f.prev(), order[(i + n - 1) % n]);
713 }
714 }
715
716 #[test]
717 fn key_input_insert_backspace_arrow() {
718 let mut k = KeyInput::default();
719 k.insert_char('a');
720 k.insert_char('b');
721 k.insert_char('c');
722 assert_eq!(k.buf, "abc");
723 assert_eq!(k.cursor, 3);
724 assert!(k.dirty);
725 k.move_left();
726 k.move_left();
727 assert_eq!(k.cursor, 1);
728 k.insert_char('x'); assert_eq!(k.buf, "axbc");
730 assert_eq!(k.cursor, 2);
731 k.backspace();
732 assert_eq!(k.buf, "abc");
733 assert_eq!(k.cursor, 1);
734 }
735
736 #[test]
737 fn key_input_masks_by_default_reveals_on_toggle() {
738 let mut k = KeyInput::default();
739 for c in "secret-key".chars() {
740 k.insert_char(c);
741 }
742 assert_eq!(k.display(), "•".repeat(10));
743 k.toggle_reveal();
744 assert_eq!(k.display(), "secret-key");
745 }
746
747 #[test]
748 fn key_input_handles_unicode() {
749 let mut k = KeyInput::default();
750 k.insert_char('a');
751 k.insert_char('→');
752 k.insert_char('b');
753 assert_eq!(k.buf, "a→b");
754 assert_eq!(k.cursor, 3);
755 k.move_left();
756 k.backspace(); assert_eq!(k.buf, "ab");
758 }
759
760 #[test]
761 fn save_to_path_writes_minimal_toml_when_starting_empty() {
762 let (_dir, path) = temp_config(None);
763 let s = state_with("zk", "ok", VendorId::Zai);
764 save_to_path(&s, &path).unwrap();
765 let raw = std::fs::read_to_string(&path).unwrap();
766 assert!(raw.contains("primary = \"zai\""));
767 assert!(raw.contains("[zai]"));
768 assert!(raw.contains("api_key = \"zk\""));
769 assert!(raw.contains("[openrouter]"));
770 assert!(raw.contains("api_key = \"ok\""));
771 }
772
773 #[test]
774 fn save_to_path_preserves_existing_comments_and_unrelated_fields() {
775 let (_dir, path) = temp_config(Some(
776 r##"# my comment
777[ui]
778# pre-existing comment
779primary = "anthropic"
780
781[zai]
782enabled = true
783api_key_env = "ZAI_API_KEY"
784# tier comment
785plan_tier = "pro"
786
787[openrouter]
788enabled = true
789api_key_env = "OPENROUTER_API_KEY"
790"##,
791 ));
792
793 let s = state_with("zk2", "ok2", VendorId::Openrouter);
794 save_to_path(&s, &path).unwrap();
795
796 let raw = std::fs::read_to_string(&path).unwrap();
797 assert!(raw.contains("# my comment"));
799 assert!(raw.contains("# pre-existing comment"));
800 assert!(raw.contains("# tier comment"));
801 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
803 assert!(raw.contains("plan_tier = \"pro\""));
804 assert!(raw.contains("primary = \"openrouter\""));
806 assert!(raw.contains("api_key = \"zk2\""));
808 assert!(raw.contains("api_key = \"ok2\""));
809 }
810
811 #[test]
812 fn save_does_not_write_empty_key_when_dirty_but_blank() {
813 let (_dir, path) = temp_config(None);
814 let mut s = state_with("", "", VendorId::Anthropic);
815 s.zai.dirty = true;
818 s.openrouter.dirty = true;
819 save_to_path(&s, &path).unwrap();
820 let raw = std::fs::read_to_string(&path).unwrap();
821 assert!(!raw.contains("api_key ="));
823 }
824
825 #[test]
826 fn save_dirty_empty_key_removes_existing_inline_key() {
827 let (_dir, path) = temp_config(Some(
828 r#"[zai]
829api_key = "old-secret"
830plan_tier = "pro"
831"#,
832 ));
833 let mut s = SettingsState::from_config(&Config::default());
834 s.zai.dirty = true;
835 save_to_path(&s, &path).unwrap();
836
837 let raw = std::fs::read_to_string(&path).unwrap();
838 assert!(!raw.contains("api_key"));
839 assert!(raw.contains("plan_tier = \"pro\""));
840 }
841
842 #[test]
843 fn save_untouched_empty_key_preserves_existing_inline_key() {
844 let (_dir, path) = temp_config(Some(
845 r#"[zai]
846api_key = "old-secret"
847"#,
848 ));
849 let s = SettingsState::from_config(&Config::default());
850 save_to_path(&s, &path).unwrap();
851
852 let raw = std::fs::read_to_string(&path).unwrap();
853 assert!(raw.contains("api_key = \"old-secret\""));
854 }
855
856 #[test]
857 fn settings_modal_fits_all_fields_and_save_in_24_rows() {
858 let modal = settings_modal_rect(Rect::new(0, 0, 100, 24));
859 assert!(modal.height >= SETTINGS_CONTENT_HEIGHT + 2);
861 assert!(modal.height <= 24);
862 }
863
864 #[test]
865 #[cfg(unix)]
866 fn save_chmods_to_600() {
867 use std::os::unix::fs::PermissionsExt;
868 let (_dir, path) = temp_config(None);
869 let s = state_with("zk", "ok", VendorId::Zai);
870 save_to_path(&s, &path).unwrap();
871 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
872 assert_eq!(mode & 0o777, 0o600);
873 }
874
875 #[test]
876 fn save_preserves_kimi_enabled_true_and_unrelated_comments() {
877 let (_dir, path) = temp_config(Some(
878 r##"[ui]
879primary = "anthropic"
880
881[kimi]
882# Kimi is opt-in
883enabled = true
884api_key_env = "KIMI_API_KEY"
885"##,
886 ));
887
888 let s = SettingsState {
891 focus: Focus::Primary,
892 primary_choices: VendorId::all().to_vec(),
893 primary: VendorId::Anthropic,
894 zai: KeyInput::default(),
895 openrouter: KeyInput::default(),
896 deepseek: KeyInput::default(),
897 kimi: KeyInput::default(),
898 status: String::new(),
899 };
900 save_to_path(&s, &path).unwrap();
901
902 let raw = std::fs::read_to_string(&path).unwrap();
903 assert!(raw.contains("# Kimi is opt-in"));
904 assert!(raw.contains("enabled = true"));
905 assert!(raw.contains("api_key_env = \"KIMI_API_KEY\""));
906 assert!(raw.contains("primary = \"anthropic\""));
907 }
908
909 #[test]
910 fn handle_key_tab_cycles_focus() {
911 let mut s = SettingsState {
912 focus: Focus::Primary,
913 primary_choices: VendorId::all().to_vec(),
914 primary: VendorId::Anthropic,
915 zai: KeyInput::default(),
916 openrouter: KeyInput::default(),
917 deepseek: KeyInput::default(),
918 kimi: KeyInput::default(),
919 status: String::new(),
920 };
921 assert_eq!(
922 handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
923 Action::Continue
924 );
925 assert_eq!(s.focus, Focus::ZaiKey);
926 assert_eq!(
927 handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
928 Action::Continue
929 );
930 assert_eq!(s.focus, Focus::Primary);
931 }
932
933 #[test]
934 fn handle_key_esc_closes_without_saving() {
935 let mut s = SettingsState {
936 focus: Focus::Primary,
937 primary_choices: VendorId::all().to_vec(),
938 primary: VendorId::Anthropic,
939 zai: KeyInput::default(),
940 openrouter: KeyInput::default(),
941 deepseek: KeyInput::default(),
942 kimi: KeyInput::default(),
943 status: String::new(),
944 };
945 assert_eq!(
946 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
947 Action::Close
948 );
949 }
950
951 #[test]
952 fn handle_key_left_right_cycles_primary_vendor() {
953 let mut s = SettingsState {
954 focus: Focus::Primary,
955 primary_choices: VendorId::all().to_vec(),
956 primary: VendorId::Anthropic,
957 zai: KeyInput::default(),
958 openrouter: KeyInput::default(),
959 deepseek: KeyInput::default(),
960 kimi: KeyInput::default(),
961 status: String::new(),
962 };
963 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
964 assert_eq!(s.primary, VendorId::Openai);
965 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
966 assert_eq!(s.primary, VendorId::Zai);
967 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
968 assert_eq!(s.primary, VendorId::Openai);
969 }
970
971 #[test]
972 fn primary_uses_enabled_vendors_for_display_and_cycling() {
973 let mut cfg = Config::default();
974 cfg.openai.enabled = false;
975 cfg.zai.enabled = false;
976 cfg.openrouter.enabled = false;
977 cfg.kimi.enabled = true;
978 cfg.ui.primary = Some(VendorId::Openai); let mut state = SettingsState::from_config(&cfg);
981 assert_eq!(
982 state.primary_choices,
983 vec![VendorId::Anthropic, VendorId::Kimi]
984 );
985 assert_eq!(state.primary, VendorId::Anthropic);
986 handle_key(&mut state, KeyCode::Right, KeyModifiers::NONE);
987 assert_eq!(state.primary, VendorId::Kimi);
988 }
989
990 #[test]
991 fn save_replaces_disabled_primary_with_effective_enabled_choice() {
992 let (_dir, path) = temp_config(Some(
993 r#"[ui]
994primary = "deepseek"
995"#,
996 ));
997 let mut cfg = Config::default();
998 cfg.ui.primary = Some(VendorId::Deepseek);
999 let mut state = SettingsState::from_config(&cfg);
1000 state.zai = KeyInput::from_config(Some("new-key"));
1001 state.zai.dirty = true;
1002
1003 save_to_path(&state, &path).unwrap();
1004 let raw = std::fs::read_to_string(&path).unwrap();
1005 assert!(raw.contains("primary = \"anthropic\""));
1006 assert!(!raw.contains("primary = \"deepseek\""));
1007 }
1008
1009 #[test]
1010 fn no_enabled_vendors_does_not_write_an_ineffective_primary() {
1011 let (_dir, path) = temp_config(Some(
1012 r#"[ui]
1013primary = "deepseek"
1014"#,
1015 ));
1016 let mut cfg = Config::default();
1017 cfg.anthropic.enabled = false;
1018 cfg.openai.enabled = false;
1019 cfg.zai.enabled = false;
1020 cfg.openrouter.enabled = false;
1021 cfg.deepseek.enabled = false;
1022 cfg.kimi.enabled = false;
1023 cfg.ui.primary = Some(VendorId::Deepseek);
1024 let mut state = SettingsState::from_config(&cfg);
1025 state.zai = KeyInput::from_config(Some("new-key"));
1026 state.zai.dirty = true;
1027
1028 assert!(state.primary_choices.is_empty());
1029 save_to_path(&state, &path).unwrap();
1030 let raw = std::fs::read_to_string(&path).unwrap();
1031 assert!(raw.contains("primary = \"deepseek\""));
1032 }
1033
1034 #[test]
1035 fn handle_key_ctrl_v_toggles_reveal_on_focused_key_field() {
1036 let mut s = SettingsState {
1037 focus: Focus::ZaiKey,
1038 primary_choices: VendorId::all().to_vec(),
1039 primary: VendorId::Anthropic,
1040 zai: KeyInput::from_config(Some("secret")),
1041 openrouter: KeyInput::default(),
1042 deepseek: KeyInput::default(),
1043 kimi: KeyInput::default(),
1044 status: String::new(),
1045 };
1046 assert!(!s.zai.revealed);
1047 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1048 assert!(s.zai.revealed);
1049 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1050 assert!(!s.zai.revealed);
1051 }
1052
1053 #[test]
1054 fn handle_key_ctrl_s_attempts_save_from_any_field() {
1055 let (_dir, path) = temp_config(None);
1056 let s = state_with("zk", "ok", VendorId::Zai);
1059 save_to_path(&s, &path).unwrap();
1060 let raw = std::fs::read_to_string(&path).unwrap();
1061 assert!(raw.contains("api_key = \"zk\""));
1062 }
1063
1064 #[test]
1065 fn save_to_path_writes_kimi_key_when_dirty() {
1066 let (_dir, path) = temp_config(None);
1067 let mut s = SettingsState {
1068 focus: Focus::Primary,
1069 primary_choices: VendorId::all().to_vec(),
1070 primary: VendorId::Anthropic,
1071 zai: KeyInput::default(),
1072 openrouter: KeyInput::default(),
1073 deepseek: KeyInput::default(),
1074 kimi: KeyInput::from_config(Some("kk")),
1075 status: String::new(),
1076 };
1077 s.kimi.dirty = true;
1078 save_to_path(&s, &path).unwrap();
1079 let raw = std::fs::read_to_string(&path).unwrap();
1080 assert!(raw.contains("[kimi]"));
1081 assert!(raw.contains("api_key = \"kk\""));
1082 }
1083}