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
425 .ui
426 .primary
427 .filter(|vendor| {
428 primary_choices.contains(vendor)
429 && (*vendor != VendorId::Copilot || cfg.copilot.enabled)
430 })
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]
2199 fn key_input_insert_backspace_arrow() {
2200 let mut k = KeyInput::default();
2201 k.insert_char('a');
2202 k.insert_char('b');
2203 k.insert_char('c');
2204 assert_eq!(k.buf, "abc");
2205 assert_eq!(k.cursor, 3);
2206 assert!(k.dirty);
2207 k.move_left();
2208 k.move_left();
2209 assert_eq!(k.cursor, 1);
2210 k.insert_char('x');
2211 assert_eq!(k.buf, "axbc");
2212 assert_eq!(k.cursor, 2);
2213 k.backspace();
2214 assert_eq!(k.buf, "abc");
2215 assert_eq!(k.cursor, 1);
2216 }
2217
2218 #[test]
2219 fn key_input_masks_by_default_reveals_on_toggle() {
2220 let mut k = KeyInput::default();
2221 for c in "secret-key".chars() {
2222 k.insert_char(c);
2223 }
2224 assert_eq!(k.display(), "•".repeat(10));
2225 k.toggle_reveal();
2226 assert_eq!(k.display(), "secret-key");
2227 }
2228
2229 #[test]
2230 fn key_input_handles_unicode() {
2231 let mut k = KeyInput::default();
2232 k.insert_char('a');
2233 k.insert_char('→');
2234 k.insert_char('b');
2235 assert_eq!(k.buf, "a→b");
2236 assert_eq!(k.cursor, 3);
2237 k.move_left();
2238 k.backspace();
2239 assert_eq!(k.buf, "ab");
2240 }
2241
2242 #[test]
2243 fn value_text_shows_cursor_and_empty_states() {
2244 let mut k = KeyInput::default();
2245 assert_eq!(value_text(&k, false), "(empty)");
2246 assert_eq!(value_text(&k, true), "‸");
2247 k.insert_char('a');
2248 k.insert_char('b');
2249 assert_eq!(value_text(&k, true), "••‸");
2251 assert_eq!(value_text(&k, false), "••");
2252 }
2253
2254 #[test]
2255 fn save_writes_key_and_enables_vendor() {
2256 let (_dir, path) = temp_config(None);
2257 let mut s = blank_state(VendorId::Kilo);
2258 s.keys[key_index(VendorId::Kilo)] = KeyInput::from_config(Some("sk-kilo"));
2259 s.keys[key_index(VendorId::Kilo)].dirty = true;
2260 save_to_path(&s, &path).unwrap();
2261 let raw = std::fs::read_to_string(&path).unwrap();
2262 assert!(raw.contains("primary = \"kilo\""));
2263 assert!(raw.contains("[kilo]"));
2264 assert!(raw.contains("api_key = \"sk-kilo\""));
2265 assert!(raw.contains("enabled = true"));
2266 }
2267
2268 #[test]
2269 fn save_writes_minimal_toml_when_starting_empty() {
2270 let (_dir, path) = temp_config(None);
2271 let s = state_with("zk", "ok", VendorId::Zai);
2272 save_to_path(&s, &path).unwrap();
2273 let raw = std::fs::read_to_string(&path).unwrap();
2274 assert!(raw.contains("primary = \"zai\""));
2275 assert!(raw.contains("[zai]"));
2276 assert!(raw.contains("api_key = \"zk\""));
2277 assert!(raw.contains("[openrouter]"));
2278 assert!(raw.contains("api_key = \"ok\""));
2279 }
2280
2281 #[test]
2282 fn save_preserves_existing_comments_and_unrelated_fields() {
2283 let (_dir, path) = temp_config(Some(
2284 r##"# my comment
2285[ui]
2286# pre-existing comment
2287primary = "anthropic"
2288
2289[zai]
2290enabled = true
2291api_key_env = "ZAI_API_KEY"
2292# tier comment
2293plan_tier = "pro"
2294
2295[openrouter]
2296enabled = true
2297api_key_env = "OPENROUTER_API_KEY"
2298
2299[[openrouter.accounts]]
2300label = "work"
2301api_key_env = "OPENROUTER_WORK_API_KEY"
2302"##,
2303 ));
2304
2305 let s = state_with("zk2", "ok2", VendorId::Openrouter);
2306 save_to_path(&s, &path).unwrap();
2307
2308 let raw = std::fs::read_to_string(&path).unwrap();
2309 assert!(raw.contains("# my comment"));
2310 assert!(raw.contains("# pre-existing comment"));
2311 assert!(raw.contains("# tier comment"));
2312 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
2313 assert!(raw.contains("[[openrouter.accounts]]"));
2314 assert!(raw.contains("api_key_env = \"OPENROUTER_WORK_API_KEY\""));
2315 assert!(raw.contains("plan_tier = \"pro\""));
2316 assert!(raw.contains("primary = \"openrouter\""));
2317 assert!(raw.contains("api_key = \"zk2\""));
2318 assert!(raw.contains("api_key = \"ok2\""));
2319 }
2320
2321 #[test]
2322 fn save_refuses_to_replace_an_unreadable_existing_config() {
2323 let (_dir, path) = temp_config(None);
2324 let original = [0xff, 0xfe, 0xfd];
2325 std::fs::write(&path, original).unwrap();
2326 let state = state_with("new-secret", "", VendorId::Zai);
2327
2328 assert!(save_to_path(&state, &path).is_err());
2329 assert_eq!(std::fs::read(&path).unwrap(), original);
2330 }
2331
2332 #[test]
2333 fn save_does_not_write_empty_key_when_dirty_but_blank() {
2334 let (_dir, path) = temp_config(None);
2335 let mut s = blank_state(VendorId::Anthropic);
2336 for k in &mut s.keys {
2338 k.dirty = true;
2339 }
2340 save_to_path(&s, &path).unwrap();
2341 let raw = std::fs::read_to_string(&path).unwrap();
2342 assert!(!raw.contains("api_key ="));
2343 }
2344
2345 #[test]
2346 #[cfg(unix)]
2347 fn save_chmods_to_600() {
2348 use std::os::unix::fs::PermissionsExt;
2349 let (_dir, path) = temp_config(None);
2350 let s = state_with("zk", "ok", VendorId::Zai);
2351 save_to_path(&s, &path).unwrap();
2352 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2353 assert_eq!(mode & 0o777, 0o600);
2354 }
2355
2356 #[test]
2357 fn tab_cycles_focus_from_primary_to_first_key() {
2358 let mut s = blank_state(VendorId::Anthropic);
2359 assert_eq!(
2360 handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
2361 Action::Continue
2362 );
2363 assert_eq!(s.focus, Focus::Key(0));
2364 assert_eq!(
2365 handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
2366 Action::Continue
2367 );
2368 assert_eq!(s.focus, Focus::Primary);
2369 }
2370
2371 #[test]
2372 fn esc_closes_without_saving() {
2373 let mut s = blank_state(VendorId::Anthropic);
2374 assert_eq!(
2375 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
2376 Action::Close
2377 );
2378 }
2379
2380 #[test]
2381 fn left_right_cycles_primary_vendor() {
2382 let mut s = blank_state(VendorId::Anthropic);
2384 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2385 assert_eq!(s.primary, VendorId::AnthropicApi);
2386 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2387 assert_eq!(s.primary, VendorId::Openai);
2388 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
2389 assert_eq!(s.primary, VendorId::AnthropicApi);
2390 }
2391
2392 #[test]
2393 fn left_right_offers_enabled_vendors_only() {
2394 let mut s = blank_state(VendorId::Anthropic);
2396 s.primary_choices = vec![VendorId::Anthropic, VendorId::Grok];
2397 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2398 assert_eq!(s.primary, VendorId::Grok);
2399 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2401 assert_eq!(s.primary, VendorId::Anthropic);
2402 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
2403 assert_eq!(s.primary, VendorId::Grok);
2404 }
2405
2406 #[test]
2407 fn no_enabled_vendors_leaves_primary_selector_inert() {
2408 let mut s = blank_state(VendorId::Anthropic);
2409 s.primary_choices = vec![];
2410 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2411 assert_eq!(s.primary, VendorId::Anthropic);
2412 }
2413
2414 #[test]
2415 fn disabled_copilot_is_offered_but_not_shown_as_the_current_primary() {
2416 let mut cfg = Config::default();
2417 cfg.ui.primary = Some(VendorId::Copilot);
2418 let state = SettingsState::from_config(&cfg);
2419
2420 assert!(state.primary_choices.contains(&VendorId::Copilot));
2421 assert_eq!(state.primary, VendorId::Anthropic);
2422 }
2423
2424 #[test]
2425 fn save_does_not_write_a_disabled_primary() {
2426 let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
2429 let mut s = state_with("zk", "ok", VendorId::Grok);
2430 s.primary_choices = vec![VendorId::Anthropic];
2431 save_to_path(&s, &path).unwrap();
2432 let raw = std::fs::read_to_string(&path).unwrap();
2433 assert!(raw.contains("primary = \"anthropic\""));
2434 assert!(!raw.contains("primary = \"grok\""));
2435 assert!(raw.contains("zk"));
2437 }
2438
2439 #[test]
2440 fn save_removes_an_inline_key_the_user_cleared() {
2441 let (_dir, path) = temp_config(Some(
2444 "[zai]\nenabled = true\napi_key = \"old-secret\"\nplan_tier = \"pro\"\n",
2445 ));
2446 let mut s = blank_state(VendorId::Zai);
2447 s.primary_choices = vec![VendorId::Zai];
2448 s.keys[key_index(VendorId::Zai)] = KeyInput::default();
2449 s.keys[key_index(VendorId::Zai)].dirty = true;
2450 save_to_path(&s, &path).unwrap();
2451 let raw = std::fs::read_to_string(&path).unwrap();
2452 assert!(!raw.contains("old-secret"));
2453 assert!(!raw.contains("api_key"));
2454 assert!(raw.contains("plan_tier = \"pro\""));
2456 }
2457
2458 #[test]
2459 fn untouched_key_field_is_left_alone() {
2460 let (_dir, path) = temp_config(Some("[zai]\napi_key = \"keep-me\"\n"));
2462 let mut s = blank_state(VendorId::Zai);
2463 s.primary_choices = vec![VendorId::Zai];
2464 save_to_path(&s, &path).unwrap();
2465 let raw = std::fs::read_to_string(&path).unwrap();
2466 assert!(raw.contains("keep-me"));
2467 }
2468
2469 #[test]
2470 fn typing_edits_the_focused_key_only() {
2471 let mut s = blank_state(VendorId::Anthropic);
2472 s.focus = Focus::Key(key_index(VendorId::Grok));
2473 for c in "xai-abc".chars() {
2474 handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
2475 }
2476 assert_eq!(s.keys[key_index(VendorId::Grok)].buf, "xai-abc");
2477 assert!(s.keys[key_index(VendorId::Grok)].dirty);
2478 assert!(s.keys[key_index(VendorId::Zai)].buf.is_empty());
2480 }
2481
2482 #[test]
2483 fn ctrl_v_toggles_reveal_on_focused_key_field() {
2484 let mut s = blank_state(VendorId::Anthropic);
2485 let zi = key_index(VendorId::Zai);
2486 s.focus = Focus::Key(zi);
2487 s.keys[zi] = KeyInput::from_config(Some("secret"));
2488 assert!(!s.keys[zi].revealed);
2489 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
2490 assert!(s.keys[zi].revealed);
2491 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
2492 assert!(!s.keys[zi].revealed);
2493 }
2494
2495 #[test]
2496 fn control_chorded_chars_do_not_type_into_fields() {
2497 let mut s = blank_state(VendorId::Anthropic);
2498 s.focus = Focus::Key(0);
2499 handle_key(&mut s, KeyCode::Char('a'), KeyModifiers::CONTROL);
2501 assert!(s.keys[0].buf.is_empty());
2502 assert!(!s.keys[0].dirty);
2503 assert_eq!(
2505 handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
2506 Action::Quit
2507 );
2508 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
2510 assert_eq!(s.keys[0].buf, "x");
2511 }
2512
2513 #[test]
2514 fn ctrl_v_on_non_key_focus_is_noop() {
2515 let mut s = blank_state(VendorId::Anthropic);
2516 s.focus = Focus::Primary;
2517 assert_eq!(
2519 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL),
2520 Action::Continue
2521 );
2522 }
2523
2524 fn state_focused_on_zai() -> SettingsState {
2525 let mut state = blank_state(VendorId::Anthropic);
2526 state.focus = Focus::Key(key_index(VendorId::Zai));
2527 state
2528 }
2529
2530 #[test]
2531 fn handle_key_ctrl_c_quits_without_typing_into_key_field() {
2532 let mut s = state_focused_on_zai();
2533 let zi = key_index(VendorId::Zai);
2534 assert_eq!(
2535 handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
2536 Action::Quit
2537 );
2538 assert!(s.keys[zi].buf.is_empty());
2539 assert!(!s.keys[zi].dirty);
2541 }
2542
2543 #[test]
2544 fn handle_key_alt_chord_does_not_type_into_key_field() {
2545 let mut s = state_focused_on_zai();
2546 let zi = key_index(VendorId::Zai);
2547 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::ALT);
2548 assert!(s.keys[zi].buf.is_empty());
2549 assert!(!s.keys[zi].dirty);
2550 }
2551
2552 #[test]
2553 fn handle_key_platform_modifier_chords_do_not_type_into_key_field() {
2554 for modifier in [KeyModifiers::SUPER, KeyModifiers::HYPER, KeyModifiers::META] {
2555 let mut s = state_focused_on_zai();
2556 let zi = key_index(VendorId::Zai);
2557 handle_key(&mut s, KeyCode::Char('x'), modifier);
2558 assert!(s.keys[zi].buf.is_empty(), "modifier {modifier:?}");
2559 assert!(!s.keys[zi].dirty, "modifier {modifier:?}");
2560 }
2561 }
2562
2563 #[test]
2564 fn handle_key_shift_still_types_uppercase() {
2565 let mut s = state_focused_on_zai();
2566 let zi = key_index(VendorId::Zai);
2567 handle_key(&mut s, KeyCode::Char('A'), KeyModifiers::SHIFT);
2568 assert_eq!(s.keys[zi].buf, "A");
2569 assert!(s.keys[zi].dirty);
2570 }
2571
2572 #[test]
2573 fn handle_key_plain_space_still_cycles_primary_vendor() {
2574 let mut s = blank_state(VendorId::Anthropic);
2575 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
2576 assert_eq!(s.primary, VendorId::AnthropicApi);
2577 }
2578
2579 #[test]
2580 fn handle_key_ctrl_s_attempts_save_from_any_field() {
2581 let (_dir, path) = temp_config(None);
2582 let s = state_with("zk", "ok", VendorId::Zai);
2583 save_to_path(&s, &path).unwrap();
2584 let raw = std::fs::read_to_string(&path).unwrap();
2585 assert!(raw.contains("api_key = \"zk\""));
2586 }
2587 #[test]
2588 fn save_to_path_writes_kimi_key_when_dirty() {
2589 let (_dir, path) = temp_config(None);
2590 let mut s = blank_state(VendorId::Anthropic);
2591 let kimi = key_index(VendorId::Kimi);
2592 s.keys[kimi] = KeyInput::from_config(Some("kk"));
2593 s.keys[kimi].dirty = true;
2594 save_to_path(&s, &path).unwrap();
2595 let raw = std::fs::read_to_string(&path).unwrap();
2596 assert!(raw.contains("[kimi]"));
2597 assert!(raw.contains("api_key = \"kk\""));
2598 }
2599
2600 #[test]
2601 fn settings_save_uses_the_same_config_path_as_load() {
2602 assert_eq!(
2603 default_config_path().unwrap(),
2604 crate::config::resolved_path().unwrap()
2605 );
2606 }
2607
2608 #[test]
2609 fn native_snapshot_reports_key_state_without_serializing_secrets() {
2610 let mut cfg = Config::default();
2611 cfg.zai.api_key = Some("never-leak-this-key".into());
2612 cfg.zai.api_key_env = "CUSTOM_ZAI_KEY".into();
2613 let raw = settings_snapshot_json_with(&cfg, |name| name == "CUSTOM_ZAI_KEY").unwrap();
2614 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
2615
2616 assert_eq!(parsed["schema_version"], 1);
2617 assert_eq!(parsed["primary"], "anthropic");
2618 let zai = parsed["keys"]
2619 .as_array()
2620 .unwrap()
2621 .iter()
2622 .find(|row| row["id"] == "zai")
2623 .unwrap();
2624 assert_eq!(zai["configured"], true);
2625 assert_eq!(zai["inline_configured"], true);
2626 assert_eq!(zai["environment_configured"], true);
2627 assert_eq!(zai["environment"], "CUSTOM_ZAI_KEY");
2628 assert!(!raw.contains("never-leak-this-key"));
2629 assert!(parsed.get("api_key").is_none());
2630 }
2631
2632 #[test]
2633 fn native_snapshot_offers_copilot_primary_without_a_token_field() {
2634 let cfg = Config::default();
2635 let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
2636 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
2637 assert!(
2638 parsed["primary_choices"]
2639 .as_array()
2640 .unwrap()
2641 .iter()
2642 .any(|row| row["id"] == "copilot")
2643 );
2644 assert!(
2645 !parsed["keys"]
2646 .as_array()
2647 .unwrap()
2648 .iter()
2649 .any(|row| row["id"] == "copilot")
2650 );
2651 }
2652
2653 #[test]
2654 fn native_key_only_patch_does_not_require_or_replace_primary() {
2655 let cfg = Config::default();
2656 let original_primary = SettingsState::from_config(&cfg).primary;
2657 let request = serde_json::json!({
2658 "schema_version": 1,
2659 "keys": {"kimi": {"action": "set", "value": "new-kimi-key"}}
2660 });
2661
2662 let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
2663 assert_eq!(state.primary, original_primary);
2664 let kimi_index = KEY_VENDORS
2665 .iter()
2666 .position(|vendor| vendor.id == VendorId::Kimi)
2667 .unwrap();
2668 assert!(state.keys[kimi_index].dirty);
2669 assert_eq!(state.keys[kimi_index].buf, "new-kimi-key");
2670 }
2671
2672 #[test]
2673 fn native_patch_reuses_tui_persistence_and_preserves_existing_config() {
2674 let (_dir, path) = temp_config(Some(
2675 r#"# keep this comment
2676[ui]
2677primary = "anthropic"
2678
2679[zai]
2680enabled = true
2681api_key_env = "ZAI_API_KEY"
2682plan_tier = "pro"
2683
2684[openrouter]
2685enabled = true
2686"#,
2687 ));
2688 let cfg = Config::load_from(&path).unwrap();
2689 let request = serde_json::json!({
2690 "schema_version": 1,
2691 "primary": "openrouter",
2692 "keys": {
2693 "zai": {"action": "set", "value": "new-zai-key"}
2694 }
2695 });
2696
2697 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
2698 let raw = std::fs::read_to_string(&path).unwrap();
2699 assert!(raw.contains("# keep this comment"));
2700 assert!(raw.contains("plan_tier = \"pro\""));
2701 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
2702 assert!(raw.contains("primary = \"openrouter\""));
2703 assert!(raw.contains("api_key = \"new-zai-key\""));
2704 }
2705
2706 #[test]
2707 fn native_patch_distinguishes_clear_from_unchanged() {
2708 let (_dir, path) = temp_config(Some(
2709 "[zai]\nenabled = true\napi_key = \"remove-me\"\n\
2710 [openrouter]\nenabled = true\napi_key = \"keep-me\"\n",
2711 ));
2712 let cfg = Config::load_from(&path).unwrap();
2713 let request = serde_json::json!({
2714 "schema_version": 1,
2715 "primary": "zai",
2716 "keys": {"zai": {"action": "clear"}}
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("remove-me"));
2722 assert!(raw.contains("keep-me"));
2723 }
2724
2725 #[test]
2726 fn native_primary_selection_enables_copilot_without_writing_a_token() {
2727 let (_dir, path) = temp_config(Some(
2728 "[copilot]\nenabled = false\ntoken = \"legacy-value\"\ntoken_env = \"OLD_TOKEN\"\n",
2729 ));
2730 let cfg = Config::load_from(&path).unwrap();
2731 let select = serde_json::json!({
2732 "schema_version": 1,
2733 "primary": "copilot"
2734 });
2735 apply_settings_json_to_path(&cfg, &select.to_string(), &path).unwrap();
2736 let raw = std::fs::read_to_string(&path).unwrap();
2737 assert!(raw.contains("enabled = true"));
2738 assert!(raw.contains("primary = \"copilot\""));
2739 assert!(!raw.contains("token ="));
2740 assert!(!raw.contains("token_env ="));
2741 }
2742
2743 #[test]
2744 fn native_patch_errors_never_echo_key_values() {
2745 let raw = serde_json::json!({
2746 "schema_version": 1,
2747 "primary": "anthropic",
2748 "keys": {
2749 "zai": {"action": "set", "value": "secret\nwith-control"}
2750 }
2751 })
2752 .to_string();
2753 let error = state_from_apply_request(&Config::default(), &raw)
2754 .unwrap_err()
2755 .to_string();
2756 assert!(!error.contains("secret"));
2757 assert!(error.contains("control characters"));
2758 }
2759
2760 #[test]
2761 fn native_patch_input_is_bounded_before_json_parsing() {
2762 let oversized = vec![b'x'; MAX_SETTINGS_REQUEST_BYTES as usize + 1];
2763 let error = read_settings_request(std::io::Cursor::new(oversized))
2764 .unwrap_err()
2765 .to_string();
2766 assert!(error.contains("exceeds"));
2767 }
2768
2769 #[test]
2772 fn native_snapshot_lists_every_provider_with_its_enabled_state() {
2773 let mut cfg = Config::default();
2774 cfg.grok.enabled = true;
2775 let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
2776 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
2777
2778 let vendors = parsed["vendors"].as_array().unwrap();
2779 assert_eq!(vendors.len(), VendorId::all().len());
2780 let by_id = |slug: &str| {
2781 vendors
2782 .iter()
2783 .find(|row| row["id"] == slug)
2784 .unwrap_or_else(|| panic!("no {slug} row: {vendors:?}"))
2785 .clone()
2786 };
2787 assert_eq!(by_id("anthropic")["enabled"], true);
2788 assert_eq!(by_id("grok")["enabled"], true);
2789 assert_eq!(by_id("kimi")["enabled"], false);
2790 assert_eq!(by_id("anthropic")["label"], "Claude");
2792 assert_eq!(by_id("opencode-go")["label"], "OpenCode Go");
2793 }
2794
2795 #[test]
2796 fn native_patch_toggles_providers_and_preserves_the_rest_of_the_file() {
2797 let (_dir, path) = temp_config(Some("# keep\n[zai]\nenabled = true\napi_key = \"k\"\n"));
2798 let cfg = Config::load_from(&path).unwrap();
2799 let request = serde_json::json!({
2800 "schema_version": 1,
2801 "vendors": {"grok": true, "zai": false}
2802 });
2803
2804 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
2805
2806 let raw = std::fs::read_to_string(&path).unwrap();
2807 assert!(raw.starts_with("# keep\n"), "{raw}");
2808 assert!(raw.contains("api_key = \"k\""), "{raw}");
2809 let reloaded = Config::load_from(&path).unwrap();
2810 assert!(reloaded.is_enabled(VendorId::Grok));
2811 assert!(!reloaded.is_enabled(VendorId::Zai));
2812 }
2813
2814 #[test]
2815 fn native_patch_refuses_an_unknown_provider_slug_without_writing() {
2816 let (_dir, path) = temp_config(Some("[zai]\nenabled = true\n"));
2817 let cfg = Config::load_from(&path).unwrap();
2818 for bad in ["", "mytool", "not-a-vendor"] {
2819 let request = serde_json::json!({
2820 "schema_version": 1,
2821 "vendors": {bad: true}
2822 });
2823 let error = state_from_apply_request(&cfg, &request.to_string())
2824 .unwrap_err()
2825 .to_string();
2826 assert!(error.contains("unknown provider"), "{bad}: {error}");
2827 }
2828 assert_eq!(
2830 std::fs::read_to_string(&path).unwrap(),
2831 "[zai]\nenabled = true\n"
2832 );
2833 }
2834
2835 #[test]
2837 fn native_vendor_toggle_alone_is_a_valid_patch() {
2838 let cfg = Config::default();
2839 let request = serde_json::json!({
2840 "schema_version": 1,
2841 "vendors": {"kimi": true}
2842 });
2843 let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
2844 let kimi = vendor_index(VendorId::Kimi);
2845 assert_eq!(
2846 state.vendors[kimi],
2847 ProviderSwitch {
2848 enabled: true,
2849 dirty: true
2850 }
2851 );
2852 }
2853
2854 #[test]
2857 fn body_layout_positions_every_focusable_row_without_overlap() {
2858 let keys = 5;
2859 let vendors = 7;
2860 let layout = body_layout(keys, vendors);
2861 assert_eq!(layout.primary, 1);
2863 assert_eq!(layout.first_key, 4);
2864 assert_eq!(layout.first_vendor, layout.first_key + keys + 2);
2866 assert_eq!(layout.notify_enabled, layout.first_vendor + vendors + 2);
2867 assert_eq!(layout.notify_threshold, layout.notify_enabled + 1);
2868 assert_eq!(layout.save, layout.notify_threshold + 2);
2869 assert_eq!(layout.rows, layout.save + 1);
2870 let mut rows = vec![
2872 layout.primary,
2873 layout.notify_enabled,
2874 layout.notify_threshold,
2875 layout.save,
2876 ];
2877 rows.extend(layout.first_key..layout.first_key + keys);
2878 rows.extend(layout.first_vendor..layout.first_vendor + vendors);
2879 rows.sort_unstable();
2880 let mut unique = rows.clone();
2881 unique.dedup();
2882 assert_eq!(rows, unique);
2883 assert!(rows.iter().all(|row| *row < layout.rows));
2884 }
2885
2886 #[test]
2887 fn focus_line_matches_the_body_layout_for_every_focus_variant() {
2888 let state = blank_state(VendorId::Anthropic);
2889 let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
2890 let cases = [
2891 (Focus::Primary, layout.primary),
2892 (Focus::Key(0), layout.first_key),
2893 (
2894 Focus::Key(KEY_VENDORS.len() - 1),
2895 layout.first_key + KEY_VENDORS.len() - 1,
2896 ),
2897 (Focus::Vendor(0), layout.first_vendor),
2898 (
2899 Focus::Vendor(state.vendors.len() - 1),
2900 layout.first_vendor + state.vendors.len() - 1,
2901 ),
2902 (Focus::NotifyEnabled, layout.notify_enabled),
2903 (Focus::NotifyThreshold, layout.notify_threshold),
2904 (Focus::Save, layout.save),
2905 ];
2906 for (focus, want) in cases {
2907 let mut s = state.clone();
2908 s.focus = focus;
2909 assert_eq!(focus_line(&s), want, "{focus:?}");
2910 }
2911 }
2912
2913 #[test]
2914 fn follow_focus_scroll_sticks_jumps_and_clamps() {
2915 assert_eq!(follow_focus_scroll(3, 50, 10, 0), 0);
2917 assert_eq!(follow_focus_scroll(9, 50, 10, 0), 0);
2918 assert_eq!(follow_focus_scroll(10, 50, 10, 0), 1);
2920 assert_eq!(follow_focus_scroll(25, 50, 10, 5), 16);
2921 assert_eq!(follow_focus_scroll(2, 50, 10, 16), 2);
2923 assert_eq!(follow_focus_scroll(49, 50, 10, 0), 40);
2925 assert_eq!(follow_focus_scroll(60, 50, 10, 0), 40);
2926 assert_eq!(follow_focus_scroll(5, 50, 0, 3), 0);
2928 assert_eq!(follow_focus_scroll(5, 0, 10, 3), 0);
2929 }
2930
2931 #[test]
2934 fn render_records_clickable_hint_links_for_the_focused_control() {
2935 use ratatui::Terminal;
2936 use ratatui::backend::TestBackend;
2937
2938 let theme = crate::theme::Theme::default();
2939 let mut state = blank_state(VendorId::Anthropic);
2940 state.focus = Focus::Vendor(vendor_index(VendorId::Grok));
2941 let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
2942 let mut hits = Vec::new();
2943 terminal
2944 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
2945 .unwrap();
2946
2947 let hint_keys: Vec<(KeyCode, KeyModifiers)> = hits
2948 .iter()
2949 .filter_map(|(row, _)| match row {
2950 SettingsRow::HintKey(code, mods) => Some((*code, *mods)),
2951 _ => None,
2952 })
2953 .collect();
2954 assert_eq!(
2957 hint_keys,
2958 vec![
2959 (KeyCode::Char(' '), KeyModifiers::NONE),
2960 (KeyCode::Char('s'), KeyModifiers::CONTROL),
2961 (KeyCode::Esc, KeyModifiers::NONE),
2962 ]
2963 );
2964 let ys: Vec<u16> = hits
2966 .iter()
2967 .filter(|(row, _)| matches!(row, SettingsRow::HintKey(..)))
2968 .map(|(_, rect)| rect.y)
2969 .collect();
2970 assert!(ys.windows(2).all(|pair| pair[0] == pair[1]));
2971
2972 let mut state = blank_state(VendorId::Anthropic);
2975 state.focus = Focus::Primary;
2976 let mut hits = Vec::new();
2977 terminal
2978 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
2979 .unwrap();
2980 let hint_keys: Vec<(KeyCode, KeyModifiers)> = hits
2981 .iter()
2982 .filter_map(|(row, _)| match row {
2983 SettingsRow::HintKey(code, mods) => Some((*code, *mods)),
2984 _ => None,
2985 })
2986 .collect();
2987 assert!(hint_keys.contains(&(KeyCode::Right, KeyModifiers::NONE)));
2988 assert!(!hint_keys.contains(&(KeyCode::Char(' '), KeyModifiers::NONE)));
2989 }
2990
2991 #[test]
2995 fn render_records_switch_cells_right_of_the_labels() {
2996 use ratatui::Terminal;
2997 use ratatui::backend::TestBackend;
2998
2999 let theme = crate::theme::Theme::default();
3000 let mut state = blank_state(VendorId::Anthropic);
3001 state.focus = Focus::Vendor(0);
3002 let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3003 let mut hits = Vec::new();
3004 terminal
3005 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3006 .unwrap();
3007
3008 let focus_of = |i: usize| {
3009 hits.iter().find_map(|(row, rect)| match row {
3010 SettingsRow::Focus(Focus::Vendor(v)) if *v == i => Some(*rect),
3011 _ => None,
3012 })
3013 };
3014 for (i, id) in VendorId::all().iter().enumerate() {
3015 let focus_rect = focus_of(i).unwrap_or_else(|| panic!("vendor {i} has no focus row"));
3016 let switch = hits
3017 .iter()
3018 .find_map(|(row, rect)| match row {
3019 SettingsRow::Switch(Focus::Vendor(v), KeyCode::Char(' '), _) if *v == i => {
3020 Some(*rect)
3021 }
3022 _ => None,
3023 })
3024 .unwrap_or_else(|| panic!("vendor {i} has no switch cell"));
3025 let expected_x =
3030 5 + crate::display::text_width(&format!("{:<11}", id.display_name())) as u16;
3031 let value_len = if state.vendors[i].enabled { 2 } else { 3 }; let focused = state.focus == Focus::Vendor(i);
3033 assert_eq!(switch.y, focus_rect.y);
3034 assert_eq!(switch.x, focus_rect.x + expected_x);
3035 assert_eq!(
3036 switch.width,
3037 (if focused { 6 } else { 2 }) + value_len,
3038 "rendered value segment"
3039 );
3040 assert!(switch.x + switch.width <= focus_rect.x + focus_rect.width);
3041 }
3042 assert!(hits.iter().any(|(row, _)| matches!(
3044 row,
3045 SettingsRow::Switch(Focus::NotifyEnabled, KeyCode::Char(' '), _)
3046 )));
3047 }
3048
3049 #[test]
3052 fn render_records_primary_arrow_cells_when_focused() {
3053 use ratatui::Terminal;
3054 use ratatui::backend::TestBackend;
3055
3056 let theme = crate::theme::Theme::default();
3057 let mut state = blank_state(VendorId::Anthropic);
3058 state.focus = Focus::Primary;
3059 let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3060 let mut hits = Vec::new();
3061 terminal
3062 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3063 .unwrap();
3064
3065 let focus_rect = hits
3066 .iter()
3067 .find_map(|(row, rect)| match row {
3068 SettingsRow::Focus(Focus::Primary) => Some(*rect),
3069 _ => None,
3070 })
3071 .expect("primary row has a focus target");
3072 let prev = hits.iter().find_map(|(row, rect)| match row {
3073 SettingsRow::Switch(Focus::Primary, KeyCode::Left, _) => Some(*rect),
3074 _ => None,
3075 });
3076 let next = hits.iter().find_map(|(row, rect)| match row {
3077 SettingsRow::Switch(Focus::Primary, KeyCode::Right, _) => Some(*rect),
3078 _ => None,
3079 });
3080 let prev = prev.expect("focused primary has a ◀ cell");
3081 let next = next.expect("focused primary has a ▶ cell");
3082 assert_eq!(prev.x, focus_rect.x + 5);
3083 assert_eq!(prev.width, 2);
3084 assert!(next.x > prev.x + 2, "▶ sits right of the vendor name");
3085 assert_eq!(next.width, 2);
3086 assert_eq!(prev.y, next.y);
3087
3088 let mut state = blank_state(VendorId::Anthropic);
3091 state.focus = Focus::Vendor(0);
3092 let mut unfocused_hits = Vec::new();
3093 terminal
3094 .draw(|f| render(f, f.area(), &mut state, &theme, &mut unfocused_hits))
3095 .unwrap();
3096 assert!(
3097 !unfocused_hits
3098 .iter()
3099 .any(|(row, _)| matches!(row, SettingsRow::Switch(Focus::Primary, _, _)))
3100 );
3101 }
3102
3103 #[test]
3107 fn primary_arrow_cells_sit_at_fixed_columns() {
3108 use ratatui::Terminal;
3109 use ratatui::backend::TestBackend;
3110
3111 let theme = crate::theme::Theme::default();
3112 let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3113 let mut arrow_x = |primary: VendorId| {
3114 let mut state = blank_state(primary);
3115 state.focus = Focus::Primary;
3116 let mut hits = Vec::new();
3117 terminal
3118 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3119 .unwrap();
3120 hits.iter()
3121 .find_map(|(row, rect)| match row {
3122 SettingsRow::Switch(Focus::Primary, KeyCode::Right, _) => Some(rect.x),
3123 _ => None,
3124 })
3125 .expect("focused primary has a ▶ cell")
3126 };
3127 assert_eq!(arrow_x(VendorId::Anthropic), arrow_x(VendorId::Copilot));
3128 }
3129
3130 #[test]
3134 fn picker_key_handling_selects_and_closes() {
3135 let mut s = blank_state(VendorId::Anthropic);
3136 s.focus = Focus::Primary;
3137 s.picker = Some(PrimaryPicker {
3138 cursor: 0,
3139 scroll: 0,
3140 });
3141 let count = s.primary_choices.len();
3142 assert!(count > 1);
3143
3144 assert_eq!(
3145 handle_key(&mut s, KeyCode::Down, KeyModifiers::NONE),
3146 Action::Continue
3147 );
3148 assert_eq!(s.picker.as_ref().unwrap().cursor, 1);
3149 handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
3151 handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
3152 assert_eq!(s.picker.as_ref().unwrap().cursor, count - 1);
3153
3154 let expected = s.primary_choices[s.picker.as_ref().unwrap().cursor];
3156 assert_eq!(
3157 handle_key(&mut s, KeyCode::Enter, KeyModifiers::NONE),
3158 Action::Continue
3159 );
3160 assert_eq!(s.primary, expected);
3161 assert!(s.picker.is_none());
3162
3163 s.picker = Some(PrimaryPicker {
3166 cursor: 2,
3167 scroll: 0,
3168 });
3169 assert_eq!(
3170 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
3171 Action::Continue
3172 );
3173 assert!(s.picker.is_none());
3174 assert_eq!(
3175 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
3176 Action::Close
3177 );
3178 }
3179
3180 #[test]
3182 fn open_picker_records_one_click_row_per_choice() {
3183 use ratatui::Terminal;
3184 use ratatui::backend::TestBackend;
3185
3186 let theme = crate::theme::Theme::default();
3187 let mut state = blank_state(VendorId::Anthropic);
3188 state.focus = Focus::Primary;
3189 state.picker = Some(PrimaryPicker {
3190 cursor: 0,
3191 scroll: 0,
3192 });
3193 let mut terminal = Terminal::new(TestBackend::new(100, 70)).unwrap();
3194 let mut hits = Vec::new();
3195 terminal
3196 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3197 .unwrap();
3198
3199 let picks: Vec<usize> = hits
3200 .iter()
3201 .filter_map(|(row, _)| match row {
3202 SettingsRow::Pick(i) => Some(*i),
3203 _ => None,
3204 })
3205 .collect();
3206 assert_eq!(picks.len(), state.primary_choices.len());
3207 assert_eq!(picks.first().copied(), Some(0));
3208 assert_eq!(picks.last().copied(), Some(state.primary_choices.len() - 1));
3209 }
3210
3211 #[test]
3215 fn render_scrolls_the_body_so_a_late_row_and_save_stay_visible() {
3216 use ratatui::Terminal;
3217 use ratatui::backend::TestBackend;
3218
3219 let backend = TestBackend::new(100, 24);
3220 let mut terminal = Terminal::new(backend).unwrap();
3221 let theme = crate::theme::Theme::default();
3222 let mut state = blank_state(VendorId::Anthropic);
3223 state.focus = Focus::Vendor(vendor_index(VendorId::Grok));
3224 let mut hits = Vec::new();
3225
3226 terminal
3227 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3228 .unwrap();
3229
3230 let rendered = buffer_text(terminal.backend().buffer());
3231 assert!(
3232 rendered.iter().any(|line| line.contains("Grok")),
3233 "focused provider row must be visible: {rendered:?}"
3234 );
3235 let mut state = blank_state(VendorId::Anthropic);
3238 state.focus = Focus::Save;
3239 let mut hits = Vec::new();
3240 terminal
3241 .draw(|f| render(f, f.area(), &mut state, &theme, &mut hits))
3242 .unwrap();
3243 let rendered = buffer_text(terminal.backend().buffer());
3244 assert!(
3245 rendered.iter().any(|line| line.contains("Save")),
3246 "Save must stay visible: {rendered:?}"
3247 );
3248 }
3249
3250 fn buffer_text(buffer: &ratatui::buffer::Buffer) -> Vec<String> {
3252 let area = buffer.area;
3253 let mut lines = Vec::new();
3254 for y in area.top()..area.bottom() {
3255 let mut line = String::new();
3256 for x in area.left()..area.right() {
3257 line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
3258 }
3259 lines.push(line.trim_end().to_string());
3260 }
3261 lines
3262 }
3263}