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