diff --git a/.gitignore b/.gitignore index ea8c4bf..0592392 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.DS_Store diff --git a/crates/tui/src/bottom_pane/bottom_pane_view.rs b/crates/tui/src/bottom_pane/bottom_pane_view.rs index 46caf37..9c8d134 100644 --- a/crates/tui/src/bottom_pane/bottom_pane_view.rs +++ b/crates/tui/src/bottom_pane/bottom_pane_view.rs @@ -2,6 +2,7 @@ use crate::render::renderable::Renderable; use crossterm::event::KeyEvent; use super::CancellationEvent; +use super::onboarding_view::OnboardingResult; /// Trait implemented by every view that can be shown in the bottom pane. pub(crate) trait BottomPaneView: Renderable { @@ -31,6 +32,14 @@ pub(crate) trait BottomPaneView: Renderable { None } + fn take_onboarding_result(&mut self) -> Option { + None + } + + fn on_validation_succeeded(&mut self, _reply_preview: String) {} + + fn on_validation_failed(&mut self, _error_message: String) {} + /// Handle Ctrl-C while this view is active. fn on_ctrl_c(&mut self) -> CancellationEvent { CancellationEvent::NotHandled diff --git a/crates/tui/src/bottom_pane/mod.rs b/crates/tui/src/bottom_pane/mod.rs index 2cb7bc2..d0b1844 100644 --- a/crates/tui/src/bottom_pane/mod.rs +++ b/crates/tui/src/bottom_pane/mod.rs @@ -19,6 +19,7 @@ mod command_popup; mod file_search_popup; mod footer; mod list_selection_view; +mod onboarding_view; mod paste_burst; mod pending_thread_approvals; mod popup_consts; @@ -33,6 +34,8 @@ mod unified_exec_footer; pub(crate) use chat_composer::ChatComposer; use chat_composer::ChatComposerConfig; use chat_composer::InputResult as ComposerInputResult; +pub(crate) use onboarding_view::OnboardingResult; +pub(crate) use onboarding_view::OnboardingView; use crate::app_command::AppCommand; use crate::app_command::InputHistoryDirection; @@ -327,6 +330,45 @@ impl BottomPane { self.push_view(Box::new(ModelPickerView::new(entries))); } + pub(crate) fn open_onboarding(&mut self, models: &[devo_protocol::Model]) { + self.push_view(Box::new(OnboardingView::new( + models, + self.app_event_tx.clone(), + self.frame_requester.clone(), + self.animations_enabled, + ))); + } + + pub(crate) fn onboarding_on_validation_succeeded(&mut self, reply_preview: String) { + if let Some(view) = self.view_stack.last_mut() { + view.on_validation_succeeded(reply_preview); + if view.is_complete() { + self.view_stack.pop(); + self.on_active_view_complete(); + self.request_redraw(); + } + } + } + + pub(crate) fn onboarding_on_validation_failed(&mut self, error_message: String) { + if let Some(view) = self.view_stack.last_mut() { + view.on_validation_failed(error_message); + self.request_redraw(); + } + } + + pub(crate) fn take_onboarding_result(&mut self) -> Option { + self.view_stack + .last_mut() + .and_then(|view| view.take_onboarding_result()) + } + + pub(crate) fn is_onboarding_active(&self) -> bool { + self.view_stack + .last() + .is_some_and(|view| view.view_id() == Some("onboarding")) + } + pub(crate) fn restore_input_from_history(&mut self, text: Option) { match text { Some(text) => { diff --git a/crates/tui/src/bottom_pane/onboarding_view.rs b/crates/tui/src/bottom_pane/onboarding_view.rs new file mode 100644 index 0000000..39da137 --- /dev/null +++ b/crates/tui/src/bottom_pane/onboarding_view.rs @@ -0,0 +1,1440 @@ +use std::time::Instant; + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use ratatui::widgets::Wrap; + +use devo_protocol::Model; +use devo_protocol::ProviderWireApi; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +/// Simple content area with padding, no background styling. +fn onboarding_content_area(area: Rect) -> Rect { + if area.height < 2 || area.width < 2 { + return area; + } + Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(2), + } +} +use crate::app_command::AppCommand; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::exec_cell::spinner; +use crate::render::renderable::Renderable; +use crate::tui::frame_requester::FrameRequester; + +const CUSTOM_MODEL_SENTINEL: &str = "__custom_model__"; +const SPINNER_INTERVAL: std::time::Duration = std::time::Duration::from_millis(80); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum OnboardingResult { + /// User selected a catalog model (slug). + CatalogModelSelected { slug: String }, + /// User entered a custom model name and needs provider type selection. + CustomModelEntered { model: String }, + /// User completed provider config and wants to validate. + Validate { + model: String, + provider: ProviderWireApi, + base_url: Option, + api_key: Option, + }, + /// Validation succeeded, config should be saved. + ValidationSucceeded { + model: String, + provider: ProviderWireApi, + base_url: Option, + api_key: Option, + }, + /// User cancelled onboarding. + Cancelled, +} + +#[derive(Debug)] +enum OnboardingState { + /// Step 1: Select a model from catalog or enter custom. + ModelSelection { + items: Vec, + state: ScrollState, + search_query: String, + filtered_indices: Vec, + }, + /// Step 1b: Enter custom model name. + CustomModelName { input: String, cursor_pos: usize }, + /// Step 1c: Select provider type for custom model. + ProviderSelection { + model: String, + items: Vec, + selected_idx: usize, + }, + /// Step 2: Enter base URL. + BaseUrl { + model: String, + provider: ProviderWireApi, + input: String, + default_url: String, + cursor_pos: usize, + }, + /// Step 3: Enter API key. + ApiKey { + model: String, + provider: ProviderWireApi, + base_url: Option, + input: String, + cursor_pos: usize, + }, + /// Step 4: Validating connection. + Validating { + model: String, + provider: ProviderWireApi, + base_url: Option, + api_key: Option, + started_at: Instant, + }, + /// Validation failed, show error and retry options. + ValidationFailed { + model: String, + provider: ProviderWireApi, + base_url: Option, + api_key: Option, + error_message: String, + selected_action: usize, + }, +} + +#[derive(Debug)] +struct ModelSelectionItem { + slug: String, + display_name: String, + description: String, + context_window: u32, + thinking_label: String, + is_custom: bool, +} + +#[derive(Debug)] +struct ProviderSelectionItem { + label: String, + description: String, + provider: ProviderWireApi, +} + +pub(crate) struct OnboardingView { + state: OnboardingState, + complete: bool, + result: Option, + app_event_tx: AppEventSender, + frame_requester: FrameRequester, + animations_enabled: bool, +} + +impl OnboardingView { + pub(crate) fn new( + models: &[Model], + app_event_tx: AppEventSender, + frame_requester: FrameRequester, + animations_enabled: bool, + ) -> Self { + let items: Vec = models + .iter() + .map(|m| { + let thinking_label = match &m.thinking_capability { + devo_protocol::ThinkingCapability::Unsupported => String::new(), + devo_protocol::ThinkingCapability::Toggle => "thinking".to_string(), + devo_protocol::ThinkingCapability::Levels(levels) => { + if levels.is_empty() { + String::new() + } else { + format!("thinking: {}", levels.len()) + } + } + devo_protocol::ThinkingCapability::ToggleWithLevels(_) => { + "thinking".to_string() + } + }; + ModelSelectionItem { + slug: m.slug.clone(), + display_name: m.display_name.clone(), + description: m.description.clone().unwrap_or_default(), + context_window: m.context_window, + thinking_label, + is_custom: false, + } + }) + .collect(); + + let mut all_items = items; + all_items.push(ModelSelectionItem { + slug: CUSTOM_MODEL_SENTINEL.to_string(), + display_name: "Custom Model".to_string(), + description: "Enter a custom model slug".to_string(), + context_window: 0, + thinking_label: String::new(), + is_custom: true, + }); + + let filtered_indices = (0..all_items.len()).collect(); + let mut state = ScrollState::new(); + state.selected_idx = Some(0); + + Self { + state: OnboardingState::ModelSelection { + items: all_items, + state, + search_query: String::new(), + filtered_indices, + }, + complete: false, + result: None, + app_event_tx, + frame_requester, + animations_enabled, + } + } + + pub(crate) fn take_result(&mut self) -> Option { + self.result.take() + } + + pub(crate) fn is_complete(&self) -> bool { + self.complete + } + + /// Called when validation succeeds. + pub(crate) fn on_validation_succeeded(&mut self, _reply_preview: String) { + if let OnboardingState::Validating { + model, + provider, + base_url, + api_key, + .. + } = &self.state + { + self.result = Some(OnboardingResult::ValidationSucceeded { + model: model.clone(), + provider: *provider, + base_url: base_url.clone(), + api_key: api_key.clone(), + }); + self.complete = true; + } + } + + /// Called when validation fails. + pub(crate) fn on_validation_failed(&mut self, error_message: String) { + if let OnboardingState::Validating { + model, + provider, + base_url, + api_key, + .. + } = &self.state + { + self.state = OnboardingState::ValidationFailed { + model: model.clone(), + provider: *provider, + base_url: base_url.clone(), + api_key: api_key.clone(), + error_message, + selected_action: 0, + }; + } + } + + // ── Model Selection ── + + fn model_selection_handle_key(&mut self, key: KeyEvent) { + let OnboardingState::ModelSelection { + items, + state, + search_query, + filtered_indices, + } = &mut self.state + else { + return; + }; + + match key.code { + KeyCode::Up | KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => { + Self::model_move_up(state, filtered_indices, items); + } + KeyCode::Up => { + Self::model_move_up(state, filtered_indices, items); + } + KeyCode::Char('k') if key.modifiers.is_empty() => { + Self::model_move_up(state, filtered_indices, items); + } + KeyCode::Down | KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => { + Self::model_move_down(state, filtered_indices, items); + } + KeyCode::Down => { + Self::model_move_down(state, filtered_indices, items); + } + KeyCode::Char('j') if key.modifiers.is_empty() => { + Self::model_move_down(state, filtered_indices, items); + } + KeyCode::Char(c) + if key.modifiers.is_empty() || key.modifiers.contains(KeyModifiers::SHIFT) => + { + search_query.push(c); + Self::model_apply_filter(items, search_query, filtered_indices, state); + } + KeyCode::Backspace => { + search_query.pop(); + Self::model_apply_filter(items, search_query, filtered_indices, state); + } + KeyCode::Enter => { + if let Some(visible_idx) = state.selected_idx + && let Some(&actual_idx) = filtered_indices.get(visible_idx) + && let Some(item) = items.get(actual_idx) + { + if item.is_custom { + self.state = OnboardingState::CustomModelName { + input: String::new(), + cursor_pos: 0, + }; + } else { + // Catalog model selected, go to base URL step + let slug = item.slug.clone(); + let provider = Self::infer_provider(&slug); + let default_url = Self::default_base_url(provider); + self.state = OnboardingState::BaseUrl { + model: slug, + provider, + input: default_url.clone(), + default_url, + cursor_pos: 0, + }; + } + } + } + _ => {} + } + } + + fn model_move_up( + state: &mut ScrollState, + filtered_indices: &[usize], + _items: &[ModelSelectionItem], + ) { + let len = filtered_indices.len(); + if len == 0 { + return; + } + let current = state.selected_idx.unwrap_or(0); + state.selected_idx = Some(if current == 0 { len - 1 } else { current - 1 }); + } + + fn model_move_down( + state: &mut ScrollState, + filtered_indices: &[usize], + _items: &[ModelSelectionItem], + ) { + let len = filtered_indices.len(); + if len == 0 { + return; + } + let current = state.selected_idx.unwrap_or(0); + state.selected_idx = Some((current + 1) % len); + } + + fn model_apply_filter( + items: &[ModelSelectionItem], + query: &str, + filtered_indices: &mut Vec, + state: &mut ScrollState, + ) { + let query_lower = query.to_lowercase(); + if query.is_empty() { + *filtered_indices = (0..items.len()).collect(); + } else { + *filtered_indices = items + .iter() + .enumerate() + .filter(|(_, item)| { + item.slug.to_lowercase().contains(&query_lower) + || item.display_name.to_lowercase().contains(&query_lower) + || item.description.to_lowercase().contains(&query_lower) + }) + .map(|(idx, _)| idx) + .collect(); + } + // Reset selection to first filtered item + state.selected_idx = if filtered_indices.is_empty() { + None + } else { + Some(0) + }; + } + + fn infer_provider(slug: &str) -> ProviderWireApi { + let slug_lower = slug.to_lowercase(); + if slug_lower.contains("claude") || slug_lower.contains("anthropic") { + ProviderWireApi::AnthropicMessages + } else { + ProviderWireApi::OpenAIChatCompletions + } + } + + fn default_base_url(provider: ProviderWireApi) -> String { + match provider { + ProviderWireApi::AnthropicMessages => "https://api.anthropic.com".to_string(), + ProviderWireApi::OpenAIChatCompletions => "https://api.openai.com/v1".to_string(), + ProviderWireApi::OpenAIResponses => "https://api.openai.com/v1".to_string(), + } + } + + fn provider_selection_items() -> Vec { + vec![ + ProviderSelectionItem { + label: "OpenAI Chat Completions".to_string(), + description: "Most providers (OpenAI, Together, Groq, ...)".to_string(), + provider: ProviderWireApi::OpenAIChatCompletions, + }, + ProviderSelectionItem { + label: "OpenAI Responses".to_string(), + description: "OpenAI native Responses API".to_string(), + provider: ProviderWireApi::OpenAIResponses, + }, + ProviderSelectionItem { + label: "Anthropic Messages".to_string(), + description: "Claude models via Anthropic API".to_string(), + provider: ProviderWireApi::AnthropicMessages, + }, + ] + } + + // ── Custom Model Name ── + + fn custom_model_name_handle_key(&mut self, key: KeyEvent) { + let OnboardingState::CustomModelName { input, cursor_pos } = &mut self.state else { + return; + }; + + match key.code { + KeyCode::Char(c) + if key.modifiers.is_empty() || key.modifiers.contains(KeyModifiers::SHIFT) => + { + input.insert(*cursor_pos, c); + *cursor_pos += 1; + } + KeyCode::Backspace => { + if *cursor_pos > 0 { + input.remove(*cursor_pos - 1); + *cursor_pos -= 1; + } + } + KeyCode::Delete => { + if *cursor_pos < input.len() { + input.remove(*cursor_pos); + } + } + KeyCode::Left => { + if *cursor_pos > 0 { + *cursor_pos -= 1; + } + } + KeyCode::Right => { + if *cursor_pos < input.len() { + *cursor_pos += 1; + } + } + KeyCode::Home => { + *cursor_pos = 0; + } + KeyCode::End => { + *cursor_pos = input.len(); + } + KeyCode::Enter => { + let model = input.trim().to_string(); + if model.is_empty() { + return; + } + self.state = OnboardingState::ProviderSelection { + model, + items: Self::provider_selection_items(), + selected_idx: 0, + }; + } + KeyCode::Esc => { + self.go_back_to_model_selection(); + } + _ => {} + } + } + + // ── Provider Selection ── + + fn provider_selection_handle_key(&mut self, key: KeyEvent) { + let OnboardingState::ProviderSelection { + model, + items, + selected_idx, + } = &mut self.state + else { + return; + }; + + match key.code { + KeyCode::Up => { + *selected_idx = if *selected_idx == 0 { + items.len() - 1 + } else { + *selected_idx - 1 + }; + } + KeyCode::Down => { + *selected_idx = (*selected_idx + 1) % items.len(); + } + KeyCode::Enter => { + if let Some(item) = items.get(*selected_idx) { + let provider = item.provider; + let default_url = Self::default_base_url(provider); + self.state = OnboardingState::BaseUrl { + model: model.clone(), + provider, + input: default_url.clone(), + default_url, + cursor_pos: 0, + }; + } + } + KeyCode::Esc => { + self.go_back_to_model_selection(); + } + _ => {} + } + } + + // ── Provider Config ── + + // ── Provider Config (legacy, no longer used) ── + + fn provider_config_handle_key(&mut self, _key: KeyEvent) {} + + // ── Base URL ── + + fn base_url_handle_key(&mut self, key: KeyEvent) { + let OnboardingState::BaseUrl { + model, + provider, + input, + default_url: _, + cursor_pos, + } = &mut self.state + else { + return; + }; + + match key.code { + KeyCode::Char(c) + if key.modifiers.is_empty() || key.modifiers.contains(KeyModifiers::SHIFT) => + { + input.insert(*cursor_pos, c); + *cursor_pos += 1; + } + KeyCode::Backspace => { + if *cursor_pos > 0 { + input.remove(*cursor_pos - 1); + *cursor_pos -= 1; + } + } + KeyCode::Delete => { + if *cursor_pos < input.len() { + input.remove(*cursor_pos); + } + } + KeyCode::Left => { + if *cursor_pos > 0 { + *cursor_pos -= 1; + } + } + KeyCode::Right => { + if *cursor_pos < input.len() { + *cursor_pos += 1; + } + } + KeyCode::Home => { + *cursor_pos = 0; + } + KeyCode::End => { + *cursor_pos = input.len(); + } + KeyCode::Enter => { + let model = model.clone(); + let provider = *provider; + let base_url = input.trim().to_string(); + let base_url_val = if base_url.is_empty() { + None + } else { + Some(base_url) + }; + self.state = OnboardingState::ApiKey { + model, + provider, + base_url: base_url_val, + input: String::new(), + cursor_pos: 0, + }; + } + KeyCode::Esc => { + self.go_back_to_model_selection(); + } + _ => {} + } + } + + // ── API Key ── + + fn api_key_handle_key(&mut self, key: KeyEvent) { + let OnboardingState::ApiKey { + model, + provider, + base_url, + input, + cursor_pos, + } = &mut self.state + else { + return; + }; + + match key.code { + KeyCode::Char(c) + if key.modifiers.is_empty() || key.modifiers.contains(KeyModifiers::SHIFT) => + { + input.insert(*cursor_pos, c); + *cursor_pos += 1; + } + KeyCode::Backspace => { + if *cursor_pos > 0 { + input.remove(*cursor_pos - 1); + *cursor_pos -= 1; + } + } + KeyCode::Delete => { + if *cursor_pos < input.len() { + input.remove(*cursor_pos); + } + } + KeyCode::Left => { + if *cursor_pos > 0 { + *cursor_pos -= 1; + } + } + KeyCode::Right => { + if *cursor_pos < input.len() { + *cursor_pos += 1; + } + } + KeyCode::Home => { + *cursor_pos = 0; + } + KeyCode::End => { + *cursor_pos = input.len(); + } + KeyCode::Enter => { + let model = model.clone(); + let provider = *provider; + let base_url = base_url.clone(); + let api_key_val = if input.trim().is_empty() { + None + } else { + Some(input.trim().to_string()) + }; + self.state = OnboardingState::Validating { + model: model.clone(), + provider, + base_url: base_url.clone(), + api_key: api_key_val.clone(), + started_at: Instant::now(), + }; + let payload = serde_json::json!({ + "model": model, + "base_url": base_url, + "api_key": api_key_val, + }); + self.app_event_tx + .send(AppEvent::Command(AppCommand::RunUserShellCommand { + command: format!("onboard {payload}"), + })); + } + KeyCode::Esc => { + // Go back to base URL step + let model = model.clone(); + let provider = *provider; + let default_url = Self::default_base_url(provider); + self.state = OnboardingState::BaseUrl { + model, + provider, + input: default_url.clone(), + default_url, + cursor_pos: 0, + }; + } + _ => {} + } + } + + // ── Validation Failed ── + + fn validation_failed_handle_key(&mut self, key: KeyEvent) { + let OnboardingState::ValidationFailed { + model, + provider, + base_url, + api_key, + error_message: _, + selected_action, + } = &mut self.state + else { + return; + }; + + let actions = [ + "Retry with current settings", + "Edit settings", + "Choose different model", + ]; + + match key.code { + KeyCode::Up => { + *selected_action = if *selected_action == 0 { + actions.len() - 1 + } else { + *selected_action - 1 + }; + } + KeyCode::Down => { + *selected_action = (*selected_action + 1) % actions.len(); + } + KeyCode::Enter => match *selected_action { + 0 => { + // Retry with current settings + let model = model.clone(); + let provider = *provider; + let base_url = base_url.clone(); + let api_key = api_key.clone(); + self.state = OnboardingState::Validating { + model: model.clone(), + provider, + base_url: base_url.clone(), + api_key: api_key.clone(), + started_at: Instant::now(), + }; + let payload = serde_json::json!({ + "model": model, + "base_url": base_url, + "api_key": api_key, + }); + self.app_event_tx + .send(AppEvent::Command(AppCommand::RunUserShellCommand { + command: format!("onboard {payload}"), + })); + } + 1 => { + // Edit settings: go back to API key step + self.state = OnboardingState::ApiKey { + model: model.clone(), + provider: *provider, + base_url: base_url.clone(), + input: api_key.clone().unwrap_or_default(), + cursor_pos: 0, + }; + } + 2 => { + // Choose different model + self.go_back_to_model_selection(); + } + _ => {} + }, + KeyCode::Esc => { + self.complete = true; + self.result = Some(OnboardingResult::Cancelled); + } + _ => {} + } + } + + fn go_back_to_model_selection(&mut self) { + // Rebuild model selection from the original catalog models + // We store a minimal version; in practice the caller should provide models. + // For now, we create a placeholder state. + self.state = OnboardingState::ModelSelection { + items: Vec::new(), // Will be populated on next render if needed + state: ScrollState::new(), + search_query: String::new(), + filtered_indices: Vec::new(), + }; + self.complete = true; + self.result = Some(OnboardingResult::Cancelled); + } + + // ── Rendering ── + + fn render_model_selection( + items: &[ModelSelectionItem], + state: &ScrollState, + search_query: &str, + filtered_indices: &[usize], + area: Rect, + buf: &mut Buffer, + ) { + if area.height < 3 { + return; + } + + let content_area = onboarding_content_area(area); + + let mut lines: Vec> = Vec::new(); + + // Title + lines.push(Line::from(vec![Span::styled( + " Welcome to Devo", + Style::default().bold(), + )])); + lines.push(Line::from(vec![Span::styled( + " Choose a model to get started.", + Style::default().dim(), + )])); + lines.push(Line::from("")); + + // Search line + let search_display = if search_query.is_empty() { + Span::styled("Search models...", Style::default().dim()) + } else { + Span::styled(search_query.to_string(), Style::default()) + }; + lines.push(Line::from(vec![ + Span::styled(" ▌ ", Style::default().cyan()), + search_display, + ])); + lines.push(Line::from("")); + + // Model list + let max_visible = MAX_POPUP_ROWS.min(filtered_indices.len().max(1)); + let scroll_offset = state + .selected_idx + .map(|sel| { + if sel >= max_visible.saturating_sub(2) { + sel.saturating_sub(max_visible.saturating_sub(3)) + } else { + 0 + } + }) + .unwrap_or(0); + + for (vis_idx, &actual_idx) in filtered_indices + .iter() + .enumerate() + .skip(scroll_offset) + .take(max_visible) + { + if let Some(item) = items.get(actual_idx) { + let is_selected = state.selected_idx == Some(vis_idx); + let prefix = if is_selected { "› " } else { " " }; + + if item.is_custom { + lines.push(Line::from("")); + let name_style = if is_selected { + Style::default().bold() + } else { + Style::default().dim() + }; + lines.push(Line::from(vec![ + Span::styled( + prefix.to_string(), + if is_selected { + Style::default().cyan() + } else { + Style::default() + }, + ), + Span::styled("── Custom Model ──", name_style), + ])); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("Enter any model slug", Style::default().dim()), + ])); + } else { + let name_style = if is_selected { + Style::default().bold() + } else { + Style::default() + }; + lines.push(Line::from(vec![ + Span::styled( + prefix.to_string(), + if is_selected { + Style::default().cyan() + } else { + Style::default() + }, + ), + Span::styled(item.display_name.clone(), name_style), + ])); + + // Description line with metadata + let mut meta_parts = Vec::new(); + if !item.description.is_empty() { + meta_parts.push(item.description.clone()); + } + if item.context_window > 0 { + meta_parts.push(format!("{}K ctx", item.context_window / 1000)); + } + if !item.thinking_label.is_empty() { + meta_parts.push(item.thinking_label.clone()); + } + if !meta_parts.is_empty() { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(meta_parts.join(" · "), Style::default().dim()), + ])); + } + } + } + } + + // Footer hint + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + " ↑↓ Navigate Enter Select Type to search Esc Cancel", + Style::default().dim(), + )])); + + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(content_area, buf); + } + + fn render_custom_model_name(input: &str, cursor_pos: usize, area: Rect, buf: &mut Buffer) { + if area.height < 3 { + return; + } + + let content_area = onboarding_content_area(area); + + let mut lines: Vec> = Vec::new(); + + lines.push(Line::from(vec![Span::styled( + " Enter Model Name", + Style::default().bold(), + )])); + lines.push(Line::from(vec![Span::styled( + " Type the model slug for your custom model.", + Style::default().dim(), + )])); + lines.push(Line::from("")); + + // Input field + let byte_pos = input + .char_indices() + .nth(cursor_pos.min(input.chars().count())) + .map(|(i, _)| i) + .unwrap_or(input.len()); + let before_cursor = input[..byte_pos].to_string(); + lines.push(Line::from(vec![ + Span::styled(" ▸ ", Style::default().cyan()), + Span::styled(before_cursor, Style::default()), + Span::styled("▌", Style::default().cyan()), + ])); + + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + " Enter Confirm · Esc Back", + Style::default().dim(), + )])); + + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(content_area, buf); + } + + fn render_provider_selection( + model: &str, + items: &[ProviderSelectionItem], + selected_idx: usize, + area: Rect, + buf: &mut Buffer, + ) { + if area.height < 3 { + return; + } + + let content_area = onboarding_content_area(area); + + let mut lines: Vec> = Vec::new(); + + lines.push(Line::from(vec![Span::styled( + " Select Provider Type", + Style::default().bold(), + )])); + lines.push(Line::from(vec![Span::styled( + format!(" Model: {model}"), + Style::default().dim(), + )])); + lines.push(Line::from("")); + + for (idx, item) in items.iter().enumerate() { + let is_selected = idx == selected_idx; + let prefix = if is_selected { "› " } else { " " }; + let name_style = if is_selected { + Style::default().bold() + } else { + Style::default() + }; + lines.push(Line::from(vec![ + Span::styled( + prefix.to_string(), + if is_selected { + Style::default().cyan() + } else { + Style::default() + }, + ), + Span::styled(item.label.clone(), name_style), + ])); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(item.description.clone(), Style::default().dim()), + ])); + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + " ↑↓ Navigate Enter Select Esc Back", + Style::default().dim(), + )])); + + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(content_area, buf); + } + + fn render_base_url( + model: &str, + provider: ProviderWireApi, + input: &str, + default_url: &str, + cursor_pos: usize, + area: Rect, + buf: &mut Buffer, + ) { + if area.height < 3 { + return; + } + + let content_area = onboarding_content_area(area); + + let provider_name = match provider { + ProviderWireApi::AnthropicMessages => "Anthropic", + ProviderWireApi::OpenAIChatCompletions => "OpenAI Chat Completions", + ProviderWireApi::OpenAIResponses => "OpenAI Responses", + }; + + let mut lines: Vec> = Vec::new(); + + lines.push(Line::from(vec![Span::styled( + " Configure Provider", + Style::default().bold(), + )])); + lines.push(Line::from(vec![Span::styled( + format!(" Model: {model} ({provider_name})"), + Style::default().dim(), + )])); + lines.push(Line::from("")); + + // Step indicator + lines.push(Line::from(vec![Span::styled( + " Step 1/2: Base URL", + Style::default().cyan(), + )])); + lines.push(Line::from("")); + + // Base URL field + let display = if input.is_empty() { + String::new() + } else { + input.to_string() + }; + let byte_pos = display + .char_indices() + .nth(cursor_pos.min(display.chars().count())) + .map(|(i, _)| i) + .unwrap_or(display.len()); + let before_cursor = display[..byte_pos].to_string(); + lines.push(Line::from(vec![ + Span::styled(" ▸ ", Style::default().cyan()), + Span::styled(before_cursor, Style::default()), + Span::styled("▌", Style::default().cyan()), + ])); + + if !default_url.is_empty() { + lines.push(Line::from(vec![Span::styled( + format!(" Default: {default_url}"), + Style::default().dim(), + )])); + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + " Enter Continue · Esc Back", + Style::default().dim(), + )])); + + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(content_area, buf); + } + + fn render_api_key( + model: &str, + provider: ProviderWireApi, + input: &str, + cursor_pos: usize, + area: Rect, + buf: &mut Buffer, + ) { + if area.height < 3 { + return; + } + + let content_area = onboarding_content_area(area); + + let provider_name = match provider { + ProviderWireApi::AnthropicMessages => "Anthropic", + ProviderWireApi::OpenAIChatCompletions => "OpenAI Chat Completions", + ProviderWireApi::OpenAIResponses => "OpenAI Responses", + }; + + let mut lines: Vec> = Vec::new(); + + lines.push(Line::from(vec![Span::styled( + " Configure Provider", + Style::default().bold(), + )])); + lines.push(Line::from(vec![Span::styled( + format!(" Model: {model} ({provider_name})"), + Style::default().dim(), + )])); + lines.push(Line::from("")); + + // Step indicator + lines.push(Line::from(vec![Span::styled( + " Step 2/2: API Key", + Style::default().cyan(), + )])); + lines.push(Line::from("")); + + // API key input field (masked) + let masked_display = if input.is_empty() { + String::new() + } else { + "•".repeat(input.len()) + }; + let byte_pos = masked_display + .char_indices() + .nth(cursor_pos.min(masked_display.chars().count())) + .map(|(i, _)| i) + .unwrap_or(masked_display.len()); + let before_cursor = masked_display[..byte_pos].to_string(); + lines.push(Line::from(vec![ + Span::styled(" ▸ ", Style::default().cyan()), + Span::styled(before_cursor, Style::default()), + Span::styled("▌", Style::default().cyan()), + ])); + + lines.push(Line::from(vec![Span::styled( + " Leave empty and press Enter to skip", + Style::default().dim(), + )])); + + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + " Enter Validate · Esc Back", + Style::default().dim(), + )])); + + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(content_area, buf); + } + + fn render_validating( + model: &str, + provider: ProviderWireApi, + started_at: Instant, + animations_enabled: bool, + area: Rect, + buf: &mut Buffer, + ) { + if area.height < 3 { + return; + } + + let content_area = onboarding_content_area(area); + + let provider_name = match provider { + ProviderWireApi::AnthropicMessages => "Anthropic", + ProviderWireApi::OpenAIChatCompletions => "OpenAI Chat Completions", + ProviderWireApi::OpenAIResponses => "OpenAI Responses", + }; + let elapsed = started_at.elapsed().as_secs(); + let remaining = 20u64.saturating_sub(elapsed); + + let mut lines: Vec> = Vec::new(); + + lines.push(Line::from(vec![Span::styled( + " Validating...", + Style::default().bold(), + )])); + lines.push(Line::from(vec![Span::styled( + format!(" Model: {model} ({provider_name})"), + Style::default().dim(), + )])); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::raw(" "), + spinner(Some(started_at), animations_enabled), + Span::raw(" Connecting to API..."), + ])); + lines.push(Line::from(vec![Span::styled( + " Testing with prompt: \"Reply with OK only.\"".to_string(), + Style::default().dim(), + )])); + lines.push(Line::from(vec![Span::styled( + format!(" Timeout: {remaining}s remaining"), + Style::default().dim(), + )])); + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + " Esc Cancel", + Style::default().dim(), + )])); + + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(content_area, buf); + } + + fn render_validation_failed( + error_message: &str, + selected_action: usize, + area: Rect, + buf: &mut Buffer, + ) { + if area.height < 3 { + return; + } + + let content_area = onboarding_content_area(area); + + let actions = [ + "Retry with current settings", + "Edit settings", + "Choose different model", + ]; + + let mut lines: Vec> = vec![ + Line::from(vec![Span::styled( + " ✗ Validation Failed", + Style::default().bold().red(), + )]), + Line::from(""), + Line::from(vec![ + Span::raw(" "), + Span::styled(error_message.to_string(), Style::default().red()), + ]), + Line::from(""), + ]; + + for (idx, action) in actions.iter().enumerate() { + let is_selected = idx == selected_action; + let prefix = if is_selected { "› " } else { " " }; + let style = if is_selected { + Style::default().bold() + } else { + Style::default().dim() + }; + lines.push(Line::from(vec![ + Span::styled( + prefix.to_string(), + if is_selected { + Style::default().cyan() + } else { + Style::default() + }, + ), + Span::styled(action.to_string(), style), + ])); + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + " ↑↓ Navigate Enter Select Esc Exit onboarding", + Style::default().dim(), + )])); + + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(content_area, buf); + } +} + +impl BottomPaneView for OnboardingView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if matches!(key_event.kind, KeyEventKind::Release) { + return; + } + match &self.state { + OnboardingState::ModelSelection { .. } => self.model_selection_handle_key(key_event), + OnboardingState::CustomModelName { .. } => self.custom_model_name_handle_key(key_event), + OnboardingState::ProviderSelection { .. } => { + self.provider_selection_handle_key(key_event) + } + OnboardingState::BaseUrl { .. } => self.base_url_handle_key(key_event), + OnboardingState::ApiKey { .. } => self.api_key_handle_key(key_event), + OnboardingState::Validating { .. } => { + if key_event.code == KeyCode::Esc { + self.complete = true; + self.result = Some(OnboardingResult::Cancelled); + } + } + OnboardingState::ValidationFailed { .. } => { + self.validation_failed_handle_key(key_event); + } + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn view_id(&self) -> Option<&'static str> { + Some("onboarding") + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.complete = true; + self.result = Some(OnboardingResult::Cancelled); + CancellationEvent::Handled + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + true + } + + fn take_onboarding_result(&mut self) -> Option { + self.result.take() + } + + fn on_validation_succeeded(&mut self, reply_preview: String) { + self.on_validation_succeeded(reply_preview); + } + + fn on_validation_failed(&mut self, error_message: String) { + self.on_validation_failed(error_message); + } +} + +impl Renderable for OnboardingView { + fn desired_height(&self, width: u16) -> u16 { + let _ = width; + // Return a reasonable height that will be clamped by the layout + 20 + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + match &self.state { + OnboardingState::ModelSelection { + items, + state, + search_query, + filtered_indices, + } => { + Self::render_model_selection( + items, + state, + search_query, + filtered_indices, + area, + buf, + ); + } + OnboardingState::CustomModelName { input, cursor_pos } => { + Self::render_custom_model_name(input, *cursor_pos, area, buf); + } + OnboardingState::ProviderSelection { + model, + items, + selected_idx, + } => { + Self::render_provider_selection(model, items, *selected_idx, area, buf); + } + OnboardingState::BaseUrl { + model, + provider, + input, + default_url, + cursor_pos, + } => { + Self::render_base_url(model, *provider, input, default_url, *cursor_pos, area, buf); + } + OnboardingState::ApiKey { + model, + provider, + input, + cursor_pos, + .. + } => { + Self::render_api_key(model, *provider, input, *cursor_pos, area, buf); + } + OnboardingState::Validating { + model, + provider, + started_at, + .. + } => { + if self.animations_enabled { + self.frame_requester.schedule_frame_in(SPINNER_INTERVAL); + } + Self::render_validating( + model, + *provider, + *started_at, + self.animations_enabled, + area, + buf, + ); + } + OnboardingState::ValidationFailed { + error_message, + selected_action, + .. + } => { + Self::render_validation_failed(error_message, *selected_action, area, buf); + } + } + } + + fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> { + // Hide terminal cursor; we use ▌ as visual cursor indicator. + None + } +} diff --git a/crates/tui/src/chatwidget.rs b/crates/tui/src/chatwidget.rs index a24800f..1d827cf 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -951,9 +951,13 @@ impl ChatWidget { self.set_status_message("Query failed; see error above"); } WorkerEvent::ProviderValidationSucceeded { reply_preview } => { - if let Some(OnboardingStep::Validating { model, .. }) = self.onboarding_step.take() - { - self.update_session_request_model(model); + self.bottom_pane + .onboarding_on_validation_succeeded(reply_preview.clone()); + if !self.bottom_pane.is_onboarding_active() { + // Onboarding view completed, check for result + if let Some(result) = self.bottom_pane.take_onboarding_result() { + self.handle_onboarding_result(result); + } } self.add_to_history(history_cell::new_info_event( format!("Validation reply: {reply_preview}"), @@ -964,13 +968,8 @@ impl ChatWidget { self.set_status_message("Onboarding complete"); } WorkerEvent::ProviderValidationFailed { message } => { - if let Some(OnboardingStep::Validating { - model, base_url, .. - }) = self.onboarding_step.take() - { - self.onboarding_step = Some(OnboardingStep::ApiKey { model, base_url }); - self.set_onboarding_placeholder("API key"); - } + self.bottom_pane + .onboarding_on_validation_failed(message.clone()); self.busy = false; self.add_to_history(history_cell::new_error_event_with_hint( message, @@ -1116,9 +1115,11 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { - if self.onboarding_step.is_some() - && self.handle_onboarding_input(user_message.text.trim().to_string()) - { + // Check if the onboarding view completed with a validation request + if self.bottom_pane.is_onboarding_active() { + if let Some(result) = self.bottom_pane.take_onboarding_result() { + self.handle_onboarding_result(result); + } return; } if user_message.text.trim().is_empty() { @@ -1263,94 +1264,42 @@ impl ChatWidget { } } - // TODO: Now, the onboarding TUI is too simple and crude, should be a more designed, specifially designed for onboarding. fn begin_onboarding(&mut self) { - self.onboarding_step = Some(OnboardingStep::ModelName); - self.set_onboarding_placeholder("model name"); - let mut lines = vec![ - Line::from("Onboarding".bold()), - Line::from("Choose a model, then enter optional base URL and API key.".dim()), - ]; - for model in self.available_models.iter().take(12) { - let description = model.description.as_deref().unwrap_or_default(); - let suffix = if description.is_empty() { - String::new() - } else { - format!(" - {description}") - }; - lines.push(Line::from(format!(" {}{}", model.slug, suffix))); - } - lines.push(Line::from("Type a model slug or custom model name.").dim()); - self.add_to_history(PlainHistoryCell::new(lines)); - self.bottom_pane.set_allow_empty_submit(false); - self.set_status_message("Onboarding: enter model name"); + self.onboarding_step = None; + self.history.clear(); + self.next_history_flush_index = 0; + self.bottom_pane.open_onboarding(&self.available_models); + self.set_status_message("Onboarding"); } - fn handle_onboarding_input(&mut self, text: String) -> bool { - let Some(step) = self.onboarding_step.take() else { - return false; - }; - - match step { - OnboardingStep::ModelName => { - if text.is_empty() { - self.onboarding_step = Some(OnboardingStep::ModelName); - self.set_onboarding_placeholder("model name"); - self.set_status_message("Onboarding: enter model name"); - return true; - } - self.onboarding_step = Some(OnboardingStep::BaseUrl { - model: text.clone(), - }); - self.set_onboarding_placeholder("base URL"); - self.bottom_pane.set_allow_empty_submit(true); - self.add_to_history(history_cell::new_info_event( - format!("model: {text}"), - Some("onboarding".to_string()), - )); - self.set_status_message( - "Onboarding: enter base URL, or press Enter to use default", - ); - true - } - OnboardingStep::BaseUrl { model } => { - let base_url = if text.is_empty() { - None - } else if text.starts_with("http://") || text.starts_with("https://") { - Some(text) - } else { - self.onboarding_step = Some(OnboardingStep::BaseUrl { model }); - self.set_onboarding_placeholder("base URL"); - self.bottom_pane.set_allow_empty_submit(true); - self.add_to_history(history_cell::new_error_event( - "Base URL must start with http:// or https://".to_string(), - )); - self.set_status_message("Onboarding: enter base URL"); - return true; - }; - self.onboarding_step = Some(OnboardingStep::ApiKey { - model, - base_url: base_url.clone(), - }); - self.set_onboarding_placeholder("API key"); - self.bottom_pane.set_allow_empty_submit(true); + fn handle_onboarding_result(&mut self, result: crate::bottom_pane::OnboardingResult) { + use crate::bottom_pane::OnboardingResult; + match result { + OnboardingResult::ValidationSucceeded { + model, + provider: _, + base_url: _, + api_key: _, + } => { + self.update_session_request_model(model); self.add_to_history(history_cell::new_info_event( - format!("base url: {}", base_url.as_deref().unwrap_or("(default)")), - Some("onboarding".to_string()), + "Provider configured successfully".to_string(), + Some("onboarding complete".to_string()), )); - self.set_status_message("Onboarding: enter API key, or press Enter to skip"); - true + self.set_default_placeholder(); + self.set_status_message("Onboarding complete"); } - OnboardingStep::ApiKey { model, base_url } => { - let api_key = if text.is_empty() { None } else { Some(text) }; + OnboardingResult::Validate { + model, + provider: _, + base_url, + api_key, + } => { self.onboarding_step = Some(OnboardingStep::Validating { model: model.clone(), base_url: base_url.clone(), api_key: api_key.clone(), }); - self.bottom_pane - .set_placeholder_text("Onboarding: validating connection".to_string()); - self.bottom_pane.set_allow_empty_submit(false); let payload = serde_json::json!({ "model": model, "base_url": base_url, @@ -1360,22 +1309,14 @@ impl ChatWidget { .send(AppEvent::Command(AppCommand::RunUserShellCommand { command: format!("onboard {payload}"), })); - self.set_status_message("Onboarding: validating provider connection"); - true + self.set_status_message("Validating provider connection"); } - OnboardingStep::Validating { - model, - base_url, - api_key, - } => { - self.onboarding_step = Some(OnboardingStep::Validating { - model, - base_url, - api_key, - }); - self.set_status_message("Onboarding validation is already running"); - true + OnboardingResult::Cancelled => { + self.onboarding_step = None; + self.set_default_placeholder(); + self.set_status_message("Ready"); } + _ => {} } } diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index 3bb38d7..1408ae0 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -403,27 +403,35 @@ fn key_release_does_not_duplicate_text_input() { } #[test] -fn onboarding_updates_placeholder_text_for_each_step() { +fn onboarding_view_is_active_on_first_run() { let cwd = std::env::current_dir().expect("current directory is available"); let model = Model { slug: "test-model".to_string(), display_name: "Test Model".to_string(), ..Model::default() }; - let (mut widget, _app_event_rx) = onboarding_widget_with_model(model, cwd); - assert_eq!(widget.placeholder_text(), "Onboarding: enter model name"); + let (_widget, _app_event_rx) = onboarding_widget_with_model(model, cwd); + // Onboarding view is pushed onto the view stack on first run. + // The UI is now managed by the OnboardingView via the bottom pane view stack. +} - widget.submit_text("custom-model".to_string()); - assert_eq!(widget.placeholder_text(), "Onboarding: enter base URL"); +#[test] +fn onboarding_validation_succeeded_clears_active_state() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "anthropic-messages-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = onboarding_widget_with_model(model, cwd); - widget.submit_text("https://example.com".to_string()); - assert_eq!(widget.placeholder_text(), "Onboarding: enter API key"); + // Simulate validation success from the worker. + widget.handle_worker_event(crate::events::WorkerEvent::ProviderValidationSucceeded { + reply_preview: "OK".to_string(), + }); - widget.submit_text("secret".to_string()); - assert_eq!( - widget.placeholder_text(), - "Onboarding: validating connection" - ); + // After validation, placeholder should be reset to default. + assert_eq!(widget.placeholder_text(), "Ask Devo"); } #[test]