pub mod api_key;
pub mod language;
pub mod trust_directory;
pub mod welcome;
use std::path::{Path, PathBuf};
use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Padding, Paragraph, Wrap},
};
use crate::palette;
use crate::tui::app::{App, OnboardingState};
pub fn render(f: &mut Frame, area: Rect, app: &App) {
let block = Block::default().style(Style::default().bg(palette::DEEPSEEK_INK));
f.render_widget(block, area);
let content_width = 76.min(area.width.saturating_sub(4));
let content_height = 20.min(area.height.saturating_sub(4));
let content_area = Rect {
x: (area.width - content_width) / 2,
y: (area.height - content_height) / 2,
width: content_width,
height: content_height,
};
let lines = match app.onboarding {
OnboardingState::Welcome => welcome::lines(),
OnboardingState::Language => language::lines(app),
OnboardingState::ApiKey => api_key::lines(app),
OnboardingState::TrustDirectory => trust_directory::lines(app),
OnboardingState::Tips => tips_lines(app),
OnboardingState::None => Vec::new(),
};
if !lines.is_empty() {
let mut panel = Block::default()
.title(Line::from(Span::styled(
" DeepSeek TUI ",
Style::default()
.fg(palette::DEEPSEEK_BLUE)
.add_modifier(Modifier::BOLD),
)))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::DEEPSEEK_SLATE))
.padding(Padding::new(2, 2, 1, 1));
if !app.onboarding_workspace_trust_gate {
let (step, total) = onboarding_step(app);
panel = panel.title_bottom(Line::from(Span::styled(
format!(" Step {step}/{total} "),
Style::default()
.fg(palette::TEXT_MUTED)
.add_modifier(Modifier::BOLD),
)));
}
let inner = panel.inner(content_area);
f.render_widget(panel, content_area);
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
f.render_widget(paragraph, inner);
}
}
fn onboarding_step(app: &App) -> (usize, usize) {
let needs_trust = !app.trust_mode && needs_trust(&app.workspace);
let mut total = 3;
if app.onboarding_needs_api_key {
total += 1;
}
if needs_trust {
total += 1;
}
let step = match app.onboarding {
OnboardingState::Welcome => 1,
OnboardingState::Language => 2,
OnboardingState::ApiKey => 3,
OnboardingState::TrustDirectory => {
if app.onboarding_needs_api_key { 4 } else { 3 }
}
OnboardingState::Tips => total,
OnboardingState::None => total,
};
(step, total)
}
pub fn tips_lines(app: &App) -> Vec<ratatui::text::Line<'static>> {
use crate::localization::MessageId;
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
vec![
Line::from(Span::styled(
app.tr(MessageId::OnboardTipsTitle).to_string(),
Style::default()
.fg(palette::DEEPSEEK_SKY)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::raw(app.tr(MessageId::OnboardTipsLine1).to_string())),
Line::from(Span::raw(app.tr(MessageId::OnboardTipsLine2).to_string())),
Line::from(Span::raw(app.tr(MessageId::OnboardTipsLine3).to_string())),
Line::from(Span::raw(app.tr(MessageId::OnboardTipsLine4).to_string())),
Line::from(vec![
Span::styled(
app.tr(MessageId::OnboardTipsFooterEnter).to_string(),
Style::default()
.fg(palette::TEXT_PRIMARY)
.add_modifier(Modifier::BOLD),
),
Span::styled(
app.tr(MessageId::OnboardTipsFooterAction).to_string(),
Style::default().fg(palette::TEXT_MUTED),
),
]),
]
}
pub fn default_marker_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".deepseek").join(".onboarded"))
}
pub fn is_onboarded() -> bool {
default_marker_path().is_some_and(|path| path.exists())
}
pub fn mark_onboarded() -> std::io::Result<PathBuf> {
let path = default_marker_path().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory not found")
})?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, "")?;
Ok(path)
}
pub fn needs_trust(workspace: &Path) -> bool {
if crate::config::is_workspace_trusted(workspace) {
return false;
}
let markers = [
workspace.join(".deepseek").join("trusted"),
workspace.join(".deepseek").join("trust.json"),
];
!markers.iter().any(|path| path.exists())
}
pub fn mark_trusted(workspace: &Path) -> anyhow::Result<PathBuf> {
crate::config::save_workspace_trust(workspace)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApiKeyValidation {
Accept { warning: Option<String> },
Reject(String),
}
#[must_use]
pub fn validate_api_key_for_onboarding(api_key: &str) -> ApiKeyValidation {
let trimmed = api_key.trim();
if trimmed.is_empty() {
return ApiKeyValidation::Reject("API key cannot be empty.".to_string());
}
if trimmed.contains(char::is_whitespace) {
return ApiKeyValidation::Reject(
"API key appears malformed (contains whitespace).".to_string(),
);
}
if trimmed.len() < 16 {
return ApiKeyValidation::Accept {
warning: Some(
"API key looks short. Double-check it, but unusual formats are allowed."
.to_string(),
),
};
}
if !trimmed.contains('-') {
return ApiKeyValidation::Accept {
warning: Some(
"API key format looks unusual. Check that the full key was copied.".to_string(),
),
};
}
ApiKeyValidation::Accept { warning: None }
}
pub fn advance_onboarding_from_welcome(app: &mut App) {
app.status_message = None;
app.onboarding = OnboardingState::Language;
}
pub fn advance_onboarding_after_language(app: &mut App) {
app.status_message = None;
if app.onboarding_needs_api_key {
app.onboarding = OnboardingState::ApiKey;
} else if !app.trust_mode && needs_trust(&app.workspace) {
app.onboarding = OnboardingState::TrustDirectory;
} else {
app.onboarding = OnboardingState::Tips;
}
}
pub fn sync_api_key_validation_status(app: &mut App, show_empty_error: bool) {
if app.api_key_input.trim().is_empty() && !show_empty_error {
app.status_message = None;
return;
}
match validate_api_key_for_onboarding(&app.api_key_input) {
ApiKeyValidation::Accept { warning } => {
app.status_message = warning;
}
ApiKeyValidation::Reject(message) => {
app.status_message = Some(message);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_rejects_empty_or_whitespace() {
assert!(matches!(
validate_api_key_for_onboarding(""),
ApiKeyValidation::Reject(_)
));
assert!(matches!(
validate_api_key_for_onboarding(" "),
ApiKeyValidation::Reject(_)
));
assert!(matches!(
validate_api_key_for_onboarding("sk live abc"),
ApiKeyValidation::Reject(_)
));
}
#[test]
fn validate_warns_on_short_or_no_hyphen_keys_but_accepts() {
match validate_api_key_for_onboarding("abc123") {
ApiKeyValidation::Accept { warning: Some(_) } => {}
_ => panic!("expected accept-with-warning"),
}
match validate_api_key_for_onboarding("abcdefghijklmnop") {
ApiKeyValidation::Accept { warning: Some(_) } => {}
_ => panic!("expected accept-with-warning"),
}
}
#[test]
fn validate_accepts_well_formed_key() {
assert_eq!(
validate_api_key_for_onboarding("sk-1234567890abcdef"),
ApiKeyValidation::Accept { warning: None }
);
}
}