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
184impl Focus {
185 pub fn next(self) -> Self {
186 match self {
187 Focus::Primary => Focus::Key(0),
188 Focus::Key(i) if i + 1 < KEY_VENDORS.len() => Focus::Key(i + 1),
189 Focus::Key(_) => Focus::Vendor(0),
190 Focus::Vendor(i) if i + 1 < PROVIDER_SWITCH_COUNT => Focus::Vendor(i + 1),
191 Focus::Vendor(_) => Focus::NotifyEnabled,
192 Focus::NotifyEnabled => Focus::NotifyThreshold,
193 Focus::NotifyThreshold => Focus::Save,
194 Focus::Save => Focus::Primary,
195 }
196 }
197 pub fn prev(self) -> Self {
198 match self {
199 Focus::Primary => Focus::Save,
200 Focus::Key(0) => Focus::Primary,
201 Focus::Key(i) => Focus::Key(i - 1),
202 Focus::Vendor(0) => Focus::Key(KEY_VENDORS.len() - 1),
203 Focus::Vendor(i) => Focus::Vendor(i - 1),
204 Focus::NotifyEnabled => Focus::Vendor(PROVIDER_SWITCH_COUNT - 1),
205 Focus::NotifyThreshold => Focus::NotifyEnabled,
206 Focus::Save => Focus::NotifyThreshold,
207 }
208 }
209}
210
211#[derive(Debug, Clone, Default)]
213pub struct KeyInput {
214 pub buf: String,
215 pub cursor: usize,
217 pub revealed: bool,
219 pub dirty: bool,
223}
224
225impl KeyInput {
226 pub fn from_config(initial: Option<&str>) -> Self {
227 let buf = initial.unwrap_or("").to_string();
228 let cursor = buf.chars().count();
229 Self {
230 buf,
231 cursor,
232 revealed: false,
233 dirty: false,
234 }
235 }
236
237 pub fn insert_char(&mut self, c: char) {
238 let byte_idx = self.char_to_byte(self.cursor);
239 self.buf.insert(byte_idx, c);
240 self.cursor += 1;
241 self.dirty = true;
242 }
243
244 pub fn backspace(&mut self) {
245 if self.cursor == 0 {
246 return;
247 }
248 let prev_byte = self.char_to_byte(self.cursor - 1);
249 let cur_byte = self.char_to_byte(self.cursor);
250 self.buf.replace_range(prev_byte..cur_byte, "");
251 self.cursor -= 1;
252 self.dirty = true;
253 }
254
255 pub fn delete(&mut self) {
256 let n = self.buf.chars().count();
257 if self.cursor >= n {
258 return;
259 }
260 let cur_byte = self.char_to_byte(self.cursor);
261 let next_byte = self.char_to_byte(self.cursor + 1);
262 self.buf.replace_range(cur_byte..next_byte, "");
263 self.dirty = true;
264 }
265
266 pub fn move_left(&mut self) {
267 if self.cursor > 0 {
268 self.cursor -= 1;
269 }
270 }
271 pub fn move_right(&mut self) {
272 if self.cursor < self.buf.chars().count() {
273 self.cursor += 1;
274 }
275 }
276 pub fn move_home(&mut self) {
277 self.cursor = 0;
278 }
279 pub fn move_end(&mut self) {
280 self.cursor = self.buf.chars().count();
281 }
282 pub fn toggle_reveal(&mut self) {
283 self.revealed = !self.revealed;
284 }
285
286 pub fn display(&self) -> String {
288 if self.revealed {
289 self.buf.clone()
290 } else {
291 "•".repeat(self.buf.chars().count())
292 }
293 }
294
295 fn char_to_byte(&self, char_idx: usize) -> usize {
296 self.buf
297 .char_indices()
298 .map(|(b, _)| b)
299 .chain(std::iter::once(self.buf.len()))
300 .nth(char_idx)
301 .unwrap_or(self.buf.len())
302 }
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309pub struct ProviderSwitch {
310 pub enabled: bool,
311 pub dirty: bool,
312}
313
314#[derive(Debug, Clone)]
316pub struct SettingsState {
317 pub focus: Focus,
318 pub primary_choices: Vec<VendorId>,
321 pub primary: VendorId,
322 pub keys: Vec<KeyInput>,
324 pub vendors: Vec<ProviderSwitch>,
327 pub notify_enabled: bool,
329 pub notify_enabled_dirty: bool,
330 pub notify_threshold: KeyInput,
332 pub status: String,
334 pub scroll: u16,
337}
338
339impl SettingsState {
340 pub fn from_config(cfg: &Config) -> Self {
341 Self::from_config_with(cfg, |name| {
342 std::env::var_os(name).is_some_and(|v| !v.is_empty())
343 })
344 }
345
346 pub fn from_config_with(cfg: &Config, env_set: impl Fn(&str) -> bool) -> Self {
355 let keys = KEY_VENDORS
356 .iter()
357 .map(|kv| KeyInput::from_config(cfg.inline_api_key(kv.id)))
358 .collect();
359 let mut primary_choices = cfg.enabled_vendors();
360 if !primary_choices.contains(&VendorId::Copilot) {
364 primary_choices.push(VendorId::Copilot);
365 }
366 for kv in KEY_VENDORS {
374 if primary_choices.contains(&kv.id) {
375 continue;
376 }
377 let env = cfg.api_key_env_for(kv.id);
378 let exported = is_valid_env_var_name(env) && env_set(env);
379 if cfg.inline_api_key(kv.id).is_some() || exported {
380 primary_choices.push(kv.id);
381 }
382 }
383 let primary = cfg
387 .ui
388 .primary
389 .filter(|vendor| {
390 primary_choices.contains(vendor)
391 && (*vendor != VendorId::Copilot || cfg.copilot.enabled)
392 })
393 .or_else(|| primary_choices.first().copied())
394 .unwrap_or_else(|| cfg.ui.primary.unwrap_or(VendorId::Anthropic));
395 Self {
396 focus: Focus::Primary,
397 primary_choices,
398 primary,
399 keys,
400 vendors: VendorId::all()
401 .iter()
402 .map(|id| ProviderSwitch {
403 enabled: cfg.is_enabled(*id),
404 dirty: false,
405 })
406 .collect(),
407 notify_enabled: cfg.notifications.enabled,
408 notify_enabled_dirty: false,
409 notify_threshold: KeyInput::from_config(Some(&cfg.notifications.threshold.to_string())),
410 status: String::new(),
411 scroll: 0,
412 }
413 }
414
415 fn focused_key_mut(&mut self) -> Option<&mut KeyInput> {
417 match self.focus {
418 Focus::Key(i) => self.keys.get_mut(i),
419 _ => None,
420 }
421 }
422}
423
424#[derive(Debug, Clone, Copy, PartialEq, Eq)]
426pub enum Action {
427 Continue,
429 Close,
431 SavedAndClose,
433 Quit,
436}
437
438#[cfg(unix)]
441const PERMS_NOTE: &str = " (chmod 600)";
442#[cfg(not(unix))]
443const PERMS_NOTE: &str = "";
444
445fn saved_status() -> String {
446 format!(
447 "saved to {}{}",
448 crate::config::config_path_hint(),
449 PERMS_NOTE
450 )
451}
452
453pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
455 if matches!(code, KeyCode::Esc) {
456 return Action::Close;
457 }
458 if matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL) {
459 return Action::Quit;
460 }
461 if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
463 return try_save(state);
464 }
465 if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
466 if let Some(input) = state.focused_key_mut() {
467 input.toggle_reveal();
468 }
469 return Action::Continue;
470 }
471 match code {
472 KeyCode::Tab | KeyCode::Down => {
473 state.focus = state.focus.next();
474 return Action::Continue;
475 }
476 KeyCode::BackTab | KeyCode::Up => {
477 state.focus = state.focus.prev();
478 return Action::Continue;
479 }
480 _ => {}
481 }
482
483 if matches!(code, KeyCode::Char(_))
488 && mods.intersects(
489 KeyModifiers::CONTROL
490 | KeyModifiers::ALT
491 | KeyModifiers::SUPER
492 | KeyModifiers::HYPER
493 | KeyModifiers::META,
494 )
495 {
496 return Action::Continue;
497 }
498
499 match state.focus {
501 Focus::Primary => handle_primary(state, code),
502 Focus::Key(i) => {
503 if let Some(input) = state.keys.get_mut(i) {
504 handle_input(input, code);
505 }
506 }
507 Focus::Vendor(i) => handle_provider_switch(state, i, code),
508 Focus::NotifyEnabled => handle_notify_enabled(state, code),
509 Focus::NotifyThreshold => handle_threshold_input(&mut state.notify_threshold, code),
510 Focus::Save => {
511 if matches!(code, KeyCode::Enter) {
512 return try_save(state);
513 }
514 }
515 }
516 Action::Continue
517}
518
519fn try_save(state: &mut SettingsState) -> Action {
520 match save_to_config_default(state) {
521 Ok(()) => {
522 state.status = saved_status();
523 Action::SavedAndClose
524 }
525 Err(e) => {
526 state.status = format!("save failed: {e}");
527 Action::Continue
528 }
529 }
530}
531
532fn handle_primary(state: &mut SettingsState, code: KeyCode) {
533 let choices = &state.primary_choices;
535 let Some(idx) = choices.iter().position(|v| *v == state.primary) else {
536 return;
537 };
538 let step = match code {
539 KeyCode::Left => -1,
540 KeyCode::Right | KeyCode::Char(' ') => 1,
541 _ => return,
542 };
543 state.primary = choices[((idx as i32 + step).rem_euclid(choices.len() as i32)) as usize];
544}
545
546fn handle_input(input: &mut KeyInput, code: KeyCode) {
547 match code {
548 KeyCode::Char(c) => input.insert_char(c),
549 KeyCode::Backspace => input.backspace(),
550 KeyCode::Delete => input.delete(),
551 KeyCode::Left => input.move_left(),
552 KeyCode::Right => input.move_right(),
553 KeyCode::Home => input.move_home(),
554 KeyCode::End => input.move_end(),
555 _ => {}
556 }
557}
558
559fn handle_notify_enabled(state: &mut SettingsState, code: KeyCode) {
562 if matches!(code, KeyCode::Left | KeyCode::Right | KeyCode::Char(' ')) {
563 state.notify_enabled = !state.notify_enabled;
564 state.notify_enabled_dirty = true;
565 }
566}
567
568fn handle_provider_switch(state: &mut SettingsState, index: usize, code: KeyCode) {
572 if matches!(code, KeyCode::Left | KeyCode::Right | KeyCode::Char(' '))
573 && let Some(switch) = state.vendors.get_mut(index)
574 {
575 switch.enabled = !switch.enabled;
576 switch.dirty = true;
577 }
578}
579
580fn handle_threshold_input(input: &mut KeyInput, code: KeyCode) {
584 match code {
585 KeyCode::Char(c) if c.is_ascii_digit() => input.insert_char(c),
586 KeyCode::Backspace => input.backspace(),
587 KeyCode::Delete => input.delete(),
588 KeyCode::Left => input.move_left(),
589 KeyCode::Right => input.move_right(),
590 KeyCode::Home => input.move_home(),
591 KeyCode::End => input.move_end(),
592 _ => {}
593 }
594}
595
596fn save_to_config_default(state: &SettingsState) -> Result<()> {
599 let path = default_config_path()?;
600 if let Some(parent) = path.parent() {
601 std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
602 }
603 save_to_path(state, &path)?;
604 crate::waybar::request_refresh();
605 Ok(())
606}
607
608pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
611 let mut doc = read_config_document(path)?;
612
613 if let Some(table) = doc
617 .get_mut("copilot")
618 .and_then(toml_edit::Item::as_table_mut)
619 {
620 table.remove("token");
621 table.remove("token_env");
622 }
623
624 if state.primary_choices.contains(&state.primary) {
633 set_string(&mut doc, "ui", "primary", state.primary.slug())?;
634 if state.primary == VendorId::Copilot || KEY_VENDORS.iter().any(|kv| kv.id == state.primary)
635 {
636 set_bool(&mut doc, state.primary.config_section(), "enabled", true)?;
637 }
638 }
639
640 for (i, kv) in KEY_VENDORS.iter().enumerate() {
641 let Some(input) = state.keys.get(i) else {
642 continue;
643 };
644 update_key(&mut doc, kv, input)?;
645 }
646
647 for (i, id) in VendorId::all().iter().enumerate() {
653 let Some(switch) = state.vendors.get(i) else {
654 continue;
655 };
656 if switch.dirty {
657 crate::config::set_vendor_enabled_in_doc(&mut doc, id.slug(), switch.enabled)?;
658 }
659 }
660
661 if state.notify_enabled_dirty {
664 set_bool(&mut doc, "notifications", "enabled", state.notify_enabled)?;
665 }
666 if state.notify_threshold.dirty {
667 let raw = state.notify_threshold.buf.trim();
668 let threshold = raw.parse::<u8>();
669 match threshold {
670 Ok(threshold) if (1..=100).contains(&threshold) => {
671 set_value(
672 &mut doc,
673 "notifications",
674 "threshold",
675 Some(toml_edit::Value::from(i64::from(threshold))),
676 )?;
677 }
678 _ => {
679 return Err(AppError::Other(format!(
680 "[notifications] threshold must be a whole number between 1 and 100, got {raw:?}"
681 )));
682 }
683 }
684 }
685
686 write_config_document(path, &doc)
687}
688
689fn update_key(doc: &mut DocumentMut, vendor: &KeyVendor, input: &KeyInput) -> Result<()> {
695 if !input.dirty {
696 return Ok(());
697 }
698 if input.buf.is_empty() {
699 if let Some(table) = doc
700 .get_mut(vendor.section)
701 .and_then(toml_edit::Item::as_table_mut)
702 {
703 table.remove(vendor.config_key);
704 }
705 return Ok(());
706 }
707 set_string(doc, vendor.section, vendor.config_key, &input.buf)?;
708 set_bool(doc, vendor.section, "enabled", true)
709}
710
711fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
714 let table = doc
715 .entry(section)
716 .or_insert_with(toml_edit::table)
717 .as_table_mut()
718 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
719
720 if let Some(item) = table.get_mut(key)
721 && let Some(v) = item.as_value_mut()
722 {
723 *v = toml_edit::Value::from(new_value);
724 v.decor_mut().set_prefix(" ");
725 return Ok(());
726 }
727 table.insert(key, value(new_value));
728 Ok(())
729}
730
731fn default_config_path() -> Result<PathBuf> {
732 crate::config::resolved_path()
737 .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
738}
739
740#[derive(Debug, Serialize)]
747struct SettingsSnapshot {
748 schema_version: u8,
749 primary: String,
750 primary_choices: Vec<PrimaryChoice>,
751 keys: Vec<KeyStatus>,
752 vendors: Vec<VendorStatus>,
755}
756
757#[derive(Debug, Serialize)]
758struct PrimaryChoice {
759 id: String,
760 label: String,
761}
762
763#[derive(Debug, Serialize)]
764struct KeyStatus {
765 id: String,
766 label: String,
767 environment: String,
768 secret_label: String,
769 note: String,
770 configured: bool,
771 inline_configured: bool,
772 environment_configured: bool,
773}
774
775#[derive(Debug, Serialize)]
778struct VendorStatus {
779 id: String,
780 label: String,
781 enabled: bool,
782}
783
784#[derive(Debug, Deserialize)]
790#[serde(deny_unknown_fields)]
791struct ApplyRequest {
792 schema_version: u8,
793 primary: Option<String>,
794 #[serde(default)]
795 keys: BTreeMap<String, KeyMutation>,
796 #[serde(default)]
797 vendors: BTreeMap<String, bool>,
798}
799
800#[derive(Debug, Deserialize)]
801#[serde(tag = "action", rename_all = "lowercase", deny_unknown_fields)]
802enum KeyMutation {
803 Set { value: String },
804 Clear,
805}
806
807const SETTINGS_SCHEMA_VERSION: u8 = 1;
808const MAX_SETTINGS_REQUEST_BYTES: u64 = 64 * 1024;
809const MAX_API_KEY_BYTES: usize = 16 * 1024;
810
811fn snapshot_from_config_with(
812 cfg: &Config,
813 environment_configured: impl Fn(&str) -> bool,
814) -> SettingsSnapshot {
815 let state = SettingsState::from_config(cfg);
816 let primary_choices = state
817 .primary_choices
818 .iter()
819 .map(|id| PrimaryChoice {
820 id: id.slug().to_string(),
821 label: id.display_name().to_string(),
822 })
823 .collect();
824 let keys = KEY_VENDORS
825 .iter()
826 .map(|vendor| {
827 let environment = cfg.api_key_env_for(vendor.id);
828 let inline_configured = cfg.inline_api_key(vendor.id).is_some();
829 let environment_configured = environment_configured(environment);
830 KeyStatus {
831 id: vendor.id.slug().to_string(),
832 label: vendor.label.to_string(),
833 environment: environment.to_string(),
834 secret_label: vendor.secret_label.to_string(),
835 note: vendor.note.to_string(),
836 configured: inline_configured || environment_configured,
837 inline_configured,
838 environment_configured,
839 }
840 })
841 .collect();
842 let vendors = VendorId::all()
843 .iter()
844 .map(|id| VendorStatus {
845 id: id.slug().to_string(),
846 label: id.display_name().to_string(),
847 enabled: cfg.is_enabled(*id),
848 })
849 .collect();
850 SettingsSnapshot {
851 schema_version: SETTINGS_SCHEMA_VERSION,
852 primary: state.primary.slug().to_string(),
853 primary_choices,
854 keys,
855 vendors,
856 }
857}
858
859fn settings_snapshot_json(cfg: &Config) -> Result<String> {
860 Ok(serde_json::to_string(&snapshot_from_config_with(
861 cfg,
862 |environment| std::env::var_os(environment).is_some_and(|value| !value.is_empty()),
863 ))?)
864}
865
866#[cfg(test)]
867fn settings_snapshot_json_with(
868 cfg: &Config,
869 environment_configured: impl Fn(&str) -> bool,
870) -> Result<String> {
871 Ok(serde_json::to_string(&snapshot_from_config_with(
872 cfg,
873 environment_configured,
874 ))?)
875}
876
877fn vendor_from_slug(slug: &str) -> Option<VendorId> {
878 VendorId::from_slug(slug)
879}
880
881fn state_from_apply_request(cfg: &Config, raw: &str) -> Result<SettingsState> {
882 let request: ApplyRequest = serde_json::from_str(raw)?;
883 if request.schema_version != SETTINGS_SCHEMA_VERSION {
884 return Err(AppError::Other(format!(
885 "unsupported settings schema version {}",
886 request.schema_version
887 )));
888 }
889
890 let mut state = SettingsState::from_config(cfg);
891 if let Some(primary) = request.primary {
892 let id = vendor_from_slug(&primary)
893 .ok_or_else(|| AppError::Other(format!("unknown primary vendor {primary:?}")))?;
894 if !state.primary_choices.contains(&id) {
895 return Err(AppError::Other(format!(
896 "primary vendor {primary:?} is not enabled"
897 )));
898 }
899 state.primary = id;
900 }
901
902 for (id, mutation) in request.keys {
903 let index = KEY_VENDORS
904 .iter()
905 .position(|vendor| vendor.id.slug() == id)
906 .ok_or_else(|| AppError::Other(format!("unknown credential vendor {id:?}")))?;
907 let input = &mut state.keys[index];
908 match mutation {
909 KeyMutation::Set { value } => {
910 if value.is_empty() {
911 return Err(AppError::Other(format!(
912 "{} for {id:?} is empty; use the clear action to remove it",
913 KEY_VENDORS[index].secret_label
914 )));
915 }
916 if value.len() > MAX_API_KEY_BYTES {
917 return Err(AppError::Other(format!(
918 "{} for {id:?} exceeds {MAX_API_KEY_BYTES} bytes",
919 KEY_VENDORS[index].secret_label
920 )));
921 }
922 if value.chars().any(char::is_control) {
923 return Err(AppError::Other(format!(
924 "{} for {id:?} contains control characters",
925 KEY_VENDORS[index].secret_label
926 )));
927 }
928 input.buf = value;
929 }
930 KeyMutation::Clear => input.buf.clear(),
931 }
932 input.cursor = input.buf.chars().count();
933 input.dirty = true;
934 input.revealed = false;
935 }
936
937 for (slug, enabled) in request.vendors {
941 let index = VendorId::all()
942 .iter()
943 .position(|id| id.slug() == slug)
944 .ok_or_else(|| AppError::Other(format!("unknown provider {slug:?}")))?;
945 state.vendors[index] = ProviderSwitch {
946 enabled,
947 dirty: true,
948 };
949 }
950 Ok(state)
951}
952
953#[cfg(test)]
954fn apply_settings_json_to_path(cfg: &Config, raw: &str, path: &Path) -> Result<()> {
955 let state = state_from_apply_request(cfg, raw)?;
956 save_to_path(&state, path)
957}
958
959fn read_settings_request<R: BufRead>(reader: R) -> Result<String> {
960 let mut limited = reader.take(MAX_SETTINGS_REQUEST_BYTES + 1);
961 let mut bytes = Vec::new();
962 limited.read_until(b'\n', &mut bytes)?;
963 if bytes.len() as u64 > MAX_SETTINGS_REQUEST_BYTES {
964 return Err(AppError::Other(format!(
965 "settings request exceeds {MAX_SETTINGS_REQUEST_BYTES} bytes"
966 )));
967 }
968 if bytes.last() == Some(&b'\n') {
969 bytes.pop();
970 if bytes.last() == Some(&b'\r') {
971 bytes.pop();
972 }
973 }
974 String::from_utf8(bytes)
975 .map_err(|_| AppError::Other("settings request is not valid UTF-8".into()))
976}
977
978fn apply_settings_from_stdin() -> Result<()> {
979 let raw = read_settings_request(std::io::stdin().lock())?;
980 let cfg = Config::load()?;
981 let state = state_from_apply_request(&cfg, &raw)?;
982 save_to_config_default(&state)
983}
984
985fn enable_vendor_at(path: &Path, vendor: VendorId) -> Result<()> {
987 let mut doc = read_config_document(path)?;
988 let before = doc.to_string();
989 set_bool(&mut doc, vendor.config_section(), "enabled", true)?;
990 if doc.to_string() != before {
991 write_config_document(path, &doc)?;
992 }
993 Ok(())
994}
995
996pub fn run_cli(action: &crate::widget::cli::SettingsAction) -> i32 {
1000 let result = match action {
1001 crate::widget::cli::SettingsAction::Enable { vendor } => default_config_path()
1002 .and_then(|path| enable_vendor_at(&path, vendor.to_id()))
1003 .map(|()| {
1004 crate::waybar::request_refresh();
1005 println!(r#"{{"ok":true}}"#);
1006 }),
1007 crate::widget::cli::SettingsAction::Show => Config::load()
1008 .and_then(|cfg| settings_snapshot_json(&cfg))
1009 .map(|json| println!("{json}")),
1010 crate::widget::cli::SettingsAction::Apply => {
1011 apply_settings_from_stdin().map(|()| println!(r#"{{"ok":true}}"#))
1012 }
1013 };
1014 match result {
1015 Ok(()) => 0,
1016 Err(error) => {
1017 eprintln!("settings: {error}");
1018 1
1019 }
1020 }
1021}
1022
1023struct BodyLayout {
1029 primary: usize,
1030 first_key: usize,
1031 first_vendor: usize,
1032 notify_enabled: usize,
1033 notify_threshold: usize,
1034 save: usize,
1035 rows: usize,
1037}
1038
1039fn body_layout(keys: usize, vendors: usize) -> BodyLayout {
1040 let first_key = 4;
1041 let first_vendor = first_key + keys + 2;
1042 let notify_enabled = first_vendor + vendors + 2;
1043 BodyLayout {
1044 primary: 1,
1045 first_key,
1046 first_vendor,
1047 notify_enabled,
1048 notify_threshold: notify_enabled + 1,
1049 save: notify_enabled + 3,
1050 rows: notify_enabled + 4,
1051 }
1052}
1053
1054fn focus_line(state: &SettingsState) -> usize {
1056 let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
1057 match state.focus {
1058 Focus::Primary => layout.primary,
1059 Focus::Key(i) => layout.first_key + i,
1060 Focus::Vendor(i) => layout.first_vendor + i,
1061 Focus::NotifyEnabled => layout.notify_enabled,
1062 Focus::NotifyThreshold => layout.notify_threshold,
1063 Focus::Save => layout.save,
1064 }
1065}
1066
1067fn follow_focus_scroll(focused: usize, total: usize, viewport: u16, current: u16) -> u16 {
1071 if viewport == 0 || total == 0 {
1072 return 0;
1073 }
1074 let max_offset = (total as u16).saturating_sub(viewport);
1075 let focused = focused.min(total - 1) as u16;
1076 let wanted = if focused < current {
1077 focused
1078 } else if focused >= current.saturating_add(viewport) {
1079 (focused + 1).saturating_sub(viewport)
1082 } else {
1083 current
1084 };
1085 wanted.min(max_offset)
1086}
1087
1088pub fn render(f: &mut Frame, area: Rect, state: &mut SettingsState, theme: &Theme) {
1090 let modal = centered_rect(74, 88, area);
1091 f.render_widget(Clear, modal);
1092
1093 let bubble = bubble_theme(theme);
1094 let block = bubble.titled_modal_block(" Settings ");
1095 let inner = block.inner(modal);
1096 f.render_widget(block, modal);
1097
1098 let chunks = Layout::default()
1100 .direction(Direction::Vertical)
1101 .constraints([Constraint::Min(0), Constraint::Length(1)])
1102 .split(inner);
1103
1104 let mut lines: Vec<Line> = vec![
1106 section_header("Primary vendor", "shown first on the bar / TUI", &bubble),
1107 primary_line(state, &bubble),
1108 Line::from(""),
1109 section_header(
1110 "Credentials",
1111 "pick a row, type the credential, then Ctrl-S — Claude & Codex use CLI login",
1112 &bubble,
1113 ),
1114 ];
1115 for (i, kv) in KEY_VENDORS.iter().enumerate() {
1116 let focused = state.focus == Focus::Key(i);
1117 lines.push(key_row(kv, &state.keys[i], focused, &bubble));
1118 }
1119 lines.push(Line::from(""));
1120
1121 lines.push(section_header(
1123 "Providers",
1124 "which vendors the report, bar and TUI fetch at all",
1125 &bubble,
1126 ));
1127 for (i, id) in VendorId::all().iter().enumerate() {
1128 let switch = state.vendors.get(i).copied().unwrap_or(ProviderSwitch {
1129 enabled: false,
1130 dirty: false,
1131 });
1132 lines.push(provider_row(
1133 id,
1134 switch,
1135 state.focus == Focus::Vendor(i),
1136 &bubble,
1137 ));
1138 }
1139 lines.push(Line::from(""));
1140
1141 lines.push(section_header(
1143 "Notifications",
1144 "desktop alert when a quota window crosses the threshold",
1145 &bubble,
1146 ));
1147 lines.push(notify_enabled_line(state, &bubble));
1148 lines.push(notify_threshold_line(state, &bubble));
1149 lines.push(Line::from(""));
1150
1151 lines.push(save_line(state.focus == Focus::Save, &bubble));
1153 if !state.status.is_empty() {
1154 let ok = state.status.starts_with("saved");
1155 let mark = if ok { " ✓ " } else { " ✗ " };
1156 let style = if ok { bubble.accent } else { bubble.selected };
1157 lines.push(Line::from(vec![
1158 Span::styled(mark, style.add_modifier(Modifier::BOLD)),
1159 Span::styled(state.status.clone(), bubble.muted),
1160 ]));
1161 }
1162
1163 let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
1166 let total = layout.rows + usize::from(!state.status.is_empty());
1167 state.scroll = follow_focus_scroll(focus_line(state), total, chunks[0].height, state.scroll);
1168 f.render_widget(Paragraph::new(lines).scroll((state.scroll, 0)), chunks[0]);
1169
1170 let hint = match state.focus {
1172 Focus::Primary => bubble.help_line([
1173 ("↑↓/tab", "move"),
1174 ("←→", "change vendor"),
1175 ("^S", "save"),
1176 ("esc", "close"),
1177 ]),
1178 Focus::Key(_) => bubble.help_line([
1179 ("↑↓/tab", "move"),
1180 ("type", "edit key"),
1181 ("^V", "reveal"),
1182 ("^S", "save"),
1183 ("esc", "close"),
1184 ]),
1185 Focus::Vendor(_) => bubble.help_line([
1186 ("↑↓/tab", "move"),
1187 ("←→/space", "toggle"),
1188 ("^S", "save"),
1189 ("esc", "close"),
1190 ]),
1191 Focus::NotifyEnabled => bubble.help_line([
1192 ("↑↓/tab", "move"),
1193 ("←→/space", "toggle"),
1194 ("^S", "save"),
1195 ("esc", "close"),
1196 ]),
1197 Focus::NotifyThreshold => bubble.help_line([
1198 ("↑↓/tab", "move"),
1199 ("type", "digits 1-100"),
1200 ("^S", "save"),
1201 ("esc", "close"),
1202 ]),
1203 Focus::Save => {
1204 bubble.help_line([("↑↓/tab", "move"), ("enter/^S", "save"), ("esc", "close")])
1205 }
1206 };
1207 f.render_widget(Paragraph::new(hint), chunks[1]);
1208}
1209
1210fn section_header(title: &str, sub: &str, theme: &BubbleTheme) -> Line<'static> {
1211 Line::from(vec![
1212 theme.span(" "),
1213 Span::styled(title.to_string(), theme.title.add_modifier(Modifier::BOLD)),
1214 theme.muted(format!(" — {sub}")),
1215 ])
1216}
1217
1218fn primary_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1219 let focused = state.focus == Focus::Primary;
1220 let name = state.primary.display_name().to_string();
1221 if focused {
1222 Line::from(vec![
1223 theme.span(" "),
1224 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1225 Span::styled("◀ ", theme.accent),
1226 Span::styled(
1227 format!(" {name} "),
1228 theme
1229 .selected
1230 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1231 ),
1232 Span::styled(" ▶", theme.accent),
1233 theme.muted(" ← → to change"),
1234 ])
1235 } else {
1236 Line::from(vec![theme.span(" "), Span::styled(name, theme.text)])
1237 }
1238}
1239
1240fn key_row(kv: &KeyVendor, input: &KeyInput, focused: bool, theme: &BubbleTheme) -> Line<'static> {
1241 let label = format!("{:<11}", kv.label);
1242 let value = value_text(input, focused);
1243
1244 let env_name = kv.id.api_key_env();
1246 let env_set = std::env::var(env_name)
1247 .map(|v| !v.is_empty())
1248 .unwrap_or(false);
1249 let mut suffix = format!(" {env_name}");
1250 if env_set {
1251 suffix.push_str(" · env set (overrides)");
1252 }
1253 if !kv.note.is_empty() {
1254 suffix.push_str(&format!(" · {}", kv.note));
1255 }
1256
1257 if focused {
1258 let val_style = if input.buf.is_empty() {
1259 theme.accent.add_modifier(Modifier::BOLD)
1260 } else {
1261 theme.selected.add_modifier(Modifier::REVERSED)
1262 };
1263 let mut spans = vec![
1264 theme.span(" "),
1265 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1266 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1267 Span::styled(format!(" {value} "), val_style),
1268 ];
1269 if input.revealed {
1270 spans.push(theme.muted(" [revealed]"));
1271 }
1272 spans.push(theme.muted(suffix));
1273 Line::from(spans)
1274 } else {
1275 let val_style = if input.buf.is_empty() {
1276 theme.muted
1277 } else {
1278 theme.text
1279 };
1280 Line::from(vec![
1281 theme.span(" "),
1282 Span::styled(label, theme.text),
1283 Span::styled(format!(" {value}"), val_style),
1284 theme.muted(suffix),
1285 ])
1286 }
1287}
1288
1289fn value_text(input: &KeyInput, focused: bool) -> String {
1292 if input.buf.is_empty() {
1293 return if focused {
1294 "‸".to_string()
1295 } else {
1296 "(empty)".to_string()
1297 };
1298 }
1299 let base = input.display();
1300 if !focused {
1301 return base;
1302 }
1303 let mut chars: Vec<char> = base.chars().collect();
1304 let pos = input.cursor.min(chars.len());
1305 chars.insert(pos, '‸');
1306 chars.into_iter().collect()
1307}
1308
1309fn provider_row(
1312 id: &VendorId,
1313 switch: ProviderSwitch,
1314 focused: bool,
1315 theme: &BubbleTheme,
1316) -> Line<'static> {
1317 let label = format!("{:<11}", id.display_name());
1318 let value = if switch.enabled { "on" } else { "off" };
1319 if focused {
1320 Line::from(vec![
1321 theme.span(" "),
1322 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1323 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1324 Span::styled(
1325 format!(" ◀ {value} ▶ "),
1326 theme
1327 .selected
1328 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1329 ),
1330 ])
1331 } else {
1332 Line::from(vec![
1333 theme.span(" "),
1334 Span::styled(label, theme.text),
1335 Span::styled(format!(" {value}"), theme.muted),
1336 ])
1337 }
1338}
1339
1340fn notify_enabled_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1343 let focused = state.focus == Focus::NotifyEnabled;
1344 let label = format!("{:<11}", "Quota alerts");
1345 let value = if state.notify_enabled { "on" } else { "off" };
1346 if focused {
1347 Line::from(vec![
1348 theme.span(" "),
1349 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1350 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1351 Span::styled(
1352 format!(" ◀ {value} ▶ "),
1353 theme
1354 .selected
1355 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1356 ),
1357 ])
1358 } else {
1359 Line::from(vec![
1360 theme.span(" "),
1361 Span::styled(label, theme.text),
1362 Span::styled(format!(" {value}"), theme.muted),
1363 ])
1364 }
1365}
1366
1367fn notify_threshold_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1370 let focused = state.focus == Focus::NotifyThreshold;
1371 let label = format!("{:<11}", "Threshold %");
1372 let input = &state.notify_threshold;
1373 let value = if input.buf.is_empty() {
1374 if focused {
1375 "‸".to_string()
1376 } else {
1377 "(97)".to_string()
1378 }
1379 } else {
1380 let mut chars: Vec<char> = input.buf.chars().collect();
1381 if focused {
1382 let pos = input.cursor.min(chars.len());
1383 chars.insert(pos, '‸');
1384 }
1385 chars.into_iter().collect()
1386 };
1387 if focused {
1388 Line::from(vec![
1389 theme.span(" "),
1390 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1391 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1392 Span::styled(
1393 format!(" {value} "),
1394 theme
1395 .selected
1396 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1397 ),
1398 theme.muted(" 1-100"),
1399 ])
1400 } else {
1401 Line::from(vec![
1402 theme.span(" "),
1403 Span::styled(label, theme.text),
1404 Span::styled(format!(" {value}"), theme.text),
1405 theme.muted(" 1-100"),
1406 ])
1407 }
1408}
1409
1410fn save_line(focused: bool, theme: &BubbleTheme) -> Line<'static> {
1411 let style = if focused {
1412 theme
1413 .selected
1414 .add_modifier(Modifier::REVERSED | Modifier::BOLD)
1415 } else {
1416 theme.accent.add_modifier(Modifier::BOLD)
1417 };
1418 let marker = if focused { "▸ " } else { " " };
1419 Line::from(vec![
1420 theme.span(" "),
1421 Span::styled(marker, theme.accent.add_modifier(Modifier::BOLD)),
1422 Span::styled(" Save (Ctrl-S) ", style),
1423 ])
1424}
1425
1426fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1428 let popup_h = (r.height * percent_y) / 100;
1429 let popup_w = (r.width * percent_x) / 100;
1430 Rect {
1431 x: r.x + (r.width - popup_w) / 2,
1432 y: r.y + (r.height - popup_h) / 2,
1433 width: popup_w,
1434 height: popup_h,
1435 }
1436}
1437
1438pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
1440
1441#[cfg(test)]
1442mod tests {
1443 use super::*;
1444 use tempfile::TempDir;
1445
1446 fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
1447 crate::cache::closed_temp_file("config.toml", initial)
1448 }
1449
1450 fn key_index(id: VendorId) -> usize {
1451 KEY_VENDORS.iter().position(|kv| kv.id == id).unwrap()
1452 }
1453
1454 #[test]
1455 fn explicit_enable_preserves_other_settings_and_is_idempotent() {
1456 let dir = tempfile::tempdir().unwrap();
1457 let path = dir.path().join("config.toml");
1458 let original = "# keep me\n[anthropic]\nenabled = false # intentional\n[openrouter]\napi_key = \"test-key\"\nenabled = false\n";
1459 std::fs::write(&path, original).unwrap();
1460 enable_vendor_at(&path, VendorId::Anthropic).unwrap();
1461 let expected = original.replacen("enabled = false", "enabled = true", 1);
1462 assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
1463 enable_vendor_at(&path, VendorId::Anthropic).unwrap();
1464 assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
1465 }
1466
1467 #[test]
1468 fn explicit_enable_creates_missing_config_and_rejects_malformed_config() {
1469 let dir = tempfile::tempdir().unwrap();
1470 let path = dir.path().join("nested/config.toml");
1471 enable_vendor_at(&path, VendorId::Anthropic).unwrap();
1472 assert!(
1473 std::fs::read_to_string(&path)
1474 .unwrap()
1475 .contains("enabled = true")
1476 );
1477 let broken = "[anthropic\n";
1478 std::fs::write(&path, broken).unwrap();
1479 assert!(enable_vendor_at(&path, VendorId::Anthropic).is_err());
1480 assert_eq!(std::fs::read_to_string(&path).unwrap(), broken);
1481 }
1482
1483 fn blank_state(primary: VendorId) -> SettingsState {
1484 SettingsState {
1485 focus: Focus::Primary,
1486 primary_choices: VendorId::all().to_vec(),
1487 primary,
1488 keys: KEY_VENDORS.iter().map(|_| KeyInput::default()).collect(),
1489 vendors: VendorId::all()
1490 .iter()
1491 .map(|id| ProviderSwitch {
1492 enabled: matches!(id.slug(), "anthropic" | "openai" | "zai" | "openrouter"),
1493 dirty: false,
1494 })
1495 .collect(),
1496 notify_enabled: true,
1497 notify_enabled_dirty: false,
1498 notify_threshold: KeyInput::from_config(Some("97")),
1499 status: String::new(),
1500 scroll: 0,
1501 }
1502 }
1503
1504 fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
1506 let mut s = blank_state(primary);
1507 s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some(zai));
1508 s.keys[key_index(VendorId::Zai)].dirty = true;
1509 s.keys[key_index(VendorId::Openrouter)] = KeyInput::from_config(Some(opr));
1510 s.keys[key_index(VendorId::Openrouter)].dirty = true;
1511 s
1512 }
1513
1514 #[test]
1515 fn focus_cycles_through_primary_all_keys_notifications_and_save() {
1516 let mut f = Focus::Primary;
1517 let mut seen = vec![f];
1518 for _ in 0..(KEY_VENDORS.len() + PROVIDER_SWITCH_COUNT + 4) {
1521 f = f.next();
1522 seen.push(f);
1523 }
1524 assert_eq!(seen.first(), Some(&Focus::Primary));
1527 assert_eq!(seen.last(), Some(&Focus::Primary));
1528 assert!(seen.contains(&Focus::Key(0)));
1529 assert!(seen.contains(&Focus::Key(KEY_VENDORS.len() - 1)));
1530 assert!(seen.contains(&Focus::Vendor(0)));
1531 assert!(seen.contains(&Focus::Vendor(PROVIDER_SWITCH_COUNT - 1)));
1532 assert!(seen.contains(&Focus::NotifyEnabled));
1533 assert!(seen.contains(&Focus::NotifyThreshold));
1534 assert!(seen.contains(&Focus::Save));
1535 assert_eq!(Focus::Primary.next().prev(), Focus::Primary);
1538 assert_eq!(Focus::Save.prev().next(), Focus::Save);
1539 assert_eq!(
1540 Focus::Key(KEY_VENDORS.len() - 1).next(),
1541 Focus::Vendor(0),
1542 "the first provider row follows the last credential"
1543 );
1544 assert_eq!(Focus::Vendor(0).prev(), Focus::Key(KEY_VENDORS.len() - 1));
1545 assert_eq!(
1546 Focus::Vendor(PROVIDER_SWITCH_COUNT - 1).next(),
1547 Focus::NotifyEnabled
1548 );
1549 assert_eq!(
1550 Focus::NotifyEnabled.prev(),
1551 Focus::Vendor(PROVIDER_SWITCH_COUNT - 1)
1552 );
1553 assert_eq!(Focus::NotifyThreshold.next(), Focus::Save);
1554 assert_eq!(Focus::Primary.prev(), Focus::Save);
1555 }
1556
1557 #[test]
1561 fn provider_switch_count_matches_the_vendor_enumeration() {
1562 assert_eq!(PROVIDER_SWITCH_COUNT, VendorId::all().len());
1563 }
1564
1565 #[test]
1566 fn from_config_prefills_provider_switches_from_enabled_vendors() {
1567 let mut cfg = Config::default();
1568 cfg.grok.enabled = true;
1569 cfg.anthropic.enabled = false;
1570 let s = SettingsState::from_config_with(&cfg, |_| false);
1571 for (i, id) in VendorId::all().iter().enumerate() {
1572 assert_eq!(
1573 s.vendors[i],
1574 ProviderSwitch {
1575 enabled: cfg.is_enabled(*id),
1576 dirty: false
1577 },
1578 "{}",
1579 id.slug()
1580 );
1581 }
1582 assert!(!s.vendors[vendor_index(VendorId::Anthropic)].enabled);
1583 assert!(s.vendors[vendor_index(VendorId::Grok)].enabled);
1584 }
1585
1586 fn vendor_index(id: VendorId) -> usize {
1587 VendorId::all().iter().position(|v| *v == id).unwrap()
1588 }
1589
1590 #[test]
1591 fn provider_switch_flips_on_left_right_and_space() {
1592 let mut s = blank_state(VendorId::Anthropic);
1593 let grok = vendor_index(VendorId::Grok);
1594 s.focus = Focus::Vendor(grok);
1595 assert!(!s.vendors[grok].enabled);
1596 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1597 assert_eq!(
1598 s.vendors[grok],
1599 ProviderSwitch {
1600 enabled: true,
1601 dirty: true
1602 }
1603 );
1604 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
1605 assert!(!s.vendors[grok].enabled);
1606 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1607 assert!(s.vendors[grok].enabled);
1608 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
1610 assert!(s.vendors[grok].enabled);
1611 assert!(s.vendors[grok].dirty);
1612 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::CONTROL);
1614 assert!(s.vendors[grok].enabled);
1615 let before = s.vendors[grok];
1617 handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE);
1618 assert_ne!(s.focus, Focus::Vendor(grok));
1619 assert_eq!(s.vendors[grok], before);
1620 }
1621
1622 #[test]
1623 fn save_writes_only_toggled_providers_and_round_trips() {
1624 let (_dir, path) = temp_config(Some("# keep\n[anthropic]\nenabled = true\n"));
1625 let mut s = blank_state(VendorId::Anthropic);
1626 s.primary_choices = vec![VendorId::Anthropic];
1627 s.vendors[vendor_index(VendorId::Grok)].enabled = true;
1629 s.vendors[vendor_index(VendorId::Grok)].dirty = true;
1630 s.vendors[vendor_index(VendorId::Anthropic)].enabled = false;
1631 s.vendors[vendor_index(VendorId::Anthropic)].dirty = true;
1632 save_to_path(&s, &path).unwrap();
1633
1634 let raw = std::fs::read_to_string(&path).unwrap();
1635 assert!(raw.starts_with("# keep\n"), "{raw}");
1636 assert!(raw.contains("[anthropic]\nenabled = false"), "{raw}");
1637 assert!(raw.contains("[grok]\nenabled = true"), "{raw}");
1638 assert!(!raw.contains("[zai]"), "{raw}");
1642 assert!(!raw.contains("[openrouter]"), "{raw}");
1643
1644 let reloaded = Config::load_from(&path).unwrap();
1645 assert!(reloaded.is_enabled(VendorId::Grok));
1646 assert!(!reloaded.is_enabled(VendorId::Anthropic));
1647 assert!(
1648 reloaded.is_enabled(VendorId::Zai),
1649 "untouched default stays on"
1650 );
1651 }
1652
1653 #[test]
1654 fn an_untouched_overlay_save_writes_no_provider_sections() {
1655 let (_dir, path) = temp_config(None);
1656 let mut s = blank_state(VendorId::Anthropic);
1657 s.primary_choices = vec![VendorId::Anthropic];
1658 save_to_path(&s, &path).unwrap();
1659 let raw = std::fs::read_to_string(&path).unwrap();
1660 assert!(!raw.contains("enabled ="), "{raw}");
1661 assert!(!raw.contains("[grok]"), "{raw}");
1662 }
1663
1664 #[test]
1667 fn an_explicit_provider_off_wins_over_a_pasted_credential() {
1668 let (_dir, path) = temp_config(None);
1669 let mut s = blank_state(VendorId::Zai);
1670 s.primary_choices = vec![VendorId::Zai];
1671 s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some("sk-zai"));
1672 s.keys[key_index(VendorId::Zai)].dirty = true;
1673 s.vendors[vendor_index(VendorId::Zai)].enabled = false;
1674 s.vendors[vendor_index(VendorId::Zai)].dirty = true;
1675 save_to_path(&s, &path).unwrap();
1676 let raw = std::fs::read_to_string(&path).unwrap();
1677 assert!(raw.contains("api_key = \"sk-zai\""), "{raw}");
1678 assert!(raw.contains("enabled = false"), "{raw}");
1679 assert!(!Config::load_from(&path).unwrap().is_enabled(VendorId::Zai));
1680 }
1681
1682 #[test]
1683 fn every_key_vendor_has_a_field() {
1684 for id in [
1686 VendorId::Zai,
1687 VendorId::Openrouter,
1688 VendorId::Deepseek,
1689 VendorId::Kilo,
1690 VendorId::Novita,
1691 VendorId::Moonshot,
1692 VendorId::Grok,
1693 ] {
1694 assert!(
1695 KEY_VENDORS.iter().any(|kv| kv.id == id),
1696 "{id:?} has no key field"
1697 );
1698 }
1699 assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Anthropic));
1701 assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Openai));
1702 }
1703
1704 #[test]
1705 fn from_config_prefills_existing_keys() {
1706 let mut cfg = Config::default();
1707 cfg.kilo.api_key = Some("sk-kilo".into());
1708 let s = SettingsState::from_config(&cfg);
1709 assert_eq!(s.keys[key_index(VendorId::Kilo)].buf, "sk-kilo");
1710 assert!(!s.keys[key_index(VendorId::Kilo)].dirty);
1711 }
1712
1713 #[test]
1714 fn from_config_prefills_the_notification_fields() {
1715 let mut cfg = Config::default();
1716 cfg.notifications.enabled = false;
1717 cfg.notifications.threshold = 100;
1718 let s = SettingsState::from_config_with(&cfg, |_| false);
1719 assert!(!s.notify_enabled);
1720 assert!(!s.notify_enabled_dirty);
1721 assert_eq!(s.notify_threshold.buf, "100");
1722 assert!(!s.notify_threshold.dirty);
1723 }
1724
1725 #[test]
1726 fn notification_toggle_flips_on_left_right_and_space() {
1727 let mut s = blank_state(VendorId::Anthropic);
1728 s.focus = Focus::NotifyEnabled;
1729 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1730 assert!(!s.notify_enabled);
1731 assert!(s.notify_enabled_dirty);
1732 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
1733 assert!(s.notify_enabled);
1734 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1735 assert!(!s.notify_enabled);
1736 handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
1738 assert!(!s.notify_enabled);
1739 }
1740
1741 #[test]
1742 fn threshold_edits_accept_digits_and_reject_everything_else() {
1743 let mut s = blank_state(VendorId::Anthropic);
1744 s.focus = Focus::NotifyThreshold;
1745 s.notify_threshold = KeyInput::default();
1747 for c in ['9', 'a', '.', '-', '→'] {
1748 handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
1749 }
1750 assert_eq!(s.notify_threshold.buf, "9");
1751 assert!(s.notify_threshold.dirty);
1752 handle_key(&mut s, KeyCode::Char('8'), KeyModifiers::NONE);
1753 assert_eq!(s.notify_threshold.buf, "98");
1754 handle_key(&mut s, KeyCode::Backspace, KeyModifiers::NONE);
1755 assert_eq!(s.notify_threshold.buf, "9");
1756 }
1757
1758 #[test]
1759 fn save_writes_the_notification_fields_and_round_trips() {
1760 let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
1761 let mut s = blank_state(VendorId::Anthropic);
1762 s.notify_enabled = false;
1763 s.notify_enabled_dirty = true;
1764 s.notify_threshold = KeyInput::default();
1766 for c in "90".chars() {
1767 s.notify_threshold.insert_char(c);
1768 }
1769 save_to_path(&s, &path).unwrap();
1770
1771 let raw = std::fs::read_to_string(&path).unwrap();
1772 assert!(raw.contains("[notifications]"), "{raw}");
1773 assert!(raw.contains("enabled = false"), "{raw}");
1774 assert!(raw.contains("threshold = 90"), "{raw}");
1775 let reloaded = Config::load_from(&path).unwrap();
1777 assert!(!reloaded.notifications.enabled);
1778 assert_eq!(reloaded.notifications.threshold, 90);
1779 }
1780
1781 #[test]
1782 fn save_leaves_an_untouched_notification_section_alone() {
1783 let (_dir, path) = temp_config(None);
1784 let s = blank_state(VendorId::Anthropic);
1785 save_to_path(&s, &path).unwrap();
1786 let raw = std::fs::read_to_string(&path).unwrap();
1787 assert!(!raw.contains("[notifications]"), "{raw}");
1788 }
1789
1790 #[test]
1791 fn save_rejects_an_out_of_range_threshold_without_writing() {
1792 let (_dir, path) = temp_config(None);
1793 let mut s = blank_state(VendorId::Anthropic);
1794 for c in "300".chars() {
1795 s.notify_threshold.insert_char(c);
1796 }
1797 let err = save_to_path(&s, &path).unwrap_err().to_string();
1798 assert!(
1799 err.contains("[notifications] threshold must be a whole number between 1 and 100"),
1800 "{err}"
1801 );
1802 assert!(!path.exists());
1804
1805 let (_dir, path) = temp_config(None);
1807 let mut s = blank_state(VendorId::Anthropic);
1808 s.notify_threshold = KeyInput::default();
1809 s.notify_threshold.dirty = true;
1810 assert!(save_to_path(&s, &path).is_err());
1811 assert!(!path.exists());
1812 }
1813
1814 #[test]
1815 fn copilot_has_no_editable_credential_field() {
1816 assert!(
1817 !KEY_VENDORS
1818 .iter()
1819 .any(|vendor| vendor.id == VendorId::Copilot)
1820 );
1821 }
1822
1823 #[test]
1826 fn a_key_vendor_with_its_env_var_exported_is_offered() {
1827 let cfg = Config::default();
1828 let env = cfg.api_key_env_for(VendorId::Ollama).to_string();
1829
1830 let without = SettingsState::from_config_with(&cfg, |_| false);
1831 assert!(!without.primary_choices.contains(&VendorId::Ollama));
1832
1833 let with = SettingsState::from_config_with(&cfg, |name| name == env);
1834 assert!(
1835 with.primary_choices.contains(&VendorId::Ollama),
1836 "a key vendor whose env var is exported must be selectable: {:?}",
1837 with.primary_choices
1838 );
1839 }
1840
1841 #[test]
1842 fn from_config_offers_enabled_vendors_only() {
1843 let cfg = Config::default();
1844 let s = SettingsState::from_config_with(&cfg, |_| false);
1847 let mut expected = cfg.enabled_vendors();
1848 expected.push(VendorId::Copilot);
1849 assert_eq!(s.primary_choices, expected);
1850 assert!(!s.primary_choices.contains(&VendorId::Grok));
1853 assert!(s.primary_choices.contains(&s.primary));
1854 assert!(s.primary_choices.contains(&VendorId::Copilot));
1855 }
1856
1857 #[test]
1858 fn from_config_falls_back_when_configured_primary_is_disabled() {
1859 let mut cfg = Config::default();
1862 cfg.ui.primary = Some(VendorId::Grok);
1863 let s = SettingsState::from_config(&cfg);
1864 assert_ne!(s.primary, VendorId::Grok);
1865 assert_eq!(Some(s.primary), cfg.enabled_vendors().first().copied());
1866 }
1867
1868 #[test]
1869 fn key_input_insert_backspace_arrow() {
1870 let mut k = KeyInput::default();
1871 k.insert_char('a');
1872 k.insert_char('b');
1873 k.insert_char('c');
1874 assert_eq!(k.buf, "abc");
1875 assert_eq!(k.cursor, 3);
1876 assert!(k.dirty);
1877 k.move_left();
1878 k.move_left();
1879 assert_eq!(k.cursor, 1);
1880 k.insert_char('x');
1881 assert_eq!(k.buf, "axbc");
1882 assert_eq!(k.cursor, 2);
1883 k.backspace();
1884 assert_eq!(k.buf, "abc");
1885 assert_eq!(k.cursor, 1);
1886 }
1887
1888 #[test]
1889 fn key_input_masks_by_default_reveals_on_toggle() {
1890 let mut k = KeyInput::default();
1891 for c in "secret-key".chars() {
1892 k.insert_char(c);
1893 }
1894 assert_eq!(k.display(), "•".repeat(10));
1895 k.toggle_reveal();
1896 assert_eq!(k.display(), "secret-key");
1897 }
1898
1899 #[test]
1900 fn key_input_handles_unicode() {
1901 let mut k = KeyInput::default();
1902 k.insert_char('a');
1903 k.insert_char('→');
1904 k.insert_char('b');
1905 assert_eq!(k.buf, "a→b");
1906 assert_eq!(k.cursor, 3);
1907 k.move_left();
1908 k.backspace();
1909 assert_eq!(k.buf, "ab");
1910 }
1911
1912 #[test]
1913 fn value_text_shows_cursor_and_empty_states() {
1914 let mut k = KeyInput::default();
1915 assert_eq!(value_text(&k, false), "(empty)");
1916 assert_eq!(value_text(&k, true), "‸");
1917 k.insert_char('a');
1918 k.insert_char('b');
1919 assert_eq!(value_text(&k, true), "••‸");
1921 assert_eq!(value_text(&k, false), "••");
1922 }
1923
1924 #[test]
1925 fn save_writes_key_and_enables_vendor() {
1926 let (_dir, path) = temp_config(None);
1927 let mut s = blank_state(VendorId::Kilo);
1928 s.keys[key_index(VendorId::Kilo)] = KeyInput::from_config(Some("sk-kilo"));
1929 s.keys[key_index(VendorId::Kilo)].dirty = true;
1930 save_to_path(&s, &path).unwrap();
1931 let raw = std::fs::read_to_string(&path).unwrap();
1932 assert!(raw.contains("primary = \"kilo\""));
1933 assert!(raw.contains("[kilo]"));
1934 assert!(raw.contains("api_key = \"sk-kilo\""));
1935 assert!(raw.contains("enabled = true"));
1936 }
1937
1938 #[test]
1939 fn save_writes_minimal_toml_when_starting_empty() {
1940 let (_dir, path) = temp_config(None);
1941 let s = state_with("zk", "ok", VendorId::Zai);
1942 save_to_path(&s, &path).unwrap();
1943 let raw = std::fs::read_to_string(&path).unwrap();
1944 assert!(raw.contains("primary = \"zai\""));
1945 assert!(raw.contains("[zai]"));
1946 assert!(raw.contains("api_key = \"zk\""));
1947 assert!(raw.contains("[openrouter]"));
1948 assert!(raw.contains("api_key = \"ok\""));
1949 }
1950
1951 #[test]
1952 fn save_preserves_existing_comments_and_unrelated_fields() {
1953 let (_dir, path) = temp_config(Some(
1954 r##"# my comment
1955[ui]
1956# pre-existing comment
1957primary = "anthropic"
1958
1959[zai]
1960enabled = true
1961api_key_env = "ZAI_API_KEY"
1962# tier comment
1963plan_tier = "pro"
1964
1965[openrouter]
1966enabled = true
1967api_key_env = "OPENROUTER_API_KEY"
1968
1969[[openrouter.accounts]]
1970label = "work"
1971api_key_env = "OPENROUTER_WORK_API_KEY"
1972"##,
1973 ));
1974
1975 let s = state_with("zk2", "ok2", VendorId::Openrouter);
1976 save_to_path(&s, &path).unwrap();
1977
1978 let raw = std::fs::read_to_string(&path).unwrap();
1979 assert!(raw.contains("# my comment"));
1980 assert!(raw.contains("# pre-existing comment"));
1981 assert!(raw.contains("# tier comment"));
1982 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
1983 assert!(raw.contains("[[openrouter.accounts]]"));
1984 assert!(raw.contains("api_key_env = \"OPENROUTER_WORK_API_KEY\""));
1985 assert!(raw.contains("plan_tier = \"pro\""));
1986 assert!(raw.contains("primary = \"openrouter\""));
1987 assert!(raw.contains("api_key = \"zk2\""));
1988 assert!(raw.contains("api_key = \"ok2\""));
1989 }
1990
1991 #[test]
1992 fn save_refuses_to_replace_an_unreadable_existing_config() {
1993 let (_dir, path) = temp_config(None);
1994 let original = [0xff, 0xfe, 0xfd];
1995 std::fs::write(&path, original).unwrap();
1996 let state = state_with("new-secret", "", VendorId::Zai);
1997
1998 assert!(save_to_path(&state, &path).is_err());
1999 assert_eq!(std::fs::read(&path).unwrap(), original);
2000 }
2001
2002 #[test]
2003 fn save_does_not_write_empty_key_when_dirty_but_blank() {
2004 let (_dir, path) = temp_config(None);
2005 let mut s = blank_state(VendorId::Anthropic);
2006 for k in &mut s.keys {
2008 k.dirty = true;
2009 }
2010 save_to_path(&s, &path).unwrap();
2011 let raw = std::fs::read_to_string(&path).unwrap();
2012 assert!(!raw.contains("api_key ="));
2013 }
2014
2015 #[test]
2016 #[cfg(unix)]
2017 fn save_chmods_to_600() {
2018 use std::os::unix::fs::PermissionsExt;
2019 let (_dir, path) = temp_config(None);
2020 let s = state_with("zk", "ok", VendorId::Zai);
2021 save_to_path(&s, &path).unwrap();
2022 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2023 assert_eq!(mode & 0o777, 0o600);
2024 }
2025
2026 #[test]
2027 fn tab_cycles_focus_from_primary_to_first_key() {
2028 let mut s = blank_state(VendorId::Anthropic);
2029 assert_eq!(
2030 handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
2031 Action::Continue
2032 );
2033 assert_eq!(s.focus, Focus::Key(0));
2034 assert_eq!(
2035 handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
2036 Action::Continue
2037 );
2038 assert_eq!(s.focus, Focus::Primary);
2039 }
2040
2041 #[test]
2042 fn esc_closes_without_saving() {
2043 let mut s = blank_state(VendorId::Anthropic);
2044 assert_eq!(
2045 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
2046 Action::Close
2047 );
2048 }
2049
2050 #[test]
2051 fn left_right_cycles_primary_vendor() {
2052 let mut s = blank_state(VendorId::Anthropic);
2054 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2055 assert_eq!(s.primary, VendorId::AnthropicApi);
2056 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2057 assert_eq!(s.primary, VendorId::Openai);
2058 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
2059 assert_eq!(s.primary, VendorId::AnthropicApi);
2060 }
2061
2062 #[test]
2063 fn left_right_offers_enabled_vendors_only() {
2064 let mut s = blank_state(VendorId::Anthropic);
2066 s.primary_choices = vec![VendorId::Anthropic, VendorId::Grok];
2067 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2068 assert_eq!(s.primary, VendorId::Grok);
2069 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2071 assert_eq!(s.primary, VendorId::Anthropic);
2072 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
2073 assert_eq!(s.primary, VendorId::Grok);
2074 }
2075
2076 #[test]
2077 fn no_enabled_vendors_leaves_primary_selector_inert() {
2078 let mut s = blank_state(VendorId::Anthropic);
2079 s.primary_choices = vec![];
2080 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
2081 assert_eq!(s.primary, VendorId::Anthropic);
2082 }
2083
2084 #[test]
2085 fn disabled_copilot_is_offered_but_not_shown_as_the_current_primary() {
2086 let mut cfg = Config::default();
2087 cfg.ui.primary = Some(VendorId::Copilot);
2088 let state = SettingsState::from_config(&cfg);
2089
2090 assert!(state.primary_choices.contains(&VendorId::Copilot));
2091 assert_eq!(state.primary, VendorId::Anthropic);
2092 }
2093
2094 #[test]
2095 fn save_does_not_write_a_disabled_primary() {
2096 let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
2099 let mut s = state_with("zk", "ok", VendorId::Grok);
2100 s.primary_choices = vec![VendorId::Anthropic];
2101 save_to_path(&s, &path).unwrap();
2102 let raw = std::fs::read_to_string(&path).unwrap();
2103 assert!(raw.contains("primary = \"anthropic\""));
2104 assert!(!raw.contains("primary = \"grok\""));
2105 assert!(raw.contains("zk"));
2107 }
2108
2109 #[test]
2110 fn save_removes_an_inline_key_the_user_cleared() {
2111 let (_dir, path) = temp_config(Some(
2114 "[zai]\nenabled = true\napi_key = \"old-secret\"\nplan_tier = \"pro\"\n",
2115 ));
2116 let mut s = blank_state(VendorId::Zai);
2117 s.primary_choices = vec![VendorId::Zai];
2118 s.keys[key_index(VendorId::Zai)] = KeyInput::default();
2119 s.keys[key_index(VendorId::Zai)].dirty = true;
2120 save_to_path(&s, &path).unwrap();
2121 let raw = std::fs::read_to_string(&path).unwrap();
2122 assert!(!raw.contains("old-secret"));
2123 assert!(!raw.contains("api_key"));
2124 assert!(raw.contains("plan_tier = \"pro\""));
2126 }
2127
2128 #[test]
2129 fn untouched_key_field_is_left_alone() {
2130 let (_dir, path) = temp_config(Some("[zai]\napi_key = \"keep-me\"\n"));
2132 let mut s = blank_state(VendorId::Zai);
2133 s.primary_choices = vec![VendorId::Zai];
2134 save_to_path(&s, &path).unwrap();
2135 let raw = std::fs::read_to_string(&path).unwrap();
2136 assert!(raw.contains("keep-me"));
2137 }
2138
2139 #[test]
2140 fn typing_edits_the_focused_key_only() {
2141 let mut s = blank_state(VendorId::Anthropic);
2142 s.focus = Focus::Key(key_index(VendorId::Grok));
2143 for c in "xai-abc".chars() {
2144 handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
2145 }
2146 assert_eq!(s.keys[key_index(VendorId::Grok)].buf, "xai-abc");
2147 assert!(s.keys[key_index(VendorId::Grok)].dirty);
2148 assert!(s.keys[key_index(VendorId::Zai)].buf.is_empty());
2150 }
2151
2152 #[test]
2153 fn ctrl_v_toggles_reveal_on_focused_key_field() {
2154 let mut s = blank_state(VendorId::Anthropic);
2155 let zi = key_index(VendorId::Zai);
2156 s.focus = Focus::Key(zi);
2157 s.keys[zi] = KeyInput::from_config(Some("secret"));
2158 assert!(!s.keys[zi].revealed);
2159 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
2160 assert!(s.keys[zi].revealed);
2161 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
2162 assert!(!s.keys[zi].revealed);
2163 }
2164
2165 #[test]
2166 fn control_chorded_chars_do_not_type_into_fields() {
2167 let mut s = blank_state(VendorId::Anthropic);
2168 s.focus = Focus::Key(0);
2169 handle_key(&mut s, KeyCode::Char('a'), KeyModifiers::CONTROL);
2171 assert!(s.keys[0].buf.is_empty());
2172 assert!(!s.keys[0].dirty);
2173 assert_eq!(
2175 handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
2176 Action::Quit
2177 );
2178 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
2180 assert_eq!(s.keys[0].buf, "x");
2181 }
2182
2183 #[test]
2184 fn ctrl_v_on_non_key_focus_is_noop() {
2185 let mut s = blank_state(VendorId::Anthropic);
2186 s.focus = Focus::Primary;
2187 assert_eq!(
2189 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL),
2190 Action::Continue
2191 );
2192 }
2193
2194 fn state_focused_on_zai() -> SettingsState {
2195 let mut state = blank_state(VendorId::Anthropic);
2196 state.focus = Focus::Key(key_index(VendorId::Zai));
2197 state
2198 }
2199
2200 #[test]
2201 fn handle_key_ctrl_c_quits_without_typing_into_key_field() {
2202 let mut s = state_focused_on_zai();
2203 let zi = key_index(VendorId::Zai);
2204 assert_eq!(
2205 handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
2206 Action::Quit
2207 );
2208 assert!(s.keys[zi].buf.is_empty());
2209 assert!(!s.keys[zi].dirty);
2211 }
2212
2213 #[test]
2214 fn handle_key_alt_chord_does_not_type_into_key_field() {
2215 let mut s = state_focused_on_zai();
2216 let zi = key_index(VendorId::Zai);
2217 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::ALT);
2218 assert!(s.keys[zi].buf.is_empty());
2219 assert!(!s.keys[zi].dirty);
2220 }
2221
2222 #[test]
2223 fn handle_key_platform_modifier_chords_do_not_type_into_key_field() {
2224 for modifier in [KeyModifiers::SUPER, KeyModifiers::HYPER, KeyModifiers::META] {
2225 let mut s = state_focused_on_zai();
2226 let zi = key_index(VendorId::Zai);
2227 handle_key(&mut s, KeyCode::Char('x'), modifier);
2228 assert!(s.keys[zi].buf.is_empty(), "modifier {modifier:?}");
2229 assert!(!s.keys[zi].dirty, "modifier {modifier:?}");
2230 }
2231 }
2232
2233 #[test]
2234 fn handle_key_shift_still_types_uppercase() {
2235 let mut s = state_focused_on_zai();
2236 let zi = key_index(VendorId::Zai);
2237 handle_key(&mut s, KeyCode::Char('A'), KeyModifiers::SHIFT);
2238 assert_eq!(s.keys[zi].buf, "A");
2239 assert!(s.keys[zi].dirty);
2240 }
2241
2242 #[test]
2243 fn handle_key_plain_space_still_cycles_primary_vendor() {
2244 let mut s = blank_state(VendorId::Anthropic);
2245 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
2246 assert_eq!(s.primary, VendorId::AnthropicApi);
2247 }
2248
2249 #[test]
2250 fn handle_key_ctrl_s_attempts_save_from_any_field() {
2251 let (_dir, path) = temp_config(None);
2252 let s = state_with("zk", "ok", VendorId::Zai);
2253 save_to_path(&s, &path).unwrap();
2254 let raw = std::fs::read_to_string(&path).unwrap();
2255 assert!(raw.contains("api_key = \"zk\""));
2256 }
2257 #[test]
2258 fn save_to_path_writes_kimi_key_when_dirty() {
2259 let (_dir, path) = temp_config(None);
2260 let mut s = blank_state(VendorId::Anthropic);
2261 let kimi = key_index(VendorId::Kimi);
2262 s.keys[kimi] = KeyInput::from_config(Some("kk"));
2263 s.keys[kimi].dirty = true;
2264 save_to_path(&s, &path).unwrap();
2265 let raw = std::fs::read_to_string(&path).unwrap();
2266 assert!(raw.contains("[kimi]"));
2267 assert!(raw.contains("api_key = \"kk\""));
2268 }
2269
2270 #[test]
2271 fn settings_save_uses_the_same_config_path_as_load() {
2272 assert_eq!(
2273 default_config_path().unwrap(),
2274 crate::config::resolved_path().unwrap()
2275 );
2276 }
2277
2278 #[test]
2279 fn native_snapshot_reports_key_state_without_serializing_secrets() {
2280 let mut cfg = Config::default();
2281 cfg.zai.api_key = Some("never-leak-this-key".into());
2282 cfg.zai.api_key_env = "CUSTOM_ZAI_KEY".into();
2283 let raw = settings_snapshot_json_with(&cfg, |name| name == "CUSTOM_ZAI_KEY").unwrap();
2284 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
2285
2286 assert_eq!(parsed["schema_version"], 1);
2287 assert_eq!(parsed["primary"], "anthropic");
2288 let zai = parsed["keys"]
2289 .as_array()
2290 .unwrap()
2291 .iter()
2292 .find(|row| row["id"] == "zai")
2293 .unwrap();
2294 assert_eq!(zai["configured"], true);
2295 assert_eq!(zai["inline_configured"], true);
2296 assert_eq!(zai["environment_configured"], true);
2297 assert_eq!(zai["environment"], "CUSTOM_ZAI_KEY");
2298 assert!(!raw.contains("never-leak-this-key"));
2299 assert!(parsed.get("api_key").is_none());
2300 }
2301
2302 #[test]
2303 fn native_snapshot_offers_copilot_primary_without_a_token_field() {
2304 let cfg = Config::default();
2305 let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
2306 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
2307 assert!(
2308 parsed["primary_choices"]
2309 .as_array()
2310 .unwrap()
2311 .iter()
2312 .any(|row| row["id"] == "copilot")
2313 );
2314 assert!(
2315 !parsed["keys"]
2316 .as_array()
2317 .unwrap()
2318 .iter()
2319 .any(|row| row["id"] == "copilot")
2320 );
2321 }
2322
2323 #[test]
2324 fn native_key_only_patch_does_not_require_or_replace_primary() {
2325 let cfg = Config::default();
2326 let original_primary = SettingsState::from_config(&cfg).primary;
2327 let request = serde_json::json!({
2328 "schema_version": 1,
2329 "keys": {"kimi": {"action": "set", "value": "new-kimi-key"}}
2330 });
2331
2332 let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
2333 assert_eq!(state.primary, original_primary);
2334 let kimi_index = KEY_VENDORS
2335 .iter()
2336 .position(|vendor| vendor.id == VendorId::Kimi)
2337 .unwrap();
2338 assert!(state.keys[kimi_index].dirty);
2339 assert_eq!(state.keys[kimi_index].buf, "new-kimi-key");
2340 }
2341
2342 #[test]
2343 fn native_patch_reuses_tui_persistence_and_preserves_existing_config() {
2344 let (_dir, path) = temp_config(Some(
2345 r#"# keep this comment
2346[ui]
2347primary = "anthropic"
2348
2349[zai]
2350enabled = true
2351api_key_env = "ZAI_API_KEY"
2352plan_tier = "pro"
2353
2354[openrouter]
2355enabled = true
2356"#,
2357 ));
2358 let cfg = Config::load_from(&path).unwrap();
2359 let request = serde_json::json!({
2360 "schema_version": 1,
2361 "primary": "openrouter",
2362 "keys": {
2363 "zai": {"action": "set", "value": "new-zai-key"}
2364 }
2365 });
2366
2367 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
2368 let raw = std::fs::read_to_string(&path).unwrap();
2369 assert!(raw.contains("# keep this comment"));
2370 assert!(raw.contains("plan_tier = \"pro\""));
2371 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
2372 assert!(raw.contains("primary = \"openrouter\""));
2373 assert!(raw.contains("api_key = \"new-zai-key\""));
2374 }
2375
2376 #[test]
2377 fn native_patch_distinguishes_clear_from_unchanged() {
2378 let (_dir, path) = temp_config(Some(
2379 "[zai]\nenabled = true\napi_key = \"remove-me\"\n\
2380 [openrouter]\nenabled = true\napi_key = \"keep-me\"\n",
2381 ));
2382 let cfg = Config::load_from(&path).unwrap();
2383 let request = serde_json::json!({
2384 "schema_version": 1,
2385 "primary": "zai",
2386 "keys": {"zai": {"action": "clear"}}
2387 });
2388
2389 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
2390 let raw = std::fs::read_to_string(&path).unwrap();
2391 assert!(!raw.contains("remove-me"));
2392 assert!(raw.contains("keep-me"));
2393 }
2394
2395 #[test]
2396 fn native_primary_selection_enables_copilot_without_writing_a_token() {
2397 let (_dir, path) = temp_config(Some(
2398 "[copilot]\nenabled = false\ntoken = \"legacy-value\"\ntoken_env = \"OLD_TOKEN\"\n",
2399 ));
2400 let cfg = Config::load_from(&path).unwrap();
2401 let select = serde_json::json!({
2402 "schema_version": 1,
2403 "primary": "copilot"
2404 });
2405 apply_settings_json_to_path(&cfg, &select.to_string(), &path).unwrap();
2406 let raw = std::fs::read_to_string(&path).unwrap();
2407 assert!(raw.contains("enabled = true"));
2408 assert!(raw.contains("primary = \"copilot\""));
2409 assert!(!raw.contains("token ="));
2410 assert!(!raw.contains("token_env ="));
2411 }
2412
2413 #[test]
2414 fn native_patch_errors_never_echo_key_values() {
2415 let raw = serde_json::json!({
2416 "schema_version": 1,
2417 "primary": "anthropic",
2418 "keys": {
2419 "zai": {"action": "set", "value": "secret\nwith-control"}
2420 }
2421 })
2422 .to_string();
2423 let error = state_from_apply_request(&Config::default(), &raw)
2424 .unwrap_err()
2425 .to_string();
2426 assert!(!error.contains("secret"));
2427 assert!(error.contains("control characters"));
2428 }
2429
2430 #[test]
2431 fn native_patch_input_is_bounded_before_json_parsing() {
2432 let oversized = vec![b'x'; MAX_SETTINGS_REQUEST_BYTES as usize + 1];
2433 let error = read_settings_request(std::io::Cursor::new(oversized))
2434 .unwrap_err()
2435 .to_string();
2436 assert!(error.contains("exceeds"));
2437 }
2438
2439 #[test]
2442 fn native_snapshot_lists_every_provider_with_its_enabled_state() {
2443 let mut cfg = Config::default();
2444 cfg.grok.enabled = true;
2445 let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
2446 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
2447
2448 let vendors = parsed["vendors"].as_array().unwrap();
2449 assert_eq!(vendors.len(), VendorId::all().len());
2450 let by_id = |slug: &str| {
2451 vendors
2452 .iter()
2453 .find(|row| row["id"] == slug)
2454 .unwrap_or_else(|| panic!("no {slug} row: {vendors:?}"))
2455 .clone()
2456 };
2457 assert_eq!(by_id("anthropic")["enabled"], true);
2458 assert_eq!(by_id("grok")["enabled"], true);
2459 assert_eq!(by_id("kimi")["enabled"], false);
2460 assert_eq!(by_id("anthropic")["label"], "Claude");
2462 assert_eq!(by_id("opencode-go")["label"], "OpenCode Go");
2463 }
2464
2465 #[test]
2466 fn native_patch_toggles_providers_and_preserves_the_rest_of_the_file() {
2467 let (_dir, path) = temp_config(Some("# keep\n[zai]\nenabled = true\napi_key = \"k\"\n"));
2468 let cfg = Config::load_from(&path).unwrap();
2469 let request = serde_json::json!({
2470 "schema_version": 1,
2471 "vendors": {"grok": true, "zai": false}
2472 });
2473
2474 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
2475
2476 let raw = std::fs::read_to_string(&path).unwrap();
2477 assert!(raw.starts_with("# keep\n"), "{raw}");
2478 assert!(raw.contains("api_key = \"k\""), "{raw}");
2479 let reloaded = Config::load_from(&path).unwrap();
2480 assert!(reloaded.is_enabled(VendorId::Grok));
2481 assert!(!reloaded.is_enabled(VendorId::Zai));
2482 }
2483
2484 #[test]
2485 fn native_patch_refuses_an_unknown_provider_slug_without_writing() {
2486 let (_dir, path) = temp_config(Some("[zai]\nenabled = true\n"));
2487 let cfg = Config::load_from(&path).unwrap();
2488 for bad in ["", "mytool", "not-a-vendor"] {
2489 let request = serde_json::json!({
2490 "schema_version": 1,
2491 "vendors": {bad: true}
2492 });
2493 let error = state_from_apply_request(&cfg, &request.to_string())
2494 .unwrap_err()
2495 .to_string();
2496 assert!(error.contains("unknown provider"), "{bad}: {error}");
2497 }
2498 assert_eq!(
2500 std::fs::read_to_string(&path).unwrap(),
2501 "[zai]\nenabled = true\n"
2502 );
2503 }
2504
2505 #[test]
2507 fn native_vendor_toggle_alone_is_a_valid_patch() {
2508 let cfg = Config::default();
2509 let request = serde_json::json!({
2510 "schema_version": 1,
2511 "vendors": {"kimi": true}
2512 });
2513 let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
2514 let kimi = vendor_index(VendorId::Kimi);
2515 assert_eq!(
2516 state.vendors[kimi],
2517 ProviderSwitch {
2518 enabled: true,
2519 dirty: true
2520 }
2521 );
2522 }
2523
2524 #[test]
2527 fn body_layout_positions_every_focusable_row_without_overlap() {
2528 let keys = 5;
2529 let vendors = 7;
2530 let layout = body_layout(keys, vendors);
2531 assert_eq!(layout.primary, 1);
2533 assert_eq!(layout.first_key, 4);
2534 assert_eq!(layout.first_vendor, layout.first_key + keys + 2);
2536 assert_eq!(layout.notify_enabled, layout.first_vendor + vendors + 2);
2537 assert_eq!(layout.notify_threshold, layout.notify_enabled + 1);
2538 assert_eq!(layout.save, layout.notify_threshold + 2);
2539 assert_eq!(layout.rows, layout.save + 1);
2540 let mut rows = vec![
2542 layout.primary,
2543 layout.notify_enabled,
2544 layout.notify_threshold,
2545 layout.save,
2546 ];
2547 rows.extend(layout.first_key..layout.first_key + keys);
2548 rows.extend(layout.first_vendor..layout.first_vendor + vendors);
2549 rows.sort_unstable();
2550 let mut unique = rows.clone();
2551 unique.dedup();
2552 assert_eq!(rows, unique);
2553 assert!(rows.iter().all(|row| *row < layout.rows));
2554 }
2555
2556 #[test]
2557 fn focus_line_matches_the_body_layout_for_every_focus_variant() {
2558 let state = blank_state(VendorId::Anthropic);
2559 let layout = body_layout(KEY_VENDORS.len(), state.vendors.len());
2560 let cases = [
2561 (Focus::Primary, layout.primary),
2562 (Focus::Key(0), layout.first_key),
2563 (
2564 Focus::Key(KEY_VENDORS.len() - 1),
2565 layout.first_key + KEY_VENDORS.len() - 1,
2566 ),
2567 (Focus::Vendor(0), layout.first_vendor),
2568 (
2569 Focus::Vendor(state.vendors.len() - 1),
2570 layout.first_vendor + state.vendors.len() - 1,
2571 ),
2572 (Focus::NotifyEnabled, layout.notify_enabled),
2573 (Focus::NotifyThreshold, layout.notify_threshold),
2574 (Focus::Save, layout.save),
2575 ];
2576 for (focus, want) in cases {
2577 let mut s = state.clone();
2578 s.focus = focus;
2579 assert_eq!(focus_line(&s), want, "{focus:?}");
2580 }
2581 }
2582
2583 #[test]
2584 fn follow_focus_scroll_sticks_jumps_and_clamps() {
2585 assert_eq!(follow_focus_scroll(3, 50, 10, 0), 0);
2587 assert_eq!(follow_focus_scroll(9, 50, 10, 0), 0);
2588 assert_eq!(follow_focus_scroll(10, 50, 10, 0), 1);
2590 assert_eq!(follow_focus_scroll(25, 50, 10, 5), 16);
2591 assert_eq!(follow_focus_scroll(2, 50, 10, 16), 2);
2593 assert_eq!(follow_focus_scroll(49, 50, 10, 0), 40);
2595 assert_eq!(follow_focus_scroll(60, 50, 10, 0), 40);
2596 assert_eq!(follow_focus_scroll(5, 50, 0, 3), 0);
2598 assert_eq!(follow_focus_scroll(5, 0, 10, 3), 0);
2599 }
2600
2601 #[test]
2605 fn render_scrolls_the_body_so_a_late_row_and_save_stay_visible() {
2606 use ratatui::Terminal;
2607 use ratatui::backend::TestBackend;
2608
2609 let backend = TestBackend::new(100, 24);
2610 let mut terminal = Terminal::new(backend).unwrap();
2611 let theme = crate::theme::Theme::default();
2612 let mut state = blank_state(VendorId::Anthropic);
2613 state.focus = Focus::Vendor(vendor_index(VendorId::Grok));
2614
2615 terminal
2616 .draw(|f| render(f, f.area(), &mut state, &theme))
2617 .unwrap();
2618
2619 let rendered = buffer_text(terminal.backend().buffer());
2620 assert!(
2621 rendered.iter().any(|line| line.contains("Grok")),
2622 "focused provider row must be visible: {rendered:?}"
2623 );
2624 let mut state = blank_state(VendorId::Anthropic);
2627 state.focus = Focus::Save;
2628 terminal
2629 .draw(|f| render(f, f.area(), &mut state, &theme))
2630 .unwrap();
2631 let rendered = buffer_text(terminal.backend().buffer());
2632 assert!(
2633 rendered.iter().any(|line| line.contains("Save")),
2634 "Save must stay visible: {rendered:?}"
2635 );
2636 }
2637
2638 fn buffer_text(buffer: &ratatui::buffer::Buffer) -> Vec<String> {
2640 let area = buffer.area;
2641 let mut lines = Vec::new();
2642 for y in area.top()..area.bottom() {
2643 let mut line = String::new();
2644 for x in area.left()..area.right() {
2645 line.push(buffer[(x, y)].symbol().chars().next().unwrap_or(' '));
2646 }
2647 lines.push(line.trim_end().to_string());
2648 }
2649 lines
2650 }
2651}