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