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