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, is_valid_env_var_name, read_config_document, set_bool, set_value, write_config_document,
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: "subscription request quota",
131 },
132 KeyVendor {
133 id: VendorId::Moonshot,
134 label: "Moonshot",
135 section: VendorId::Moonshot.config_section(),
136 config_key: "api_key",
137 secret_label: "API key",
138 note: "account balance",
139 },
140 KeyVendor {
141 id: VendorId::Grok,
142 label: "Grok",
143 section: VendorId::Grok.config_section(),
144 config_key: "api_key",
145 secret_label: "API key",
146 note: "management key, not the inference key",
147 },
148 KeyVendor {
149 id: VendorId::Minimax,
150 label: "MiniMax",
151 section: VendorId::Minimax.config_section(),
152 config_key: "api_key",
153 secret_label: "API key",
154 note: "Token Plan subscription key",
155 },
156 KeyVendor {
157 id: VendorId::OpenCodeGo,
158 label: "OpenCode Go",
159 section: VendorId::OpenCodeGo.config_section(),
160 config_key: "api_key",
161 secret_label: "API key",
162 note: "usage quota",
163 },
164 KeyVendor {
165 id: VendorId::Ollama,
166 label: "Ollama Cloud",
167 section: VendorId::Ollama.config_section(),
168 config_key: "api_key",
169 secret_label: "API key",
170 note: "ollama.com/settings/keys",
171 },
172 KeyVendor {
173 id: VendorId::OrcaRouter,
174 label: "OrcaRouter",
175 section: VendorId::OrcaRouter.config_section(),
176 config_key: "api_key",
177 secret_label: "API key",
178 note: "credit balance",
179 },
180 KeyVendor {
181 id: VendorId::Lyceum,
182 label: "Lyceum",
183 section: VendorId::Lyceum.config_section(),
184 config_key: "api_key",
185 secret_label: "API key",
186 note: "billing credits",
187 },
188];
189
190pub const PROVIDER_SWITCH_COUNT: usize = VendorId::all().len();
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum Focus {
200 Primary,
201 Key(usize),
202 Vendor(usize),
203 NotifyEnabled,
204 NotifyThreshold,
205 Save,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum SettingsRow {
211 Focus(Focus),
213 Switch(Focus, KeyCode, KeyModifiers),
217 OpenPicker,
220 Pick(usize),
223 HintKey(KeyCode, KeyModifiers),
226}
227
228impl Focus {
229 pub fn next(self) -> Self {
230 match self {
231 Focus::Primary => Focus::Key(0),
232 Focus::Key(i) if i + 1 < KEY_VENDORS.len() => Focus::Key(i + 1),
233 Focus::Key(_) => Focus::Vendor(0),
234 Focus::Vendor(i) if i + 1 < PROVIDER_SWITCH_COUNT => Focus::Vendor(i + 1),
235 Focus::Vendor(_) => Focus::NotifyEnabled,
236 Focus::NotifyEnabled => Focus::NotifyThreshold,
237 Focus::NotifyThreshold => Focus::Save,
238 Focus::Save => Focus::Primary,
239 }
240 }
241 pub fn prev(self) -> Self {
242 match self {
243 Focus::Primary => Focus::Save,
244 Focus::Key(0) => Focus::Primary,
245 Focus::Key(i) => Focus::Key(i - 1),
246 Focus::Vendor(0) => Focus::Key(KEY_VENDORS.len() - 1),
247 Focus::Vendor(i) => Focus::Vendor(i - 1),
248 Focus::NotifyEnabled => Focus::Vendor(PROVIDER_SWITCH_COUNT - 1),
249 Focus::NotifyThreshold => Focus::NotifyEnabled,
250 Focus::Save => Focus::NotifyThreshold,
251 }
252 }
253}
254
255#[derive(Debug, Clone, Copy, Default)]
258pub struct PrimaryPicker {
259 pub cursor: usize,
260 pub scroll: u16,
261}
262
263#[derive(Debug, Clone, Default)]
265pub struct KeyInput {
266 pub buf: String,
267 pub cursor: usize,
269 pub revealed: bool,
271 pub dirty: bool,
275}
276
277impl KeyInput {
278 pub fn from_config(initial: Option<&str>) -> Self {
279 let buf = initial.unwrap_or("").to_string();
280 let cursor = buf.chars().count();
281 Self {
282 buf,
283 cursor,
284 revealed: false,
285 dirty: false,
286 }
287 }
288
289 pub fn insert_char(&mut self, c: char) {
290 let byte_idx = self.char_to_byte(self.cursor);
291 self.buf.insert(byte_idx, c);
292 self.cursor += 1;
293 self.dirty = true;
294 }
295
296 pub fn backspace(&mut self) {
297 if self.cursor == 0 {
298 return;
299 }
300 let prev_byte = self.char_to_byte(self.cursor - 1);
301 let cur_byte = self.char_to_byte(self.cursor);
302 self.buf.replace_range(prev_byte..cur_byte, "");
303 self.cursor -= 1;
304 self.dirty = true;
305 }
306
307 pub fn delete(&mut self) {
308 let n = self.buf.chars().count();
309 if self.cursor >= n {
310 return;
311 }
312 let cur_byte = self.char_to_byte(self.cursor);
313 let next_byte = self.char_to_byte(self.cursor + 1);
314 self.buf.replace_range(cur_byte..next_byte, "");
315 self.dirty = true;
316 }
317
318 pub fn move_left(&mut self) {
319 if self.cursor > 0 {
320 self.cursor -= 1;
321 }
322 }
323 pub fn move_right(&mut self) {
324 if self.cursor < self.buf.chars().count() {
325 self.cursor += 1;
326 }
327 }
328 pub fn move_home(&mut self) {
329 self.cursor = 0;
330 }
331 pub fn move_end(&mut self) {
332 self.cursor = self.buf.chars().count();
333 }
334 pub fn toggle_reveal(&mut self) {
335 self.revealed = !self.revealed;
336 }
337
338 pub fn display(&self) -> String {
340 if self.revealed {
341 self.buf.clone()
342 } else {
343 "•".repeat(self.buf.chars().count())
344 }
345 }
346
347 fn char_to_byte(&self, char_idx: usize) -> usize {
348 self.buf
349 .char_indices()
350 .map(|(b, _)| b)
351 .chain(std::iter::once(self.buf.len()))
352 .nth(char_idx)
353 .unwrap_or(self.buf.len())
354 }
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub struct ProviderSwitch {
362 pub enabled: bool,
363 pub dirty: bool,
364}
365
366#[derive(Debug, Clone)]
368pub struct SettingsState {
369 pub focus: Focus,
370 pub primary_choices: Vec<VendorId>,
373 pub primary: VendorId,
374 pub keys: Vec<KeyInput>,
376 pub vendors: Vec<ProviderSwitch>,
379 pub notify_enabled: bool,
381 pub notify_enabled_dirty: bool,
382 pub notify_threshold: KeyInput,
384 pub status: String,
386 pub scroll: u16,
389 pub picker: Option<PrimaryPicker>,
391}
392
393impl SettingsState {
394 pub fn from_config(cfg: &Config) -> Self {
395 Self::from_config_with(cfg, |name| {
396 std::env::var_os(name).is_some_and(|v| !v.is_empty())
397 })
398 }
399
400 pub fn from_config_with(cfg: &Config, env_set: impl Fn(&str) -> bool) -> Self {
409 let keys = KEY_VENDORS
410 .iter()
411 .map(|kv| KeyInput::from_config(cfg.inline_api_key(kv.id)))
412 .collect();
413 let mut primary_choices = cfg.enabled_vendors();
414 if !primary_choices.contains(&VendorId::Copilot) {
418 primary_choices.push(VendorId::Copilot);
419 }
420 for kv in KEY_VENDORS {
428 if primary_choices.contains(&kv.id) {
429 continue;
430 }
431 let env = cfg.api_key_env_for(kv.id);
432 let exported = is_valid_env_var_name(env) && env_set(env);
433 if cfg.inline_api_key(kv.id).is_some() || exported {
434 primary_choices.push(kv.id);
435 }
436 }
437 let primary = cfg
444 .ui
445 .primary
446 .filter(|vendor| primary_choices.contains(vendor) && cfg.is_enabled(*vendor))
447 .or_else(|| primary_choices.first().copied())
448 .unwrap_or_else(|| cfg.ui.primary.unwrap_or(VendorId::Anthropic));
449 Self {
450 focus: Focus::Primary,
451 primary_choices,
452 primary,
453 keys,
454 vendors: VendorId::all()
455 .iter()
456 .map(|id| ProviderSwitch {
457 enabled: cfg.is_enabled(*id),
458 dirty: false,
459 })
460 .collect(),
461 notify_enabled: cfg.notifications.enabled,
462 notify_enabled_dirty: false,
463 notify_threshold: KeyInput::from_config(Some(&cfg.notifications.threshold.to_string())),
464 status: String::new(),
465 scroll: 0,
466 picker: None,
467 }
468 }
469
470 fn focused_key_mut(&mut self) -> Option<&mut KeyInput> {
472 match self.focus {
473 Focus::Key(i) => self.keys.get_mut(i),
474 _ => None,
475 }
476 }
477
478 fn next_focus(&self) -> Focus {
481 self.focus.next()
482 }
483
484 fn prev_focus(&self) -> Focus {
485 self.focus.prev()
486 }
487}
488
489#[derive(Debug, Clone, Copy, PartialEq, Eq)]
491pub enum Action {
492 Continue,
494 Close,
496 SavedAndClose,
498 Quit,
501}
502
503#[cfg(unix)]
506const PERMS_NOTE: &str = " (chmod 600)";
507#[cfg(not(unix))]
508const PERMS_NOTE: &str = "";
509
510fn saved_status() -> String {
511 format!(
512 "saved to {}{}",
513 crate::config::config_path_hint(),
514 PERMS_NOTE
515 )
516}
517
518pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
520 if state.picker.is_some()
523 && !(matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL))
524 {
525 return handle_picker_key(state, code);
526 }
527 if matches!(code, KeyCode::Esc) {
528 return Action::Close;
529 }
530 if matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL) {
531 return Action::Quit;
532 }
533 if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
535 return try_save(state);
536 }
537 if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
538 if let Some(input) = state.focused_key_mut() {
539 input.toggle_reveal();
540 }
541 return Action::Continue;
542 }
543 match code {
544 KeyCode::Tab | KeyCode::Down => {
545 state.focus = state.next_focus();
546 return Action::Continue;
547 }
548 KeyCode::BackTab | KeyCode::Up => {
549 state.focus = state.prev_focus();
550 return Action::Continue;
551 }
552 _ => {}
553 }
554
555 if matches!(code, KeyCode::Char(_))
560 && mods.intersects(
561 KeyModifiers::CONTROL
562 | KeyModifiers::ALT
563 | KeyModifiers::SUPER
564 | KeyModifiers::HYPER
565 | KeyModifiers::META,
566 )
567 {
568 return Action::Continue;
569 }
570
571 match state.focus {
573 Focus::Primary => handle_primary(state, code),
574 Focus::Key(i) => {
575 if let Some(input) = state.keys.get_mut(i) {
576 handle_input(input, code);
577 }
578 }
579 Focus::Vendor(i) => handle_provider_switch(state, i, code),
580 Focus::NotifyEnabled => handle_notify_enabled(state, code),
581 Focus::NotifyThreshold => handle_threshold_input(&mut state.notify_threshold, code),
582 Focus::Save => {
583 if matches!(code, KeyCode::Enter) {
584 return try_save(state);
585 }
586 }
587 }
588 Action::Continue
589}
590
591fn try_save(state: &mut SettingsState) -> Action {
592 match save_to_config_default(state) {
593 Ok(()) => {
594 state.status = saved_status();
595 Action::SavedAndClose
596 }
597 Err(e) => {
598 state.status = format!("save failed: {e}");
599 Action::Continue
600 }
601 }
602}
603
604fn handle_picker_key(state: &mut SettingsState, code: KeyCode) -> Action {
607 let count = state.primary_choices.len();
608 if count == 0 {
609 state.picker = None;
610 return Action::Continue;
611 }
612 let Some(picker) = state.picker.as_mut() else {
613 return Action::Continue;
614 };
615 match code {
616 KeyCode::Up => picker.cursor = (picker.cursor + count - 1) % count,
617 KeyCode::Down => picker.cursor = (picker.cursor + 1) % count,
618 KeyCode::Enter | KeyCode::Char(' ') => {
619 state.primary = state.primary_choices[picker.cursor];
620 state.picker = None;
621 }
622 KeyCode::Esc => state.picker = None,
623 _ => {}
624 }
625 Action::Continue
626}
627
628fn handle_primary(state: &mut SettingsState, code: KeyCode) {
629 let choices = &state.primary_choices;
631 let Some(idx) = choices.iter().position(|v| *v == state.primary) else {
632 return;
633 };
634 let step = match code {
635 KeyCode::Left => -1,
636 KeyCode::Right | KeyCode::Char(' ') => 1,
637 _ => return,
638 };
639 state.primary = choices[((idx as i32 + step).rem_euclid(choices.len() as i32)) as usize];
640}
641
642fn handle_input(input: &mut KeyInput, code: KeyCode) {
643 match code {
644 KeyCode::Char(c) => input.insert_char(c),
645 KeyCode::Backspace => input.backspace(),
646 KeyCode::Delete => input.delete(),
647 KeyCode::Left => input.move_left(),
648 KeyCode::Right => input.move_right(),
649 KeyCode::Home => input.move_home(),
650 KeyCode::End => input.move_end(),
651 _ => {}
652 }
653}
654
655fn handle_notify_enabled(state: &mut SettingsState, code: KeyCode) {
658 if matches!(code, KeyCode::Left | KeyCode::Right | KeyCode::Char(' ')) {
659 state.notify_enabled = !state.notify_enabled;
660 state.notify_enabled_dirty = true;
661 }
662}
663
664fn handle_provider_switch(state: &mut SettingsState, index: usize, code: KeyCode) {
668 if matches!(code, KeyCode::Left | KeyCode::Right | KeyCode::Char(' '))
669 && let Some(switch) = state.vendors.get_mut(index)
670 {
671 switch.enabled = !switch.enabled;
672 switch.dirty = true;
673 }
674}
675
676fn handle_threshold_input(input: &mut KeyInput, code: KeyCode) {
680 match code {
681 KeyCode::Char(c) if c.is_ascii_digit() => input.insert_char(c),
682 KeyCode::Backspace => input.backspace(),
683 KeyCode::Delete => input.delete(),
684 KeyCode::Left => input.move_left(),
685 KeyCode::Right => input.move_right(),
686 KeyCode::Home => input.move_home(),
687 KeyCode::End => input.move_end(),
688 _ => {}
689 }
690}
691
692fn save_to_config_default(state: &SettingsState) -> Result<()> {
695 let path = default_config_path()?;
696 if let Some(parent) = path.parent() {
697 std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
698 }
699 save_to_path(state, &path)?;
700 crate::waybar::request_refresh();
701 Ok(())
702}
703
704pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
707 let mut doc = read_config_document(path)?;
708
709 if let Some(table) = doc
713 .get_mut("copilot")
714 .and_then(toml_edit::Item::as_table_mut)
715 {
716 table.remove("token");
717 table.remove("token_env");
718 }
719
720 if state.primary_choices.contains(&state.primary) {
729 set_string(&mut doc, "ui", "primary", state.primary.slug())?;
730 if state.primary == VendorId::Copilot || KEY_VENDORS.iter().any(|kv| kv.id == state.primary)
731 {
732 set_bool(&mut doc, state.primary.config_section(), "enabled", true)?;
733 }
734 }
735
736 for (i, kv) in KEY_VENDORS.iter().enumerate() {
737 let Some(input) = state.keys.get(i) else {
738 continue;
739 };
740 update_key(&mut doc, kv, input)?;
741 }
742
743 for (i, id) in VendorId::all().iter().enumerate() {
749 let Some(switch) = state.vendors.get(i) else {
750 continue;
751 };
752 if switch.dirty {
753 crate::config::set_vendor_enabled_in_doc(&mut doc, id.slug(), switch.enabled)?;
754 }
755 }
756
757 if state.notify_enabled_dirty {
760 set_bool(&mut doc, "notifications", "enabled", state.notify_enabled)?;
761 }
762 if state.notify_threshold.dirty {
763 let raw = state.notify_threshold.buf.trim();
764 let threshold = raw.parse::<u8>();
765 match threshold {
766 Ok(threshold) if (1..=100).contains(&threshold) => {
767 set_value(
768 &mut doc,
769 "notifications",
770 "threshold",
771 Some(toml_edit::Value::from(i64::from(threshold))),
772 )?;
773 }
774 _ => {
775 return Err(AppError::Other(format!(
776 "[notifications] threshold must be a whole number between 1 and 100, got {raw:?}"
777 )));
778 }
779 }
780 }
781
782 write_config_document(path, &doc)
783}
784
785fn update_key(doc: &mut DocumentMut, vendor: &KeyVendor, input: &KeyInput) -> Result<()> {
791 if !input.dirty {
792 return Ok(());
793 }
794 if input.buf.is_empty() {
795 if let Some(table) = doc
796 .get_mut(vendor.section)
797 .and_then(toml_edit::Item::as_table_mut)
798 {
799 table.remove(vendor.config_key);
800 }
801 return Ok(());
802 }
803 set_string(doc, vendor.section, vendor.config_key, &input.buf)?;
804 set_bool(doc, vendor.section, "enabled", true)
805}
806
807fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
810 let table = doc
811 .entry(section)
812 .or_insert_with(toml_edit::table)
813 .as_table_mut()
814 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
815
816 if let Some(item) = table.get_mut(key)
817 && let Some(v) = item.as_value_mut()
818 {
819 *v = toml_edit::Value::from(new_value);
820 v.decor_mut().set_prefix(" ");
821 return Ok(());
822 }
823 table.insert(key, value(new_value));
824 Ok(())
825}
826
827fn default_config_path() -> Result<PathBuf> {
828 crate::config::resolved_path()
833 .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
834}
835
836#[derive(Debug, Serialize)]
843struct SettingsSnapshot {
844 schema_version: u8,
845 primary: String,
846 primary_choices: Vec<PrimaryChoice>,
847 keys: Vec<KeyStatus>,
848 vendors: Vec<VendorStatus>,
851}
852
853#[derive(Debug, Serialize)]
854struct PrimaryChoice {
855 id: String,
856 label: String,
857}
858
859#[derive(Debug, Serialize)]
860struct KeyStatus {
861 id: String,
862 label: String,
863 environment: String,
864 secret_label: String,
865 note: String,
866 configured: bool,
867 inline_configured: bool,
868 environment_configured: bool,
869}
870
871#[derive(Debug, Serialize)]
874struct VendorStatus {
875 id: String,
876 label: String,
877 enabled: bool,
878}
879
880#[derive(Debug, Deserialize)]
886#[serde(deny_unknown_fields)]
887struct ApplyRequest {
888 schema_version: u8,
889 primary: Option<String>,
890 #[serde(default)]
891 keys: BTreeMap<String, KeyMutation>,
892 #[serde(default)]
893 vendors: BTreeMap<String, bool>,
894}
895
896#[derive(Debug, Deserialize)]
897#[serde(tag = "action", rename_all = "lowercase", deny_unknown_fields)]
898enum KeyMutation {
899 Set { value: String },
900 Clear,
901}
902
903const SETTINGS_SCHEMA_VERSION: u8 = 1;
904const MAX_SETTINGS_REQUEST_BYTES: u64 = 64 * 1024;
905const MAX_API_KEY_BYTES: usize = 16 * 1024;
906
907fn snapshot_from_config_with(
908 cfg: &Config,
909 environment_configured: impl Fn(&str) -> bool,
910) -> SettingsSnapshot {
911 let state = SettingsState::from_config(cfg);
912 let primary_choices = state
913 .primary_choices
914 .iter()
915 .map(|id| PrimaryChoice {
916 id: id.slug().to_string(),
917 label: id.display_name().to_string(),
918 })
919 .collect();
920 let keys = KEY_VENDORS
921 .iter()
922 .map(|vendor| {
923 let environment = cfg.api_key_env_for(vendor.id);
924 let inline_configured = cfg.inline_api_key(vendor.id).is_some();
925 let environment_configured = environment_configured(environment);
926 KeyStatus {
927 id: vendor.id.slug().to_string(),
928 label: vendor.label.to_string(),
929 environment: environment.to_string(),
930 secret_label: vendor.secret_label.to_string(),
931 note: vendor.note.to_string(),
932 configured: inline_configured || environment_configured,
933 inline_configured,
934 environment_configured,
935 }
936 })
937 .collect();
938 let vendors = VendorId::all()
939 .iter()
940 .map(|id| VendorStatus {
941 id: id.slug().to_string(),
942 label: id.display_name().to_string(),
943 enabled: cfg.is_enabled(*id),
944 })
945 .collect();
946 SettingsSnapshot {
947 schema_version: SETTINGS_SCHEMA_VERSION,
948 primary: state.primary.slug().to_string(),
949 primary_choices,
950 keys,
951 vendors,
952 }
953}
954
955fn settings_snapshot_json(cfg: &Config) -> Result<String> {
956 Ok(serde_json::to_string(&snapshot_from_config_with(
957 cfg,
958 |environment| std::env::var_os(environment).is_some_and(|value| !value.is_empty()),
959 ))?)
960}
961
962#[cfg(test)]
963fn settings_snapshot_json_with(
964 cfg: &Config,
965 environment_configured: impl Fn(&str) -> bool,
966) -> Result<String> {
967 Ok(serde_json::to_string(&snapshot_from_config_with(
968 cfg,
969 environment_configured,
970 ))?)
971}
972
973fn vendor_from_slug(slug: &str) -> Option<VendorId> {
974 VendorId::from_slug(slug)
975}
976
977fn state_from_apply_request(cfg: &Config, raw: &str) -> Result<SettingsState> {
978 let request: ApplyRequest = serde_json::from_str(raw)?;
979 if request.schema_version != SETTINGS_SCHEMA_VERSION {
980 return Err(AppError::Other(format!(
981 "unsupported settings schema version {}",
982 request.schema_version
983 )));
984 }
985
986 let mut state = SettingsState::from_config(cfg);
987 if let Some(primary) = request.primary {
988 let id = vendor_from_slug(&primary)
989 .ok_or_else(|| AppError::Other(format!("unknown primary vendor {primary:?}")))?;
990 if !state.primary_choices.contains(&id) {
991 return Err(AppError::Other(format!(
992 "primary vendor {primary:?} is not enabled"
993 )));
994 }
995 state.primary = id;
996 }
997
998 for (id, mutation) in request.keys {
999 let index = KEY_VENDORS
1000 .iter()
1001 .position(|vendor| vendor.id.slug() == id)
1002 .ok_or_else(|| AppError::Other(format!("unknown credential vendor {id:?}")))?;
1003 let input = &mut state.keys[index];
1004 match mutation {
1005 KeyMutation::Set { value } => {
1006 if value.is_empty() {
1007 return Err(AppError::Other(format!(
1008 "{} for {id:?} is empty; use the clear action to remove it",
1009 KEY_VENDORS[index].secret_label
1010 )));
1011 }
1012 if value.len() > MAX_API_KEY_BYTES {
1013 return Err(AppError::Other(format!(
1014 "{} for {id:?} exceeds {MAX_API_KEY_BYTES} bytes",
1015 KEY_VENDORS[index].secret_label
1016 )));
1017 }
1018 if value.chars().any(char::is_control) {
1019 return Err(AppError::Other(format!(
1020 "{} for {id:?} contains control characters",
1021 KEY_VENDORS[index].secret_label
1022 )));
1023 }
1024 input.buf = value;
1025 }
1026 KeyMutation::Clear => input.buf.clear(),
1027 }
1028 input.cursor = input.buf.chars().count();
1029 input.dirty = true;
1030 input.revealed = false;
1031 }
1032
1033 for (slug, enabled) in request.vendors {
1037 let index = VendorId::all()
1038 .iter()
1039 .position(|id| id.slug() == slug)
1040 .ok_or_else(|| AppError::Other(format!("unknown provider {slug:?}")))?;
1041 state.vendors[index] = ProviderSwitch {
1042 enabled,
1043 dirty: true,
1044 };
1045 }
1046 Ok(state)
1047}
1048
1049#[cfg(test)]
1050fn apply_settings_json_to_path(cfg: &Config, raw: &str, path: &Path) -> Result<()> {
1051 let state = state_from_apply_request(cfg, raw)?;
1052 save_to_path(&state, path)
1053}
1054
1055fn read_settings_request<R: BufRead>(reader: R) -> Result<String> {
1056 let mut limited = reader.take(MAX_SETTINGS_REQUEST_BYTES + 1);
1057 let mut bytes = Vec::new();
1058 limited.read_until(b'\n', &mut bytes)?;
1059 if bytes.len() as u64 > MAX_SETTINGS_REQUEST_BYTES {
1060 return Err(AppError::Other(format!(
1061 "settings request exceeds {MAX_SETTINGS_REQUEST_BYTES} bytes"
1062 )));
1063 }
1064 if bytes.last() == Some(&b'\n') {
1065 bytes.pop();
1066 if bytes.last() == Some(&b'\r') {
1067 bytes.pop();
1068 }
1069 }
1070 String::from_utf8(bytes)
1071 .map_err(|_| AppError::Other("settings request is not valid UTF-8".into()))
1072}
1073
1074fn apply_settings_from_stdin() -> Result<()> {
1075 let raw = read_settings_request(std::io::stdin().lock())?;
1076 let cfg = Config::load()?;
1077 let state = state_from_apply_request(&cfg, &raw)?;
1078 save_to_config_default(&state)
1079}
1080
1081fn enable_vendor_at(path: &Path, vendor: VendorId) -> Result<()> {
1083 let mut doc = read_config_document(path)?;
1084 let before = doc.to_string();
1085 set_bool(&mut doc, vendor.config_section(), "enabled", true)?;
1086 if doc.to_string() != before {
1087 write_config_document(path, &doc)?;
1088 }
1089 Ok(())
1090}
1091
1092pub fn run_cli(action: &crate::widget::cli::SettingsAction) -> i32 {
1096 let result = match action {
1097 crate::widget::cli::SettingsAction::Enable { vendor } => default_config_path()
1098 .and_then(|path| enable_vendor_at(&path, vendor.to_id()))
1099 .map(|()| {
1100 crate::waybar::request_refresh();
1101 println!(r#"{{"ok":true}}"#);
1102 }),
1103 crate::widget::cli::SettingsAction::Show => Config::load()
1104 .and_then(|cfg| settings_snapshot_json(&cfg))
1105 .map(|json| println!("{json}")),
1106 crate::widget::cli::SettingsAction::Apply => {
1107 apply_settings_from_stdin().map(|()| println!(r#"{{"ok":true}}"#))
1108 }
1109 };
1110 match result {
1111 Ok(()) => 0,
1112 Err(error) => {
1113 eprintln!("settings: {error}");
1114 1
1115 }
1116 }
1117}
1118
1119struct BodyLayout {
1125 primary: usize,
1126 first_key: usize,
1127 first_vendor: usize,
1128 notify_enabled: usize,
1129 notify_threshold: usize,
1130 save: usize,
1131 rows: usize,
1133}
1134
1135fn body_layout(keys: usize, vendors: usize) -> BodyLayout {
1136 let first_key = 4;
1137 let first_vendor = first_key + keys + 2;
1138 let notify_enabled = first_vendor + vendors + 2;
1139 BodyLayout {
1140 primary: 1,
1141 first_key,
1142 first_vendor,
1143 notify_enabled,
1144 notify_threshold: notify_enabled + 1,
1145 save: notify_enabled + 3,
1146 rows: notify_enabled + 4,
1147 }
1148}
1149
1150fn focus_line(state: &SettingsState) -> usize {
1152 let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
1153 match state.focus {
1154 Focus::Primary => layout.primary,
1155 Focus::Key(i) => layout.first_key + i,
1156 Focus::Vendor(i) => layout.first_vendor + i,
1157 Focus::NotifyEnabled => layout.notify_enabled,
1158 Focus::NotifyThreshold => layout.notify_threshold,
1159 Focus::Save => layout.save,
1160 }
1161}
1162
1163fn follow_focus_scroll(focused: usize, total: usize, viewport: u16, current: u16) -> u16 {
1167 if viewport == 0 || total == 0 {
1168 return 0;
1169 }
1170 let max_offset = (total as u16).saturating_sub(viewport);
1171 let focused = focused.min(total - 1) as u16;
1172 let wanted = if focused < current {
1173 focused
1174 } else if focused >= current.saturating_add(viewport) {
1175 (focused + 1).saturating_sub(viewport)
1178 } else {
1179 current
1180 };
1181 wanted.min(max_offset)
1182}
1183
1184pub fn render(
1186 f: &mut Frame,
1187 area: Rect,
1188 state: &mut SettingsState,
1189 theme: &Theme,
1190 hits: &mut Vec<(SettingsRow, Rect)>,
1191) {
1192 let modal = centered_rect(74, 88, area);
1193 f.render_widget(Clear, modal);
1194
1195 let bubble = bubble_theme(theme);
1196 let block = bubble.titled_modal_block(" Settings ");
1197 let inner = block.inner(modal);
1198 f.render_widget(block, modal);
1199
1200 let chunks = Layout::default()
1202 .direction(Direction::Vertical)
1203 .constraints([Constraint::Min(0), Constraint::Length(1)])
1204 .split(inner);
1205
1206 let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
1212 let total = layout.rows + usize::from(!state.status.is_empty());
1213 let scroll = follow_focus_scroll(focus_line(state), total, chunks[0].height, state.scroll);
1214 state.scroll = scroll;
1215
1216 let row_at = |line: usize| {
1221 let top = chunks[0].y;
1222 let bottom = top.saturating_add(chunks[0].height);
1223 let y = top.saturating_add(line as u16).checked_sub(scroll)?;
1224 (y >= top && y < bottom).then_some(Rect::new(inner.x, y, inner.width, 1))
1225 };
1226
1227 let mut lines: Vec<Line> = vec![
1229 section_header("Primary vendor", "shown first on the bar / TUI", &bubble),
1230 primary_line(state, &bubble),
1231 Line::from(""),
1232 section_header("API keys", "all key providers", &bubble),
1233 ];
1234 if let Some(r) = row_at(1) {
1235 if state.focus == Focus::Primary {
1241 let pad = primary_name_pad(state);
1242 hits.push((
1243 SettingsRow::Switch(Focus::Primary, KeyCode::Left, KeyModifiers::NONE),
1244 Rect::new(r.x.saturating_add(5), r.y, 2, 1),
1245 ));
1246 hits.push((
1247 SettingsRow::Switch(Focus::Primary, KeyCode::Right, KeyModifiers::NONE),
1248 Rect::new(r.x.saturating_add(9 + pad), r.y, 2, 1),
1249 ));
1250 hits.push((
1251 SettingsRow::OpenPicker,
1252 Rect::new(r.x.saturating_add(7), r.y, pad + 2, 1),
1253 ));
1254 }
1255 hits.push((SettingsRow::Focus(Focus::Primary), r));
1256 }
1257
1258 for (i, kv) in KEY_VENDORS.iter().enumerate() {
1259 let focused = state.focus == Focus::Key(i);
1260 if let Some(r) = row_at(lines.len()) {
1261 hits.push((SettingsRow::Focus(Focus::Key(i)), r));
1262 }
1263 lines.push(key_row(kv, &state.keys[i], focused, &bubble));
1264 }
1265
1266 lines.push(Line::from(""));
1267
1268 lines.push(section_header(
1270 "Providers",
1271 "which vendors the report, bar and TUI fetch at all",
1272 &bubble,
1273 ));
1274 for (i, id) in VendorId::all().iter().enumerate() {
1275 let switch = state.vendors.get(i).copied().unwrap_or(ProviderSwitch {
1276 enabled: false,
1277 dirty: false,
1278 });
1279 let focused = state.focus == Focus::Vendor(i);
1280 if let Some(r) = row_at(lines.len()) {
1281 let (value_x, value_w) =
1287 switch_cell(id.display_name(), switch_value(switch.enabled), focused);
1288 hits.push((
1289 SettingsRow::Switch(Focus::Vendor(i), KeyCode::Char(' '), KeyModifiers::NONE),
1290 Rect::new(r.x.saturating_add(value_x), r.y, value_w, 1),
1291 ));
1292 hits.push((SettingsRow::Focus(Focus::Vendor(i)), r));
1293 }
1294 lines.push(provider_row(id, switch, focused, &bubble));
1295 }
1296 lines.push(Line::from(""));
1297
1298 lines.push(section_header(
1300 "Notifications",
1301 "desktop alert when a quota window crosses the threshold",
1302 &bubble,
1303 ));
1304 if let Some(r) = row_at(lines.len()) {
1305 let (value_x, value_w) = switch_cell(
1306 "Quota alerts",
1307 switch_value(state.notify_enabled),
1308 state.focus == Focus::NotifyEnabled,
1309 );
1310 hits.push((
1311 SettingsRow::Switch(Focus::NotifyEnabled, KeyCode::Char(' '), KeyModifiers::NONE),
1312 Rect::new(r.x.saturating_add(value_x), r.y, value_w, 1),
1313 ));
1314 hits.push((SettingsRow::Focus(Focus::NotifyEnabled), r));
1315 }
1316 lines.push(notify_enabled_line(state, &bubble));
1317 if let Some(r) = row_at(lines.len()) {
1318 hits.push((SettingsRow::Focus(Focus::NotifyThreshold), r));
1319 }
1320 lines.push(notify_threshold_line(state, &bubble));
1321 lines.push(Line::from(""));
1322
1323 if let Some(r) = row_at(lines.len()) {
1325 hits.push((SettingsRow::Focus(Focus::Save), r));
1326 }
1327 lines.push(save_line(state.focus == Focus::Save, &bubble));
1328 if !state.status.is_empty() {
1329 let ok = state.status.starts_with("saved");
1330 let mark = if ok { " ✓ " } else { " ✗ " };
1331 let style = if ok { bubble.accent } else { bubble.selected };
1332 lines.push(Line::from(vec![
1333 Span::styled(mark, style.add_modifier(Modifier::BOLD)),
1334 Span::styled(state.status.clone(), bubble.muted),
1335 ]));
1336 }
1337
1338 f.render_widget(Paragraph::new(lines).scroll((scroll, 0)), chunks[0]);
1341
1342 type HintSegment<'a> = (&'a str, &'a str, Option<(KeyCode, KeyModifiers)>);
1346
1347 let segments: &[HintSegment] = match state.focus {
1352 Focus::Primary => &[
1353 ("↑↓/tab", "move", None),
1354 (
1355 "←→",
1356 "change vendor",
1357 Some((KeyCode::Right, KeyModifiers::NONE)),
1358 ),
1359 (
1360 "^S",
1361 "save",
1362 Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1363 ),
1364 ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1365 ],
1366 Focus::Key(_) => &[
1367 ("↑↓/tab", "move", None),
1368 ("type", "edit key", None),
1369 (
1370 "^V",
1371 "reveal",
1372 Some((KeyCode::Char('v'), KeyModifiers::CONTROL)),
1373 ),
1374 (
1375 "^S",
1376 "save",
1377 Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1378 ),
1379 ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1380 ],
1381 Focus::Vendor(_) | Focus::NotifyEnabled => &[
1382 ("↑↓/tab", "move", None),
1383 (
1384 "←→/space",
1385 "toggle",
1386 Some((KeyCode::Char(' '), KeyModifiers::NONE)),
1387 ),
1388 (
1389 "^S",
1390 "save",
1391 Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1392 ),
1393 ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1394 ],
1395 Focus::NotifyThreshold => &[
1396 ("↑↓/tab", "move", None),
1397 ("type", "digits 1-100", None),
1398 (
1399 "^S",
1400 "save",
1401 Some((KeyCode::Char('s'), KeyModifiers::CONTROL)),
1402 ),
1403 ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1404 ],
1405 Focus::Save => &[
1406 ("↑↓/tab", "move", None),
1407 (
1408 "enter/^S",
1409 "save",
1410 Some((KeyCode::Enter, KeyModifiers::NONE)),
1411 ),
1412 ("esc", "close", Some((KeyCode::Esc, KeyModifiers::NONE))),
1413 ],
1414 };
1415 let hint = bubble.help_line(segments.iter().map(|(key, desc, _)| (*key, *desc)));
1416 f.render_widget(Paragraph::new(hint), chunks[1]);
1417
1418 let right = chunks[1].x.saturating_add(chunks[1].width);
1423 let mut x = chunks[1].x;
1424 for (key, desc, action) in segments {
1425 let width = (crate::display::text_width(key) as u16)
1426 .saturating_add(1)
1427 .saturating_add(crate::display::text_width(desc) as u16);
1428 if let Some((code, mods)) = action
1429 && x < right
1430 {
1431 let visible = width.min(right.saturating_sub(x));
1432 if visible > 0 {
1433 hits.push((
1434 SettingsRow::HintKey(*code, *mods),
1435 Rect::new(x, chunks[1].y, visible, 1),
1436 ));
1437 }
1438 }
1439 x = x.saturating_add(width).saturating_add(3);
1440 }
1441
1442 if state.picker.is_some() {
1445 render_primary_picker(f, state, &bubble, chunks[0], hits);
1446 }
1447}
1448
1449fn render_primary_picker(
1453 f: &mut Frame,
1454 state: &mut SettingsState,
1455 theme: &BubbleTheme,
1456 body: Rect,
1457 hits: &mut Vec<(SettingsRow, Rect)>,
1458) {
1459 let count = state.primary_choices.len();
1460 if count == 0 {
1461 return;
1462 }
1463 let pad = primary_name_pad(state) as usize;
1464 let width = u16::try_from(pad + 6).unwrap_or(u16::MAX).min(body.width);
1465 if width < 4 {
1466 return;
1467 }
1468 let height = (count as u16 + 2)
1470 .min(body.bottom().saturating_sub(body.y + 2))
1471 .max(3);
1472 let area = Rect::new(
1473 body.x.saturating_add(2),
1474 body.y.saturating_add(2),
1475 width,
1476 height,
1477 );
1478
1479 f.render_widget(Clear, area);
1480 let block = theme.titled_modal_block(" Primary vendor ");
1481 let inner = block.inner(area);
1482 f.render_widget(block, area);
1483
1484 let Some(picker) = state.picker.as_mut() else {
1485 return;
1486 };
1487 let viewport = inner.height;
1488 picker.scroll = follow_focus_scroll(picker.cursor, count, viewport, picker.scroll);
1489 for (index, vendor) in state
1490 .primary_choices
1491 .iter()
1492 .enumerate()
1493 .skip(picker.scroll as usize)
1494 .take(viewport as usize)
1495 {
1496 let y = inner.y + u16::try_from(index - picker.scroll as usize).unwrap_or(u16::MAX);
1497 let cursor = picker.cursor == index;
1498 let current = *vendor == state.primary;
1499 let style = if cursor {
1500 theme.accent.add_modifier(Modifier::BOLD)
1501 } else if current {
1502 theme.title
1503 } else {
1504 theme.text
1505 };
1506 let line = Line::from(vec![
1507 theme.span(if cursor { "▸ " } else { " " }),
1508 Span::styled(format!("{:<pad$}", vendor.display_name(), pad = pad), style),
1509 theme.span(if current { " ✓" } else { "" }),
1510 ]);
1511 f.render_widget(line, Rect::new(inner.x, y, inner.width, 1));
1512 hits.push((
1513 SettingsRow::Pick(index),
1514 Rect::new(inner.x, y, inner.width, 1),
1515 ));
1516 }
1517}
1518
1519fn section_header(title: &str, sub: &str, theme: &BubbleTheme) -> Line<'static> {
1520 Line::from(vec![
1521 theme.span(" "),
1522 Span::styled(title.to_string(), theme.title.add_modifier(Modifier::BOLD)),
1523 theme.muted(format!(" — {sub}")),
1524 ])
1525}
1526
1527fn primary_name_pad(state: &SettingsState) -> u16 {
1530 state
1531 .primary_choices
1532 .iter()
1533 .map(|v| crate::display::text_width(v.display_name()) as u16)
1534 .max()
1535 .unwrap_or(1)
1536}
1537
1538fn primary_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1539 let focused = state.focus == Focus::Primary;
1540 let name = state.primary.display_name().to_string();
1541 if focused {
1542 let pad = primary_name_pad(state) as usize;
1543 Line::from(vec![
1544 theme.span(" "),
1545 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1546 Span::styled("◀ ", theme.accent),
1547 Span::styled(
1548 format!(" {:<pad$} ", name, pad = pad),
1549 theme
1550 .selected
1551 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1552 ),
1553 Span::styled(" ▶", theme.accent),
1554 theme.muted(" ← → or click to change"),
1555 ])
1556 } else {
1557 Line::from(vec![theme.span(" "), Span::styled(name, theme.text)])
1558 }
1559}
1560
1561fn key_row(kv: &KeyVendor, input: &KeyInput, focused: bool, theme: &BubbleTheme) -> Line<'static> {
1562 let label = format!("{:<11}", kv.label);
1563 let value = value_text(input, focused);
1564
1565 let env_name = kv.id.api_key_env();
1567 let env_set = std::env::var(env_name)
1568 .map(|v| !v.is_empty())
1569 .unwrap_or(false);
1570 let mut suffix = format!(" {env_name}");
1571 if env_set {
1572 suffix.push_str(" · env set (overrides)");
1573 }
1574 if !kv.note.is_empty() {
1575 suffix.push_str(&format!(" · {}", kv.note));
1576 }
1577
1578 if focused {
1579 let val_style = if input.buf.is_empty() {
1580 theme.accent.add_modifier(Modifier::BOLD)
1581 } else {
1582 theme.selected.add_modifier(Modifier::REVERSED)
1583 };
1584 let mut spans = vec![
1585 theme.span(" "),
1586 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1587 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1588 Span::styled(format!(" {value} "), val_style),
1589 ];
1590 if input.revealed {
1591 spans.push(theme.muted(" [revealed]"));
1592 }
1593 spans.push(theme.muted(suffix));
1594 Line::from(spans)
1595 } else {
1596 let val_style = if input.buf.is_empty() {
1597 theme.muted
1598 } else {
1599 theme.text
1600 };
1601 Line::from(vec![
1602 theme.span(" "),
1603 Span::styled(label, theme.text),
1604 Span::styled(format!(" {value}"), val_style),
1605 theme.muted(suffix),
1606 ])
1607 }
1608}
1609
1610fn value_text(input: &KeyInput, focused: bool) -> String {
1613 if input.buf.is_empty() {
1614 return if focused {
1615 "‸".to_string()
1616 } else {
1617 "(empty)".to_string()
1618 };
1619 }
1620 let base = input.display();
1621 if !focused {
1622 return base;
1623 }
1624 let mut chars: Vec<char> = base.chars().collect();
1625 let pos = input.cursor.min(chars.len());
1626 chars.insert(pos, '‸');
1627 chars.into_iter().collect()
1628}
1629
1630fn padded_label(label: &str) -> String {
1634 format!("{:<11}", label)
1635}
1636
1637fn switch_value(enabled: bool) -> &'static str {
1639 if enabled { "on" } else { "off" }
1640}
1641
1642fn switch_cell(label: &str, value: &str, focused: bool) -> (u16, u16) {
1647 let value_x = 5 + crate::display::text_width(&padded_label(label)) as u16;
1648 let decoration_w = if focused { 6 } else { 2 };
1649 let value_w = decoration_w + crate::display::text_width(value) as u16;
1650 (value_x, value_w)
1651}
1652
1653fn provider_row(
1656 id: &VendorId,
1657 switch: ProviderSwitch,
1658 focused: bool,
1659 theme: &BubbleTheme,
1660) -> Line<'static> {
1661 let label = padded_label(id.display_name());
1662 let value = switch_value(switch.enabled);
1663 if focused {
1664 Line::from(vec![
1665 theme.span(" "),
1666 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1667 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1668 Span::styled(
1669 format!(" ◀ {value} ▶ "),
1670 theme
1671 .selected
1672 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1673 ),
1674 ])
1675 } else {
1676 Line::from(vec![
1677 theme.span(" "),
1678 Span::styled(label, theme.text),
1679 Span::styled(format!(" {value}"), theme.muted),
1680 ])
1681 }
1682}
1683
1684fn notify_enabled_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1687 let focused = state.focus == Focus::NotifyEnabled;
1688 let label = padded_label("Quota alerts");
1689 let value = switch_value(state.notify_enabled);
1690 if focused {
1691 Line::from(vec![
1692 theme.span(" "),
1693 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1694 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1695 Span::styled(
1696 format!(" ◀ {value} ▶ "),
1697 theme
1698 .selected
1699 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1700 ),
1701 ])
1702 } else {
1703 Line::from(vec![
1704 theme.span(" "),
1705 Span::styled(label, theme.text),
1706 Span::styled(format!(" {value}"), theme.muted),
1707 ])
1708 }
1709}
1710
1711fn notify_threshold_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1714 let focused = state.focus == Focus::NotifyThreshold;
1715 let label = format!("{:<11}", "Threshold %");
1716 let input = &state.notify_threshold;
1717 let value = if input.buf.is_empty() {
1718 if focused {
1719 "‸".to_string()
1720 } else {
1721 "(97)".to_string()
1722 }
1723 } else {
1724 let mut chars: Vec<char> = input.buf.chars().collect();
1725 if focused {
1726 let pos = input.cursor.min(chars.len());
1727 chars.insert(pos, '‸');
1728 }
1729 chars.into_iter().collect()
1730 };
1731 if focused {
1732 Line::from(vec![
1733 theme.span(" "),
1734 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1735 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1736 Span::styled(
1737 format!(" {value} "),
1738 theme
1739 .selected
1740 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1741 ),
1742 theme.muted(" 1-100"),
1743 ])
1744 } else {
1745 Line::from(vec![
1746 theme.span(" "),
1747 Span::styled(label, theme.text),
1748 Span::styled(format!(" {value}"), theme.text),
1749 theme.muted(" 1-100"),
1750 ])
1751 }
1752}
1753
1754fn save_line(focused: bool, theme: &BubbleTheme) -> Line<'static> {
1755 let style = if focused {
1756 theme
1757 .selected
1758 .add_modifier(Modifier::REVERSED | Modifier::BOLD)
1759 } else {
1760 theme.accent.add_modifier(Modifier::BOLD)
1761 };
1762 let marker = if focused { "▸ " } else { " " };
1763 Line::from(vec![
1764 theme.span(" "),
1765 Span::styled(marker, theme.accent.add_modifier(Modifier::BOLD)),
1766 Span::styled(" Save (Ctrl-S) ", style),
1767 ])
1768}
1769
1770fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1772 let popup_h = (r.height * percent_y) / 100;
1773 let popup_w = (r.width * percent_x) / 100;
1774 Rect {
1775 x: r.x + (r.width - popup_w) / 2,
1776 y: r.y + (r.height - popup_h) / 2,
1777 width: popup_w,
1778 height: popup_h,
1779 }
1780}
1781
1782pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
1784
1785#[cfg(test)]
1786mod tests {
1787 use super::*;
1788 use tempfile::TempDir;
1789
1790 fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
1791 crate::cache::closed_temp_file("config.toml", initial)
1792 }
1793
1794 fn key_index(id: VendorId) -> usize {
1795 KEY_VENDORS.iter().position(|kv| kv.id == id).unwrap()
1796 }
1797
1798 #[test]
1799 fn explicit_enable_preserves_other_settings_and_is_idempotent() {
1800 let dir = tempfile::tempdir().unwrap();
1801 let path = dir.path().join("config.toml");
1802 let original = "# keep me\n[anthropic]\nenabled = false # intentional\n[openrouter]\napi_key = \"test-key\"\nenabled = false\n";
1803 std::fs::write(&path, original).unwrap();
1804 enable_vendor_at(&path, VendorId::Anthropic).unwrap();
1805 let expected = original.replacen("enabled = false", "enabled = true", 1);
1806 assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
1807 enable_vendor_at(&path, VendorId::Anthropic).unwrap();
1808 assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
1809 }
1810
1811 #[test]
1812 fn explicit_enable_creates_missing_config_and_rejects_malformed_config() {
1813 let dir = tempfile::tempdir().unwrap();
1814 let path = dir.path().join("nested/config.toml");
1815 enable_vendor_at(&path, VendorId::Anthropic).unwrap();
1816 assert!(
1817 std::fs::read_to_string(&path)
1818 .unwrap()
1819 .contains("enabled = true")
1820 );
1821 let broken = "[anthropic\n";
1822 std::fs::write(&path, broken).unwrap();
1823 assert!(enable_vendor_at(&path, VendorId::Anthropic).is_err());
1824 assert_eq!(std::fs::read_to_string(&path).unwrap(), broken);
1825 }
1826
1827 fn blank_state(primary: VendorId) -> SettingsState {
1828 SettingsState {
1829 focus: Focus::Primary,
1830 primary_choices: VendorId::all().to_vec(),
1831 primary,
1832 keys: KEY_VENDORS.iter().map(|_| KeyInput::default()).collect(),
1833 vendors: VendorId::all()
1834 .iter()
1835 .map(|id| ProviderSwitch {
1836 enabled: matches!(id.slug(), "anthropic" | "openai" | "zai" | "openrouter"),
1837 dirty: false,
1838 })
1839 .collect(),
1840 notify_enabled: true,
1841 notify_enabled_dirty: false,
1842 notify_threshold: KeyInput::from_config(Some("97")),
1843 status: String::new(),
1844 scroll: 0,
1845 picker: None,
1846 }
1847 }
1848
1849 fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
1851 let mut s = blank_state(primary);
1852 s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some(zai));
1853 s.keys[key_index(VendorId::Zai)].dirty = true;
1854 s.keys[key_index(VendorId::Openrouter)] = KeyInput::from_config(Some(opr));
1855 s.keys[key_index(VendorId::Openrouter)].dirty = true;
1856 s
1857 }
1858
1859 #[test]
1860 fn focus_cycles_through_primary_all_keys_notifications_and_save() {
1861 let mut f = Focus::Primary;
1862 let mut seen = vec![f];
1863 for _ in 0..(KEY_VENDORS.len() + PROVIDER_SWITCH_COUNT + 4) {
1866 f = f.next();
1867 seen.push(f);
1868 }
1869 assert_eq!(seen.first(), Some(&Focus::Primary));
1872 assert_eq!(seen.last(), Some(&Focus::Primary));
1873 assert!(seen.contains(&Focus::Key(0)));
1874 assert!(seen.contains(&Focus::Key(KEY_VENDORS.len() - 1)));
1875 assert!(seen.contains(&Focus::Vendor(0)));
1876 assert!(seen.contains(&Focus::Vendor(PROVIDER_SWITCH_COUNT - 1)));
1877 assert!(seen.contains(&Focus::NotifyEnabled));
1878 assert!(seen.contains(&Focus::NotifyThreshold));
1879 assert!(seen.contains(&Focus::Save));
1880 assert_eq!(Focus::Primary.next().prev(), Focus::Primary);
1883 assert_eq!(Focus::Save.prev().next(), Focus::Save);
1884 assert_eq!(
1885 Focus::Key(KEY_VENDORS.len() - 1).next(),
1886 Focus::Vendor(0),
1887 "the first provider row follows the last credential"
1888 );
1889 assert_eq!(Focus::Vendor(0).prev(), Focus::Key(KEY_VENDORS.len() - 1));
1890 assert_eq!(
1891 Focus::Vendor(PROVIDER_SWITCH_COUNT - 1).next(),
1892 Focus::NotifyEnabled
1893 );
1894 assert_eq!(
1895 Focus::NotifyEnabled.prev(),
1896 Focus::Vendor(PROVIDER_SWITCH_COUNT - 1)
1897 );
1898 assert_eq!(Focus::NotifyThreshold.next(), Focus::Save);
1899 assert_eq!(Focus::Primary.prev(), Focus::Save);
1900 }
1901
1902 #[test]
1906 fn provider_switch_count_matches_the_vendor_enumeration() {
1907 assert_eq!(PROVIDER_SWITCH_COUNT, VendorId::all().len());
1908 }
1909
1910 #[test]
1911 fn from_config_prefills_provider_switches_from_enabled_vendors() {
1912 let mut cfg = Config::default();
1913 cfg.grok.enabled = true;
1914 cfg.anthropic.enabled = false;
1915 let s = SettingsState::from_config_with(&cfg, |_| false);
1916 for (i, id) in VendorId::all().iter().enumerate() {
1917 assert_eq!(
1918 s.vendors[i],
1919 ProviderSwitch {
1920 enabled: cfg.is_enabled(*id),
1921 dirty: false
1922 },
1923 "{}",
1924 id.slug()
1925 );
1926 }
1927 assert!(!s.vendors[vendor_index(VendorId::Anthropic)].enabled);
1928 assert!(s.vendors[vendor_index(VendorId::Grok)].enabled);
1929 }
1930
1931 fn vendor_index(id: VendorId) -> usize {
1932 VendorId::all().iter().position(|v| *v == id).unwrap()
1933 }
1934
1935 #[test]
1936 fn provider_switch_flips_on_left_right_and_space() {
1937 let mut s = blank_state(VendorId::Anthropic);
1938 let grok = vendor_index(VendorId::Grok);
1939 s.focus = Focus::Vendor(grok);
1940 assert!(!s.vendors[grok].enabled);
1941 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1942 assert_eq!(
1943 s.vendors[grok],
1944 ProviderSwitch {
1945 enabled: true,
1946 dirty: true
1947 }
1948 );
1949 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
1950 assert!(!s.vendors[grok].enabled);
1951 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1952 assert!(s.vendors[grok].enabled);
1953 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
1955 assert!(s.vendors[grok].enabled);
1956 assert!(s.vendors[grok].dirty);
1957 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::CONTROL);
1959 assert!(s.vendors[grok].enabled);
1960 let before = s.vendors[grok];
1962 handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE);
1963 assert_ne!(s.focus, Focus::Vendor(grok));
1964 assert_eq!(s.vendors[grok], before);
1965 }
1966
1967 #[test]
1968 fn save_writes_only_toggled_providers_and_round_trips() {
1969 let (_dir, path) = temp_config(Some("# keep\n[anthropic]\nenabled = true\n"));
1970 let mut s = blank_state(VendorId::Anthropic);
1971 s.primary_choices = vec![VendorId::Anthropic];
1972 s.vendors[vendor_index(VendorId::Grok)].enabled = true;
1974 s.vendors[vendor_index(VendorId::Grok)].dirty = true;
1975 s.vendors[vendor_index(VendorId::Anthropic)].enabled = false;
1976 s.vendors[vendor_index(VendorId::Anthropic)].dirty = true;
1977 save_to_path(&s, &path).unwrap();
1978
1979 let raw = std::fs::read_to_string(&path).unwrap();
1980 assert!(raw.starts_with("# keep\n"), "{raw}");
1981 assert!(raw.contains("[anthropic]\nenabled = false"), "{raw}");
1982 assert!(raw.contains("[grok]\nenabled = true"), "{raw}");
1983 assert!(!raw.contains("[zai]"), "{raw}");
1987 assert!(!raw.contains("[openrouter]"), "{raw}");
1988
1989 let reloaded = Config::load_from(&path).unwrap();
1990 assert!(reloaded.is_enabled(VendorId::Grok));
1991 assert!(!reloaded.is_enabled(VendorId::Anthropic));
1992 assert!(
1993 reloaded.is_enabled(VendorId::Zai),
1994 "untouched default stays on"
1995 );
1996 }
1997
1998 #[test]
1999 fn an_untouched_overlay_save_writes_no_provider_sections() {
2000 let (_dir, path) = temp_config(None);
2001 let mut s = blank_state(VendorId::Anthropic);
2002 s.primary_choices = vec![VendorId::Anthropic];
2003 save_to_path(&s, &path).unwrap();
2004 let raw = std::fs::read_to_string(&path).unwrap();
2005 assert!(!raw.contains("enabled ="), "{raw}");
2006 assert!(!raw.contains("[grok]"), "{raw}");
2007 }
2008
2009 #[test]
2012 fn an_explicit_provider_off_wins_over_a_pasted_credential() {
2013 let (_dir, path) = temp_config(None);
2014 let mut s = blank_state(VendorId::Zai);
2015 s.primary_choices = vec![VendorId::Zai];
2016 s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some("sk-zai"));
2017 s.keys[key_index(VendorId::Zai)].dirty = true;
2018 s.vendors[vendor_index(VendorId::Zai)].enabled = false;
2019 s.vendors[vendor_index(VendorId::Zai)].dirty = true;
2020 save_to_path(&s, &path).unwrap();
2021 let raw = std::fs::read_to_string(&path).unwrap();
2022 assert!(raw.contains("api_key = \"sk-zai\""), "{raw}");
2023 assert!(raw.contains("enabled = false"), "{raw}");
2024 assert!(!Config::load_from(&path).unwrap().is_enabled(VendorId::Zai));
2025 }
2026
2027 #[test]
2028 fn every_key_vendor_has_a_field() {
2029 for id in [
2031 VendorId::Zai,
2032 VendorId::Openrouter,
2033 VendorId::Deepseek,
2034 VendorId::Deepinfra,
2035 VendorId::Kilo,
2036 VendorId::Novita,
2037 VendorId::Synthetic,
2038 VendorId::Moonshot,
2039 VendorId::Grok,
2040 ] {
2041 assert!(
2042 KEY_VENDORS.iter().any(|kv| kv.id == id),
2043 "{id:?} has no key field"
2044 );
2045 }
2046 assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Anthropic));
2048 assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Openai));
2049 }
2050
2051 #[test]
2052 fn from_config_prefills_existing_keys() {
2053 let mut cfg = Config::default();
2054 cfg.kilo.api_key = Some("sk-kilo".into());
2055 let s = SettingsState::from_config(&cfg);
2056 assert_eq!(s.keys[key_index(VendorId::Kilo)].buf, "sk-kilo");
2057 assert!(!s.keys[key_index(VendorId::Kilo)].dirty);
2058 }
2059
2060 #[test]
2061 fn from_config_prefills_the_notification_fields() {
2062 let mut cfg = Config::default();
2063 cfg.notifications.enabled = false;
2064 cfg.notifications.threshold = 100;
2065 let s = SettingsState::from_config_with(&cfg, |_| false);
2066 assert!(!s.notify_enabled);
2067 assert!(!s.notify_enabled_dirty);
2068 assert_eq!(s.notify_threshold.buf, "100");
2069 assert!(!s.notify_threshold.dirty);
2070 }
2071
2072 #[test]
2073 fn notification_toggle_flips_on_left_right_and_space() {
2074 let mut s = blank_state(VendorId::Anthropic);
2075 s.focus = Focus::NotifyEnabled;
2076 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2077 assert!(!s.notify_enabled);
2078 assert!(s.notify_enabled_dirty);
2079 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
2080 assert!(s.notify_enabled);
2081 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
2082 assert!(!s.notify_enabled);
2083 handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
2085 assert!(!s.notify_enabled);
2086 }
2087
2088 #[test]
2089 fn threshold_edits_accept_digits_and_reject_everything_else() {
2090 let mut s = blank_state(VendorId::Anthropic);
2091 s.focus = Focus::NotifyThreshold;
2092 s.notify_threshold = KeyInput::default();
2094 for c in ['9', 'a', '.', '-', '→'] {
2095 handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
2096 }
2097 assert_eq!(s.notify_threshold.buf, "9");
2098 assert!(s.notify_threshold.dirty);
2099 handle_key(&mut s, KeyCode::Char('8'), KeyModifiers::NONE);
2100 assert_eq!(s.notify_threshold.buf, "98");
2101 handle_key(&mut s, KeyCode::Backspace, KeyModifiers::NONE);
2102 assert_eq!(s.notify_threshold.buf, "9");
2103 }
2104
2105 #[test]
2106 fn save_writes_the_notification_fields_and_round_trips() {
2107 let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
2108 let mut s = blank_state(VendorId::Anthropic);
2109 s.notify_enabled = false;
2110 s.notify_enabled_dirty = true;
2111 s.notify_threshold = KeyInput::default();
2113 for c in "90".chars() {
2114 s.notify_threshold.insert_char(c);
2115 }
2116 save_to_path(&s, &path).unwrap();
2117
2118 let raw = std::fs::read_to_string(&path).unwrap();
2119 assert!(raw.contains("[notifications]"), "{raw}");
2120 assert!(raw.contains("enabled = false"), "{raw}");
2121 assert!(raw.contains("threshold = 90"), "{raw}");
2122 let reloaded = Config::load_from(&path).unwrap();
2124 assert!(!reloaded.notifications.enabled);
2125 assert_eq!(reloaded.notifications.threshold, 90);
2126 }
2127
2128 #[test]
2129 fn save_leaves_an_untouched_notification_section_alone() {
2130 let (_dir, path) = temp_config(None);
2131 let s = blank_state(VendorId::Anthropic);
2132 save_to_path(&s, &path).unwrap();
2133 let raw = std::fs::read_to_string(&path).unwrap();
2134 assert!(!raw.contains("[notifications]"), "{raw}");
2135 }
2136
2137 #[test]
2138 fn save_rejects_an_out_of_range_threshold_without_writing() {
2139 let (_dir, path) = temp_config(None);
2140 let mut s = blank_state(VendorId::Anthropic);
2141 for c in "300".chars() {
2142 s.notify_threshold.insert_char(c);
2143 }
2144 let err = save_to_path(&s, &path).unwrap_err().to_string();
2145 assert!(
2146 err.contains("[notifications] threshold must be a whole number between 1 and 100"),
2147 "{err}"
2148 );
2149 assert!(!path.exists());
2151
2152 let (_dir, path) = temp_config(None);
2154 let mut s = blank_state(VendorId::Anthropic);
2155 s.notify_threshold = KeyInput::default();
2156 s.notify_threshold.dirty = true;
2157 assert!(save_to_path(&s, &path).is_err());
2158 assert!(!path.exists());
2159 }
2160
2161 #[test]
2162 fn copilot_has_no_editable_credential_field() {
2163 assert!(
2164 !KEY_VENDORS
2165 .iter()
2166 .any(|vendor| vendor.id == VendorId::Copilot)
2167 );
2168 }
2169
2170 #[test]
2173 fn a_key_vendor_with_its_env_var_exported_is_offered() {
2174 let cfg = Config::default();
2175 let env = cfg.api_key_env_for(VendorId::Ollama).to_string();
2176
2177 let without = SettingsState::from_config_with(&cfg, |_| false);
2178 assert!(!without.primary_choices.contains(&VendorId::Ollama));
2179
2180 let with = SettingsState::from_config_with(&cfg, |name| name == env);
2181 assert!(
2182 with.primary_choices.contains(&VendorId::Ollama),
2183 "a key vendor whose env var is exported must be selectable: {:?}",
2184 with.primary_choices
2185 );
2186 }
2187
2188 #[test]
2189 fn from_config_offers_enabled_vendors_only() {
2190 let cfg = Config::default();
2191 let s = SettingsState::from_config_with(&cfg, |_| false);
2194 let mut expected = cfg.enabled_vendors();
2195 expected.push(VendorId::Copilot);
2196 assert_eq!(s.primary_choices, expected);
2197 assert!(!s.primary_choices.contains(&VendorId::Grok));
2200 assert!(s.primary_choices.contains(&s.primary));
2201 assert!(s.primary_choices.contains(&VendorId::Copilot));
2202 }
2203
2204 #[test]
2205 fn from_config_falls_back_when_configured_primary_is_disabled() {
2206 let mut cfg = Config::default();
2209 cfg.ui.primary = Some(VendorId::Grok);
2210 let s = SettingsState::from_config(&cfg);
2211 assert_ne!(s.primary, VendorId::Grok);
2212 assert_eq!(Some(s.primary), cfg.enabled_vendors().first().copied());
2213 }
2214
2215 #[test]
2220 fn an_unrelated_save_leaves_a_disabled_primary_switched_off() {
2221 let (_dir, path) = temp_config(Some(
2222 "[ui]\nprimary = \"openrouter\"\n[openrouter]\nenabled = false\napi_key = \"test\"\n",
2223 ));
2224 let cfg = Config::load_from(&path).unwrap();
2225 let mut s = SettingsState::from_config_with(&cfg, |_| false);
2226 assert!(s.primary_choices.contains(&VendorId::Openrouter));
2227 assert_ne!(s.primary, VendorId::Openrouter);
2228
2229 s.notify_enabled = !s.notify_enabled;
2230 s.notify_enabled_dirty = true;
2231 save_to_path(&s, &path).unwrap();
2232
2233 let saved = Config::load_from(&path).unwrap();
2234 assert!(!saved.is_enabled(VendorId::Openrouter));
2235 }
2236
2237 #[test]
2238 fn key_input_insert_backspace_arrow() {
2239 let mut k = KeyInput::default();
2240 k.insert_char('a');
2241 k.insert_char('b');
2242 k.insert_char('c');
2243 assert_eq!(k.buf, "abc");
2244 assert_eq!(k.cursor, 3);
2245 assert!(k.dirty);
2246 k.move_left();
2247 k.move_left();
2248 assert_eq!(k.cursor, 1);
2249 k.insert_char('x');
2250 assert_eq!(k.buf, "axbc");
2251 assert_eq!(k.cursor, 2);
2252 k.backspace();
2253 assert_eq!(k.buf, "abc");
2254 assert_eq!(k.cursor, 1);
2255 }
2256
2257 #[test]
2258 fn key_input_masks_by_default_reveals_on_toggle() {
2259 let mut k = KeyInput::default();
2260 for c in "secret-key".chars() {
2261 k.insert_char(c);
2262 }
2263 assert_eq!(k.display(), "•".repeat(10));
2264 k.toggle_reveal();
2265 assert_eq!(k.display(), "secret-key");
2266 }
2267
2268 #[test]
2269 fn key_input_handles_unicode() {
2270 let mut k = KeyInput::default();
2271 k.insert_char('a');
2272 k.insert_char('→');
2273 k.insert_char('b');
2274 assert_eq!(k.buf, "a→b");
2275 assert_eq!(k.cursor, 3);
2276 k.move_left();
2277 k.backspace();
2278 assert_eq!(k.buf, "ab");
2279 }
2280
2281 #[test]
2282 fn value_text_shows_cursor_and_empty_states() {
2283 let mut k = KeyInput::default();
2284 assert_eq!(value_text(&k, false), "(empty)");
2285 assert_eq!(value_text(&k, true), "‸");
2286 k.insert_char('a');
2287 k.insert_char('b');
2288 assert_eq!(value_text(&k, true), "••‸");
2290 assert_eq!(value_text(&k, false), "••");
2291 }
2292
2293 #[test]
2294 fn save_writes_key_and_enables_vendor() {
2295 let (_dir, path) = temp_config(None);
2296 let mut s = blank_state(VendorId::Kilo);
2297 s.keys[key_index(VendorId::Kilo)] = KeyInput::from_config(Some("sk-kilo"));
2298 s.keys[key_index(VendorId::Kilo)].dirty = true;
2299 save_to_path(&s, &path).unwrap();
2300 let raw = std::fs::read_to_string(&path).unwrap();
2301 assert!(raw.contains("primary = \"kilo\""));
2302 assert!(raw.contains("[kilo]"));
2303 assert!(raw.contains("api_key = \"sk-kilo\""));
2304 assert!(raw.contains("enabled = true"));
2305 }
2306
2307 #[test]
2308 fn save_writes_minimal_toml_when_starting_empty() {
2309 let (_dir, path) = temp_config(None);
2310 let s = state_with("zk", "ok", VendorId::Zai);
2311 save_to_path(&s, &path).unwrap();
2312 let raw = std::fs::read_to_string(&path).unwrap();
2313 assert!(raw.contains("primary = \"zai\""));
2314 assert!(raw.contains("[zai]"));
2315 assert!(raw.contains("api_key = \"zk\""));
2316 assert!(raw.contains("[openrouter]"));
2317 assert!(raw.contains("api_key = \"ok\""));
2318 }
2319
2320 #[test]
2321 fn save_preserves_existing_comments_and_unrelated_fields() {
2322 let (_dir, path) = temp_config(Some(
2323 r##"# my comment
2324[ui]
2325# pre-existing comment
2326primary = "anthropic"
2327
2328[zai]
2329enabled = true
2330api_key_env = "ZAI_API_KEY"
2331# tier comment
2332plan_tier = "pro"
2333
2334[openrouter]
2335enabled = true
2336api_key_env = "OPENROUTER_API_KEY"
2337
2338[[openrouter.accounts]]
2339label = "work"
2340api_key_env = "OPENROUTER_WORK_API_KEY"
2341"##,
2342 ));
2343
2344 let s = state_with("zk2", "ok2", VendorId::Openrouter);
2345 save_to_path(&s, &path).unwrap();
2346
2347 let raw = std::fs::read_to_string(&path).unwrap();
2348 assert!(raw.contains("# my comment"));
2349 assert!(raw.contains("# pre-existing comment"));
2350 assert!(raw.contains("# tier comment"));
2351 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
2352 assert!(raw.contains("[[openrouter.accounts]]"));
2353 assert!(raw.contains("api_key_env = \"OPENROUTER_WORK_API_KEY\""));
2354 assert!(raw.contains("plan_tier = \"pro\""));
2355 assert!(raw.contains("primary = \"openrouter\""));
2356 assert!(raw.contains("api_key = \"zk2\""));
2357 assert!(raw.contains("api_key = \"ok2\""));
2358 }
2359
2360 #[test]
2361 fn save_refuses_to_replace_an_unreadable_existing_config() {
2362 let (_dir, path) = temp_config(None);
2363 let original = [0xff, 0xfe, 0xfd];
2364 std::fs::write(&path, original).unwrap();
2365 let state = state_with("new-secret", "", VendorId::Zai);
2366
2367 assert!(save_to_path(&state, &path).is_err());
2368 assert_eq!(std::fs::read(&path).unwrap(), original);
2369 }
2370
2371 #[test]
2372 fn save_does_not_write_empty_key_when_dirty_but_blank() {
2373 let (_dir, path) = temp_config(None);
2374 let mut s = blank_state(VendorId::Anthropic);
2375 for k in &mut s.keys {
2377 k.dirty = true;
2378 }
2379 save_to_path(&s, &path).unwrap();
2380 let raw = std::fs::read_to_string(&path).unwrap();
2381 assert!(!raw.contains("api_key ="));
2382 }
2383
2384 #[test]
2385 #[cfg(unix)]
2386 fn save_chmods_to_600() {
2387 use std::os::unix::fs::PermissionsExt;
2388 let (_dir, path) = temp_config(None);
2389 let s = state_with("zk", "ok", VendorId::Zai);
2390 save_to_path(&s, &path).unwrap();
2391 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2392 assert_eq!(mode & 0o777, 0o600);
2393 }
2394
2395 #[test]
2396 fn tab_cycles_focus_from_primary_to_first_key() {
2397 let mut s = blank_state(VendorId::Anthropic);
2398 assert_eq!(
2399 handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
2400 Action::Continue
2401 );
2402 assert_eq!(s.focus, Focus::Key(0));
2403 assert_eq!(
2404 handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
2405 Action::Continue
2406 );
2407 assert_eq!(s.focus, Focus::Primary);
2408 }
2409
2410 #[test]
2411 fn esc_closes_without_saving() {
2412 let mut s = blank_state(VendorId::Anthropic);
2413 assert_eq!(
2414 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
2415 Action::Close
2416 );
2417 }
2418
2419 #[test]
2420 fn left_right_cycles_primary_vendor() {
2421 let mut s = blank_state(VendorId::Anthropic);
2423 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2424 assert_eq!(s.primary, VendorId::AnthropicApi);
2425 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2426 assert_eq!(s.primary, VendorId::Openai);
2427 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
2428 assert_eq!(s.primary, VendorId::AnthropicApi);
2429 }
2430
2431 #[test]
2432 fn left_right_offers_enabled_vendors_only() {
2433 let mut s = blank_state(VendorId::Anthropic);
2435 s.primary_choices = vec![VendorId::Anthropic, VendorId::Grok];
2436 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2437 assert_eq!(s.primary, VendorId::Grok);
2438 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2440 assert_eq!(s.primary, VendorId::Anthropic);
2441 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
2442 assert_eq!(s.primary, VendorId::Grok);
2443 }
2444
2445 #[test]
2446 fn no_enabled_vendors_leaves_primary_selector_inert() {
2447 let mut s = blank_state(VendorId::Anthropic);
2448 s.primary_choices = vec![];
2449 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2450 assert_eq!(s.primary, VendorId::Anthropic);
2451 }
2452
2453 #[test]
2454 fn disabled_copilot_is_offered_but_not_shown_as_the_current_primary() {
2455 let mut cfg = Config::default();
2456 cfg.ui.primary = Some(VendorId::Copilot);
2457 let state = SettingsState::from_config(&cfg);
2458
2459 assert!(state.primary_choices.contains(&VendorId::Copilot));
2460 assert_eq!(state.primary, VendorId::Anthropic);
2461 }
2462
2463 #[test]
2464 fn save_does_not_write_a_disabled_primary() {
2465 let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
2468 let mut s = state_with("zk", "ok", VendorId::Grok);
2469 s.primary_choices = vec![VendorId::Anthropic];
2470 save_to_path(&s, &path).unwrap();
2471 let raw = std::fs::read_to_string(&path).unwrap();
2472 assert!(raw.contains("primary = \"anthropic\""));
2473 assert!(!raw.contains("primary = \"grok\""));
2474 assert!(raw.contains("zk"));
2476 }
2477
2478 #[test]
2479 fn save_removes_an_inline_key_the_user_cleared() {
2480 let (_dir, path) = temp_config(Some(
2483 "[zai]\nenabled = true\napi_key = \"old-secret\"\nplan_tier = \"pro\"\n",
2484 ));
2485 let mut s = blank_state(VendorId::Zai);
2486 s.primary_choices = vec![VendorId::Zai];
2487 s.keys[key_index(VendorId::Zai)] = KeyInput::default();
2488 s.keys[key_index(VendorId::Zai)].dirty = true;
2489 save_to_path(&s, &path).unwrap();
2490 let raw = std::fs::read_to_string(&path).unwrap();
2491 assert!(!raw.contains("old-secret"));
2492 assert!(!raw.contains("api_key"));
2493 assert!(raw.contains("plan_tier = \"pro\""));
2495 }
2496
2497 #[test]
2498 fn untouched_key_field_is_left_alone() {
2499 let (_dir, path) = temp_config(Some("[zai]\napi_key = \"keep-me\"\n"));
2501 let mut s = blank_state(VendorId::Zai);
2502 s.primary_choices = vec![VendorId::Zai];
2503 save_to_path(&s, &path).unwrap();
2504 let raw = std::fs::read_to_string(&path).unwrap();
2505 assert!(raw.contains("keep-me"));
2506 }
2507
2508 #[test]
2509 fn typing_edits_the_focused_key_only() {
2510 let mut s = blank_state(VendorId::Anthropic);
2511 s.focus = Focus::Key(key_index(VendorId::Grok));
2512 for c in "xai-abc".chars() {
2513 handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
2514 }
2515 assert_eq!(s.keys[key_index(VendorId::Grok)].buf, "xai-abc");
2516 assert!(s.keys[key_index(VendorId::Grok)].dirty);
2517 assert!(s.keys[key_index(VendorId::Zai)].buf.is_empty());
2519 }
2520
2521 #[test]
2522 fn ctrl_v_toggles_reveal_on_focused_key_field() {
2523 let mut s = blank_state(VendorId::Anthropic);
2524 let zi = key_index(VendorId::Zai);
2525 s.focus = Focus::Key(zi);
2526 s.keys[zi] = KeyInput::from_config(Some("secret"));
2527 assert!(!s.keys[zi].revealed);
2528 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
2529 assert!(s.keys[zi].revealed);
2530 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
2531 assert!(!s.keys[zi].revealed);
2532 }
2533
2534 #[test]
2535 fn control_chorded_chars_do_not_type_into_fields() {
2536 let mut s = blank_state(VendorId::Anthropic);
2537 s.focus = Focus::Key(0);
2538 handle_key(&mut s, KeyCode::Char('a'), KeyModifiers::CONTROL);
2540 assert!(s.keys[0].buf.is_empty());
2541 assert!(!s.keys[0].dirty);
2542 assert_eq!(
2544 handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
2545 Action::Quit
2546 );
2547 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
2549 assert_eq!(s.keys[0].buf, "x");
2550 }
2551
2552 #[test]
2553 fn ctrl_v_on_non_key_focus_is_noop() {
2554 let mut s = blank_state(VendorId::Anthropic);
2555 s.focus = Focus::Primary;
2556 assert_eq!(
2558 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL),
2559 Action::Continue
2560 );
2561 }
2562
2563 fn state_focused_on_zai() -> SettingsState {
2564 let mut state = blank_state(VendorId::Anthropic);
2565 state.focus = Focus::Key(key_index(VendorId::Zai));
2566 state
2567 }
2568
2569 #[test]
2570 fn handle_key_ctrl_c_quits_without_typing_into_key_field() {
2571 let mut s = state_focused_on_zai();
2572 let zi = key_index(VendorId::Zai);
2573 assert_eq!(
2574 handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
2575 Action::Quit
2576 );
2577 assert!(s.keys[zi].buf.is_empty());
2578 assert!(!s.keys[zi].dirty);
2580 }
2581
2582 #[test]
2583 fn handle_key_alt_chord_does_not_type_into_key_field() {
2584 let mut s = state_focused_on_zai();
2585 let zi = key_index(VendorId::Zai);
2586 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::ALT);
2587 assert!(s.keys[zi].buf.is_empty());
2588 assert!(!s.keys[zi].dirty);
2589 }
2590
2591 #[test]
2592 fn handle_key_platform_modifier_chords_do_not_type_into_key_field() {
2593 for modifier in [KeyModifiers::SUPER, KeyModifiers::HYPER, KeyModifiers::META] {
2594 let mut s = state_focused_on_zai();
2595 let zi = key_index(VendorId::Zai);
2596 handle_key(&mut s, KeyCode::Char('x'), modifier);
2597 assert!(s.keys[zi].buf.is_empty(), "modifier {modifier:?}");
2598 assert!(!s.keys[zi].dirty, "modifier {modifier:?}");
2599 }
2600 }
2601
2602 #[test]
2603 fn handle_key_shift_still_types_uppercase() {
2604 let mut s = state_focused_on_zai();
2605 let zi = key_index(VendorId::Zai);
2606 handle_key(&mut s, KeyCode::Char('A'), KeyModifiers::SHIFT);
2607 assert_eq!(s.keys[zi].buf, "A");
2608 assert!(s.keys[zi].dirty);
2609 }
2610
2611 #[test]
2612 fn handle_key_plain_space_still_cycles_primary_vendor() {
2613 let mut s = blank_state(VendorId::Anthropic);
2614 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
2615 assert_eq!(s.primary, VendorId::AnthropicApi);
2616 }
2617
2618 #[test]
2619 fn handle_key_ctrl_s_attempts_save_from_any_field() {
2620 let (_dir, path) = temp_config(None);
2621 let s = state_with("zk", "ok", VendorId::Zai);
2622 save_to_path(&s, &path).unwrap();
2623 let raw = std::fs::read_to_string(&path).unwrap();
2624 assert!(raw.contains("api_key = \"zk\""));
2625 }
2626 #[test]
2627 fn save_to_path_writes_kimi_key_when_dirty() {
2628 let (_dir, path) = temp_config(None);
2629 let mut s = blank_state(VendorId::Anthropic);
2630 let kimi = key_index(VendorId::Kimi);
2631 s.keys[kimi] = KeyInput::from_config(Some("kk"));
2632 s.keys[kimi].dirty = true;
2633 save_to_path(&s, &path).unwrap();
2634 let raw = std::fs::read_to_string(&path).unwrap();
2635 assert!(raw.contains("[kimi]"));
2636 assert!(raw.contains("api_key = \"kk\""));
2637 }
2638
2639 #[test]
2640 fn settings_save_uses_the_same_config_path_as_load() {
2641 assert_eq!(
2642 default_config_path().unwrap(),
2643 crate::config::resolved_path().unwrap()
2644 );
2645 }
2646
2647 #[test]
2648 fn native_snapshot_reports_key_state_without_serializing_secrets() {
2649 let mut cfg = Config::default();
2650 cfg.zai.api_key = Some("never-leak-this-key".into());
2651 cfg.zai.api_key_env = "CUSTOM_ZAI_KEY".into();
2652 let raw = settings_snapshot_json_with(&cfg, |name| name == "CUSTOM_ZAI_KEY").unwrap();
2653 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
2654
2655 assert_eq!(parsed["schema_version"], 1);
2656 assert_eq!(parsed["primary"], "anthropic");
2657 let zai = parsed["keys"]
2658 .as_array()
2659 .unwrap()
2660 .iter()
2661 .find(|row| row["id"] == "zai")
2662 .unwrap();
2663 assert_eq!(zai["configured"], true);
2664 assert_eq!(zai["inline_configured"], true);
2665 assert_eq!(zai["environment_configured"], true);
2666 assert_eq!(zai["environment"], "CUSTOM_ZAI_KEY");
2667 assert!(!raw.contains("never-leak-this-key"));
2668 assert!(parsed.get("api_key").is_none());
2669 }
2670
2671 #[test]
2672 fn native_snapshot_offers_copilot_primary_without_a_token_field() {
2673 let cfg = Config::default();
2674 let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
2675 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
2676 assert!(
2677 parsed["primary_choices"]
2678 .as_array()
2679 .unwrap()
2680 .iter()
2681 .any(|row| row["id"] == "copilot")
2682 );
2683 assert!(
2684 !parsed["keys"]
2685 .as_array()
2686 .unwrap()
2687 .iter()
2688 .any(|row| row["id"] == "copilot")
2689 );
2690 }
2691
2692 #[test]
2693 fn native_key_only_patch_does_not_require_or_replace_primary() {
2694 let cfg = Config::default();
2695 let original_primary = SettingsState::from_config(&cfg).primary;
2696 let request = serde_json::json!({
2697 "schema_version": 1,
2698 "keys": {"kimi": {"action": "set", "value": "new-kimi-key"}}
2699 });
2700
2701 let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
2702 assert_eq!(state.primary, original_primary);
2703 let kimi_index = KEY_VENDORS
2704 .iter()
2705 .position(|vendor| vendor.id == VendorId::Kimi)
2706 .unwrap();
2707 assert!(state.keys[kimi_index].dirty);
2708 assert_eq!(state.keys[kimi_index].buf, "new-kimi-key");
2709 }
2710
2711 #[test]
2712 fn native_patch_reuses_tui_persistence_and_preserves_existing_config() {
2713 let (_dir, path) = temp_config(Some(
2714 r#"# keep this comment
2715[ui]
2716primary = "anthropic"
2717
2718[zai]
2719enabled = true
2720api_key_env = "ZAI_API_KEY"
2721plan_tier = "pro"
2722
2723[openrouter]
2724enabled = true
2725"#,
2726 ));
2727 let cfg = Config::load_from(&path).unwrap();
2728 let request = serde_json::json!({
2729 "schema_version": 1,
2730 "primary": "openrouter",
2731 "keys": {
2732 "zai": {"action": "set", "value": "new-zai-key"}
2733 }
2734 });
2735
2736 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
2737 let raw = std::fs::read_to_string(&path).unwrap();
2738 assert!(raw.contains("# keep this comment"));
2739 assert!(raw.contains("plan_tier = \"pro\""));
2740 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
2741 assert!(raw.contains("primary = \"openrouter\""));
2742 assert!(raw.contains("api_key = \"new-zai-key\""));
2743 }
2744
2745 #[test]
2746 fn native_patch_distinguishes_clear_from_unchanged() {
2747 let (_dir, path) = temp_config(Some(
2748 "[zai]\nenabled = true\napi_key = \"remove-me\"\n\
2749 [openrouter]\nenabled = true\napi_key = \"keep-me\"\n",
2750 ));
2751 let cfg = Config::load_from(&path).unwrap();
2752 let request = serde_json::json!({
2753 "schema_version": 1,
2754 "primary": "zai",
2755 "keys": {"zai": {"action": "clear"}}
2756 });
2757
2758 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
2759 let raw = std::fs::read_to_string(&path).unwrap();
2760 assert!(!raw.contains("remove-me"));
2761 assert!(raw.contains("keep-me"));
2762 }
2763
2764 #[test]
2765 fn native_primary_selection_enables_copilot_without_writing_a_token() {
2766 let (_dir, path) = temp_config(Some(
2767 "[copilot]\nenabled = false\ntoken = \"legacy-value\"\ntoken_env = \"OLD_TOKEN\"\n",
2768 ));
2769 let cfg = Config::load_from(&path).unwrap();
2770 let select = serde_json::json!({
2771 "schema_version": 1,
2772 "primary": "copilot"
2773 });
2774 apply_settings_json_to_path(&cfg, &select.to_string(), &path).unwrap();
2775 let raw = std::fs::read_to_string(&path).unwrap();
2776 assert!(raw.contains("enabled = true"));
2777 assert!(raw.contains("primary = \"copilot\""));
2778 assert!(!raw.contains("token ="));
2779 assert!(!raw.contains("token_env ="));
2780 }
2781
2782 #[test]
2783 fn native_patch_errors_never_echo_key_values() {
2784 let raw = serde_json::json!({
2785 "schema_version": 1,
2786 "primary": "anthropic",
2787 "keys": {
2788 "zai": {"action": "set", "value": "secret\nwith-control"}
2789 }
2790 })
2791 .to_string();
2792 let error = state_from_apply_request(&Config::default(), &raw)
2793 .unwrap_err()
2794 .to_string();
2795 assert!(!error.contains("secret"));
2796 assert!(error.contains("control characters"));
2797 }
2798
2799 #[test]
2800 fn native_patch_input_is_bounded_before_json_parsing() {
2801 let oversized = vec![b'x'; MAX_SETTINGS_REQUEST_BYTES as usize + 1];
2802 let error = read_settings_request(std::io::Cursor::new(oversized))
2803 .unwrap_err()
2804 .to_string();
2805 assert!(error.contains("exceeds"));
2806 }
2807
2808 #[test]
2811 fn native_snapshot_lists_every_provider_with_its_enabled_state() {
2812 let mut cfg = Config::default();
2813 cfg.grok.enabled = true;
2814 let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
2815 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
2816
2817 let vendors = parsed["vendors"].as_array().unwrap();
2818 assert_eq!(vendors.len(), VendorId::all().len());
2819 let by_id = |slug: &str| {
2820 vendors
2821 .iter()
2822 .find(|row| row["id"] == slug)
2823 .unwrap_or_else(|| panic!("no {slug} row: {vendors:?}"))
2824 .clone()
2825 };
2826 assert_eq!(by_id("anthropic")["enabled"], true);
2827 assert_eq!(by_id("grok")["enabled"], true);
2828 assert_eq!(by_id("kimi")["enabled"], false);
2829 assert_eq!(by_id("anthropic")["label"], "Claude");
2831 assert_eq!(by_id("opencode-go")["label"], "OpenCode Go");
2832 }
2833
2834 #[test]
2835 fn native_patch_toggles_providers_and_preserves_the_rest_of_the_file() {
2836 let (_dir, path) = temp_config(Some("# keep\n[zai]\nenabled = true\napi_key = \"k\"\n"));
2837 let cfg = Config::load_from(&path).unwrap();
2838 let request = serde_json::json!({
2839 "schema_version": 1,
2840 "vendors": {"grok": true, "zai": false}
2841 });
2842
2843 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
2844
2845 let raw = std::fs::read_to_string(&path).unwrap();
2846 assert!(raw.starts_with("# keep\n"), "{raw}");
2847 assert!(raw.contains("api_key = \"k\""), "{raw}");
2848 let reloaded = Config::load_from(&path).unwrap();
2849 assert!(reloaded.is_enabled(VendorId::Grok));
2850 assert!(!reloaded.is_enabled(VendorId::Zai));
2851 }
2852
2853 #[test]
2854 fn native_patch_refuses_an_unknown_provider_slug_without_writing() {
2855 let (_dir, path) = temp_config(Some("[zai]\nenabled = true\n"));
2856 let cfg = Config::load_from(&path).unwrap();
2857 for bad in ["", "mytool", "not-a-vendor"] {
2858 let request = serde_json::json!({
2859 "schema_version": 1,
2860 "vendors": {bad: true}
2861 });
2862 let error = state_from_apply_request(&cfg, &request.to_string())
2863 .unwrap_err()
2864 .to_string();
2865 assert!(error.contains("unknown provider"), "{bad}: {error}");
2866 }
2867 assert_eq!(
2869 std::fs::read_to_string(&path).unwrap(),
2870 "[zai]\nenabled = true\n"
2871 );
2872 }
2873
2874 #[test]
2876 fn native_vendor_toggle_alone_is_a_valid_patch() {
2877 let cfg = Config::default();
2878 let request = serde_json::json!({
2879 "schema_version": 1,
2880 "vendors": {"kimi": true}
2881 });
2882 let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
2883 let kimi = vendor_index(VendorId::Kimi);
2884 assert_eq!(
2885 state.vendors[kimi],
2886 ProviderSwitch {
2887 enabled: true,
2888 dirty: true
2889 }
2890 );
2891 }
2892
2893 #[test]
2896 fn body_layout_positions_every_focusable_row_without_overlap() {
2897 let keys = 5;
2898 let vendors = 7;
2899 let layout = body_layout(keys, vendors);
2900 assert_eq!(layout.primary, 1);
2902 assert_eq!(layout.first_key, 4);
2903 assert_eq!(layout.first_vendor, layout.first_key + keys + 2);
2905 assert_eq!(layout.notify_enabled, layout.first_vendor + vendors + 2);
2906 assert_eq!(layout.notify_threshold, layout.notify_enabled + 1);
2907 assert_eq!(layout.save, layout.notify_threshold + 2);
2908 assert_eq!(layout.rows, layout.save + 1);
2909 let mut rows = vec![
2911 layout.primary,
2912 layout.notify_enabled,
2913 layout.notify_threshold,
2914 layout.save,
2915 ];
2916 rows.extend(layout.first_key..layout.first_key + keys);
2917 rows.extend(layout.first_vendor..layout.first_vendor + vendors);
2918 rows.sort_unstable();
2919 let mut unique = rows.clone();
2920 unique.dedup();
2921 assert_eq!(rows, unique);
2922 assert!(rows.iter().all(|row| *row < layout.rows));
2923 }
2924
2925 #[test]
2926 fn focus_line_matches_the_body_layout_for_every_focus_variant() {
2927 let state = blank_state(VendorId::Anthropic);
2928 let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
2929 let cases = [
2930 (Focus::Primary, layout.primary),
2931 (Focus::Key(0), layout.first_key),
2932 (
2933 Focus::Key(KEY_VENDORS.len() - 1),
2934 layout.first_key + KEY_VENDORS.len() - 1,
2935 ),
2936 (Focus::Vendor(0), layout.first_vendor),
2937 (
2938 Focus::Vendor(state.vendors.len() - 1),
2939 layout.first_vendor + state.vendors.len() - 1,
2940 ),
2941 (Focus::NotifyEnabled, layout.notify_enabled),
2942 (Focus::NotifyThreshold, layout.notify_threshold),
2943 (Focus::Save, layout.save),
2944 ];
2945 for (focus, want) in cases {
2946 let mut s = state.clone();
2947 s.focus = focus;
2948 assert_eq!(focus_line(&s), want, "{focus:?}");
2949 }
2950 }
2951
2952 #[test]
2953 fn follow_focus_scroll_sticks_jumps_and_clamps() {
2954 assert_eq!(follow_focus_scroll(3, 50, 10, 0), 0);
2956 assert_eq!(follow_focus_scroll(9, 50, 10, 0), 0);
2957 assert_eq!(follow_focus_scroll(10, 50, 10, 0), 1);
2959 assert_eq!(follow_focus_scroll(25, 50, 10, 5), 16);
2960 assert_eq!(follow_focus_scroll(2, 50, 10, 16), 2);
2962 assert_eq!(follow_focus_scroll(49, 50, 10, 0), 40);
2964 assert_eq!(follow_focus_scroll(60, 50, 10, 0), 40);
2965 assert_eq!(follow_focus_scroll(5, 50, 0, 3), 0);
2967 assert_eq!(follow_focus_scroll(5, 0, 10, 3), 0);
2968 }
2969
2970 #[test]
2973 fn render_records_clickable_hint_links_for_the_focused_control() {
2974 use ratatui::Terminal;
2975 use ratatui::backend::TestBackend;
2976
2977 let theme = crate::theme::Theme::default();
2978 let mut state = blank_state(VendorId::Anthropic);
2979 state.focus = Focus::Vendor(vendor_index(VendorId::Grok));
2980 let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
2981 let mut hits = Vec::new();
2982 terminal
2983 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
2984 .unwrap();
2985
2986 let hint_keys: Vec<(KeyCode, KeyModifiers)> = hits
2987 .iter()
2988 .filter_map(|(row, _)| match row {
2989 SettingsRow::HintKey(code, mods) => Some((*code, *mods)),
2990 _ => None,
2991 })
2992 .collect();
2993 assert_eq!(
2996 hint_keys,
2997 vec![
2998 (KeyCode::Char(' '), KeyModifiers::NONE),
2999 (KeyCode::Char('s'), KeyModifiers::CONTROL),
3000 (KeyCode::Esc, KeyModifiers::NONE),
3001 ]
3002 );
3003 let ys: Vec<u16> = hits
3005 .iter()
3006 .filter(|(row, _)| matches!(row, SettingsRow::HintKey(..)))
3007 .map(|(_, rect)| rect.y)
3008 .collect();
3009 assert!(ys.windows(2).all(|pair| pair[0] == pair[1]));
3010
3011 let mut state = blank_state(VendorId::Anthropic);
3014 state.focus = Focus::Primary;
3015 let mut hits = Vec::new();
3016 terminal
3017 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3018 .unwrap();
3019 let hint_keys: Vec<(KeyCode, KeyModifiers)> = hits
3020 .iter()
3021 .filter_map(|(row, _)| match row {
3022 SettingsRow::HintKey(code, mods) => Some((*code, *mods)),
3023 _ => None,
3024 })
3025 .collect();
3026 assert!(hint_keys.contains(&(KeyCode::Right, KeyModifiers::NONE)));
3027 assert!(!hint_keys.contains(&(KeyCode::Char(' '), KeyModifiers::NONE)));
3028 }
3029
3030 #[test]
3034 fn render_records_switch_cells_right_of_the_labels() {
3035 use ratatui::Terminal;
3036 use ratatui::backend::TestBackend;
3037
3038 let theme = crate::theme::Theme::default();
3039 let mut state = blank_state(VendorId::Anthropic);
3040 state.focus = Focus::Vendor(0);
3041 let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3042 let mut hits = Vec::new();
3043 terminal
3044 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3045 .unwrap();
3046
3047 let focus_of = |i: usize| {
3048 hits.iter().find_map(|(row, rect)| match row {
3049 SettingsRow::Focus(Focus::Vendor(v)) if *v == i => Some(*rect),
3050 _ => None,
3051 })
3052 };
3053 for (i, id) in VendorId::all().iter().enumerate() {
3054 let focus_rect = focus_of(i).unwrap_or_else(|| panic!("vendor {i} has no focus row"));
3055 let switch = hits
3056 .iter()
3057 .find_map(|(row, rect)| match row {
3058 SettingsRow::Switch(Focus::Vendor(v), KeyCode::Char(' '), _) if *v == i => {
3059 Some(*rect)
3060 }
3061 _ => None,
3062 })
3063 .unwrap_or_else(|| panic!("vendor {i} has no switch cell"));
3064 let expected_x =
3069 5 + crate::display::text_width(&format!("{:<11}", id.display_name())) as u16;
3070 let value_len = if state.vendors[i].enabled { 2 } else { 3 }; let focused = state.focus == Focus::Vendor(i);
3072 assert_eq!(switch.y, focus_rect.y);
3073 assert_eq!(switch.x, focus_rect.x + expected_x);
3074 assert_eq!(
3075 switch.width,
3076 (if focused { 6 } else { 2 }) + value_len,
3077 "rendered value segment"
3078 );
3079 assert!(switch.x + switch.width <= focus_rect.x + focus_rect.width);
3080 }
3081 assert!(hits.iter().any(|(row, _)| matches!(
3083 row,
3084 SettingsRow::Switch(Focus::NotifyEnabled, KeyCode::Char(' '), _)
3085 )));
3086 }
3087
3088 #[test]
3091 fn render_records_primary_arrow_cells_when_focused() {
3092 use ratatui::Terminal;
3093 use ratatui::backend::TestBackend;
3094
3095 let theme = crate::theme::Theme::default();
3096 let mut state = blank_state(VendorId::Anthropic);
3097 state.focus = Focus::Primary;
3098 let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3099 let mut hits = Vec::new();
3100 terminal
3101 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3102 .unwrap();
3103
3104 let focus_rect = hits
3105 .iter()
3106 .find_map(|(row, rect)| match row {
3107 SettingsRow::Focus(Focus::Primary) => Some(*rect),
3108 _ => None,
3109 })
3110 .expect("primary row has a focus target");
3111 let prev = hits.iter().find_map(|(row, rect)| match row {
3112 SettingsRow::Switch(Focus::Primary, KeyCode::Left, _) => Some(*rect),
3113 _ => None,
3114 });
3115 let next = hits.iter().find_map(|(row, rect)| match row {
3116 SettingsRow::Switch(Focus::Primary, KeyCode::Right, _) => Some(*rect),
3117 _ => None,
3118 });
3119 let prev = prev.expect("focused primary has a ◀ cell");
3120 let next = next.expect("focused primary has a ▶ cell");
3121 assert_eq!(prev.x, focus_rect.x + 5);
3122 assert_eq!(prev.width, 2);
3123 assert!(next.x > prev.x + 2, "▶ sits right of the vendor name");
3124 assert_eq!(next.width, 2);
3125 assert_eq!(prev.y, next.y);
3126
3127 let mut state = blank_state(VendorId::Anthropic);
3130 state.focus = Focus::Vendor(0);
3131 let mut unfocused_hits = Vec::new();
3132 terminal
3133 .draw(|f| render(f, f.area(), &mut state, &theme, &mut unfocused_hits))
3134 .unwrap();
3135 assert!(
3136 !unfocused_hits
3137 .iter()
3138 .any(|(row, _)| matches!(row, SettingsRow::Switch(Focus::Primary, _, _)))
3139 );
3140 }
3141
3142 #[test]
3146 fn primary_arrow_cells_sit_at_fixed_columns() {
3147 use ratatui::Terminal;
3148 use ratatui::backend::TestBackend;
3149
3150 let theme = crate::theme::Theme::default();
3151 let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3152 let mut arrow_x = |primary: VendorId| {
3153 let mut state = blank_state(primary);
3154 state.focus = Focus::Primary;
3155 let mut hits = Vec::new();
3156 terminal
3157 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3158 .unwrap();
3159 hits.iter()
3160 .find_map(|(row, rect)| match row {
3161 SettingsRow::Switch(Focus::Primary, KeyCode::Right, _) => Some(rect.x),
3162 _ => None,
3163 })
3164 .expect("focused primary has a ▶ cell")
3165 };
3166 assert_eq!(arrow_x(VendorId::Anthropic), arrow_x(VendorId::Copilot));
3167 }
3168
3169 #[test]
3173 fn picker_key_handling_selects_and_closes() {
3174 let mut s = blank_state(VendorId::Anthropic);
3175 s.focus = Focus::Primary;
3176 s.picker = Some(PrimaryPicker {
3177 cursor: 0,
3178 scroll: 0,
3179 });
3180 let count = s.primary_choices.len();
3181 assert!(count > 1);
3182
3183 assert_eq!(
3184 handle_key(&mut s, KeyCode::Down, KeyModifiers::NONE),
3185 Action::Continue
3186 );
3187 assert_eq!(s.picker.as_ref().unwrap().cursor, 1);
3188 handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
3190 handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
3191 assert_eq!(s.picker.as_ref().unwrap().cursor, count - 1);
3192
3193 let expected = s.primary_choices[s.picker.as_ref().unwrap().cursor];
3195 assert_eq!(
3196 handle_key(&mut s, KeyCode::Enter, KeyModifiers::NONE),
3197 Action::Continue
3198 );
3199 assert_eq!(s.primary, expected);
3200 assert!(s.picker.is_none());
3201
3202 s.picker = Some(PrimaryPicker {
3205 cursor: 2,
3206 scroll: 0,
3207 });
3208 assert_eq!(
3209 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
3210 Action::Continue
3211 );
3212 assert!(s.picker.is_none());
3213 assert_eq!(
3214 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
3215 Action::Close
3216 );
3217 }
3218
3219 #[test]
3221 fn open_picker_records_one_click_row_per_choice() {
3222 use ratatui::Terminal;
3223 use ratatui::backend::TestBackend;
3224
3225 let theme = crate::theme::Theme::default();
3226 let mut state = blank_state(VendorId::Anthropic);
3227 state.focus = Focus::Primary;
3228 state.picker = Some(PrimaryPicker {
3229 cursor: 0,
3230 scroll: 0,
3231 });
3232 let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3233 let mut hits = Vec::new();
3234 terminal
3235 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3236 .unwrap();
3237
3238 let picks: Vec<usize> = hits
3239 .iter()
3240 .filter_map(|(row, _)| match row {
3241 SettingsRow::Pick(i) => Some(*i),
3242 _ => None,
3243 })
3244 .collect();
3245 assert_eq!(picks.len(), state.primary_choices.len());
3246 assert_eq!(picks.first().copied(), Some(0));
3247 assert_eq!(picks.last().copied(), Some(state.primary_choices.len() - 1));
3248 }
3249
3250 #[test]
3254 fn render_scrolls_the_body_so_a_late_row_and_save_stay_visible() {
3255 use ratatui::Terminal;
3256 use ratatui::backend::TestBackend;
3257
3258 let backend = TestBackend::new(100, 24);
3259 let mut terminal = Terminal::new(backend).unwrap();
3260 let theme = crate::theme::Theme::default();
3261 let mut state = blank_state(VendorId::Anthropic);
3262 state.focus = Focus::Vendor(vendor_index(VendorId::Grok));
3263 let mut hits = Vec::new();
3264
3265 terminal
3266 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3267 .unwrap();
3268
3269 let rendered = buffer_text(terminal.backend().buffer());
3270 assert!(
3271 rendered.iter().any(|line| line.contains("Grok")),
3272 "focused provider row must be visible: {rendered:?}"
3273 );
3274 let mut state = blank_state(VendorId::Anthropic);
3277 state.focus = Focus::Save;
3278 let mut hits = Vec::new();
3279 terminal
3280 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3281 .unwrap();
3282 let rendered = buffer_text(terminal.backend().buffer());
3283 assert!(
3284 rendered.iter().any(|line| line.contains("Save")),
3285 "Save must stay visible: {rendered:?}"
3286 );
3287 }
3288
3289 fn buffer_text(buffer: &ratatui::buffer::Buffer) -> Vec<String> {
3291 let area = buffer.area;
3292 let mut lines = Vec::new();
3293 for y in area.top()..area.bottom() {
3294 let mut line = String::new();
3295 for x in area.left()..area.right() {
3296 line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
3297 }
3298 lines.push(line.trim_end().to_string());
3299 }
3300 lines
3301 }
3302}