1use std::collections::BTreeMap;
24use std::io::BufRead;
25use std::path::{Path, PathBuf};
26
27use ratatui::Frame;
28use ratatui::layout::{Constraint, Direction, Layout, Rect};
29use ratatui::style::Modifier;
30use ratatui::text::{Line, Span};
31use ratatui::widgets::{Clear, Paragraph};
32use ratatui_bubbletea_theme::BubbleTheme;
33use serde::{Deserialize, Serialize};
34use toml_edit::{DocumentMut, value};
35
36use crate::config::{
37 Config, edit_config_document, is_valid_env_var_name, read_config_document, set_bool, set_value,
38};
39use crate::error::{AppError, Result};
40use crate::theme::Theme;
41use crate::tui::style::bubble_theme;
42use crate::vendor::VendorId;
43
44pub struct KeyVendor {
48 pub id: VendorId,
49 pub label: &'static str,
50 pub section: &'static str,
51 pub config_key: &'static str,
53 pub secret_label: &'static str,
55 pub note: &'static str,
57}
58
59pub const KEY_VENDORS: &[KeyVendor] = &[
60 KeyVendor {
61 id: VendorId::AnthropicApi,
62 label: "Anthropic API",
63 section: VendorId::AnthropicApi.config_section(),
64 config_key: "api_key",
65 secret_label: "API key",
66 note: "admin key — monthly spend",
67 },
68 KeyVendor {
69 id: VendorId::Zai,
70 label: "Z.AI",
71 section: VendorId::Zai.config_section(),
72 config_key: "api_key",
73 secret_label: "API key",
74 note: "",
75 },
76 KeyVendor {
77 id: VendorId::Openrouter,
78 label: "OpenRouter",
79 section: VendorId::Openrouter.config_section(),
80 config_key: "api_key",
81 secret_label: "API key",
82 note: "",
83 },
84 KeyVendor {
85 id: VendorId::Deepseek,
86 label: "DeepSeek",
87 section: VendorId::Deepseek.config_section(),
88 config_key: "api_key",
89 secret_label: "API key",
90 note: "",
91 },
92 KeyVendor {
93 id: VendorId::Deepinfra,
94 label: "DeepInfra",
95 section: VendorId::Deepinfra.config_section(),
96 config_key: "api_key",
97 secret_label: "API key",
98 note: "billing balance and monthly spend",
99 },
100 KeyVendor {
101 id: VendorId::Kimi,
102 label: "Kimi",
103 section: VendorId::Kimi.config_section(),
104 config_key: "api_key",
105 secret_label: "API key",
106 note: "coding-plan usage",
107 },
108 KeyVendor {
109 id: VendorId::Kilo,
110 label: "Kilo",
111 section: VendorId::Kilo.config_section(),
112 config_key: "api_key",
113 secret_label: "API key",
114 note: "",
115 },
116 KeyVendor {
117 id: VendorId::Novita,
118 label: "Novita",
119 section: VendorId::Novita.config_section(),
120 config_key: "api_key",
121 secret_label: "API key",
122 note: "",
123 },
124 KeyVendor {
125 id: VendorId::Synthetic,
126 label: "Synthetic",
127 section: VendorId::Synthetic.config_section(),
128 config_key: "api_key",
129 secret_label: "API key",
130 note: "weekly credit quota",
131 },
132 KeyVendor {
133 id: VendorId::DeepRelay,
134 label: "DeepRelay",
135 section: VendorId::DeepRelay.config_section(),
136 config_key: "api_key",
137 secret_label: "API key",
138 note: "key needs the billing:read scope",
139 },
140 KeyVendor {
141 id: VendorId::Moonshot,
142 label: "Moonshot",
143 section: VendorId::Moonshot.config_section(),
144 config_key: "api_key",
145 secret_label: "API key",
146 note: "account balance",
147 },
148 KeyVendor {
149 id: VendorId::Grok,
150 label: "Grok",
151 section: VendorId::Grok.config_section(),
152 config_key: "api_key",
153 secret_label: "API key",
154 note: "management key, not the inference key",
155 },
156 KeyVendor {
157 id: VendorId::Minimax,
158 label: "MiniMax",
159 section: VendorId::Minimax.config_section(),
160 config_key: "api_key",
161 secret_label: "API key",
162 note: "Token Plan subscription key",
163 },
164 KeyVendor {
165 id: VendorId::OpenCodeGo,
166 label: "OpenCode Go",
167 section: VendorId::OpenCodeGo.config_section(),
168 config_key: "api_key",
169 secret_label: "API key",
170 note: "usage quota",
171 },
172 KeyVendor {
173 id: VendorId::Ollama,
174 label: "Ollama Cloud",
175 section: VendorId::Ollama.config_section(),
176 config_key: "api_key",
177 secret_label: "API key",
178 note: "ollama.com/settings/keys",
179 },
180 KeyVendor {
181 id: VendorId::OrcaRouter,
182 label: "OrcaRouter",
183 section: VendorId::OrcaRouter.config_section(),
184 config_key: "api_key",
185 secret_label: "API key",
186 note: "credit balance",
187 },
188 KeyVendor {
189 id: VendorId::Lyceum,
190 label: "Lyceum",
191 section: VendorId::Lyceum.config_section(),
192 config_key: "api_key",
193 secret_label: "API key",
194 note: "billing credits",
195 },
196];
197
198pub const PROVIDER_SWITCH_COUNT: usize = VendorId::all().len();
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum Focus {
208 Primary,
209 Key(usize),
210 Vendor(usize),
211 NotifyEnabled,
212 NotifyThreshold,
213 Save,
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum SettingsRow {
219 Focus(Focus),
221 Switch(Focus, KeyCode, KeyModifiers),
225 OpenPicker,
228 Pick(usize),
231 HintKey(KeyCode, KeyModifiers),
234}
235
236impl Focus {
237 pub fn next(self) -> Self {
238 match self {
239 Focus::Primary => Focus::Key(0),
240 Focus::Key(i) if i + 1 < KEY_VENDORS.len() => Focus::Key(i + 1),
241 Focus::Key(_) => Focus::Vendor(0),
242 Focus::Vendor(i) if i + 1 < PROVIDER_SWITCH_COUNT => Focus::Vendor(i + 1),
243 Focus::Vendor(_) => Focus::NotifyEnabled,
244 Focus::NotifyEnabled => Focus::NotifyThreshold,
245 Focus::NotifyThreshold => Focus::Save,
246 Focus::Save => Focus::Primary,
247 }
248 }
249 pub fn prev(self) -> Self {
250 match self {
251 Focus::Primary => Focus::Save,
252 Focus::Key(0) => Focus::Primary,
253 Focus::Key(i) => Focus::Key(i - 1),
254 Focus::Vendor(0) => Focus::Key(KEY_VENDORS.len() - 1),
255 Focus::Vendor(i) => Focus::Vendor(i - 1),
256 Focus::NotifyEnabled => Focus::Vendor(PROVIDER_SWITCH_COUNT - 1),
257 Focus::NotifyThreshold => Focus::NotifyEnabled,
258 Focus::Save => Focus::NotifyThreshold,
259 }
260 }
261}
262
263#[derive(Debug, Clone, Copy, Default)]
266pub struct PrimaryPicker {
267 pub cursor: usize,
268 pub scroll: u16,
269}
270
271#[derive(Debug, Clone, Default)]
273pub struct KeyInput {
274 pub buf: String,
275 pub cursor: usize,
277 pub revealed: bool,
279 pub dirty: bool,
283}
284
285impl KeyInput {
286 pub fn from_config(initial: Option<&str>) -> Self {
287 let buf = initial.unwrap_or("").to_string();
288 let cursor = buf.chars().count();
289 Self {
290 buf,
291 cursor,
292 revealed: false,
293 dirty: false,
294 }
295 }
296
297 pub fn insert_char(&mut self, c: char) {
298 let byte_idx = self.char_to_byte(self.cursor);
299 self.buf.insert(byte_idx, c);
300 self.cursor += 1;
301 self.dirty = true;
302 }
303
304 pub fn backspace(&mut self) {
305 if self.cursor == 0 {
306 return;
307 }
308 let prev_byte = self.char_to_byte(self.cursor - 1);
309 let cur_byte = self.char_to_byte(self.cursor);
310 self.buf.replace_range(prev_byte..cur_byte, "");
311 self.cursor -= 1;
312 self.dirty = true;
313 }
314
315 pub fn delete(&mut self) {
316 let n = self.buf.chars().count();
317 if self.cursor >= n {
318 return;
319 }
320 let cur_byte = self.char_to_byte(self.cursor);
321 let next_byte = self.char_to_byte(self.cursor + 1);
322 self.buf.replace_range(cur_byte..next_byte, "");
323 self.dirty = true;
324 }
325
326 pub fn move_left(&mut self) {
327 if self.cursor > 0 {
328 self.cursor -= 1;
329 }
330 }
331 pub fn move_right(&mut self) {
332 if self.cursor < self.buf.chars().count() {
333 self.cursor += 1;
334 }
335 }
336 pub fn move_home(&mut self) {
337 self.cursor = 0;
338 }
339 pub fn move_end(&mut self) {
340 self.cursor = self.buf.chars().count();
341 }
342 pub fn toggle_reveal(&mut self) {
343 self.revealed = !self.revealed;
344 }
345
346 pub fn display(&self) -> String {
348 if self.revealed {
349 self.buf.clone()
350 } else {
351 "•".repeat(self.buf.chars().count())
352 }
353 }
354
355 fn char_to_byte(&self, char_idx: usize) -> usize {
356 self.buf
357 .char_indices()
358 .map(|(b, _)| b)
359 .chain(std::iter::once(self.buf.len()))
360 .nth(char_idx)
361 .unwrap_or(self.buf.len())
362 }
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
369pub struct ProviderSwitch {
370 pub enabled: bool,
371 pub dirty: bool,
372}
373
374#[derive(Debug, Clone)]
376pub struct SettingsState {
377 pub focus: Focus,
378 pub primary_choices: Vec<VendorId>,
381 pub primary: VendorId,
382 pub keys: Vec<KeyInput>,
384 pub vendors: Vec<ProviderSwitch>,
387 pub notify_enabled: bool,
389 pub notify_enabled_dirty: bool,
390 pub notify_threshold: KeyInput,
392 pub status: String,
394 pub scroll: u16,
397 pub picker: Option<PrimaryPicker>,
399}
400
401impl SettingsState {
402 pub fn from_config(cfg: &Config) -> Self {
403 Self::from_config_with(cfg, |name| {
404 std::env::var_os(name).is_some_and(|v| !v.is_empty())
405 })
406 }
407
408 pub fn from_config_with(cfg: &Config, env_set: impl Fn(&str) -> bool) -> Self {
417 let keys = KEY_VENDORS
418 .iter()
419 .map(|kv| KeyInput::from_config(cfg.inline_api_key(kv.id)))
420 .collect();
421 let mut primary_choices = cfg.enabled_vendors();
422 if !primary_choices.contains(&VendorId::Copilot) {
426 primary_choices.push(VendorId::Copilot);
427 }
428 for kv in KEY_VENDORS {
436 if primary_choices.contains(&kv.id) {
437 continue;
438 }
439 let env = cfg.api_key_env_for(kv.id);
440 let exported = is_valid_env_var_name(env) && env_set(env);
441 if cfg.inline_api_key(kv.id).is_some() || exported {
442 primary_choices.push(kv.id);
443 }
444 }
445 let primary = cfg
452 .ui
453 .primary
454 .filter(|vendor| primary_choices.contains(vendor) && cfg.is_enabled(*vendor))
455 .or_else(|| primary_choices.first().copied())
456 .unwrap_or_else(|| cfg.ui.primary.unwrap_or(VendorId::Anthropic));
457 Self {
458 focus: Focus::Primary,
459 primary_choices,
460 primary,
461 keys,
462 vendors: VendorId::all()
463 .iter()
464 .map(|id| ProviderSwitch {
465 enabled: cfg.is_enabled(*id),
466 dirty: false,
467 })
468 .collect(),
469 notify_enabled: cfg.notifications.enabled,
470 notify_enabled_dirty: false,
471 notify_threshold: KeyInput::from_config(Some(&cfg.notifications.threshold.to_string())),
472 status: String::new(),
473 scroll: 0,
474 picker: None,
475 }
476 }
477
478 fn focused_key_mut(&mut self) -> Option<&mut KeyInput> {
480 match self.focus {
481 Focus::Key(i) => self.keys.get_mut(i),
482 _ => None,
483 }
484 }
485
486 fn next_focus(&self) -> Focus {
489 self.focus.next()
490 }
491
492 fn prev_focus(&self) -> Focus {
493 self.focus.prev()
494 }
495}
496
497#[derive(Debug, Clone, Copy, PartialEq, Eq)]
499pub enum Action {
500 Continue,
502 Close,
504 SavedAndClose,
506 Quit,
509}
510
511#[cfg(unix)]
514const PERMS_NOTE: &str = " (chmod 600)";
515#[cfg(not(unix))]
516const PERMS_NOTE: &str = "";
517
518fn saved_status() -> String {
519 format!(
520 "saved to {}{}",
521 crate::config::config_path_hint(),
522 PERMS_NOTE
523 )
524}
525
526pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
528 if state.picker.is_some()
531 && !(matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL))
532 {
533 return handle_picker_key(state, code);
534 }
535 if matches!(code, KeyCode::Esc) {
536 return Action::Close;
537 }
538 if matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL) {
539 return Action::Quit;
540 }
541 if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
543 return try_save(state);
544 }
545 if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
546 if let Some(input) = state.focused_key_mut() {
547 input.toggle_reveal();
548 }
549 return Action::Continue;
550 }
551 match code {
552 KeyCode::Tab | KeyCode::Down => {
553 state.focus = state.next_focus();
554 return Action::Continue;
555 }
556 KeyCode::BackTab | KeyCode::Up => {
557 state.focus = state.prev_focus();
558 return Action::Continue;
559 }
560 _ => {}
561 }
562
563 if matches!(code, KeyCode::Char(_))
568 && mods.intersects(
569 KeyModifiers::CONTROL
570 | KeyModifiers::ALT
571 | KeyModifiers::SUPER
572 | KeyModifiers::HYPER
573 | KeyModifiers::META,
574 )
575 {
576 return Action::Continue;
577 }
578
579 match state.focus {
581 Focus::Primary => handle_primary(state, code),
582 Focus::Key(i) => {
583 if let Some(input) = state.keys.get_mut(i) {
584 handle_input(input, code);
585 }
586 }
587 Focus::Vendor(i) => handle_provider_switch(state, i, code),
588 Focus::NotifyEnabled => handle_notify_enabled(state, code),
589 Focus::NotifyThreshold => handle_threshold_input(&mut state.notify_threshold, code),
590 Focus::Save => {
591 if matches!(code, KeyCode::Enter) {
592 return try_save(state);
593 }
594 }
595 }
596 Action::Continue
597}
598
599fn try_save(state: &mut SettingsState) -> Action {
600 match save_to_config_default(state) {
601 Ok(()) => {
602 state.status = saved_status();
603 Action::SavedAndClose
604 }
605 Err(e) => {
606 state.status = format!("save failed: {e}");
607 Action::Continue
608 }
609 }
610}
611
612fn handle_picker_key(state: &mut SettingsState, code: KeyCode) -> Action {
615 let count = state.primary_choices.len();
616 if count == 0 {
617 state.picker = None;
618 return Action::Continue;
619 }
620 let Some(picker) = state.picker.as_mut() else {
621 return Action::Continue;
622 };
623 match code {
624 KeyCode::Up => picker.cursor = (picker.cursor + count - 1) % count,
625 KeyCode::Down => picker.cursor = (picker.cursor + 1) % count,
626 KeyCode::Enter | KeyCode::Char(' ') => {
627 state.primary = state.primary_choices[picker.cursor];
628 state.picker = None;
629 }
630 KeyCode::Esc => state.picker = None,
631 _ => {}
632 }
633 Action::Continue
634}
635
636fn handle_primary(state: &mut SettingsState, code: KeyCode) {
637 let choices = &state.primary_choices;
639 let Some(idx) = choices.iter().position(|v| *v == state.primary) else {
640 return;
641 };
642 let step = match code {
643 KeyCode::Left => -1,
644 KeyCode::Right | KeyCode::Char(' ') => 1,
645 _ => return,
646 };
647 state.primary = choices[((idx as i32 + step).rem_euclid(choices.len() as i32)) as usize];
648}
649
650fn handle_input(input: &mut KeyInput, code: KeyCode) {
651 match code {
652 KeyCode::Char(c) => input.insert_char(c),
653 KeyCode::Backspace => input.backspace(),
654 KeyCode::Delete => input.delete(),
655 KeyCode::Left => input.move_left(),
656 KeyCode::Right => input.move_right(),
657 KeyCode::Home => input.move_home(),
658 KeyCode::End => input.move_end(),
659 _ => {}
660 }
661}
662
663fn handle_notify_enabled(state: &mut SettingsState, code: KeyCode) {
666 if matches!(code, KeyCode::Left | KeyCode::Right | KeyCode::Char(' ')) {
667 state.notify_enabled = !state.notify_enabled;
668 state.notify_enabled_dirty = true;
669 }
670}
671
672fn handle_provider_switch(state: &mut SettingsState, index: usize, code: KeyCode) {
676 if matches!(code, KeyCode::Left | KeyCode::Right | KeyCode::Char(' '))
677 && let Some(switch) = state.vendors.get_mut(index)
678 {
679 switch.enabled = !switch.enabled;
680 switch.dirty = true;
681 }
682}
683
684fn handle_threshold_input(input: &mut KeyInput, code: KeyCode) {
688 match code {
689 KeyCode::Char(c) if c.is_ascii_digit() => input.insert_char(c),
690 KeyCode::Backspace => input.backspace(),
691 KeyCode::Delete => input.delete(),
692 KeyCode::Left => input.move_left(),
693 KeyCode::Right => input.move_right(),
694 KeyCode::Home => input.move_home(),
695 KeyCode::End => input.move_end(),
696 _ => {}
697 }
698}
699
700fn save_to_config_default(state: &SettingsState) -> Result<()> {
703 let path = default_config_path()?;
704 if let Some(parent) = path.parent() {
705 std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
706 }
707 save_to_path(state, &path)?;
708 crate::waybar::request_refresh();
709 Ok(())
710}
711
712pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
715 edit_config_document(path, |doc| {
718 if let Some(table) = doc
722 .get_mut("copilot")
723 .and_then(toml_edit::Item::as_table_like_mut)
724 {
725 table.remove("token");
726 table.remove("token_env");
727 }
728
729 if state.primary_choices.contains(&state.primary) {
738 set_string(doc, "ui", "primary", state.primary.slug())?;
739 if state.primary == VendorId::Copilot
740 || KEY_VENDORS.iter().any(|kv| kv.id == state.primary)
741 {
742 set_bool(doc, state.primary.config_section(), "enabled", true)?;
743 }
744 }
745
746 for (i, kv) in KEY_VENDORS.iter().enumerate() {
747 let Some(input) = state.keys.get(i) else {
748 continue;
749 };
750 update_key(doc, kv, input)?;
751 }
752
753 for (i, id) in VendorId::all().iter().enumerate() {
759 let Some(switch) = state.vendors.get(i) else {
760 continue;
761 };
762 if switch.dirty {
763 crate::config::set_vendor_enabled_in_doc(doc, id.slug(), switch.enabled)?;
764 }
765 }
766
767 if state.notify_enabled_dirty {
770 set_bool(doc, "notifications", "enabled", state.notify_enabled)?;
771 }
772 if state.notify_threshold.dirty {
773 let raw = state.notify_threshold.buf.trim();
774 let threshold = raw.parse::<u8>();
775 match threshold {
776 Ok(threshold) if (1..=100).contains(&threshold) => {
777 set_value(
778 doc,
779 "notifications",
780 "threshold",
781 Some(toml_edit::Value::from(i64::from(threshold))),
782 )?;
783 }
784 _ => {
785 return Err(AppError::Other(format!(
786 "[notifications] threshold must be a whole number between 1 and 100, got {raw:?}"
787 )));
788 }
789 }
790 }
791
792 Ok(())
793 })
794}
795
796fn reject_invalid_key(secret_label: &str, vendor_slug: &str, value: &str) -> Result<()> {
800 if value.is_empty() {
801 return Err(AppError::Other(format!(
802 "{secret_label} for {vendor_slug:?} is empty; use the clear action to remove it"
803 )));
804 }
805 if value.len() > MAX_API_KEY_BYTES {
806 return Err(AppError::Other(format!(
807 "{secret_label} for {vendor_slug:?} exceeds {MAX_API_KEY_BYTES} bytes"
808 )));
809 }
810 if value.chars().any(char::is_control) {
811 return Err(AppError::Other(format!(
812 "{secret_label} for {vendor_slug:?} contains control characters"
813 )));
814 }
815 Ok(())
816}
817
818pub(crate) fn key_vendor_for_slug(slug: &str) -> Option<&'static KeyVendor> {
822 KEY_VENDORS.iter().find(|vendor| vendor.id.slug() == slug)
823}
824
825pub(crate) fn validate_key_value(vendor: &KeyVendor, value: &str) -> Result<()> {
828 reject_invalid_key(vendor.secret_label, vendor.id.slug(), value)
829}
830
831pub(crate) struct KeyItemSnapshot {
837 key_decor: toml_edit::Decor,
838 value: toml_edit::Value,
839}
840
841pub(crate) struct KeySnapshot {
847 #[cfg_attr(not(test), allow(dead_code))]
851 pub file_existed: bool,
852 pub section_existed: bool,
853 pub key: Option<KeyItemSnapshot>,
854 pub enabled: Option<KeyItemSnapshot>,
855}
856
857fn capture_key_item(table: &dyn toml_edit::TableLike, name: &str) -> Option<KeyItemSnapshot> {
861 let (key, item) = table.get_key_value(name)?;
862 let value = item.as_value()?.clone();
863 Some(KeyItemSnapshot {
864 key_decor: key.leaf_decor().clone(),
865 value,
866 })
867}
868
869pub(crate) fn snapshot_inline_key(path: &Path, vendor: &KeyVendor) -> Result<KeySnapshot> {
875 let file_existed = path.exists();
876 let doc = read_config_document(path)?;
877 Ok(key_snapshot_of_doc(&doc, file_existed, vendor))
878}
879
880fn key_snapshot_of_doc(
884 doc: &toml_edit::DocumentMut,
885 file_existed: bool,
886 vendor: &KeyVendor,
887) -> KeySnapshot {
888 match doc
889 .get(vendor.section)
890 .and_then(toml_edit::Item::as_table_like)
891 {
892 Some(table) => KeySnapshot {
893 file_existed,
894 section_existed: true,
895 key: capture_key_item(table, vendor.config_key),
896 enabled: capture_key_item(table, "enabled"),
897 },
898 None => KeySnapshot {
899 file_existed,
900 section_existed: false,
901 key: None,
902 enabled: None,
903 },
904 }
905}
906
907pub(crate) const KEY_ALREADY_SAVED_MESSAGE: &str =
912 "A key is already saved in config.toml; replace it in the TUI settings.";
913
914pub(crate) fn saved_inline_key(snapshot: &KeySnapshot) -> Option<String> {
919 snapshot
920 .key
921 .as_ref()
922 .and_then(|item| item.value.as_str())
923 .filter(|value| !value.is_empty())
924 .map(str::to_string)
925}
926
927#[cfg_attr(not(test), allow(dead_code))]
937pub(crate) fn write_inline_key(path: &Path, vendor: &KeyVendor, value: &str) -> Result<()> {
938 validate_key_value(vendor, value)?;
939 edit_config_document(path, |doc| {
940 set_string(doc, vendor.section, vendor.config_key, value)?;
941 set_bool(doc, vendor.section, "enabled", true)
942 })
943}
944
945pub(crate) fn snapshot_and_write_inline_key_if_absent(
958 path: &Path,
959 vendor: &KeyVendor,
960 value: &str,
961) -> Result<KeySnapshot> {
962 validate_key_value(vendor, value)?;
963 edit_config_document(path, |doc| {
964 let snapshot = key_snapshot_of_doc(doc, path.exists(), vendor);
965 if saved_inline_key(&snapshot).is_some() {
966 return Err(AppError::Other(KEY_ALREADY_SAVED_MESSAGE.to_string()));
967 }
968 set_string(doc, vendor.section, vendor.config_key, value)?;
969 set_bool(doc, vendor.section, "enabled", true)?;
970 Ok(snapshot)
971 })
972}
973
974pub(crate) fn restore_inline_key_snapshot(
990 path: &Path,
991 vendor: &KeyVendor,
992 snapshot: &KeySnapshot,
993 candidate: &str,
994) -> Result<()> {
995 edit_config_document(path, |doc| {
996 let table = doc
997 .entry(vendor.section)
998 .or_insert_with(toml_edit::table)
999 .as_table_like_mut()
1000 .ok_or_else(|| {
1001 AppError::Other(format!("config.toml: [{}] is not a table", vendor.section))
1002 })?;
1003 let current_key = table
1011 .get(vendor.config_key)
1012 .and_then(toml_edit::Item::as_str);
1013 match &snapshot.key {
1014 Some(captured) if current_key == Some(candidate) => {
1015 if let Some((mut key, item)) = table.get_key_value_mut(vendor.config_key) {
1016 *key.leaf_decor_mut() = captured.key_decor.clone();
1017 *item = toml_edit::Item::Value(captured.value.clone());
1018 }
1019 }
1020 None if current_key == Some(candidate) => {
1021 table.remove(vendor.config_key);
1022 }
1023 _ => {}
1025 }
1026 let current_enabled = table.get("enabled").and_then(toml_edit::Item::as_bool);
1027 match &snapshot.enabled {
1028 Some(captured) if current_enabled == Some(true) => {
1029 if let Some((mut key, item)) = table.get_key_value_mut("enabled") {
1030 *key.leaf_decor_mut() = captured.key_decor.clone();
1031 *item = toml_edit::Item::Value(captured.value.clone());
1032 }
1033 }
1034 None if current_enabled == Some(true) => {
1035 table.remove("enabled");
1036 }
1037 _ => {}
1039 }
1040 if !snapshot.section_existed
1044 && doc
1045 .get(vendor.section)
1046 .and_then(toml_edit::Item::as_table_like)
1047 .is_some_and(|table| table.is_empty())
1048 {
1049 doc.remove(vendor.section);
1050 }
1051 Ok(())
1052 })
1053}
1054
1055fn update_key(doc: &mut DocumentMut, vendor: &KeyVendor, input: &KeyInput) -> Result<()> {
1061 if !input.dirty {
1062 return Ok(());
1063 }
1064 if input.buf.is_empty() {
1065 if let Some(table) = doc
1066 .get_mut(vendor.section)
1067 .and_then(toml_edit::Item::as_table_like_mut)
1068 {
1069 table.remove(vendor.config_key);
1070 }
1071 return Ok(());
1072 }
1073 set_string(doc, vendor.section, vendor.config_key, &input.buf)?;
1074 set_bool(doc, vendor.section, "enabled", true)
1075}
1076
1077fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
1080 let table = doc
1081 .entry(section)
1082 .or_insert_with(toml_edit::table)
1083 .as_table_like_mut()
1084 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
1085
1086 if let Some(item) = table.get_mut(key)
1087 && let Some(v) = item.as_value_mut()
1088 {
1089 *v = toml_edit::Value::from(new_value);
1090 v.decor_mut().set_prefix(" ");
1091 return Ok(());
1092 }
1093 table.insert(key, value(new_value));
1094 Ok(())
1095}
1096
1097fn default_config_path() -> Result<PathBuf> {
1098 crate::config::resolved_path()
1103 .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
1104}
1105
1106#[derive(Debug, Serialize)]
1113struct SettingsSnapshot {
1114 schema_version: u8,
1115 primary: String,
1116 primary_choices: Vec<PrimaryChoice>,
1117 keys: Vec<KeyStatus>,
1118 vendors: Vec<VendorStatus>,
1121}
1122
1123#[derive(Debug, Serialize)]
1124struct PrimaryChoice {
1125 id: String,
1126 label: String,
1127}
1128
1129#[derive(Debug, Serialize)]
1130struct KeyStatus {
1131 id: String,
1132 label: String,
1133 environment: String,
1134 secret_label: String,
1135 note: String,
1136 configured: bool,
1137 inline_configured: bool,
1138 environment_configured: bool,
1139}
1140
1141#[derive(Debug, Serialize)]
1144struct VendorStatus {
1145 id: String,
1146 label: String,
1147 enabled: bool,
1148}
1149
1150#[derive(Debug, Deserialize)]
1156#[serde(deny_unknown_fields)]
1157struct ApplyRequest {
1158 schema_version: u8,
1159 primary: Option<String>,
1160 #[serde(default)]
1161 keys: BTreeMap<String, KeyMutation>,
1162 #[serde(default)]
1163 vendors: BTreeMap<String, bool>,
1164}
1165
1166#[derive(Debug, Deserialize)]
1167#[serde(tag = "action", rename_all = "lowercase", deny_unknown_fields)]
1168enum KeyMutation {
1169 Set { value: String },
1170 Clear,
1171}
1172
1173const SETTINGS_SCHEMA_VERSION: u8 = 1;
1174const MAX_SETTINGS_REQUEST_BYTES: u64 = 64 * 1024;
1175pub(crate) const MAX_API_KEY_BYTES: usize = 16 * 1024;
1177
1178fn snapshot_from_config_with(
1179 cfg: &Config,
1180 environment_configured: impl Fn(&str) -> bool,
1181) -> SettingsSnapshot {
1182 let state = SettingsState::from_config(cfg);
1183 let primary_choices = state
1184 .primary_choices
1185 .iter()
1186 .map(|id| PrimaryChoice {
1187 id: id.slug().to_string(),
1188 label: id.display_name().to_string(),
1189 })
1190 .collect();
1191 let keys = KEY_VENDORS
1192 .iter()
1193 .map(|vendor| {
1194 let environment = cfg.api_key_env_for(vendor.id);
1195 let inline_configured = cfg.inline_api_key(vendor.id).is_some();
1196 let environment_configured = environment_configured(environment);
1197 KeyStatus {
1198 id: vendor.id.slug().to_string(),
1199 label: vendor.label.to_string(),
1200 environment: environment.to_string(),
1201 secret_label: vendor.secret_label.to_string(),
1202 note: vendor.note.to_string(),
1203 configured: inline_configured || environment_configured,
1204 inline_configured,
1205 environment_configured,
1206 }
1207 })
1208 .collect();
1209 let vendors = VendorId::all()
1210 .iter()
1211 .map(|id| VendorStatus {
1212 id: id.slug().to_string(),
1213 label: id.display_name().to_string(),
1214 enabled: cfg.is_enabled(*id),
1215 })
1216 .collect();
1217 SettingsSnapshot {
1218 schema_version: SETTINGS_SCHEMA_VERSION,
1219 primary: state.primary.slug().to_string(),
1220 primary_choices,
1221 keys,
1222 vendors,
1223 }
1224}
1225
1226fn settings_snapshot_json(cfg: &Config) -> Result<String> {
1227 Ok(serde_json::to_string(&snapshot_from_config_with(
1228 cfg,
1229 |environment| std::env::var_os(environment).is_some_and(|value| !value.is_empty()),
1230 ))?)
1231}
1232
1233#[cfg(test)]
1234fn settings_snapshot_json_with(
1235 cfg: &Config,
1236 environment_configured: impl Fn(&str) -> bool,
1237) -> Result<String> {
1238 Ok(serde_json::to_string(&snapshot_from_config_with(
1239 cfg,
1240 environment_configured,
1241 ))?)
1242}
1243
1244fn vendor_from_slug(slug: &str) -> Option<VendorId> {
1245 VendorId::from_slug(slug)
1246}
1247
1248fn state_from_apply_request(cfg: &Config, raw: &str) -> Result<SettingsState> {
1249 let request: ApplyRequest = serde_json::from_str(raw)?;
1250 if request.schema_version != SETTINGS_SCHEMA_VERSION {
1251 return Err(AppError::Other(format!(
1252 "unsupported settings schema version {}",
1253 request.schema_version
1254 )));
1255 }
1256
1257 let mut state = SettingsState::from_config(cfg);
1258 if let Some(primary) = request.primary {
1259 let id = vendor_from_slug(&primary)
1260 .ok_or_else(|| AppError::Other(format!("unknown primary vendor {primary:?}")))?;
1261 if !state.primary_choices.contains(&id) {
1262 return Err(AppError::Other(format!(
1263 "primary vendor {primary:?} is not enabled"
1264 )));
1265 }
1266 state.primary = id;
1267 }
1268
1269 for (id, mutation) in request.keys {
1270 let index = KEY_VENDORS
1271 .iter()
1272 .position(|vendor| vendor.id.slug() == id)
1273 .ok_or_else(|| AppError::Other(format!("unknown credential vendor {id:?}")))?;
1274 let input = &mut state.keys[index];
1275 match mutation {
1276 KeyMutation::Set { value } => {
1277 reject_invalid_key(KEY_VENDORS[index].secret_label, &id, &value)?;
1278 input.buf = value;
1279 }
1280 KeyMutation::Clear => input.buf.clear(),
1281 }
1282 input.cursor = input.buf.chars().count();
1283 input.dirty = true;
1284 input.revealed = false;
1285 }
1286
1287 for (slug, enabled) in request.vendors {
1291 let index = VendorId::all()
1292 .iter()
1293 .position(|id| id.slug() == slug)
1294 .ok_or_else(|| AppError::Other(format!("unknown provider {slug:?}")))?;
1295 state.vendors[index] = ProviderSwitch {
1296 enabled,
1297 dirty: true,
1298 };
1299 }
1300 Ok(state)
1301}
1302
1303#[cfg(test)]
1304fn apply_settings_json_to_path(cfg: &Config, raw: &str, path: &Path) -> Result<()> {
1305 let state = state_from_apply_request(cfg, raw)?;
1306 save_to_path(&state, path)
1307}
1308
1309fn read_settings_request<R: BufRead>(reader: R) -> Result<String> {
1310 let mut limited = reader.take(MAX_SETTINGS_REQUEST_BYTES + 1);
1311 let mut bytes = Vec::new();
1312 limited.read_until(b'\n', &mut bytes)?;
1313 if bytes.len() as u64 > MAX_SETTINGS_REQUEST_BYTES {
1314 return Err(AppError::Other(format!(
1315 "settings request exceeds {MAX_SETTINGS_REQUEST_BYTES} bytes"
1316 )));
1317 }
1318 if bytes.last() == Some(&b'\n') {
1319 bytes.pop();
1320 if bytes.last() == Some(&b'\r') {
1321 bytes.pop();
1322 }
1323 }
1324 String::from_utf8(bytes)
1325 .map_err(|_| AppError::Other("settings request is not valid UTF-8".into()))
1326}
1327
1328fn apply_settings_from_stdin() -> Result<()> {
1329 let raw = read_settings_request(std::io::stdin().lock())?;
1330 let cfg = Config::load()?;
1331 let state = state_from_apply_request(&cfg, &raw)?;
1332 save_to_config_default(&state)
1333}
1334
1335fn enable_vendor_at(path: &Path, vendor: VendorId) -> Result<()> {
1337 edit_config_document(path, |doc| {
1338 set_bool(doc, vendor.config_section(), "enabled", true)
1339 })
1340}
1341
1342pub fn run_cli(action: &crate::widget::cli::SettingsAction) -> i32 {
1346 let result = match action {
1347 crate::widget::cli::SettingsAction::Enable { vendor } => default_config_path()
1348 .and_then(|path| enable_vendor_at(&path, vendor.to_id()))
1349 .map(|()| {
1350 crate::waybar::request_refresh();
1351 println!(r#"{{"ok":true}}"#);
1352 }),
1353 crate::widget::cli::SettingsAction::Show => Config::load()
1354 .and_then(|cfg| settings_snapshot_json(&cfg))
1355 .map(|json| println!("{json}")),
1356 crate::widget::cli::SettingsAction::Apply => {
1357 apply_settings_from_stdin().map(|()| println!(r#"{{"ok":true}}"#))
1358 }
1359 };
1360 match result {
1361 Ok(()) => 0,
1362 Err(error) => {
1363 eprintln!("settings: {error}");
1364 1
1365 }
1366 }
1367}
1368
1369struct BodyLayout {
1375 primary: usize,
1376 first_key: usize,
1377 first_vendor: usize,
1378 notify_enabled: usize,
1379 notify_threshold: usize,
1380 save: usize,
1381 rows: usize,
1383}
1384
1385fn body_layout(keys: usize, vendors: usize) -> BodyLayout {
1386 let first_key = 4;
1387 let first_vendor = first_key + keys + 2;
1388 let notify_enabled = first_vendor + vendors + 2;
1389 BodyLayout {
1390 primary: 1,
1391 first_key,
1392 first_vendor,
1393 notify_enabled,
1394 notify_threshold: notify_enabled + 1,
1395 save: notify_enabled + 3,
1396 rows: notify_enabled + 4,
1397 }
1398}
1399
1400fn focus_line(state: &SettingsState) -> usize {
1402 let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
1403 match state.focus {
1404 Focus::Primary => layout.primary,
1405 Focus::Key(i) => layout.first_key + i,
1406 Focus::Vendor(i) => layout.first_vendor + i,
1407 Focus::NotifyEnabled => layout.notify_enabled,
1408 Focus::NotifyThreshold => layout.notify_threshold,
1409 Focus::Save => layout.save,
1410 }
1411}
1412
1413fn follow_focus_scroll(focused: usize, total: usize, viewport: u16, current: u16) -> u16 {
1417 if viewport == 0 || total == 0 {
1418 return 0;
1419 }
1420 let max_offset = (total as u16).saturating_sub(viewport);
1421 let focused = focused.min(total - 1) as u16;
1422 let wanted = if focused < current {
1423 focused
1424 } else if focused >= current.saturating_add(viewport) {
1425 (focused + 1).saturating_sub(viewport)
1428 } else {
1429 current
1430 };
1431 wanted.min(max_offset)
1432}
1433
1434pub fn render(
1436 f: &mut Frame,
1437 area: Rect,
1438 state: &mut SettingsState,
1439 theme: &Theme,
1440 hits: &mut Vec<(SettingsRow, Rect)>,
1441) {
1442 let modal = centered_rect(74, 88, area);
1443 f.render_widget(Clear, modal);
1444
1445 let bubble = bubble_theme(theme);
1446 let block = bubble.titled_modal_block(" Settings ");
1447 let inner = block.inner(modal);
1448 f.render_widget(block, modal);
1449
1450 let chunks = Layout::default()
1452 .direction(Direction::Vertical)
1453 .constraints([Constraint::Min(0), Constraint::Length(1)])
1454 .split(inner);
1455
1456 let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
1462 let total = layout.rows + usize::from(!state.status.is_empty());
1463 let scroll = follow_focus_scroll(focus_line(state), total, chunks[0].height, state.scroll);
1464 state.scroll = scroll;
1465
1466 let row_at = |line: usize| {
1471 let top = chunks[0].y;
1472 let bottom = top.saturating_add(chunks[0].height);
1473 let y = top.saturating_add(line as u16).checked_sub(scroll)?;
1474 (y >= top && y < bottom).then_some(Rect::new(inner.x, y, inner.width, 1))
1475 };
1476
1477 let mut lines: Vec<Line> = vec![
1479 section_header("Primary vendor", "shown first on the bar / TUI", &bubble),
1480 primary_line(state, &bubble),
1481 Line::from(""),
1482 section_header("API keys", "all key providers", &bubble),
1483 ];
1484 if let Some(r) = row_at(1) {
1485 if state.focus == Focus::Primary {
1491 let pad = primary_name_pad(state);
1492 hits.push((
1493 SettingsRow::Switch(Focus::Primary, KeyCode::Left, KeyModifiers::NONE),
1494 Rect::new(r.x.saturating_add(5), r.y, 2, 1),
1495 ));
1496 hits.push((
1497 SettingsRow::Switch(Focus::Primary, KeyCode::Right, KeyModifiers::NONE),
1498 Rect::new(r.x.saturating_add(9 + pad), r.y, 2, 1),
1499 ));
1500 hits.push((
1501 SettingsRow::OpenPicker,
1502 Rect::new(r.x.saturating_add(7), r.y, pad + 2, 1),
1503 ));
1504 }
1505 hits.push((SettingsRow::Focus(Focus::Primary), r));
1506 }
1507
1508 for (i, kv) in KEY_VENDORS.iter().enumerate() {
1509 let focused = state.focus == Focus::Key(i);
1510 if let Some(r) = row_at(lines.len()) {
1511 hits.push((SettingsRow::Focus(Focus::Key(i)), r));
1512 }
1513 lines.push(key_row(kv, &state.keys[i], focused, &bubble));
1514 }
1515
1516 lines.push(Line::from(""));
1517
1518 lines.push(section_header(
1520 "Providers",
1521 "which vendors the report, bar and TUI fetch at all",
1522 &bubble,
1523 ));
1524 for (i, id) in VendorId::all().iter().enumerate() {
1525 let switch = state.vendors.get(i).copied().unwrap_or(ProviderSwitch {
1526 enabled: false,
1527 dirty: false,
1528 });
1529 let focused = state.focus == Focus::Vendor(i);
1530 if let Some(r) = row_at(lines.len()) {
1531 let (value_x, value_w) =
1537 switch_cell(id.display_name(), switch_value(switch.enabled), focused);
1538 hits.push((
1539 SettingsRow::Switch(Focus::Vendor(i), KeyCode::Char(' '), KeyModifiers::NONE),
1540 Rect::new(r.x.saturating_add(value_x), r.y, value_w, 1),
1541 ));
1542 hits.push((SettingsRow::Focus(Focus::Vendor(i)), r));
1543 }
1544 lines.push(provider_row(id, switch, focused, &bubble));
1545 }
1546 lines.push(Line::from(""));
1547
1548 lines.push(section_header(
1550 "Notifications",
1551 "desktop alert when a quota window crosses the threshold",
1552 &bubble,
1553 ));
1554 if let Some(r) = row_at(lines.len()) {
1555 let (value_x, value_w) = switch_cell(
1556 "Quota alerts",
1557 switch_value(state.notify_enabled),
1558 state.focus == Focus::NotifyEnabled,
1559 );
1560 hits.push((
1561 SettingsRow::Switch(Focus::NotifyEnabled, KeyCode::Char(' '), KeyModifiers::NONE),
1562 Rect::new(r.x.saturating_add(value_x), r.y, value_w, 1),
1563 ));
1564 hits.push((SettingsRow::Focus(Focus::NotifyEnabled), r));
1565 }
1566 lines.push(notify_enabled_line(state, &bubble));
1567 if let Some(r) = row_at(lines.len()) {
1568 hits.push((SettingsRow::Focus(Focus::NotifyThreshold), r));
1569 }
1570 lines.push(notify_threshold_line(state, &bubble));
1571 lines.push(Line::from(""));
1572
1573 if let Some(r) = row_at(lines.len()) {
1575 hits.push((SettingsRow::Focus(Focus::Save), r));
1576 }
1577 lines.push(save_line(state.focus == Focus::Save, &bubble));
1578 if !state.status.is_empty() {
1579 let ok = state.status.starts_with("saved");
1580 let mark = if ok { " ✓ " } else { " ✗ " };
1581 let style = if ok { bubble.accent } else { bubble.selected };
1582 lines.push(Line::from(vec![
1583 Span::styled(mark, style.add_modifier(Modifier::BOLD)),
1584 Span::styled(state.status.clone(), bubble.muted),
1585 ]));
1586 }
1587
1588 f.render_widget(Paragraph::new(lines).scroll((scroll, 0)), chunks[0]);
1591
1592 type HintSegment<'a> = (&'a str, &'a str, Option<(KeyCode, KeyModifiers)>);
1596
1597 let segments: &[HintSegment] = match state.focus {
1602 Focus::Primary => &[
1603 ("↑↓/tab", "move", None),
1604 (
1605 "←→",
1606 "change vendor",
1607 Some((KeyCode::Right, KeyModifiers::NONE)),
1608 ),
1609 (
1610 "^S",
1611 "save",
1612 Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1613 ),
1614 ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1615 ],
1616 Focus::Key(_) => &[
1617 ("↑↓/tab", "move", None),
1618 ("type", "edit key", None),
1619 (
1620 "^V",
1621 "reveal",
1622 Some((KeyCode::Char('v'), KeyModifiers::CONTROL)),
1623 ),
1624 (
1625 "^S",
1626 "save",
1627 Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1628 ),
1629 ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1630 ],
1631 Focus::Vendor(_) | Focus::NotifyEnabled => &[
1632 ("↑↓/tab", "move", None),
1633 (
1634 "←→/space",
1635 "toggle",
1636 Some((KeyCode::Char(' '), KeyModifiers::NONE)),
1637 ),
1638 (
1639 "^S",
1640 "save",
1641 Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1642 ),
1643 ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1644 ],
1645 Focus::NotifyThreshold => &[
1646 ("↑↓/tab", "move", None),
1647 ("type", "digits 1-100", None),
1648 (
1649 "^S",
1650 "save",
1651 Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1652 ),
1653 ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1654 ],
1655 Focus::Save => &[
1656 ("↑↓/tab", "move", None),
1657 (
1658 "enter/^S",
1659 "save",
1660 Some((KeyCode::Enter, KeyModifiers::NONE)),
1661 ),
1662 ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1663 ],
1664 };
1665 let hint = bubble.help_line(segments.iter().map(|(key, desc, _)| (*key, *desc)));
1666 f.render_widget(Paragraph::new(hint), chunks[1]);
1667
1668 let right = chunks[1].x.saturating_add(chunks[1].width);
1673 let mut x = chunks[1].x;
1674 for (key, desc, action) in segments {
1675 let width = (crate::display::text_width(key) as u16)
1676 .saturating_add(1)
1677 .saturating_add(crate::display::text_width(desc) as u16);
1678 if let Some((code, mods)) = action
1679 && x < right
1680 {
1681 let visible = width.min(right.saturating_sub(x));
1682 if visible > 0 {
1683 hits.push((
1684 SettingsRow::HintKey(*code, *mods),
1685 Rect::new(x, chunks[1].y, visible, 1),
1686 ));
1687 }
1688 }
1689 x = x.saturating_add(width).saturating_add(3);
1690 }
1691
1692 if state.picker.is_some() {
1695 render_primary_picker(f, state, &bubble, chunks[0], hits);
1696 }
1697}
1698
1699fn render_primary_picker(
1703 f: &mut Frame,
1704 state: &mut SettingsState,
1705 theme: &BubbleTheme,
1706 body: Rect,
1707 hits: &mut Vec<(SettingsRow, Rect)>,
1708) {
1709 let count = state.primary_choices.len();
1710 if count == 0 {
1711 return;
1712 }
1713 let pad = primary_name_pad(state) as usize;
1714 let width = u16::try_from(pad + 6).unwrap_or(u16::MAX).min(body.width);
1715 if width < 4 {
1716 return;
1717 }
1718 let height = (count as u16 + 2)
1720 .min(body.bottom().saturating_sub(body.y + 2))
1721 .max(3);
1722 let area = Rect::new(
1723 body.x.saturating_add(2),
1724 body.y.saturating_add(2),
1725 width,
1726 height,
1727 );
1728
1729 f.render_widget(Clear, area);
1730 let block = theme.titled_modal_block(" Primary vendor ");
1731 let inner = block.inner(area);
1732 f.render_widget(block, area);
1733
1734 let Some(picker) = state.picker.as_mut() else {
1735 return;
1736 };
1737 let viewport = inner.height;
1738 picker.scroll = follow_focus_scroll(picker.cursor, count, viewport, picker.scroll);
1739 for (index, vendor) in state
1740 .primary_choices
1741 .iter()
1742 .enumerate()
1743 .skip(picker.scroll as usize)
1744 .take(viewport as usize)
1745 {
1746 let y = inner.y + u16::try_from(index - picker.scroll as usize).unwrap_or(u16::MAX);
1747 let cursor = picker.cursor == index;
1748 let current = *vendor == state.primary;
1749 let style = if cursor {
1750 theme.accent.add_modifier(Modifier::BOLD)
1751 } else if current {
1752 theme.title
1753 } else {
1754 theme.text
1755 };
1756 let line = Line::from(vec![
1757 theme.span(if cursor { "▸ " } else { " " }),
1758 Span::styled(format!("{:<pad$}", vendor.display_name(), pad = pad), style),
1759 theme.span(if current { " ✓" } else { "" }),
1760 ]);
1761 f.render_widget(line, Rect::new(inner.x, y, inner.width, 1));
1762 hits.push((
1763 SettingsRow::Pick(index),
1764 Rect::new(inner.x, y, inner.width, 1),
1765 ));
1766 }
1767}
1768
1769fn section_header(title: &str, sub: &str, theme: &BubbleTheme) -> Line<'static> {
1770 Line::from(vec![
1771 theme.span(" "),
1772 Span::styled(title.to_string(), theme.title.add_modifier(Modifier::BOLD)),
1773 theme.muted(format!(" — {sub}")),
1774 ])
1775}
1776
1777fn primary_name_pad(state: &SettingsState) -> u16 {
1780 state
1781 .primary_choices
1782 .iter()
1783 .map(|v| crate::display::text_width(v.display_name()) as u16)
1784 .max()
1785 .unwrap_or(1)
1786}
1787
1788fn primary_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1789 let focused = state.focus == Focus::Primary;
1790 let name = state.primary.display_name().to_string();
1791 if focused {
1792 let pad = primary_name_pad(state) as usize;
1793 Line::from(vec![
1794 theme.span(" "),
1795 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1796 Span::styled("◀ ", theme.accent),
1797 Span::styled(
1798 format!(" {:<pad$} ", name, pad = pad),
1799 theme
1800 .selected
1801 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1802 ),
1803 Span::styled(" ▶", theme.accent),
1804 theme.muted(" ← → or click to change"),
1805 ])
1806 } else {
1807 Line::from(vec![theme.span(" "), Span::styled(name, theme.text)])
1808 }
1809}
1810
1811fn key_row(kv: &KeyVendor, input: &KeyInput, focused: bool, theme: &BubbleTheme) -> Line<'static> {
1812 let label = format!("{:<11}", kv.label);
1813 let value = value_text(input, focused);
1814
1815 let env_name = kv.id.api_key_env();
1817 let env_set = std::env::var(env_name)
1818 .map(|v| !v.is_empty())
1819 .unwrap_or(false);
1820 let mut suffix = format!(" {env_name}");
1821 if env_set {
1822 suffix.push_str(" · env set (overrides)");
1823 }
1824 if !kv.note.is_empty() {
1825 suffix.push_str(&format!(" · {}", kv.note));
1826 }
1827
1828 if focused {
1829 let val_style = if input.buf.is_empty() {
1830 theme.accent.add_modifier(Modifier::BOLD)
1831 } else {
1832 theme.selected.add_modifier(Modifier::REVERSED)
1833 };
1834 let mut spans = vec![
1835 theme.span(" "),
1836 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1837 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1838 Span::styled(format!(" {value} "), val_style),
1839 ];
1840 if input.revealed {
1841 spans.push(theme.muted(" [revealed]"));
1842 }
1843 spans.push(theme.muted(suffix));
1844 Line::from(spans)
1845 } else {
1846 let val_style = if input.buf.is_empty() {
1847 theme.muted
1848 } else {
1849 theme.text
1850 };
1851 Line::from(vec![
1852 theme.span(" "),
1853 Span::styled(label, theme.text),
1854 Span::styled(format!(" {value}"), val_style),
1855 theme.muted(suffix),
1856 ])
1857 }
1858}
1859
1860fn value_text(input: &KeyInput, focused: bool) -> String {
1863 if input.buf.is_empty() {
1864 return if focused {
1865 "‸".to_string()
1866 } else {
1867 "(empty)".to_string()
1868 };
1869 }
1870 let base = input.display();
1871 if !focused {
1872 return base;
1873 }
1874 let mut chars: Vec<char> = base.chars().collect();
1875 let pos = input.cursor.min(chars.len());
1876 chars.insert(pos, '‸');
1877 chars.into_iter().collect()
1878}
1879
1880fn padded_label(label: &str) -> String {
1884 format!("{:<11}", label)
1885}
1886
1887fn switch_value(enabled: bool) -> &'static str {
1889 if enabled { "on" } else { "off" }
1890}
1891
1892fn switch_cell(label: &str, value: &str, focused: bool) -> (u16, u16) {
1897 let value_x = 5 + crate::display::text_width(&padded_label(label)) as u16;
1898 let decoration_w = if focused { 6 } else { 2 };
1899 let value_w = decoration_w + crate::display::text_width(value) as u16;
1900 (value_x, value_w)
1901}
1902
1903fn provider_row(
1906 id: &VendorId,
1907 switch: ProviderSwitch,
1908 focused: bool,
1909 theme: &BubbleTheme,
1910) -> Line<'static> {
1911 let label = padded_label(id.display_name());
1912 let value = switch_value(switch.enabled);
1913 if focused {
1914 Line::from(vec![
1915 theme.span(" "),
1916 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1917 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1918 Span::styled(
1919 format!(" ◀ {value} ▶ "),
1920 theme
1921 .selected
1922 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1923 ),
1924 ])
1925 } else {
1926 Line::from(vec![
1927 theme.span(" "),
1928 Span::styled(label, theme.text),
1929 Span::styled(format!(" {value}"), theme.muted),
1930 ])
1931 }
1932}
1933
1934fn notify_enabled_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1937 let focused = state.focus == Focus::NotifyEnabled;
1938 let label = padded_label("Quota alerts");
1939 let value = switch_value(state.notify_enabled);
1940 if focused {
1941 Line::from(vec![
1942 theme.span(" "),
1943 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1944 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1945 Span::styled(
1946 format!(" ◀ {value} ▶ "),
1947 theme
1948 .selected
1949 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1950 ),
1951 ])
1952 } else {
1953 Line::from(vec![
1954 theme.span(" "),
1955 Span::styled(label, theme.text),
1956 Span::styled(format!(" {value}"), theme.muted),
1957 ])
1958 }
1959}
1960
1961fn notify_threshold_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1964 let focused = state.focus == Focus::NotifyThreshold;
1965 let label = format!("{:<11}", "Threshold %");
1966 let input = &state.notify_threshold;
1967 let value = if input.buf.is_empty() {
1968 if focused {
1969 "‸".to_string()
1970 } else {
1971 "(97)".to_string()
1972 }
1973 } else {
1974 let mut chars: Vec<char> = input.buf.chars().collect();
1975 if focused {
1976 let pos = input.cursor.min(chars.len());
1977 chars.insert(pos, '‸');
1978 }
1979 chars.into_iter().collect()
1980 };
1981 if focused {
1982 Line::from(vec![
1983 theme.span(" "),
1984 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1985 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1986 Span::styled(
1987 format!(" {value} "),
1988 theme
1989 .selected
1990 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1991 ),
1992 theme.muted(" 1-100"),
1993 ])
1994 } else {
1995 Line::from(vec![
1996 theme.span(" "),
1997 Span::styled(label, theme.text),
1998 Span::styled(format!(" {value}"), theme.text),
1999 theme.muted(" 1-100"),
2000 ])
2001 }
2002}
2003
2004fn save_line(focused: bool, theme: &BubbleTheme) -> Line<'static> {
2005 let style = if focused {
2006 theme
2007 .selected
2008 .add_modifier(Modifier::REVERSED | Modifier::BOLD)
2009 } else {
2010 theme.accent.add_modifier(Modifier::BOLD)
2011 };
2012 let marker = if focused { "▸ " } else { " " };
2013 Line::from(vec![
2014 theme.span(" "),
2015 Span::styled(marker, theme.accent.add_modifier(Modifier::BOLD)),
2016 Span::styled(" Save (Ctrl-S) ", style),
2017 ])
2018}
2019
2020fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
2022 let popup_h = (r.height * percent_y) / 100;
2023 let popup_w = (r.width * percent_x) / 100;
2024 Rect {
2025 x: r.x + (r.width - popup_w) / 2,
2026 y: r.y + (r.height - popup_h) / 2,
2027 width: popup_w,
2028 height: popup_h,
2029 }
2030}
2031
2032pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
2034
2035#[cfg(test)]
2036mod tests {
2037 use super::*;
2038 use tempfile::TempDir;
2039
2040 fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
2041 crate::cache::closed_temp_file("config.toml", initial)
2042 }
2043
2044 fn key_index(id: VendorId) -> usize {
2045 KEY_VENDORS.iter().position(|kv| kv.id == id).unwrap()
2046 }
2047
2048 #[test]
2049 fn explicit_enable_preserves_other_settings_and_is_idempotent() {
2050 let dir = tempfile::tempdir().unwrap();
2051 let path = dir.path().join("config.toml");
2052 let original = "# keep me\n[anthropic]\nenabled = false # intentional\n[openrouter]\napi_key = \"test-key\"\nenabled = false\n";
2053 std::fs::write(&path, original).unwrap();
2054 enable_vendor_at(&path, VendorId::Anthropic).unwrap();
2055 let expected = original.replacen("enabled = false", "enabled = true", 1);
2056 assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
2057 enable_vendor_at(&path, VendorId::Anthropic).unwrap();
2058 assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
2059 }
2060
2061 #[test]
2062 fn explicit_enable_creates_missing_config_and_rejects_malformed_config() {
2063 let dir = tempfile::tempdir().unwrap();
2064 let path = dir.path().join("nested/config.toml");
2065 enable_vendor_at(&path, VendorId::Anthropic).unwrap();
2066 assert!(
2067 std::fs::read_to_string(&path)
2068 .unwrap()
2069 .contains("enabled = true")
2070 );
2071 let broken = "[anthropic\n";
2072 std::fs::write(&path, broken).unwrap();
2073 assert!(enable_vendor_at(&path, VendorId::Anthropic).is_err());
2074 assert_eq!(std::fs::read_to_string(&path).unwrap(), broken);
2075 }
2076
2077 fn blank_state(primary: VendorId) -> SettingsState {
2078 SettingsState {
2079 focus: Focus::Primary,
2080 primary_choices: VendorId::all().to_vec(),
2081 primary,
2082 keys: KEY_VENDORS.iter().map(|_| KeyInput::default()).collect(),
2083 vendors: VendorId::all()
2084 .iter()
2085 .map(|id| ProviderSwitch {
2086 enabled: matches!(id.slug(), "anthropic" | "openai" | "zai" | "openrouter"),
2087 dirty: false,
2088 })
2089 .collect(),
2090 notify_enabled: true,
2091 notify_enabled_dirty: false,
2092 notify_threshold: KeyInput::from_config(Some("97")),
2093 status: String::new(),
2094 scroll: 0,
2095 picker: None,
2096 }
2097 }
2098
2099 fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
2101 let mut s = blank_state(primary);
2102 s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some(zai));
2103 s.keys[key_index(VendorId::Zai)].dirty = true;
2104 s.keys[key_index(VendorId::Openrouter)] = KeyInput::from_config(Some(opr));
2105 s.keys[key_index(VendorId::Openrouter)].dirty = true;
2106 s
2107 }
2108
2109 #[test]
2110 fn focus_cycles_through_primary_all_keys_notifications_and_save() {
2111 let mut f = Focus::Primary;
2112 let mut seen = vec![f];
2113 for _ in 0..(KEY_VENDORS.len() + PROVIDER_SWITCH_COUNT + 4) {
2116 f = f.next();
2117 seen.push(f);
2118 }
2119 assert_eq!(seen.first(), Some(&Focus::Primary));
2122 assert_eq!(seen.last(), Some(&Focus::Primary));
2123 assert!(seen.contains(&Focus::Key(0)));
2124 assert!(seen.contains(&Focus::Key(KEY_VENDORS.len() - 1)));
2125 assert!(seen.contains(&Focus::Vendor(0)));
2126 assert!(seen.contains(&Focus::Vendor(PROVIDER_SWITCH_COUNT - 1)));
2127 assert!(seen.contains(&Focus::NotifyEnabled));
2128 assert!(seen.contains(&Focus::NotifyThreshold));
2129 assert!(seen.contains(&Focus::Save));
2130 assert_eq!(Focus::Primary.next().prev(), Focus::Primary);
2133 assert_eq!(Focus::Save.prev().next(), Focus::Save);
2134 assert_eq!(
2135 Focus::Key(KEY_VENDORS.len() - 1).next(),
2136 Focus::Vendor(0),
2137 "the first provider row follows the last credential"
2138 );
2139 assert_eq!(Focus::Vendor(0).prev(), Focus::Key(KEY_VENDORS.len() - 1));
2140 assert_eq!(
2141 Focus::Vendor(PROVIDER_SWITCH_COUNT - 1).next(),
2142 Focus::NotifyEnabled
2143 );
2144 assert_eq!(
2145 Focus::NotifyEnabled.prev(),
2146 Focus::Vendor(PROVIDER_SWITCH_COUNT - 1)
2147 );
2148 assert_eq!(Focus::NotifyThreshold.next(), Focus::Save);
2149 assert_eq!(Focus::Primary.prev(), Focus::Save);
2150 }
2151
2152 #[test]
2156 fn provider_switch_count_matches_the_vendor_enumeration() {
2157 assert_eq!(PROVIDER_SWITCH_COUNT, VendorId::all().len());
2158 }
2159
2160 #[test]
2161 fn from_config_prefills_provider_switches_from_enabled_vendors() {
2162 let mut cfg = Config::default();
2163 cfg.grok.enabled = true;
2164 cfg.anthropic.enabled = false;
2165 let s = SettingsState::from_config_with(&cfg, |_| false);
2166 for (i, id) in VendorId::all().iter().enumerate() {
2167 assert_eq!(
2168 s.vendors[i],
2169 ProviderSwitch {
2170 enabled: cfg.is_enabled(*id),
2171 dirty: false
2172 },
2173 "{}",
2174 id.slug()
2175 );
2176 }
2177 assert!(!s.vendors[vendor_index(VendorId::Anthropic)].enabled);
2178 assert!(s.vendors[vendor_index(VendorId::Grok)].enabled);
2179 }
2180
2181 fn vendor_index(id: VendorId) -> usize {
2182 VendorId::all().iter().position(|v| *v == id).unwrap()
2183 }
2184
2185 #[test]
2186 fn provider_switch_flips_on_left_right_and_space() {
2187 let mut s = blank_state(VendorId::Anthropic);
2188 let grok = vendor_index(VendorId::Grok);
2189 s.focus = Focus::Vendor(grok);
2190 assert!(!s.vendors[grok].enabled);
2191 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2192 assert_eq!(
2193 s.vendors[grok],
2194 ProviderSwitch {
2195 enabled: true,
2196 dirty: true
2197 }
2198 );
2199 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
2200 assert!(!s.vendors[grok].enabled);
2201 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
2202 assert!(s.vendors[grok].enabled);
2203 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
2205 assert!(s.vendors[grok].enabled);
2206 assert!(s.vendors[grok].dirty);
2207 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::CONTROL);
2209 assert!(s.vendors[grok].enabled);
2210 let before = s.vendors[grok];
2212 handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE);
2213 assert_ne!(s.focus, Focus::Vendor(grok));
2214 assert_eq!(s.vendors[grok], before);
2215 }
2216
2217 #[test]
2218 fn save_writes_only_toggled_providers_and_round_trips() {
2219 let (_dir, path) = temp_config(Some("# keep\n[anthropic]\nenabled = true\n"));
2220 let mut s = blank_state(VendorId::Anthropic);
2221 s.primary_choices = vec![VendorId::Anthropic];
2222 s.vendors[vendor_index(VendorId::Grok)].enabled = true;
2224 s.vendors[vendor_index(VendorId::Grok)].dirty = true;
2225 s.vendors[vendor_index(VendorId::Anthropic)].enabled = false;
2226 s.vendors[vendor_index(VendorId::Anthropic)].dirty = true;
2227 save_to_path(&s, &path).unwrap();
2228
2229 let raw = std::fs::read_to_string(&path).unwrap();
2230 assert!(raw.starts_with("# keep\n"), "{raw}");
2231 assert!(raw.contains("[anthropic]\nenabled = false"), "{raw}");
2232 assert!(raw.contains("[grok]\nenabled = true"), "{raw}");
2233 assert!(!raw.contains("[zai]"), "{raw}");
2237 assert!(!raw.contains("[openrouter]"), "{raw}");
2238
2239 let reloaded = Config::load_from(&path).unwrap();
2240 assert!(reloaded.is_enabled(VendorId::Grok));
2241 assert!(!reloaded.is_enabled(VendorId::Anthropic));
2242 assert!(
2243 reloaded.is_enabled(VendorId::Zai),
2244 "untouched default stays on"
2245 );
2246 }
2247
2248 #[test]
2249 fn an_untouched_overlay_save_writes_no_provider_sections() {
2250 let (_dir, path) = temp_config(None);
2251 let mut s = blank_state(VendorId::Anthropic);
2252 s.primary_choices = vec![VendorId::Anthropic];
2253 save_to_path(&s, &path).unwrap();
2254 let raw = std::fs::read_to_string(&path).unwrap();
2255 assert!(!raw.contains("enabled ="), "{raw}");
2256 assert!(!raw.contains("[grok]"), "{raw}");
2257 }
2258
2259 #[test]
2262 fn an_explicit_provider_off_wins_over_a_pasted_credential() {
2263 let (_dir, path) = temp_config(None);
2264 let mut s = blank_state(VendorId::Zai);
2265 s.primary_choices = vec![VendorId::Zai];
2266 s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some("sk-zai"));
2267 s.keys[key_index(VendorId::Zai)].dirty = true;
2268 s.vendors[vendor_index(VendorId::Zai)].enabled = false;
2269 s.vendors[vendor_index(VendorId::Zai)].dirty = true;
2270 save_to_path(&s, &path).unwrap();
2271 let raw = std::fs::read_to_string(&path).unwrap();
2272 assert!(raw.contains("api_key = \"sk-zai\""), "{raw}");
2273 assert!(raw.contains("enabled = false"), "{raw}");
2274 assert!(!Config::load_from(&path).unwrap().is_enabled(VendorId::Zai));
2275 }
2276
2277 #[test]
2278 fn every_key_vendor_has_a_field() {
2279 for id in [
2281 VendorId::Zai,
2282 VendorId::Openrouter,
2283 VendorId::Deepseek,
2284 VendorId::Deepinfra,
2285 VendorId::Kilo,
2286 VendorId::Novita,
2287 VendorId::Synthetic,
2288 VendorId::Moonshot,
2289 VendorId::Grok,
2290 ] {
2291 assert!(
2292 KEY_VENDORS.iter().any(|kv| kv.id == id),
2293 "{id:?} has no key field"
2294 );
2295 }
2296 assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Anthropic));
2298 assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Openai));
2299 }
2300
2301 #[test]
2302 fn from_config_prefills_existing_keys() {
2303 let mut cfg = Config::default();
2304 cfg.kilo.api_key = Some("sk-kilo".into());
2305 let s = SettingsState::from_config(&cfg);
2306 assert_eq!(s.keys[key_index(VendorId::Kilo)].buf, "sk-kilo");
2307 assert!(!s.keys[key_index(VendorId::Kilo)].dirty);
2308 }
2309
2310 #[test]
2311 fn from_config_prefills_the_notification_fields() {
2312 let mut cfg = Config::default();
2313 cfg.notifications.enabled = false;
2314 cfg.notifications.threshold = 100;
2315 let s = SettingsState::from_config_with(&cfg, |_| false);
2316 assert!(!s.notify_enabled);
2317 assert!(!s.notify_enabled_dirty);
2318 assert_eq!(s.notify_threshold.buf, "100");
2319 assert!(!s.notify_threshold.dirty);
2320 }
2321
2322 #[test]
2323 fn notification_toggle_flips_on_left_right_and_space() {
2324 let mut s = blank_state(VendorId::Anthropic);
2325 s.focus = Focus::NotifyEnabled;
2326 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2327 assert!(!s.notify_enabled);
2328 assert!(s.notify_enabled_dirty);
2329 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
2330 assert!(s.notify_enabled);
2331 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
2332 assert!(!s.notify_enabled);
2333 handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
2335 assert!(!s.notify_enabled);
2336 }
2337
2338 #[test]
2339 fn threshold_edits_accept_digits_and_reject_everything_else() {
2340 let mut s = blank_state(VendorId::Anthropic);
2341 s.focus = Focus::NotifyThreshold;
2342 s.notify_threshold = KeyInput::default();
2344 for c in ['9', 'a', '.', '-', '→'] {
2345 handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
2346 }
2347 assert_eq!(s.notify_threshold.buf, "9");
2348 assert!(s.notify_threshold.dirty);
2349 handle_key(&mut s, KeyCode::Char('8'), KeyModifiers::NONE);
2350 assert_eq!(s.notify_threshold.buf, "98");
2351 handle_key(&mut s, KeyCode::Backspace, KeyModifiers::NONE);
2352 assert_eq!(s.notify_threshold.buf, "9");
2353 }
2354
2355 #[test]
2356 fn save_writes_the_notification_fields_and_round_trips() {
2357 let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
2358 let mut s = blank_state(VendorId::Anthropic);
2359 s.notify_enabled = false;
2360 s.notify_enabled_dirty = true;
2361 s.notify_threshold = KeyInput::default();
2363 for c in "90".chars() {
2364 s.notify_threshold.insert_char(c);
2365 }
2366 save_to_path(&s, &path).unwrap();
2367
2368 let raw = std::fs::read_to_string(&path).unwrap();
2369 assert!(raw.contains("[notifications]"), "{raw}");
2370 assert!(raw.contains("enabled = false"), "{raw}");
2371 assert!(raw.contains("threshold = 90"), "{raw}");
2372 let reloaded = Config::load_from(&path).unwrap();
2374 assert!(!reloaded.notifications.enabled);
2375 assert_eq!(reloaded.notifications.threshold, 90);
2376 }
2377
2378 #[test]
2379 fn save_leaves_an_untouched_notification_section_alone() {
2380 let (_dir, path) = temp_config(None);
2381 let s = blank_state(VendorId::Anthropic);
2382 save_to_path(&s, &path).unwrap();
2383 let raw = std::fs::read_to_string(&path).unwrap();
2384 assert!(!raw.contains("[notifications]"), "{raw}");
2385 }
2386
2387 #[test]
2388 fn save_rejects_an_out_of_range_threshold_without_writing() {
2389 let (_dir, path) = temp_config(None);
2390 let mut s = blank_state(VendorId::Anthropic);
2391 for c in "300".chars() {
2392 s.notify_threshold.insert_char(c);
2393 }
2394 let err = save_to_path(&s, &path).unwrap_err().to_string();
2395 assert!(
2396 err.contains("[notifications] threshold must be a whole number between 1 and 100"),
2397 "{err}"
2398 );
2399 assert!(!path.exists());
2401
2402 let (_dir, path) = temp_config(None);
2404 let mut s = blank_state(VendorId::Anthropic);
2405 s.notify_threshold = KeyInput::default();
2406 s.notify_threshold.dirty = true;
2407 assert!(save_to_path(&s, &path).is_err());
2408 assert!(!path.exists());
2409 }
2410
2411 #[test]
2412 fn copilot_has_no_editable_credential_field() {
2413 assert!(
2414 !KEY_VENDORS
2415 .iter()
2416 .any(|vendor| vendor.id == VendorId::Copilot)
2417 );
2418 }
2419
2420 #[test]
2423 fn a_key_vendor_with_its_env_var_exported_is_offered() {
2424 let cfg = Config::default();
2425 let env = cfg.api_key_env_for(VendorId::Ollama).to_string();
2426
2427 let without = SettingsState::from_config_with(&cfg, |_| false);
2428 assert!(!without.primary_choices.contains(&VendorId::Ollama));
2429
2430 let with = SettingsState::from_config_with(&cfg, |name| name == env);
2431 assert!(
2432 with.primary_choices.contains(&VendorId::Ollama),
2433 "a key vendor whose env var is exported must be selectable: {:?}",
2434 with.primary_choices
2435 );
2436 }
2437
2438 #[test]
2439 fn from_config_offers_enabled_vendors_only() {
2440 let cfg = Config::default();
2441 let s = SettingsState::from_config_with(&cfg, |_| false);
2444 let mut expected = cfg.enabled_vendors();
2445 expected.push(VendorId::Copilot);
2446 assert_eq!(s.primary_choices, expected);
2447 assert!(!s.primary_choices.contains(&VendorId::Grok));
2450 assert!(s.primary_choices.contains(&s.primary));
2451 assert!(s.primary_choices.contains(&VendorId::Copilot));
2452 }
2453
2454 #[test]
2455 fn from_config_falls_back_when_configured_primary_is_disabled() {
2456 let mut cfg = Config::default();
2459 cfg.ui.primary = Some(VendorId::Grok);
2460 let s = SettingsState::from_config(&cfg);
2461 assert_ne!(s.primary, VendorId::Grok);
2462 assert_eq!(Some(s.primary), cfg.enabled_vendors().first().copied());
2463 }
2464
2465 #[test]
2470 fn an_unrelated_save_leaves_a_disabled_primary_switched_off() {
2471 let (_dir, path) = temp_config(Some(
2472 "[ui]\nprimary = \"openrouter\"\n[openrouter]\nenabled = false\napi_key = \"test\"\n",
2473 ));
2474 let cfg = Config::load_from(&path).unwrap();
2475 let mut s = SettingsState::from_config_with(&cfg, |_| false);
2476 assert!(s.primary_choices.contains(&VendorId::Openrouter));
2477 assert_ne!(s.primary, VendorId::Openrouter);
2478
2479 s.notify_enabled = !s.notify_enabled;
2480 s.notify_enabled_dirty = true;
2481 save_to_path(&s, &path).unwrap();
2482
2483 let saved = Config::load_from(&path).unwrap();
2484 assert!(!saved.is_enabled(VendorId::Openrouter));
2485 }
2486
2487 #[test]
2488 fn key_input_insert_backspace_arrow() {
2489 let mut k = KeyInput::default();
2490 k.insert_char('a');
2491 k.insert_char('b');
2492 k.insert_char('c');
2493 assert_eq!(k.buf, "abc");
2494 assert_eq!(k.cursor, 3);
2495 assert!(k.dirty);
2496 k.move_left();
2497 k.move_left();
2498 assert_eq!(k.cursor, 1);
2499 k.insert_char('x');
2500 assert_eq!(k.buf, "axbc");
2501 assert_eq!(k.cursor, 2);
2502 k.backspace();
2503 assert_eq!(k.buf, "abc");
2504 assert_eq!(k.cursor, 1);
2505 }
2506
2507 #[test]
2508 fn key_input_masks_by_default_reveals_on_toggle() {
2509 let mut k = KeyInput::default();
2510 for c in "secret-key".chars() {
2511 k.insert_char(c);
2512 }
2513 assert_eq!(k.display(), "•".repeat(10));
2514 k.toggle_reveal();
2515 assert_eq!(k.display(), "secret-key");
2516 }
2517
2518 #[test]
2519 fn key_input_handles_unicode() {
2520 let mut k = KeyInput::default();
2521 k.insert_char('a');
2522 k.insert_char('→');
2523 k.insert_char('b');
2524 assert_eq!(k.buf, "a→b");
2525 assert_eq!(k.cursor, 3);
2526 k.move_left();
2527 k.backspace();
2528 assert_eq!(k.buf, "ab");
2529 }
2530
2531 #[test]
2532 fn value_text_shows_cursor_and_empty_states() {
2533 let mut k = KeyInput::default();
2534 assert_eq!(value_text(&k, false), "(empty)");
2535 assert_eq!(value_text(&k, true), "‸");
2536 k.insert_char('a');
2537 k.insert_char('b');
2538 assert_eq!(value_text(&k, true), "••‸");
2540 assert_eq!(value_text(&k, false), "••");
2541 }
2542
2543 #[test]
2544 fn save_writes_key_and_enables_vendor() {
2545 let (_dir, path) = temp_config(None);
2546 let mut s = blank_state(VendorId::Kilo);
2547 s.keys[key_index(VendorId::Kilo)] = KeyInput::from_config(Some("sk-kilo"));
2548 s.keys[key_index(VendorId::Kilo)].dirty = true;
2549 save_to_path(&s, &path).unwrap();
2550 let raw = std::fs::read_to_string(&path).unwrap();
2551 assert!(raw.contains("primary = \"kilo\""));
2552 assert!(raw.contains("[kilo]"));
2553 assert!(raw.contains("api_key = \"sk-kilo\""));
2554 assert!(raw.contains("enabled = true"));
2555 }
2556
2557 #[test]
2558 fn save_writes_minimal_toml_when_starting_empty() {
2559 let (_dir, path) = temp_config(None);
2560 let s = state_with("zk", "ok", VendorId::Zai);
2561 save_to_path(&s, &path).unwrap();
2562 let raw = std::fs::read_to_string(&path).unwrap();
2563 assert!(raw.contains("primary = \"zai\""));
2564 assert!(raw.contains("[zai]"));
2565 assert!(raw.contains("api_key = \"zk\""));
2566 assert!(raw.contains("[openrouter]"));
2567 assert!(raw.contains("api_key = \"ok\""));
2568 }
2569
2570 #[test]
2571 fn save_preserves_existing_comments_and_unrelated_fields() {
2572 let (_dir, path) = temp_config(Some(
2573 r##"# my comment
2574[ui]
2575# pre-existing comment
2576primary = "anthropic"
2577
2578[zai]
2579enabled = true
2580api_key_env = "ZAI_API_KEY"
2581# tier comment
2582plan_tier = "pro"
2583
2584[openrouter]
2585enabled = true
2586api_key_env = "OPENROUTER_API_KEY"
2587
2588[[openrouter.accounts]]
2589label = "work"
2590api_key_env = "OPENROUTER_WORK_API_KEY"
2591"##,
2592 ));
2593
2594 let s = state_with("zk2", "ok2", VendorId::Openrouter);
2595 save_to_path(&s, &path).unwrap();
2596
2597 let raw = std::fs::read_to_string(&path).unwrap();
2598 assert!(raw.contains("# my comment"));
2599 assert!(raw.contains("# pre-existing comment"));
2600 assert!(raw.contains("# tier comment"));
2601 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
2602 assert!(raw.contains("[[openrouter.accounts]]"));
2603 assert!(raw.contains("api_key_env = \"OPENROUTER_WORK_API_KEY\""));
2604 assert!(raw.contains("plan_tier = \"pro\""));
2605 assert!(raw.contains("primary = \"openrouter\""));
2606 assert!(raw.contains("api_key = \"zk2\""));
2607 assert!(raw.contains("api_key = \"ok2\""));
2608 }
2609
2610 #[test]
2611 fn save_refuses_to_replace_an_unreadable_existing_config() {
2612 let (_dir, path) = temp_config(None);
2613 let original = [0xff, 0xfe, 0xfd];
2614 std::fs::write(&path, original).unwrap();
2615 let state = state_with("new-secret", "", VendorId::Zai);
2616
2617 assert!(save_to_path(&state, &path).is_err());
2618 assert_eq!(std::fs::read(&path).unwrap(), original);
2619 }
2620
2621 #[test]
2622 fn save_does_not_write_empty_key_when_dirty_but_blank() {
2623 let (_dir, path) = temp_config(None);
2624 let mut s = blank_state(VendorId::Anthropic);
2625 for k in &mut s.keys {
2627 k.dirty = true;
2628 }
2629 save_to_path(&s, &path).unwrap();
2630 let raw = std::fs::read_to_string(&path).unwrap();
2631 assert!(!raw.contains("api_key ="));
2632 }
2633
2634 #[test]
2635 #[cfg(unix)]
2636 fn save_chmods_to_600() {
2637 use std::os::unix::fs::PermissionsExt;
2638 let (_dir, path) = temp_config(None);
2639 let s = state_with("zk", "ok", VendorId::Zai);
2640 save_to_path(&s, &path).unwrap();
2641 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2642 assert_eq!(mode & 0o777, 0o600);
2643 }
2644
2645 #[test]
2646 fn key_vendor_for_slug_finds_only_the_key_table() {
2647 for vendor in KEY_VENDORS {
2649 assert_eq!(
2650 key_vendor_for_slug(vendor.id.slug()).map(|kv| kv.id),
2651 Some(vendor.id),
2652 "{} must resolve",
2653 vendor.id.slug()
2654 );
2655 }
2656 for id in VendorId::all() {
2659 if KEY_VENDORS.iter().any(|kv| kv.id == *id) {
2660 continue;
2661 }
2662 assert!(key_vendor_for_slug(id.slug()).is_none(), "{:?}", id);
2663 }
2664 assert!(key_vendor_for_slug("no-such-vendor").is_none());
2665 }
2666
2667 #[test]
2668 fn validate_key_value_refuses_empty_long_and_controlled() {
2669 let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2670 assert!(validate_key_value(vendor, "test-key-value").is_ok());
2671 let empty = validate_key_value(vendor, "");
2672 assert!(empty.is_err());
2673 assert!(empty.unwrap_err().to_string().contains("is empty"));
2674 let long = "x".repeat(MAX_API_KEY_BYTES + 1);
2675 let over = validate_key_value(vendor, &long);
2676 assert!(over.is_err());
2677 assert!(
2678 over.unwrap_err()
2679 .to_string()
2680 .contains(&format!("exceeds {MAX_API_KEY_BYTES}"))
2681 );
2682 let controlled = validate_key_value(vendor, "a\u{1b}[31mb");
2683 assert!(controlled.is_err());
2684 assert!(
2685 controlled
2686 .unwrap_err()
2687 .to_string()
2688 .contains("control characters")
2689 );
2690 let cfg = crate::config::Config::default();
2694 let bad = serde_json::json!({
2695 "schema_version": 1,
2696 "keys": { VendorId::Zai.slug(): { "action": "set", "value": "a\u{1b}[31mb" } }
2697 })
2698 .to_string();
2699 assert!(state_from_apply_request(&cfg, &bad).is_err());
2700 let good = serde_json::json!({
2701 "schema_version": 1,
2702 "keys": { VendorId::Zai.slug(): { "action": "set", "value": "test-key-value" } }
2703 })
2704 .to_string();
2705 assert!(state_from_apply_request(&cfg, &good).is_ok());
2706 }
2707
2708 #[test]
2709 fn inline_key_write_leaves_the_value_in_the_right_section() {
2710 let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2711 let (_dir, path) = temp_config(Some("# keep me\n[ui]\nprimary = \"zai\"\n"));
2712 write_inline_key(&path, vendor, "test-key-value").unwrap();
2713 let raw = std::fs::read_to_string(&path).unwrap();
2714 assert!(raw.contains("[zai]"), "{raw}");
2715 assert!(raw.contains(r#"api_key = "test-key-value""#), "{raw}");
2716 assert!(raw.contains("enabled = true"), "{raw}");
2718 assert!(raw.contains("# keep me"), "{raw}");
2720 assert!(raw.contains(r#"primary = "zai""#), "{raw}");
2721 let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2723 assert!(snapshot.key.is_some());
2724 assert!(snapshot.enabled.is_some());
2725 }
2726
2727 #[test]
2728 #[cfg(unix)]
2729 fn inline_key_write_chmods_the_config_to_600() {
2730 use std::os::unix::fs::PermissionsExt;
2731 let vendor = key_vendor_for_slug(VendorId::Openrouter.slug()).unwrap();
2732 let (_dir, path) = temp_config(None);
2733 write_inline_key(&path, vendor, "test-key-value").unwrap();
2734 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2735 assert_eq!(mode & 0o777, 0o600);
2736 }
2737
2738 #[test]
2739 fn inline_key_write_refuses_invalid_values_without_touching_the_file() {
2740 let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2741 let original = "[zai]\napi_key = \"old-key-value\"\n";
2742 let (_dir, path) = temp_config(Some(original));
2743 let long = "x".repeat(MAX_API_KEY_BYTES + 1);
2744 for bad in ["", long.as_str(), "a\u{1b}[31mb"] {
2745 assert!(write_inline_key(&path, vendor, bad).is_err(), "{bad:?}");
2746 assert_eq!(
2747 std::fs::read_to_string(&path).unwrap(),
2748 original,
2749 "the file must not move for {bad:?}"
2750 );
2751 }
2752 let dir = tempfile::tempdir().unwrap();
2754 let empty = dir.path().join("config.toml");
2755 assert!(write_inline_key(&empty, vendor, "").is_err());
2756 assert!(!empty.exists());
2757 }
2758
2759 #[test]
2760 fn inline_key_snapshot_restores_the_exact_previous_state() {
2761 let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2762 let original = "[ui]\nprimary = \"zai\"\n[zai]\n# my old key\napi_key = \"old-key-value\"\nenabled = false\n";
2765 let (_dir, path) = temp_config(Some(original));
2766 let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2767 assert!(snapshot.file_existed);
2768 assert!(snapshot.section_existed);
2769 write_inline_key(&path, vendor, "test-key-value").unwrap();
2770 assert_ne!(std::fs::read_to_string(&path).unwrap(), original);
2771 restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2772 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
2773 }
2774
2775 #[test]
2776 fn inline_key_snapshot_restore_keeps_a_later_user_decision() {
2777 let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2778 let original = "[ui]\nprimary = \"zai\"\n[zai]\napi_key = \"old-key-value\"\n";
2779 let (_dir, path) = temp_config(Some(original));
2780 let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2781 write_inline_key(&path, vendor, "test-key-value").unwrap();
2782 write_inline_key(&path, vendor, "another-key-value").unwrap();
2785 crate::config::set_vendor_enabled(&path, vendor.id.slug(), false).unwrap();
2786 restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2787 let raw = std::fs::read_to_string(&path).unwrap();
2788 assert!(raw.contains(r#"api_key = "another-key-value""#), "{raw}");
2790 assert!(raw.contains("enabled = false"), "{raw}");
2791 assert!(!raw.contains("test-key-value"), "{raw}");
2792 assert!(!raw.contains("old-key-value"), "{raw}");
2793 }
2794
2795 #[test]
2796 fn saved_inline_key_counts_an_empty_field_as_absent() {
2797 let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2798 let (_dir, path) = temp_config(Some("[zai]\napi_key = ''\n"));
2801 assert!(saved_inline_key(&snapshot_inline_key(&path, vendor).unwrap()).is_none());
2802 let (_dir, path) = temp_config(Some("[zai]\napi_key = \"k\"\n"));
2803 let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2804 assert_eq!(saved_inline_key(&snapshot).as_deref(), Some("k"));
2805 }
2806
2807 #[test]
2808 fn check_write_refuses_a_saved_nonempty_key() {
2809 let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2810 let original = "[ui]\nprimary = \"zai\"\n[zai]\napi_key = \"old-key-value\"\n";
2811 let (_dir, path) = temp_config(Some(original));
2812 let error = match snapshot_and_write_inline_key_if_absent(&path, vendor, "test-key-value") {
2813 Ok(_) => panic!("a saved key must not be replaced by the check's write"),
2814 Err(error) => error,
2815 };
2816 assert_eq!(error.user_message(), KEY_ALREADY_SAVED_MESSAGE);
2817 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
2820 }
2821
2822 #[test]
2823 fn check_write_accepts_an_empty_field_and_returns_the_pre_state() {
2824 let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2825 let (_dir, path) = temp_config(Some("[zai]\napi_key = ''\nenabled = false\n"));
2826 let snapshot =
2829 snapshot_and_write_inline_key_if_absent(&path, vendor, "test-key-value").unwrap();
2830 assert!(snapshot.file_existed);
2831 assert!(snapshot.section_existed);
2832 assert_eq!(
2835 saved_inline_key(&snapshot),
2836 None,
2837 "an empty field is no credential, even when captured"
2838 );
2839 let raw = std::fs::read_to_string(&path).unwrap();
2840 assert!(raw.contains(r#"api_key = "test-key-value""#), "{raw}");
2841 assert!(raw.contains("enabled = true"), "{raw}");
2842 }
2843
2844 #[test]
2850 fn tui_save_and_check_write_serialize_on_the_config_lock() {
2851 use std::sync::mpsc;
2852 let (dir, path) = temp_config(Some("[ui]\nprimary = \"zai\"\n"));
2853 let _ = dir;
2854 let lock_path = crate::config::config_lock_path(&path);
2855
2856 let waiters =
2859 |path: &std::path::Path, tx: mpsc::Sender<()>, tx_check: mpsc::Sender<Result<()>>| {
2860 let tui_path = path.to_path_buf();
2861 let tui = std::thread::spawn(move || {
2862 let state = state_with("tui-key-value", "openrouter-key-value", VendorId::Zai);
2863 tx.send(()).unwrap();
2864 save_to_path(&state, &tui_path).unwrap();
2865 });
2866 let check_path = path.to_path_buf();
2867 let check = std::thread::spawn(move || {
2868 let vendor = key_vendor_for_slug("zai").unwrap();
2869 tx_check
2870 .send(
2871 snapshot_and_write_inline_key_if_absent(
2872 &check_path,
2873 vendor,
2874 "candidate-key-value",
2875 )
2876 .map(|_| ()),
2877 )
2878 .unwrap();
2879 });
2880 (tui, check)
2881 };
2882
2883 let held = crate::cache::acquire_lock(&lock_path, std::time::Duration::from_secs(30))
2888 .expect("the test takes the config lock");
2889 let (tx, rx) = mpsc::channel();
2890 let (tx_check, rx_check) = mpsc::channel();
2891 let (tui, check) = waiters(&path, tx, tx_check);
2892 rx.recv().unwrap();
2893 drop(held);
2894 let check_result = rx_check.recv().unwrap();
2895 tui.join().unwrap();
2896 check.join().unwrap();
2897 let raw = std::fs::read_to_string(&path).unwrap();
2898 assert!(
2899 raw.contains(r#"api_key = "tui-key-value""#),
2900 "the TUI's key must survive: {raw}"
2901 );
2902 assert!(
2903 !raw.contains("candidate-key-value"),
2904 "the candidate must not overwrite the TUI's key: {raw}"
2905 );
2906 if let Err(error) = check_result {
2907 assert_eq!(error.user_message(), KEY_ALREADY_SAVED_MESSAGE);
2910 }
2911
2912 let before = std::fs::read_to_string(&path).unwrap();
2916 let held = crate::cache::acquire_lock(&lock_path, std::time::Duration::from_secs(30))
2917 .expect("the test takes the config lock again");
2918 let (tx, rx) = mpsc::channel();
2919 let (tx_check, rx_check) = mpsc::channel();
2920 let (tui, check) = waiters(&path, tx, tx_check);
2921 rx.recv().unwrap();
2922 drop(held);
2923 let error = rx_check
2924 .recv()
2925 .unwrap()
2926 .expect_err("a saved key must refuse the candidate");
2927 tui.join().unwrap();
2928 check.join().unwrap();
2929 assert_eq!(error.user_message(), KEY_ALREADY_SAVED_MESSAGE);
2930 assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
2931 }
2932 #[test]
2933 fn inline_key_snapshot_removes_the_key_when_there_was_none() {
2934 let vendor = key_vendor_for_slug(VendorId::Deepseek.slug()).unwrap();
2935 let original = "[ui]\nprimary = \"deepseek\"\n";
2936 let (_dir, path) = temp_config(Some(original));
2937 let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2938 assert!(!snapshot.section_existed);
2939 write_inline_key(&path, vendor, "test-key-value").unwrap();
2940 restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2941 let raw = std::fs::read_to_string(&path).unwrap();
2942 assert!(!raw.contains("api_key"), "the key must be gone: {raw}");
2945 assert!(
2946 !raw.contains("[deepseek]"),
2947 "the section must be gone: {raw}"
2948 );
2949 assert!(raw.contains(r#"primary = "deepseek""#), "{raw}");
2950 }
2951
2952 #[test]
2953 fn inline_key_snapshot_of_a_missing_file_removes_it_on_restore() {
2954 let vendor = key_vendor_for_slug(VendorId::Kimi.slug()).unwrap();
2955 let (dir, path) = temp_config(None);
2956 let _ = dir;
2957 let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2958 assert!(!snapshot.file_existed);
2959 assert!(!snapshot.section_existed);
2960 assert!(snapshot.key.is_none());
2961 assert!(snapshot.enabled.is_none());
2962 write_inline_key(&path, vendor, "test-key-value").unwrap();
2963 restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2964 assert!(!path.exists(), "the file the check created must be gone");
2965 }
2966
2967 #[test]
2968 fn inline_key_snapshot_keeps_edits_made_elsewhere_in_the_meantime() {
2969 let vendor = key_vendor_for_slug(VendorId::Zai.slug()).unwrap();
2970 let original = "[ui]\nprimary = \"zai\"\n[zai]\napi_key = \"old-key-value\"\n";
2971 let (_dir, path) = temp_config(Some(original));
2972 let snapshot = snapshot_inline_key(&path, vendor).unwrap();
2973 write_inline_key(&path, vendor, "test-key-value").unwrap();
2974 let mut doc = crate::config::read_config_document(&path).unwrap();
2976 set_string(&mut doc, "zai", "api_key_env", "SOME_ENV_VAR").unwrap();
2977 crate::config::write_config_document(&path, &doc).unwrap();
2978 restore_inline_key_snapshot(&path, vendor, &snapshot, "test-key-value").unwrap();
2979 let raw = std::fs::read_to_string(&path).unwrap();
2980 assert!(raw.contains(r#"api_key = "old-key-value""#), "{raw}");
2982 assert!(raw.contains(r#"api_key_env = "SOME_ENV_VAR""#), "{raw}");
2984 }
2985
2986 #[test]
2987 fn tab_cycles_focus_from_primary_to_first_key() {
2988 let mut s = blank_state(VendorId::Anthropic);
2989 assert_eq!(
2990 handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
2991 Action::Continue
2992 );
2993 assert_eq!(s.focus, Focus::Key(0));
2994 assert_eq!(
2995 handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
2996 Action::Continue
2997 );
2998 assert_eq!(s.focus, Focus::Primary);
2999 }
3000
3001 #[test]
3002 fn esc_closes_without_saving() {
3003 let mut s = blank_state(VendorId::Anthropic);
3004 assert_eq!(
3005 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
3006 Action::Close
3007 );
3008 }
3009
3010 #[test]
3011 fn left_right_cycles_primary_vendor() {
3012 let mut s = blank_state(VendorId::Anthropic);
3014 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3015 assert_eq!(s.primary, VendorId::AnthropicApi);
3016 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3017 assert_eq!(s.primary, VendorId::Openai);
3018 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
3019 assert_eq!(s.primary, VendorId::AnthropicApi);
3020 }
3021
3022 #[test]
3023 fn left_right_offers_enabled_vendors_only() {
3024 let mut s = blank_state(VendorId::Anthropic);
3026 s.primary_choices = vec![VendorId::Anthropic, VendorId::Grok];
3027 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3028 assert_eq!(s.primary, VendorId::Grok);
3029 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3031 assert_eq!(s.primary, VendorId::Anthropic);
3032 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
3033 assert_eq!(s.primary, VendorId::Grok);
3034 }
3035
3036 #[test]
3037 fn no_enabled_vendors_leaves_primary_selector_inert() {
3038 let mut s = blank_state(VendorId::Anthropic);
3039 s.primary_choices = vec![];
3040 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
3041 assert_eq!(s.primary, VendorId::Anthropic);
3042 }
3043
3044 #[test]
3045 fn disabled_copilot_is_offered_but_not_shown_as_the_current_primary() {
3046 let mut cfg = Config::default();
3047 cfg.ui.primary = Some(VendorId::Copilot);
3048 let state = SettingsState::from_config(&cfg);
3049
3050 assert!(state.primary_choices.contains(&VendorId::Copilot));
3051 assert_eq!(state.primary, VendorId::Anthropic);
3052 }
3053
3054 #[test]
3055 fn save_does_not_write_a_disabled_primary() {
3056 let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
3059 let mut s = state_with("zk", "ok", VendorId::Grok);
3060 s.primary_choices = vec![VendorId::Anthropic];
3061 save_to_path(&s, &path).unwrap();
3062 let raw = std::fs::read_to_string(&path).unwrap();
3063 assert!(raw.contains("primary = \"anthropic\""));
3064 assert!(!raw.contains("primary = \"grok\""));
3065 assert!(raw.contains("zk"));
3067 }
3068
3069 #[test]
3070 fn save_removes_an_inline_key_the_user_cleared() {
3071 let (_dir, path) = temp_config(Some(
3074 "[zai]\nenabled = true\napi_key = \"old-secret\"\nplan_tier = \"pro\"\n",
3075 ));
3076 let mut s = blank_state(VendorId::Zai);
3077 s.primary_choices = vec![VendorId::Zai];
3078 s.keys[key_index(VendorId::Zai)] = KeyInput::default();
3079 s.keys[key_index(VendorId::Zai)].dirty = true;
3080 save_to_path(&s, &path).unwrap();
3081 let raw = std::fs::read_to_string(&path).unwrap();
3082 assert!(!raw.contains("old-secret"));
3083 assert!(!raw.contains("api_key"));
3084 assert!(raw.contains("plan_tier = \"pro\""));
3086 }
3087
3088 #[test]
3089 fn save_clears_a_key_from_a_toml_inline_table() {
3090 let (_dir, path) = temp_config(Some(
3091 "zai = { enabled = false, api_key = \"remove-me\", plan_tier = \"pro\" } # keep me\n\
3092 [ui]\nprimary = \"anthropic\"\n",
3093 ));
3094 let cfg = Config::load_from(&path).unwrap();
3095 let mut state = SettingsState::from_config_with(&cfg, |_| false);
3096 let input = &mut state.keys[key_index(VendorId::Zai)];
3097 *input = KeyInput::default();
3098 input.dirty = true;
3099
3100 save_to_path(&state, &path).unwrap();
3101
3102 let reloaded = Config::load_from(&path).unwrap();
3103 assert!(reloaded.zai.api_key.is_none());
3104 assert!(!reloaded.zai.enabled);
3105 assert_eq!(reloaded.zai.plan_tier.as_deref(), Some("pro"));
3106 let raw = std::fs::read_to_string(&path).unwrap();
3107 assert!(!raw.contains("remove-me"));
3108 assert!(raw.contains("zai = {"));
3109 assert!(raw.contains("# keep me"));
3110 }
3111
3112 #[test]
3113 fn save_updates_a_key_and_enables_a_toml_inline_table() {
3114 let (_dir, path) = temp_config(Some(
3115 "zai = { enabled = false, api_key = \"old-secret\", plan_tier = \"pro\" } # keep me\n\
3116 [ui]\nprimary = \"anthropic\"\n",
3117 ));
3118 let cfg = Config::load_from(&path).unwrap();
3119 let mut state = SettingsState::from_config_with(&cfg, |_| false);
3120 let input = &mut state.keys[key_index(VendorId::Zai)];
3121 input.buf = "new-secret".into();
3122 input.dirty = true;
3123
3124 save_to_path(&state, &path).unwrap();
3125
3126 let reloaded = Config::load_from(&path).unwrap();
3127 assert_eq!(reloaded.zai.api_key.as_deref(), Some("new-secret"));
3128 assert!(reloaded.zai.enabled);
3129 assert_eq!(reloaded.zai.plan_tier.as_deref(), Some("pro"));
3130 let raw = std::fs::read_to_string(&path).unwrap();
3131 assert!(!raw.contains("old-secret"));
3132 assert!(raw.contains("new-secret"));
3133 assert!(raw.contains("zai = {"));
3134 assert!(raw.contains("# keep me"));
3135 }
3136
3137 #[test]
3138 fn enable_vendor_at_enables_a_toml_inline_table() {
3139 let (_dir, path) = temp_config(Some(
3140 "zai = { enabled = false, plan_tier = \"pro\" } # keep me\n\
3141 [ui]\nprimary = \"anthropic\"\n",
3142 ));
3143 enable_vendor_at(&path, VendorId::Zai).unwrap();
3144
3145 let reloaded = Config::load_from(&path).unwrap();
3146 assert!(reloaded.zai.enabled);
3147 assert_eq!(reloaded.zai.plan_tier.as_deref(), Some("pro"));
3148 let raw = std::fs::read_to_string(&path).unwrap();
3149 assert!(raw.contains("zai = {"));
3150 assert!(raw.contains("# keep me"));
3151 }
3152
3153 #[test]
3154 fn untouched_key_field_is_left_alone() {
3155 let (_dir, path) = temp_config(Some("[zai]\napi_key = \"keep-me\"\n"));
3157 let mut s = blank_state(VendorId::Zai);
3158 s.primary_choices = vec![VendorId::Zai];
3159 save_to_path(&s, &path).unwrap();
3160 let raw = std::fs::read_to_string(&path).unwrap();
3161 assert!(raw.contains("keep-me"));
3162 }
3163
3164 #[test]
3165 fn typing_edits_the_focused_key_only() {
3166 let mut s = blank_state(VendorId::Anthropic);
3167 s.focus = Focus::Key(key_index(VendorId::Grok));
3168 for c in "xai-abc".chars() {
3169 handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
3170 }
3171 assert_eq!(s.keys[key_index(VendorId::Grok)].buf, "xai-abc");
3172 assert!(s.keys[key_index(VendorId::Grok)].dirty);
3173 assert!(s.keys[key_index(VendorId::Zai)].buf.is_empty());
3175 }
3176
3177 #[test]
3178 fn ctrl_v_toggles_reveal_on_focused_key_field() {
3179 let mut s = blank_state(VendorId::Anthropic);
3180 let zi = key_index(VendorId::Zai);
3181 s.focus = Focus::Key(zi);
3182 s.keys[zi] = KeyInput::from_config(Some("secret"));
3183 assert!(!s.keys[zi].revealed);
3184 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
3185 assert!(s.keys[zi].revealed);
3186 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
3187 assert!(!s.keys[zi].revealed);
3188 }
3189
3190 #[test]
3191 fn control_chorded_chars_do_not_type_into_fields() {
3192 let mut s = blank_state(VendorId::Anthropic);
3193 s.focus = Focus::Key(0);
3194 handle_key(&mut s, KeyCode::Char('a'), KeyModifiers::CONTROL);
3196 assert!(s.keys[0].buf.is_empty());
3197 assert!(!s.keys[0].dirty);
3198 assert_eq!(
3200 handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
3201 Action::Quit
3202 );
3203 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
3205 assert_eq!(s.keys[0].buf, "x");
3206 }
3207
3208 #[test]
3209 fn ctrl_v_on_non_key_focus_is_noop() {
3210 let mut s = blank_state(VendorId::Anthropic);
3211 s.focus = Focus::Primary;
3212 assert_eq!(
3214 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL),
3215 Action::Continue
3216 );
3217 }
3218
3219 fn state_focused_on_zai() -> SettingsState {
3220 let mut state = blank_state(VendorId::Anthropic);
3221 state.focus = Focus::Key(key_index(VendorId::Zai));
3222 state
3223 }
3224
3225 #[test]
3226 fn handle_key_ctrl_c_quits_without_typing_into_key_field() {
3227 let mut s = state_focused_on_zai();
3228 let zi = key_index(VendorId::Zai);
3229 assert_eq!(
3230 handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
3231 Action::Quit
3232 );
3233 assert!(s.keys[zi].buf.is_empty());
3234 assert!(!s.keys[zi].dirty);
3236 }
3237
3238 #[test]
3239 fn handle_key_alt_chord_does_not_type_into_key_field() {
3240 let mut s = state_focused_on_zai();
3241 let zi = key_index(VendorId::Zai);
3242 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::ALT);
3243 assert!(s.keys[zi].buf.is_empty());
3244 assert!(!s.keys[zi].dirty);
3245 }
3246
3247 #[test]
3248 fn handle_key_platform_modifier_chords_do_not_type_into_key_field() {
3249 for modifier in [KeyModifiers::SUPER, KeyModifiers::HYPER, KeyModifiers::META] {
3250 let mut s = state_focused_on_zai();
3251 let zi = key_index(VendorId::Zai);
3252 handle_key(&mut s, KeyCode::Char('x'), modifier);
3253 assert!(s.keys[zi].buf.is_empty(), "modifier {modifier:?}");
3254 assert!(!s.keys[zi].dirty, "modifier {modifier:?}");
3255 }
3256 }
3257
3258 #[test]
3259 fn handle_key_shift_still_types_uppercase() {
3260 let mut s = state_focused_on_zai();
3261 let zi = key_index(VendorId::Zai);
3262 handle_key(&mut s, KeyCode::Char('A'), KeyModifiers::SHIFT);
3263 assert_eq!(s.keys[zi].buf, "A");
3264 assert!(s.keys[zi].dirty);
3265 }
3266
3267 #[test]
3268 fn handle_key_plain_space_still_cycles_primary_vendor() {
3269 let mut s = blank_state(VendorId::Anthropic);
3270 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
3271 assert_eq!(s.primary, VendorId::AnthropicApi);
3272 }
3273
3274 #[test]
3275 fn handle_key_ctrl_s_attempts_save_from_any_field() {
3276 let (_dir, path) = temp_config(None);
3277 let s = state_with("zk", "ok", VendorId::Zai);
3278 save_to_path(&s, &path).unwrap();
3279 let raw = std::fs::read_to_string(&path).unwrap();
3280 assert!(raw.contains("api_key = \"zk\""));
3281 }
3282 #[test]
3283 fn save_to_path_writes_kimi_key_when_dirty() {
3284 let (_dir, path) = temp_config(None);
3285 let mut s = blank_state(VendorId::Anthropic);
3286 let kimi = key_index(VendorId::Kimi);
3287 s.keys[kimi] = KeyInput::from_config(Some("kk"));
3288 s.keys[kimi].dirty = true;
3289 save_to_path(&s, &path).unwrap();
3290 let raw = std::fs::read_to_string(&path).unwrap();
3291 assert!(raw.contains("[kimi]"));
3292 assert!(raw.contains("api_key = \"kk\""));
3293 }
3294
3295 #[test]
3296 fn settings_save_uses_the_same_config_path_as_load() {
3297 assert_eq!(
3298 default_config_path().unwrap(),
3299 crate::config::resolved_path().unwrap()
3300 );
3301 }
3302
3303 #[test]
3304 fn native_snapshot_reports_key_state_without_serializing_secrets() {
3305 let mut cfg = Config::default();
3306 cfg.zai.api_key = Some("never-leak-this-key".into());
3307 cfg.zai.api_key_env = "CUSTOM_ZAI_KEY".into();
3308 let raw = settings_snapshot_json_with(&cfg, |name| name == "CUSTOM_ZAI_KEY").unwrap();
3309 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
3310
3311 assert_eq!(parsed["schema_version"], 1);
3312 assert_eq!(parsed["primary"], "anthropic");
3313 let zai = parsed["keys"]
3314 .as_array()
3315 .unwrap()
3316 .iter()
3317 .find(|row| row["id"] == "zai")
3318 .unwrap();
3319 assert_eq!(zai["configured"], true);
3320 assert_eq!(zai["inline_configured"], true);
3321 assert_eq!(zai["environment_configured"], true);
3322 assert_eq!(zai["environment"], "CUSTOM_ZAI_KEY");
3323 assert!(!raw.contains("never-leak-this-key"));
3324 assert!(parsed.get("api_key").is_none());
3325 }
3326
3327 #[test]
3328 fn native_snapshot_offers_copilot_primary_without_a_token_field() {
3329 let cfg = Config::default();
3330 let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
3331 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
3332 assert!(
3333 parsed["primary_choices"]
3334 .as_array()
3335 .unwrap()
3336 .iter()
3337 .any(|row| row["id"] == "copilot")
3338 );
3339 assert!(
3340 !parsed["keys"]
3341 .as_array()
3342 .unwrap()
3343 .iter()
3344 .any(|row| row["id"] == "copilot")
3345 );
3346 }
3347
3348 #[test]
3349 fn native_key_only_patch_does_not_require_or_replace_primary() {
3350 let cfg = Config::default();
3351 let original_primary = SettingsState::from_config(&cfg).primary;
3352 let request = serde_json::json!({
3353 "schema_version": 1,
3354 "keys": {"kimi": {"action": "set", "value": "new-kimi-key"}}
3355 });
3356
3357 let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
3358 assert_eq!(state.primary, original_primary);
3359 let kimi_index = KEY_VENDORS
3360 .iter()
3361 .position(|vendor| vendor.id == VendorId::Kimi)
3362 .unwrap();
3363 assert!(state.keys[kimi_index].dirty);
3364 assert_eq!(state.keys[kimi_index].buf, "new-kimi-key");
3365 }
3366
3367 #[test]
3368 fn native_patch_reuses_tui_persistence_and_preserves_existing_config() {
3369 let (_dir, path) = temp_config(Some(
3370 r#"# keep this comment
3371[ui]
3372primary = "anthropic"
3373
3374[zai]
3375enabled = true
3376api_key_env = "ZAI_API_KEY"
3377plan_tier = "pro"
3378
3379[openrouter]
3380enabled = true
3381"#,
3382 ));
3383 let cfg = Config::load_from(&path).unwrap();
3384 let request = serde_json::json!({
3385 "schema_version": 1,
3386 "primary": "openrouter",
3387 "keys": {
3388 "zai": {"action": "set", "value": "new-zai-key"}
3389 }
3390 });
3391
3392 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
3393 let raw = std::fs::read_to_string(&path).unwrap();
3394 assert!(raw.contains("# keep this comment"));
3395 assert!(raw.contains("plan_tier = \"pro\""));
3396 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
3397 assert!(raw.contains("primary = \"openrouter\""));
3398 assert!(raw.contains("api_key = \"new-zai-key\""));
3399 }
3400
3401 #[test]
3402 fn native_patch_distinguishes_clear_from_unchanged() {
3403 let (_dir, path) = temp_config(Some(
3404 "[zai]\nenabled = true\napi_key = \"remove-me\"\n\
3405 [openrouter]\nenabled = true\napi_key = \"keep-me\"\n",
3406 ));
3407 let cfg = Config::load_from(&path).unwrap();
3408 let request = serde_json::json!({
3409 "schema_version": 1,
3410 "primary": "zai",
3411 "keys": {"zai": {"action": "clear"}}
3412 });
3413
3414 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
3415 let raw = std::fs::read_to_string(&path).unwrap();
3416 assert!(!raw.contains("remove-me"));
3417 assert!(raw.contains("keep-me"));
3418 }
3419
3420 #[test]
3421 fn native_primary_selection_enables_copilot_without_writing_a_token() {
3422 let (_dir, path) = temp_config(Some(
3423 "[copilot]\nenabled = false\ntoken = \"legacy-value\"\ntoken_env = \"OLD_TOKEN\"\n",
3424 ));
3425 let cfg = Config::load_from(&path).unwrap();
3426 let select = serde_json::json!({
3427 "schema_version": 1,
3428 "primary": "copilot"
3429 });
3430 apply_settings_json_to_path(&cfg, &select.to_string(), &path).unwrap();
3431 let raw = std::fs::read_to_string(&path).unwrap();
3432 assert!(raw.contains("enabled = true"));
3433 assert!(raw.contains("primary = \"copilot\""));
3434 assert!(!raw.contains("token ="));
3435 assert!(!raw.contains("token_env ="));
3436 }
3437
3438 #[test]
3439 fn native_patch_errors_never_echo_key_values() {
3440 let raw = serde_json::json!({
3441 "schema_version": 1,
3442 "primary": "anthropic",
3443 "keys": {
3444 "zai": {"action": "set", "value": "secret\nwith-control"}
3445 }
3446 })
3447 .to_string();
3448 let error = state_from_apply_request(&Config::default(), &raw)
3449 .unwrap_err()
3450 .to_string();
3451 assert!(!error.contains("secret"));
3452 assert!(error.contains("control characters"));
3453 }
3454
3455 #[test]
3456 fn native_patch_input_is_bounded_before_json_parsing() {
3457 let oversized = vec![b'x'; MAX_SETTINGS_REQUEST_BYTES as usize + 1];
3458 let error = read_settings_request(std::io::Cursor::new(oversized))
3459 .unwrap_err()
3460 .to_string();
3461 assert!(error.contains("exceeds"));
3462 }
3463
3464 #[test]
3467 fn native_snapshot_lists_every_provider_with_its_enabled_state() {
3468 let mut cfg = Config::default();
3469 cfg.grok.enabled = true;
3470 let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
3471 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
3472
3473 let vendors = parsed["vendors"].as_array().unwrap();
3474 assert_eq!(vendors.len(), VendorId::all().len());
3475 let by_id = |slug: &str| {
3476 vendors
3477 .iter()
3478 .find(|row| row["id"] == slug)
3479 .unwrap_or_else(|| panic!("no {slug} row: {vendors:?}"))
3480 .clone()
3481 };
3482 assert_eq!(by_id("anthropic")["enabled"], true);
3483 assert_eq!(by_id("grok")["enabled"], true);
3484 assert_eq!(by_id("kimi")["enabled"], false);
3485 assert_eq!(by_id("anthropic")["label"], "Claude");
3487 assert_eq!(by_id("opencode-go")["label"], "OpenCode Go");
3488 }
3489
3490 #[test]
3491 fn native_patch_toggles_providers_and_preserves_the_rest_of_the_file() {
3492 let (_dir, path) = temp_config(Some("# keep\n[zai]\nenabled = true\napi_key = \"k\"\n"));
3493 let cfg = Config::load_from(&path).unwrap();
3494 let request = serde_json::json!({
3495 "schema_version": 1,
3496 "vendors": {"grok": true, "zai": false}
3497 });
3498
3499 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
3500
3501 let raw = std::fs::read_to_string(&path).unwrap();
3502 assert!(raw.starts_with("# keep\n"), "{raw}");
3503 assert!(raw.contains("api_key = \"k\""), "{raw}");
3504 let reloaded = Config::load_from(&path).unwrap();
3505 assert!(reloaded.is_enabled(VendorId::Grok));
3506 assert!(!reloaded.is_enabled(VendorId::Zai));
3507 }
3508
3509 #[test]
3510 fn native_patch_refuses_an_unknown_provider_slug_without_writing() {
3511 let (_dir, path) = temp_config(Some("[zai]\nenabled = true\n"));
3512 let cfg = Config::load_from(&path).unwrap();
3513 for bad in ["", "mytool", "not-a-vendor"] {
3514 let request = serde_json::json!({
3515 "schema_version": 1,
3516 "vendors": {bad: true}
3517 });
3518 let error = state_from_apply_request(&cfg, &request.to_string())
3519 .unwrap_err()
3520 .to_string();
3521 assert!(error.contains("unknown provider"), "{bad}: {error}");
3522 }
3523 assert_eq!(
3525 std::fs::read_to_string(&path).unwrap(),
3526 "[zai]\nenabled = true\n"
3527 );
3528 }
3529
3530 #[test]
3532 fn native_vendor_toggle_alone_is_a_valid_patch() {
3533 let cfg = Config::default();
3534 let request = serde_json::json!({
3535 "schema_version": 1,
3536 "vendors": {"kimi": true}
3537 });
3538 let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
3539 let kimi = vendor_index(VendorId::Kimi);
3540 assert_eq!(
3541 state.vendors[kimi],
3542 ProviderSwitch {
3543 enabled: true,
3544 dirty: true
3545 }
3546 );
3547 }
3548
3549 #[test]
3552 fn body_layout_positions_every_focusable_row_without_overlap() {
3553 let keys = 5;
3554 let vendors = 7;
3555 let layout = body_layout(keys, vendors);
3556 assert_eq!(layout.primary, 1);
3558 assert_eq!(layout.first_key, 4);
3559 assert_eq!(layout.first_vendor, layout.first_key + keys + 2);
3561 assert_eq!(layout.notify_enabled, layout.first_vendor + vendors + 2);
3562 assert_eq!(layout.notify_threshold, layout.notify_enabled + 1);
3563 assert_eq!(layout.save, layout.notify_threshold + 2);
3564 assert_eq!(layout.rows, layout.save + 1);
3565 let mut rows = vec![
3567 layout.primary,
3568 layout.notify_enabled,
3569 layout.notify_threshold,
3570 layout.save,
3571 ];
3572 rows.extend(layout.first_key..layout.first_key + keys);
3573 rows.extend(layout.first_vendor..layout.first_vendor + vendors);
3574 rows.sort_unstable();
3575 let mut unique = rows.clone();
3576 unique.dedup();
3577 assert_eq!(rows, unique);
3578 assert!(rows.iter().all(|row| *row < layout.rows));
3579 }
3580
3581 #[test]
3582 fn focus_line_matches_the_body_layout_for_every_focus_variant() {
3583 let state = blank_state(VendorId::Anthropic);
3584 let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
3585 let cases = [
3586 (Focus::Primary, layout.primary),
3587 (Focus::Key(0), layout.first_key),
3588 (
3589 Focus::Key(KEY_VENDORS.len() - 1),
3590 layout.first_key + KEY_VENDORS.len() - 1,
3591 ),
3592 (Focus::Vendor(0), layout.first_vendor),
3593 (
3594 Focus::Vendor(state.vendors.len() - 1),
3595 layout.first_vendor + state.vendors.len() - 1,
3596 ),
3597 (Focus::NotifyEnabled, layout.notify_enabled),
3598 (Focus::NotifyThreshold, layout.notify_threshold),
3599 (Focus::Save, layout.save),
3600 ];
3601 for (focus, want) in cases {
3602 let mut s = state.clone();
3603 s.focus = focus;
3604 assert_eq!(focus_line(&s), want, "{focus:?}");
3605 }
3606 }
3607
3608 #[test]
3609 fn follow_focus_scroll_sticks_jumps_and_clamps() {
3610 assert_eq!(follow_focus_scroll(3, 50, 10, 0), 0);
3612 assert_eq!(follow_focus_scroll(9, 50, 10, 0), 0);
3613 assert_eq!(follow_focus_scroll(10, 50, 10, 0), 1);
3615 assert_eq!(follow_focus_scroll(25, 50, 10, 5), 16);
3616 assert_eq!(follow_focus_scroll(2, 50, 10, 16), 2);
3618 assert_eq!(follow_focus_scroll(49, 50, 10, 0), 40);
3620 assert_eq!(follow_focus_scroll(60, 50, 10, 0), 40);
3621 assert_eq!(follow_focus_scroll(5, 50, 0, 3), 0);
3623 assert_eq!(follow_focus_scroll(5, 0, 10, 3), 0);
3624 }
3625
3626 #[test]
3629 fn render_records_clickable_hint_links_for_the_focused_control() {
3630 use ratatui::Terminal;
3631 use ratatui::backend::TestBackend;
3632
3633 let theme = crate::theme::Theme::default();
3634 let mut state = blank_state(VendorId::Anthropic);
3635 state.focus = Focus::Vendor(vendor_index(VendorId::Grok));
3636 let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
3637 let mut hits = Vec::new();
3638 terminal
3639 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3640 .unwrap();
3641
3642 let hint_keys: Vec<(KeyCode, KeyModifiers)> = hits
3643 .iter()
3644 .filter_map(|(row, _)| match row {
3645 SettingsRow::HintKey(code, mods) => Some((*code, *mods)),
3646 _ => None,
3647 })
3648 .collect();
3649 assert_eq!(
3652 hint_keys,
3653 vec![
3654 (KeyCode::Char(' '), KeyModifiers::NONE),
3655 (KeyCode::Char('s'), KeyModifiers::CONTROL),
3656 (KeyCode::Esc, KeyModifiers::NONE),
3657 ]
3658 );
3659 let ys: Vec<u16> = hits
3661 .iter()
3662 .filter(|(row, _)| matches!(row, SettingsRow::HintKey(..)))
3663 .map(|(_, rect)| rect.y)
3664 .collect();
3665 assert!(ys.windows(2).all(|pair| pair[0] == pair[1]));
3666
3667 let mut state = blank_state(VendorId::Anthropic);
3670 state.focus = Focus::Primary;
3671 let mut hits = Vec::new();
3672 terminal
3673 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3674 .unwrap();
3675 let hint_keys: Vec<(KeyCode, KeyModifiers)> = hits
3676 .iter()
3677 .filter_map(|(row, _)| match row {
3678 SettingsRow::HintKey(code, mods) => Some((*code, *mods)),
3679 _ => None,
3680 })
3681 .collect();
3682 assert!(hint_keys.contains(&(KeyCode::Right, KeyModifiers::NONE)));
3683 assert!(!hint_keys.contains(&(KeyCode::Char(' '), KeyModifiers::NONE)));
3684 }
3685
3686 #[test]
3690 fn render_records_switch_cells_right_of_the_labels() {
3691 use ratatui::Terminal;
3692 use ratatui::backend::TestBackend;
3693
3694 let theme = crate::theme::Theme::default();
3695 let mut state = blank_state(VendorId::Anthropic);
3696 state.focus = Focus::Vendor(0);
3697 let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3698 let mut hits = Vec::new();
3699 terminal
3700 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3701 .unwrap();
3702
3703 let focus_of = |i: usize| {
3704 hits.iter().find_map(|(row, rect)| match row {
3705 SettingsRow::Focus(Focus::Vendor(v)) if *v == i => Some(*rect),
3706 _ => None,
3707 })
3708 };
3709 for (i, id) in VendorId::all().iter().enumerate() {
3710 let focus_rect = focus_of(i).unwrap_or_else(|| panic!("vendor {i} has no focus row"));
3711 let switch = hits
3712 .iter()
3713 .find_map(|(row, rect)| match row {
3714 SettingsRow::Switch(Focus::Vendor(v), KeyCode::Char(' '), _) if *v == i => {
3715 Some(*rect)
3716 }
3717 _ => None,
3718 })
3719 .unwrap_or_else(|| panic!("vendor {i} has no switch cell"));
3720 let expected_x =
3725 5 + crate::display::text_width(&format!("{:<11}", id.display_name())) as u16;
3726 let value_len = if state.vendors[i].enabled { 2 } else { 3 }; let focused = state.focus == Focus::Vendor(i);
3728 assert_eq!(switch.y, focus_rect.y);
3729 assert_eq!(switch.x, focus_rect.x + expected_x);
3730 assert_eq!(
3731 switch.width,
3732 (if focused { 6 } else { 2 }) + value_len,
3733 "rendered value segment"
3734 );
3735 assert!(switch.x + switch.width <= focus_rect.x + focus_rect.width);
3736 }
3737 assert!(hits.iter().any(|(row, _)| matches!(
3739 row,
3740 SettingsRow::Switch(Focus::NotifyEnabled, KeyCode::Char(' '), _)
3741 )));
3742 }
3743
3744 #[test]
3747 fn render_records_primary_arrow_cells_when_focused() {
3748 use ratatui::Terminal;
3749 use ratatui::backend::TestBackend;
3750
3751 let theme = crate::theme::Theme::default();
3752 let mut state = blank_state(VendorId::Anthropic);
3753 state.focus = Focus::Primary;
3754 let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3755 let mut hits = Vec::new();
3756 terminal
3757 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3758 .unwrap();
3759
3760 let focus_rect = hits
3761 .iter()
3762 .find_map(|(row, rect)| match row {
3763 SettingsRow::Focus(Focus::Primary) => Some(*rect),
3764 _ => None,
3765 })
3766 .expect("primary row has a focus target");
3767 let prev = hits.iter().find_map(|(row, rect)| match row {
3768 SettingsRow::Switch(Focus::Primary, KeyCode::Left, _) => Some(*rect),
3769 _ => None,
3770 });
3771 let next = hits.iter().find_map(|(row, rect)| match row {
3772 SettingsRow::Switch(Focus::Primary, KeyCode::Right, _) => Some(*rect),
3773 _ => None,
3774 });
3775 let prev = prev.expect("focused primary has a ◀ cell");
3776 let next = next.expect("focused primary has a ▶ cell");
3777 assert_eq!(prev.x, focus_rect.x + 5);
3778 assert_eq!(prev.width, 2);
3779 assert!(next.x > prev.x + 2, "▶ sits right of the vendor name");
3780 assert_eq!(next.width, 2);
3781 assert_eq!(prev.y, next.y);
3782
3783 let mut state = blank_state(VendorId::Anthropic);
3786 state.focus = Focus::Vendor(0);
3787 let mut unfocused_hits = Vec::new();
3788 terminal
3789 .draw(|f| render(f, f.area(), &mut state, &theme, &mut unfocused_hits))
3790 .unwrap();
3791 assert!(
3792 !unfocused_hits
3793 .iter()
3794 .any(|(row, _)| matches!(row, SettingsRow::Switch(Focus::Primary, _, _)))
3795 );
3796 }
3797
3798 #[test]
3802 fn primary_arrow_cells_sit_at_fixed_columns() {
3803 use ratatui::Terminal;
3804 use ratatui::backend::TestBackend;
3805
3806 let theme = crate::theme::Theme::default();
3807 let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3808 let mut arrow_x = |primary: VendorId| {
3809 let mut state = blank_state(primary);
3810 state.focus = Focus::Primary;
3811 let mut hits = Vec::new();
3812 terminal
3813 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3814 .unwrap();
3815 hits.iter()
3816 .find_map(|(row, rect)| match row {
3817 SettingsRow::Switch(Focus::Primary, KeyCode::Right, _) => Some(rect.x),
3818 _ => None,
3819 })
3820 .expect("focused primary has a ▶ cell")
3821 };
3822 assert_eq!(arrow_x(VendorId::Anthropic), arrow_x(VendorId::Copilot));
3823 }
3824
3825 #[test]
3829 fn picker_key_handling_selects_and_closes() {
3830 let mut s = blank_state(VendorId::Anthropic);
3831 s.focus = Focus::Primary;
3832 s.picker = Some(PrimaryPicker {
3833 cursor: 0,
3834 scroll: 0,
3835 });
3836 let count = s.primary_choices.len();
3837 assert!(count > 1);
3838
3839 assert_eq!(
3840 handle_key(&mut s, KeyCode::Down, KeyModifiers::NONE),
3841 Action::Continue
3842 );
3843 assert_eq!(s.picker.as_ref().unwrap().cursor, 1);
3844 handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
3846 handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
3847 assert_eq!(s.picker.as_ref().unwrap().cursor, count - 1);
3848
3849 let expected = s.primary_choices[s.picker.as_ref().unwrap().cursor];
3851 assert_eq!(
3852 handle_key(&mut s, KeyCode::Enter, KeyModifiers::NONE),
3853 Action::Continue
3854 );
3855 assert_eq!(s.primary, expected);
3856 assert!(s.picker.is_none());
3857
3858 s.picker = Some(PrimaryPicker {
3861 cursor: 2,
3862 scroll: 0,
3863 });
3864 assert_eq!(
3865 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
3866 Action::Continue
3867 );
3868 assert!(s.picker.is_none());
3869 assert_eq!(
3870 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
3871 Action::Close
3872 );
3873 }
3874
3875 #[test]
3877 fn open_picker_records_one_click_row_per_choice() {
3878 use ratatui::Terminal;
3879 use ratatui::backend::TestBackend;
3880
3881 let theme = crate::theme::Theme::default();
3882 let mut state = blank_state(VendorId::Anthropic);
3883 state.focus = Focus::Primary;
3884 state.picker = Some(PrimaryPicker {
3885 cursor: 0,
3886 scroll: 0,
3887 });
3888 let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3889 let mut hits = Vec::new();
3890 terminal
3891 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3892 .unwrap();
3893
3894 let picks: Vec<usize> = hits
3895 .iter()
3896 .filter_map(|(row, _)| match row {
3897 SettingsRow::Pick(i) => Some(*i),
3898 _ => None,
3899 })
3900 .collect();
3901 assert_eq!(picks.len(), state.primary_choices.len());
3902 assert_eq!(picks.first().copied(), Some(0));
3903 assert_eq!(picks.last().copied(), Some(state.primary_choices.len() - 1));
3904 }
3905
3906 #[test]
3910 fn render_scrolls_the_body_so_a_late_row_and_save_stay_visible() {
3911 use ratatui::Terminal;
3912 use ratatui::backend::TestBackend;
3913
3914 let backend = TestBackend::new(100, 24);
3915 let mut terminal = Terminal::new(backend).unwrap();
3916 let theme = crate::theme::Theme::default();
3917 let mut state = blank_state(VendorId::Anthropic);
3918 state.focus = Focus::Vendor(vendor_index(VendorId::Grok));
3919 let mut hits = Vec::new();
3920
3921 terminal
3922 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3923 .unwrap();
3924
3925 let rendered = buffer_text(terminal.backend().buffer());
3926 assert!(
3927 rendered.iter().any(|line| line.contains("Grok")),
3928 "focused provider row must be visible: {rendered:?}"
3929 );
3930 let mut state = blank_state(VendorId::Anthropic);
3933 state.focus = Focus::Save;
3934 let mut hits = Vec::new();
3935 terminal
3936 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3937 .unwrap();
3938 let rendered = buffer_text(terminal.backend().buffer());
3939 assert!(
3940 rendered.iter().any(|line| line.contains("Save")),
3941 "Save must stay visible: {rendered:?}"
3942 );
3943 }
3944
3945 fn buffer_text(buffer: &ratatui::buffer::Buffer) -> Vec<String> {
3947 let area = buffer.area;
3948 let mut lines = Vec::new();
3949 for y in area.top()..area.bottom() {
3950 let mut line = String::new();
3951 for x in area.left()..area.right() {
3952 line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
3953 }
3954 lines.push(line.trim_end().to_string());
3955 }
3956 lines
3957 }
3958}