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