1use std::path::{Path, PathBuf};
14
15use ratatui::Frame;
16use ratatui::layout::{Constraint, Direction, Layout, Rect};
17use ratatui::style::Modifier;
18use ratatui::text::{Line, Span};
19use ratatui::widgets::{Clear, Paragraph};
20use ratatui_bubbletea_theme::BubbleTheme;
21use toml_edit::{DocumentMut, value};
22
23use crate::config::Config;
24use crate::error::{AppError, Result};
25use crate::theme::Theme;
26use crate::tui::style::bubble_theme;
27use crate::vendor::VendorId;
28
29pub struct KeyVendor {
33 pub id: VendorId,
34 pub label: &'static str,
35 pub env: &'static str,
36 pub section: &'static str,
37 pub note: &'static str,
39}
40
41pub const KEY_VENDORS: &[KeyVendor] = &[
42 KeyVendor {
43 id: VendorId::AnthropicApi,
44 label: "Anthropic API",
45 env: "ANTHROPIC_ADMIN_KEY",
46 section: "anthropic_api",
47 note: "admin key — monthly spend",
48 },
49 KeyVendor {
50 id: VendorId::Zai,
51 label: "Z.AI",
52 env: "ZAI_API_KEY",
53 section: "zai",
54 note: "",
55 },
56 KeyVendor {
57 id: VendorId::Openrouter,
58 label: "OpenRouter",
59 env: "OPENROUTER_API_KEY",
60 section: "openrouter",
61 note: "",
62 },
63 KeyVendor {
64 id: VendorId::Deepseek,
65 label: "DeepSeek",
66 env: "DEEPSEEK_API_KEY",
67 section: "deepseek",
68 note: "",
69 },
70 KeyVendor {
71 id: VendorId::Kimi,
72 label: "Kimi",
73 env: "KIMI_API_KEY",
74 section: "kimi",
75 note: "coding-plan usage",
76 },
77 KeyVendor {
78 id: VendorId::Kilo,
79 label: "Kilo",
80 env: "KILO_API_KEY",
81 section: "kilo",
82 note: "",
83 },
84 KeyVendor {
85 id: VendorId::Novita,
86 label: "Novita",
87 env: "NOVITA_API_KEY",
88 section: "novita",
89 note: "",
90 },
91 KeyVendor {
92 id: VendorId::Moonshot,
93 label: "Moonshot",
94 env: "MOONSHOT_API_KEY",
95 section: "moonshot",
96 note: "account balance",
97 },
98 KeyVendor {
99 id: VendorId::Grok,
100 label: "Grok",
101 env: "XAI_MANAGEMENT_KEY",
102 section: "grok",
103 note: "management key, not the inference key",
104 },
105 KeyVendor {
106 id: VendorId::Minimax,
107 label: "MiniMax",
108 env: "MINIMAX_API_KEY",
109 section: "minimax",
110 note: "Token Plan subscription key",
111 },
112];
113
114fn config_inline_key<'a>(cfg: &'a Config, section: &str) -> Option<&'a str> {
117 match section {
118 "anthropic_api" => cfg.anthropic_api.api_key.as_deref(),
119 "zai" => cfg.zai.api_key.as_deref(),
120 "openrouter" => cfg.openrouter.api_key.as_deref(),
121 "deepseek" => cfg.deepseek.api_key.as_deref(),
122 "kimi" => cfg.kimi.api_key.as_deref(),
123 "kilo" => cfg.kilo.api_key.as_deref(),
124 "novita" => cfg.novita.api_key.as_deref(),
125 "moonshot" => cfg.moonshot.api_key.as_deref(),
126 "grok" => cfg.grok.api_key.as_deref(),
127 "minimax" => cfg.minimax.api_key.as_deref(),
128 _ => None,
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum Focus {
135 Primary,
136 Key(usize),
137 Save,
138}
139
140impl Focus {
141 pub fn next(self) -> Self {
142 match self {
143 Focus::Primary => Focus::Key(0),
144 Focus::Key(i) if i + 1 < KEY_VENDORS.len() => Focus::Key(i + 1),
145 Focus::Key(_) => Focus::Save,
146 Focus::Save => Focus::Primary,
147 }
148 }
149 pub fn prev(self) -> Self {
150 match self {
151 Focus::Primary => Focus::Save,
152 Focus::Key(0) => Focus::Primary,
153 Focus::Key(i) => Focus::Key(i - 1),
154 Focus::Save => Focus::Key(KEY_VENDORS.len() - 1),
155 }
156 }
157}
158
159#[derive(Debug, Clone, Default)]
161pub struct KeyInput {
162 pub buf: String,
163 pub cursor: usize,
165 pub revealed: bool,
167 pub dirty: bool,
171}
172
173impl KeyInput {
174 pub fn from_config(initial: Option<&str>) -> Self {
175 let buf = initial.unwrap_or("").to_string();
176 let cursor = buf.chars().count();
177 Self {
178 buf,
179 cursor,
180 revealed: false,
181 dirty: false,
182 }
183 }
184
185 pub fn insert_char(&mut self, c: char) {
186 let byte_idx = self.char_to_byte(self.cursor);
187 self.buf.insert(byte_idx, c);
188 self.cursor += 1;
189 self.dirty = true;
190 }
191
192 pub fn backspace(&mut self) {
193 if self.cursor == 0 {
194 return;
195 }
196 let prev_byte = self.char_to_byte(self.cursor - 1);
197 let cur_byte = self.char_to_byte(self.cursor);
198 self.buf.replace_range(prev_byte..cur_byte, "");
199 self.cursor -= 1;
200 self.dirty = true;
201 }
202
203 pub fn delete(&mut self) {
204 let n = self.buf.chars().count();
205 if self.cursor >= n {
206 return;
207 }
208 let cur_byte = self.char_to_byte(self.cursor);
209 let next_byte = self.char_to_byte(self.cursor + 1);
210 self.buf.replace_range(cur_byte..next_byte, "");
211 self.dirty = true;
212 }
213
214 pub fn move_left(&mut self) {
215 if self.cursor > 0 {
216 self.cursor -= 1;
217 }
218 }
219 pub fn move_right(&mut self) {
220 if self.cursor < self.buf.chars().count() {
221 self.cursor += 1;
222 }
223 }
224 pub fn move_home(&mut self) {
225 self.cursor = 0;
226 }
227 pub fn move_end(&mut self) {
228 self.cursor = self.buf.chars().count();
229 }
230 pub fn toggle_reveal(&mut self) {
231 self.revealed = !self.revealed;
232 }
233
234 pub fn display(&self) -> String {
236 if self.revealed {
237 self.buf.clone()
238 } else {
239 "•".repeat(self.buf.chars().count())
240 }
241 }
242
243 fn char_to_byte(&self, char_idx: usize) -> usize {
244 self.buf
245 .char_indices()
246 .map(|(b, _)| b)
247 .chain(std::iter::once(self.buf.len()))
248 .nth(char_idx)
249 .unwrap_or(self.buf.len())
250 }
251}
252
253#[derive(Debug, Clone)]
255pub struct SettingsState {
256 pub focus: Focus,
257 pub primary_choices: Vec<VendorId>,
260 pub primary: VendorId,
261 pub keys: Vec<KeyInput>,
263 pub status: String,
265}
266
267impl SettingsState {
268 pub fn from_config(cfg: &Config) -> Self {
269 let keys = KEY_VENDORS
270 .iter()
271 .map(|kv| KeyInput::from_config(config_inline_key(cfg, kv.section)))
272 .collect();
273 let primary_choices = cfg.enabled_vendors();
274 let primary = cfg
278 .ui
279 .primary
280 .filter(|vendor| primary_choices.contains(vendor))
281 .or_else(|| primary_choices.first().copied())
282 .unwrap_or_else(|| cfg.ui.primary.unwrap_or(VendorId::Anthropic));
283 Self {
284 focus: Focus::Primary,
285 primary_choices,
286 primary,
287 keys,
288 status: String::new(),
289 }
290 }
291
292 fn focused_key_mut(&mut self) -> Option<&mut KeyInput> {
294 match self.focus {
295 Focus::Key(i) => self.keys.get_mut(i),
296 _ => None,
297 }
298 }
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub enum Action {
304 Continue,
306 Close,
308 SavedAndClose,
310 Quit,
313}
314
315#[cfg(unix)]
318const PERMS_NOTE: &str = " (chmod 600)";
319#[cfg(not(unix))]
320const PERMS_NOTE: &str = "";
321
322fn saved_status() -> String {
323 format!(
324 "saved to {}{}",
325 crate::config::config_path_hint(),
326 PERMS_NOTE
327 )
328}
329
330pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
332 if matches!(code, KeyCode::Esc) {
333 return Action::Close;
334 }
335 if matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL) {
336 return Action::Quit;
337 }
338 if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
340 return try_save(state);
341 }
342 if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
343 if let Some(input) = state.focused_key_mut() {
344 input.toggle_reveal();
345 }
346 return Action::Continue;
347 }
348 match code {
349 KeyCode::Tab | KeyCode::Down => {
350 state.focus = state.focus.next();
351 return Action::Continue;
352 }
353 KeyCode::BackTab | KeyCode::Up => {
354 state.focus = state.focus.prev();
355 return Action::Continue;
356 }
357 _ => {}
358 }
359
360 if matches!(code, KeyCode::Char(_))
365 && mods.intersects(
366 KeyModifiers::CONTROL
367 | KeyModifiers::ALT
368 | KeyModifiers::SUPER
369 | KeyModifiers::HYPER
370 | KeyModifiers::META,
371 )
372 {
373 return Action::Continue;
374 }
375
376 match state.focus {
378 Focus::Primary => handle_primary(state, code),
379 Focus::Key(i) => {
380 if let Some(input) = state.keys.get_mut(i) {
381 handle_input(input, code);
382 }
383 }
384 Focus::Save => {
385 if matches!(code, KeyCode::Enter) {
386 return try_save(state);
387 }
388 }
389 }
390 Action::Continue
391}
392
393fn try_save(state: &mut SettingsState) -> Action {
394 match save_to_config_default(state) {
395 Ok(()) => {
396 state.status = saved_status();
397 Action::SavedAndClose
398 }
399 Err(e) => {
400 state.status = format!("save failed: {e}");
401 Action::Continue
402 }
403 }
404}
405
406fn handle_primary(state: &mut SettingsState, code: KeyCode) {
407 let choices = &state.primary_choices;
409 let Some(idx) = choices.iter().position(|v| *v == state.primary) else {
410 return;
411 };
412 let step = match code {
413 KeyCode::Left => -1,
414 KeyCode::Right | KeyCode::Char(' ') => 1,
415 _ => return,
416 };
417 state.primary = choices[((idx as i32 + step).rem_euclid(choices.len() as i32)) as usize];
418}
419
420fn handle_input(input: &mut KeyInput, code: KeyCode) {
421 match code {
422 KeyCode::Char(c) => input.insert_char(c),
423 KeyCode::Backspace => input.backspace(),
424 KeyCode::Delete => input.delete(),
425 KeyCode::Left => input.move_left(),
426 KeyCode::Right => input.move_right(),
427 KeyCode::Home => input.move_home(),
428 KeyCode::End => input.move_end(),
429 _ => {}
430 }
431}
432
433fn save_to_config_default(state: &SettingsState) -> Result<()> {
436 let path = default_config_path()?;
437 if let Some(parent) = path.parent() {
438 std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
439 }
440 save_to_path(state, &path)?;
441 crate::waybar::request_refresh();
442 Ok(())
443}
444
445pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
448 let original = match std::fs::read_to_string(path) {
449 Ok(contents) => contents,
450 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
451 Err(error) => return Err(AppError::io_at(path, error)),
452 };
453 let mut doc: DocumentMut = if original.trim().is_empty() {
454 DocumentMut::new()
455 } else {
456 original.parse().map_err(|e: toml_edit::TomlError| {
457 AppError::Other(format!("config.toml not parseable: {e}"))
458 })?
459 };
460
461 if state.primary_choices.contains(&state.primary) {
465 set_string(&mut doc, "ui", "primary", state.primary.slug())?;
466 }
467
468 for (i, kv) in KEY_VENDORS.iter().enumerate() {
469 let Some(input) = state.keys.get(i) else {
470 continue;
471 };
472 update_key(&mut doc, kv.section, input)?;
473 }
474
475 let bytes = doc.to_string();
476 crate::cache::atomic_write(path, bytes.as_bytes())?;
477
478 #[cfg(unix)]
479 {
480 use std::os::unix::fs::PermissionsExt;
481 if let Ok(meta) = std::fs::metadata(path) {
482 let mut perms = meta.permissions();
483 perms.set_mode(0o600);
484 let _ = std::fs::set_permissions(path, perms);
485 }
486 }
487 Ok(())
488}
489
490fn update_key(doc: &mut DocumentMut, section: &str, input: &KeyInput) -> Result<()> {
495 if !input.dirty {
496 return Ok(());
497 }
498 if input.buf.is_empty() {
499 if let Some(table) = doc.get_mut(section).and_then(toml_edit::Item::as_table_mut) {
500 table.remove("api_key");
501 }
502 return Ok(());
503 }
504 set_string(doc, section, "api_key", &input.buf)?;
505 set_bool(doc, section, "enabled", true)
506}
507
508fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
511 let table = doc
512 .entry(section)
513 .or_insert_with(toml_edit::table)
514 .as_table_mut()
515 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
516
517 if let Some(item) = table.get_mut(key)
518 && let Some(v) = item.as_value_mut()
519 {
520 *v = toml_edit::Value::from(new_value);
521 v.decor_mut().set_prefix(" ");
522 return Ok(());
523 }
524 table.insert(key, value(new_value));
525 Ok(())
526}
527
528fn set_bool(doc: &mut DocumentMut, section: &str, key: &str, new_value: bool) -> Result<()> {
530 let table = doc
531 .entry(section)
532 .or_insert_with(toml_edit::table)
533 .as_table_mut()
534 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
535
536 if let Some(item) = table.get_mut(key)
537 && let Some(v) = item.as_value_mut()
538 {
539 *v = toml_edit::Value::from(new_value);
540 v.decor_mut().set_prefix(" ");
541 return Ok(());
542 }
543 table.insert(key, value(new_value));
544 Ok(())
545}
546
547fn default_config_path() -> Result<PathBuf> {
548 crate::config::resolved_path()
553 .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
554}
555
556pub fn render(f: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
560 let modal = centered_rect(74, 88, area);
561 f.render_widget(Clear, modal);
562
563 let bubble = bubble_theme(theme);
564 let block = bubble.titled_modal_block(" Settings ");
565 let inner = block.inner(modal);
566 f.render_widget(block, modal);
567
568 let chunks = Layout::default()
570 .direction(Direction::Vertical)
571 .constraints([Constraint::Min(0), Constraint::Length(1)])
572 .split(inner);
573
574 let mut lines: Vec<Line> = vec![
576 section_header("Primary vendor", "shown first on the bar / TUI", &bubble),
577 primary_line(state, &bubble),
578 Line::from(""),
579 section_header(
580 "API keys",
581 "pick a row, type the key, then Ctrl-S — Claude & OpenAI use CLI login",
582 &bubble,
583 ),
584 ];
585 for (i, kv) in KEY_VENDORS.iter().enumerate() {
586 let focused = state.focus == Focus::Key(i);
587 lines.push(key_row(kv, &state.keys[i], focused, &bubble));
588 }
589 lines.push(Line::from(""));
590
591 lines.push(save_line(state.focus == Focus::Save, &bubble));
593 if !state.status.is_empty() {
594 let ok = state.status.starts_with("saved");
595 let mark = if ok { " ✓ " } else { " ✗ " };
596 let style = if ok { bubble.accent } else { bubble.selected };
597 lines.push(Line::from(vec![
598 Span::styled(mark, style.add_modifier(Modifier::BOLD)),
599 Span::styled(state.status.clone(), bubble.muted),
600 ]));
601 }
602
603 f.render_widget(Paragraph::new(lines), chunks[0]);
604
605 let hint = match state.focus {
607 Focus::Primary => bubble.help_line([
608 ("↑↓/tab", "move"),
609 ("←→", "change vendor"),
610 ("^S", "save"),
611 ("esc", "close"),
612 ]),
613 Focus::Key(_) => bubble.help_line([
614 ("↑↓/tab", "move"),
615 ("type", "edit key"),
616 ("^V", "reveal"),
617 ("^S", "save"),
618 ("esc", "close"),
619 ]),
620 Focus::Save => {
621 bubble.help_line([("↑↓/tab", "move"), ("enter/^S", "save"), ("esc", "close")])
622 }
623 };
624 f.render_widget(Paragraph::new(hint), chunks[1]);
625}
626
627fn section_header(title: &str, sub: &str, theme: &BubbleTheme) -> Line<'static> {
628 Line::from(vec![
629 theme.span(" "),
630 Span::styled(title.to_string(), theme.title.add_modifier(Modifier::BOLD)),
631 theme.muted(format!(" — {sub}")),
632 ])
633}
634
635fn primary_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
636 let focused = state.focus == Focus::Primary;
637 let name = vendor_label(state.primary).to_string();
638 if focused {
639 Line::from(vec![
640 theme.span(" "),
641 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
642 Span::styled("◀ ", theme.accent),
643 Span::styled(
644 format!(" {name} "),
645 theme
646 .selected
647 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
648 ),
649 Span::styled(" ▶", theme.accent),
650 theme.muted(" ← → to change"),
651 ])
652 } else {
653 Line::from(vec![theme.span(" "), Span::styled(name, theme.text)])
654 }
655}
656
657fn key_row(kv: &KeyVendor, input: &KeyInput, focused: bool, theme: &BubbleTheme) -> Line<'static> {
658 let label = format!("{:<11}", kv.label);
659 let value = value_text(input, focused);
660
661 let env_set = std::env::var(kv.env)
663 .map(|v| !v.is_empty())
664 .unwrap_or(false);
665 let mut suffix = format!(" {}", kv.env);
666 if env_set {
667 suffix.push_str(" · env set (overrides)");
668 }
669 if !kv.note.is_empty() {
670 suffix.push_str(&format!(" · {}", kv.note));
671 }
672
673 if focused {
674 let val_style = if input.buf.is_empty() {
675 theme.accent.add_modifier(Modifier::BOLD)
676 } else {
677 theme.selected.add_modifier(Modifier::REVERSED)
678 };
679 let mut spans = vec![
680 theme.span(" "),
681 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
682 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
683 Span::styled(format!(" {value} "), val_style),
684 ];
685 if input.revealed {
686 spans.push(theme.muted(" [revealed]"));
687 }
688 spans.push(theme.muted(suffix));
689 Line::from(spans)
690 } else {
691 let val_style = if input.buf.is_empty() {
692 theme.muted
693 } else {
694 theme.text
695 };
696 Line::from(vec![
697 theme.span(" "),
698 Span::styled(label, theme.text),
699 Span::styled(format!(" {value}"), val_style),
700 theme.muted(suffix),
701 ])
702 }
703}
704
705fn value_text(input: &KeyInput, focused: bool) -> String {
708 if input.buf.is_empty() {
709 return if focused {
710 "‸".to_string()
711 } else {
712 "(empty)".to_string()
713 };
714 }
715 let base = input.display();
716 if !focused {
717 return base;
718 }
719 let mut chars: Vec<char> = base.chars().collect();
720 let pos = input.cursor.min(chars.len());
721 chars.insert(pos, '‸');
722 chars.into_iter().collect()
723}
724
725fn save_line(focused: bool, theme: &BubbleTheme) -> Line<'static> {
726 let style = if focused {
727 theme
728 .selected
729 .add_modifier(Modifier::REVERSED | Modifier::BOLD)
730 } else {
731 theme.accent.add_modifier(Modifier::BOLD)
732 };
733 let marker = if focused { "▸ " } else { " " };
734 Line::from(vec![
735 theme.span(" "),
736 Span::styled(marker, theme.accent.add_modifier(Modifier::BOLD)),
737 Span::styled(" Save (Ctrl-S) ", style),
738 ])
739}
740
741fn vendor_label(v: VendorId) -> &'static str {
742 match v {
743 VendorId::Anthropic => "Anthropic",
744 VendorId::AnthropicApi => "Anthropic API",
745 VendorId::Openai => "OpenAI",
746 VendorId::Zai => "Z.AI",
747 VendorId::Openrouter => "OpenRouter",
748 VendorId::Deepseek => "DeepSeek",
749 VendorId::Kimi => "Kimi",
750 VendorId::Kilo => "Kilo",
751 VendorId::Novita => "Novita",
752 VendorId::Moonshot => "Moonshot",
753 VendorId::Grok => "Grok",
754 VendorId::Supergrok => "SuperGrok",
755 VendorId::Antigravity => "Antigravity",
756 VendorId::Cursor => "Cursor",
757 VendorId::Minimax => "MiniMax",
758 VendorId::Kiro => "Kiro",
759 }
760}
761
762fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
764 let popup_h = (r.height * percent_y) / 100;
765 let popup_w = (r.width * percent_x) / 100;
766 Rect {
767 x: r.x + (r.width - popup_w) / 2,
768 y: r.y + (r.height - popup_h) / 2,
769 width: popup_w,
770 height: popup_h,
771 }
772}
773
774pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
776
777#[cfg(test)]
778mod tests {
779 use super::*;
780 use tempfile::TempDir;
781
782 fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
783 crate::cache::closed_temp_file("config.toml", initial)
784 }
785
786 fn key_index(id: VendorId) -> usize {
787 KEY_VENDORS.iter().position(|kv| kv.id == id).unwrap()
788 }
789
790 fn blank_state(primary: VendorId) -> SettingsState {
791 SettingsState {
792 focus: Focus::Primary,
793 primary_choices: VendorId::all().to_vec(),
794 primary,
795 keys: KEY_VENDORS.iter().map(|_| KeyInput::default()).collect(),
796 status: String::new(),
797 }
798 }
799
800 fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
802 let mut s = blank_state(primary);
803 s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some(zai));
804 s.keys[key_index(VendorId::Zai)].dirty = true;
805 s.keys[key_index(VendorId::Openrouter)] = KeyInput::from_config(Some(opr));
806 s.keys[key_index(VendorId::Openrouter)].dirty = true;
807 s
808 }
809
810 #[test]
811 fn focus_cycles_through_primary_all_keys_and_save() {
812 let mut f = Focus::Primary;
813 let mut seen = vec![f];
814 for _ in 0..(KEY_VENDORS.len() + 2) {
816 f = f.next();
817 seen.push(f);
818 }
819 assert_eq!(seen.first(), Some(&Focus::Primary));
821 assert_eq!(seen.last(), Some(&Focus::Primary));
822 assert!(seen.contains(&Focus::Key(0)));
823 assert!(seen.contains(&Focus::Key(KEY_VENDORS.len() - 1)));
824 assert!(seen.contains(&Focus::Save));
825 assert_eq!(Focus::Primary.next().prev(), Focus::Primary);
827 assert_eq!(Focus::Save.prev().next(), Focus::Save);
828 assert_eq!(Focus::Primary.prev(), Focus::Save);
829 }
830
831 #[test]
832 fn every_key_vendor_has_a_field() {
833 for id in [
835 VendorId::Zai,
836 VendorId::Openrouter,
837 VendorId::Deepseek,
838 VendorId::Kilo,
839 VendorId::Novita,
840 VendorId::Moonshot,
841 VendorId::Grok,
842 ] {
843 assert!(
844 KEY_VENDORS.iter().any(|kv| kv.id == id),
845 "{id:?} has no key field"
846 );
847 }
848 assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Anthropic));
850 assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Openai));
851 }
852
853 #[test]
854 fn from_config_prefills_existing_keys() {
855 let mut cfg = Config::default();
856 cfg.kilo.api_key = Some("sk-kilo".into());
857 let s = SettingsState::from_config(&cfg);
858 assert_eq!(s.keys[key_index(VendorId::Kilo)].buf, "sk-kilo");
859 assert!(!s.keys[key_index(VendorId::Kilo)].dirty);
860 }
861
862 #[test]
863 fn from_config_offers_enabled_vendors_only() {
864 let cfg = Config::default();
865 let s = SettingsState::from_config(&cfg);
866 assert_eq!(s.primary_choices, cfg.enabled_vendors());
867 assert!(!s.primary_choices.contains(&VendorId::Grok));
869 assert!(s.primary_choices.contains(&s.primary));
870 }
871
872 #[test]
873 fn from_config_falls_back_when_configured_primary_is_disabled() {
874 let mut cfg = Config::default();
877 cfg.ui.primary = Some(VendorId::Grok);
878 let s = SettingsState::from_config(&cfg);
879 assert_ne!(s.primary, VendorId::Grok);
880 assert_eq!(Some(s.primary), cfg.enabled_vendors().first().copied());
881 }
882
883 #[test]
884 fn key_input_insert_backspace_arrow() {
885 let mut k = KeyInput::default();
886 k.insert_char('a');
887 k.insert_char('b');
888 k.insert_char('c');
889 assert_eq!(k.buf, "abc");
890 assert_eq!(k.cursor, 3);
891 assert!(k.dirty);
892 k.move_left();
893 k.move_left();
894 assert_eq!(k.cursor, 1);
895 k.insert_char('x');
896 assert_eq!(k.buf, "axbc");
897 assert_eq!(k.cursor, 2);
898 k.backspace();
899 assert_eq!(k.buf, "abc");
900 assert_eq!(k.cursor, 1);
901 }
902
903 #[test]
904 fn key_input_masks_by_default_reveals_on_toggle() {
905 let mut k = KeyInput::default();
906 for c in "secret-key".chars() {
907 k.insert_char(c);
908 }
909 assert_eq!(k.display(), "•".repeat(10));
910 k.toggle_reveal();
911 assert_eq!(k.display(), "secret-key");
912 }
913
914 #[test]
915 fn key_input_handles_unicode() {
916 let mut k = KeyInput::default();
917 k.insert_char('a');
918 k.insert_char('→');
919 k.insert_char('b');
920 assert_eq!(k.buf, "a→b");
921 assert_eq!(k.cursor, 3);
922 k.move_left();
923 k.backspace();
924 assert_eq!(k.buf, "ab");
925 }
926
927 #[test]
928 fn value_text_shows_cursor_and_empty_states() {
929 let mut k = KeyInput::default();
930 assert_eq!(value_text(&k, false), "(empty)");
931 assert_eq!(value_text(&k, true), "‸");
932 k.insert_char('a');
933 k.insert_char('b');
934 assert_eq!(value_text(&k, true), "••‸");
936 assert_eq!(value_text(&k, false), "••");
937 }
938
939 #[test]
940 fn save_writes_key_and_enables_vendor() {
941 let (_dir, path) = temp_config(None);
942 let mut s = blank_state(VendorId::Kilo);
943 s.keys[key_index(VendorId::Kilo)] = KeyInput::from_config(Some("sk-kilo"));
944 s.keys[key_index(VendorId::Kilo)].dirty = true;
945 save_to_path(&s, &path).unwrap();
946 let raw = std::fs::read_to_string(&path).unwrap();
947 assert!(raw.contains("primary = \"kilo\""));
948 assert!(raw.contains("[kilo]"));
949 assert!(raw.contains("api_key = \"sk-kilo\""));
950 assert!(raw.contains("enabled = true"));
951 }
952
953 #[test]
954 fn save_writes_minimal_toml_when_starting_empty() {
955 let (_dir, path) = temp_config(None);
956 let s = state_with("zk", "ok", VendorId::Zai);
957 save_to_path(&s, &path).unwrap();
958 let raw = std::fs::read_to_string(&path).unwrap();
959 assert!(raw.contains("primary = \"zai\""));
960 assert!(raw.contains("[zai]"));
961 assert!(raw.contains("api_key = \"zk\""));
962 assert!(raw.contains("[openrouter]"));
963 assert!(raw.contains("api_key = \"ok\""));
964 }
965
966 #[test]
967 fn save_preserves_existing_comments_and_unrelated_fields() {
968 let (_dir, path) = temp_config(Some(
969 r##"# my comment
970[ui]
971# pre-existing comment
972primary = "anthropic"
973
974[zai]
975enabled = true
976api_key_env = "ZAI_API_KEY"
977# tier comment
978plan_tier = "pro"
979
980[openrouter]
981enabled = true
982api_key_env = "OPENROUTER_API_KEY"
983"##,
984 ));
985
986 let s = state_with("zk2", "ok2", VendorId::Openrouter);
987 save_to_path(&s, &path).unwrap();
988
989 let raw = std::fs::read_to_string(&path).unwrap();
990 assert!(raw.contains("# my comment"));
991 assert!(raw.contains("# pre-existing comment"));
992 assert!(raw.contains("# tier comment"));
993 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
994 assert!(raw.contains("plan_tier = \"pro\""));
995 assert!(raw.contains("primary = \"openrouter\""));
996 assert!(raw.contains("api_key = \"zk2\""));
997 assert!(raw.contains("api_key = \"ok2\""));
998 }
999
1000 #[test]
1001 fn save_refuses_to_replace_an_unreadable_existing_config() {
1002 let (_dir, path) = temp_config(None);
1003 let original = [0xff, 0xfe, 0xfd];
1004 std::fs::write(&path, original).unwrap();
1005 let state = state_with("new-secret", "", VendorId::Zai);
1006
1007 assert!(save_to_path(&state, &path).is_err());
1008 assert_eq!(std::fs::read(&path).unwrap(), original);
1009 }
1010
1011 #[test]
1012 fn save_does_not_write_empty_key_when_dirty_but_blank() {
1013 let (_dir, path) = temp_config(None);
1014 let mut s = blank_state(VendorId::Anthropic);
1015 for k in &mut s.keys {
1017 k.dirty = true;
1018 }
1019 save_to_path(&s, &path).unwrap();
1020 let raw = std::fs::read_to_string(&path).unwrap();
1021 assert!(!raw.contains("api_key ="));
1022 }
1023
1024 #[test]
1025 #[cfg(unix)]
1026 fn save_chmods_to_600() {
1027 use std::os::unix::fs::PermissionsExt;
1028 let (_dir, path) = temp_config(None);
1029 let s = state_with("zk", "ok", VendorId::Zai);
1030 save_to_path(&s, &path).unwrap();
1031 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1032 assert_eq!(mode & 0o777, 0o600);
1033 }
1034
1035 #[test]
1036 fn tab_cycles_focus_from_primary_to_first_key() {
1037 let mut s = blank_state(VendorId::Anthropic);
1038 assert_eq!(
1039 handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
1040 Action::Continue
1041 );
1042 assert_eq!(s.focus, Focus::Key(0));
1043 assert_eq!(
1044 handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
1045 Action::Continue
1046 );
1047 assert_eq!(s.focus, Focus::Primary);
1048 }
1049
1050 #[test]
1051 fn esc_closes_without_saving() {
1052 let mut s = blank_state(VendorId::Anthropic);
1053 assert_eq!(
1054 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
1055 Action::Close
1056 );
1057 }
1058
1059 #[test]
1060 fn left_right_cycles_primary_vendor() {
1061 let mut s = blank_state(VendorId::Anthropic);
1063 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1064 assert_eq!(s.primary, VendorId::AnthropicApi);
1065 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1066 assert_eq!(s.primary, VendorId::Openai);
1067 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1068 assert_eq!(s.primary, VendorId::AnthropicApi);
1069 }
1070
1071 #[test]
1072 fn left_right_offers_enabled_vendors_only() {
1073 let mut s = blank_state(VendorId::Anthropic);
1075 s.primary_choices = vec![VendorId::Anthropic, VendorId::Grok];
1076 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1077 assert_eq!(s.primary, VendorId::Grok);
1078 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1080 assert_eq!(s.primary, VendorId::Anthropic);
1081 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1082 assert_eq!(s.primary, VendorId::Grok);
1083 }
1084
1085 #[test]
1086 fn no_enabled_vendors_leaves_primary_selector_inert() {
1087 let mut s = blank_state(VendorId::Anthropic);
1088 s.primary_choices = vec![];
1089 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1090 assert_eq!(s.primary, VendorId::Anthropic);
1091 }
1092
1093 #[test]
1094 fn save_does_not_write_a_disabled_primary() {
1095 let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
1098 let mut s = state_with("zk", "ok", VendorId::Grok);
1099 s.primary_choices = vec![VendorId::Anthropic];
1100 save_to_path(&s, &path).unwrap();
1101 let raw = std::fs::read_to_string(&path).unwrap();
1102 assert!(raw.contains("primary = \"anthropic\""));
1103 assert!(!raw.contains("primary = \"grok\""));
1104 assert!(raw.contains("zk"));
1106 }
1107
1108 #[test]
1109 fn save_removes_an_inline_key_the_user_cleared() {
1110 let (_dir, path) = temp_config(Some(
1113 "[zai]\nenabled = true\napi_key = \"old-secret\"\nplan_tier = \"pro\"\n",
1114 ));
1115 let mut s = blank_state(VendorId::Zai);
1116 s.primary_choices = vec![VendorId::Zai];
1117 s.keys[key_index(VendorId::Zai)] = KeyInput::default();
1118 s.keys[key_index(VendorId::Zai)].dirty = true;
1119 save_to_path(&s, &path).unwrap();
1120 let raw = std::fs::read_to_string(&path).unwrap();
1121 assert!(!raw.contains("old-secret"));
1122 assert!(!raw.contains("api_key"));
1123 assert!(raw.contains("plan_tier = \"pro\""));
1125 }
1126
1127 #[test]
1128 fn untouched_key_field_is_left_alone() {
1129 let (_dir, path) = temp_config(Some("[zai]\napi_key = \"keep-me\"\n"));
1131 let mut s = blank_state(VendorId::Zai);
1132 s.primary_choices = vec![VendorId::Zai];
1133 save_to_path(&s, &path).unwrap();
1134 let raw = std::fs::read_to_string(&path).unwrap();
1135 assert!(raw.contains("keep-me"));
1136 }
1137
1138 #[test]
1139 fn typing_edits_the_focused_key_only() {
1140 let mut s = blank_state(VendorId::Anthropic);
1141 s.focus = Focus::Key(key_index(VendorId::Grok));
1142 for c in "xai-abc".chars() {
1143 handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
1144 }
1145 assert_eq!(s.keys[key_index(VendorId::Grok)].buf, "xai-abc");
1146 assert!(s.keys[key_index(VendorId::Grok)].dirty);
1147 assert!(s.keys[key_index(VendorId::Zai)].buf.is_empty());
1149 }
1150
1151 #[test]
1152 fn ctrl_v_toggles_reveal_on_focused_key_field() {
1153 let mut s = blank_state(VendorId::Anthropic);
1154 let zi = key_index(VendorId::Zai);
1155 s.focus = Focus::Key(zi);
1156 s.keys[zi] = KeyInput::from_config(Some("secret"));
1157 assert!(!s.keys[zi].revealed);
1158 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1159 assert!(s.keys[zi].revealed);
1160 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1161 assert!(!s.keys[zi].revealed);
1162 }
1163
1164 #[test]
1165 fn control_chorded_chars_do_not_type_into_fields() {
1166 let mut s = blank_state(VendorId::Anthropic);
1167 s.focus = Focus::Key(0);
1168 handle_key(&mut s, KeyCode::Char('a'), KeyModifiers::CONTROL);
1170 assert!(s.keys[0].buf.is_empty());
1171 assert!(!s.keys[0].dirty);
1172 assert_eq!(
1174 handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
1175 Action::Quit
1176 );
1177 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
1179 assert_eq!(s.keys[0].buf, "x");
1180 }
1181
1182 #[test]
1183 fn ctrl_v_on_non_key_focus_is_noop() {
1184 let mut s = blank_state(VendorId::Anthropic);
1185 s.focus = Focus::Primary;
1186 assert_eq!(
1188 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL),
1189 Action::Continue
1190 );
1191 }
1192
1193 fn state_focused_on_zai() -> SettingsState {
1194 let mut state = blank_state(VendorId::Anthropic);
1195 state.focus = Focus::Key(key_index(VendorId::Zai));
1196 state
1197 }
1198
1199 #[test]
1200 fn handle_key_ctrl_c_quits_without_typing_into_key_field() {
1201 let mut s = state_focused_on_zai();
1202 let zi = key_index(VendorId::Zai);
1203 assert_eq!(
1204 handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
1205 Action::Quit
1206 );
1207 assert!(s.keys[zi].buf.is_empty());
1208 assert!(!s.keys[zi].dirty);
1210 }
1211
1212 #[test]
1213 fn handle_key_alt_chord_does_not_type_into_key_field() {
1214 let mut s = state_focused_on_zai();
1215 let zi = key_index(VendorId::Zai);
1216 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::ALT);
1217 assert!(s.keys[zi].buf.is_empty());
1218 assert!(!s.keys[zi].dirty);
1219 }
1220
1221 #[test]
1222 fn handle_key_platform_modifier_chords_do_not_type_into_key_field() {
1223 for modifier in [KeyModifiers::SUPER, KeyModifiers::HYPER, KeyModifiers::META] {
1224 let mut s = state_focused_on_zai();
1225 let zi = key_index(VendorId::Zai);
1226 handle_key(&mut s, KeyCode::Char('x'), modifier);
1227 assert!(s.keys[zi].buf.is_empty(), "modifier {modifier:?}");
1228 assert!(!s.keys[zi].dirty, "modifier {modifier:?}");
1229 }
1230 }
1231
1232 #[test]
1233 fn handle_key_shift_still_types_uppercase() {
1234 let mut s = state_focused_on_zai();
1235 let zi = key_index(VendorId::Zai);
1236 handle_key(&mut s, KeyCode::Char('A'), KeyModifiers::SHIFT);
1237 assert_eq!(s.keys[zi].buf, "A");
1238 assert!(s.keys[zi].dirty);
1239 }
1240
1241 #[test]
1242 fn handle_key_plain_space_still_cycles_primary_vendor() {
1243 let mut s = blank_state(VendorId::Anthropic);
1244 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
1245 assert_eq!(s.primary, VendorId::AnthropicApi);
1246 }
1247
1248 #[test]
1249 fn handle_key_ctrl_s_attempts_save_from_any_field() {
1250 let (_dir, path) = temp_config(None);
1251 let s = state_with("zk", "ok", VendorId::Zai);
1252 save_to_path(&s, &path).unwrap();
1253 let raw = std::fs::read_to_string(&path).unwrap();
1254 assert!(raw.contains("api_key = \"zk\""));
1255 }
1256 #[test]
1257 fn save_to_path_writes_kimi_key_when_dirty() {
1258 let (_dir, path) = temp_config(None);
1259 let mut s = blank_state(VendorId::Anthropic);
1260 let kimi = key_index(VendorId::Kimi);
1261 s.keys[kimi] = KeyInput::from_config(Some("kk"));
1262 s.keys[kimi].dirty = true;
1263 save_to_path(&s, &path).unwrap();
1264 let raw = std::fs::read_to_string(&path).unwrap();
1265 assert!(raw.contains("[kimi]"));
1266 assert!(raw.contains("api_key = \"kk\""));
1267 }
1268
1269 #[test]
1270 fn settings_save_uses_the_same_config_path_as_load() {
1271 assert_eq!(
1272 default_config_path().unwrap(),
1273 crate::config::resolved_path().unwrap()
1274 );
1275 }
1276}