use crate::events::{Action, Event, EventHandler, key_to_action};
use crate::icons;
use crate::panels::PanelId;
use crate::shell::ShellExecutor;
use crate::theme::Theme;
use crate::ui;
use anyhow::Result;
use arct_core::{CommandAnalyzer, Context, ContextDetector, Educator, Session};
use crossterm::{
event::{KeyCode, KeyEvent, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
Terminal,
};
use std::collections::HashMap;
use std::io;
pub struct App {
pub should_quit: bool,
pub active_panel: PanelId,
pub session: Session,
pub context: Context,
pub analyzer: CommandAnalyzer,
pub educator: Educator,
pub theme: Theme,
event_handler: EventHandler,
pub show_help: bool,
pub command_buffer: String,
pub last_explanation: Option<arct_core::Explanation>,
shell_executor: ShellExecutor,
pub last_output: String,
pub output_scroll: usize,
command_history: Vec<String>,
history_position: Option<usize>,
pub environment_vars: HashMap<String, String>,
pub aliases: HashMap<String, String>,
pub config: arct_config::Config,
autocompleter: crate::autocomplete::Autocompleter,
pub completion_suggestions: Vec<String>,
ai_provider: Option<Box<dyn arct_ai::AIProvider>>,
pub ai_conversation: Vec<arct_ai::Message>,
pub ai_input_buffer: String,
pub ai_response: Option<String>,
pub ai_loading: bool,
pub ai_mode: bool,
pub onboarding: Option<crate::panels::onboarding::OnboardingWizard>,
pub settings_panel: Option<crate::panels::settings::SettingsPanel>,
pub analytics: Option<crate::analytics::Analytics>,
session_id: String,
pub lesson_panel: Option<crate::panels::lesson::LessonPanel>,
pub lesson_mode: bool,
pub virtual_fs: Option<arct_core::VirtualFileSystem>,
}
impl App {
pub fn new() -> Result<Self> {
let session = Session::new();
let working_dir = session.state.working_directory.clone();
let context = ContextDetector::detect(&working_dir)?;
let config = arct_config::Config::load().unwrap_or_else(|e| {
tracing::warn!("Failed to load config, using defaults: {}", e);
arct_config::Config::default()
});
let command_history = match crate::persistence::load_session() {
Ok(session_data) => {
tracing::info!("Loaded {} commands from history", session_data.command_history.len());
session_data.command_history
}
Err(e) => {
tracing::warn!("Failed to load session history: {}", e);
Vec::new()
}
};
let aliases = config.shell.aliases.clone();
let environment_vars = config.shell.environment.clone();
let theme = Theme::from_name(&config.theme.default_theme);
let ai_provider = if config.ai.enabled {
match Self::create_ai_provider(&config.ai) {
Ok(provider) => {
tracing::info!("AI provider initialized: {}", provider.name());
Some(provider)
}
Err(e) => {
tracing::warn!("Failed to initialize AI provider: {}", e);
None
}
}
} else {
None
};
let onboarding = if !config.general.setup_complete {
Some(crate::panels::onboarding::OnboardingWizard::new())
} else {
None
};
let welcome_message = if config.general.setup_complete {
let name = config.general.user_name.as_deref().unwrap_or("there");
let mut msg = format!("{}Welcome back, {}!\n\n", icons::welcome().content, name);
msg.push_str("Quick reminders:\n");
if config.ai.enabled {
msg.push_str(" • Press Ctrl+A to ask the AI for help\n");
}
msg.push_str(" • Press ? for help\n");
msg.push_str(" • Press Ctrl+S for settings\n");
msg.push_str(" • Tab to autocomplete commands\n\n");
msg.push_str("Start typing a command to begin!\n");
msg
} else {
String::new()
};
Ok(Self {
should_quit: false,
active_panel: PanelId::Shell,
session,
context,
analyzer: CommandAnalyzer::new(),
educator: Educator::new(),
theme,
event_handler: EventHandler::new(),
show_help: false,
command_buffer: String::new(),
last_explanation: None,
shell_executor: ShellExecutor::new()?,
last_output: welcome_message,
output_scroll: 0,
command_history,
history_position: None,
environment_vars,
aliases,
config,
autocompleter: crate::autocomplete::Autocompleter::new(),
completion_suggestions: Vec::new(),
ai_provider,
ai_conversation: Vec::new(),
ai_input_buffer: String::new(),
ai_response: None,
ai_loading: false,
ai_mode: false,
onboarding,
settings_panel: None,
analytics: crate::analytics::Analytics::new().ok(),
session_id: uuid::Uuid::new_v4().to_string(),
lesson_panel: Self::initialize_lesson_panel(),
lesson_mode: false,
virtual_fs: None,
})
}
fn initialize_lesson_panel() -> Option<crate::panels::lesson::LessonPanel> {
use arct_core::LessonLibrary;
let library = LessonLibrary::new();
let mut panel = crate::panels::lesson::LessonPanel::new();
if let Some(lesson) = library.get("nav-basics") {
panel.load_lesson(lesson.clone());
Some(panel)
} else {
Some(panel) }
}
fn create_ai_provider(config: &arct_config::AIConfig) -> Result<Box<dyn arct_ai::AIProvider>> {
let ai_config = match config.provider.as_str() {
"anthropic" => {
let api_key = config.api_key.clone()
.ok_or_else(|| anyhow::anyhow!("Anthropic API key not set"))?;
let model = config.model.clone()
.unwrap_or_else(|| "claude-3-5-sonnet-20241022".to_string());
arct_ai::AIConfig::Anthropic { api_key, model }
}
"openai" => {
let api_key = config.api_key.clone()
.ok_or_else(|| anyhow::anyhow!("OpenAI API key not set"))?;
let model = config.model.clone()
.unwrap_or_else(|| "gpt-4-turbo-preview".to_string());
arct_ai::AIConfig::OpenAI { api_key, model }
}
"local" => {
let endpoint = config.endpoint.clone()
.unwrap_or_else(|| "http://localhost:11434".to_string());
let model = config.model.clone();
arct_ai::AIConfig::Local { endpoint, model }
}
"managed" => {
let auth_token = config.api_key.clone()
.ok_or_else(|| anyhow::anyhow!("Managed API token not set"))?;
arct_ai::AIConfig::Managed { auth_token }
}
"claude-cli" => {
let model = config.model.clone();
arct_ai::AIConfig::ClaudeCLI { model }
}
_ => arct_ai::AIConfig::Disabled,
};
arct_ai::AIFactory::create(&ai_config)
.map_err(|e| anyhow::anyhow!("Failed to create AI provider: {}", e))
}
fn show_splash_screen() -> Result<()> {
use crossterm::{
cursor,
style::{Color, Print, SetForegroundColor, ResetColor},
terminal::{Clear, ClearType},
};
use std::io::Write;
let mut stdout = io::stdout();
execute!(stdout, Clear(ClearType::All), cursor::MoveTo(0, 0))?;
let logo = r#"
▄▄▄ ██▀███ ▄████▄ ▄▄▄ ▄████▄ ▄▄▄ ▓█████▄ ▓█████ ███▄ ▄███▓▓██ ██▓
▒████▄ ▓██ ▒ ██▒▒██▀ ▀█ ▒████▄ ▒██▀ ▀█ ▒████▄ ▒██▀ ██▌▓█ ▀ ▓██▒▀█▀ ██▒ ▒██ ██▒
▒██ ▀█▄ ▓██ ░▄█ ▒▒▓█ ▄ ▒██ ▀█▄ ▒▓█ ▄ ▒██ ▀█▄ ░██ █▌▒███ ▓██ ▓██░ ▒██ ██░
░██▄▄▄▄██ ▒██▀▀█▄ ▒▓▓▄ ▄██▒ ░██▄▄▄▄██ ▒▓▓▄ ▄██▒░██▄▄▄▄██ ░▓█▄ ▌▒▓█ ▄ ▒██ ▒██ ░ ▐██▓░
▓█ ▓██▒░██▓ ▒██▒▒ ▓███▀ ░ ▓█ ▓██▒▒ ▓███▀ ░ ▓█ ▓██▒░▒████▓ ░▒████▒▒██▒ ░██▒ ░ ██▒▓░
▒▒ ▓▒█░░ ▒▓ ░▒▓░░ ░▒ ▒ ░ ▒▒ ▓▒█░░ ░▒ ▒ ░ ▒▒ ▓▒█░ ▒▒▓ ▒ ░░ ▒░ ░░ ▒░ ░ ░ ██▒▒▒
▒ ▒▒ ░ ░▒ ░ ▒░ ░ ▒ ▒ ▒▒ ░ ░ ▒ ▒ ▒▒ ░ ░ ▒ ▒ ░ ░ ░░ ░ ░ ▓██ ░▒░
░ ▒ ░░ ░ ░ ░ ▒ ░ ░ ▒ ░ ░ ░ ░ ░ ░ ▒ ▒ ░░
░ ░ ░ ░ ░ ░ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░
░ ░ ░ ░ ░
"#;
let tagline = "Λ° Learn Shell Commands Interactively with AI";
let version = "v0.1.0-alpha";
let (width, height) = crossterm::terminal::size()?;
let start_row = (height / 2).saturating_sub(7);
let logo_width = 92;
let logo_col = (width / 2).saturating_sub(logo_width / 2);
for (i, line) in logo.lines().enumerate() {
let row = start_row + i as u16;
execute!(
stdout,
cursor::MoveTo(logo_col, row),
SetForegroundColor(Color::Rgb { r: 255, g: 140, b: 0 }), Print(line),
ResetColor
)?;
}
let tagline_row = start_row + 11;
let tagline_col = (width / 2).saturating_sub((tagline.len() / 2) as u16);
execute!(
stdout,
cursor::MoveTo(tagline_col, tagline_row),
SetForegroundColor(Color::White),
Print(tagline),
ResetColor
)?;
let version_row = tagline_row + 1;
let version_col = (width / 2).saturating_sub((version.len() / 2) as u16);
execute!(
stdout,
cursor::MoveTo(version_col, version_row),
SetForegroundColor(Color::DarkGrey),
Print(version),
ResetColor
)?;
stdout.flush()?;
std::thread::sleep(std::time::Duration::from_millis(1500));
execute!(stdout, Clear(ClearType::All), cursor::MoveTo(0, 0))?;
Ok(())
}
pub async fn run(&mut self) -> Result<()> {
Self::show_splash_screen()?;
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
self.event_handler.start().await;
let result = self.main_loop(&mut terminal).await;
self.save_history();
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
async fn main_loop<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
loop {
terminal.draw(|f| ui::draw(f, self))?;
if let Some(event) = self.event_handler.next().await {
self.handle_event(event).await?;
}
if self.should_quit {
break;
}
}
Ok(())
}
async fn handle_event(&mut self, event: Event) -> Result<()> {
match event {
Event::Key(key) => {
if let Some(ref mut wizard) = self.onboarding {
return self.handle_onboarding_event(key).await;
}
if self.settings_panel.is_some() {
return self.handle_settings_event(key).await;
}
if self.ai_mode && self.active_panel == PanelId::Shell && !self.show_help {
match key.code {
KeyCode::Char(c) if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT => {
self.ai_input_buffer.push(c);
return Ok(());
}
KeyCode::Backspace => {
self.ai_input_buffer.pop();
return Ok(());
}
KeyCode::Enter => {
if !self.ai_input_buffer.is_empty() {
let question = self.ai_input_buffer.clone();
self.ai_input_buffer.clear();
self.ask_ai(question).await?;
}
return Ok(());
}
_ => {}
}
}
if self.active_panel == PanelId::Shell && !self.show_help && !self.ai_mode {
match key.code {
KeyCode::Char(c) if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT => {
self.command_buffer.push(c);
self.history_position = None;
self.completion_suggestions.clear();
return Ok(());
}
KeyCode::Backspace => {
self.command_buffer.pop();
self.history_position = None;
self.completion_suggestions.clear();
return Ok(());
}
KeyCode::Tab if key.modifiers == KeyModifiers::NONE => {
self.handle_autocomplete()?;
return Ok(());
}
KeyCode::Up => {
self.history_previous();
return Ok(());
}
KeyCode::Down => {
self.history_next();
return Ok(());
}
_ => {}
}
}
let action = key_to_action(key);
self.handle_action(action).await?;
}
Event::Resize(_, _) => {
}
Event::Tick => {
}
Event::Quit => {
self.should_quit = true;
}
}
Ok(())
}
async fn handle_action(&mut self, action: Action) -> Result<()> {
match action {
Action::Quit => {
if self.show_help {
self.show_help = false;
} else {
self.should_quit = true;
}
}
Action::NextPanel => {
self.active_panel = self.active_panel.next();
self.output_scroll = 0;
}
Action::PreviousPanel => {
self.active_panel = self.active_panel.previous();
self.output_scroll = 0;
}
Action::ScrollUp => {
if self.active_panel == PanelId::Output {
self.output_scroll = self.output_scroll.saturating_sub(1);
}
}
Action::ScrollDown => {
if self.active_panel == PanelId::Output {
let total_lines = self.last_output.lines().count();
if self.output_scroll < total_lines.saturating_sub(1) {
self.output_scroll += 1;
}
}
}
Action::PageUp => {
if self.active_panel == PanelId::Output {
self.output_scroll = self.output_scroll.saturating_sub(10);
}
}
Action::PageDown => {
if self.active_panel == PanelId::Output {
let total_lines = self.last_output.lines().count();
self.output_scroll = (self.output_scroll + 10).min(total_lines.saturating_sub(1));
}
}
Action::Help => {
self.show_help = !self.show_help;
}
Action::ToggleTheme => {
self.theme = self.theme.cycle_next();
}
Action::ToggleAI => {
self.toggle_ai_mode();
}
Action::ToggleSettings => {
if self.settings_panel.is_some() {
self.settings_panel = None;
} else {
self.settings_panel = Some(crate::panels::settings::SettingsPanel::new());
}
}
Action::ToggleLesson => {
self.toggle_lesson_mode();
}
Action::Escape => {
if self.show_help {
self.show_help = false;
} else if self.settings_panel.is_some() {
self.settings_panel = None;
} else if self.ai_mode {
self.ai_mode = false;
}
}
Action::Enter => {
if !self.ai_mode {
self.execute_command().await?;
}
}
_ => {}
}
Ok(())
}
async fn execute_command(&mut self) -> Result<()> {
if self.command_buffer.is_empty() {
return Ok(());
}
let mut command_str = self.command_buffer.clone();
let cmd = self.analyzer.parse(&command_str)?;
if self.lesson_mode && cmd.program == "pwd" {
if let Some(ref vfs) = self.virtual_fs {
let current = vfs.get_current_dir().display().to_string();
self.last_output = format!("{}\n\n{}Virtual lesson filesystem\n", current, icons::hint().content);
self.command_buffer.clear();
self.add_to_history(command_str.clone());
if let Some(ref mut lesson_panel) = self.lesson_panel {
let validation = lesson_panel.validate_current_step("pwd");
if validation.is_success() && !lesson_panel.next_step() {
self.last_output.push_str(&format!("\n{}Lesson complete! Press Ctrl+L to exit.\n", icons::celebration().content));
}
}
return Ok(());
}
}
if self.lesson_mode && cmd.program == "ls" {
if let Some(ref vfs) = self.virtual_fs {
match vfs.list_directory(None) {
Ok(entries) => {
let mut output = String::new();
for entry in entries {
if entry.is_dir {
output.push_str(&format!("{}{}/\n", icons::folder().content, entry.name));
} else {
output.push_str(&format!("{}{}\n", icons::file().content, entry.name));
}
}
if output.is_empty() {
output = "Empty directory\n".to_string();
}
output.push_str(&format!("\n{}Virtual lesson filesystem\n", icons::hint().content));
self.last_output = output;
self.command_buffer.clear();
self.add_to_history(command_str.clone());
if let Some(ref mut lesson_panel) = self.lesson_panel {
let validation = lesson_panel.validate_current_step(&command_str);
if validation.is_success() && !lesson_panel.next_step() {
self.last_output.push_str(&format!("\n{}Lesson complete! Press Ctrl+L to exit.\n", icons::celebration().content));
}
}
return Ok(());
}
Err(e) => {
self.last_output = format!("{}ls: {}\n", icons::error().content, e);
return Ok(());
}
}
}
}
if self.lesson_mode {
if let Some(ref mut lesson_panel) = self.lesson_panel {
let validation = lesson_panel.validate_current_step(&command_str);
if validation.is_success() {
self.last_output = format!("{}{}\n\nMoving to next step...\n",
icons::success().content,
match &validation {
arct_core::ValidationResult::Success { message } => message,
_ => "Success!",
}
);
if !lesson_panel.next_step() {
self.last_output.push_str(&format!("\n{}Congratulations! You've completed this lesson!\n\nPress Ctrl+L to exit lesson mode.\n", icons::celebration().content));
}
} else {
self.last_output = match validation {
arct_core::ValidationResult::Failure { message, hint } => {
let mut output = format!("{}{}\n", icons::error().content, message);
if let Some(h) = hint {
output.push_str(&format!("\n{}Hint: {}\n", icons::hint().content, h));
}
output.push_str("\nTry again!\n");
output
}
arct_core::ValidationResult::Partial { message, progress } => {
format!("{}{} ({:.0}% correct)\n\nKeep trying!\n", icons::warning().content, message, progress)
}
_ => "Try again!\n".to_string(),
};
}
self.command_buffer.clear();
self.add_to_history(command_str.clone());
return Ok(());
}
}
let explanation = self.educator.explain(&cmd)?;
self.last_explanation = Some(explanation);
match cmd.program.as_str() {
"cd" => {
self.add_to_history(command_str.clone());
self.handle_cd_command(&cmd)?;
self.command_buffer.clear();
return Ok(());
}
"history" => {
self.add_to_history(command_str.clone());
self.handle_history_command(&cmd)?;
self.command_buffer.clear();
return Ok(());
}
"export" => {
self.add_to_history(command_str.clone());
self.handle_export_command(&cmd)?;
self.command_buffer.clear();
return Ok(());
}
"alias" => {
self.add_to_history(command_str.clone());
self.handle_alias_command(&cmd)?;
self.command_buffer.clear();
return Ok(());
}
_ => {
if let Some(aliased_command) = self.aliases.get(cmd.program.as_str()) {
let args_str = if !cmd.args.is_empty() {
format!(" {}", cmd.args.join(" "))
} else {
String::new()
};
command_str = format!("{}{}", aliased_command, args_str);
}
}
}
self.last_output = format!("{}Executing: {}\n", icons::loading().content, command_str);
let start_time = std::time::Instant::now();
let timeout_duration = std::time::Duration::from_secs(5);
let env_vars = self.environment_vars.clone();
let output_result = tokio::time::timeout(
timeout_duration,
self.shell_executor.execute(command_str.clone(), env_vars)
).await;
let output = match output_result {
Ok(Ok(output)) => output,
Ok(Err(e)) => format!("{}Error: {}", icons::error().content, e),
Err(_) => format!("Command timed out after {} seconds", timeout_duration.as_secs()),
};
let duration = start_time.elapsed();
self.last_output = output.clone();
let success = !output.starts_with(icons::error().content.as_ref()) && !output.contains("timed out");
self.output_scroll = 0;
self.session.record_command(
command_str.clone(),
Some(0),
Some(duration.as_millis() as u64),
);
if let Some(ref analytics) = self.analytics {
let working_dir = self.session.state.working_directory.to_string_lossy().to_string();
let _ = analytics.record_command(
&command_str,
success,
&working_dir,
&self.session_id,
);
}
self.add_to_history(command_str);
self.command_buffer.clear();
Ok(())
}
fn handle_cd_command(&mut self, cmd: &arct_core::Command) -> Result<()> {
use std::path::PathBuf;
if self.lesson_mode {
if let Some(ref mut vfs) = self.virtual_fs {
let target_str = if cmd.args.is_empty() {
"~"
} else {
&cmd.args[0]
};
match vfs.change_directory(target_str) {
Ok(new_path) => {
self.last_output = format!(
"{}Changed directory to:\n {}\n\n{}You're in the virtual lesson filesystem\n",
icons::success().content, new_path, icons::hint().content
);
self.output_scroll = 0;
return Ok(());
}
Err(e) => {
self.last_output = format!("{}cd: {}\n", icons::error().content, e);
self.output_scroll = 0;
return Ok(());
}
}
}
}
let target = if cmd.args.is_empty() {
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?
} else {
let target_str = &cmd.args[0];
if target_str == "~" {
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?
} else if target_str.starts_with("~/") {
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?;
home.join(&target_str[2..])
} else {
PathBuf::from(target_str)
}
};
match std::env::set_current_dir(&target) {
Ok(_) => {
self.session.state.working_directory = std::env::current_dir()?;
self.update_context()?;
let new_dir = std::env::current_dir()?;
self.last_output = format!(
"{}Changed directory to:\n {}\n",
icons::success().content, new_dir.display()
);
self.output_scroll = 0;
self.session.record_command(
format!("cd {}", cmd.args.join(" ")),
Some(0),
Some(0),
);
Ok(())
}
Err(e) => {
self.last_output = format!(
"{}cd: {}\n Cannot change to: {}\n",
icons::error().content, e, target.display()
);
self.output_scroll = 0;
self.session.record_command(
format!("cd {}", cmd.args.join(" ")),
Some(1),
Some(0),
);
Ok(())
}
}
}
pub fn update_context(&mut self) -> Result<()> {
let working_dir = &self.session.state.working_directory;
self.context = ContextDetector::detect(working_dir)?;
Ok(())
}
fn history_previous(&mut self) {
if self.command_history.is_empty() {
return;
}
match self.history_position {
None => {
self.history_position = Some(0);
self.command_buffer = self.command_history[0].clone();
}
Some(pos) => {
if pos < self.command_history.len() - 1 {
let new_pos = pos + 1;
self.history_position = Some(new_pos);
self.command_buffer = self.command_history[new_pos].clone();
}
}
}
}
fn history_next(&mut self) {
match self.history_position {
None => {
}
Some(0) => {
self.history_position = None;
self.command_buffer.clear();
}
Some(pos) => {
let new_pos = pos - 1;
self.history_position = Some(new_pos);
self.command_buffer = self.command_history[new_pos].clone();
}
}
}
fn add_to_history(&mut self, command: String) {
if command.trim().is_empty() {
return;
}
if let Some(last) = self.command_history.first() {
if last == &command {
return;
}
}
self.command_history.insert(0, command);
if self.command_history.len() > 1000 {
self.command_history.truncate(1000);
}
self.save_history();
}
fn save_history(&self) {
let session_data = crate::persistence::SessionData {
command_history: self.command_history.clone(),
last_updated: chrono::Local::now().to_rfc3339(),
};
if let Err(e) = crate::persistence::save_session(&session_data) {
tracing::warn!("Failed to save session history: {}", e);
}
}
fn handle_history_command(&mut self, cmd: &arct_core::Command) -> Result<()> {
let limit = if cmd.args.is_empty() {
50 } else {
cmd.args[0].parse::<usize>().unwrap_or(50)
};
if self.command_history.is_empty() {
self.last_output = "No commands in history yet.\n".to_string();
} else {
let mut output = String::new();
let total = self.command_history.len();
for (i, cmd) in self.command_history.iter().rev().enumerate().take(limit) {
let index = total - self.command_history.len() + i + 1;
output.push_str(&format!("{:5} {}\n", index, cmd));
}
self.last_output = output;
}
self.output_scroll = 0;
self.session.record_command(
format!("history {}", if cmd.args.is_empty() { String::new() } else { cmd.args.join(" ") }),
Some(0),
Some(0),
);
Ok(())
}
fn handle_export_command(&mut self, cmd: &arct_core::Command) -> Result<()> {
if cmd.args.is_empty() {
if self.environment_vars.is_empty() {
self.last_output = "No environment variables set.\n".to_string();
} else {
let mut output = String::new();
output.push_str("Exported environment variables:\n\n");
let mut vars: Vec<_> = self.environment_vars.iter().collect();
vars.sort_by_key(|(k, _)| *k);
for (key, value) in vars {
output.push_str(&format!(" {}={}\n", key, value));
}
self.last_output = output;
}
} else {
for arg in &cmd.args {
if let Some((key, value)) = arg.split_once('=') {
let key = key.trim().to_string();
let value = value.trim().to_string();
let value = if (value.starts_with('"') && value.ends_with('"')) ||
(value.starts_with('\'') && value.ends_with('\'')) {
value[1..value.len()-1].to_string()
} else {
value
};
self.environment_vars.insert(key.clone(), value.clone());
self.last_output = format!("{}Exported: {}={}\n", icons::success().content, key, value);
self.config.shell.environment = self.environment_vars.clone();
if let Err(e) = self.config.save() {
tracing::warn!("Failed to save config: {}", e);
}
} else {
self.last_output = format!("{}Invalid export syntax: {}\n Usage: export VAR=value\n", icons::error().content, arg);
break;
}
}
}
self.output_scroll = 0;
self.session.record_command(
format!("export {}", cmd.args.join(" ")),
Some(0),
Some(0),
);
Ok(())
}
fn handle_alias_command(&mut self, cmd: &arct_core::Command) -> Result<()> {
if cmd.args.is_empty() {
if self.aliases.is_empty() {
self.last_output = "No aliases defined.\n".to_string();
} else {
let mut output = String::new();
output.push_str("Defined aliases:\n\n");
let mut aliases: Vec<_> = self.aliases.iter().collect();
aliases.sort_by_key(|(k, _)| *k);
for (name, command) in aliases {
output.push_str(&format!(" {}='{}'\n", name, command));
}
self.last_output = output;
}
} else {
let arg = cmd.args.join(" ");
if let Some((name, command)) = arg.split_once('=') {
let name = name.trim().to_string();
let command = command.trim().to_string();
let command = if (command.starts_with('"') && command.ends_with('"')) ||
(command.starts_with('\'') && command.ends_with('\'')) {
command[1..command.len()-1].to_string()
} else {
command
};
self.aliases.insert(name.clone(), command.clone());
self.last_output = format!("{}Alias created: {}='{}'\n", icons::success().content, name, command);
self.config.shell.aliases = self.aliases.clone();
if let Err(e) = self.config.save() {
tracing::warn!("Failed to save config: {}", e);
}
} else {
self.last_output = format!("{}Invalid alias syntax: {}\n Usage: alias name='command'\n", icons::error().content, arg);
}
}
self.output_scroll = 0;
self.session.record_command(
format!("alias {}", cmd.args.join(" ")),
Some(0),
Some(0),
);
Ok(())
}
fn handle_autocomplete(&mut self) -> Result<()> {
if self.command_buffer.is_empty() {
return Ok(());
}
let working_dir = &self.session.state.working_directory;
let result = self.autocompleter.complete(&self.command_buffer, working_dir)?;
if !result.common_prefix.is_empty() && result.common_prefix != self.command_buffer {
let tokens: Vec<&str> = self.command_buffer.split_whitespace().collect();
if tokens.is_empty() {
self.command_buffer = result.common_prefix.clone();
} else if tokens.len() == 1 && !self.command_buffer.ends_with(' ') {
self.command_buffer = result.common_prefix.clone();
} else {
let last_token = tokens.last().unwrap_or(&"");
if let Some(idx) = self.command_buffer.rfind(last_token) {
self.command_buffer.truncate(idx);
self.command_buffer.push_str(&result.common_prefix);
}
}
}
self.completion_suggestions = result.completions.into_iter().take(10).collect();
Ok(())
}
pub fn toggle_ai_mode(&mut self) {
if self.ai_provider.is_some() {
self.ai_mode = !self.ai_mode;
if self.ai_mode {
self.ai_input_buffer.clear();
self.ai_loading = false;
}
} else {
self.last_output = format!("{}AI is not enabled. Configure it in ~/.config/arct/config.toml\n", icons::error().content);
}
}
pub fn toggle_lesson_mode(&mut self) {
self.lesson_mode = !self.lesson_mode;
if self.lesson_mode {
match arct_core::VirtualFileSystem::new("nav-basics", &self.session_id) {
Ok(vfs) => {
self.virtual_fs = Some(vfs);
self.last_output = format!("{}Lesson mode activated! You're now in a safe virtual filesystem.\n\nPress Ctrl+L again to return to normal mode.\n\nNavigate through lessons using the Learning panel on the right.\n", icons::lesson().content);
}
Err(e) => {
self.last_output = format!("{}Failed to initialize lesson environment: {}\n", icons::error().content, e);
self.lesson_mode = false;
return;
}
}
if self.lesson_panel.is_none() {
self.lesson_panel = Self::initialize_lesson_panel();
}
} else {
self.virtual_fs = None;
self.last_output = format!("{}Lesson mode deactivated. Back to normal shell mode and real filesystem.\n", icons::learning().content);
}
}
pub async fn ask_ai(&mut self, question: String) -> Result<()> {
if self.ai_provider.is_none() {
return Ok(());
}
if question.trim().is_empty() {
return Ok(());
}
self.ai_loading = true;
self.ai_conversation.push(arct_ai::Message::user(question.clone()));
let user_name = self.config.general.user_name.as_deref().unwrap_or("there");
let system_prompt = format!(
"You are an AI teaching assistant integrated into Arc Academy Terminal, \
an interactive terminal learning application. Your role is to help users \
learn shell commands and terminal skills.\n\n\
You're helping {}, so address them by name occasionally to make the \
interaction personal and engaging.\n\n\
Guidelines:\n\
- Teach shell commands with clear, executable examples\n\
- Explain concepts in beginner-friendly language\n\
- Provide commands the user can type themselves in the terminal\n\
- Keep responses concise (3-4 sentences or a short example)\n\
- Focus on common Linux/Unix commands (bash, grep, find, etc.)\n\
- Suggest safer alternatives when appropriate\n\
- You are NOT Claude Code - you cannot execute commands or use tools\n\
- You are a teaching assistant helping someone learn the terminal\n\
- Be encouraging and supportive in your teaching approach",
user_name
);
let mut messages = vec![
arct_ai::Message::system(system_prompt),
];
messages.extend(self.ai_conversation.clone());
let provider = self.ai_provider.as_ref().unwrap();
let response = provider.complete(&messages, None).await;
self.ai_loading = false;
match response {
Ok(ai_response) => {
let cleaned_content = Self::strip_markdown(&ai_response.content);
self.ai_conversation.push(arct_ai::Message::assistant(ai_response.content.clone()));
self.ai_response = Some(cleaned_content);
Ok(())
}
Err(e) => {
self.ai_response = Some(format!("{}Error: {}", icons::error().content, e));
Err(anyhow::anyhow!("AI request failed: {}", e))
}
}
}
pub fn clear_ai_conversation(&mut self) {
self.ai_conversation.clear();
self.ai_response = None;
self.ai_input_buffer.clear();
}
fn strip_markdown(text: &str) -> String {
let mut result = String::new();
let mut in_code_block = false;
let mut skip_line = false;
for line in text.lines() {
if line.trim().starts_with("```") {
in_code_block = !in_code_block;
skip_line = true;
}
if skip_line {
skip_line = false;
continue;
}
let mut cleaned = line.to_string();
if cleaned.trim_start().starts_with('#') {
cleaned = cleaned.trim_start().trim_start_matches('#').trim().to_string();
}
cleaned = cleaned.replace("**", "").replace("*", "");
cleaned = cleaned.replace('`', "");
if let Some(stripped) = cleaned.trim_start().strip_prefix("- ") {
let indent = cleaned.len() - cleaned.trim_start().len();
cleaned = format!("{}{}", " ".repeat(indent), stripped);
}
result.push_str(&cleaned);
result.push('\n');
}
result.trim_end().to_string()
}
async fn handle_onboarding_event(&mut self, key: KeyEvent) -> Result<()> {
if let Some(wizard) = self.onboarding.as_mut() {
match key.code {
KeyCode::Char(c) if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT => {
wizard.handle_char(c);
}
KeyCode::Backspace => {
wizard.handle_backspace();
}
KeyCode::Up => {
wizard.handle_up();
}
KeyCode::Down => {
let max_options = match wizard.step {
crate::panels::onboarding::OnboardingStep::AskAI => 3,
crate::panels::onboarding::OnboardingStep::AskAIProvider => 3,
_ => 1,
};
wizard.handle_down(max_options);
}
KeyCode::Enter => {
wizard.handle_enter();
if wizard.step == crate::panels::onboarding::OnboardingStep::Complete {
if let Some(wizard) = self.onboarding.take() {
self.complete_onboarding(wizard).await?;
}
}
}
_ => {}
}
}
Ok(())
}
async fn complete_onboarding(&mut self, wizard: crate::panels::onboarding::OnboardingWizard) -> Result<()> {
if !wizard.user_name.is_empty() {
self.config.general.user_name = Some(wizard.user_name.clone());
}
if let Some(ai_enabled) = wizard.ai_enabled {
self.config.ai.enabled = ai_enabled;
if ai_enabled {
if wizard.ai_provider.as_deref() == Some("claude-code") {
self.config.ai.provider = "claude-cli".to_string();
self.config.ai.model = Some("claude-sonnet-4".to_string());
} else if wizard.ai_provider.as_deref() == Some("own") {
self.config.ai.provider = "local".to_string();
self.config.ai.endpoint = Some("http://localhost:11434".to_string());
self.config.ai.model = Some("llama3.2".to_string());
} else if wizard.ai_provider.as_deref() == Some("managed") {
self.config.ai.provider = "managed".to_string();
}
}
}
self.config.general.setup_complete = true;
self.config.save()?;
if self.config.ai.enabled {
match Self::create_ai_provider(&self.config.ai) {
Ok(provider) => {
self.ai_provider = Some(provider);
}
Err(e) => {
self.last_output = format!("{}AI provider initialization failed: {}\n", icons::warning().content, e);
}
}
}
self.onboarding = None;
let name = self.config.general.user_name.as_deref().unwrap_or("there");
let mut welcome_msg = format!(
"{}Welcome, {}!\n\n\
You're all set to start learning shell commands!\n\n",
icons::celebration().content, name
);
if self.config.ai.enabled {
match self.config.ai.provider.as_str() {
"claude-cli" => {
welcome_msg.push_str(
&format!("{}Using Claude Code CLI - your Max subscription is ready!\n\
Press Ctrl+A to ask Claude for help.\n\n", icons::ai().content)
);
}
"anthropic" | "openai" => {
welcome_msg.push_str(
&format!("{}To use AI features, set your API key:\n\
export ARCT_AI_API_KEY=\"your-api-key-here\"\n\n", icons::note().content)
);
}
"local" => {
welcome_msg.push_str(
&format!("{}Using local LLM - make sure your server is running!\n\n", icons::info().content)
);
}
_ => {}
}
}
welcome_msg.push_str("Press ? for help, or just start typing commands.\n");
self.last_output = welcome_msg;
Ok(())
}
async fn handle_settings_event(&mut self, key: KeyEvent) -> Result<()> {
let (action, selected_field) = {
let panel = match self.settings_panel.as_ref() {
Some(p) => p,
None => return Ok(()),
};
let action = if panel.editing {
match key.code {
KeyCode::Char(c) if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT => {
SettingsAction::PushChar(c)
}
KeyCode::Backspace => SettingsAction::PopChar,
KeyCode::Enter => SettingsAction::SaveEdit,
KeyCode::Esc => SettingsAction::CancelEdit,
_ => SettingsAction::None,
}
} else {
match key.code {
KeyCode::Up | KeyCode::Char('k') if key.modifiers == KeyModifiers::NONE => {
SettingsAction::PreviousField
}
KeyCode::Down | KeyCode::Char('j') if key.modifiers == KeyModifiers::NONE => {
SettingsAction::NextField
}
KeyCode::Enter => SettingsAction::StartEdit,
KeyCode::Esc | KeyCode::Char('s') if key.modifiers == KeyModifiers::CONTROL => {
SettingsAction::Close
}
_ => SettingsAction::None,
}
};
(action, panel.selected_field)
};
match action {
SettingsAction::PushChar(c) => {
if let Some(panel) = self.settings_panel.as_mut() {
panel.push_char(c);
}
}
SettingsAction::PopChar => {
if let Some(panel) = self.settings_panel.as_mut() {
panel.pop_char();
}
}
SettingsAction::SaveEdit => {
if let Some(panel) = self.settings_panel.as_mut() {
panel.save_edit(&mut self.config)?;
if selected_field == crate::panels::settings::SettingField::Theme {
self.theme = Theme::from_name(&self.config.theme.default_theme);
}
if selected_field == crate::panels::settings::SettingField::AIEnabled {
if self.config.ai.enabled {
match Self::create_ai_provider(&self.config.ai) {
Ok(provider) => {
self.ai_provider = Some(provider);
}
Err(e) => {
tracing::warn!("Failed to initialize AI provider: {}", e);
self.ai_provider = None;
}
}
} else {
self.ai_provider = None;
self.ai_mode = false;
}
}
}
}
SettingsAction::CancelEdit => {
if let Some(panel) = self.settings_panel.as_mut() {
panel.cancel_editing();
}
}
SettingsAction::PreviousField => {
if let Some(panel) = self.settings_panel.as_mut() {
panel.previous_field();
}
}
SettingsAction::NextField => {
if let Some(panel) = self.settings_panel.as_mut() {
panel.next_field();
}
}
SettingsAction::StartEdit => {
if let Some(panel) = self.settings_panel.as_mut() {
panel.start_editing(&self.config);
}
}
SettingsAction::Close => {
self.settings_panel = None;
}
SettingsAction::None => {}
}
Ok(())
}
}
enum SettingsAction {
PushChar(char),
PopChar,
SaveEdit,
CancelEdit,
PreviousField,
NextField,
StartEdit,
Close,
None,
}