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