1use anyhow::Result;
12use atomcode_core::config::provider::ProviderConfig;
13use crossterm::event::{KeyCode, KeyModifiers};
14
15use super::{Modal, ModalAction};
16use crate::event_loop::{build_status, save_and_reload, Buffer, LoopCtx};
17use crate::input::key_action::classify;
18use crate::render::{MenuPayload, Renderer, UiLine};
19use crate::state::UiState;
20
21pub enum ProviderWizard {
22 MainMenu { selected: usize },
24 Add {
26 step: WizardStep,
27 draft: DraftProvider,
28 },
29 EditPick {
31 providers: Vec<String>,
32 selected: usize,
33 },
34 Edit {
37 target: String,
38 step: WizardStep,
39 draft: DraftProvider,
40 },
41 DeletePick {
43 providers: Vec<String>,
44 selected: usize,
45 },
46 DeleteConfirm { target: String },
48 SetDefaultPick {
50 providers: Vec<String>,
51 selected: usize,
52 },
53}
54
55#[derive(Clone, Copy, Debug)]
56pub enum WizardStep {
57 Name,
58 ProviderType,
59 BaseUrl,
60 ApiKey,
61 Model,
62}
63
64#[derive(Clone, Debug, Default)]
65pub struct DraftProvider {
66 pub name: String,
67 pub provider_type: String,
68 pub base_url: String,
69 pub api_key: String,
70 pub model: String,
71}
72
73impl DraftProvider {
74 fn apply_onto(&self, base: &mut ProviderConfig) {
77 if !self.provider_type.is_empty() {
78 base.provider_type = self.provider_type.clone();
79 }
80 if !self.base_url.is_empty() {
81 base.base_url = Some(self.base_url.clone());
82 }
83 if !self.api_key.is_empty() {
84 base.api_key = Some(self.api_key.clone());
85 }
86 if !self.model.is_empty() {
87 base.model = self.model.clone();
88 }
89 }
90
91 fn into_config(self) -> ProviderConfig {
92 use atomcode_core::config::provider::default_context_window_for;
93 let provider_type = self.provider_type.clone();
94 ProviderConfig {
95 provider_type: provider_type.clone(),
96 api_key: if self.api_key.is_empty() {
97 None
98 } else {
99 Some(self.api_key)
100 },
101 model: self.model,
102 base_url: if self.base_url.is_empty() {
103 None
104 } else {
105 Some(self.base_url)
106 },
107 system_prompt: None,
108 user_agent: None,
109 context_window: default_context_window_for(&provider_type),
110 max_tokens: None,
111 thinking_type: None,
112 thinking_keep: None,
113 reasoning_history: None,
114 thinking_enabled: None,
115 thinking_budget: None,
116 skip_tls_verify: false,
117 ephemeral: false,
118
119}
120 }
121}
122
123impl Modal for ProviderWizard {
124 fn handle_key(
125 &mut self,
126 code: KeyCode,
127 mods: KeyModifiers,
128 buf: &mut Buffer,
129 state: &mut UiState,
130 ctx: &mut LoopCtx,
131 renderer: &mut dyn Renderer,
132 ) -> Result<ModalAction> {
133 handle_key(code, mods, buf, state, ctx, renderer, self)
134 }
135
136 fn draw(&self, buf: &Buffer, state: &UiState, ctx: &LoopCtx, renderer: &mut dyn Renderer) {
137 redraw(buf, state, ctx, self, renderer);
138 }
139}
140
141fn handle_key(
145 code: KeyCode,
146 _mods: KeyModifiers,
147 buf: &mut Buffer,
148 state: &mut UiState,
149 ctx: &mut LoopCtx,
150 renderer: &mut dyn Renderer,
151 wizard: &mut ProviderWizard,
152) -> Result<ModalAction> {
153 if matches!(code, KeyCode::Esc) {
155 buf.text.clear();
156 buf.cursor = 0;
157 push(renderer, &crate::i18n::t(crate::i18n::Msg::ProviderWizardCancelled));
158 return Ok(ModalAction::Close);
159 }
160
161 let current = std::mem::replace(wizard, ProviderWizard::MainMenu { selected: 0 });
164 match current {
165 ProviderWizard::MainMenu { mut selected } => {
167 const ITEMS: [&str; 4] = ["add", "edit", "delete", "set-default"];
168 match code {
169 KeyCode::Up => {
170 selected = selected.saturating_sub(1);
171 *wizard = ProviderWizard::MainMenu { selected };
172 }
173 KeyCode::Down => {
174 if selected + 1 < ITEMS.len() {
175 selected += 1;
176 }
177 *wizard = ProviderWizard::MainMenu { selected };
178 }
179 KeyCode::Enter => {
180 let providers: Vec<String> = {
181 let mut v: Vec<String> = ctx.config.providers.keys().cloned().collect();
182 v.sort();
183 v
184 };
185 match ITEMS[selected] {
186 "add" => {
187 let new = ProviderWizard::Add {
188 step: WizardStep::Name,
189 draft: DraftProvider::default(),
190 };
191 show_step_prompt(
192 WizardStep::Name,
193 None,
194 buf,
195 state,
196 ctx,
197 &new,
198 renderer,
199 );
200 *wizard = new;
201 }
202 "edit" | "delete" | "set-default" if providers.is_empty() => {
203 push(renderer, &crate::i18n::t(crate::i18n::Msg::ProviderNoProviders));
204 return Ok(ModalAction::Close);
205 }
206 "edit" => {
207 let new = ProviderWizard::EditPick {
208 providers,
209 selected: 0,
210 };
211 redraw(buf, state, ctx, &new, renderer);
212 *wizard = new;
213 }
214 "delete" => {
215 let new = ProviderWizard::DeletePick {
216 providers,
217 selected: 0,
218 };
219 redraw(buf, state, ctx, &new, renderer);
220 *wizard = new;
221 }
222 "set-default" => {
223 let new = ProviderWizard::SetDefaultPick {
224 providers,
225 selected: 0,
226 };
227 redraw(buf, state, ctx, &new, renderer);
228 *wizard = new;
229 }
230 _ => {
231 *wizard = ProviderWizard::MainMenu { selected };
232 }
233 }
234 }
235 _ => {
236 *wizard = ProviderWizard::MainMenu { selected };
237 }
238 }
239 redraw(buf, state, ctx, wizard, renderer);
240 Ok(ModalAction::Continue)
241 }
242
243 ProviderWizard::EditPick {
245 providers,
246 mut selected,
247 } => {
248 match code {
249 KeyCode::Up => selected = selected.saturating_sub(1),
250 KeyCode::Down => {
251 if selected + 1 < providers.len() {
252 selected += 1;
253 }
254 }
255 KeyCode::Enter => {
256 let target = providers[selected].clone();
257 let existing = ctx.config.providers.get(&target).cloned();
258 let new = ProviderWizard::Edit {
259 target: target.clone(),
260 step: WizardStep::ProviderType, draft: DraftProvider::default(),
262 };
263 show_step_prompt(
264 WizardStep::ProviderType,
265 existing.as_ref(),
266 buf,
267 state,
268 ctx,
269 &new,
270 renderer,
271 );
272 *wizard = new;
273 return Ok(ModalAction::Continue);
274 }
275 _ => {}
276 }
277 *wizard = ProviderWizard::EditPick {
278 providers,
279 selected,
280 };
281 redraw(buf, state, ctx, wizard, renderer);
282 Ok(ModalAction::Continue)
283 }
284
285 ProviderWizard::DeletePick {
286 providers,
287 mut selected,
288 } => {
289 match code {
290 KeyCode::Up => selected = selected.saturating_sub(1),
291 KeyCode::Down => {
292 if selected + 1 < providers.len() {
293 selected += 1;
294 }
295 }
296 KeyCode::Enter => {
297 let target = providers[selected].clone();
298 push(renderer, &crate::i18n::t(crate::i18n::Msg::ProviderDeleteConfirm { name: &target }));
299 *wizard = ProviderWizard::DeleteConfirm { target };
300 redraw(buf, state, ctx, wizard, renderer);
301 return Ok(ModalAction::Continue);
302 }
303 _ => {}
304 }
305 *wizard = ProviderWizard::DeletePick {
306 providers,
307 selected,
308 };
309 redraw(buf, state, ctx, wizard, renderer);
310 Ok(ModalAction::Continue)
311 }
312
313 ProviderWizard::SetDefaultPick {
314 providers,
315 mut selected,
316 } => {
317 match code {
318 KeyCode::Up => selected = selected.saturating_sub(1),
319 KeyCode::Down => {
320 if selected + 1 < providers.len() {
321 selected += 1;
322 }
323 }
324 KeyCode::Enter => {
325 let chosen = providers[selected].clone();
326 ctx.config.default_provider = chosen.clone();
327 if let Some(p) = ctx.config.providers.get(&chosen) {
328 ctx.model_name = p.model.clone();
329 }
330 save_and_reload(ctx, renderer);
331 push(renderer, &crate::i18n::t(crate::i18n::Msg::ProviderDefaultSet { name: &chosen }));
332 return Ok(ModalAction::Close);
333 }
334 _ => {}
335 }
336 *wizard = ProviderWizard::SetDefaultPick {
337 providers,
338 selected,
339 };
340 redraw(buf, state, ctx, wizard, renderer);
341 Ok(ModalAction::Continue)
342 }
343
344 ProviderWizard::DeleteConfirm { target } => {
345 match code {
346 KeyCode::Char('y') | KeyCode::Char('Y') => {
347 ctx.config.providers.remove(&target);
348 if ctx.config.default_provider == target {
351 ctx.config.default_provider = ctx
352 .config
353 .providers
354 .keys()
355 .next()
356 .cloned()
357 .unwrap_or_default();
358 }
359 save_and_reload(ctx, renderer);
360 push(renderer, &crate::i18n::t(crate::i18n::Msg::ProviderDeleted { name: &target }));
361 }
362 _ => {
363 push(renderer, &crate::i18n::t(crate::i18n::Msg::ProviderDeleteKept));
364 }
365 }
366 Ok(ModalAction::Close)
367 }
368
369 ProviderWizard::Add { step, mut draft } => {
371 if matches!(code, KeyCode::Enter) {
372 let answer = buf.text.clone();
373 push(renderer, &format!(" ↳ {}", answer));
374 buf.text.clear();
375 buf.cursor = 0;
376 match advance_add(&mut draft, step, &answer, renderer) {
377 Some(next) => {
378 let new = ProviderWizard::Add { step: next, draft };
379 show_step_prompt(next, None, buf, state, ctx, &new, renderer);
380 *wizard = new;
381 return Ok(ModalAction::Continue);
382 }
383 None => {
384 let name = draft.name.clone();
390 let model = draft.model.clone();
391 let cfg = draft.into_config();
392 ctx.config.providers.insert(name.clone(), cfg);
393 ctx.config.default_provider = name.clone();
394 ctx.model_name = model.clone();
395 save_and_reload(ctx, renderer);
396 push(
397 renderer,
398 &crate::i18n::t(crate::i18n::Msg::ProviderAdded { name: &name, model: &model }),
399 );
400 return Ok(ModalAction::Close);
401 }
402 }
403 }
404 forward_to_buffer(code, _mods, buf, state, ctx);
406 *wizard = ProviderWizard::Add { step, draft };
407 redraw(buf, state, ctx, wizard, renderer);
408 Ok(ModalAction::Continue)
409 }
410
411 ProviderWizard::Edit {
412 target,
413 step,
414 mut draft,
415 } => {
416 if matches!(code, KeyCode::Enter) {
417 let answer = buf.text.clone();
418 push(
419 renderer,
420 &format!(
421 " ↳ {}",
422 if answer.is_empty() {
423 crate::i18n::t(crate::i18n::Msg::ProviderEditKeep).into_owned()
424 } else {
425 answer.clone()
426 }
427 ),
428 );
429 buf.text.clear();
430 buf.cursor = 0;
431 match advance_edit(&mut draft, step, &answer, renderer) {
432 Some(next) => {
433 let existing = ctx.config.providers.get(&target).cloned();
434 let new = ProviderWizard::Edit {
435 target: target.clone(),
436 step: next,
437 draft,
438 };
439 show_step_prompt(next, existing.as_ref(), buf, state, ctx, &new, renderer);
440 *wizard = new;
441 return Ok(ModalAction::Continue);
442 }
443 None => {
444 if let Some(existing) = ctx.config.providers.get_mut(&target) {
446 draft.apply_onto(existing);
447 }
448 save_and_reload(ctx, renderer);
449 push(renderer, &crate::i18n::t(crate::i18n::Msg::ProviderUpdated { name: &target }));
450 return Ok(ModalAction::Close);
451 }
452 }
453 }
454 forward_to_buffer(code, _mods, buf, state, ctx);
455 *wizard = ProviderWizard::Edit {
456 target,
457 step,
458 draft,
459 };
460 redraw(buf, state, ctx, wizard, renderer);
461 Ok(ModalAction::Continue)
462 }
463 }
464}
465
466fn redraw(
470 buf: &Buffer,
471 state: &UiState,
472 ctx: &LoopCtx,
473 wizard: &ProviderWizard,
474 renderer: &mut dyn Renderer,
475) {
476 let menu = match wizard {
477 ProviderWizard::MainMenu { selected } => Some(MenuPayload {
478 items: vec![
479 (crate::i18n::t(crate::i18n::Msg::ProviderMenuAdd).into_owned(),
480 crate::i18n::t(crate::i18n::Msg::ProviderMenuAddDesc).into_owned()),
481 (crate::i18n::t(crate::i18n::Msg::ProviderMenuEdit).into_owned(),
482 crate::i18n::t(crate::i18n::Msg::ProviderMenuEditDesc).into_owned()),
483 (crate::i18n::t(crate::i18n::Msg::ProviderMenuDelete).into_owned(),
484 crate::i18n::t(crate::i18n::Msg::ProviderMenuDeleteDesc).into_owned()),
485 (crate::i18n::t(crate::i18n::Msg::ProviderMenuSetDefault).into_owned(),
486 crate::i18n::t(crate::i18n::Msg::ProviderMenuSetDefaultDesc).into_owned()),
487 ],
488 selected: *selected,
489 kind: crate::render::MenuKind::SlashCommand,
490 }),
491 ProviderWizard::EditPick {
492 providers,
493 selected,
494 }
495 | ProviderWizard::DeletePick {
496 providers,
497 selected,
498 }
499 | ProviderWizard::SetDefaultPick {
500 providers,
501 selected,
502 } => {
503 let items: Vec<(String, String)> = providers
504 .iter()
505 .map(|name| {
506 let desc = ctx
507 .config
508 .providers
509 .get(name)
510 .map(|c| format!("{} · {}", c.provider_type, c.model))
511 .unwrap_or_default();
512 (name.clone(), desc)
513 })
514 .collect();
515 Some(MenuPayload {
516 items,
517 selected: *selected,
518 kind: crate::render::MenuKind::SlashCommand,
519 })
520 }
521 ProviderWizard::Add { .. }
523 | ProviderWizard::Edit { .. }
524 | ProviderWizard::DeleteConfirm { .. } => None,
525 };
526 renderer.render(UiLine::InputPrompt {
527 buf: buf.text.clone(),
528 cursor_byte: buf.cursor,
529 menu,
530 status: build_status(state, ctx),
531 attachments: Vec::new(),
532 });
533 renderer.flush();
534}
535
536fn push(renderer: &mut dyn Renderer, text: &str) {
540 renderer.render(UiLine::CommandOutput(format!(" {}\n", text)));
541 renderer.flush();
542}
543
544fn step_prompt_text(step: WizardStep, existing: Option<&ProviderConfig>) -> String {
547 use crate::i18n::{t, Msg};
548 match (step, existing) {
549 (WizardStep::Name, _) => t(Msg::ProviderStepName).into_owned(),
550 (WizardStep::ProviderType, None) => t(Msg::ProviderStepType).into_owned(),
551 (WizardStep::ProviderType, Some(p)) => {
552 t(Msg::ProviderStepTypeWithHint { current: &p.provider_type }).into_owned()
553 }
554 (WizardStep::BaseUrl, None) => t(Msg::ProviderStepBaseUrl).into_owned(),
555 (WizardStep::BaseUrl, Some(p)) => {
556 let default_hint = t(Msg::ProviderDefaultHint);
557 let hint = p.base_url.as_deref().unwrap_or(&default_hint);
558 t(Msg::ProviderStepBaseUrlWithHint { current: hint }).into_owned()
559 }
560 (WizardStep::ApiKey, None) => t(Msg::ProviderStepApiKey).into_owned(),
561 (WizardStep::ApiKey, Some(p)) => {
562 let hint = if p.api_key.is_some() {
563 t(Msg::ProviderStepApiKeySet)
564 } else {
565 t(Msg::ProviderStepApiKeyUnset)
566 };
567 t(Msg::ProviderStepApiKeyWithHint { hint: &hint }).into_owned()
568 }
569 (WizardStep::Model, None) => t(Msg::ProviderStepModel).into_owned(),
570 (WizardStep::Model, Some(p)) =>
571 t(Msg::ProviderStepModelWithHint { current: &p.model }).into_owned(),
572 }
573}
574
575fn show_step_prompt(
577 step: WizardStep,
578 existing: Option<&ProviderConfig>,
579 buf: &Buffer,
580 state: &UiState,
581 ctx: &LoopCtx,
582 wizard: &ProviderWizard,
583 renderer: &mut dyn Renderer,
584) {
585 push(renderer, &step_prompt_text(step, existing));
586 redraw(buf, state, ctx, wizard, renderer);
587}
588
589fn advance_add(
592 draft: &mut DraftProvider,
593 step: WizardStep,
594 answer: &str,
595 renderer: &mut dyn Renderer,
596) -> Option<WizardStep> {
597 let ans = answer.trim();
598 match step {
599 WizardStep::Name => {
600 if ans.is_empty() {
601 push(renderer, &crate::i18n::t(crate::i18n::Msg::ProviderNameEmpty));
602 return Some(WizardStep::Name);
603 }
604 draft.name = ans.to_string();
605 Some(WizardStep::ProviderType)
606 }
607 WizardStep::ProviderType => {
608 if !["openai", "claude", "ollama"].contains(&ans) {
609 push(renderer, &crate::i18n::t(crate::i18n::Msg::ProviderUnknownType));
610 return Some(WizardStep::ProviderType);
611 }
612 draft.provider_type = ans.to_string();
613 Some(WizardStep::BaseUrl)
614 }
615 WizardStep::BaseUrl => {
616 draft.base_url = ans.to_string();
617 Some(WizardStep::ApiKey)
618 }
619 WizardStep::ApiKey => {
620 draft.api_key = ans.to_string();
621 Some(WizardStep::Model)
622 }
623 WizardStep::Model => {
624 if ans.is_empty() {
625 push(renderer, &crate::i18n::t(crate::i18n::Msg::ProviderModelEmpty));
626 return Some(WizardStep::Model);
627 }
628 draft.model = ans.to_string();
629 None }
631 }
632}
633
634fn advance_edit(
638 draft: &mut DraftProvider,
639 step: WizardStep,
640 answer: &str,
641 renderer: &mut dyn Renderer,
642) -> Option<WizardStep> {
643 let ans = answer.trim();
644 match step {
645 WizardStep::Name => {
646 Some(WizardStep::ProviderType)
648 }
649 WizardStep::ProviderType => {
650 if !ans.is_empty() && !["openai", "claude", "ollama"].contains(&ans) {
651 push(
652 renderer,
653 &crate::i18n::t(crate::i18n::Msg::ProviderUnknownTypeEdit),
654 );
655 return Some(WizardStep::ProviderType);
656 }
657 draft.provider_type = ans.to_string();
658 Some(WizardStep::BaseUrl)
659 }
660 WizardStep::BaseUrl => {
661 draft.base_url = ans.to_string();
662 Some(WizardStep::ApiKey)
663 }
664 WizardStep::ApiKey => {
665 draft.api_key = ans.to_string();
666 Some(WizardStep::Model)
667 }
668 WizardStep::Model => {
669 draft.model = ans.to_string();
670 None
671 }
672 }
673}
674
675fn forward_to_buffer(code: KeyCode, modifiers: KeyModifiers, buf: &mut Buffer, state: &mut UiState, ctx: &LoopCtx) {
678 let action = classify(code, modifiers);
679 let _ = buf.apply(action, ctx.history.entries(), &ctx.commands);
680 crate::event_loop::sync_recalled_attachments(state, buf, ctx.history.entries());
681}