Skip to main content

imp_tui/views/
welcome.rs

1use imp_llm::auth::AuthStore;
2use imp_llm::model::{ModelMeta, ProviderMeta, ProviderRegistry};
3use imp_llm::ThinkingLevel;
4use ratatui::buffer::Buffer;
5use ratatui::layout::Rect;
6use ratatui::style::{Modifier, Style};
7use ratatui::text::{Line, Span};
8use ratatui::widgets::{Block, Borders, Clear, Widget};
9
10use crate::theme::Theme;
11
12/// Which step of the welcome flow the user is on.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum WelcomeStep {
15    /// Splash / introduction.
16    Welcome,
17    /// Choose provider and enter API key.
18    ProviderAuth,
19    /// Pick default model and thinking level.
20    ModelThinking,
21    /// Optional web search provider setup.
22    WebSearch,
23    /// Summary and quick tips.
24    Done,
25}
26
27const STEPS: &[WelcomeStep] = &[
28    WelcomeStep::Welcome,
29    WelcomeStep::ProviderAuth,
30    WelcomeStep::ModelThinking,
31    WelcomeStep::WebSearch,
32    WelcomeStep::Done,
33];
34
35/// Detected state for each provider — whether an env var or stored credential exists.
36#[derive(Debug, Clone)]
37pub struct ProviderStatus {
38    pub meta: ProviderMeta,
39    pub env_detected: bool,
40    pub stored: bool,
41}
42
43impl ProviderStatus {
44    pub fn has_auth(&self) -> bool {
45        self.env_detected || self.stored
46    }
47}
48
49#[derive(Debug, Clone)]
50pub struct WebProviderStatus {
51    pub id: &'static str,
52    pub label: &'static str,
53    pub env_key: &'static str,
54    pub docs_url: &'static str,
55    pub env_detected: bool,
56    pub stored: bool,
57}
58
59impl WebProviderStatus {
60    pub fn has_auth(&self) -> bool {
61        self.id == "none" || self.env_detected || self.stored
62    }
63}
64
65/// State for the welcome overlay.
66#[derive(Debug, Clone)]
67pub struct WelcomeState {
68    pub step: usize,
69    /// Provider list with detection status.
70    pub providers: Vec<ProviderStatus>,
71    /// Currently selected provider index.
72    pub provider_selected: usize,
73    /// API key input buffer (masked display).
74    pub key_input: String,
75    /// Whether the key input field is active.
76    pub key_editing: bool,
77    /// Error message for invalid key input.
78    pub key_error: Option<String>,
79    /// Available models for the selected provider.
80    pub models: Vec<ModelMeta>,
81    /// Selected model index.
82    pub model_selected: usize,
83    /// Selected thinking level.
84    pub thinking_level: ThinkingLevel,
85    /// Whether auth was resolved (env or input).
86    pub auth_resolved: bool,
87    /// The resolved API key (if entered manually).
88    pub resolved_key: Option<String>,
89    /// Optional web search providers for the built-in `web` tool.
90    pub web_providers: Vec<WebProviderStatus>,
91    /// Selected web provider index.
92    pub web_provider_selected: usize,
93    /// Optional web provider key input.
94    pub web_key_input: String,
95    /// Resolved web provider id.
96    pub resolved_web_provider: Option<String>,
97    /// Resolved web provider key (if entered manually).
98    pub resolved_web_key: Option<String>,
99}
100
101impl WelcomeState {
102    fn normalized_step(&self) -> usize {
103        self.step.min(STEPS.len().saturating_sub(1))
104    }
105
106    fn normalized_provider_selected(&self) -> usize {
107        if self.providers.is_empty() {
108            0
109        } else {
110            self.provider_selected.min(self.providers.len() - 1)
111        }
112    }
113
114    fn normalized_model_selected(&self) -> usize {
115        if self.models.is_empty() {
116            0
117        } else {
118            self.model_selected.min(self.models.len() - 1)
119        }
120    }
121
122    fn normalized_web_provider_selected(&self) -> usize {
123        if self.web_providers.is_empty() {
124            0
125        } else {
126            self.web_provider_selected.min(self.web_providers.len() - 1)
127        }
128    }
129
130    /// Create welcome state, detecting existing auth from env vars for all registered providers.
131    pub fn new(all_models: &[ModelMeta]) -> Self {
132        let registry = ProviderRegistry::with_builtins();
133        let auth_path = std::env::var("XDG_CONFIG_HOME")
134            .map(std::path::PathBuf::from)
135            .or_else(|_| std::env::var("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
136            .unwrap_or_else(|_| std::path::PathBuf::from(".config"))
137            .join("imp")
138            .join("auth.json");
139        let auth_store = AuthStore::load(&auth_path).unwrap_or_else(|_| AuthStore::new(auth_path));
140        let providers: Vec<ProviderStatus> = registry
141            .list()
142            .iter()
143            .filter(|meta| is_setup_visible_provider(meta.id))
144            .map(|meta| {
145                let env_detected = meta.env_vars.iter().any(|v| std::env::var(v).is_ok());
146                ProviderStatus {
147                    meta: meta.clone(),
148                    env_detected,
149                    stored: provider_stored_for_setup(&auth_store, meta.id),
150                }
151            })
152            .collect();
153
154        // Pre-select the first provider with auth, or the first provider (Anthropic) by default.
155        let provider_selected = providers.iter().position(|p| p.has_auth()).unwrap_or(0);
156
157        let selected_id = providers
158            .get(provider_selected)
159            .map(|provider| provider.meta.id)
160            .unwrap_or("anthropic");
161        let models = filter_models_for_provider(all_models, selected_id);
162
163        let web_providers = vec![
164            WebProviderStatus {
165                id: "none",
166                label: "Skip for now",
167                env_key: "",
168                docs_url: "",
169                env_detected: false,
170                stored: false,
171            },
172            WebProviderStatus {
173                id: "tavily",
174                label: "Tavily",
175                env_key: "TAVILY_API_KEY",
176                docs_url: "https://app.tavily.com/home",
177                env_detected: std::env::var("TAVILY_API_KEY").is_ok(),
178                stored: auth_store.stored.contains_key("tavily"),
179            },
180            WebProviderStatus {
181                id: "exa",
182                label: "Exa",
183                env_key: "EXA_API_KEY",
184                docs_url: "https://dashboard.exa.ai/api-keys",
185                env_detected: std::env::var("EXA_API_KEY").is_ok(),
186                stored: auth_store.stored.contains_key("exa"),
187            },
188        ];
189        let web_provider_selected = web_providers.iter().position(|p| p.has_auth()).unwrap_or(0);
190
191        Self {
192            step: 0,
193            providers,
194            provider_selected,
195            key_input: String::new(),
196            key_editing: false,
197            key_error: None,
198            models,
199            model_selected: 0,
200            thinking_level: ThinkingLevel::Medium,
201            auth_resolved: false,
202            resolved_key: None,
203            web_providers,
204            web_provider_selected,
205            web_key_input: String::new(),
206            resolved_web_provider: None,
207            resolved_web_key: None,
208        }
209    }
210
211    /// Mark a provider as having a stored credential.
212    pub fn mark_stored(&mut self, provider_id: &str) {
213        for p in &mut self.providers {
214            if p.meta.id == provider_id {
215                p.stored = true;
216            }
217        }
218    }
219
220    pub fn current_step(&self) -> WelcomeStep {
221        STEPS[self.normalized_step()]
222    }
223
224    pub fn selected_provider(&self) -> Option<&ProviderStatus> {
225        self.providers.get(self.normalized_provider_selected())
226    }
227
228    /// Return the selected provider's id string.
229    pub fn selected_provider_id(&self) -> Option<&str> {
230        self.selected_provider().map(|provider| provider.meta.id)
231    }
232
233    pub fn selected_model(&self) -> Option<&ModelMeta> {
234        self.models.get(self.normalized_model_selected())
235    }
236
237    pub fn advance(&mut self) {
238        if self.step + 1 < STEPS.len() {
239            self.step += 1;
240        }
241    }
242
243    pub fn go_back(&mut self) {
244        if self.step > 0 {
245            self.step -= 1;
246        }
247    }
248
249    pub fn provider_up(&mut self) {
250        if self.provider_selected > 0 {
251            self.provider_selected -= 1;
252            self.on_provider_changed();
253        }
254    }
255
256    pub fn provider_down(&mut self) {
257        if self.provider_selected + 1 < self.providers.len() {
258            self.provider_selected += 1;
259            self.on_provider_changed();
260        }
261    }
262
263    pub fn model_up(&mut self) {
264        if self.model_selected > 0 {
265            self.model_selected -= 1;
266        }
267    }
268
269    pub fn model_down(&mut self) {
270        if self.model_selected + 1 < self.models.len() {
271            self.model_selected += 1;
272        }
273    }
274
275    pub fn cycle_thinking(&mut self) {
276        self.thinking_level = match self.thinking_level {
277            ThinkingLevel::Off => ThinkingLevel::Low,
278            ThinkingLevel::Minimal => ThinkingLevel::Low,
279            ThinkingLevel::Low => ThinkingLevel::Medium,
280            ThinkingLevel::Medium => ThinkingLevel::High,
281            ThinkingLevel::High => ThinkingLevel::XHigh,
282            ThinkingLevel::XHigh => ThinkingLevel::Off,
283        };
284    }
285
286    pub fn cycle_thinking_back(&mut self) {
287        self.thinking_level = match self.thinking_level {
288            ThinkingLevel::Off => ThinkingLevel::XHigh,
289            ThinkingLevel::Minimal => ThinkingLevel::Off,
290            ThinkingLevel::Low => ThinkingLevel::Off,
291            ThinkingLevel::Medium => ThinkingLevel::Low,
292            ThinkingLevel::High => ThinkingLevel::Medium,
293            ThinkingLevel::XHigh => ThinkingLevel::High,
294        };
295    }
296
297    pub fn push_key_char(&mut self, c: char) {
298        self.key_input.push(c);
299    }
300
301    pub fn pop_key_char(&mut self) {
302        self.key_input.pop();
303    }
304
305    /// Check whether auth is available for the current provider (env or entered key).
306    pub fn check_auth_resolved(&mut self) -> Result<(), String> {
307        let Some(status) = self.selected_provider() else {
308            return Err("No providers available.".into());
309        };
310        if status.has_auth() {
311            self.auth_resolved = true;
312            self.resolved_key = None;
313            return Ok(());
314        }
315        if !self.key_input.trim().is_empty() {
316            self.auth_resolved = true;
317            self.resolved_key = Some(self.key_input.trim().to_string());
318            return Ok(());
319        }
320        Err("Please enter an API key or set the environment variable.".into())
321    }
322
323    pub fn update_models(&mut self, all_models: &[ModelMeta]) {
324        let Some(id) = self.selected_provider_id().map(str::to_string) else {
325            self.models.clear();
326            self.model_selected = 0;
327            return;
328        };
329        self.models = filter_models_for_provider(all_models, &id);
330        self.model_selected = 0;
331    }
332
333    pub fn selected_web_provider(&self) -> Option<&WebProviderStatus> {
334        self.web_providers
335            .get(self.normalized_web_provider_selected())
336    }
337
338    pub fn web_provider_up(&mut self) {
339        if self.web_provider_selected > 0 {
340            self.web_provider_selected -= 1;
341            self.on_web_provider_changed();
342        }
343    }
344
345    pub fn web_provider_down(&mut self) {
346        if self.web_provider_selected + 1 < self.web_providers.len() {
347            self.web_provider_selected += 1;
348            self.on_web_provider_changed();
349        }
350    }
351
352    pub fn push_web_key_char(&mut self, c: char) {
353        self.web_key_input.push(c);
354    }
355
356    pub fn pop_web_key_char(&mut self) {
357        self.web_key_input.pop();
358    }
359
360    pub fn check_web_auth_resolved(&mut self) -> Result<(), String> {
361        let (provider_id, has_auth) = {
362            let Some(status) = self.selected_web_provider() else {
363                return Err("No web search providers available.".into());
364            };
365            (status.id.to_string(), status.has_auth())
366        };
367        self.resolved_web_provider = Some(provider_id.clone());
368        if provider_id == "none" {
369            self.resolved_web_key = None;
370            return Ok(());
371        }
372        if has_auth {
373            self.resolved_web_key = None;
374            return Ok(());
375        }
376        if !self.web_key_input.trim().is_empty() {
377            self.resolved_web_key = Some(self.web_key_input.trim().to_string());
378            return Ok(());
379        }
380        Err("Enter a web search API key or choose Skip for now.".into())
381    }
382
383    fn on_provider_changed(&mut self) {
384        self.key_input.clear();
385        self.key_editing = false;
386        self.auth_resolved = false;
387        self.resolved_key = None;
388    }
389
390    fn on_web_provider_changed(&mut self) {
391        self.web_key_input.clear();
392        self.resolved_web_key = None;
393        self.resolved_web_provider = None;
394    }
395}
396
397fn is_setup_visible_provider(provider_id: &str) -> bool {
398    provider_id != "kimi-code"
399}
400
401fn provider_stored_for_setup(auth_store: &AuthStore, provider_id: &str) -> bool {
402    auth_store.stored.contains_key(provider_id)
403        || (provider_id == "moonshot" && auth_store.stored.contains_key("kimi-code"))
404}
405
406fn filter_models_for_provider(all_models: &[ModelMeta], provider_id: &str) -> Vec<ModelMeta> {
407    let mut models: Vec<ModelMeta> = all_models
408        .iter()
409        .filter(|m| m.provider == provider_id)
410        .cloned()
411        .collect();
412
413    match provider_id {
414        "openai" => append_missing_openai_setup_models(&mut models),
415        "openai-codex" if models.is_empty() => {
416            models = imp_llm::model::builtin_openai_codex_models();
417        }
418        _ => {}
419    }
420
421    models
422}
423
424fn append_missing_openai_setup_models(models: &mut Vec<ModelMeta>) {
425    for mut model in imp_llm::model::builtin_openai_codex_models() {
426        if models.iter().any(|existing| existing.id == model.id) {
427            continue;
428        }
429        model.provider = "openai".into();
430        models.push(model);
431    }
432}
433
434/// Detect whether this is a first run that needs the welcome flow.
435///
436/// Returns true when there is no user config AND no working auth for any
437/// supported provider.
438pub fn needs_welcome(config_dir: &std::path::Path, auth_path: &std::path::Path) -> bool {
439    let config_exists = config_dir.join("config.toml").exists();
440    if config_exists {
441        return false;
442    }
443
444    // Check if any registered provider has auth via env var.
445    let registry = ProviderRegistry::with_builtins();
446    let has_env = registry
447        .list()
448        .iter()
449        .any(|meta| meta.env_vars.iter().any(|v| std::env::var(v).is_ok()));
450
451    let has_stored = auth_path.exists()
452        && std::fs::read_to_string(auth_path)
453            .map(|s| s.trim().len() > 2) // not empty JSON "{}"
454            .unwrap_or(false);
455
456    !has_env && !has_stored
457}
458
459// ── View widget ─────────────────────────────────────────────────
460
461/// Welcome overlay widget.
462pub struct WelcomeView<'a> {
463    state: &'a WelcomeState,
464    theme: &'a Theme,
465}
466
467impl<'a> WelcomeView<'a> {
468    pub fn new(state: &'a WelcomeState, theme: &'a Theme) -> Self {
469        Self { state, theme }
470    }
471}
472
473impl Widget for WelcomeView<'_> {
474    fn render(self, area: Rect, buf: &mut Buffer) {
475        if area.height < 10 || area.width < 30 {
476            return;
477        }
478
479        Clear.render(area, buf);
480
481        let step_indicator = format!(
482            " Welcome ({}/{}) ",
483            self.state.normalized_step() + 1,
484            STEPS.len()
485        );
486        let block = Block::default()
487            .title(step_indicator)
488            .borders(Borders::ALL)
489            .border_style(self.theme.accent_style());
490        let inner = block.inner(area);
491        block.render(area, buf);
492
493        match self.state.current_step() {
494            WelcomeStep::Welcome => self.render_welcome(inner, buf),
495            WelcomeStep::ProviderAuth => self.render_provider_auth(inner, buf),
496            WelcomeStep::ModelThinking => self.render_model_thinking(inner, buf),
497            WelcomeStep::WebSearch => self.render_web_search(inner, buf),
498            WelcomeStep::Done => self.render_done(inner, buf),
499        }
500    }
501}
502
503impl WelcomeView<'_> {
504    fn render_welcome(&self, area: Rect, buf: &mut Buffer) {
505        let mut row: u16 = 0;
506        let center_x = area.x;
507
508        let logo = [
509            "  ╔╗    ╔╗  ",
510            "  ║╚════╝║  ",
511            "  ║ ■  ■ ║  ",
512            "╔═╩══════╩═╗",
513            "║    imp    ║",
514            "╚══════════╝",
515        ];
516
517        for line in &logo {
518            if row >= area.height {
519                return;
520            }
521            let offset = area.width.saturating_sub(line.len() as u16) / 2;
522            let styled = Line::from(Span::styled(*line, self.theme.accent_style()));
523            buf.set_line(center_x + offset, area.y + row, &styled, area.width);
524            row += 1;
525        }
526
527        row += 1;
528
529        let lines = [
530            (
531                "Welcome to imp — an AI coding agent.",
532                Style::default().add_modifier(Modifier::BOLD),
533            ),
534            ("", Style::default()),
535            (
536                "Let's get you set up. This takes about 30 seconds.",
537                self.theme.muted_style(),
538            ),
539        ];
540
541        for (text, style) in &lines {
542            if row >= area.height {
543                return;
544            }
545            let offset = area.width.saturating_sub(text.len() as u16) / 2;
546            let line = Line::from(Span::styled(*text, *style));
547            buf.set_line(center_x + offset, area.y + row, &line, area.width);
548            row += 1;
549        }
550
551        if area.height > row + 2 {
552            let footer_y = area.y + area.height - 1;
553            let footer = Line::from(vec![
554                Span::styled("  Enter ", Style::default().add_modifier(Modifier::BOLD)),
555                Span::styled("Continue", self.theme.muted_style()),
556                Span::raw("    "),
557                Span::styled("Esc ", Style::default().add_modifier(Modifier::BOLD)),
558                Span::styled("Skip", self.theme.muted_style()),
559            ]);
560            buf.set_line(center_x, footer_y, &footer, area.width);
561        }
562    }
563
564    fn render_provider_auth(&self, area: Rect, buf: &mut Buffer) {
565        let mut row: u16 = 0;
566        let x = area.x;
567
568        let title = Line::from(Span::styled(
569            "  Choose your AI provider",
570            Style::default().add_modifier(Modifier::BOLD),
571        ));
572        buf.set_line(x, area.y + row, &title, area.width);
573        row += 2;
574
575        for (i, status) in self.state.providers.iter().enumerate() {
576            if row >= area.height.saturating_sub(4) {
577                break;
578            }
579            let is_selected = i == self.state.provider_selected;
580            let marker = if is_selected { "▸ " } else { "  " };
581
582            let auth_hint = if status.env_detected {
583                let detected_var = status
584                    .meta
585                    .env_vars
586                    .iter()
587                    .find(|v| std::env::var(v).is_ok())
588                    .copied()
589                    .unwrap_or(status.meta.env_vars.first().copied().unwrap_or(""));
590                format!("  ({} detected ✓)", detected_var)
591            } else if status.stored {
592                "  (saved ✓)".to_string()
593            } else {
594                String::new()
595            };
596
597            let label_style = if is_selected {
598                Style::default()
599                    .fg(self.theme.accent)
600                    .add_modifier(Modifier::BOLD)
601            } else {
602                Style::default()
603            };
604
605            let line = Line::from(vec![
606                Span::styled(format!("  {marker}"), self.theme.accent_style()),
607                Span::styled(status.meta.name, label_style),
608                Span::styled(auth_hint, self.theme.success_style()),
609            ]);
610            buf.set_line(x, area.y + row, &line, area.width);
611            row += 1;
612        }
613
614        row += 1;
615
616        let Some(selected) = self.state.selected_provider() else {
617            let line = Line::from(Span::styled(
618                "  No providers available",
619                self.theme.muted_style(),
620            ));
621            buf.set_line(x, area.y + row, &line, area.width);
622            return;
623        };
624        if !selected.has_auth() {
625            let prompt_line =
626                Line::from(vec![Span::styled("  API Key: ", self.theme.muted_style())]);
627            buf.set_line(x, area.y + row, &prompt_line, area.width);
628            row += 1;
629
630            let display_key = if self.state.key_input.is_empty() {
631                "  ┌─ paste your key here ─────────────────┐".to_string()
632            } else {
633                let masked: String = self
634                    .state
635                    .key_input
636                    .chars()
637                    .enumerate()
638                    .map(|(i, c)| if i < 6 { c } else { '•' })
639                    .collect();
640                format!(
641                    "  ┌ {masked}▎{} ┐",
642                    " ".repeat(40usize.saturating_sub(masked.len() + 1))
643                )
644            };
645            let key_style = if self.state.key_input.is_empty() {
646                self.theme.muted_style()
647            } else {
648                Style::default()
649            };
650            let key_line = Line::from(Span::styled(display_key, key_style));
651            buf.set_line(x, area.y + row, &key_line, area.width);
652            row += 1;
653
654            let url_line = Line::from(vec![
655                Span::styled("  Get a key: ", self.theme.muted_style()),
656                Span::styled(
657                    selected.meta.docs_url,
658                    Style::default().fg(self.theme.accent),
659                ),
660            ]);
661            buf.set_line(x, area.y + row, &url_line, area.width);
662            row += 1;
663
664            if let Some(ref error) = self.state.key_error {
665                row += 1;
666                let error_line =
667                    Line::from(Span::styled(format!("  {error}"), self.theme.error_style()));
668                buf.set_line(x, area.y + row, &error_line, area.width);
669            }
670        } else {
671            let ready = Line::from(vec![
672                Span::styled("  ✓ ", self.theme.success_style()),
673                Span::styled("Ready to connect.", self.theme.muted_style()),
674            ]);
675            buf.set_line(x, area.y + row, &ready, area.width);
676        }
677
678        if area.height > 2 {
679            let footer_y = area.y + area.height - 1;
680            let footer = Line::from(vec![
681                Span::styled("  Enter ", Style::default().add_modifier(Modifier::BOLD)),
682                Span::styled("Continue", self.theme.muted_style()),
683                Span::raw("    "),
684                Span::styled("↑↓ ", Style::default().add_modifier(Modifier::BOLD)),
685                Span::styled("Select provider", self.theme.muted_style()),
686                Span::raw("    "),
687                Span::styled("Esc ", Style::default().add_modifier(Modifier::BOLD)),
688                Span::styled("Back", self.theme.muted_style()),
689            ]);
690            buf.set_line(x, footer_y, &footer, area.width);
691        }
692    }
693
694    fn render_model_thinking(&self, area: Rect, buf: &mut Buffer) {
695        let mut row: u16 = 0;
696        let x = area.x;
697
698        let title = Line::from(Span::styled(
699            "  Default model & thinking level",
700            Style::default().add_modifier(Modifier::BOLD),
701        ));
702        buf.set_line(x, area.y + row, &title, area.width);
703        row += 2;
704
705        let subtitle = Line::from(Span::styled("  Model:", self.theme.muted_style()));
706        buf.set_line(x, area.y + row, &subtitle, area.width);
707        row += 1;
708
709        let visible_models = 6usize;
710        let selected_model = self.state.normalized_model_selected();
711        let start = selected_model.saturating_sub(visible_models / 2);
712        let end = (start + visible_models).min(self.state.models.len());
713        let start = end.saturating_sub(visible_models);
714
715        for model_i in start..end {
716            if row >= area.height.saturating_sub(6) {
717                break;
718            }
719            let model = &self.state.models[model_i];
720            let is_selected = model_i == selected_model;
721            let marker = if is_selected { "▸ " } else { "  " };
722
723            let name_style = if is_selected {
724                Style::default()
725                    .fg(self.theme.accent)
726                    .add_modifier(Modifier::BOLD)
727            } else {
728                Style::default()
729            };
730
731            let context_str = format!("{}k", model.context_window / 1000);
732            let price_str = format!(
733                "${:.2}/{:.2}",
734                model.pricing.input_per_mtok, model.pricing.output_per_mtok
735            );
736
737            let line = Line::from(vec![
738                Span::styled(format!("    {marker}"), self.theme.accent_style()),
739                Span::styled(format!("{:<36}", &model.name), name_style),
740                Span::styled(format!("{context_str:>5}"), self.theme.muted_style()),
741                Span::raw("  "),
742                Span::styled(price_str, self.theme.muted_style()),
743            ]);
744            buf.set_line(x, area.y + row, &line, area.width);
745            row += 1;
746        }
747
748        row += 1;
749
750        let thinking_label = match self.state.thinking_level {
751            ThinkingLevel::Off => "Off",
752            ThinkingLevel::Minimal => "Minimal",
753            ThinkingLevel::Low => "Low",
754            ThinkingLevel::Medium => "Medium",
755            ThinkingLevel::High => "High",
756            ThinkingLevel::XHigh => "XHigh",
757        };
758        let thinking_line = Line::from(vec![
759            Span::styled("  Thinking:  ", self.theme.muted_style()),
760            Span::styled("← ", self.theme.accent_style()),
761            Span::styled(
762                thinking_label,
763                Style::default()
764                    .fg(self.theme.accent)
765                    .add_modifier(Modifier::BOLD),
766            ),
767            Span::styled(" →", self.theme.accent_style()),
768        ]);
769        buf.set_line(x, area.y + row, &thinking_line, area.width);
770        row += 2;
771
772        let hint = Line::from(Span::styled(
773            "  You can change these anytime with Ctrl+L and Shift+Tab.",
774            self.theme.muted_style(),
775        ));
776        if row < area.height {
777            buf.set_line(x, area.y + row, &hint, area.width);
778        }
779
780        if area.height > 2 {
781            let footer_y = area.y + area.height - 1;
782            let footer = Line::from(vec![
783                Span::styled("  Enter ", Style::default().add_modifier(Modifier::BOLD)),
784                Span::styled("Continue", self.theme.muted_style()),
785                Span::raw("    "),
786                Span::styled("↑↓ ", Style::default().add_modifier(Modifier::BOLD)),
787                Span::styled("Model", self.theme.muted_style()),
788                Span::raw("    "),
789                Span::styled("←→ ", Style::default().add_modifier(Modifier::BOLD)),
790                Span::styled("Thinking", self.theme.muted_style()),
791                Span::raw("    "),
792                Span::styled("Esc ", Style::default().add_modifier(Modifier::BOLD)),
793                Span::styled("Back", self.theme.muted_style()),
794            ]);
795            buf.set_line(x, footer_y, &footer, area.width);
796        }
797    }
798
799    fn render_web_search(&self, area: Rect, buf: &mut Buffer) {
800        let mut row: u16 = 0;
801        let x = area.x;
802
803        let title = Line::from(Span::styled(
804            "  Optional web search setup",
805            Style::default().add_modifier(Modifier::BOLD),
806        ));
807        buf.set_line(x, area.y + row, &title, area.width);
808        row += 1;
809
810        let subtitle = Line::from(Span::styled(
811            "  Add Tavily or Exa now so the web tool can search immediately.",
812            self.theme.muted_style(),
813        ));
814        buf.set_line(x, area.y + row, &subtitle, area.width);
815        row += 2;
816
817        for (i, provider) in self.state.web_providers.iter().enumerate() {
818            if row >= area.height.saturating_sub(6) {
819                break;
820            }
821            let is_selected = i == self.state.web_provider_selected;
822            let marker = if is_selected { "▸ " } else { "  " };
823            let mut status = String::new();
824            if provider.id == "none" {
825                status = "  (skip)".to_string();
826            } else if provider.env_detected {
827                status = format!("  ({} detected ✓)", provider.env_key);
828            } else if provider.stored {
829                status = "  (saved ✓)".to_string();
830            }
831            let label_style = if is_selected {
832                Style::default()
833                    .fg(self.theme.accent)
834                    .add_modifier(Modifier::BOLD)
835            } else {
836                Style::default()
837            };
838            let line = Line::from(vec![
839                Span::styled(format!("  {marker}"), self.theme.accent_style()),
840                Span::styled(provider.label, label_style),
841                Span::styled(status, self.theme.success_style()),
842            ]);
843            buf.set_line(x, area.y + row, &line, area.width);
844            row += 1;
845        }
846
847        row += 1;
848        let Some(selected) = self.state.selected_web_provider() else {
849            let line = Line::from(Span::styled(
850                "  No web search providers available",
851                self.theme.muted_style(),
852            ));
853            buf.set_line(x, area.y + row, &line, area.width);
854            return;
855        };
856        if selected.id != "none" && !selected.has_auth() {
857            let prompt_line =
858                Line::from(vec![Span::styled("  API Key: ", self.theme.muted_style())]);
859            buf.set_line(x, area.y + row, &prompt_line, area.width);
860            row += 1;
861
862            let display_key = if self.state.web_key_input.is_empty() {
863                "  ┌─ paste your key here ─────────────────┐".to_string()
864            } else {
865                let masked: String = self
866                    .state
867                    .web_key_input
868                    .chars()
869                    .enumerate()
870                    .map(|(i, c)| if i < 6 { c } else { '•' })
871                    .collect();
872                format!(
873                    "  ┌ {masked}▎{} ┐",
874                    " ".repeat(40usize.saturating_sub(masked.len() + 1))
875                )
876            };
877            let key_style = if self.state.web_key_input.is_empty() {
878                self.theme.muted_style()
879            } else {
880                Style::default()
881            };
882            let key_line = Line::from(Span::styled(display_key, key_style));
883            buf.set_line(x, area.y + row, &key_line, area.width);
884            row += 1;
885
886            let url_line = Line::from(vec![
887                Span::styled("  Get a key: ", self.theme.muted_style()),
888                Span::styled(selected.docs_url, Style::default().fg(self.theme.accent)),
889            ]);
890            buf.set_line(x, area.y + row, &url_line, area.width);
891        } else if selected.id == "none" {
892            let ready = Line::from(vec![
893                Span::styled("  ↷ ", self.theme.muted_style()),
894                Span::styled(
895                    "Skipping web search setup for now.",
896                    self.theme.muted_style(),
897                ),
898            ]);
899            buf.set_line(x, area.y + row, &ready, area.width);
900        } else {
901            let ready = Line::from(vec![
902                Span::styled("  ✓ ", self.theme.success_style()),
903                Span::styled("Web search provider is ready.", self.theme.muted_style()),
904            ]);
905            buf.set_line(x, area.y + row, &ready, area.width);
906        }
907
908        if area.height > 2 {
909            let footer_y = area.y + area.height - 1;
910            let footer = Line::from(vec![
911                Span::styled("  Enter ", Style::default().add_modifier(Modifier::BOLD)),
912                Span::styled("Continue", self.theme.muted_style()),
913                Span::raw("    "),
914                Span::styled("↑↓ ", Style::default().add_modifier(Modifier::BOLD)),
915                Span::styled("Select provider", self.theme.muted_style()),
916                Span::raw("    "),
917                Span::styled("Esc ", Style::default().add_modifier(Modifier::BOLD)),
918                Span::styled("Back", self.theme.muted_style()),
919            ]);
920            buf.set_line(x, footer_y, &footer, area.width);
921        }
922    }
923
924    fn render_done(&self, area: Rect, buf: &mut Buffer) {
925        let mut row: u16 = 0;
926        let x = area.x;
927
928        let header = Line::from(Span::styled(
929            "  ✓ You're all set.",
930            Style::default()
931                .fg(self.theme.success)
932                .add_modifier(Modifier::BOLD),
933        ));
934        buf.set_line(x, area.y + row, &header, area.width);
935        row += 2;
936
937        let provider_name = self
938            .state
939            .selected_provider()
940            .map(|provider| provider.meta.name)
941            .unwrap_or("not configured");
942        let web_provider_name = self
943            .state
944            .resolved_web_provider
945            .as_deref()
946            .filter(|id| *id != "none")
947            .map(|id| {
948                self.state
949                    .web_providers
950                    .iter()
951                    .find(|provider| provider.id == id)
952                    .map(|provider| provider.label)
953                    .unwrap_or(id)
954            })
955            .unwrap_or("not configured");
956        let model_name = self
957            .state
958            .selected_model()
959            .map(|m| m.name.as_str())
960            .unwrap_or("default");
961        let thinking_label = match self.state.thinking_level {
962            ThinkingLevel::Off => "off",
963            ThinkingLevel::Minimal => "minimal",
964            ThinkingLevel::Low => "low",
965            ThinkingLevel::Medium => "medium",
966            ThinkingLevel::High => "high",
967            ThinkingLevel::XHigh => "xhigh",
968        };
969
970        let summary_lines = [
971            format!("  Provider:  {provider_name}"),
972            format!("  Model:     {model_name}"),
973            format!("  Thinking:  {thinking_label}"),
974            format!("  Web:       {web_provider_name}"),
975        ];
976
977        for line_text in &summary_lines {
978            if row >= area.height {
979                return;
980            }
981            let line = Line::from(Span::styled(line_text.as_str(), Style::default()));
982            buf.set_line(x, area.y + row, &line, area.width);
983            row += 1;
984        }
985
986        row += 1;
987
988        let config_hint = Line::from(Span::styled(
989            "  Config saved to ~/.config/imp/config.toml",
990            self.theme.muted_style(),
991        ));
992        if row < area.height {
993            buf.set_line(x, area.y + row, &config_hint, area.width);
994            row += 1;
995        }
996
997        row += 1;
998
999        let tips_header = Line::from(Span::styled(
1000            "  Quick tips:",
1001            Style::default().add_modifier(Modifier::BOLD),
1002        ));
1003        if row < area.height {
1004            buf.set_line(x, area.y + row, &tips_header, area.width);
1005            row += 1;
1006        }
1007
1008        let tips = [
1009            ("Enter", "Send a message"),
1010            ("Ctrl+C", "Clear / Abort / Quit"),
1011            ("Ctrl+L", "Switch model"),
1012            ("Shift+Tab", "Cycle thinking level"),
1013            ("@file", "Attach file context"),
1014            ("/command", "Slash commands"),
1015        ];
1016
1017        for (key, desc) in &tips {
1018            if row >= area.height.saturating_sub(2) {
1019                break;
1020            }
1021            let line = Line::from(vec![
1022                Span::styled(format!("    {key:<12}"), self.theme.accent_style()),
1023                Span::styled(*desc, self.theme.muted_style()),
1024            ]);
1025            buf.set_line(x, area.y + row, &line, area.width);
1026            row += 1;
1027        }
1028
1029        if area.height > 2 {
1030            let footer_y = area.y + area.height - 1;
1031            let footer = Line::from(vec![
1032                Span::styled("  Enter ", Style::default().add_modifier(Modifier::BOLD)),
1033                Span::styled("Start using imp", self.theme.muted_style()),
1034            ]);
1035            buf.set_line(x, footer_y, &footer, area.width);
1036        }
1037    }
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042    use super::*;
1043    use imp_llm::model::ModelRegistry;
1044
1045    #[test]
1046    fn selected_provider_and_step_clamp_stale_indices() {
1047        let registry = ModelRegistry::with_builtins();
1048        let models = registry.list().to_vec();
1049        let mut state = WelcomeState::new(&models);
1050        state.step = usize::MAX;
1051        state.provider_selected = usize::MAX;
1052        state.web_provider_selected = usize::MAX;
1053
1054        assert_eq!(state.current_step(), WelcomeStep::Done);
1055        assert!(state.selected_provider().is_some());
1056        assert!(state.selected_web_provider().is_some());
1057    }
1058
1059    #[test]
1060    fn empty_provider_lists_fail_gracefully() {
1061        let mut state = WelcomeState::new(&[]);
1062        state.providers.clear();
1063        state.web_providers.clear();
1064
1065        assert!(state.selected_provider().is_none());
1066        assert!(state.selected_web_provider().is_none());
1067        assert!(state.check_auth_resolved().is_err());
1068        assert!(state.check_web_auth_resolved().is_err());
1069    }
1070
1071    #[test]
1072    fn setup_hides_kimi_code_provider_under_moonshot() {
1073        let registry = ModelRegistry::with_builtins();
1074        let models = registry.list().to_vec();
1075        let state = WelcomeState::new(&models);
1076
1077        assert!(state
1078            .providers
1079            .iter()
1080            .any(|provider| provider.meta.id == "moonshot"));
1081        assert!(!state
1082            .providers
1083            .iter()
1084            .any(|provider| provider.meta.id == "kimi-code"));
1085    }
1086
1087    #[test]
1088    fn openai_setup_models_include_gpt_5_5() {
1089        let registry = ModelRegistry::with_builtins();
1090        let models = filter_models_for_provider(registry.list(), "openai");
1091
1092        let gpt_5_5 = models
1093            .iter()
1094            .find(|model| model.id == "gpt-5.5")
1095            .expect("OpenAI setup model list should include GPT-5.5");
1096        assert_eq!(gpt_5_5.provider, "openai");
1097    }
1098}