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