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