feat: Complete LLMX v0.1.0 - Rebrand from Codex with LiteLLM Integration
This release represents a comprehensive transformation of the codebase from Codex to LLMX, enhanced with LiteLLM integration to support 100+ LLM providers through a unified API. ## Major Changes ### Phase 1: Repository & Infrastructure Setup - Established new repository structure and branching strategy - Created comprehensive project documentation (CLAUDE.md, LITELLM-SETUP.md) - Set up development environment and tooling configuration ### Phase 2: Rust Workspace Transformation - Renamed all Rust crates from `codex-*` to `llmx-*` (30+ crates) - Updated package names, binary names, and workspace members - Renamed core modules: codex.rs → llmx.rs, codex_delegate.rs → llmx_delegate.rs - Updated all internal references, imports, and type names - Renamed directories: codex-rs/ → llmx-rs/, codex-backend-openapi-models/ → llmx-backend-openapi-models/ - Fixed all Rust compilation errors after mass rename ### Phase 3: LiteLLM Integration - Integrated LiteLLM for multi-provider LLM support (Anthropic, OpenAI, Azure, Google AI, AWS Bedrock, etc.) - Implemented OpenAI-compatible Chat Completions API support - Added model family detection and provider-specific handling - Updated authentication to support LiteLLM API keys - Renamed environment variables: OPENAI_BASE_URL → LLMX_BASE_URL - Added LLMX_API_KEY for unified authentication - Enhanced error handling for Chat Completions API responses - Implemented fallback mechanisms between Responses API and Chat Completions API ### Phase 4: TypeScript/Node.js Components - Renamed npm package: @codex/codex-cli → @valknar/llmx - Updated TypeScript SDK to use new LLMX APIs and endpoints - Fixed all TypeScript compilation and linting errors - Updated SDK tests to support both API backends - Enhanced mock server to handle multiple API formats - Updated build scripts for cross-platform packaging ### Phase 5: Configuration & Documentation - Updated all configuration files to use LLMX naming - Rewrote README and documentation for LLMX branding - Updated config paths: ~/.codex/ → ~/.llmx/ - Added comprehensive LiteLLM setup guide - Updated all user-facing strings and help text - Created release plan and migration documentation ### Phase 6: Testing & Validation - Fixed all Rust tests for new naming scheme - Updated snapshot tests in TUI (36 frame files) - Fixed authentication storage tests - Updated Chat Completions payload and SSE tests - Fixed SDK tests for new API endpoints - Ensured compatibility with Claude Sonnet 4.5 model - Fixed test environment variables (LLMX_API_KEY, LLMX_BASE_URL) ### Phase 7: Build & Release Pipeline - Updated GitHub Actions workflows for LLMX binary names - Fixed rust-release.yml to reference llmx-rs/ instead of codex-rs/ - Updated CI/CD pipelines for new package names - Made Apple code signing optional in release workflow - Enhanced npm packaging resilience for partial platform builds - Added Windows sandbox support to workspace - Updated dotslash configuration for new binary names ### Phase 8: Final Polish - Renamed all assets (.github images, labels, templates) - Updated VSCode and DevContainer configurations - Fixed all clippy warnings and formatting issues - Applied cargo fmt and prettier formatting across codebase - Updated issue templates and pull request templates - Fixed all remaining UI text references ## Technical Details **Breaking Changes:** - Binary name changed from `codex` to `llmx` - Config directory changed from `~/.codex/` to `~/.llmx/` - Environment variables renamed (CODEX_* → LLMX_*) - npm package renamed to `@valknar/llmx` **New Features:** - Support for 100+ LLM providers via LiteLLM - Unified authentication with LLMX_API_KEY - Enhanced model provider detection and handling - Improved error handling and fallback mechanisms **Files Changed:** - 578 files modified across Rust, TypeScript, and documentation - 30+ Rust crates renamed and updated - Complete rebrand of UI, CLI, and documentation - All tests updated and passing **Dependencies:** - Updated Cargo.lock with new package names - Updated npm dependencies in llmx-cli - Enhanced OpenAPI models for LLMX backend This release establishes LLMX as a standalone project with comprehensive LiteLLM integration, maintaining full backward compatibility with existing functionality while opening support for a wide ecosystem of LLM providers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Sebastian Krüger <support@pivoine.art>
This commit is contained in:
703
llmx-rs/tui/src/onboarding/auth.rs
Normal file
703
llmx-rs/tui/src/onboarding/auth.rs
Normal file
@@ -0,0 +1,703 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use llmx_core::AuthManager;
|
||||
use llmx_core::auth::AuthCredentialsStoreMode;
|
||||
use llmx_core::auth::CLIENT_ID;
|
||||
use llmx_core::auth::login_with_api_key;
|
||||
use llmx_core::auth::read_openai_api_key_from_env;
|
||||
use llmx_login::ServerOptions;
|
||||
use llmx_login::ShutdownHandle;
|
||||
use llmx_login::run_login_server;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Widget;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::BorderType;
|
||||
use ratatui::widgets::Borders;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
use llmx_app_server_protocol::AuthMode;
|
||||
use llmx_protocol::config_types::ForcedLoginMethod;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use crate::LoginStatus;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
use crate::tui::FrameRequester;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::onboarding_screen::StepState;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum SignInState {
|
||||
PickMode,
|
||||
ChatGptContinueInBrowser(ContinueInBrowserState),
|
||||
ChatGptSuccessMessage,
|
||||
ChatGptSuccess,
|
||||
ApiKeyEntry(ApiKeyInputState),
|
||||
ApiKeyConfigured,
|
||||
}
|
||||
|
||||
const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled.";
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct ApiKeyInputState {
|
||||
value: String,
|
||||
prepopulated_from_env: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
/// Used to manage the lifecycle of SpawnedLogin and ensure it gets cleaned up.
|
||||
pub(crate) struct ContinueInBrowserState {
|
||||
auth_url: String,
|
||||
shutdown_flag: Option<ShutdownHandle>,
|
||||
}
|
||||
|
||||
impl Drop for ContinueInBrowserState {
|
||||
fn drop(&mut self) {
|
||||
if let Some(handle) = &self.shutdown_flag {
|
||||
handle.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyboardHandler for AuthModeWidget {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if self.handle_api_key_entry_key_event(&key_event) {
|
||||
return;
|
||||
}
|
||||
|
||||
match key_event.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.is_chatgpt_login_allowed() {
|
||||
self.highlighted_mode = AuthMode::ChatGPT;
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if self.is_api_login_allowed() {
|
||||
self.highlighted_mode = AuthMode::ApiKey;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('1') => {
|
||||
if self.is_chatgpt_login_allowed() {
|
||||
self.start_chatgpt_login();
|
||||
}
|
||||
}
|
||||
KeyCode::Char('2') => {
|
||||
if self.is_api_login_allowed() {
|
||||
self.start_api_key_entry();
|
||||
} else {
|
||||
self.disallow_api_login();
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
|
||||
match sign_in_state {
|
||||
SignInState::PickMode => match self.highlighted_mode {
|
||||
AuthMode::ChatGPT if self.is_chatgpt_login_allowed() => {
|
||||
self.start_chatgpt_login();
|
||||
}
|
||||
AuthMode::ApiKey if self.is_api_login_allowed() => {
|
||||
self.start_api_key_entry();
|
||||
}
|
||||
AuthMode::ChatGPT => {}
|
||||
AuthMode::ApiKey => {
|
||||
self.disallow_api_login();
|
||||
}
|
||||
},
|
||||
SignInState::ChatGptSuccessMessage => {
|
||||
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
tracing::info!("Esc pressed");
|
||||
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
|
||||
if matches!(sign_in_state, SignInState::ChatGptContinueInBrowser(_)) {
|
||||
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_paste(&mut self, pasted: String) {
|
||||
let _ = self.handle_api_key_entry_paste(pasted);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct AuthModeWidget {
|
||||
pub request_frame: FrameRequester,
|
||||
pub highlighted_mode: AuthMode,
|
||||
pub error: Option<String>,
|
||||
pub sign_in_state: Arc<RwLock<SignInState>>,
|
||||
pub llmx_home: PathBuf,
|
||||
pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
pub login_status: LoginStatus,
|
||||
pub auth_manager: Arc<AuthManager>,
|
||||
pub forced_chatgpt_workspace_id: Option<String>,
|
||||
pub forced_login_method: Option<ForcedLoginMethod>,
|
||||
}
|
||||
|
||||
impl AuthModeWidget {
|
||||
fn is_api_login_allowed(&self) -> bool {
|
||||
!matches!(self.forced_login_method, Some(ForcedLoginMethod::Chatgpt))
|
||||
}
|
||||
|
||||
fn is_chatgpt_login_allowed(&self) -> bool {
|
||||
!matches!(self.forced_login_method, Some(ForcedLoginMethod::Api))
|
||||
}
|
||||
|
||||
fn disallow_api_login(&mut self) {
|
||||
self.highlighted_mode = AuthMode::ChatGPT;
|
||||
self.error = Some(API_KEY_DISABLED_MESSAGE.to_string());
|
||||
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
|
||||
fn render_pick_mode(&self, area: Rect, buf: &mut Buffer) {
|
||||
let mut lines: Vec<Line> = vec![
|
||||
Line::from(vec![
|
||||
" ".into(),
|
||||
"Sign in with ChatGPT to use LLMX as part of your paid plan".into(),
|
||||
]),
|
||||
Line::from(vec![
|
||||
" ".into(),
|
||||
"or connect an API key for usage-based billing".into(),
|
||||
]),
|
||||
"".into(),
|
||||
];
|
||||
|
||||
let create_mode_item = |idx: usize,
|
||||
selected_mode: AuthMode,
|
||||
text: &str,
|
||||
description: &str|
|
||||
-> Vec<Line<'static>> {
|
||||
let is_selected = self.highlighted_mode == selected_mode;
|
||||
let caret = if is_selected { ">" } else { " " };
|
||||
|
||||
let line1 = if is_selected {
|
||||
Line::from(vec![
|
||||
format!("{} {}. ", caret, idx + 1).cyan().dim(),
|
||||
text.to_string().cyan(),
|
||||
])
|
||||
} else {
|
||||
format!(" {}. {text}", idx + 1).into()
|
||||
};
|
||||
|
||||
let line2 = if is_selected {
|
||||
Line::from(format!(" {description}"))
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::DIM)
|
||||
} else {
|
||||
Line::from(format!(" {description}"))
|
||||
.style(Style::default().add_modifier(Modifier::DIM))
|
||||
};
|
||||
|
||||
vec![line1, line2]
|
||||
};
|
||||
|
||||
let chatgpt_description = if self.is_chatgpt_login_allowed() {
|
||||
"Usage included with Plus, Pro, and Team plans"
|
||||
} else {
|
||||
"ChatGPT login is disabled"
|
||||
};
|
||||
lines.extend(create_mode_item(
|
||||
0,
|
||||
AuthMode::ChatGPT,
|
||||
"Sign in with ChatGPT",
|
||||
chatgpt_description,
|
||||
));
|
||||
lines.push("".into());
|
||||
if self.is_api_login_allowed() {
|
||||
lines.extend(create_mode_item(
|
||||
1,
|
||||
AuthMode::ApiKey,
|
||||
"Provide your own API key",
|
||||
"Pay for what you use",
|
||||
));
|
||||
lines.push("".into());
|
||||
} else {
|
||||
lines.push(
|
||||
" API key login is disabled by this workspace. Sign in with ChatGPT to continue."
|
||||
.dim()
|
||||
.into(),
|
||||
);
|
||||
lines.push("".into());
|
||||
}
|
||||
lines.push(
|
||||
// AE: Following styles.md, this should probably be Cyan because it's a user input tip.
|
||||
// But leaving this for a future cleanup.
|
||||
" Press Enter to continue".dim().into(),
|
||||
);
|
||||
if let Some(err) = &self.error {
|
||||
lines.push("".into());
|
||||
lines.push(err.as_str().red().into());
|
||||
}
|
||||
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) {
|
||||
let mut spans = vec![" ".into()];
|
||||
// Schedule a follow-up frame to keep the shimmer animation going.
|
||||
self.request_frame
|
||||
.schedule_frame_in(std::time::Duration::from_millis(100));
|
||||
spans.extend(shimmer_spans("Finish signing in via your browser"));
|
||||
let mut lines = vec![spans.into(), "".into()];
|
||||
|
||||
let sign_in_state = self.sign_in_state.read().unwrap();
|
||||
if let SignInState::ChatGptContinueInBrowser(state) = &*sign_in_state
|
||||
&& !state.auth_url.is_empty()
|
||||
{
|
||||
lines.push(" If the link doesn't open automatically, open the following link to authenticate:".into());
|
||||
lines.push("".into());
|
||||
lines.push(Line::from(state.auth_url.as_str().cyan().underlined()));
|
||||
lines.push("".into());
|
||||
}
|
||||
|
||||
lines.push(" Press Esc to cancel".dim().into());
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
fn render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) {
|
||||
let lines = vec![
|
||||
"✓ Signed in with your ChatGPT account".fg(Color::Green).into(),
|
||||
"".into(),
|
||||
" Before you start:".into(),
|
||||
"".into(),
|
||||
" Decide how much autonomy you want to grant LLMX".into(),
|
||||
Line::from(vec![
|
||||
" For more details see the ".into(),
|
||||
"\u{1b}]8;;https://github.com/valknar/llmx\u{7}LLMX docs\u{1b}]8;;\u{7}".underlined(),
|
||||
])
|
||||
.dim(),
|
||||
"".into(),
|
||||
" LLMX can make mistakes".into(),
|
||||
" Review the code it writes and commands it runs".dim().into(),
|
||||
"".into(),
|
||||
" Powered by your ChatGPT account".into(),
|
||||
Line::from(vec![
|
||||
" Uses your plan's rate limits and ".into(),
|
||||
"\u{1b}]8;;https://chatgpt.com/#settings\u{7}training data preferences\u{1b}]8;;\u{7}".underlined(),
|
||||
])
|
||||
.dim(),
|
||||
"".into(),
|
||||
" Press Enter to continue".fg(Color::Cyan).into(),
|
||||
];
|
||||
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
fn render_chatgpt_success(&self, area: Rect, buf: &mut Buffer) {
|
||||
let lines = vec![
|
||||
"✓ Signed in with your ChatGPT account"
|
||||
.fg(Color::Green)
|
||||
.into(),
|
||||
];
|
||||
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
fn render_api_key_configured(&self, area: Rect, buf: &mut Buffer) {
|
||||
let lines = vec![
|
||||
"✓ API key configured".fg(Color::Green).into(),
|
||||
"".into(),
|
||||
" LLMX will use usage-based billing with your API key.".into(),
|
||||
];
|
||||
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
fn render_api_key_entry(&self, area: Rect, buf: &mut Buffer, state: &ApiKeyInputState) {
|
||||
let [intro_area, input_area, footer_area] = Layout::vertical([
|
||||
Constraint::Min(4),
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(2),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
let mut intro_lines: Vec<Line> = vec![
|
||||
Line::from(vec![
|
||||
"> ".into(),
|
||||
"Use your own OpenAI API key for usage-based billing".bold(),
|
||||
]),
|
||||
"".into(),
|
||||
" Paste or type your API key below. It will be stored locally in auth.json.".into(),
|
||||
"".into(),
|
||||
];
|
||||
if state.prepopulated_from_env {
|
||||
intro_lines.push(" Detected OPENAI_API_KEY environment variable.".into());
|
||||
intro_lines.push(
|
||||
" Paste a different key if you prefer to use another account."
|
||||
.dim()
|
||||
.into(),
|
||||
);
|
||||
intro_lines.push("".into());
|
||||
}
|
||||
Paragraph::new(intro_lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(intro_area, buf);
|
||||
|
||||
let content_line: Line = if state.value.is_empty() {
|
||||
vec!["Paste or type your API key".dim()].into()
|
||||
} else {
|
||||
Line::from(state.value.clone())
|
||||
};
|
||||
Paragraph::new(content_line)
|
||||
.wrap(Wrap { trim: false })
|
||||
.block(
|
||||
Block::default()
|
||||
.title("API key")
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(Color::Cyan)),
|
||||
)
|
||||
.render(input_area, buf);
|
||||
|
||||
let mut footer_lines: Vec<Line> = vec![
|
||||
" Press Enter to save".dim().into(),
|
||||
" Press Esc to go back".dim().into(),
|
||||
];
|
||||
if let Some(error) = &self.error {
|
||||
footer_lines.push("".into());
|
||||
footer_lines.push(error.as_str().red().into());
|
||||
}
|
||||
Paragraph::new(footer_lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(footer_area, buf);
|
||||
}
|
||||
|
||||
fn handle_api_key_entry_key_event(&mut self, key_event: &KeyEvent) -> bool {
|
||||
let mut should_save: Option<String> = None;
|
||||
let mut should_request_frame = false;
|
||||
|
||||
{
|
||||
let mut guard = self.sign_in_state.write().unwrap();
|
||||
if let SignInState::ApiKeyEntry(state) = &mut *guard {
|
||||
match key_event.code {
|
||||
KeyCode::Esc => {
|
||||
*guard = SignInState::PickMode;
|
||||
self.error = None;
|
||||
should_request_frame = true;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let trimmed = state.value.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
self.error = Some("API key cannot be empty".to_string());
|
||||
should_request_frame = true;
|
||||
} else {
|
||||
should_save = Some(trimmed);
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if state.prepopulated_from_env {
|
||||
state.value.clear();
|
||||
state.prepopulated_from_env = false;
|
||||
} else {
|
||||
state.value.pop();
|
||||
}
|
||||
self.error = None;
|
||||
should_request_frame = true;
|
||||
}
|
||||
KeyCode::Char(c)
|
||||
if key_event.kind == KeyEventKind::Press
|
||||
&& !key_event.modifiers.contains(KeyModifiers::SUPER)
|
||||
&& !key_event.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !key_event.modifiers.contains(KeyModifiers::ALT) =>
|
||||
{
|
||||
if state.prepopulated_from_env {
|
||||
state.value.clear();
|
||||
state.prepopulated_from_env = false;
|
||||
}
|
||||
state.value.push(c);
|
||||
self.error = None;
|
||||
should_request_frame = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// handled; let guard drop before potential save
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(api_key) = should_save {
|
||||
self.save_api_key(api_key);
|
||||
} else if should_request_frame {
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn handle_api_key_entry_paste(&mut self, pasted: String) -> bool {
|
||||
let trimmed = pasted.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut guard = self.sign_in_state.write().unwrap();
|
||||
if let SignInState::ApiKeyEntry(state) = &mut *guard {
|
||||
if state.prepopulated_from_env {
|
||||
state.value = trimmed.to_string();
|
||||
state.prepopulated_from_env = false;
|
||||
} else {
|
||||
state.value.push_str(trimmed);
|
||||
}
|
||||
self.error = None;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
drop(guard);
|
||||
self.request_frame.schedule_frame();
|
||||
true
|
||||
}
|
||||
|
||||
fn start_api_key_entry(&mut self) {
|
||||
if !self.is_api_login_allowed() {
|
||||
self.disallow_api_login();
|
||||
return;
|
||||
}
|
||||
self.error = None;
|
||||
let prefill_from_env = read_openai_api_key_from_env();
|
||||
let mut guard = self.sign_in_state.write().unwrap();
|
||||
match &mut *guard {
|
||||
SignInState::ApiKeyEntry(state) => {
|
||||
if state.value.is_empty() {
|
||||
if let Some(prefill) = prefill_from_env {
|
||||
state.value = prefill;
|
||||
state.prepopulated_from_env = true;
|
||||
} else {
|
||||
state.prepopulated_from_env = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
*guard = SignInState::ApiKeyEntry(ApiKeyInputState {
|
||||
value: prefill_from_env.clone().unwrap_or_default(),
|
||||
prepopulated_from_env: prefill_from_env.is_some(),
|
||||
});
|
||||
}
|
||||
}
|
||||
drop(guard);
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
|
||||
fn save_api_key(&mut self, api_key: String) {
|
||||
if !self.is_api_login_allowed() {
|
||||
self.disallow_api_login();
|
||||
return;
|
||||
}
|
||||
match login_with_api_key(
|
||||
&self.llmx_home,
|
||||
&api_key,
|
||||
self.cli_auth_credentials_store_mode,
|
||||
) {
|
||||
Ok(()) => {
|
||||
self.error = None;
|
||||
self.login_status = LoginStatus::AuthMode(AuthMode::ApiKey);
|
||||
self.auth_manager.reload();
|
||||
*self.sign_in_state.write().unwrap() = SignInState::ApiKeyConfigured;
|
||||
}
|
||||
Err(err) => {
|
||||
self.error = Some(format!("Failed to save API key: {err}"));
|
||||
let mut guard = self.sign_in_state.write().unwrap();
|
||||
if let SignInState::ApiKeyEntry(existing) = &mut *guard {
|
||||
if existing.value.is_empty() {
|
||||
existing.value.push_str(&api_key);
|
||||
}
|
||||
existing.prepopulated_from_env = false;
|
||||
} else {
|
||||
*guard = SignInState::ApiKeyEntry(ApiKeyInputState {
|
||||
value: api_key,
|
||||
prepopulated_from_env: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
|
||||
fn start_chatgpt_login(&mut self) {
|
||||
// If we're already authenticated with ChatGPT, don't start a new login –
|
||||
// just proceed to the success message flow.
|
||||
if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) {
|
||||
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
|
||||
self.request_frame.schedule_frame();
|
||||
return;
|
||||
}
|
||||
|
||||
self.error = None;
|
||||
let opts = ServerOptions::new(
|
||||
self.llmx_home.clone(),
|
||||
CLIENT_ID.to_string(),
|
||||
self.forced_chatgpt_workspace_id.clone(),
|
||||
self.cli_auth_credentials_store_mode,
|
||||
);
|
||||
match run_login_server(opts) {
|
||||
Ok(child) => {
|
||||
let sign_in_state = self.sign_in_state.clone();
|
||||
let request_frame = self.request_frame.clone();
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
let auth_url = child.auth_url.clone();
|
||||
{
|
||||
*sign_in_state.write().unwrap() =
|
||||
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
|
||||
auth_url,
|
||||
shutdown_flag: Some(child.cancel_handle()),
|
||||
});
|
||||
}
|
||||
request_frame.schedule_frame();
|
||||
let r = child.block_until_done().await;
|
||||
match r {
|
||||
Ok(()) => {
|
||||
// Force the auth manager to reload the new auth information.
|
||||
auth_manager.reload();
|
||||
|
||||
*sign_in_state.write().unwrap() = SignInState::ChatGptSuccessMessage;
|
||||
request_frame.schedule_frame();
|
||||
}
|
||||
_ => {
|
||||
*sign_in_state.write().unwrap() = SignInState::PickMode;
|
||||
// self.error = Some(e.to_string());
|
||||
request_frame.schedule_frame();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
|
||||
self.error = Some(e.to_string());
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StepStateProvider for AuthModeWidget {
|
||||
fn get_step_state(&self) -> StepState {
|
||||
let sign_in_state = self.sign_in_state.read().unwrap();
|
||||
match &*sign_in_state {
|
||||
SignInState::PickMode
|
||||
| SignInState::ApiKeyEntry(_)
|
||||
| SignInState::ChatGptContinueInBrowser(_)
|
||||
| SignInState::ChatGptSuccessMessage => StepState::InProgress,
|
||||
SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for AuthModeWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let sign_in_state = self.sign_in_state.read().unwrap();
|
||||
match &*sign_in_state {
|
||||
SignInState::PickMode => {
|
||||
self.render_pick_mode(area, buf);
|
||||
}
|
||||
SignInState::ChatGptContinueInBrowser(_) => {
|
||||
self.render_continue_in_browser(area, buf);
|
||||
}
|
||||
SignInState::ChatGptSuccessMessage => {
|
||||
self.render_chatgpt_success_message(area, buf);
|
||||
}
|
||||
SignInState::ChatGptSuccess => {
|
||||
self.render_chatgpt_success(area, buf);
|
||||
}
|
||||
SignInState::ApiKeyEntry(state) => {
|
||||
self.render_api_key_entry(area, buf, state);
|
||||
}
|
||||
SignInState::ApiKeyConfigured => {
|
||||
self.render_api_key_configured(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use llmx_core::auth::AuthCredentialsStoreMode;
|
||||
|
||||
fn widget_forced_chatgpt() -> (AuthModeWidget, TempDir) {
|
||||
let llmx_home = TempDir::new().unwrap();
|
||||
let llmx_home_path = llmx_home.path().to_path_buf();
|
||||
let widget = AuthModeWidget {
|
||||
request_frame: FrameRequester::test_dummy(),
|
||||
highlighted_mode: AuthMode::ChatGPT,
|
||||
error: None,
|
||||
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
|
||||
llmx_home: llmx_home_path.clone(),
|
||||
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
|
||||
login_status: LoginStatus::NotAuthenticated,
|
||||
auth_manager: AuthManager::shared(
|
||||
llmx_home_path,
|
||||
false,
|
||||
AuthCredentialsStoreMode::File,
|
||||
),
|
||||
forced_chatgpt_workspace_id: None,
|
||||
forced_login_method: Some(ForcedLoginMethod::Chatgpt),
|
||||
};
|
||||
(widget, llmx_home)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_flow_disabled_when_chatgpt_forced() {
|
||||
let (mut widget, _tmp) = widget_forced_chatgpt();
|
||||
|
||||
widget.start_api_key_entry();
|
||||
|
||||
assert_eq!(widget.error.as_deref(), Some(API_KEY_DISABLED_MESSAGE));
|
||||
assert!(matches!(
|
||||
&*widget.sign_in_state.read().unwrap(),
|
||||
SignInState::PickMode
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_api_key_is_blocked_when_chatgpt_forced() {
|
||||
let (mut widget, _tmp) = widget_forced_chatgpt();
|
||||
|
||||
widget.save_api_key("sk-test".to_string());
|
||||
|
||||
assert_eq!(widget.error.as_deref(), Some(API_KEY_DISABLED_MESSAGE));
|
||||
assert!(matches!(
|
||||
&*widget.sign_in_state.read().unwrap(),
|
||||
SignInState::PickMode
|
||||
));
|
||||
assert_eq!(widget.login_status, LoginStatus::NotAuthenticated);
|
||||
}
|
||||
}
|
||||
8
llmx-rs/tui/src/onboarding/mod.rs
Normal file
8
llmx-rs/tui/src/onboarding/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
mod auth;
|
||||
pub mod onboarding_screen;
|
||||
mod trust_directory;
|
||||
pub use trust_directory::TrustDirectorySelection;
|
||||
mod welcome;
|
||||
mod windows;
|
||||
|
||||
pub(crate) use windows::WSL_INSTRUCTIONS;
|
||||
457
llmx-rs/tui/src/onboarding/onboarding_screen.rs
Normal file
457
llmx-rs/tui/src/onboarding/onboarding_screen.rs
Normal file
@@ -0,0 +1,457 @@
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use llmx_core::AuthManager;
|
||||
use llmx_core::config::Config;
|
||||
use llmx_core::git_info::get_git_repo_root;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Widget;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use llmx_app_server_protocol::AuthMode;
|
||||
use llmx_protocol::config_types::ForcedLoginMethod;
|
||||
|
||||
use crate::LoginStatus;
|
||||
use crate::onboarding::auth::AuthModeWidget;
|
||||
use crate::onboarding::auth::SignInState;
|
||||
use crate::onboarding::trust_directory::TrustDirectorySelection;
|
||||
use crate::onboarding::trust_directory::TrustDirectoryWidget;
|
||||
use crate::onboarding::welcome::WelcomeWidget;
|
||||
use crate::onboarding::windows::WindowsSetupWidget;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::tui::Tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use color_eyre::eyre::Result;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum Step {
|
||||
Windows(WindowsSetupWidget),
|
||||
Welcome(WelcomeWidget),
|
||||
Auth(AuthModeWidget),
|
||||
TrustDirectory(TrustDirectoryWidget),
|
||||
}
|
||||
|
||||
pub(crate) trait KeyboardHandler {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent);
|
||||
fn handle_paste(&mut self, _pasted: String) {}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum StepState {
|
||||
Hidden,
|
||||
InProgress,
|
||||
Complete,
|
||||
}
|
||||
|
||||
pub(crate) trait StepStateProvider {
|
||||
fn get_step_state(&self) -> StepState;
|
||||
}
|
||||
|
||||
pub(crate) struct OnboardingScreen {
|
||||
request_frame: FrameRequester,
|
||||
steps: Vec<Step>,
|
||||
is_done: bool,
|
||||
windows_install_selected: bool,
|
||||
should_exit: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct OnboardingScreenArgs {
|
||||
pub show_windows_wsl_screen: bool,
|
||||
pub show_trust_screen: bool,
|
||||
pub show_login_screen: bool,
|
||||
pub login_status: LoginStatus,
|
||||
pub auth_manager: Arc<AuthManager>,
|
||||
pub config: Config,
|
||||
}
|
||||
|
||||
pub(crate) struct OnboardingResult {
|
||||
pub directory_trust_decision: Option<TrustDirectorySelection>,
|
||||
pub windows_install_selected: bool,
|
||||
pub should_exit: bool,
|
||||
}
|
||||
|
||||
impl OnboardingScreen {
|
||||
pub(crate) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self {
|
||||
let OnboardingScreenArgs {
|
||||
show_windows_wsl_screen,
|
||||
show_trust_screen,
|
||||
show_login_screen,
|
||||
login_status,
|
||||
auth_manager,
|
||||
config,
|
||||
} = args;
|
||||
let cwd = config.cwd.clone();
|
||||
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
|
||||
let forced_login_method = config.forced_login_method;
|
||||
let llmx_home = config.llmx_home;
|
||||
let cli_auth_credentials_store_mode = config.cli_auth_credentials_store_mode;
|
||||
let mut steps: Vec<Step> = Vec::new();
|
||||
if show_windows_wsl_screen {
|
||||
steps.push(Step::Windows(WindowsSetupWidget::new(llmx_home.clone())));
|
||||
}
|
||||
steps.push(Step::Welcome(WelcomeWidget::new(
|
||||
!matches!(login_status, LoginStatus::NotAuthenticated),
|
||||
tui.frame_requester(),
|
||||
)));
|
||||
if show_login_screen {
|
||||
let highlighted_mode = match forced_login_method {
|
||||
Some(ForcedLoginMethod::Api) => AuthMode::ApiKey,
|
||||
_ => AuthMode::ChatGPT,
|
||||
};
|
||||
steps.push(Step::Auth(AuthModeWidget {
|
||||
request_frame: tui.frame_requester(),
|
||||
highlighted_mode,
|
||||
error: None,
|
||||
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
|
||||
llmx_home: llmx_home.clone(),
|
||||
cli_auth_credentials_store_mode,
|
||||
login_status,
|
||||
auth_manager,
|
||||
forced_chatgpt_workspace_id,
|
||||
forced_login_method,
|
||||
}))
|
||||
}
|
||||
let is_git_repo = get_git_repo_root(&cwd).is_some();
|
||||
let highlighted = if is_git_repo {
|
||||
TrustDirectorySelection::Trust
|
||||
} else {
|
||||
// Default to not trusting the directory if it's not a git repo.
|
||||
TrustDirectorySelection::DontTrust
|
||||
};
|
||||
if show_trust_screen {
|
||||
steps.push(Step::TrustDirectory(TrustDirectoryWidget {
|
||||
cwd,
|
||||
llmx_home,
|
||||
is_git_repo,
|
||||
selection: None,
|
||||
highlighted,
|
||||
error: None,
|
||||
}))
|
||||
}
|
||||
// TODO: add git warning.
|
||||
Self {
|
||||
request_frame: tui.frame_requester(),
|
||||
steps,
|
||||
is_done: false,
|
||||
windows_install_selected: false,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_steps_mut(&mut self) -> Vec<&mut Step> {
|
||||
let mut out: Vec<&mut Step> = Vec::new();
|
||||
for step in self.steps.iter_mut() {
|
||||
match step.get_step_state() {
|
||||
StepState::Hidden => continue,
|
||||
StepState::Complete => out.push(step),
|
||||
StepState::InProgress => {
|
||||
out.push(step);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn current_steps(&self) -> Vec<&Step> {
|
||||
let mut out: Vec<&Step> = Vec::new();
|
||||
for step in self.steps.iter() {
|
||||
match step.get_step_state() {
|
||||
StepState::Hidden => continue,
|
||||
StepState::Complete => out.push(step),
|
||||
StepState::InProgress => {
|
||||
out.push(step);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn is_auth_in_progress(&self) -> bool {
|
||||
self.steps.iter().any(|step| {
|
||||
matches!(step, Step::Auth(_)) && matches!(step.get_step_state(), StepState::InProgress)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_done(&self) -> bool {
|
||||
self.is_done
|
||||
|| !self
|
||||
.steps
|
||||
.iter()
|
||||
.any(|step| matches!(step.get_step_state(), StepState::InProgress))
|
||||
}
|
||||
|
||||
pub fn directory_trust_decision(&self) -> Option<TrustDirectorySelection> {
|
||||
self.steps
|
||||
.iter()
|
||||
.find_map(|step| {
|
||||
if let Step::TrustDirectory(TrustDirectoryWidget { selection, .. }) = step {
|
||||
Some(*selection)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub fn windows_install_selected(&self) -> bool {
|
||||
self.windows_install_selected
|
||||
}
|
||||
|
||||
pub fn should_exit(&self) -> bool {
|
||||
self.should_exit
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyboardHandler for OnboardingScreen {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('q'),
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
if self.is_auth_in_progress() {
|
||||
// If the user cancels the auth menu, exit the app rather than
|
||||
// leave the user at a prompt in an unauthed state.
|
||||
self.should_exit = true;
|
||||
}
|
||||
self.is_done = true;
|
||||
}
|
||||
_ => {
|
||||
if let Some(Step::Welcome(widget)) = self
|
||||
.steps
|
||||
.iter_mut()
|
||||
.find(|step| matches!(step, Step::Welcome(_)))
|
||||
{
|
||||
widget.handle_key_event(key_event);
|
||||
}
|
||||
if let Some(active_step) = self.current_steps_mut().into_iter().last() {
|
||||
active_step.handle_key_event(key_event);
|
||||
}
|
||||
}
|
||||
};
|
||||
if self
|
||||
.steps
|
||||
.iter()
|
||||
.any(|step| matches!(step, Step::Windows(widget) if widget.exit_requested()))
|
||||
{
|
||||
self.windows_install_selected = true;
|
||||
self.is_done = true;
|
||||
}
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
|
||||
fn handle_paste(&mut self, pasted: String) {
|
||||
if pasted.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(active_step) = self.current_steps_mut().into_iter().last() {
|
||||
active_step.handle_paste(pasted);
|
||||
}
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &OnboardingScreen {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
Clear.render(area, buf);
|
||||
// Render steps top-to-bottom, measuring each step's height dynamically.
|
||||
let mut y = area.y;
|
||||
let bottom = area.y.saturating_add(area.height);
|
||||
let width = area.width;
|
||||
|
||||
// Helper to scan a temporary buffer and return number of used rows.
|
||||
fn used_rows(tmp: &Buffer, width: u16, height: u16) -> u16 {
|
||||
if width == 0 || height == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut last_non_empty: Option<u16> = None;
|
||||
for yy in 0..height {
|
||||
let mut any = false;
|
||||
for xx in 0..width {
|
||||
let cell = &tmp[(xx, yy)];
|
||||
let has_symbol = !cell.symbol().trim().is_empty();
|
||||
let has_style = cell.fg != Color::Reset
|
||||
|| cell.bg != Color::Reset
|
||||
|| !cell.modifier.is_empty();
|
||||
if has_symbol || has_style {
|
||||
any = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if any {
|
||||
last_non_empty = Some(yy);
|
||||
}
|
||||
}
|
||||
last_non_empty.map(|v| v + 2).unwrap_or(0)
|
||||
}
|
||||
|
||||
let mut i = 0usize;
|
||||
let current_steps = self.current_steps();
|
||||
|
||||
while i < current_steps.len() && y < bottom {
|
||||
let step = ¤t_steps[i];
|
||||
let max_h = bottom.saturating_sub(y);
|
||||
if max_h == 0 || width == 0 {
|
||||
break;
|
||||
}
|
||||
let scratch_area = Rect::new(0, 0, width, max_h);
|
||||
let mut scratch = Buffer::empty(scratch_area);
|
||||
step.render_ref(scratch_area, &mut scratch);
|
||||
let h = used_rows(&scratch, width, max_h).min(max_h);
|
||||
if h > 0 {
|
||||
let target = Rect {
|
||||
x: area.x,
|
||||
y,
|
||||
width,
|
||||
height: h,
|
||||
};
|
||||
Clear.render(target, buf);
|
||||
step.render_ref(target, buf);
|
||||
y = y.saturating_add(h);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyboardHandler for Step {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
match self {
|
||||
Step::Windows(widget) => widget.handle_key_event(key_event),
|
||||
Step::Welcome(widget) => widget.handle_key_event(key_event),
|
||||
Step::Auth(widget) => widget.handle_key_event(key_event),
|
||||
Step::TrustDirectory(widget) => widget.handle_key_event(key_event),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_paste(&mut self, pasted: String) {
|
||||
match self {
|
||||
Step::Windows(_) => {}
|
||||
Step::Welcome(_) => {}
|
||||
Step::Auth(widget) => widget.handle_paste(pasted),
|
||||
Step::TrustDirectory(widget) => widget.handle_paste(pasted),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StepStateProvider for Step {
|
||||
fn get_step_state(&self) -> StepState {
|
||||
match self {
|
||||
Step::Windows(w) => w.get_step_state(),
|
||||
Step::Welcome(w) => w.get_step_state(),
|
||||
Step::Auth(w) => w.get_step_state(),
|
||||
Step::TrustDirectory(w) => w.get_step_state(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for Step {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
match self {
|
||||
Step::Windows(widget) => {
|
||||
widget.render_ref(area, buf);
|
||||
}
|
||||
Step::Welcome(widget) => {
|
||||
widget.render_ref(area, buf);
|
||||
}
|
||||
Step::Auth(widget) => {
|
||||
widget.render_ref(area, buf);
|
||||
}
|
||||
Step::TrustDirectory(widget) => {
|
||||
widget.render_ref(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run_onboarding_app(
|
||||
args: OnboardingScreenArgs,
|
||||
tui: &mut Tui,
|
||||
) -> Result<OnboardingResult> {
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
let mut onboarding_screen = OnboardingScreen::new(tui, args);
|
||||
// One-time guard to fully clear the screen after ChatGPT login success message is shown
|
||||
let mut did_full_clear_after_success = false;
|
||||
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&onboarding_screen, frame.area());
|
||||
})?;
|
||||
|
||||
let tui_events = tui.event_stream();
|
||||
tokio::pin!(tui_events);
|
||||
|
||||
while !onboarding_screen.is_done() {
|
||||
if let Some(event) = tui_events.next().await {
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => {
|
||||
onboarding_screen.handle_key_event(key_event);
|
||||
}
|
||||
TuiEvent::Paste(text) => {
|
||||
onboarding_screen.handle_paste(text);
|
||||
}
|
||||
TuiEvent::Draw => {
|
||||
if !did_full_clear_after_success
|
||||
&& onboarding_screen.steps.iter().any(|step| {
|
||||
if let Step::Auth(w) = step {
|
||||
w.sign_in_state.read().is_ok_and(|g| {
|
||||
matches!(&*g, super::auth::SignInState::ChatGptSuccessMessage)
|
||||
})
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
{
|
||||
// Reset any lingering SGR (underline/color) before clearing
|
||||
let _ = ratatui::crossterm::execute!(
|
||||
std::io::stdout(),
|
||||
ratatui::crossterm::style::SetAttribute(
|
||||
ratatui::crossterm::style::Attribute::Reset
|
||||
),
|
||||
ratatui::crossterm::style::SetAttribute(
|
||||
ratatui::crossterm::style::Attribute::NoUnderline
|
||||
),
|
||||
ratatui::crossterm::style::SetForegroundColor(
|
||||
ratatui::crossterm::style::Color::Reset
|
||||
),
|
||||
ratatui::crossterm::style::SetBackgroundColor(
|
||||
ratatui::crossterm::style::Color::Reset
|
||||
)
|
||||
);
|
||||
let _ = tui.terminal.clear();
|
||||
did_full_clear_after_success = true;
|
||||
}
|
||||
let _ = tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&onboarding_screen, frame.area());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(OnboardingResult {
|
||||
directory_trust_decision: onboarding_screen.directory_trust_decision(),
|
||||
windows_install_selected: onboarding_screen.windows_install_selected(),
|
||||
should_exit: onboarding_screen.should_exit(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/onboarding/trust_directory.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
> You are running LLMX in /workspace/project
|
||||
|
||||
Since this folder is version controlled, you may wish to allow LLMX
|
||||
to work in this folder without asking for approval.
|
||||
|
||||
› 1. Yes, allow LLMX to work in this folder without asking for
|
||||
approval
|
||||
2. No, ask me to approve edits and commands
|
||||
|
||||
Press enter to continue
|
||||
225
llmx-rs/tui/src/onboarding/trust_directory.rs
Normal file
225
llmx-rs/tui/src/onboarding/trust_directory.rs
Normal file
@@ -0,0 +1,225 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use llmx_core::config::set_project_trusted;
|
||||
use llmx_core::git_info::resolve_root_git_project_for_trust;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
use crate::key_hint;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
use crate::render::Insets;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::render::renderable::RenderableExt as _;
|
||||
use crate::selection_list::selection_option_row;
|
||||
|
||||
use super::onboarding_screen::StepState;
|
||||
pub(crate) struct TrustDirectoryWidget {
|
||||
pub llmx_home: PathBuf,
|
||||
pub cwd: PathBuf,
|
||||
pub is_git_repo: bool,
|
||||
pub selection: Option<TrustDirectorySelection>,
|
||||
pub highlighted: TrustDirectorySelection,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TrustDirectorySelection {
|
||||
Trust,
|
||||
DontTrust,
|
||||
}
|
||||
|
||||
impl WidgetRef for &TrustDirectoryWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let mut column = ColumnRenderable::new();
|
||||
|
||||
column.push(Line::from(vec![
|
||||
"> ".into(),
|
||||
"You are running LLMX in ".bold(),
|
||||
self.cwd.to_string_lossy().to_string().into(),
|
||||
]));
|
||||
column.push("");
|
||||
|
||||
let guidance = if self.is_git_repo {
|
||||
"Since this folder is version controlled, you may wish to allow LLMX to work in this folder without asking for approval."
|
||||
} else {
|
||||
"Since this folder is not version controlled, we recommend requiring approval of all edits and commands."
|
||||
};
|
||||
|
||||
column.push(
|
||||
Paragraph::new(guidance.to_string())
|
||||
.wrap(Wrap { trim: true })
|
||||
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
);
|
||||
column.push("");
|
||||
|
||||
let mut options: Vec<(&str, TrustDirectorySelection)> = Vec::new();
|
||||
if self.is_git_repo {
|
||||
options.push((
|
||||
"Yes, allow LLMX to work in this folder without asking for approval",
|
||||
TrustDirectorySelection::Trust,
|
||||
));
|
||||
options.push((
|
||||
"No, ask me to approve edits and commands",
|
||||
TrustDirectorySelection::DontTrust,
|
||||
));
|
||||
} else {
|
||||
options.push((
|
||||
"Allow LLMX to work in this folder without asking for approval",
|
||||
TrustDirectorySelection::Trust,
|
||||
));
|
||||
options.push((
|
||||
"Require approval of edits and commands",
|
||||
TrustDirectorySelection::DontTrust,
|
||||
));
|
||||
}
|
||||
|
||||
for (idx, (text, selection)) in options.iter().enumerate() {
|
||||
column.push(selection_option_row(
|
||||
idx,
|
||||
text.to_string(),
|
||||
self.highlighted == *selection,
|
||||
));
|
||||
}
|
||||
|
||||
column.push("");
|
||||
|
||||
if let Some(error) = &self.error {
|
||||
column.push(
|
||||
Paragraph::new(error.to_string())
|
||||
.red()
|
||||
.wrap(Wrap { trim: true })
|
||||
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
);
|
||||
column.push("");
|
||||
}
|
||||
|
||||
column.push(
|
||||
Line::from(vec![
|
||||
"Press ".dim(),
|
||||
key_hint::plain(KeyCode::Enter).into(),
|
||||
" to continue".dim(),
|
||||
])
|
||||
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
);
|
||||
|
||||
column.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyboardHandler for TrustDirectoryWidget {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if key_event.kind == KeyEventKind::Release {
|
||||
return;
|
||||
}
|
||||
|
||||
match key_event.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.highlighted = TrustDirectorySelection::Trust;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.highlighted = TrustDirectorySelection::DontTrust;
|
||||
}
|
||||
KeyCode::Char('1') | KeyCode::Char('y') => self.handle_trust(),
|
||||
KeyCode::Char('2') | KeyCode::Char('n') => self.handle_dont_trust(),
|
||||
KeyCode::Enter => match self.highlighted {
|
||||
TrustDirectorySelection::Trust => self.handle_trust(),
|
||||
TrustDirectorySelection::DontTrust => self.handle_dont_trust(),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StepStateProvider for TrustDirectoryWidget {
|
||||
fn get_step_state(&self) -> StepState {
|
||||
match self.selection {
|
||||
Some(_) => StepState::Complete,
|
||||
None => StepState::InProgress,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TrustDirectoryWidget {
|
||||
fn handle_trust(&mut self) {
|
||||
let target =
|
||||
resolve_root_git_project_for_trust(&self.cwd).unwrap_or_else(|| self.cwd.clone());
|
||||
if let Err(e) = set_project_trusted(&self.llmx_home, &target) {
|
||||
tracing::error!("Failed to set project trusted: {e:?}");
|
||||
self.error = Some(format!("Failed to set trust for {}: {e}", target.display()));
|
||||
}
|
||||
|
||||
self.selection = Some(TrustDirectorySelection::Trust);
|
||||
}
|
||||
|
||||
fn handle_dont_trust(&mut self) {
|
||||
self.highlighted = TrustDirectorySelection::DontTrust;
|
||||
self.selection = Some(TrustDirectorySelection::DontTrust);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::test_backend::VT100Backend;
|
||||
|
||||
use super::*;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::Terminal;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn release_event_does_not_change_selection() {
|
||||
let mut widget = TrustDirectoryWidget {
|
||||
llmx_home: PathBuf::from("."),
|
||||
cwd: PathBuf::from("."),
|
||||
is_git_repo: false,
|
||||
selection: None,
|
||||
highlighted: TrustDirectorySelection::DontTrust,
|
||||
error: None,
|
||||
};
|
||||
|
||||
let release = KeyEvent {
|
||||
kind: KeyEventKind::Release,
|
||||
..KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
|
||||
};
|
||||
widget.handle_key_event(release);
|
||||
assert_eq!(widget.selection, None);
|
||||
|
||||
let press = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||||
widget.handle_key_event(press);
|
||||
assert_eq!(widget.selection, Some(TrustDirectorySelection::DontTrust));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_snapshot_for_git_repo() {
|
||||
let widget = TrustDirectoryWidget {
|
||||
llmx_home: PathBuf::from("."),
|
||||
cwd: PathBuf::from("/workspace/project"),
|
||||
is_git_repo: true,
|
||||
selection: None,
|
||||
highlighted: TrustDirectorySelection::Trust,
|
||||
error: None,
|
||||
};
|
||||
|
||||
let mut terminal = Terminal::new(VT100Backend::new(70, 14)).expect("terminal");
|
||||
terminal
|
||||
.draw(|f| (&widget).render_ref(f.area(), f.buffer_mut()))
|
||||
.expect("draw");
|
||||
|
||||
insta::assert_snapshot!(terminal.backend());
|
||||
}
|
||||
}
|
||||
143
llmx-rs/tui/src/onboarding/welcome.rs
Normal file
143
llmx-rs/tui/src/onboarding/welcome.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
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::prelude::Widget;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
use crate::ascii_animation::AsciiAnimation;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
use crate::tui::FrameRequester;
|
||||
|
||||
use super::onboarding_screen::StepState;
|
||||
|
||||
const MIN_ANIMATION_HEIGHT: u16 = 20;
|
||||
const MIN_ANIMATION_WIDTH: u16 = 60;
|
||||
|
||||
pub(crate) struct WelcomeWidget {
|
||||
pub is_logged_in: bool,
|
||||
animation: AsciiAnimation,
|
||||
}
|
||||
|
||||
impl KeyboardHandler for WelcomeWidget {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if key_event.kind == KeyEventKind::Press
|
||||
&& key_event.code == KeyCode::Char('.')
|
||||
&& key_event.modifiers.contains(KeyModifiers::CONTROL)
|
||||
{
|
||||
tracing::warn!("Welcome background to press '.'");
|
||||
let _ = self.animation.pick_random_variant();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WelcomeWidget {
|
||||
pub(crate) fn new(is_logged_in: bool, request_frame: FrameRequester) -> Self {
|
||||
Self {
|
||||
is_logged_in,
|
||||
animation: AsciiAnimation::new(request_frame),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &WelcomeWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
Clear.render(area, buf);
|
||||
self.animation.schedule_next_frame();
|
||||
|
||||
// Skip the animation entirely when the viewport is too small so we don't clip frames.
|
||||
let show_animation =
|
||||
area.height >= MIN_ANIMATION_HEIGHT && area.width >= MIN_ANIMATION_WIDTH;
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
if show_animation {
|
||||
let frame = self.animation.current_frame();
|
||||
// let frame_line_count = frame.lines().count();
|
||||
// lines.reserve(frame_line_count + 2);
|
||||
lines.extend(frame.lines().map(Into::into));
|
||||
lines.push("".into());
|
||||
}
|
||||
lines.push(Line::from(vec![
|
||||
" ".into(),
|
||||
"Welcome to ".into(),
|
||||
"LLMX".bold(),
|
||||
", OpenAI's command-line coding agent".into(),
|
||||
]));
|
||||
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
impl StepStateProvider for WelcomeWidget {
|
||||
fn get_step_state(&self) -> StepState {
|
||||
match self.is_logged_in {
|
||||
true => StepState::Hidden,
|
||||
false => StepState::Complete,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
static VARIANT_A: [&str; 1] = ["frame-a"];
|
||||
static VARIANT_B: [&str; 1] = ["frame-b"];
|
||||
static VARIANTS: [&[&str]; 2] = [&VARIANT_A, &VARIANT_B];
|
||||
|
||||
#[test]
|
||||
fn welcome_renders_animation_on_first_draw() {
|
||||
let widget = WelcomeWidget::new(false, FrameRequester::test_dummy());
|
||||
let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&widget).render(area, &mut buf);
|
||||
|
||||
let mut found = false;
|
||||
let mut last_non_empty: Option<u16> = None;
|
||||
for y in 0..area.height {
|
||||
for x in 0..area.width {
|
||||
if !buf[(x, y)].symbol().trim().is_empty() {
|
||||
found = true;
|
||||
last_non_empty = Some(y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(found, "expected welcome animation to render characters");
|
||||
let measured_rows = last_non_empty.map(|v| v + 2).unwrap_or(0);
|
||||
assert!(
|
||||
measured_rows >= MIN_ANIMATION_HEIGHT,
|
||||
"expected measurement to report at least {MIN_ANIMATION_HEIGHT} rows, got {measured_rows}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_dot_changes_animation_variant() {
|
||||
let mut widget = WelcomeWidget {
|
||||
is_logged_in: false,
|
||||
animation: AsciiAnimation::with_variants(FrameRequester::test_dummy(), &VARIANTS, 0),
|
||||
};
|
||||
|
||||
let before = widget.animation.current_frame();
|
||||
widget.handle_key_event(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::CONTROL));
|
||||
let after = widget.animation.current_frame();
|
||||
|
||||
assert_ne!(
|
||||
before, after,
|
||||
"expected ctrl+. to switch welcome animation variant"
|
||||
);
|
||||
}
|
||||
}
|
||||
205
llmx-rs/tui/src/onboarding/windows.rs
Normal file
205
llmx-rs/tui/src/onboarding/windows.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use llmx_core::config::edit::ConfigEditsBuilder;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Widget;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
|
||||
use super::onboarding_screen::StepState;
|
||||
|
||||
pub(crate) const WSL_INSTRUCTIONS: &str = r#"Install WSL2 by opening PowerShell as Administrator and running:
|
||||
# Install WSL using the default Linux distribution (Ubuntu).
|
||||
# See https://learn.microsoft.com/en-us/windows/wsl/install for more info
|
||||
wsl --install
|
||||
|
||||
# Restart your computer, then start a shell inside of Windows Subsystem for Linux
|
||||
wsl
|
||||
|
||||
# Install Node.js in WSL via nvm
|
||||
# Documentation: https://learn.microsoft.com/en-us/windows/dev-environment/javascript/nodejs-on-wsl
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash && export NVM_DIR="$HOME/.nvm" && \. "$NVM_DIR/nvm.sh"
|
||||
nvm install 22
|
||||
|
||||
# Install and run LLMX in WSL
|
||||
npm install --global @openai/llmx
|
||||
llmx
|
||||
|
||||
# Additional details and instructions for how to install and run LLMX in WSL:
|
||||
https://developers.openai.com/llmx/windows"#;
|
||||
|
||||
pub(crate) struct WindowsSetupWidget {
|
||||
pub llmx_home: PathBuf,
|
||||
pub selection: Option<WindowsSetupSelection>,
|
||||
pub highlighted: WindowsSetupSelection,
|
||||
pub error: Option<String>,
|
||||
exit_requested: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WindowsSetupSelection {
|
||||
Continue,
|
||||
Install,
|
||||
}
|
||||
|
||||
impl WindowsSetupWidget {
|
||||
pub fn new(llmx_home: PathBuf) -> Self {
|
||||
Self {
|
||||
llmx_home,
|
||||
selection: None,
|
||||
highlighted: WindowsSetupSelection::Install,
|
||||
error: None,
|
||||
exit_requested: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_continue(&mut self) {
|
||||
self.highlighted = WindowsSetupSelection::Continue;
|
||||
match ConfigEditsBuilder::new(&self.llmx_home)
|
||||
.set_windows_wsl_setup_acknowledged(true)
|
||||
.apply_blocking()
|
||||
{
|
||||
Ok(()) => {
|
||||
self.selection = Some(WindowsSetupSelection::Continue);
|
||||
self.exit_requested = false;
|
||||
self.error = None;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to persist Windows onboarding acknowledgement: {err:?}");
|
||||
self.error = Some(format!("Failed to update config: {err}"));
|
||||
self.selection = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_install(&mut self) {
|
||||
self.highlighted = WindowsSetupSelection::Install;
|
||||
self.selection = Some(WindowsSetupSelection::Install);
|
||||
self.exit_requested = true;
|
||||
}
|
||||
|
||||
pub fn exit_requested(&self) -> bool {
|
||||
self.exit_requested
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &WindowsSetupWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let mut lines: Vec<Line> = vec![
|
||||
Line::from(vec![
|
||||
"> ".into(),
|
||||
"To use all LLMX features, we recommend running LLMX in Windows Subsystem for Linux (WSL2)".bold(),
|
||||
]),
|
||||
Line::from(vec![" ".into(), "WSL allows LLMX to run Agent mode in a sandboxed environment with better data protections in place.".into()]),
|
||||
Line::from(vec![" ".into(), "Learn more: https://developers.openai.com/llmx/windows".into()]),
|
||||
Line::from(""),
|
||||
];
|
||||
|
||||
let create_option =
|
||||
|idx: usize, option: WindowsSetupSelection, text: &str| -> Line<'static> {
|
||||
if self.highlighted == option {
|
||||
Line::from(format!("> {}. {text}", idx + 1)).cyan()
|
||||
} else {
|
||||
Line::from(format!(" {}. {}", idx + 1, text))
|
||||
}
|
||||
};
|
||||
|
||||
lines.push(create_option(
|
||||
0,
|
||||
WindowsSetupSelection::Install,
|
||||
"Exit and install WSL2",
|
||||
));
|
||||
lines.push(create_option(
|
||||
1,
|
||||
WindowsSetupSelection::Continue,
|
||||
"Continue anyway",
|
||||
));
|
||||
lines.push("".into());
|
||||
|
||||
if let Some(error) = &self.error {
|
||||
lines.push(Line::from(format!(" {error}")).fg(Color::Red));
|
||||
lines.push("".into());
|
||||
}
|
||||
|
||||
lines.push(Line::from(vec![" Press Enter to continue".dim()]));
|
||||
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyboardHandler for WindowsSetupWidget {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if key_event.kind == KeyEventKind::Release {
|
||||
return;
|
||||
}
|
||||
|
||||
match key_event.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.highlighted = WindowsSetupSelection::Install;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.highlighted = WindowsSetupSelection::Continue;
|
||||
}
|
||||
KeyCode::Char('1') => self.handle_install(),
|
||||
KeyCode::Char('2') => self.handle_continue(),
|
||||
KeyCode::Enter => match self.highlighted {
|
||||
WindowsSetupSelection::Install => self.handle_install(),
|
||||
WindowsSetupSelection::Continue => self.handle_continue(),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StepStateProvider for WindowsSetupWidget {
|
||||
fn get_step_state(&self) -> StepState {
|
||||
match self.selection {
|
||||
Some(WindowsSetupSelection::Continue) => StepState::Hidden,
|
||||
Some(WindowsSetupSelection::Install) => StepState::Complete,
|
||||
None => StepState::InProgress,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn windows_step_hidden_after_continue() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let mut widget = WindowsSetupWidget::new(temp_dir.path().to_path_buf());
|
||||
|
||||
assert_eq!(widget.get_step_state(), StepState::InProgress);
|
||||
|
||||
widget.handle_continue();
|
||||
|
||||
assert_eq!(widget.get_step_state(), StepState::Hidden);
|
||||
assert!(!widget.exit_requested());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_step_complete_after_install_selection() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let mut widget = WindowsSetupWidget::new(temp_dir.path().to_path_buf());
|
||||
|
||||
widget.handle_install();
|
||||
|
||||
assert_eq!(widget.get_step_state(), StepState::Complete);
|
||||
assert!(widget.exit_requested());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user