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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum WelcomeStep {
15 Welcome,
17 ProviderAuth,
19 ModelThinking,
21 WebSearch,
23 Done,
25}
26
27const STEPS: &[WelcomeStep] = &[
28 WelcomeStep::Welcome,
29 WelcomeStep::ProviderAuth,
30 WelcomeStep::ModelThinking,
31 WelcomeStep::WebSearch,
32 WelcomeStep::Done,
33];
34
35#[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#[derive(Debug, Clone)]
67pub struct WelcomeState {
68 pub step: usize,
69 pub providers: Vec<ProviderStatus>,
71 pub provider_selected: usize,
73 pub key_input: String,
75 pub key_editing: bool,
77 pub key_error: Option<String>,
79 pub models: Vec<ModelMeta>,
81 pub model_selected: usize,
83 pub thinking_level: ThinkingLevel,
85 pub auth_resolved: bool,
87 pub resolved_key: Option<String>,
89 pub web_providers: Vec<WebProviderStatus>,
91 pub web_provider_selected: usize,
93 pub web_key_input: String,
95 pub resolved_web_provider: Option<String>,
97 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 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 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 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 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 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
434pub 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 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) .unwrap_or(false);
455
456 !has_env && !has_stored
457}
458
459pub 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}