Skip to main content

atomcode_tuix/modals/
provider_wizard.rs

1// crates/atomcode-tuix/src/modals/provider_wizard.rs
2//
3// `/provider` modal — multi-step Q&A wizard for provider management.
4//
5// Runs entirely in scrollback (no alt-screen): each step pushes a prompt
6// line ("Provider name?"), the user types + Enter, the answer is echoed
7// back and the next step's prompt appears. Persistent menus (MainMenu,
8// EditPick, DeletePick, SetDefaultPick) reuse the `MenuPayload` footer
9// palette. Esc cancels at any point.
10
11use 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    /// Initial picker: Add / Edit / Delete / Set Default.
23    MainMenu { selected: usize },
24    /// Sequential `Add` prompts. `draft` accumulates answered fields.
25    Add {
26        step: WizardStep,
27        draft: DraftProvider,
28    },
29    /// Pick which provider to edit.
30    EditPick {
31        providers: Vec<String>,
32        selected: usize,
33    },
34    /// Editing a specific provider; same flow as `Add` but prompts show
35    /// the existing value as a hint and an empty Enter keeps it.
36    Edit {
37        target: String,
38        step: WizardStep,
39        draft: DraftProvider,
40    },
41    /// Pick which provider to delete.
42    DeletePick {
43        providers: Vec<String>,
44        selected: usize,
45    },
46    /// Final y/N confirmation before a delete actually lands.
47    DeleteConfirm { target: String },
48    /// Pick which provider to make default.
49    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    /// Merge this draft onto `base` — empty fields leave `base` untouched.
75    /// Used by Edit so an empty Enter at a prompt keeps the existing value.
76    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
141/// Process one key for the wizard. Returns `Continue` if the wizard
142/// stays active, `Close` when it's done (cancelled, committed, or
143/// transitioned to Idle after a terminal operation).
144fn 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    // Esc always cancels at any point.
154    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    // Take the current state out so we can move fields; put it back
162    // (or replace it) before returning Continue.
163    let current = std::mem::replace(wizard, ProviderWizard::MainMenu { selected: 0 });
164    match current {
165        // ── Menu states: Up / Down / Enter navigate; others ignored. ──
166        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        // ── Picker states share Up/Down/Enter logic. ──
244        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, // skip Name (immutable)
261                        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 we just dropped the default, fall back to any
349                    // remaining provider or blank.
350                    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        // ── Text-input states: Enter submits, chars edit buf, others pass through Buffer. ──
370        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                        // All fields gathered — commit and switch to it.
385                        // Users expect /provider add to behave like "create
386                        // and activate": after the wizard closes, the newly
387                        // added entry should be the current default so the
388                        // next message uses it without an extra /model step.
389                        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 other keys to the buffer so typing / editing works.
405            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                        // Commit edit: merge draft onto existing provider.
445                        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
466/// Redraw the footer with the wizard's current menu/prompt. Text-input
467/// steps show the normal input box; picker steps show an overlay menu
468/// built from wizard state.
469fn 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        // Q&A steps: plain input box, no overlay menu.
522        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
536/// Push a prompt line into scrollback. Steps share the same "tool-line"
537/// styling — a muted line with two-space indent — so the Q&A reads like
538/// the rest of the conversation rather than a modal popup.
539fn push(renderer: &mut dyn Renderer, text: &str) {
540    renderer.render(UiLine::CommandOutput(format!("  {}\n", text)));
541    renderer.flush();
542}
543
544/// Prompt string for the given wizard step; includes the existing value
545/// as a hint in Edit mode so the user sees what empty-Enter will keep.
546fn 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
575/// Push the prompt for this step into scrollback + redraw footer.
576fn 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
589/// Validate and advance the "Add" sub-flow. Returns the next state, or
590/// None when the wizard has committed / cancelled (caller clears).
591fn 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 // signal: ready to commit
630        }
631    }
632}
633
634/// Validate and advance the "Edit" sub-flow. Empty answers preserve
635/// the existing value, so the caller needs `existing` to know what
636/// that value is.
637fn 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            // Name isn't editable (it's the key into the provider map).
647            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
675/// Route a keystroke into `Buffer::apply` so text-input wizard steps
676/// support the usual editing shortcuts (Backspace / Left / Right / etc).
677fn 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}