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