1use std::collections::BTreeMap;
21use std::io::BufRead;
22use std::path::{Path, PathBuf};
23
24use ratatui::Frame;
25use ratatui::layout::{Constraint, Direction, Layout, Rect};
26use ratatui::style::Modifier;
27use ratatui::text::{Line, Span};
28use ratatui::widgets::{Clear, Paragraph};
29use ratatui_bubbletea_theme::BubbleTheme;
30use serde::{Deserialize, Serialize};
31use toml_edit::{DocumentMut, value};
32
33use crate::config::{
34 Config, is_valid_env_var_name, read_config_document, set_bool, set_value, write_config_document,
35};
36use crate::error::{AppError, Result};
37use crate::theme::Theme;
38use crate::tui::style::bubble_theme;
39use crate::vendor::VendorId;
40
41pub struct KeyVendor {
45 pub id: VendorId,
46 pub label: &'static str,
47 pub section: &'static str,
48 pub config_key: &'static str,
50 pub secret_label: &'static str,
52 pub note: &'static str,
54}
55
56pub const KEY_VENDORS: &[KeyVendor] = &[
57 KeyVendor {
58 id: VendorId::AnthropicApi,
59 label: "Anthropic API",
60 section: VendorId::AnthropicApi.config_section(),
61 config_key: "api_key",
62 secret_label: "API key",
63 note: "admin key — monthly spend",
64 },
65 KeyVendor {
66 id: VendorId::Zai,
67 label: "Z.AI",
68 section: VendorId::Zai.config_section(),
69 config_key: "api_key",
70 secret_label: "API key",
71 note: "",
72 },
73 KeyVendor {
74 id: VendorId::Openrouter,
75 label: "OpenRouter",
76 section: VendorId::Openrouter.config_section(),
77 config_key: "api_key",
78 secret_label: "API key",
79 note: "",
80 },
81 KeyVendor {
82 id: VendorId::Deepseek,
83 label: "DeepSeek",
84 section: VendorId::Deepseek.config_section(),
85 config_key: "api_key",
86 secret_label: "API key",
87 note: "",
88 },
89 KeyVendor {
90 id: VendorId::Kimi,
91 label: "Kimi",
92 section: VendorId::Kimi.config_section(),
93 config_key: "api_key",
94 secret_label: "API key",
95 note: "coding-plan usage",
96 },
97 KeyVendor {
98 id: VendorId::Kilo,
99 label: "Kilo",
100 section: VendorId::Kilo.config_section(),
101 config_key: "api_key",
102 secret_label: "API key",
103 note: "",
104 },
105 KeyVendor {
106 id: VendorId::Novita,
107 label: "Novita",
108 section: VendorId::Novita.config_section(),
109 config_key: "api_key",
110 secret_label: "API key",
111 note: "",
112 },
113 KeyVendor {
114 id: VendorId::Moonshot,
115 label: "Moonshot",
116 section: VendorId::Moonshot.config_section(),
117 config_key: "api_key",
118 secret_label: "API key",
119 note: "account balance",
120 },
121 KeyVendor {
122 id: VendorId::Grok,
123 label: "Grok",
124 section: VendorId::Grok.config_section(),
125 config_key: "api_key",
126 secret_label: "API key",
127 note: "management key, not the inference key",
128 },
129 KeyVendor {
130 id: VendorId::Minimax,
131 label: "MiniMax",
132 section: VendorId::Minimax.config_section(),
133 config_key: "api_key",
134 secret_label: "API key",
135 note: "Token Plan subscription key",
136 },
137 KeyVendor {
138 id: VendorId::OpenCodeGo,
139 label: "OpenCode Go",
140 section: VendorId::OpenCodeGo.config_section(),
141 config_key: "api_key",
142 secret_label: "API key",
143 note: "usage quota",
144 },
145 KeyVendor {
146 id: VendorId::Ollama,
147 label: "Ollama Cloud",
148 section: VendorId::Ollama.config_section(),
149 config_key: "api_key",
150 secret_label: "API key",
151 note: "ollama.com/settings/keys",
152 },
153 KeyVendor {
154 id: VendorId::OrcaRouter,
155 label: "OrcaRouter",
156 section: VendorId::OrcaRouter.config_section(),
157 config_key: "api_key",
158 secret_label: "API key",
159 note: "credit balance",
160 },
161];
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum Focus {
166 Primary,
167 Key(usize),
168 NotifyEnabled,
169 NotifyThreshold,
170 Save,
171}
172
173impl Focus {
174 pub fn next(self) -> Self {
175 match self {
176 Focus::Primary => Focus::Key(0),
177 Focus::Key(i) if i + 1 < KEY_VENDORS.len() => Focus::Key(i + 1),
178 Focus::Key(_) => Focus::NotifyEnabled,
179 Focus::NotifyEnabled => Focus::NotifyThreshold,
180 Focus::NotifyThreshold => Focus::Save,
181 Focus::Save => Focus::Primary,
182 }
183 }
184 pub fn prev(self) -> Self {
185 match self {
186 Focus::Primary => Focus::Save,
187 Focus::Key(0) => Focus::Primary,
188 Focus::Key(i) => Focus::Key(i - 1),
189 Focus::Save => Focus::NotifyThreshold,
190 Focus::NotifyThreshold => Focus::NotifyEnabled,
191 Focus::NotifyEnabled => Focus::Key(KEY_VENDORS.len() - 1),
192 }
193 }
194}
195
196#[derive(Debug, Clone, Default)]
198pub struct KeyInput {
199 pub buf: String,
200 pub cursor: usize,
202 pub revealed: bool,
204 pub dirty: bool,
208}
209
210impl KeyInput {
211 pub fn from_config(initial: Option<&str>) -> Self {
212 let buf = initial.unwrap_or("").to_string();
213 let cursor = buf.chars().count();
214 Self {
215 buf,
216 cursor,
217 revealed: false,
218 dirty: false,
219 }
220 }
221
222 pub fn insert_char(&mut self, c: char) {
223 let byte_idx = self.char_to_byte(self.cursor);
224 self.buf.insert(byte_idx, c);
225 self.cursor += 1;
226 self.dirty = true;
227 }
228
229 pub fn backspace(&mut self) {
230 if self.cursor == 0 {
231 return;
232 }
233 let prev_byte = self.char_to_byte(self.cursor - 1);
234 let cur_byte = self.char_to_byte(self.cursor);
235 self.buf.replace_range(prev_byte..cur_byte, "");
236 self.cursor -= 1;
237 self.dirty = true;
238 }
239
240 pub fn delete(&mut self) {
241 let n = self.buf.chars().count();
242 if self.cursor >= n {
243 return;
244 }
245 let cur_byte = self.char_to_byte(self.cursor);
246 let next_byte = self.char_to_byte(self.cursor + 1);
247 self.buf.replace_range(cur_byte..next_byte, "");
248 self.dirty = true;
249 }
250
251 pub fn move_left(&mut self) {
252 if self.cursor > 0 {
253 self.cursor -= 1;
254 }
255 }
256 pub fn move_right(&mut self) {
257 if self.cursor < self.buf.chars().count() {
258 self.cursor += 1;
259 }
260 }
261 pub fn move_home(&mut self) {
262 self.cursor = 0;
263 }
264 pub fn move_end(&mut self) {
265 self.cursor = self.buf.chars().count();
266 }
267 pub fn toggle_reveal(&mut self) {
268 self.revealed = !self.revealed;
269 }
270
271 pub fn display(&self) -> String {
273 if self.revealed {
274 self.buf.clone()
275 } else {
276 "•".repeat(self.buf.chars().count())
277 }
278 }
279
280 fn char_to_byte(&self, char_idx: usize) -> usize {
281 self.buf
282 .char_indices()
283 .map(|(b, _)| b)
284 .chain(std::iter::once(self.buf.len()))
285 .nth(char_idx)
286 .unwrap_or(self.buf.len())
287 }
288}
289
290#[derive(Debug, Clone)]
292pub struct SettingsState {
293 pub focus: Focus,
294 pub primary_choices: Vec<VendorId>,
297 pub primary: VendorId,
298 pub keys: Vec<KeyInput>,
300 pub notify_enabled: bool,
302 pub notify_enabled_dirty: bool,
303 pub notify_threshold: KeyInput,
305 pub status: String,
307}
308
309impl SettingsState {
310 pub fn from_config(cfg: &Config) -> Self {
311 Self::from_config_with(cfg, |name| {
312 std::env::var_os(name).is_some_and(|v| !v.is_empty())
313 })
314 }
315
316 pub fn from_config_with(cfg: &Config, env_set: impl Fn(&str) -> bool) -> Self {
325 let keys = KEY_VENDORS
326 .iter()
327 .map(|kv| KeyInput::from_config(cfg.inline_api_key(kv.id)))
328 .collect();
329 let mut primary_choices = cfg.enabled_vendors();
330 if !primary_choices.contains(&VendorId::Copilot) {
334 primary_choices.push(VendorId::Copilot);
335 }
336 for kv in KEY_VENDORS {
344 if primary_choices.contains(&kv.id) {
345 continue;
346 }
347 let env = cfg.api_key_env_for(kv.id);
348 let exported = is_valid_env_var_name(env) && env_set(env);
349 if cfg.inline_api_key(kv.id).is_some() || exported {
350 primary_choices.push(kv.id);
351 }
352 }
353 let primary = cfg
357 .ui
358 .primary
359 .filter(|vendor| {
360 primary_choices.contains(vendor)
361 && (*vendor != VendorId::Copilot || cfg.copilot.enabled)
362 })
363 .or_else(|| primary_choices.first().copied())
364 .unwrap_or_else(|| cfg.ui.primary.unwrap_or(VendorId::Anthropic));
365 Self {
366 focus: Focus::Primary,
367 primary_choices,
368 primary,
369 keys,
370 notify_enabled: cfg.notifications.enabled,
371 notify_enabled_dirty: false,
372 notify_threshold: KeyInput::from_config(Some(&cfg.notifications.threshold.to_string())),
373 status: String::new(),
374 }
375 }
376
377 fn focused_key_mut(&mut self) -> Option<&mut KeyInput> {
379 match self.focus {
380 Focus::Key(i) => self.keys.get_mut(i),
381 _ => None,
382 }
383 }
384}
385
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub enum Action {
389 Continue,
391 Close,
393 SavedAndClose,
395 Quit,
398}
399
400#[cfg(unix)]
403const PERMS_NOTE: &str = " (chmod 600)";
404#[cfg(not(unix))]
405const PERMS_NOTE: &str = "";
406
407fn saved_status() -> String {
408 format!(
409 "saved to {}{}",
410 crate::config::config_path_hint(),
411 PERMS_NOTE
412 )
413}
414
415pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
417 if matches!(code, KeyCode::Esc) {
418 return Action::Close;
419 }
420 if matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL) {
421 return Action::Quit;
422 }
423 if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
425 return try_save(state);
426 }
427 if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
428 if let Some(input) = state.focused_key_mut() {
429 input.toggle_reveal();
430 }
431 return Action::Continue;
432 }
433 match code {
434 KeyCode::Tab | KeyCode::Down => {
435 state.focus = state.focus.next();
436 return Action::Continue;
437 }
438 KeyCode::BackTab | KeyCode::Up => {
439 state.focus = state.focus.prev();
440 return Action::Continue;
441 }
442 _ => {}
443 }
444
445 if matches!(code, KeyCode::Char(_))
450 && mods.intersects(
451 KeyModifiers::CONTROL
452 | KeyModifiers::ALT
453 | KeyModifiers::SUPER
454 | KeyModifiers::HYPER
455 | KeyModifiers::META,
456 )
457 {
458 return Action::Continue;
459 }
460
461 match state.focus {
463 Focus::Primary => handle_primary(state, code),
464 Focus::Key(i) => {
465 if let Some(input) = state.keys.get_mut(i) {
466 handle_input(input, code);
467 }
468 }
469 Focus::NotifyEnabled => handle_notify_enabled(state, code),
470 Focus::NotifyThreshold => handle_threshold_input(&mut state.notify_threshold, code),
471 Focus::Save => {
472 if matches!(code, KeyCode::Enter) {
473 return try_save(state);
474 }
475 }
476 }
477 Action::Continue
478}
479
480fn try_save(state: &mut SettingsState) -> Action {
481 match save_to_config_default(state) {
482 Ok(()) => {
483 state.status = saved_status();
484 Action::SavedAndClose
485 }
486 Err(e) => {
487 state.status = format!("save failed: {e}");
488 Action::Continue
489 }
490 }
491}
492
493fn handle_primary(state: &mut SettingsState, code: KeyCode) {
494 let choices = &state.primary_choices;
496 let Some(idx) = choices.iter().position(|v| *v == state.primary) else {
497 return;
498 };
499 let step = match code {
500 KeyCode::Left => -1,
501 KeyCode::Right | KeyCode::Char(' ') => 1,
502 _ => return,
503 };
504 state.primary = choices[((idx as i32 + step).rem_euclid(choices.len() as i32)) as usize];
505}
506
507fn handle_input(input: &mut KeyInput, code: KeyCode) {
508 match code {
509 KeyCode::Char(c) => input.insert_char(c),
510 KeyCode::Backspace => input.backspace(),
511 KeyCode::Delete => input.delete(),
512 KeyCode::Left => input.move_left(),
513 KeyCode::Right => input.move_right(),
514 KeyCode::Home => input.move_home(),
515 KeyCode::End => input.move_end(),
516 _ => {}
517 }
518}
519
520fn handle_notify_enabled(state: &mut SettingsState, code: KeyCode) {
523 if matches!(code, KeyCode::Left | KeyCode::Right | KeyCode::Char(' ')) {
524 state.notify_enabled = !state.notify_enabled;
525 state.notify_enabled_dirty = true;
526 }
527}
528
529fn handle_threshold_input(input: &mut KeyInput, code: KeyCode) {
533 match code {
534 KeyCode::Char(c) if c.is_ascii_digit() => input.insert_char(c),
535 KeyCode::Backspace => input.backspace(),
536 KeyCode::Delete => input.delete(),
537 KeyCode::Left => input.move_left(),
538 KeyCode::Right => input.move_right(),
539 KeyCode::Home => input.move_home(),
540 KeyCode::End => input.move_end(),
541 _ => {}
542 }
543}
544
545fn save_to_config_default(state: &SettingsState) -> Result<()> {
548 let path = default_config_path()?;
549 if let Some(parent) = path.parent() {
550 std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
551 }
552 save_to_path(state, &path)?;
553 crate::waybar::request_refresh();
554 Ok(())
555}
556
557pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
560 let mut doc = read_config_document(path)?;
561
562 if let Some(table) = doc
566 .get_mut("copilot")
567 .and_then(toml_edit::Item::as_table_mut)
568 {
569 table.remove("token");
570 table.remove("token_env");
571 }
572
573 if state.primary_choices.contains(&state.primary) {
582 set_string(&mut doc, "ui", "primary", state.primary.slug())?;
583 if state.primary == VendorId::Copilot || KEY_VENDORS.iter().any(|kv| kv.id == state.primary)
584 {
585 set_bool(&mut doc, state.primary.config_section(), "enabled", true)?;
586 }
587 }
588
589 for (i, kv) in KEY_VENDORS.iter().enumerate() {
590 let Some(input) = state.keys.get(i) else {
591 continue;
592 };
593 update_key(&mut doc, kv, input)?;
594 }
595
596 if state.notify_enabled_dirty {
599 set_bool(&mut doc, "notifications", "enabled", state.notify_enabled)?;
600 }
601 if state.notify_threshold.dirty {
602 let raw = state.notify_threshold.buf.trim();
603 let threshold = raw.parse::<u8>();
604 match threshold {
605 Ok(threshold) if (1..=100).contains(&threshold) => {
606 set_value(
607 &mut doc,
608 "notifications",
609 "threshold",
610 Some(toml_edit::Value::from(i64::from(threshold))),
611 )?;
612 }
613 _ => {
614 return Err(AppError::Other(format!(
615 "[notifications] threshold must be a whole number between 1 and 100, got {raw:?}"
616 )));
617 }
618 }
619 }
620
621 write_config_document(path, &doc)
622}
623
624fn update_key(doc: &mut DocumentMut, vendor: &KeyVendor, input: &KeyInput) -> Result<()> {
630 if !input.dirty {
631 return Ok(());
632 }
633 if input.buf.is_empty() {
634 if let Some(table) = doc
635 .get_mut(vendor.section)
636 .and_then(toml_edit::Item::as_table_mut)
637 {
638 table.remove(vendor.config_key);
639 }
640 return Ok(());
641 }
642 set_string(doc, vendor.section, vendor.config_key, &input.buf)?;
643 set_bool(doc, vendor.section, "enabled", true)
644}
645
646fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
649 let table = doc
650 .entry(section)
651 .or_insert_with(toml_edit::table)
652 .as_table_mut()
653 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
654
655 if let Some(item) = table.get_mut(key)
656 && let Some(v) = item.as_value_mut()
657 {
658 *v = toml_edit::Value::from(new_value);
659 v.decor_mut().set_prefix(" ");
660 return Ok(());
661 }
662 table.insert(key, value(new_value));
663 Ok(())
664}
665
666fn default_config_path() -> Result<PathBuf> {
667 crate::config::resolved_path()
672 .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
673}
674
675#[derive(Debug, Serialize)]
682struct SettingsSnapshot {
683 schema_version: u8,
684 primary: String,
685 primary_choices: Vec<PrimaryChoice>,
686 keys: Vec<KeyStatus>,
687}
688
689#[derive(Debug, Serialize)]
690struct PrimaryChoice {
691 id: String,
692 label: String,
693}
694
695#[derive(Debug, Serialize)]
696struct KeyStatus {
697 id: String,
698 label: String,
699 environment: String,
700 secret_label: String,
701 note: String,
702 configured: bool,
703 inline_configured: bool,
704 environment_configured: bool,
705}
706
707#[derive(Debug, Deserialize)]
711#[serde(deny_unknown_fields)]
712struct ApplyRequest {
713 schema_version: u8,
714 primary: Option<String>,
715 #[serde(default)]
716 keys: BTreeMap<String, KeyMutation>,
717}
718
719#[derive(Debug, Deserialize)]
720#[serde(tag = "action", rename_all = "lowercase", deny_unknown_fields)]
721enum KeyMutation {
722 Set { value: String },
723 Clear,
724}
725
726const SETTINGS_SCHEMA_VERSION: u8 = 1;
727const MAX_SETTINGS_REQUEST_BYTES: u64 = 64 * 1024;
728const MAX_API_KEY_BYTES: usize = 16 * 1024;
729
730fn snapshot_from_config_with(
731 cfg: &Config,
732 environment_configured: impl Fn(&str) -> bool,
733) -> SettingsSnapshot {
734 let state = SettingsState::from_config(cfg);
735 let primary_choices = state
736 .primary_choices
737 .iter()
738 .map(|id| PrimaryChoice {
739 id: id.slug().to_string(),
740 label: id.display_name().to_string(),
741 })
742 .collect();
743 let keys = KEY_VENDORS
744 .iter()
745 .map(|vendor| {
746 let environment = cfg.api_key_env_for(vendor.id);
747 let inline_configured = cfg.inline_api_key(vendor.id).is_some();
748 let environment_configured = environment_configured(environment);
749 KeyStatus {
750 id: vendor.id.slug().to_string(),
751 label: vendor.label.to_string(),
752 environment: environment.to_string(),
753 secret_label: vendor.secret_label.to_string(),
754 note: vendor.note.to_string(),
755 configured: inline_configured || environment_configured,
756 inline_configured,
757 environment_configured,
758 }
759 })
760 .collect();
761 SettingsSnapshot {
762 schema_version: SETTINGS_SCHEMA_VERSION,
763 primary: state.primary.slug().to_string(),
764 primary_choices,
765 keys,
766 }
767}
768
769fn settings_snapshot_json(cfg: &Config) -> Result<String> {
770 Ok(serde_json::to_string(&snapshot_from_config_with(
771 cfg,
772 |environment| std::env::var_os(environment).is_some_and(|value| !value.is_empty()),
773 ))?)
774}
775
776#[cfg(test)]
777fn settings_snapshot_json_with(
778 cfg: &Config,
779 environment_configured: impl Fn(&str) -> bool,
780) -> Result<String> {
781 Ok(serde_json::to_string(&snapshot_from_config_with(
782 cfg,
783 environment_configured,
784 ))?)
785}
786
787fn vendor_from_slug(slug: &str) -> Option<VendorId> {
788 VendorId::all().iter().copied().find(|id| id.slug() == slug)
789}
790
791fn state_from_apply_request(cfg: &Config, raw: &str) -> Result<SettingsState> {
792 let request: ApplyRequest = serde_json::from_str(raw)?;
793 if request.schema_version != SETTINGS_SCHEMA_VERSION {
794 return Err(AppError::Other(format!(
795 "unsupported settings schema version {}",
796 request.schema_version
797 )));
798 }
799
800 let mut state = SettingsState::from_config(cfg);
801 if let Some(primary) = request.primary {
802 let id = vendor_from_slug(&primary)
803 .ok_or_else(|| AppError::Other(format!("unknown primary vendor {primary:?}")))?;
804 if !state.primary_choices.contains(&id) {
805 return Err(AppError::Other(format!(
806 "primary vendor {primary:?} is not enabled"
807 )));
808 }
809 state.primary = id;
810 }
811
812 for (id, mutation) in request.keys {
813 let index = KEY_VENDORS
814 .iter()
815 .position(|vendor| vendor.id.slug() == id)
816 .ok_or_else(|| AppError::Other(format!("unknown credential vendor {id:?}")))?;
817 let input = &mut state.keys[index];
818 match mutation {
819 KeyMutation::Set { value } => {
820 if value.is_empty() {
821 return Err(AppError::Other(format!(
822 "{} for {id:?} is empty; use the clear action to remove it",
823 KEY_VENDORS[index].secret_label
824 )));
825 }
826 if value.len() > MAX_API_KEY_BYTES {
827 return Err(AppError::Other(format!(
828 "{} for {id:?} exceeds {MAX_API_KEY_BYTES} bytes",
829 KEY_VENDORS[index].secret_label
830 )));
831 }
832 if value.chars().any(char::is_control) {
833 return Err(AppError::Other(format!(
834 "{} for {id:?} contains control characters",
835 KEY_VENDORS[index].secret_label
836 )));
837 }
838 input.buf = value;
839 }
840 KeyMutation::Clear => input.buf.clear(),
841 }
842 input.cursor = input.buf.chars().count();
843 input.dirty = true;
844 input.revealed = false;
845 }
846 Ok(state)
847}
848
849#[cfg(test)]
850fn apply_settings_json_to_path(cfg: &Config, raw: &str, path: &Path) -> Result<()> {
851 let state = state_from_apply_request(cfg, raw)?;
852 save_to_path(&state, path)
853}
854
855fn read_settings_request<R: BufRead>(reader: R) -> Result<String> {
856 let mut limited = reader.take(MAX_SETTINGS_REQUEST_BYTES + 1);
857 let mut bytes = Vec::new();
858 limited.read_until(b'\n', &mut bytes)?;
859 if bytes.len() as u64 > MAX_SETTINGS_REQUEST_BYTES {
860 return Err(AppError::Other(format!(
861 "settings request exceeds {MAX_SETTINGS_REQUEST_BYTES} bytes"
862 )));
863 }
864 if bytes.last() == Some(&b'\n') {
865 bytes.pop();
866 if bytes.last() == Some(&b'\r') {
867 bytes.pop();
868 }
869 }
870 String::from_utf8(bytes)
871 .map_err(|_| AppError::Other("settings request is not valid UTF-8".into()))
872}
873
874fn apply_settings_from_stdin() -> Result<()> {
875 let raw = read_settings_request(std::io::stdin().lock())?;
876 let cfg = Config::load()?;
877 let state = state_from_apply_request(&cfg, &raw)?;
878 save_to_config_default(&state)
879}
880
881fn enable_vendor_at(path: &Path, vendor: VendorId) -> Result<()> {
883 let mut doc = read_config_document(path)?;
884 let before = doc.to_string();
885 set_bool(&mut doc, vendor.config_section(), "enabled", true)?;
886 if doc.to_string() != before {
887 write_config_document(path, &doc)?;
888 }
889 Ok(())
890}
891
892pub fn run_cli(action: &crate::widget::cli::SettingsAction) -> i32 {
896 let result = match action {
897 crate::widget::cli::SettingsAction::Enable { vendor } => default_config_path()
898 .and_then(|path| enable_vendor_at(&path, vendor.to_id()))
899 .map(|()| {
900 crate::waybar::request_refresh();
901 println!(r#"{{"ok":true}}"#);
902 }),
903 crate::widget::cli::SettingsAction::Show => Config::load()
904 .and_then(|cfg| settings_snapshot_json(&cfg))
905 .map(|json| println!("{json}")),
906 crate::widget::cli::SettingsAction::Apply => {
907 apply_settings_from_stdin().map(|()| println!(r#"{{"ok":true}}"#))
908 }
909 };
910 match result {
911 Ok(()) => 0,
912 Err(error) => {
913 eprintln!("settings: {error}");
914 1
915 }
916 }
917}
918
919pub fn render(f: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
923 let modal = centered_rect(74, 88, area);
924 f.render_widget(Clear, modal);
925
926 let bubble = bubble_theme(theme);
927 let block = bubble.titled_modal_block(" Settings ");
928 let inner = block.inner(modal);
929 f.render_widget(block, modal);
930
931 let chunks = Layout::default()
933 .direction(Direction::Vertical)
934 .constraints([Constraint::Min(0), Constraint::Length(1)])
935 .split(inner);
936
937 let mut lines: Vec<Line> = vec![
939 section_header("Primary vendor", "shown first on the bar / TUI", &bubble),
940 primary_line(state, &bubble),
941 Line::from(""),
942 section_header(
943 "Credentials",
944 "pick a row, type the credential, then Ctrl-S — Claude & Codex use CLI login",
945 &bubble,
946 ),
947 ];
948 for (i, kv) in KEY_VENDORS.iter().enumerate() {
949 let focused = state.focus == Focus::Key(i);
950 lines.push(key_row(kv, &state.keys[i], focused, &bubble));
951 }
952 lines.push(Line::from(""));
953
954 lines.push(section_header(
956 "Notifications",
957 "desktop alert when a quota window crosses the threshold",
958 &bubble,
959 ));
960 lines.push(notify_enabled_line(state, &bubble));
961 lines.push(notify_threshold_line(state, &bubble));
962 lines.push(Line::from(""));
963
964 lines.push(save_line(state.focus == Focus::Save, &bubble));
966 if !state.status.is_empty() {
967 let ok = state.status.starts_with("saved");
968 let mark = if ok { " ✓ " } else { " ✗ " };
969 let style = if ok { bubble.accent } else { bubble.selected };
970 lines.push(Line::from(vec![
971 Span::styled(mark, style.add_modifier(Modifier::BOLD)),
972 Span::styled(state.status.clone(), bubble.muted),
973 ]));
974 }
975
976 f.render_widget(Paragraph::new(lines), chunks[0]);
977
978 let hint = match state.focus {
980 Focus::Primary => bubble.help_line([
981 ("↑↓/tab", "move"),
982 ("←→", "change vendor"),
983 ("^S", "save"),
984 ("esc", "close"),
985 ]),
986 Focus::Key(_) => bubble.help_line([
987 ("↑↓/tab", "move"),
988 ("type", "edit key"),
989 ("^V", "reveal"),
990 ("^S", "save"),
991 ("esc", "close"),
992 ]),
993 Focus::NotifyEnabled => bubble.help_line([
994 ("↑↓/tab", "move"),
995 ("←→/space", "toggle"),
996 ("^S", "save"),
997 ("esc", "close"),
998 ]),
999 Focus::NotifyThreshold => bubble.help_line([
1000 ("↑↓/tab", "move"),
1001 ("type", "digits 1-100"),
1002 ("^S", "save"),
1003 ("esc", "close"),
1004 ]),
1005 Focus::Save => {
1006 bubble.help_line([("↑↓/tab", "move"), ("enter/^S", "save"), ("esc", "close")])
1007 }
1008 };
1009 f.render_widget(Paragraph::new(hint), chunks[1]);
1010}
1011
1012fn section_header(title: &str, sub: &str, theme: &BubbleTheme) -> Line<'static> {
1013 Line::from(vec![
1014 theme.span(" "),
1015 Span::styled(title.to_string(), theme.title.add_modifier(Modifier::BOLD)),
1016 theme.muted(format!(" — {sub}")),
1017 ])
1018}
1019
1020fn primary_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1021 let focused = state.focus == Focus::Primary;
1022 let name = state.primary.display_name().to_string();
1023 if focused {
1024 Line::from(vec![
1025 theme.span(" "),
1026 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1027 Span::styled("◀ ", theme.accent),
1028 Span::styled(
1029 format!(" {name} "),
1030 theme
1031 .selected
1032 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1033 ),
1034 Span::styled(" ▶", theme.accent),
1035 theme.muted(" ← → to change"),
1036 ])
1037 } else {
1038 Line::from(vec![theme.span(" "), Span::styled(name, theme.text)])
1039 }
1040}
1041
1042fn key_row(kv: &KeyVendor, input: &KeyInput, focused: bool, theme: &BubbleTheme) -> Line<'static> {
1043 let label = format!("{:<11}", kv.label);
1044 let value = value_text(input, focused);
1045
1046 let env_name = kv.id.api_key_env();
1048 let env_set = std::env::var(env_name)
1049 .map(|v| !v.is_empty())
1050 .unwrap_or(false);
1051 let mut suffix = format!(" {env_name}");
1052 if env_set {
1053 suffix.push_str(" · env set (overrides)");
1054 }
1055 if !kv.note.is_empty() {
1056 suffix.push_str(&format!(" · {}", kv.note));
1057 }
1058
1059 if focused {
1060 let val_style = if input.buf.is_empty() {
1061 theme.accent.add_modifier(Modifier::BOLD)
1062 } else {
1063 theme.selected.add_modifier(Modifier::REVERSED)
1064 };
1065 let mut spans = vec![
1066 theme.span(" "),
1067 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1068 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1069 Span::styled(format!(" {value} "), val_style),
1070 ];
1071 if input.revealed {
1072 spans.push(theme.muted(" [revealed]"));
1073 }
1074 spans.push(theme.muted(suffix));
1075 Line::from(spans)
1076 } else {
1077 let val_style = if input.buf.is_empty() {
1078 theme.muted
1079 } else {
1080 theme.text
1081 };
1082 Line::from(vec![
1083 theme.span(" "),
1084 Span::styled(label, theme.text),
1085 Span::styled(format!(" {value}"), val_style),
1086 theme.muted(suffix),
1087 ])
1088 }
1089}
1090
1091fn value_text(input: &KeyInput, focused: bool) -> String {
1094 if input.buf.is_empty() {
1095 return if focused {
1096 "‸".to_string()
1097 } else {
1098 "(empty)".to_string()
1099 };
1100 }
1101 let base = input.display();
1102 if !focused {
1103 return base;
1104 }
1105 let mut chars: Vec<char> = base.chars().collect();
1106 let pos = input.cursor.min(chars.len());
1107 chars.insert(pos, '‸');
1108 chars.into_iter().collect()
1109}
1110
1111fn notify_enabled_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1114 let focused = state.focus == Focus::NotifyEnabled;
1115 let label = format!("{:<11}", "Quota alerts");
1116 let value = if state.notify_enabled { "on" } else { "off" };
1117 if focused {
1118 Line::from(vec![
1119 theme.span(" "),
1120 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1121 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1122 Span::styled(
1123 format!(" ◀ {value} ▶ "),
1124 theme
1125 .selected
1126 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1127 ),
1128 ])
1129 } else {
1130 Line::from(vec![
1131 theme.span(" "),
1132 Span::styled(label, theme.text),
1133 Span::styled(format!(" {value}"), theme.muted),
1134 ])
1135 }
1136}
1137
1138fn notify_threshold_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
1141 let focused = state.focus == Focus::NotifyThreshold;
1142 let label = format!("{:<11}", "Threshold %");
1143 let input = &state.notify_threshold;
1144 let value = if input.buf.is_empty() {
1145 if focused {
1146 "‸".to_string()
1147 } else {
1148 "(97)".to_string()
1149 }
1150 } else {
1151 let mut chars: Vec<char> = input.buf.chars().collect();
1152 if focused {
1153 let pos = input.cursor.min(chars.len());
1154 chars.insert(pos, '‸');
1155 }
1156 chars.into_iter().collect()
1157 };
1158 if focused {
1159 Line::from(vec![
1160 theme.span(" "),
1161 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
1162 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
1163 Span::styled(
1164 format!(" {value} "),
1165 theme
1166 .selected
1167 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
1168 ),
1169 theme.muted(" 1-100"),
1170 ])
1171 } else {
1172 Line::from(vec![
1173 theme.span(" "),
1174 Span::styled(label, theme.text),
1175 Span::styled(format!(" {value}"), theme.text),
1176 theme.muted(" 1-100"),
1177 ])
1178 }
1179}
1180
1181fn save_line(focused: bool, theme: &BubbleTheme) -> Line<'static> {
1182 let style = if focused {
1183 theme
1184 .selected
1185 .add_modifier(Modifier::REVERSED | Modifier::BOLD)
1186 } else {
1187 theme.accent.add_modifier(Modifier::BOLD)
1188 };
1189 let marker = if focused { "▸ " } else { " " };
1190 Line::from(vec![
1191 theme.span(" "),
1192 Span::styled(marker, theme.accent.add_modifier(Modifier::BOLD)),
1193 Span::styled(" Save (Ctrl-S) ", style),
1194 ])
1195}
1196
1197fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1199 let popup_h = (r.height * percent_y) / 100;
1200 let popup_w = (r.width * percent_x) / 100;
1201 Rect {
1202 x: r.x + (r.width - popup_w) / 2,
1203 y: r.y + (r.height - popup_h) / 2,
1204 width: popup_w,
1205 height: popup_h,
1206 }
1207}
1208
1209pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
1211
1212#[cfg(test)]
1213mod tests {
1214 use super::*;
1215 use tempfile::TempDir;
1216
1217 fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
1218 crate::cache::closed_temp_file("config.toml", initial)
1219 }
1220
1221 fn key_index(id: VendorId) -> usize {
1222 KEY_VENDORS.iter().position(|kv| kv.id == id).unwrap()
1223 }
1224
1225 #[test]
1226 fn explicit_enable_preserves_other_settings_and_is_idempotent() {
1227 let dir = tempfile::tempdir().unwrap();
1228 let path = dir.path().join("config.toml");
1229 let original = "# keep me\n[anthropic]\nenabled = false # intentional\n[openrouter]\napi_key = \"test-key\"\nenabled = false\n";
1230 std::fs::write(&path, original).unwrap();
1231 enable_vendor_at(&path, VendorId::Anthropic).unwrap();
1232 let expected = original.replacen("enabled = false", "enabled = true", 1);
1233 assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
1234 enable_vendor_at(&path, VendorId::Anthropic).unwrap();
1235 assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
1236 }
1237
1238 #[test]
1239 fn explicit_enable_creates_missing_config_and_rejects_malformed_config() {
1240 let dir = tempfile::tempdir().unwrap();
1241 let path = dir.path().join("nested/config.toml");
1242 enable_vendor_at(&path, VendorId::Anthropic).unwrap();
1243 assert!(
1244 std::fs::read_to_string(&path)
1245 .unwrap()
1246 .contains("enabled = true")
1247 );
1248 let broken = "[anthropic\n";
1249 std::fs::write(&path, broken).unwrap();
1250 assert!(enable_vendor_at(&path, VendorId::Anthropic).is_err());
1251 assert_eq!(std::fs::read_to_string(&path).unwrap(), broken);
1252 }
1253
1254 fn blank_state(primary: VendorId) -> SettingsState {
1255 SettingsState {
1256 focus: Focus::Primary,
1257 primary_choices: VendorId::all().to_vec(),
1258 primary,
1259 keys: KEY_VENDORS.iter().map(|_| KeyInput::default()).collect(),
1260 notify_enabled: true,
1261 notify_enabled_dirty: false,
1262 notify_threshold: KeyInput::from_config(Some("97")),
1263 status: String::new(),
1264 }
1265 }
1266
1267 fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
1269 let mut s = blank_state(primary);
1270 s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some(zai));
1271 s.keys[key_index(VendorId::Zai)].dirty = true;
1272 s.keys[key_index(VendorId::Openrouter)] = KeyInput::from_config(Some(opr));
1273 s.keys[key_index(VendorId::Openrouter)].dirty = true;
1274 s
1275 }
1276
1277 #[test]
1278 fn focus_cycles_through_primary_all_keys_notifications_and_save() {
1279 let mut f = Focus::Primary;
1280 let mut seen = vec![f];
1281 for _ in 0..(KEY_VENDORS.len() + 4) {
1283 f = f.next();
1284 seen.push(f);
1285 }
1286 assert_eq!(seen.first(), Some(&Focus::Primary));
1288 assert_eq!(seen.last(), Some(&Focus::Primary));
1289 assert!(seen.contains(&Focus::Key(0)));
1290 assert!(seen.contains(&Focus::Key(KEY_VENDORS.len() - 1)));
1291 assert!(seen.contains(&Focus::NotifyEnabled));
1292 assert!(seen.contains(&Focus::NotifyThreshold));
1293 assert!(seen.contains(&Focus::Save));
1294 assert_eq!(Focus::Primary.next().prev(), Focus::Primary);
1296 assert_eq!(Focus::Save.prev().next(), Focus::Save);
1297 assert_eq!(
1298 Focus::NotifyEnabled.prev(),
1299 Focus::Key(KEY_VENDORS.len() - 1)
1300 );
1301 assert_eq!(Focus::NotifyThreshold.next(), Focus::Save);
1302 assert_eq!(Focus::Primary.prev(), Focus::Save);
1303 }
1304
1305 #[test]
1306 fn every_key_vendor_has_a_field() {
1307 for id in [
1309 VendorId::Zai,
1310 VendorId::Openrouter,
1311 VendorId::Deepseek,
1312 VendorId::Kilo,
1313 VendorId::Novita,
1314 VendorId::Moonshot,
1315 VendorId::Grok,
1316 ] {
1317 assert!(
1318 KEY_VENDORS.iter().any(|kv| kv.id == id),
1319 "{id:?} has no key field"
1320 );
1321 }
1322 assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Anthropic));
1324 assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Openai));
1325 }
1326
1327 #[test]
1328 fn from_config_prefills_existing_keys() {
1329 let mut cfg = Config::default();
1330 cfg.kilo.api_key = Some("sk-kilo".into());
1331 let s = SettingsState::from_config(&cfg);
1332 assert_eq!(s.keys[key_index(VendorId::Kilo)].buf, "sk-kilo");
1333 assert!(!s.keys[key_index(VendorId::Kilo)].dirty);
1334 }
1335
1336 #[test]
1337 fn from_config_prefills_the_notification_fields() {
1338 let mut cfg = Config::default();
1339 cfg.notifications.enabled = false;
1340 cfg.notifications.threshold = 100;
1341 let s = SettingsState::from_config_with(&cfg, |_| false);
1342 assert!(!s.notify_enabled);
1343 assert!(!s.notify_enabled_dirty);
1344 assert_eq!(s.notify_threshold.buf, "100");
1345 assert!(!s.notify_threshold.dirty);
1346 }
1347
1348 #[test]
1349 fn notification_toggle_flips_on_left_right_and_space() {
1350 let mut s = blank_state(VendorId::Anthropic);
1351 s.focus = Focus::NotifyEnabled;
1352 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1353 assert!(!s.notify_enabled);
1354 assert!(s.notify_enabled_dirty);
1355 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
1356 assert!(s.notify_enabled);
1357 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1358 assert!(!s.notify_enabled);
1359 handle_key(&mut s, KeyCode::Up, KeyModifiers::NONE);
1361 assert!(!s.notify_enabled);
1362 }
1363
1364 #[test]
1365 fn threshold_edits_accept_digits_and_reject_everything_else() {
1366 let mut s = blank_state(VendorId::Anthropic);
1367 s.focus = Focus::NotifyThreshold;
1368 s.notify_threshold = KeyInput::default();
1370 for c in ['9', 'a', '.', '-', '→'] {
1371 handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
1372 }
1373 assert_eq!(s.notify_threshold.buf, "9");
1374 assert!(s.notify_threshold.dirty);
1375 handle_key(&mut s, KeyCode::Char('8'), KeyModifiers::NONE);
1376 assert_eq!(s.notify_threshold.buf, "98");
1377 handle_key(&mut s, KeyCode::Backspace, KeyModifiers::NONE);
1378 assert_eq!(s.notify_threshold.buf, "9");
1379 }
1380
1381 #[test]
1382 fn save_writes_the_notification_fields_and_round_trips() {
1383 let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
1384 let mut s = blank_state(VendorId::Anthropic);
1385 s.notify_enabled = false;
1386 s.notify_enabled_dirty = true;
1387 s.notify_threshold = KeyInput::default();
1389 for c in "90".chars() {
1390 s.notify_threshold.insert_char(c);
1391 }
1392 save_to_path(&s, &path).unwrap();
1393
1394 let raw = std::fs::read_to_string(&path).unwrap();
1395 assert!(raw.contains("[notifications]"), "{raw}");
1396 assert!(raw.contains("enabled = false"), "{raw}");
1397 assert!(raw.contains("threshold = 90"), "{raw}");
1398 let reloaded = Config::load_from(&path).unwrap();
1400 assert!(!reloaded.notifications.enabled);
1401 assert_eq!(reloaded.notifications.threshold, 90);
1402 }
1403
1404 #[test]
1405 fn save_leaves_an_untouched_notification_section_alone() {
1406 let (_dir, path) = temp_config(None);
1407 let s = blank_state(VendorId::Anthropic);
1408 save_to_path(&s, &path).unwrap();
1409 let raw = std::fs::read_to_string(&path).unwrap();
1410 assert!(!raw.contains("[notifications]"), "{raw}");
1411 }
1412
1413 #[test]
1414 fn save_rejects_an_out_of_range_threshold_without_writing() {
1415 let (_dir, path) = temp_config(None);
1416 let mut s = blank_state(VendorId::Anthropic);
1417 for c in "300".chars() {
1418 s.notify_threshold.insert_char(c);
1419 }
1420 let err = save_to_path(&s, &path).unwrap_err().to_string();
1421 assert!(
1422 err.contains("[notifications] threshold must be a whole number between 1 and 100"),
1423 "{err}"
1424 );
1425 assert!(!path.exists());
1427
1428 let (_dir, path) = temp_config(None);
1430 let mut s = blank_state(VendorId::Anthropic);
1431 s.notify_threshold = KeyInput::default();
1432 s.notify_threshold.dirty = true;
1433 assert!(save_to_path(&s, &path).is_err());
1434 assert!(!path.exists());
1435 }
1436
1437 #[test]
1438 fn copilot_has_no_editable_credential_field() {
1439 assert!(
1440 !KEY_VENDORS
1441 .iter()
1442 .any(|vendor| vendor.id == VendorId::Copilot)
1443 );
1444 }
1445
1446 #[test]
1449 fn a_key_vendor_with_its_env_var_exported_is_offered() {
1450 let cfg = Config::default();
1451 let env = cfg.api_key_env_for(VendorId::Ollama).to_string();
1452
1453 let without = SettingsState::from_config_with(&cfg, |_| false);
1454 assert!(!without.primary_choices.contains(&VendorId::Ollama));
1455
1456 let with = SettingsState::from_config_with(&cfg, |name| name == env);
1457 assert!(
1458 with.primary_choices.contains(&VendorId::Ollama),
1459 "a key vendor whose env var is exported must be selectable: {:?}",
1460 with.primary_choices
1461 );
1462 }
1463
1464 #[test]
1465 fn from_config_offers_enabled_vendors_only() {
1466 let cfg = Config::default();
1467 let s = SettingsState::from_config_with(&cfg, |_| false);
1470 let mut expected = cfg.enabled_vendors();
1471 expected.push(VendorId::Copilot);
1472 assert_eq!(s.primary_choices, expected);
1473 assert!(!s.primary_choices.contains(&VendorId::Grok));
1476 assert!(s.primary_choices.contains(&s.primary));
1477 assert!(s.primary_choices.contains(&VendorId::Copilot));
1478 }
1479
1480 #[test]
1481 fn from_config_falls_back_when_configured_primary_is_disabled() {
1482 let mut cfg = Config::default();
1485 cfg.ui.primary = Some(VendorId::Grok);
1486 let s = SettingsState::from_config(&cfg);
1487 assert_ne!(s.primary, VendorId::Grok);
1488 assert_eq!(Some(s.primary), cfg.enabled_vendors().first().copied());
1489 }
1490
1491 #[test]
1492 fn key_input_insert_backspace_arrow() {
1493 let mut k = KeyInput::default();
1494 k.insert_char('a');
1495 k.insert_char('b');
1496 k.insert_char('c');
1497 assert_eq!(k.buf, "abc");
1498 assert_eq!(k.cursor, 3);
1499 assert!(k.dirty);
1500 k.move_left();
1501 k.move_left();
1502 assert_eq!(k.cursor, 1);
1503 k.insert_char('x');
1504 assert_eq!(k.buf, "axbc");
1505 assert_eq!(k.cursor, 2);
1506 k.backspace();
1507 assert_eq!(k.buf, "abc");
1508 assert_eq!(k.cursor, 1);
1509 }
1510
1511 #[test]
1512 fn key_input_masks_by_default_reveals_on_toggle() {
1513 let mut k = KeyInput::default();
1514 for c in "secret-key".chars() {
1515 k.insert_char(c);
1516 }
1517 assert_eq!(k.display(), "•".repeat(10));
1518 k.toggle_reveal();
1519 assert_eq!(k.display(), "secret-key");
1520 }
1521
1522 #[test]
1523 fn key_input_handles_unicode() {
1524 let mut k = KeyInput::default();
1525 k.insert_char('a');
1526 k.insert_char('→');
1527 k.insert_char('b');
1528 assert_eq!(k.buf, "a→b");
1529 assert_eq!(k.cursor, 3);
1530 k.move_left();
1531 k.backspace();
1532 assert_eq!(k.buf, "ab");
1533 }
1534
1535 #[test]
1536 fn value_text_shows_cursor_and_empty_states() {
1537 let mut k = KeyInput::default();
1538 assert_eq!(value_text(&k, false), "(empty)");
1539 assert_eq!(value_text(&k, true), "‸");
1540 k.insert_char('a');
1541 k.insert_char('b');
1542 assert_eq!(value_text(&k, true), "••‸");
1544 assert_eq!(value_text(&k, false), "••");
1545 }
1546
1547 #[test]
1548 fn save_writes_key_and_enables_vendor() {
1549 let (_dir, path) = temp_config(None);
1550 let mut s = blank_state(VendorId::Kilo);
1551 s.keys[key_index(VendorId::Kilo)] = KeyInput::from_config(Some("sk-kilo"));
1552 s.keys[key_index(VendorId::Kilo)].dirty = true;
1553 save_to_path(&s, &path).unwrap();
1554 let raw = std::fs::read_to_string(&path).unwrap();
1555 assert!(raw.contains("primary = \"kilo\""));
1556 assert!(raw.contains("[kilo]"));
1557 assert!(raw.contains("api_key = \"sk-kilo\""));
1558 assert!(raw.contains("enabled = true"));
1559 }
1560
1561 #[test]
1562 fn save_writes_minimal_toml_when_starting_empty() {
1563 let (_dir, path) = temp_config(None);
1564 let s = state_with("zk", "ok", VendorId::Zai);
1565 save_to_path(&s, &path).unwrap();
1566 let raw = std::fs::read_to_string(&path).unwrap();
1567 assert!(raw.contains("primary = \"zai\""));
1568 assert!(raw.contains("[zai]"));
1569 assert!(raw.contains("api_key = \"zk\""));
1570 assert!(raw.contains("[openrouter]"));
1571 assert!(raw.contains("api_key = \"ok\""));
1572 }
1573
1574 #[test]
1575 fn save_preserves_existing_comments_and_unrelated_fields() {
1576 let (_dir, path) = temp_config(Some(
1577 r##"# my comment
1578[ui]
1579# pre-existing comment
1580primary = "anthropic"
1581
1582[zai]
1583enabled = true
1584api_key_env = "ZAI_API_KEY"
1585# tier comment
1586plan_tier = "pro"
1587
1588[openrouter]
1589enabled = true
1590api_key_env = "OPENROUTER_API_KEY"
1591
1592[[openrouter.accounts]]
1593label = "work"
1594api_key_env = "OPENROUTER_WORK_API_KEY"
1595"##,
1596 ));
1597
1598 let s = state_with("zk2", "ok2", VendorId::Openrouter);
1599 save_to_path(&s, &path).unwrap();
1600
1601 let raw = std::fs::read_to_string(&path).unwrap();
1602 assert!(raw.contains("# my comment"));
1603 assert!(raw.contains("# pre-existing comment"));
1604 assert!(raw.contains("# tier comment"));
1605 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
1606 assert!(raw.contains("[[openrouter.accounts]]"));
1607 assert!(raw.contains("api_key_env = \"OPENROUTER_WORK_API_KEY\""));
1608 assert!(raw.contains("plan_tier = \"pro\""));
1609 assert!(raw.contains("primary = \"openrouter\""));
1610 assert!(raw.contains("api_key = \"zk2\""));
1611 assert!(raw.contains("api_key = \"ok2\""));
1612 }
1613
1614 #[test]
1615 fn save_refuses_to_replace_an_unreadable_existing_config() {
1616 let (_dir, path) = temp_config(None);
1617 let original = [0xff, 0xfe, 0xfd];
1618 std::fs::write(&path, original).unwrap();
1619 let state = state_with("new-secret", "", VendorId::Zai);
1620
1621 assert!(save_to_path(&state, &path).is_err());
1622 assert_eq!(std::fs::read(&path).unwrap(), original);
1623 }
1624
1625 #[test]
1626 fn save_does_not_write_empty_key_when_dirty_but_blank() {
1627 let (_dir, path) = temp_config(None);
1628 let mut s = blank_state(VendorId::Anthropic);
1629 for k in &mut s.keys {
1631 k.dirty = true;
1632 }
1633 save_to_path(&s, &path).unwrap();
1634 let raw = std::fs::read_to_string(&path).unwrap();
1635 assert!(!raw.contains("api_key ="));
1636 }
1637
1638 #[test]
1639 #[cfg(unix)]
1640 fn save_chmods_to_600() {
1641 use std::os::unix::fs::PermissionsExt;
1642 let (_dir, path) = temp_config(None);
1643 let s = state_with("zk", "ok", VendorId::Zai);
1644 save_to_path(&s, &path).unwrap();
1645 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1646 assert_eq!(mode & 0o777, 0o600);
1647 }
1648
1649 #[test]
1650 fn tab_cycles_focus_from_primary_to_first_key() {
1651 let mut s = blank_state(VendorId::Anthropic);
1652 assert_eq!(
1653 handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
1654 Action::Continue
1655 );
1656 assert_eq!(s.focus, Focus::Key(0));
1657 assert_eq!(
1658 handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
1659 Action::Continue
1660 );
1661 assert_eq!(s.focus, Focus::Primary);
1662 }
1663
1664 #[test]
1665 fn esc_closes_without_saving() {
1666 let mut s = blank_state(VendorId::Anthropic);
1667 assert_eq!(
1668 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
1669 Action::Close
1670 );
1671 }
1672
1673 #[test]
1674 fn left_right_cycles_primary_vendor() {
1675 let mut s = blank_state(VendorId::Anthropic);
1677 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1678 assert_eq!(s.primary, VendorId::AnthropicApi);
1679 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1680 assert_eq!(s.primary, VendorId::Openai);
1681 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1682 assert_eq!(s.primary, VendorId::AnthropicApi);
1683 }
1684
1685 #[test]
1686 fn left_right_offers_enabled_vendors_only() {
1687 let mut s = blank_state(VendorId::Anthropic);
1689 s.primary_choices = vec![VendorId::Anthropic, VendorId::Grok];
1690 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1691 assert_eq!(s.primary, VendorId::Grok);
1692 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1694 assert_eq!(s.primary, VendorId::Anthropic);
1695 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1696 assert_eq!(s.primary, VendorId::Grok);
1697 }
1698
1699 #[test]
1700 fn no_enabled_vendors_leaves_primary_selector_inert() {
1701 let mut s = blank_state(VendorId::Anthropic);
1702 s.primary_choices = vec![];
1703 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1704 assert_eq!(s.primary, VendorId::Anthropic);
1705 }
1706
1707 #[test]
1708 fn disabled_copilot_is_offered_but_not_shown_as_the_current_primary() {
1709 let mut cfg = Config::default();
1710 cfg.ui.primary = Some(VendorId::Copilot);
1711 let state = SettingsState::from_config(&cfg);
1712
1713 assert!(state.primary_choices.contains(&VendorId::Copilot));
1714 assert_eq!(state.primary, VendorId::Anthropic);
1715 }
1716
1717 #[test]
1718 fn save_does_not_write_a_disabled_primary() {
1719 let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
1722 let mut s = state_with("zk", "ok", VendorId::Grok);
1723 s.primary_choices = vec![VendorId::Anthropic];
1724 save_to_path(&s, &path).unwrap();
1725 let raw = std::fs::read_to_string(&path).unwrap();
1726 assert!(raw.contains("primary = \"anthropic\""));
1727 assert!(!raw.contains("primary = \"grok\""));
1728 assert!(raw.contains("zk"));
1730 }
1731
1732 #[test]
1733 fn save_removes_an_inline_key_the_user_cleared() {
1734 let (_dir, path) = temp_config(Some(
1737 "[zai]\nenabled = true\napi_key = \"old-secret\"\nplan_tier = \"pro\"\n",
1738 ));
1739 let mut s = blank_state(VendorId::Zai);
1740 s.primary_choices = vec![VendorId::Zai];
1741 s.keys[key_index(VendorId::Zai)] = KeyInput::default();
1742 s.keys[key_index(VendorId::Zai)].dirty = true;
1743 save_to_path(&s, &path).unwrap();
1744 let raw = std::fs::read_to_string(&path).unwrap();
1745 assert!(!raw.contains("old-secret"));
1746 assert!(!raw.contains("api_key"));
1747 assert!(raw.contains("plan_tier = \"pro\""));
1749 }
1750
1751 #[test]
1752 fn untouched_key_field_is_left_alone() {
1753 let (_dir, path) = temp_config(Some("[zai]\napi_key = \"keep-me\"\n"));
1755 let mut s = blank_state(VendorId::Zai);
1756 s.primary_choices = vec![VendorId::Zai];
1757 save_to_path(&s, &path).unwrap();
1758 let raw = std::fs::read_to_string(&path).unwrap();
1759 assert!(raw.contains("keep-me"));
1760 }
1761
1762 #[test]
1763 fn typing_edits_the_focused_key_only() {
1764 let mut s = blank_state(VendorId::Anthropic);
1765 s.focus = Focus::Key(key_index(VendorId::Grok));
1766 for c in "xai-abc".chars() {
1767 handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
1768 }
1769 assert_eq!(s.keys[key_index(VendorId::Grok)].buf, "xai-abc");
1770 assert!(s.keys[key_index(VendorId::Grok)].dirty);
1771 assert!(s.keys[key_index(VendorId::Zai)].buf.is_empty());
1773 }
1774
1775 #[test]
1776 fn ctrl_v_toggles_reveal_on_focused_key_field() {
1777 let mut s = blank_state(VendorId::Anthropic);
1778 let zi = key_index(VendorId::Zai);
1779 s.focus = Focus::Key(zi);
1780 s.keys[zi] = KeyInput::from_config(Some("secret"));
1781 assert!(!s.keys[zi].revealed);
1782 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1783 assert!(s.keys[zi].revealed);
1784 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1785 assert!(!s.keys[zi].revealed);
1786 }
1787
1788 #[test]
1789 fn control_chorded_chars_do_not_type_into_fields() {
1790 let mut s = blank_state(VendorId::Anthropic);
1791 s.focus = Focus::Key(0);
1792 handle_key(&mut s, KeyCode::Char('a'), KeyModifiers::CONTROL);
1794 assert!(s.keys[0].buf.is_empty());
1795 assert!(!s.keys[0].dirty);
1796 assert_eq!(
1798 handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
1799 Action::Quit
1800 );
1801 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
1803 assert_eq!(s.keys[0].buf, "x");
1804 }
1805
1806 #[test]
1807 fn ctrl_v_on_non_key_focus_is_noop() {
1808 let mut s = blank_state(VendorId::Anthropic);
1809 s.focus = Focus::Primary;
1810 assert_eq!(
1812 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL),
1813 Action::Continue
1814 );
1815 }
1816
1817 fn state_focused_on_zai() -> SettingsState {
1818 let mut state = blank_state(VendorId::Anthropic);
1819 state.focus = Focus::Key(key_index(VendorId::Zai));
1820 state
1821 }
1822
1823 #[test]
1824 fn handle_key_ctrl_c_quits_without_typing_into_key_field() {
1825 let mut s = state_focused_on_zai();
1826 let zi = key_index(VendorId::Zai);
1827 assert_eq!(
1828 handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
1829 Action::Quit
1830 );
1831 assert!(s.keys[zi].buf.is_empty());
1832 assert!(!s.keys[zi].dirty);
1834 }
1835
1836 #[test]
1837 fn handle_key_alt_chord_does_not_type_into_key_field() {
1838 let mut s = state_focused_on_zai();
1839 let zi = key_index(VendorId::Zai);
1840 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::ALT);
1841 assert!(s.keys[zi].buf.is_empty());
1842 assert!(!s.keys[zi].dirty);
1843 }
1844
1845 #[test]
1846 fn handle_key_platform_modifier_chords_do_not_type_into_key_field() {
1847 for modifier in [KeyModifiers::SUPER, KeyModifiers::HYPER, KeyModifiers::META] {
1848 let mut s = state_focused_on_zai();
1849 let zi = key_index(VendorId::Zai);
1850 handle_key(&mut s, KeyCode::Char('x'), modifier);
1851 assert!(s.keys[zi].buf.is_empty(), "modifier {modifier:?}");
1852 assert!(!s.keys[zi].dirty, "modifier {modifier:?}");
1853 }
1854 }
1855
1856 #[test]
1857 fn handle_key_shift_still_types_uppercase() {
1858 let mut s = state_focused_on_zai();
1859 let zi = key_index(VendorId::Zai);
1860 handle_key(&mut s, KeyCode::Char('A'), KeyModifiers::SHIFT);
1861 assert_eq!(s.keys[zi].buf, "A");
1862 assert!(s.keys[zi].dirty);
1863 }
1864
1865 #[test]
1866 fn handle_key_plain_space_still_cycles_primary_vendor() {
1867 let mut s = blank_state(VendorId::Anthropic);
1868 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
1869 assert_eq!(s.primary, VendorId::AnthropicApi);
1870 }
1871
1872 #[test]
1873 fn handle_key_ctrl_s_attempts_save_from_any_field() {
1874 let (_dir, path) = temp_config(None);
1875 let s = state_with("zk", "ok", VendorId::Zai);
1876 save_to_path(&s, &path).unwrap();
1877 let raw = std::fs::read_to_string(&path).unwrap();
1878 assert!(raw.contains("api_key = \"zk\""));
1879 }
1880 #[test]
1881 fn save_to_path_writes_kimi_key_when_dirty() {
1882 let (_dir, path) = temp_config(None);
1883 let mut s = blank_state(VendorId::Anthropic);
1884 let kimi = key_index(VendorId::Kimi);
1885 s.keys[kimi] = KeyInput::from_config(Some("kk"));
1886 s.keys[kimi].dirty = true;
1887 save_to_path(&s, &path).unwrap();
1888 let raw = std::fs::read_to_string(&path).unwrap();
1889 assert!(raw.contains("[kimi]"));
1890 assert!(raw.contains("api_key = \"kk\""));
1891 }
1892
1893 #[test]
1894 fn settings_save_uses_the_same_config_path_as_load() {
1895 assert_eq!(
1896 default_config_path().unwrap(),
1897 crate::config::resolved_path().unwrap()
1898 );
1899 }
1900
1901 #[test]
1902 fn native_snapshot_reports_key_state_without_serializing_secrets() {
1903 let mut cfg = Config::default();
1904 cfg.zai.api_key = Some("never-leak-this-key".into());
1905 cfg.zai.api_key_env = "CUSTOM_ZAI_KEY".into();
1906 let raw = settings_snapshot_json_with(&cfg, |name| name == "CUSTOM_ZAI_KEY").unwrap();
1907 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
1908
1909 assert_eq!(parsed["schema_version"], 1);
1910 assert_eq!(parsed["primary"], "anthropic");
1911 let zai = parsed["keys"]
1912 .as_array()
1913 .unwrap()
1914 .iter()
1915 .find(|row| row["id"] == "zai")
1916 .unwrap();
1917 assert_eq!(zai["configured"], true);
1918 assert_eq!(zai["inline_configured"], true);
1919 assert_eq!(zai["environment_configured"], true);
1920 assert_eq!(zai["environment"], "CUSTOM_ZAI_KEY");
1921 assert!(!raw.contains("never-leak-this-key"));
1922 assert!(parsed.get("api_key").is_none());
1923 }
1924
1925 #[test]
1926 fn native_snapshot_offers_copilot_primary_without_a_token_field() {
1927 let cfg = Config::default();
1928 let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
1929 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
1930 assert!(
1931 parsed["primary_choices"]
1932 .as_array()
1933 .unwrap()
1934 .iter()
1935 .any(|row| row["id"] == "copilot")
1936 );
1937 assert!(
1938 !parsed["keys"]
1939 .as_array()
1940 .unwrap()
1941 .iter()
1942 .any(|row| row["id"] == "copilot")
1943 );
1944 }
1945
1946 #[test]
1947 fn native_key_only_patch_does_not_require_or_replace_primary() {
1948 let cfg = Config::default();
1949 let original_primary = SettingsState::from_config(&cfg).primary;
1950 let request = serde_json::json!({
1951 "schema_version": 1,
1952 "keys": {"kimi": {"action": "set", "value": "new-kimi-key"}}
1953 });
1954
1955 let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
1956 assert_eq!(state.primary, original_primary);
1957 let kimi_index = KEY_VENDORS
1958 .iter()
1959 .position(|vendor| vendor.id == VendorId::Kimi)
1960 .unwrap();
1961 assert!(state.keys[kimi_index].dirty);
1962 assert_eq!(state.keys[kimi_index].buf, "new-kimi-key");
1963 }
1964
1965 #[test]
1966 fn native_patch_reuses_tui_persistence_and_preserves_existing_config() {
1967 let (_dir, path) = temp_config(Some(
1968 r#"# keep this comment
1969[ui]
1970primary = "anthropic"
1971
1972[zai]
1973enabled = true
1974api_key_env = "ZAI_API_KEY"
1975plan_tier = "pro"
1976
1977[openrouter]
1978enabled = true
1979"#,
1980 ));
1981 let cfg = Config::load_from(&path).unwrap();
1982 let request = serde_json::json!({
1983 "schema_version": 1,
1984 "primary": "openrouter",
1985 "keys": {
1986 "zai": {"action": "set", "value": "new-zai-key"}
1987 }
1988 });
1989
1990 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
1991 let raw = std::fs::read_to_string(&path).unwrap();
1992 assert!(raw.contains("# keep this comment"));
1993 assert!(raw.contains("plan_tier = \"pro\""));
1994 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
1995 assert!(raw.contains("primary = \"openrouter\""));
1996 assert!(raw.contains("api_key = \"new-zai-key\""));
1997 }
1998
1999 #[test]
2000 fn native_patch_distinguishes_clear_from_unchanged() {
2001 let (_dir, path) = temp_config(Some(
2002 "[zai]\nenabled = true\napi_key = \"remove-me\"\n\
2003 [openrouter]\nenabled = true\napi_key = \"keep-me\"\n",
2004 ));
2005 let cfg = Config::load_from(&path).unwrap();
2006 let request = serde_json::json!({
2007 "schema_version": 1,
2008 "primary": "zai",
2009 "keys": {"zai": {"action": "clear"}}
2010 });
2011
2012 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
2013 let raw = std::fs::read_to_string(&path).unwrap();
2014 assert!(!raw.contains("remove-me"));
2015 assert!(raw.contains("keep-me"));
2016 }
2017
2018 #[test]
2019 fn native_primary_selection_enables_copilot_without_writing_a_token() {
2020 let (_dir, path) = temp_config(Some(
2021 "[copilot]\nenabled = false\ntoken = \"legacy-value\"\ntoken_env = \"OLD_TOKEN\"\n",
2022 ));
2023 let cfg = Config::load_from(&path).unwrap();
2024 let select = serde_json::json!({
2025 "schema_version": 1,
2026 "primary": "copilot"
2027 });
2028 apply_settings_json_to_path(&cfg, &select.to_string(), &path).unwrap();
2029 let raw = std::fs::read_to_string(&path).unwrap();
2030 assert!(raw.contains("enabled = true"));
2031 assert!(raw.contains("primary = \"copilot\""));
2032 assert!(!raw.contains("token ="));
2033 assert!(!raw.contains("token_env ="));
2034 }
2035
2036 #[test]
2037 fn native_patch_errors_never_echo_key_values() {
2038 let raw = serde_json::json!({
2039 "schema_version": 1,
2040 "primary": "anthropic",
2041 "keys": {
2042 "zai": {"action": "set", "value": "secret\nwith-control"}
2043 }
2044 })
2045 .to_string();
2046 let error = state_from_apply_request(&Config::default(), &raw)
2047 .unwrap_err()
2048 .to_string();
2049 assert!(!error.contains("secret"));
2050 assert!(error.contains("control characters"));
2051 }
2052
2053 #[test]
2054 fn native_patch_input_is_bounded_before_json_parsing() {
2055 let oversized = vec![b'x'; MAX_SETTINGS_REQUEST_BYTES as usize + 1];
2056 let error = read_settings_request(std::io::Cursor::new(oversized))
2057 .unwrap_err()
2058 .to_string();
2059 assert!(error.contains("exceeds"));
2060 }
2061}