| 1 | //! Onboarding flow rendering and helpers. |
| 2 | |
| 3 | pub mod api_key; |
| 4 | pub mod language; |
| 5 | pub mod trust_directory; |
| 6 | pub mod welcome; |
| 7 | |
| 8 | use std::path::{Path, PathBuf}; |
| 9 | |
| 10 | use ratatui::{ |
| 11 | Frame, |
| 12 | layout::Rect, |
| 13 | style::{Modifier, Style}, |
| 14 | text::{Line, Span}, |
| 15 | widgets::{Block, Borders, Padding, Paragraph, Wrap}, |
| 16 | }; |
| 17 | |
| 18 | use crate::palette; |
| 19 | use crate::tui::app::{App, OnboardingState}; |
| 20 | |
| 21 | pub fn render(f: &mut Frame, area: Rect, app: &App) { |
| 22 | let block = Block::default().style(Style::default().bg(palette::DEEPSEEK_INK)); |
| 23 | f.render_widget(block, area); |
| 24 | |
| 25 | let content_width = 76.min(area.width.saturating_sub(4)); |
| 26 | let content_height = 20.min(area.height.saturating_sub(4)); |
| 27 | let content_area = Rect { |
| 28 | x: (area.width - content_width) / 2, |
| 29 | y: (area.height - content_height) / 2, |
| 30 | width: content_width, |
| 31 | height: content_height, |
| 32 | }; |
| 33 | |
| 34 | let lines = match app.onboarding { |
| 35 | OnboardingState::Welcome => welcome::lines(), |
| 36 | OnboardingState::Language => language::lines(app), |
| 37 | OnboardingState::ApiKey => api_key::lines(app), |
| 38 | OnboardingState::TrustDirectory => trust_directory::lines(app), |
| 39 | OnboardingState::Tips => tips_lines(), |
| 40 | OnboardingState::None => Vec::new(), |
| 41 | }; |
| 42 | |
| 43 | if !lines.is_empty() { |
| 44 | let mut panel = Block::default() |
| 45 | .title(Line::from(Span::styled( |
| 46 | " DeepSeek TUI ", |
| 47 | Style::default() |
| 48 | .fg(palette::DEEPSEEK_BLUE) |
| 49 | .add_modifier(Modifier::BOLD), |
| 50 | ))) |
| 51 | .borders(Borders::ALL) |
| 52 | .border_style(Style::default().fg(palette::BORDER_COLOR)) |
| 53 | .style(Style::default().bg(palette::DEEPSEEK_SLATE)) |
| 54 | .padding(Padding::new(2, 2, 1, 1)); |
| 55 | if !app.onboarding_workspace_trust_gate { |
| 56 | let (step, total) = onboarding_step(app); |
| 57 | panel = panel.title_bottom(Line::from(Span::styled( |
| 58 | format!(" Step {step}/{total} "), |
| 59 | Style::default() |
| 60 | .fg(palette::TEXT_MUTED) |
| 61 | .add_modifier(Modifier::BOLD), |
| 62 | ))); |
| 63 | } |
| 64 | let inner = panel.inner(content_area); |
| 65 | f.render_widget(panel, content_area); |
| 66 | let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); |
| 67 | f.render_widget(paragraph, inner); |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | fn onboarding_step(app: &App) -> (usize, usize) { |
| 72 | let needs_trust = !app.trust_mode && needs_trust(&app.workspace); |
| 73 | // Welcome + Language + Tips are always shown. |
| 74 | let mut total = 3; |
| 75 | if app.onboarding_needs_api_key { |
| 76 | total += 1; |
| 77 | } |
| 78 | if needs_trust { |
| 79 | total += 1; |
| 80 | } |
| 81 | |
| 82 | let step = match app.onboarding { |
| 83 | OnboardingState::Welcome => 1, |
| 84 | OnboardingState::Language => 2, |
| 85 | OnboardingState::ApiKey => 3, |
| 86 | OnboardingState::TrustDirectory => { |
| 87 | // Welcome (1) + Language (2) + optional ApiKey |
| 88 | if app.onboarding_needs_api_key { 4 } else { 3 } |
| 89 | } |
| 90 | OnboardingState::Tips => total, |
| 91 | OnboardingState::None => total, |
| 92 | }; |
| 93 | |
| 94 | (step, total) |
| 95 | } |
| 96 | |
| 97 | pub fn tips_lines() -> Vec<ratatui::text::Line<'static>> { |
| 98 | use ratatui::style::Modifier; |
| 99 | use ratatui::text::{Line, Span}; |
| 100 | |
| 101 | vec![ |
| 102 | Line::from(Span::styled( |
| 103 | "Start Simple", |
| 104 | Style::default() |
| 105 | .fg(palette::DEEPSEEK_SKY) |
| 106 | .add_modifier(Modifier::BOLD), |
| 107 | )), |
| 108 | Line::from(""), |
| 109 | Line::from(Span::raw( |
| 110 | "Write the task in plain language. Use /help or Ctrl+K when you want a command.", |
| 111 | )), |
| 112 | Line::from(Span::raw( |
| 113 | "The bottom composer is multi-line: Enter sends, Alt+Enter or Ctrl+J adds a new line.", |
| 114 | )), |
| 115 | Line::from(Span::raw( |
| 116 | "Switch modes only when the job changes: Plan for review-first work, Agent for execution, YOLO when you want auto-approval.", |
| 117 | )), |
| 118 | Line::from(Span::raw( |
| 119 | "Ctrl+R resumes earlier sessions, and Esc backs out of the current draft or overlay.", |
| 120 | )), |
| 121 | Line::from(vec![ |
| 122 | Span::styled("Press ", Style::default().fg(palette::TEXT_MUTED)), |
| 123 | Span::styled( |
| 124 | "Enter", |
| 125 | Style::default() |
| 126 | .fg(palette::TEXT_PRIMARY) |
| 127 | .add_modifier(Modifier::BOLD), |
| 128 | ), |
| 129 | Span::styled( |
| 130 | " to open the workspace", |
| 131 | Style::default().fg(palette::TEXT_MUTED), |
| 132 | ), |
| 133 | ]), |
| 134 | ] |
| 135 | } |
| 136 | |
| 137 | pub fn default_marker_path() -> Option<PathBuf> { |
| 138 | dirs::home_dir().map(|home| home.join(".deepseek").join(".onboarded")) |
| 139 | } |
| 140 | |
| 141 | pub fn is_onboarded() -> bool { |
| 142 | default_marker_path().is_some_and(|path| path.exists()) |
| 143 | } |
| 144 | |
| 145 | pub fn mark_onboarded() -> std::io::Result<PathBuf> { |
| 146 | let path = default_marker_path().ok_or_else(|| { |
| 147 | std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory not found") |
| 148 | })?; |
| 149 | if let Some(parent) = path.parent() { |
| 150 | std::fs::create_dir_all(parent)?; |
| 151 | } |
| 152 | std::fs::write(&path, "")?; |
| 153 | Ok(path) |
| 154 | } |
| 155 | |
| 156 | pub fn needs_trust(workspace: &Path) -> bool { |
| 157 | if crate::config::is_workspace_trusted(workspace) { |
| 158 | return false; |
| 159 | } |
| 160 | |
| 161 | let markers = [ |
| 162 | workspace.join(".deepseek").join("trusted"), |
| 163 | workspace.join(".deepseek").join("trust.json"), |
| 164 | ]; |
| 165 | !markers.iter().any(|path| path.exists()) |
| 166 | } |
| 167 | |
| 168 | pub fn mark_trusted(workspace: &Path) -> anyhow::Result<PathBuf> { |
| 169 | crate::config::save_workspace_trust(workspace) |
| 170 | } |
| 171 |