1use std::collections::BTreeMap;
19use std::io::BufRead;
20use std::path::{Path, PathBuf};
21
22use ratatui::Frame;
23use ratatui::layout::{Constraint, Direction, Layout, Rect};
24use ratatui::style::Modifier;
25use ratatui::text::{Line, Span};
26use ratatui::widgets::{Clear, Paragraph};
27use ratatui_bubbletea_theme::BubbleTheme;
28use serde::{Deserialize, Serialize};
29use toml_edit::{DocumentMut, value};
30
31use crate::config::{
32 Config, is_valid_env_var_name, read_config_document, set_bool, write_config_document,
33};
34use crate::error::{AppError, Result};
35use crate::theme::Theme;
36use crate::tui::style::bubble_theme;
37use crate::vendor::VendorId;
38
39pub struct KeyVendor {
43 pub id: VendorId,
44 pub label: &'static str,
45 pub section: &'static str,
46 pub config_key: &'static str,
48 pub secret_label: &'static str,
50 pub note: &'static str,
52}
53
54pub const KEY_VENDORS: &[KeyVendor] = &[
55 KeyVendor {
56 id: VendorId::AnthropicApi,
57 label: "Anthropic API",
58 section: VendorId::AnthropicApi.config_section(),
59 config_key: "api_key",
60 secret_label: "API key",
61 note: "admin key — monthly spend",
62 },
63 KeyVendor {
64 id: VendorId::Zai,
65 label: "Z.AI",
66 section: VendorId::Zai.config_section(),
67 config_key: "api_key",
68 secret_label: "API key",
69 note: "",
70 },
71 KeyVendor {
72 id: VendorId::Openrouter,
73 label: "OpenRouter",
74 section: VendorId::Openrouter.config_section(),
75 config_key: "api_key",
76 secret_label: "API key",
77 note: "",
78 },
79 KeyVendor {
80 id: VendorId::Deepseek,
81 label: "DeepSeek",
82 section: VendorId::Deepseek.config_section(),
83 config_key: "api_key",
84 secret_label: "API key",
85 note: "",
86 },
87 KeyVendor {
88 id: VendorId::Kimi,
89 label: "Kimi",
90 section: VendorId::Kimi.config_section(),
91 config_key: "api_key",
92 secret_label: "API key",
93 note: "coding-plan usage",
94 },
95 KeyVendor {
96 id: VendorId::Kilo,
97 label: "Kilo",
98 section: VendorId::Kilo.config_section(),
99 config_key: "api_key",
100 secret_label: "API key",
101 note: "",
102 },
103 KeyVendor {
104 id: VendorId::Novita,
105 label: "Novita",
106 section: VendorId::Novita.config_section(),
107 config_key: "api_key",
108 secret_label: "API key",
109 note: "",
110 },
111 KeyVendor {
112 id: VendorId::Moonshot,
113 label: "Moonshot",
114 section: VendorId::Moonshot.config_section(),
115 config_key: "api_key",
116 secret_label: "API key",
117 note: "account balance",
118 },
119 KeyVendor {
120 id: VendorId::Grok,
121 label: "Grok",
122 section: VendorId::Grok.config_section(),
123 config_key: "api_key",
124 secret_label: "API key",
125 note: "management key, not the inference key",
126 },
127 KeyVendor {
128 id: VendorId::Minimax,
129 label: "MiniMax",
130 section: VendorId::Minimax.config_section(),
131 config_key: "api_key",
132 secret_label: "API key",
133 note: "Token Plan subscription key",
134 },
135 KeyVendor {
136 id: VendorId::OpenCodeGo,
137 label: "OpenCode Go",
138 section: VendorId::OpenCodeGo.config_section(),
139 config_key: "api_key",
140 secret_label: "API key",
141 note: "usage quota",
142 },
143 KeyVendor {
144 id: VendorId::Ollama,
145 label: "Ollama Cloud",
146 section: VendorId::Ollama.config_section(),
147 config_key: "api_key",
148 secret_label: "API key",
149 note: "ollama.com/settings/keys",
150 },
151];
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum Focus {
156 Primary,
157 Key(usize),
158 Save,
159}
160
161impl Focus {
162 pub fn next(self) -> Self {
163 match self {
164 Focus::Primary => Focus::Key(0),
165 Focus::Key(i) if i + 1 < KEY_VENDORS.len() => Focus::Key(i + 1),
166 Focus::Key(_) => Focus::Save,
167 Focus::Save => Focus::Primary,
168 }
169 }
170 pub fn prev(self) -> Self {
171 match self {
172 Focus::Primary => Focus::Save,
173 Focus::Key(0) => Focus::Primary,
174 Focus::Key(i) => Focus::Key(i - 1),
175 Focus::Save => Focus::Key(KEY_VENDORS.len() - 1),
176 }
177 }
178}
179
180#[derive(Debug, Clone, Default)]
182pub struct KeyInput {
183 pub buf: String,
184 pub cursor: usize,
186 pub revealed: bool,
188 pub dirty: bool,
192}
193
194impl KeyInput {
195 pub fn from_config(initial: Option<&str>) -> Self {
196 let buf = initial.unwrap_or("").to_string();
197 let cursor = buf.chars().count();
198 Self {
199 buf,
200 cursor,
201 revealed: false,
202 dirty: false,
203 }
204 }
205
206 pub fn insert_char(&mut self, c: char) {
207 let byte_idx = self.char_to_byte(self.cursor);
208 self.buf.insert(byte_idx, c);
209 self.cursor += 1;
210 self.dirty = true;
211 }
212
213 pub fn backspace(&mut self) {
214 if self.cursor == 0 {
215 return;
216 }
217 let prev_byte = self.char_to_byte(self.cursor - 1);
218 let cur_byte = self.char_to_byte(self.cursor);
219 self.buf.replace_range(prev_byte..cur_byte, "");
220 self.cursor -= 1;
221 self.dirty = true;
222 }
223
224 pub fn delete(&mut self) {
225 let n = self.buf.chars().count();
226 if self.cursor >= n {
227 return;
228 }
229 let cur_byte = self.char_to_byte(self.cursor);
230 let next_byte = self.char_to_byte(self.cursor + 1);
231 self.buf.replace_range(cur_byte..next_byte, "");
232 self.dirty = true;
233 }
234
235 pub fn move_left(&mut self) {
236 if self.cursor > 0 {
237 self.cursor -= 1;
238 }
239 }
240 pub fn move_right(&mut self) {
241 if self.cursor < self.buf.chars().count() {
242 self.cursor += 1;
243 }
244 }
245 pub fn move_home(&mut self) {
246 self.cursor = 0;
247 }
248 pub fn move_end(&mut self) {
249 self.cursor = self.buf.chars().count();
250 }
251 pub fn toggle_reveal(&mut self) {
252 self.revealed = !self.revealed;
253 }
254
255 pub fn display(&self) -> String {
257 if self.revealed {
258 self.buf.clone()
259 } else {
260 "•".repeat(self.buf.chars().count())
261 }
262 }
263
264 fn char_to_byte(&self, char_idx: usize) -> usize {
265 self.buf
266 .char_indices()
267 .map(|(b, _)| b)
268 .chain(std::iter::once(self.buf.len()))
269 .nth(char_idx)
270 .unwrap_or(self.buf.len())
271 }
272}
273
274#[derive(Debug, Clone)]
276pub struct SettingsState {
277 pub focus: Focus,
278 pub primary_choices: Vec<VendorId>,
281 pub primary: VendorId,
282 pub keys: Vec<KeyInput>,
284 pub status: String,
286}
287
288impl SettingsState {
289 pub fn from_config(cfg: &Config) -> Self {
290 Self::from_config_with(cfg, |name| {
291 std::env::var_os(name).is_some_and(|v| !v.is_empty())
292 })
293 }
294
295 pub fn from_config_with(cfg: &Config, env_set: impl Fn(&str) -> bool) -> Self {
304 let keys = KEY_VENDORS
305 .iter()
306 .map(|kv| KeyInput::from_config(cfg.inline_api_key(kv.id)))
307 .collect();
308 let mut primary_choices = cfg.enabled_vendors();
309 if !primary_choices.contains(&VendorId::Copilot) {
313 primary_choices.push(VendorId::Copilot);
314 }
315 for kv in KEY_VENDORS {
323 if primary_choices.contains(&kv.id) {
324 continue;
325 }
326 let env = cfg.api_key_env_for(kv.id);
327 let exported = is_valid_env_var_name(env) && env_set(env);
328 if cfg.inline_api_key(kv.id).is_some() || exported {
329 primary_choices.push(kv.id);
330 }
331 }
332 let primary = cfg
336 .ui
337 .primary
338 .filter(|vendor| {
339 primary_choices.contains(vendor)
340 && (*vendor != VendorId::Copilot || cfg.copilot.enabled)
341 })
342 .or_else(|| primary_choices.first().copied())
343 .unwrap_or_else(|| cfg.ui.primary.unwrap_or(VendorId::Anthropic));
344 Self {
345 focus: Focus::Primary,
346 primary_choices,
347 primary,
348 keys,
349 status: String::new(),
350 }
351 }
352
353 fn focused_key_mut(&mut self) -> Option<&mut KeyInput> {
355 match self.focus {
356 Focus::Key(i) => self.keys.get_mut(i),
357 _ => None,
358 }
359 }
360}
361
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
364pub enum Action {
365 Continue,
367 Close,
369 SavedAndClose,
371 Quit,
374}
375
376#[cfg(unix)]
379const PERMS_NOTE: &str = " (chmod 600)";
380#[cfg(not(unix))]
381const PERMS_NOTE: &str = "";
382
383fn saved_status() -> String {
384 format!(
385 "saved to {}{}",
386 crate::config::config_path_hint(),
387 PERMS_NOTE
388 )
389}
390
391pub fn handle_key(state: &mut SettingsState, code: KeyCode, mods: KeyModifiers) -> Action {
393 if matches!(code, KeyCode::Esc) {
394 return Action::Close;
395 }
396 if matches!(code, KeyCode::Char('c')) && mods.contains(KeyModifiers::CONTROL) {
397 return Action::Quit;
398 }
399 if matches!(code, KeyCode::Char('s')) && mods.contains(KeyModifiers::CONTROL) {
401 return try_save(state);
402 }
403 if matches!(code, KeyCode::Char('v')) && mods.contains(KeyModifiers::CONTROL) {
404 if let Some(input) = state.focused_key_mut() {
405 input.toggle_reveal();
406 }
407 return Action::Continue;
408 }
409 match code {
410 KeyCode::Tab | KeyCode::Down => {
411 state.focus = state.focus.next();
412 return Action::Continue;
413 }
414 KeyCode::BackTab | KeyCode::Up => {
415 state.focus = state.focus.prev();
416 return Action::Continue;
417 }
418 _ => {}
419 }
420
421 if matches!(code, KeyCode::Char(_))
426 && mods.intersects(
427 KeyModifiers::CONTROL
428 | KeyModifiers::ALT
429 | KeyModifiers::SUPER
430 | KeyModifiers::HYPER
431 | KeyModifiers::META,
432 )
433 {
434 return Action::Continue;
435 }
436
437 match state.focus {
439 Focus::Primary => handle_primary(state, code),
440 Focus::Key(i) => {
441 if let Some(input) = state.keys.get_mut(i) {
442 handle_input(input, code);
443 }
444 }
445 Focus::Save => {
446 if matches!(code, KeyCode::Enter) {
447 return try_save(state);
448 }
449 }
450 }
451 Action::Continue
452}
453
454fn try_save(state: &mut SettingsState) -> Action {
455 match save_to_config_default(state) {
456 Ok(()) => {
457 state.status = saved_status();
458 Action::SavedAndClose
459 }
460 Err(e) => {
461 state.status = format!("save failed: {e}");
462 Action::Continue
463 }
464 }
465}
466
467fn handle_primary(state: &mut SettingsState, code: KeyCode) {
468 let choices = &state.primary_choices;
470 let Some(idx) = choices.iter().position(|v| *v == state.primary) else {
471 return;
472 };
473 let step = match code {
474 KeyCode::Left => -1,
475 KeyCode::Right | KeyCode::Char(' ') => 1,
476 _ => return,
477 };
478 state.primary = choices[((idx as i32 + step).rem_euclid(choices.len() as i32)) as usize];
479}
480
481fn handle_input(input: &mut KeyInput, code: KeyCode) {
482 match code {
483 KeyCode::Char(c) => input.insert_char(c),
484 KeyCode::Backspace => input.backspace(),
485 KeyCode::Delete => input.delete(),
486 KeyCode::Left => input.move_left(),
487 KeyCode::Right => input.move_right(),
488 KeyCode::Home => input.move_home(),
489 KeyCode::End => input.move_end(),
490 _ => {}
491 }
492}
493
494fn save_to_config_default(state: &SettingsState) -> Result<()> {
497 let path = default_config_path()?;
498 if let Some(parent) = path.parent() {
499 std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
500 }
501 save_to_path(state, &path)?;
502 crate::waybar::request_refresh();
503 Ok(())
504}
505
506pub fn save_to_path(state: &SettingsState, path: &Path) -> Result<()> {
509 let mut doc = read_config_document(path)?;
510
511 if let Some(table) = doc
515 .get_mut("copilot")
516 .and_then(toml_edit::Item::as_table_mut)
517 {
518 table.remove("token");
519 table.remove("token_env");
520 }
521
522 if state.primary_choices.contains(&state.primary) {
531 set_string(&mut doc, "ui", "primary", state.primary.slug())?;
532 if state.primary == VendorId::Copilot || KEY_VENDORS.iter().any(|kv| kv.id == state.primary)
533 {
534 set_bool(&mut doc, state.primary.config_section(), "enabled", true)?;
535 }
536 }
537
538 for (i, kv) in KEY_VENDORS.iter().enumerate() {
539 let Some(input) = state.keys.get(i) else {
540 continue;
541 };
542 update_key(&mut doc, kv, input)?;
543 }
544
545 write_config_document(path, &doc)
546}
547
548fn update_key(doc: &mut DocumentMut, vendor: &KeyVendor, input: &KeyInput) -> Result<()> {
554 if !input.dirty {
555 return Ok(());
556 }
557 if input.buf.is_empty() {
558 if let Some(table) = doc
559 .get_mut(vendor.section)
560 .and_then(toml_edit::Item::as_table_mut)
561 {
562 table.remove(vendor.config_key);
563 }
564 return Ok(());
565 }
566 set_string(doc, vendor.section, vendor.config_key, &input.buf)?;
567 set_bool(doc, vendor.section, "enabled", true)
568}
569
570fn set_string(doc: &mut DocumentMut, section: &str, key: &str, new_value: &str) -> Result<()> {
573 let table = doc
574 .entry(section)
575 .or_insert_with(toml_edit::table)
576 .as_table_mut()
577 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
578
579 if let Some(item) = table.get_mut(key)
580 && let Some(v) = item.as_value_mut()
581 {
582 *v = toml_edit::Value::from(new_value);
583 v.decor_mut().set_prefix(" ");
584 return Ok(());
585 }
586 table.insert(key, value(new_value));
587 Ok(())
588}
589
590fn default_config_path() -> Result<PathBuf> {
591 crate::config::resolved_path()
596 .ok_or_else(|| AppError::Other("could not resolve config dir".into()))
597}
598
599#[derive(Debug, Serialize)]
606struct SettingsSnapshot {
607 schema_version: u8,
608 primary: String,
609 primary_choices: Vec<PrimaryChoice>,
610 keys: Vec<KeyStatus>,
611}
612
613#[derive(Debug, Serialize)]
614struct PrimaryChoice {
615 id: String,
616 label: String,
617}
618
619#[derive(Debug, Serialize)]
620struct KeyStatus {
621 id: String,
622 label: String,
623 environment: String,
624 secret_label: String,
625 note: String,
626 configured: bool,
627 inline_configured: bool,
628 environment_configured: bool,
629}
630
631#[derive(Debug, Deserialize)]
635#[serde(deny_unknown_fields)]
636struct ApplyRequest {
637 schema_version: u8,
638 primary: Option<String>,
639 #[serde(default)]
640 keys: BTreeMap<String, KeyMutation>,
641}
642
643#[derive(Debug, Deserialize)]
644#[serde(tag = "action", rename_all = "lowercase", deny_unknown_fields)]
645enum KeyMutation {
646 Set { value: String },
647 Clear,
648}
649
650const SETTINGS_SCHEMA_VERSION: u8 = 1;
651const MAX_SETTINGS_REQUEST_BYTES: u64 = 64 * 1024;
652const MAX_API_KEY_BYTES: usize = 16 * 1024;
653
654fn snapshot_from_config_with(
655 cfg: &Config,
656 environment_configured: impl Fn(&str) -> bool,
657) -> SettingsSnapshot {
658 let state = SettingsState::from_config(cfg);
659 let primary_choices = state
660 .primary_choices
661 .iter()
662 .map(|id| PrimaryChoice {
663 id: id.slug().to_string(),
664 label: id.display_name().to_string(),
665 })
666 .collect();
667 let keys = KEY_VENDORS
668 .iter()
669 .map(|vendor| {
670 let environment = cfg.api_key_env_for(vendor.id);
671 let inline_configured = cfg.inline_api_key(vendor.id).is_some();
672 let environment_configured = environment_configured(environment);
673 KeyStatus {
674 id: vendor.id.slug().to_string(),
675 label: vendor.label.to_string(),
676 environment: environment.to_string(),
677 secret_label: vendor.secret_label.to_string(),
678 note: vendor.note.to_string(),
679 configured: inline_configured || environment_configured,
680 inline_configured,
681 environment_configured,
682 }
683 })
684 .collect();
685 SettingsSnapshot {
686 schema_version: SETTINGS_SCHEMA_VERSION,
687 primary: state.primary.slug().to_string(),
688 primary_choices,
689 keys,
690 }
691}
692
693fn settings_snapshot_json(cfg: &Config) -> Result<String> {
694 Ok(serde_json::to_string(&snapshot_from_config_with(
695 cfg,
696 |environment| std::env::var_os(environment).is_some_and(|value| !value.is_empty()),
697 ))?)
698}
699
700#[cfg(test)]
701fn settings_snapshot_json_with(
702 cfg: &Config,
703 environment_configured: impl Fn(&str) -> bool,
704) -> Result<String> {
705 Ok(serde_json::to_string(&snapshot_from_config_with(
706 cfg,
707 environment_configured,
708 ))?)
709}
710
711fn vendor_from_slug(slug: &str) -> Option<VendorId> {
712 VendorId::all().iter().copied().find(|id| id.slug() == slug)
713}
714
715fn state_from_apply_request(cfg: &Config, raw: &str) -> Result<SettingsState> {
716 let request: ApplyRequest = serde_json::from_str(raw)?;
717 if request.schema_version != SETTINGS_SCHEMA_VERSION {
718 return Err(AppError::Other(format!(
719 "unsupported settings schema version {}",
720 request.schema_version
721 )));
722 }
723
724 let mut state = SettingsState::from_config(cfg);
725 if let Some(primary) = request.primary {
726 let id = vendor_from_slug(&primary)
727 .ok_or_else(|| AppError::Other(format!("unknown primary vendor {primary:?}")))?;
728 if !state.primary_choices.contains(&id) {
729 return Err(AppError::Other(format!(
730 "primary vendor {primary:?} is not enabled"
731 )));
732 }
733 state.primary = id;
734 }
735
736 for (id, mutation) in request.keys {
737 let index = KEY_VENDORS
738 .iter()
739 .position(|vendor| vendor.id.slug() == id)
740 .ok_or_else(|| AppError::Other(format!("unknown credential vendor {id:?}")))?;
741 let input = &mut state.keys[index];
742 match mutation {
743 KeyMutation::Set { value } => {
744 if value.is_empty() {
745 return Err(AppError::Other(format!(
746 "{} for {id:?} is empty; use the clear action to remove it",
747 KEY_VENDORS[index].secret_label
748 )));
749 }
750 if value.len() > MAX_API_KEY_BYTES {
751 return Err(AppError::Other(format!(
752 "{} for {id:?} exceeds {MAX_API_KEY_BYTES} bytes",
753 KEY_VENDORS[index].secret_label
754 )));
755 }
756 if value.chars().any(char::is_control) {
757 return Err(AppError::Other(format!(
758 "{} for {id:?} contains control characters",
759 KEY_VENDORS[index].secret_label
760 )));
761 }
762 input.buf = value;
763 }
764 KeyMutation::Clear => input.buf.clear(),
765 }
766 input.cursor = input.buf.chars().count();
767 input.dirty = true;
768 input.revealed = false;
769 }
770 Ok(state)
771}
772
773#[cfg(test)]
774fn apply_settings_json_to_path(cfg: &Config, raw: &str, path: &Path) -> Result<()> {
775 let state = state_from_apply_request(cfg, raw)?;
776 save_to_path(&state, path)
777}
778
779fn read_settings_request<R: BufRead>(reader: R) -> Result<String> {
780 let mut limited = reader.take(MAX_SETTINGS_REQUEST_BYTES + 1);
781 let mut bytes = Vec::new();
782 limited.read_until(b'\n', &mut bytes)?;
783 if bytes.len() as u64 > MAX_SETTINGS_REQUEST_BYTES {
784 return Err(AppError::Other(format!(
785 "settings request exceeds {MAX_SETTINGS_REQUEST_BYTES} bytes"
786 )));
787 }
788 if bytes.last() == Some(&b'\n') {
789 bytes.pop();
790 if bytes.last() == Some(&b'\r') {
791 bytes.pop();
792 }
793 }
794 String::from_utf8(bytes)
795 .map_err(|_| AppError::Other("settings request is not valid UTF-8".into()))
796}
797
798fn apply_settings_from_stdin() -> Result<()> {
799 let raw = read_settings_request(std::io::stdin().lock())?;
800 let cfg = Config::load()?;
801 let state = state_from_apply_request(&cfg, &raw)?;
802 save_to_config_default(&state)
803}
804
805fn enable_vendor_at(path: &Path, vendor: VendorId) -> Result<()> {
807 let mut doc = read_config_document(path)?;
808 let before = doc.to_string();
809 set_bool(&mut doc, vendor.config_section(), "enabled", true)?;
810 if doc.to_string() != before {
811 write_config_document(path, &doc)?;
812 }
813 Ok(())
814}
815
816pub fn run_cli(action: &crate::widget::cli::SettingsAction) -> i32 {
820 let result = match action {
821 crate::widget::cli::SettingsAction::Enable { vendor } => default_config_path()
822 .and_then(|path| enable_vendor_at(&path, vendor.to_id()))
823 .map(|()| {
824 crate::waybar::request_refresh();
825 println!(r#"{{"ok":true}}"#);
826 }),
827 crate::widget::cli::SettingsAction::Show => Config::load()
828 .and_then(|cfg| settings_snapshot_json(&cfg))
829 .map(|json| println!("{json}")),
830 crate::widget::cli::SettingsAction::Apply => {
831 apply_settings_from_stdin().map(|()| println!(r#"{{"ok":true}}"#))
832 }
833 };
834 match result {
835 Ok(()) => 0,
836 Err(error) => {
837 eprintln!("settings: {error}");
838 1
839 }
840 }
841}
842
843pub fn render(f: &mut Frame, area: Rect, state: &SettingsState, theme: &Theme) {
847 let modal = centered_rect(74, 88, area);
848 f.render_widget(Clear, modal);
849
850 let bubble = bubble_theme(theme);
851 let block = bubble.titled_modal_block(" Settings ");
852 let inner = block.inner(modal);
853 f.render_widget(block, modal);
854
855 let chunks = Layout::default()
857 .direction(Direction::Vertical)
858 .constraints([Constraint::Min(0), Constraint::Length(1)])
859 .split(inner);
860
861 let mut lines: Vec<Line> = vec![
863 section_header("Primary vendor", "shown first on the bar / TUI", &bubble),
864 primary_line(state, &bubble),
865 Line::from(""),
866 section_header(
867 "Credentials",
868 "pick a row, type the credential, then Ctrl-S — Claude & Codex use CLI login",
869 &bubble,
870 ),
871 ];
872 for (i, kv) in KEY_VENDORS.iter().enumerate() {
873 let focused = state.focus == Focus::Key(i);
874 lines.push(key_row(kv, &state.keys[i], focused, &bubble));
875 }
876 lines.push(Line::from(""));
877
878 lines.push(save_line(state.focus == Focus::Save, &bubble));
880 if !state.status.is_empty() {
881 let ok = state.status.starts_with("saved");
882 let mark = if ok { " ✓ " } else { " ✗ " };
883 let style = if ok { bubble.accent } else { bubble.selected };
884 lines.push(Line::from(vec![
885 Span::styled(mark, style.add_modifier(Modifier::BOLD)),
886 Span::styled(state.status.clone(), bubble.muted),
887 ]));
888 }
889
890 f.render_widget(Paragraph::new(lines), chunks[0]);
891
892 let hint = match state.focus {
894 Focus::Primary => bubble.help_line([
895 ("↑↓/tab", "move"),
896 ("←→", "change vendor"),
897 ("^S", "save"),
898 ("esc", "close"),
899 ]),
900 Focus::Key(_) => bubble.help_line([
901 ("↑↓/tab", "move"),
902 ("type", "edit key"),
903 ("^V", "reveal"),
904 ("^S", "save"),
905 ("esc", "close"),
906 ]),
907 Focus::Save => {
908 bubble.help_line([("↑↓/tab", "move"), ("enter/^S", "save"), ("esc", "close")])
909 }
910 };
911 f.render_widget(Paragraph::new(hint), chunks[1]);
912}
913
914fn section_header(title: &str, sub: &str, theme: &BubbleTheme) -> Line<'static> {
915 Line::from(vec![
916 theme.span(" "),
917 Span::styled(title.to_string(), theme.title.add_modifier(Modifier::BOLD)),
918 theme.muted(format!(" — {sub}")),
919 ])
920}
921
922fn primary_line(state: &SettingsState, theme: &BubbleTheme) -> Line<'static> {
923 let focused = state.focus == Focus::Primary;
924 let name = state.primary.display_name().to_string();
925 if focused {
926 Line::from(vec![
927 theme.span(" "),
928 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
929 Span::styled("◀ ", theme.accent),
930 Span::styled(
931 format!(" {name} "),
932 theme
933 .selected
934 .add_modifier(Modifier::REVERSED | Modifier::BOLD),
935 ),
936 Span::styled(" ▶", theme.accent),
937 theme.muted(" ← → to change"),
938 ])
939 } else {
940 Line::from(vec![theme.span(" "), Span::styled(name, theme.text)])
941 }
942}
943
944fn key_row(kv: &KeyVendor, input: &KeyInput, focused: bool, theme: &BubbleTheme) -> Line<'static> {
945 let label = format!("{:<11}", kv.label);
946 let value = value_text(input, focused);
947
948 let env_name = kv.id.api_key_env();
950 let env_set = std::env::var(env_name)
951 .map(|v| !v.is_empty())
952 .unwrap_or(false);
953 let mut suffix = format!(" {env_name}");
954 if env_set {
955 suffix.push_str(" · env set (overrides)");
956 }
957 if !kv.note.is_empty() {
958 suffix.push_str(&format!(" · {}", kv.note));
959 }
960
961 if focused {
962 let val_style = if input.buf.is_empty() {
963 theme.accent.add_modifier(Modifier::BOLD)
964 } else {
965 theme.selected.add_modifier(Modifier::REVERSED)
966 };
967 let mut spans = vec![
968 theme.span(" "),
969 Span::styled("▸ ", theme.accent.add_modifier(Modifier::BOLD)),
970 Span::styled(label, theme.title.add_modifier(Modifier::BOLD)),
971 Span::styled(format!(" {value} "), val_style),
972 ];
973 if input.revealed {
974 spans.push(theme.muted(" [revealed]"));
975 }
976 spans.push(theme.muted(suffix));
977 Line::from(spans)
978 } else {
979 let val_style = if input.buf.is_empty() {
980 theme.muted
981 } else {
982 theme.text
983 };
984 Line::from(vec![
985 theme.span(" "),
986 Span::styled(label, theme.text),
987 Span::styled(format!(" {value}"), val_style),
988 theme.muted(suffix),
989 ])
990 }
991}
992
993fn value_text(input: &KeyInput, focused: bool) -> String {
996 if input.buf.is_empty() {
997 return if focused {
998 "‸".to_string()
999 } else {
1000 "(empty)".to_string()
1001 };
1002 }
1003 let base = input.display();
1004 if !focused {
1005 return base;
1006 }
1007 let mut chars: Vec<char> = base.chars().collect();
1008 let pos = input.cursor.min(chars.len());
1009 chars.insert(pos, '‸');
1010 chars.into_iter().collect()
1011}
1012
1013fn save_line(focused: bool, theme: &BubbleTheme) -> Line<'static> {
1014 let style = if focused {
1015 theme
1016 .selected
1017 .add_modifier(Modifier::REVERSED | Modifier::BOLD)
1018 } else {
1019 theme.accent.add_modifier(Modifier::BOLD)
1020 };
1021 let marker = if focused { "▸ " } else { " " };
1022 Line::from(vec![
1023 theme.span(" "),
1024 Span::styled(marker, theme.accent.add_modifier(Modifier::BOLD)),
1025 Span::styled(" Save (Ctrl-S) ", style),
1026 ])
1027}
1028
1029fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1031 let popup_h = (r.height * percent_y) / 100;
1032 let popup_w = (r.width * percent_x) / 100;
1033 Rect {
1034 x: r.x + (r.width - popup_w) / 2,
1035 y: r.y + (r.height - popup_h) / 2,
1036 width: popup_w,
1037 height: popup_h,
1038 }
1039}
1040
1041pub use ratatui::crossterm::event::{KeyCode, KeyModifiers};
1043
1044#[cfg(test)]
1045mod tests {
1046 use super::*;
1047 use tempfile::TempDir;
1048
1049 fn temp_config(initial: Option<&str>) -> (TempDir, std::path::PathBuf) {
1050 crate::cache::closed_temp_file("config.toml", initial)
1051 }
1052
1053 fn key_index(id: VendorId) -> usize {
1054 KEY_VENDORS.iter().position(|kv| kv.id == id).unwrap()
1055 }
1056
1057 #[test]
1058 fn explicit_enable_preserves_other_settings_and_is_idempotent() {
1059 let dir = tempfile::tempdir().unwrap();
1060 let path = dir.path().join("config.toml");
1061 let original = "# keep me\n[anthropic]\nenabled = false # intentional\n[openrouter]\napi_key = \"test-key\"\nenabled = false\n";
1062 std::fs::write(&path, original).unwrap();
1063 enable_vendor_at(&path, VendorId::Anthropic).unwrap();
1064 let expected = original.replacen("enabled = false", "enabled = true", 1);
1065 assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
1066 enable_vendor_at(&path, VendorId::Anthropic).unwrap();
1067 assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
1068 }
1069
1070 #[test]
1071 fn explicit_enable_creates_missing_config_and_rejects_malformed_config() {
1072 let dir = tempfile::tempdir().unwrap();
1073 let path = dir.path().join("nested/config.toml");
1074 enable_vendor_at(&path, VendorId::Anthropic).unwrap();
1075 assert!(
1076 std::fs::read_to_string(&path)
1077 .unwrap()
1078 .contains("enabled = true")
1079 );
1080 let broken = "[anthropic\n";
1081 std::fs::write(&path, broken).unwrap();
1082 assert!(enable_vendor_at(&path, VendorId::Anthropic).is_err());
1083 assert_eq!(std::fs::read_to_string(&path).unwrap(), broken);
1084 }
1085
1086 fn blank_state(primary: VendorId) -> SettingsState {
1087 SettingsState {
1088 focus: Focus::Primary,
1089 primary_choices: VendorId::all().to_vec(),
1090 primary,
1091 keys: KEY_VENDORS.iter().map(|_| KeyInput::default()).collect(),
1092 status: String::new(),
1093 }
1094 }
1095
1096 fn state_with(zai: &str, opr: &str, primary: VendorId) -> SettingsState {
1098 let mut s = blank_state(primary);
1099 s.keys[key_index(VendorId::Zai)] = KeyInput::from_config(Some(zai));
1100 s.keys[key_index(VendorId::Zai)].dirty = true;
1101 s.keys[key_index(VendorId::Openrouter)] = KeyInput::from_config(Some(opr));
1102 s.keys[key_index(VendorId::Openrouter)].dirty = true;
1103 s
1104 }
1105
1106 #[test]
1107 fn focus_cycles_through_primary_all_keys_and_save() {
1108 let mut f = Focus::Primary;
1109 let mut seen = vec![f];
1110 for _ in 0..(KEY_VENDORS.len() + 2) {
1112 f = f.next();
1113 seen.push(f);
1114 }
1115 assert_eq!(seen.first(), Some(&Focus::Primary));
1117 assert_eq!(seen.last(), Some(&Focus::Primary));
1118 assert!(seen.contains(&Focus::Key(0)));
1119 assert!(seen.contains(&Focus::Key(KEY_VENDORS.len() - 1)));
1120 assert!(seen.contains(&Focus::Save));
1121 assert_eq!(Focus::Primary.next().prev(), Focus::Primary);
1123 assert_eq!(Focus::Save.prev().next(), Focus::Save);
1124 assert_eq!(Focus::Primary.prev(), Focus::Save);
1125 }
1126
1127 #[test]
1128 fn every_key_vendor_has_a_field() {
1129 for id in [
1131 VendorId::Zai,
1132 VendorId::Openrouter,
1133 VendorId::Deepseek,
1134 VendorId::Kilo,
1135 VendorId::Novita,
1136 VendorId::Moonshot,
1137 VendorId::Grok,
1138 ] {
1139 assert!(
1140 KEY_VENDORS.iter().any(|kv| kv.id == id),
1141 "{id:?} has no key field"
1142 );
1143 }
1144 assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Anthropic));
1146 assert!(!KEY_VENDORS.iter().any(|kv| kv.id == VendorId::Openai));
1147 }
1148
1149 #[test]
1150 fn from_config_prefills_existing_keys() {
1151 let mut cfg = Config::default();
1152 cfg.kilo.api_key = Some("sk-kilo".into());
1153 let s = SettingsState::from_config(&cfg);
1154 assert_eq!(s.keys[key_index(VendorId::Kilo)].buf, "sk-kilo");
1155 assert!(!s.keys[key_index(VendorId::Kilo)].dirty);
1156 }
1157
1158 #[test]
1159 fn copilot_has_no_editable_credential_field() {
1160 assert!(
1161 !KEY_VENDORS
1162 .iter()
1163 .any(|vendor| vendor.id == VendorId::Copilot)
1164 );
1165 }
1166
1167 #[test]
1170 fn a_key_vendor_with_its_env_var_exported_is_offered() {
1171 let cfg = Config::default();
1172 let env = cfg.api_key_env_for(VendorId::Ollama).to_string();
1173
1174 let without = SettingsState::from_config_with(&cfg, |_| false);
1175 assert!(!without.primary_choices.contains(&VendorId::Ollama));
1176
1177 let with = SettingsState::from_config_with(&cfg, |name| name == env);
1178 assert!(
1179 with.primary_choices.contains(&VendorId::Ollama),
1180 "a key vendor whose env var is exported must be selectable: {:?}",
1181 with.primary_choices
1182 );
1183 }
1184
1185 #[test]
1186 fn from_config_offers_enabled_vendors_only() {
1187 let cfg = Config::default();
1188 let s = SettingsState::from_config_with(&cfg, |_| false);
1191 let mut expected = cfg.enabled_vendors();
1192 expected.push(VendorId::Copilot);
1193 assert_eq!(s.primary_choices, expected);
1194 assert!(!s.primary_choices.contains(&VendorId::Grok));
1197 assert!(s.primary_choices.contains(&s.primary));
1198 assert!(s.primary_choices.contains(&VendorId::Copilot));
1199 }
1200
1201 #[test]
1202 fn from_config_falls_back_when_configured_primary_is_disabled() {
1203 let mut cfg = Config::default();
1206 cfg.ui.primary = Some(VendorId::Grok);
1207 let s = SettingsState::from_config(&cfg);
1208 assert_ne!(s.primary, VendorId::Grok);
1209 assert_eq!(Some(s.primary), cfg.enabled_vendors().first().copied());
1210 }
1211
1212 #[test]
1213 fn key_input_insert_backspace_arrow() {
1214 let mut k = KeyInput::default();
1215 k.insert_char('a');
1216 k.insert_char('b');
1217 k.insert_char('c');
1218 assert_eq!(k.buf, "abc");
1219 assert_eq!(k.cursor, 3);
1220 assert!(k.dirty);
1221 k.move_left();
1222 k.move_left();
1223 assert_eq!(k.cursor, 1);
1224 k.insert_char('x');
1225 assert_eq!(k.buf, "axbc");
1226 assert_eq!(k.cursor, 2);
1227 k.backspace();
1228 assert_eq!(k.buf, "abc");
1229 assert_eq!(k.cursor, 1);
1230 }
1231
1232 #[test]
1233 fn key_input_masks_by_default_reveals_on_toggle() {
1234 let mut k = KeyInput::default();
1235 for c in "secret-key".chars() {
1236 k.insert_char(c);
1237 }
1238 assert_eq!(k.display(), "•".repeat(10));
1239 k.toggle_reveal();
1240 assert_eq!(k.display(), "secret-key");
1241 }
1242
1243 #[test]
1244 fn key_input_handles_unicode() {
1245 let mut k = KeyInput::default();
1246 k.insert_char('a');
1247 k.insert_char('→');
1248 k.insert_char('b');
1249 assert_eq!(k.buf, "a→b");
1250 assert_eq!(k.cursor, 3);
1251 k.move_left();
1252 k.backspace();
1253 assert_eq!(k.buf, "ab");
1254 }
1255
1256 #[test]
1257 fn value_text_shows_cursor_and_empty_states() {
1258 let mut k = KeyInput::default();
1259 assert_eq!(value_text(&k, false), "(empty)");
1260 assert_eq!(value_text(&k, true), "‸");
1261 k.insert_char('a');
1262 k.insert_char('b');
1263 assert_eq!(value_text(&k, true), "••‸");
1265 assert_eq!(value_text(&k, false), "••");
1266 }
1267
1268 #[test]
1269 fn save_writes_key_and_enables_vendor() {
1270 let (_dir, path) = temp_config(None);
1271 let mut s = blank_state(VendorId::Kilo);
1272 s.keys[key_index(VendorId::Kilo)] = KeyInput::from_config(Some("sk-kilo"));
1273 s.keys[key_index(VendorId::Kilo)].dirty = true;
1274 save_to_path(&s, &path).unwrap();
1275 let raw = std::fs::read_to_string(&path).unwrap();
1276 assert!(raw.contains("primary = \"kilo\""));
1277 assert!(raw.contains("[kilo]"));
1278 assert!(raw.contains("api_key = \"sk-kilo\""));
1279 assert!(raw.contains("enabled = true"));
1280 }
1281
1282 #[test]
1283 fn save_writes_minimal_toml_when_starting_empty() {
1284 let (_dir, path) = temp_config(None);
1285 let s = state_with("zk", "ok", VendorId::Zai);
1286 save_to_path(&s, &path).unwrap();
1287 let raw = std::fs::read_to_string(&path).unwrap();
1288 assert!(raw.contains("primary = \"zai\""));
1289 assert!(raw.contains("[zai]"));
1290 assert!(raw.contains("api_key = \"zk\""));
1291 assert!(raw.contains("[openrouter]"));
1292 assert!(raw.contains("api_key = \"ok\""));
1293 }
1294
1295 #[test]
1296 fn save_preserves_existing_comments_and_unrelated_fields() {
1297 let (_dir, path) = temp_config(Some(
1298 r##"# my comment
1299[ui]
1300# pre-existing comment
1301primary = "anthropic"
1302
1303[zai]
1304enabled = true
1305api_key_env = "ZAI_API_KEY"
1306# tier comment
1307plan_tier = "pro"
1308
1309[openrouter]
1310enabled = true
1311api_key_env = "OPENROUTER_API_KEY"
1312
1313[[openrouter.accounts]]
1314label = "work"
1315api_key_env = "OPENROUTER_WORK_API_KEY"
1316"##,
1317 ));
1318
1319 let s = state_with("zk2", "ok2", VendorId::Openrouter);
1320 save_to_path(&s, &path).unwrap();
1321
1322 let raw = std::fs::read_to_string(&path).unwrap();
1323 assert!(raw.contains("# my comment"));
1324 assert!(raw.contains("# pre-existing comment"));
1325 assert!(raw.contains("# tier comment"));
1326 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
1327 assert!(raw.contains("[[openrouter.accounts]]"));
1328 assert!(raw.contains("api_key_env = \"OPENROUTER_WORK_API_KEY\""));
1329 assert!(raw.contains("plan_tier = \"pro\""));
1330 assert!(raw.contains("primary = \"openrouter\""));
1331 assert!(raw.contains("api_key = \"zk2\""));
1332 assert!(raw.contains("api_key = \"ok2\""));
1333 }
1334
1335 #[test]
1336 fn save_refuses_to_replace_an_unreadable_existing_config() {
1337 let (_dir, path) = temp_config(None);
1338 let original = [0xff, 0xfe, 0xfd];
1339 std::fs::write(&path, original).unwrap();
1340 let state = state_with("new-secret", "", VendorId::Zai);
1341
1342 assert!(save_to_path(&state, &path).is_err());
1343 assert_eq!(std::fs::read(&path).unwrap(), original);
1344 }
1345
1346 #[test]
1347 fn save_does_not_write_empty_key_when_dirty_but_blank() {
1348 let (_dir, path) = temp_config(None);
1349 let mut s = blank_state(VendorId::Anthropic);
1350 for k in &mut s.keys {
1352 k.dirty = true;
1353 }
1354 save_to_path(&s, &path).unwrap();
1355 let raw = std::fs::read_to_string(&path).unwrap();
1356 assert!(!raw.contains("api_key ="));
1357 }
1358
1359 #[test]
1360 #[cfg(unix)]
1361 fn save_chmods_to_600() {
1362 use std::os::unix::fs::PermissionsExt;
1363 let (_dir, path) = temp_config(None);
1364 let s = state_with("zk", "ok", VendorId::Zai);
1365 save_to_path(&s, &path).unwrap();
1366 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1367 assert_eq!(mode & 0o777, 0o600);
1368 }
1369
1370 #[test]
1371 fn tab_cycles_focus_from_primary_to_first_key() {
1372 let mut s = blank_state(VendorId::Anthropic);
1373 assert_eq!(
1374 handle_key(&mut s, KeyCode::Tab, KeyModifiers::NONE),
1375 Action::Continue
1376 );
1377 assert_eq!(s.focus, Focus::Key(0));
1378 assert_eq!(
1379 handle_key(&mut s, KeyCode::BackTab, KeyModifiers::NONE),
1380 Action::Continue
1381 );
1382 assert_eq!(s.focus, Focus::Primary);
1383 }
1384
1385 #[test]
1386 fn esc_closes_without_saving() {
1387 let mut s = blank_state(VendorId::Anthropic);
1388 assert_eq!(
1389 handle_key(&mut s, KeyCode::Esc, KeyModifiers::NONE),
1390 Action::Close
1391 );
1392 }
1393
1394 #[test]
1395 fn left_right_cycles_primary_vendor() {
1396 let mut s = blank_state(VendorId::Anthropic);
1398 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1399 assert_eq!(s.primary, VendorId::AnthropicApi);
1400 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1401 assert_eq!(s.primary, VendorId::Openai);
1402 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1403 assert_eq!(s.primary, VendorId::AnthropicApi);
1404 }
1405
1406 #[test]
1407 fn left_right_offers_enabled_vendors_only() {
1408 let mut s = blank_state(VendorId::Anthropic);
1410 s.primary_choices = vec![VendorId::Anthropic, VendorId::Grok];
1411 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1412 assert_eq!(s.primary, VendorId::Grok);
1413 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1415 assert_eq!(s.primary, VendorId::Anthropic);
1416 handle_key(&mut s, KeyCode::Left, KeyModifiers::NONE);
1417 assert_eq!(s.primary, VendorId::Grok);
1418 }
1419
1420 #[test]
1421 fn no_enabled_vendors_leaves_primary_selector_inert() {
1422 let mut s = blank_state(VendorId::Anthropic);
1423 s.primary_choices = vec![];
1424 handle_key(&mut s, KeyCode::Right, KeyModifiers::NONE);
1425 assert_eq!(s.primary, VendorId::Anthropic);
1426 }
1427
1428 #[test]
1429 fn disabled_copilot_is_offered_but_not_shown_as_the_current_primary() {
1430 let mut cfg = Config::default();
1431 cfg.ui.primary = Some(VendorId::Copilot);
1432 let state = SettingsState::from_config(&cfg);
1433
1434 assert!(state.primary_choices.contains(&VendorId::Copilot));
1435 assert_eq!(state.primary, VendorId::Anthropic);
1436 }
1437
1438 #[test]
1439 fn save_does_not_write_a_disabled_primary() {
1440 let (_dir, path) = temp_config(Some("[ui]\nprimary = \"anthropic\"\n"));
1443 let mut s = state_with("zk", "ok", VendorId::Grok);
1444 s.primary_choices = vec![VendorId::Anthropic];
1445 save_to_path(&s, &path).unwrap();
1446 let raw = std::fs::read_to_string(&path).unwrap();
1447 assert!(raw.contains("primary = \"anthropic\""));
1448 assert!(!raw.contains("primary = \"grok\""));
1449 assert!(raw.contains("zk"));
1451 }
1452
1453 #[test]
1454 fn save_removes_an_inline_key_the_user_cleared() {
1455 let (_dir, path) = temp_config(Some(
1458 "[zai]\nenabled = true\napi_key = \"old-secret\"\nplan_tier = \"pro\"\n",
1459 ));
1460 let mut s = blank_state(VendorId::Zai);
1461 s.primary_choices = vec![VendorId::Zai];
1462 s.keys[key_index(VendorId::Zai)] = KeyInput::default();
1463 s.keys[key_index(VendorId::Zai)].dirty = true;
1464 save_to_path(&s, &path).unwrap();
1465 let raw = std::fs::read_to_string(&path).unwrap();
1466 assert!(!raw.contains("old-secret"));
1467 assert!(!raw.contains("api_key"));
1468 assert!(raw.contains("plan_tier = \"pro\""));
1470 }
1471
1472 #[test]
1473 fn untouched_key_field_is_left_alone() {
1474 let (_dir, path) = temp_config(Some("[zai]\napi_key = \"keep-me\"\n"));
1476 let mut s = blank_state(VendorId::Zai);
1477 s.primary_choices = vec![VendorId::Zai];
1478 save_to_path(&s, &path).unwrap();
1479 let raw = std::fs::read_to_string(&path).unwrap();
1480 assert!(raw.contains("keep-me"));
1481 }
1482
1483 #[test]
1484 fn typing_edits_the_focused_key_only() {
1485 let mut s = blank_state(VendorId::Anthropic);
1486 s.focus = Focus::Key(key_index(VendorId::Grok));
1487 for c in "xai-abc".chars() {
1488 handle_key(&mut s, KeyCode::Char(c), KeyModifiers::NONE);
1489 }
1490 assert_eq!(s.keys[key_index(VendorId::Grok)].buf, "xai-abc");
1491 assert!(s.keys[key_index(VendorId::Grok)].dirty);
1492 assert!(s.keys[key_index(VendorId::Zai)].buf.is_empty());
1494 }
1495
1496 #[test]
1497 fn ctrl_v_toggles_reveal_on_focused_key_field() {
1498 let mut s = blank_state(VendorId::Anthropic);
1499 let zi = key_index(VendorId::Zai);
1500 s.focus = Focus::Key(zi);
1501 s.keys[zi] = KeyInput::from_config(Some("secret"));
1502 assert!(!s.keys[zi].revealed);
1503 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1504 assert!(s.keys[zi].revealed);
1505 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL);
1506 assert!(!s.keys[zi].revealed);
1507 }
1508
1509 #[test]
1510 fn control_chorded_chars_do_not_type_into_fields() {
1511 let mut s = blank_state(VendorId::Anthropic);
1512 s.focus = Focus::Key(0);
1513 handle_key(&mut s, KeyCode::Char('a'), KeyModifiers::CONTROL);
1515 assert!(s.keys[0].buf.is_empty());
1516 assert!(!s.keys[0].dirty);
1517 assert_eq!(
1519 handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
1520 Action::Quit
1521 );
1522 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::NONE);
1524 assert_eq!(s.keys[0].buf, "x");
1525 }
1526
1527 #[test]
1528 fn ctrl_v_on_non_key_focus_is_noop() {
1529 let mut s = blank_state(VendorId::Anthropic);
1530 s.focus = Focus::Primary;
1531 assert_eq!(
1533 handle_key(&mut s, KeyCode::Char('v'), KeyModifiers::CONTROL),
1534 Action::Continue
1535 );
1536 }
1537
1538 fn state_focused_on_zai() -> SettingsState {
1539 let mut state = blank_state(VendorId::Anthropic);
1540 state.focus = Focus::Key(key_index(VendorId::Zai));
1541 state
1542 }
1543
1544 #[test]
1545 fn handle_key_ctrl_c_quits_without_typing_into_key_field() {
1546 let mut s = state_focused_on_zai();
1547 let zi = key_index(VendorId::Zai);
1548 assert_eq!(
1549 handle_key(&mut s, KeyCode::Char('c'), KeyModifiers::CONTROL),
1550 Action::Quit
1551 );
1552 assert!(s.keys[zi].buf.is_empty());
1553 assert!(!s.keys[zi].dirty);
1555 }
1556
1557 #[test]
1558 fn handle_key_alt_chord_does_not_type_into_key_field() {
1559 let mut s = state_focused_on_zai();
1560 let zi = key_index(VendorId::Zai);
1561 handle_key(&mut s, KeyCode::Char('x'), KeyModifiers::ALT);
1562 assert!(s.keys[zi].buf.is_empty());
1563 assert!(!s.keys[zi].dirty);
1564 }
1565
1566 #[test]
1567 fn handle_key_platform_modifier_chords_do_not_type_into_key_field() {
1568 for modifier in [KeyModifiers::SUPER, KeyModifiers::HYPER, KeyModifiers::META] {
1569 let mut s = state_focused_on_zai();
1570 let zi = key_index(VendorId::Zai);
1571 handle_key(&mut s, KeyCode::Char('x'), modifier);
1572 assert!(s.keys[zi].buf.is_empty(), "modifier {modifier:?}");
1573 assert!(!s.keys[zi].dirty, "modifier {modifier:?}");
1574 }
1575 }
1576
1577 #[test]
1578 fn handle_key_shift_still_types_uppercase() {
1579 let mut s = state_focused_on_zai();
1580 let zi = key_index(VendorId::Zai);
1581 handle_key(&mut s, KeyCode::Char('A'), KeyModifiers::SHIFT);
1582 assert_eq!(s.keys[zi].buf, "A");
1583 assert!(s.keys[zi].dirty);
1584 }
1585
1586 #[test]
1587 fn handle_key_plain_space_still_cycles_primary_vendor() {
1588 let mut s = blank_state(VendorId::Anthropic);
1589 handle_key(&mut s, KeyCode::Char(' '), KeyModifiers::NONE);
1590 assert_eq!(s.primary, VendorId::AnthropicApi);
1591 }
1592
1593 #[test]
1594 fn handle_key_ctrl_s_attempts_save_from_any_field() {
1595 let (_dir, path) = temp_config(None);
1596 let s = state_with("zk", "ok", VendorId::Zai);
1597 save_to_path(&s, &path).unwrap();
1598 let raw = std::fs::read_to_string(&path).unwrap();
1599 assert!(raw.contains("api_key = \"zk\""));
1600 }
1601 #[test]
1602 fn save_to_path_writes_kimi_key_when_dirty() {
1603 let (_dir, path) = temp_config(None);
1604 let mut s = blank_state(VendorId::Anthropic);
1605 let kimi = key_index(VendorId::Kimi);
1606 s.keys[kimi] = KeyInput::from_config(Some("kk"));
1607 s.keys[kimi].dirty = true;
1608 save_to_path(&s, &path).unwrap();
1609 let raw = std::fs::read_to_string(&path).unwrap();
1610 assert!(raw.contains("[kimi]"));
1611 assert!(raw.contains("api_key = \"kk\""));
1612 }
1613
1614 #[test]
1615 fn settings_save_uses_the_same_config_path_as_load() {
1616 assert_eq!(
1617 default_config_path().unwrap(),
1618 crate::config::resolved_path().unwrap()
1619 );
1620 }
1621
1622 #[test]
1623 fn native_snapshot_reports_key_state_without_serializing_secrets() {
1624 let mut cfg = Config::default();
1625 cfg.zai.api_key = Some("never-leak-this-key".into());
1626 cfg.zai.api_key_env = "CUSTOM_ZAI_KEY".into();
1627 let raw = settings_snapshot_json_with(&cfg, |name| name == "CUSTOM_ZAI_KEY").unwrap();
1628 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
1629
1630 assert_eq!(parsed["schema_version"], 1);
1631 assert_eq!(parsed["primary"], "anthropic");
1632 let zai = parsed["keys"]
1633 .as_array()
1634 .unwrap()
1635 .iter()
1636 .find(|row| row["id"] == "zai")
1637 .unwrap();
1638 assert_eq!(zai["configured"], true);
1639 assert_eq!(zai["inline_configured"], true);
1640 assert_eq!(zai["environment_configured"], true);
1641 assert_eq!(zai["environment"], "CUSTOM_ZAI_KEY");
1642 assert!(!raw.contains("never-leak-this-key"));
1643 assert!(parsed.get("api_key").is_none());
1644 }
1645
1646 #[test]
1647 fn native_snapshot_offers_copilot_primary_without_a_token_field() {
1648 let cfg = Config::default();
1649 let raw = settings_snapshot_json_with(&cfg, |_| false).unwrap();
1650 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
1651 assert!(
1652 parsed["primary_choices"]
1653 .as_array()
1654 .unwrap()
1655 .iter()
1656 .any(|row| row["id"] == "copilot")
1657 );
1658 assert!(
1659 !parsed["keys"]
1660 .as_array()
1661 .unwrap()
1662 .iter()
1663 .any(|row| row["id"] == "copilot")
1664 );
1665 }
1666
1667 #[test]
1668 fn native_key_only_patch_does_not_require_or_replace_primary() {
1669 let cfg = Config::default();
1670 let original_primary = SettingsState::from_config(&cfg).primary;
1671 let request = serde_json::json!({
1672 "schema_version": 1,
1673 "keys": {"kimi": {"action": "set", "value": "new-kimi-key"}}
1674 });
1675
1676 let state = state_from_apply_request(&cfg, &request.to_string()).unwrap();
1677 assert_eq!(state.primary, original_primary);
1678 let kimi_index = KEY_VENDORS
1679 .iter()
1680 .position(|vendor| vendor.id == VendorId::Kimi)
1681 .unwrap();
1682 assert!(state.keys[kimi_index].dirty);
1683 assert_eq!(state.keys[kimi_index].buf, "new-kimi-key");
1684 }
1685
1686 #[test]
1687 fn native_patch_reuses_tui_persistence_and_preserves_existing_config() {
1688 let (_dir, path) = temp_config(Some(
1689 r#"# keep this comment
1690[ui]
1691primary = "anthropic"
1692
1693[zai]
1694enabled = true
1695api_key_env = "ZAI_API_KEY"
1696plan_tier = "pro"
1697
1698[openrouter]
1699enabled = true
1700"#,
1701 ));
1702 let cfg = Config::load_from(&path).unwrap();
1703 let request = serde_json::json!({
1704 "schema_version": 1,
1705 "primary": "openrouter",
1706 "keys": {
1707 "zai": {"action": "set", "value": "new-zai-key"}
1708 }
1709 });
1710
1711 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
1712 let raw = std::fs::read_to_string(&path).unwrap();
1713 assert!(raw.contains("# keep this comment"));
1714 assert!(raw.contains("plan_tier = \"pro\""));
1715 assert!(raw.contains("api_key_env = \"ZAI_API_KEY\""));
1716 assert!(raw.contains("primary = \"openrouter\""));
1717 assert!(raw.contains("api_key = \"new-zai-key\""));
1718 }
1719
1720 #[test]
1721 fn native_patch_distinguishes_clear_from_unchanged() {
1722 let (_dir, path) = temp_config(Some(
1723 "[zai]\nenabled = true\napi_key = \"remove-me\"\n\
1724 [openrouter]\nenabled = true\napi_key = \"keep-me\"\n",
1725 ));
1726 let cfg = Config::load_from(&path).unwrap();
1727 let request = serde_json::json!({
1728 "schema_version": 1,
1729 "primary": "zai",
1730 "keys": {"zai": {"action": "clear"}}
1731 });
1732
1733 apply_settings_json_to_path(&cfg, &request.to_string(), &path).unwrap();
1734 let raw = std::fs::read_to_string(&path).unwrap();
1735 assert!(!raw.contains("remove-me"));
1736 assert!(raw.contains("keep-me"));
1737 }
1738
1739 #[test]
1740 fn native_primary_selection_enables_copilot_without_writing_a_token() {
1741 let (_dir, path) = temp_config(Some(
1742 "[copilot]\nenabled = false\ntoken = \"legacy-value\"\ntoken_env = \"OLD_TOKEN\"\n",
1743 ));
1744 let cfg = Config::load_from(&path).unwrap();
1745 let select = serde_json::json!({
1746 "schema_version": 1,
1747 "primary": "copilot"
1748 });
1749 apply_settings_json_to_path(&cfg, &select.to_string(), &path).unwrap();
1750 let raw = std::fs::read_to_string(&path).unwrap();
1751 assert!(raw.contains("enabled = true"));
1752 assert!(raw.contains("primary = \"copilot\""));
1753 assert!(!raw.contains("token ="));
1754 assert!(!raw.contains("token_env ="));
1755 }
1756
1757 #[test]
1758 fn native_patch_errors_never_echo_key_values() {
1759 let raw = serde_json::json!({
1760 "schema_version": 1,
1761 "primary": "anthropic",
1762 "keys": {
1763 "zai": {"action": "set", "value": "secret\nwith-control"}
1764 }
1765 })
1766 .to_string();
1767 let error = state_from_apply_request(&Config::default(), &raw)
1768 .unwrap_err()
1769 .to_string();
1770 assert!(!error.contains("secret"));
1771 assert!(error.contains("control characters"));
1772 }
1773
1774 #[test]
1775 fn native_patch_input_is_bounded_before_json_parsing() {
1776 let oversized = vec![b'x'; MAX_SETTINGS_REQUEST_BYTES as usize + 1];
1777 let error = read_settings_request(std::io::Cursor::new(oversized))
1778 .unwrap_err()
1779 .to_string();
1780 assert!(error.contains("exceeds"));
1781 }
1782}