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