1use crate::events::{Action, Event, EventHandler, key_to_action};
4use crate::icons;
5use crate::panels::PanelId;
6use crate::shell::ShellExecutor;
7use crate::theme::Theme;
8use crate::ui;
9use anyhow::Result;
10use arct_core::{CommandAnalyzer, Context, ContextDetector, Educator, Session};
11use crossterm::{
12 event::{KeyCode, KeyEvent, KeyModifiers},
13 execute,
14 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
15};
16use ratatui::{
17 backend::{Backend, CrosstermBackend},
18 Terminal,
19};
20use std::collections::HashMap;
21use std::io;
22
23pub struct App {
25 pub should_quit: bool,
27
28 pub active_panel: PanelId,
30
31 pub session: Session,
33
34 pub context: Context,
36
37 pub analyzer: CommandAnalyzer,
39
40 pub educator: Educator,
42
43 pub theme: Theme,
45
46 event_handler: EventHandler,
48
49 pub show_help: bool,
51
52 pub command_buffer: String,
54
55 pub last_explanation: Option<arct_core::Explanation>,
57
58 shell_executor: ShellExecutor,
60
61 pub last_output: String,
63
64 pub output_scroll: usize,
66
67 command_history: Vec<String>,
69
70 history_position: Option<usize>,
72
73 pub environment_vars: HashMap<String, String>,
75
76 pub aliases: HashMap<String, String>,
78
79 pub config: arct_config::Config,
81
82 autocompleter: crate::autocomplete::Autocompleter,
84
85 pub completion_suggestions: Vec<String>,
87
88 ai_provider: Option<Box<dyn arct_ai::AIProvider>>,
90
91 pub ai_conversation: Vec<arct_ai::Message>,
93
94 pub ai_input_buffer: String,
96
97 pub ai_response: Option<String>,
99
100 pub ai_loading: bool,
102
103 pub ai_mode: bool,
105
106 pub onboarding: Option<crate::panels::onboarding::OnboardingWizard>,
108
109 pub settings_panel: Option<crate::panels::settings::SettingsPanel>,
111
112 pub analytics: Option<crate::analytics::Analytics>,
114
115 session_id: String,
117
118 pub lesson_panel: Option<crate::panels::lesson::LessonPanel>,
120
121 pub lesson_mode: bool,
123
124 pub virtual_fs: Option<arct_core::VirtualFileSystem>,
126
127 pub lesson_menu: Option<crate::panels::lesson_menu::LessonMenuPanel>,
129
130 pub completed_lessons: std::collections::HashSet<String>,
132
133 pub user_stats: arct_core::UserStats,
135
136 pub challenge_manager: arct_core::ChallengeManager,
138
139 pub recommendation_engine: arct_core::RecommendationEngine,
141
142 pub achievements_panel: Option<crate::panels::achievements::AchievementsPanel>,
144
145 pub progress_panel: Option<crate::panels::progress::ProgressPanel>,
147
148 pub challenges_panel: Option<crate::panels::challenges::ChallengesPanel>,
150
151 pub pending_achievements: Vec<arct_core::Achievement>,
153
154 pub showing_notification: Option<crate::panels::notification::NotificationPanel>,
156}
157
158impl App {
159 pub fn new() -> Result<Self> {
161 let session = Session::new();
162 let working_dir = session.state.working_directory.clone();
163 let context = ContextDetector::detect(&working_dir)?;
164
165 let config = arct_config::Config::load().unwrap_or_else(|e| {
167 tracing::warn!("Failed to load config, using defaults: {}", e);
168 arct_config::Config::default()
169 });
170
171 let session_data = match crate::persistence::load_session() {
173 Ok(data) => {
174 tracing::info!("Loaded session with {} commands, {} completed lessons",
175 data.command_history.len(),
176 data.completed_lessons.len()
177 );
178 data
179 }
180 Err(e) => {
181 tracing::warn!("Failed to load session: {}", e);
182 crate::persistence::SessionData::new()
183 }
184 };
185
186 let command_history = session_data.command_history;
187
188 let aliases = config.shell.aliases.clone();
190 let environment_vars = config.shell.environment.clone();
191
192 let theme = Theme::from_name(&config.theme.default_theme);
194
195 let ai_provider = if config.ai.enabled {
197 match Self::create_ai_provider(&config.ai) {
198 Ok(provider) => {
199 tracing::info!("AI provider initialized: {}", provider.name());
200 Some(provider)
201 }
202 Err(e) => {
203 tracing::warn!("Failed to initialize AI provider: {}", e);
204 None
205 }
206 }
207 } else {
208 None
209 };
210
211 let onboarding = if !config.general.setup_complete {
213 Some(crate::panels::onboarding::OnboardingWizard::new())
214 } else {
215 None
216 };
217
218 let welcome_message = if config.general.setup_complete {
220 let name = config.general.user_name.as_deref().unwrap_or("there");
221 let mut msg = format!("{}Welcome back, {}!\n\n", icons::welcome().content, name);
222
223 msg.push_str("Quick reminders:\n");
225 if config.ai.enabled {
226 msg.push_str(" • Press Ctrl+A to ask the AI for help\n");
227 }
228 msg.push_str(" • Press ? for help\n");
229 msg.push_str(" • Press Ctrl+S for settings\n");
230 msg.push_str(" • Tab to autocomplete commands\n\n");
231 msg.push_str("Start typing a command to begin!\n");
232 msg
233 } else {
234 String::new()
235 };
236
237 let mut user_stats = session_data.user_stats;
239 user_stats.update_streak(); let mut challenge_manager = session_data.challenge_manager;
243 challenge_manager.get_daily_challenge();
245 challenge_manager.get_weekly_challenge();
246
247 let completed_lessons = session_data.completed_lessons;
249
250 Ok(Self {
251 should_quit: false,
252 active_panel: PanelId::Shell,
253 session,
254 context,
255 analyzer: CommandAnalyzer::new(),
256 educator: Educator::new(),
257 theme,
258 event_handler: EventHandler::new(),
259 show_help: false,
260 command_buffer: String::new(),
261 last_explanation: None,
262 shell_executor: ShellExecutor::new()?,
263 last_output: welcome_message,
264 output_scroll: 0,
265 command_history,
266 history_position: None,
267 environment_vars,
268 aliases,
269 config,
270 autocompleter: crate::autocomplete::Autocompleter::new(),
271 completion_suggestions: Vec::new(),
272 ai_provider,
273 ai_conversation: Vec::new(),
274 ai_input_buffer: String::new(),
275 ai_response: None,
276 ai_loading: false,
277 ai_mode: false,
278 onboarding,
279 settings_panel: None,
280 analytics: crate::analytics::Analytics::new().ok(),
281 session_id: uuid::Uuid::new_v4().to_string(),
282 lesson_panel: Self::initialize_lesson_panel(),
283 lesson_mode: false,
284 virtual_fs: None,
285 lesson_menu: None,
286 completed_lessons,
287 user_stats,
288 challenge_manager,
289 recommendation_engine: arct_core::RecommendationEngine::new(),
290 achievements_panel: None,
291 progress_panel: None,
292 challenges_panel: None,
293 pending_achievements: Vec::new(),
294 showing_notification: None,
295 })
296 }
297
298 fn initialize_lesson_panel() -> Option<crate::panels::lesson::LessonPanel> {
300 Some(crate::panels::lesson::LessonPanel::new())
301 }
302
303 fn create_ai_provider(config: &arct_config::AIConfig) -> Result<Box<dyn arct_ai::AIProvider>> {
305 let ai_config = match config.provider.as_str() {
306 "anthropic" => {
307 let api_key = config.api_key.clone()
308 .ok_or_else(|| anyhow::anyhow!("Anthropic API key not set"))?;
309 let model = config.model.clone()
310 .unwrap_or_else(|| "claude-3-5-sonnet-20241022".to_string());
311 arct_ai::AIConfig::Anthropic { api_key, model }
312 }
313 "openai" => {
314 let api_key = config.api_key.clone()
315 .ok_or_else(|| anyhow::anyhow!("OpenAI API key not set"))?;
316 let model = config.model.clone()
317 .unwrap_or_else(|| "gpt-4-turbo-preview".to_string());
318 arct_ai::AIConfig::OpenAI { api_key, model }
319 }
320 "local" => {
321 let endpoint = config.endpoint.clone()
322 .unwrap_or_else(|| "http://localhost:11434".to_string());
323 let model = config.model.clone();
324 arct_ai::AIConfig::Local { endpoint, model }
325 }
326 "managed" => {
327 let auth_token = config.api_key.clone()
328 .ok_or_else(|| anyhow::anyhow!("Managed API token not set"))?;
329 arct_ai::AIConfig::Managed { auth_token }
330 }
331 "claude-cli" => {
332 let model = config.model.clone();
334 arct_ai::AIConfig::ClaudeCLI { model }
335 }
336 _ => arct_ai::AIConfig::Disabled,
337 };
338
339 arct_ai::AIFactory::create(&ai_config)
340 .map_err(|e| anyhow::anyhow!("Failed to create AI provider: {}", e))
341 }
342
343 fn show_splash_screen() -> Result<()> {
345 use crossterm::{
346 cursor,
347 style::{Color, Print, SetForegroundColor, ResetColor},
348 terminal::{Clear, ClearType},
349 };
350 use std::io::Write;
351
352 let mut stdout = io::stdout();
353
354 execute!(stdout, Clear(ClearType::All), cursor::MoveTo(0, 0))?;
356
357 let logo = r#"
359 ▄▄▄ ██▀███ ▄████▄ ▄▄▄ ▄████▄ ▄▄▄ ▓█████▄ ▓█████ ███▄ ▄███▓▓██ ██▓
360▒████▄ ▓██ ▒ ██▒▒██▀ ▀█ ▒████▄ ▒██▀ ▀█ ▒████▄ ▒██▀ ██▌▓█ ▀ ▓██▒▀█▀ ██▒ ▒██ ██▒
361▒██ ▀█▄ ▓██ ░▄█ ▒▒▓█ ▄ ▒██ ▀█▄ ▒▓█ ▄ ▒██ ▀█▄ ░██ █▌▒███ ▓██ ▓██░ ▒██ ██░
362░██▄▄▄▄██ ▒██▀▀█▄ ▒▓▓▄ ▄██▒ ░██▄▄▄▄██ ▒▓▓▄ ▄██▒░██▄▄▄▄██ ░▓█▄ ▌▒▓█ ▄ ▒██ ▒██ ░ ▐██▓░
363 ▓█ ▓██▒░██▓ ▒██▒▒ ▓███▀ ░ ▓█ ▓██▒▒ ▓███▀ ░ ▓█ ▓██▒░▒████▓ ░▒████▒▒██▒ ░██▒ ░ ██▒▓░
364 ▒▒ ▓▒█░░ ▒▓ ░▒▓░░ ░▒ ▒ ░ ▒▒ ▓▒█░░ ░▒ ▒ ░ ▒▒ ▓▒█░ ▒▒▓ ▒ ░░ ▒░ ░░ ▒░ ░ ░ ██▒▒▒
365 ▒ ▒▒ ░ ░▒ ░ ▒░ ░ ▒ ▒ ▒▒ ░ ░ ▒ ▒ ▒▒ ░ ░ ▒ ▒ ░ ░ ░░ ░ ░ ▓██ ░▒░
366 ░ ▒ ░░ ░ ░ ░ ▒ ░ ░ ▒ ░ ░ ░ ░ ░ ░ ▒ ▒ ░░
367 ░ ░ ░ ░ ░ ░ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░
368 ░ ░ ░ ░ ░
369"#;
370
371 let tagline = "Λ° Learn Shell Commands Interactively with AI";
372 let version = format!("v{}", env!("CARGO_PKG_VERSION"));
373
374 let (width, height) = crossterm::terminal::size()?;
376 let start_row = (height / 2).saturating_sub(7); let logo_width = 92;
380 let logo_col = (width / 2).saturating_sub(logo_width / 2);
381
382 for (i, line) in logo.lines().enumerate() {
384 let row = start_row + i as u16;
385 execute!(
386 stdout,
387 cursor::MoveTo(logo_col, row),
388 SetForegroundColor(Color::Rgb { r: 255, g: 140, b: 0 }), Print(line),
390 ResetColor
391 )?;
392 }
393
394 let tagline_row = start_row + 11;
396 let tagline_col = (width / 2).saturating_sub((tagline.len() / 2) as u16);
397 execute!(
398 stdout,
399 cursor::MoveTo(tagline_col, tagline_row),
400 SetForegroundColor(Color::White),
401 Print(tagline),
402 ResetColor
403 )?;
404
405 let version_row = tagline_row + 1;
407 let version_col = (width / 2).saturating_sub((version.len() / 2) as u16);
408 execute!(
409 stdout,
410 cursor::MoveTo(version_col, version_row),
411 SetForegroundColor(Color::DarkGrey),
412 Print(version),
413 ResetColor
414 )?;
415
416 stdout.flush()?;
417
418 std::thread::sleep(std::time::Duration::from_millis(1500));
420
421 execute!(stdout, Clear(ClearType::All), cursor::MoveTo(0, 0))?;
423
424 Ok(())
425 }
426
427 pub async fn run(&mut self) -> Result<()> {
429 Self::show_splash_screen()?;
431
432 enable_raw_mode()?;
434 let mut stdout = io::stdout();
435 execute!(stdout, EnterAlternateScreen)?;
436 let backend = CrosstermBackend::new(stdout);
437 let mut terminal = Terminal::new(backend)?;
438
439 self.event_handler.start().await;
441
442 let result = self.main_loop(&mut terminal).await;
444
445 self.save_session();
447
448 disable_raw_mode()?;
450 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
451 terminal.show_cursor()?;
452
453 result
454 }
455
456 async fn main_loop<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
458 loop {
459 terminal.draw(|f| ui::draw(f, self))?;
461
462 if let Some(event) = self.event_handler.next().await {
464 self.handle_event(event).await?;
465 }
466
467 if self.should_quit {
469 break;
470 }
471 }
472
473 Ok(())
474 }
475
476 async fn handle_event(&mut self, event: Event) -> Result<()> {
478 match event {
479 Event::Key(key) => {
480 if self.onboarding.is_some() {
482 return self.handle_onboarding_event(key).await;
483 }
484
485 if self.settings_panel.is_some() {
487 return self.handle_settings_event(key).await;
488 }
489
490 if let Some(ref mut menu) = self.lesson_menu {
492 match key.code {
493 KeyCode::Up | KeyCode::Char('k') => {
494 menu.select_previous();
495 return Ok(());
496 }
497 KeyCode::Down | KeyCode::Char('j') => {
498 menu.select_next();
499 return Ok(());
500 }
501 KeyCode::Char(c) if c.is_ascii_digit() => {
502 if let Some(digit) = c.to_digit(10) {
503 let lesson_num = if digit == 0 { 10 } else { digit as usize };
505 menu.select_by_number(lesson_num);
506 }
507 return Ok(());
508 }
509 KeyCode::Enter => {
510 if let Some(lesson) = menu.get_selected_lesson() {
512 if let Some(ref mut panel) = self.lesson_panel {
513 panel.load_lesson(lesson);
514 self.lesson_menu = None;
515 self.last_output = format!("{}Lesson loaded! Follow the instructions in the lesson panel.\n", icons::lesson().content);
516 }
517 }
518 return Ok(());
519 }
520 KeyCode::Esc | KeyCode::Char('q') => {
521 self.lesson_menu = None;
522 return Ok(());
523 }
524 _ => {}
525 }
526 }
527
528 if self.ai_mode && self.active_panel == PanelId::Shell && !self.show_help {
530 match key.code {
531 KeyCode::Char(c) if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT => {
532 self.ai_input_buffer.push(c);
533 return Ok(());
534 }
535 KeyCode::Backspace => {
536 self.ai_input_buffer.pop();
537 return Ok(());
538 }
539 KeyCode::Enter => {
540 if !self.ai_input_buffer.is_empty() {
541 let question = self.ai_input_buffer.clone();
542 self.ai_input_buffer.clear();
543 self.ask_ai(question).await?;
544 }
545 return Ok(());
546 }
547 _ => {}
548 }
549 }
550
551 if self.active_panel == PanelId::Shell && !self.show_help && !self.ai_mode {
553 match key.code {
554 KeyCode::Char(c) if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT => {
555 self.command_buffer.push(c);
556 self.history_position = None;
558 self.completion_suggestions.clear();
560 return Ok(());
561 }
562 KeyCode::Backspace => {
563 self.command_buffer.pop();
564 self.history_position = None;
566 self.completion_suggestions.clear();
568 return Ok(());
569 }
570 KeyCode::Tab if key.modifiers == KeyModifiers::NONE => {
571 if !self.command_buffer.is_empty() {
574 self.handle_autocomplete()?;
575 return Ok(());
576 }
577 }
579 KeyCode::Up => {
580 self.history_previous();
582 return Ok(());
583 }
584 KeyCode::Down => {
585 self.history_next();
587 return Ok(());
588 }
589 _ => {}
590 }
591 }
592
593 let action = key_to_action(key);
595 self.handle_action(action).await?;
596 }
597 Event::Resize(_, _) => {
598 }
600 Event::Tick => {
601 }
603 Event::Quit => {
604 self.should_quit = true;
605 }
606 }
607
608 Ok(())
609 }
610
611 async fn handle_action(&mut self, action: Action) -> Result<()> {
613 match action {
614 Action::Quit => {
615 if self.show_help {
616 self.show_help = false;
617 } else {
618 self.should_quit = true;
619 }
620 }
621 Action::NextPanel => {
622 self.active_panel = self.active_panel.next();
623 }
625 Action::PreviousPanel => {
626 self.active_panel = self.active_panel.previous();
627 }
629 Action::ScrollUp => {
630 if self.active_panel == PanelId::Output {
632 self.output_scroll = self.output_scroll.saturating_sub(1);
633 }
634 }
635 Action::ScrollDown => {
636 if self.active_panel == PanelId::Output {
638 let total_lines = self.last_output.lines().count();
639 if self.output_scroll < total_lines.saturating_sub(1) {
640 self.output_scroll += 1;
641 }
642 }
643 }
644 Action::PageUp => {
645 if self.active_panel == PanelId::Output {
647 self.output_scroll = self.output_scroll.saturating_sub(10);
648 }
649 }
650 Action::PageDown => {
651 if self.active_panel == PanelId::Output {
653 let total_lines = self.last_output.lines().count();
654 self.output_scroll = (self.output_scroll + 10).min(total_lines.saturating_sub(1));
655 }
656 }
657 Action::ScrollOutputUp => {
658 self.output_scroll = self.output_scroll.saturating_sub(1);
660 }
661 Action::ScrollOutputDown => {
662 let total_lines = self.last_output.lines().count();
664 if self.output_scroll < total_lines.saturating_sub(1) {
665 self.output_scroll += 1;
666 }
667 }
668 Action::Help => {
669 self.show_help = !self.show_help;
670 }
671 Action::ToggleTheme => {
672 self.theme = self.theme.cycle_next();
673 }
674 Action::ToggleAI => {
675 self.toggle_ai_mode();
676 }
677 Action::ToggleSettings => {
678 if self.settings_panel.is_some() {
679 self.settings_panel = None;
680 } else {
681 self.settings_panel = Some(crate::panels::settings::SettingsPanel::new());
682 }
683 }
684 Action::ToggleLesson => {
685 self.toggle_lesson_mode();
686 }
687 Action::ShowLessonMenu => {
688 if self.lesson_mode {
689 if self.lesson_menu.is_some() {
691 self.lesson_menu = None;
692 } else {
693 self.lesson_menu = Some(crate::panels::lesson_menu::LessonMenuPanel::new());
694 }
695 }
696 }
697 Action::Escape => {
698 if self.showing_notification.is_some() {
699 self.dismiss_notification();
700 } else if self.show_help {
701 self.show_help = false;
702 } else if self.achievements_panel.is_some() {
703 self.achievements_panel = None;
704 } else if self.progress_panel.is_some() {
705 self.progress_panel = None;
706 } else if self.challenges_panel.is_some() {
707 self.challenges_panel = None;
708 } else if self.settings_panel.is_some() {
709 self.settings_panel = None;
710 } else if self.lesson_menu.is_some() {
711 self.lesson_menu = None;
712 } else if self.ai_mode {
713 self.ai_mode = false;
714 }
715 }
716 Action::Enter => {
717 if self.showing_notification.is_some() {
718 self.dismiss_notification();
719 } else if !self.ai_mode {
720 self.execute_command().await?;
721 }
722 }
723 Action::ShowAchievements => {
724 self.toggle_achievements_panel();
725 }
726 Action::ShowProgress => {
727 self.toggle_progress_panel();
728 }
729 Action::ShowChallenges => {
730 self.toggle_challenges_panel();
731 }
732 Action::DismissNotification => {
733 self.dismiss_notification();
734 }
735 _ => {}
736 }
737
738 Ok(())
739 }
740
741 fn has_flag(cmd: &arct_core::Command, flag: &str) -> bool {
743 cmd.flags.iter().any(|f| {
744 f.raw == flag ||
745 f.short == Some(flag.chars().nth(1).unwrap_or(' ')) ||
746 f.long.as_ref().map(|l| l == flag.trim_start_matches("--")).unwrap_or(false)
747 })
748 }
749
750 fn execute_virtual_fs_command(&mut self, cmd: &arct_core::Command) -> Option<String> {
752 if !self.lesson_mode {
753 return None;
754 }
755
756 let vfs = self.virtual_fs.as_mut()?;
757 let program = cmd.program.as_str();
758
759 match program {
760 "pwd" => {
761 let current = vfs.get_current_dir().display().to_string();
762 Some(format!("{}\n", current))
763 }
764
765 "ls" => {
766 let show_hidden = Self::has_flag(cmd, "-a") || Self::has_flag(cmd, "--all");
768
769 match vfs.list_directory(None) {
770 Ok(mut entries) => {
771 if !show_hidden {
773 entries.retain(|e| !e.name.starts_with('.'));
774 }
775
776 let mut output = String::new();
777 for entry in entries {
778 if entry.is_dir {
779 output.push_str(&format!("{}{}/\n", icons::folder().content, entry.name));
780 } else {
781 output.push_str(&format!("{}{}\n", icons::file().content, entry.name));
782 }
783 }
784
785 if output.is_empty() {
786 output = "Empty directory\n".to_string();
787 }
788
789 Some(output)
790 }
791 Err(e) => Some(format!("{}ls: {}\n", icons::error().content, e)),
792 }
793 }
794
795 "cd" => {
796 let target = cmd.args.first().map(|s| s.as_str()).unwrap_or("~");
797 match vfs.change_directory(target) {
798 Ok(new_path) => {
799 Some(format!("{}Changed to: {}\n", icons::folder().content, new_path))
800 }
801 Err(e) => Some(format!("{}cd: {}\n", icons::error().content, e)),
802 }
803 }
804
805 "cat" => {
806 if cmd.args.is_empty() {
807 return Some(format!("{}cat: missing file argument\n", icons::error().content));
808 }
809
810 let mut output = String::new();
811 for file in &cmd.args {
812 match vfs.read_file(file) {
813 Ok(content) => output.push_str(&content),
814 Err(e) => output.push_str(&format!("{}cat: {}\n", icons::error().content, e)),
815 }
816 }
817 Some(output)
818 }
819
820 "mkdir" => {
821 if cmd.args.is_empty() {
822 return Some(format!("{}mkdir: missing directory name\n", icons::error().content));
823 }
824
825 let parents = Self::has_flag(cmd, "-p") || Self::has_flag(cmd, "--parents");
826 let mut output = String::new();
827
828 for dir in &cmd.args {
829 match vfs.create_directory(dir, parents) {
830 Ok(_) => output.push_str(&format!("{}Created directory: {}\n", icons::folder().content, dir)),
831 Err(e) => output.push_str(&format!("{}mkdir: {}\n", icons::error().content, e)),
832 }
833 }
834 Some(output)
835 }
836
837 "touch" => {
838 if cmd.args.is_empty() {
839 return Some(format!("{}touch: missing file argument\n", icons::error().content));
840 }
841
842 let mut output = String::new();
843 for file in &cmd.args {
844 match vfs.touch_file(file) {
845 Ok(_) => output.push_str(&format!("{}Created/updated file: {}\n", icons::file().content, file)),
846 Err(e) => output.push_str(&format!("{}touch: {}\n", icons::error().content, e)),
847 }
848 }
849 Some(output)
850 }
851
852 "rm" => {
853 if cmd.args.is_empty() {
854 return Some(format!("{}rm: missing file argument\n", icons::error().content));
855 }
856
857 let recursive = Self::has_flag(cmd, "-r") || Self::has_flag(cmd, "-R") || Self::has_flag(cmd, "--recursive");
858 let force = Self::has_flag(cmd, "-f") || Self::has_flag(cmd, "--force");
859 let mut output = String::new();
860
861 for item in &cmd.args {
862 match vfs.remove(item, recursive, force) {
863 Ok(_) => output.push_str(&format!("{}Removed: {}\n", icons::success().content, item)),
864 Err(e) => output.push_str(&format!("{}rm: {}\n", icons::error().content, e)),
865 }
866 }
867 Some(output)
868 }
869
870 "mv" => {
871 if cmd.args.len() < 2 {
872 return Some(format!("{}mv: missing source or destination\n", icons::error().content));
873 }
874
875 let source = &cmd.args[0];
876 let dest = &cmd.args[1];
877
878 match vfs.move_item(source, dest) {
879 Ok(_) => Some(format!("{}Moved {} to {}\n", icons::success().content, source, dest)),
880 Err(e) => Some(format!("{}mv: {}\n", icons::error().content, e)),
881 }
882 }
883
884 "cp" => {
885 if cmd.args.len() < 2 {
886 return Some(format!("{}cp: missing source or destination\n", icons::error().content));
887 }
888
889 let recursive = Self::has_flag(cmd, "-r") || Self::has_flag(cmd, "-R") || Self::has_flag(cmd, "--recursive");
890 let source = &cmd.args[0];
891 let dest = &cmd.args[1];
892
893 match vfs.copy(source, dest, recursive) {
894 Ok(_) => Some(format!("{}Copied {} to {}\n", icons::success().content, source, dest)),
895 Err(e) => Some(format!("{}cp: {}\n", icons::error().content, e)),
896 }
897 }
898
899 _ => None,
901 }
902 }
903
904 async fn execute_command(&mut self) -> Result<()> {
906 if self.command_buffer.is_empty() && !self.lesson_mode {
908 return Ok(());
909 }
910
911 let mut command_str = self.command_buffer.clone();
912
913 if self.lesson_mode && command_str.is_empty() {
915 let mut lesson_completed_info: Option<(String, arct_core::Difficulty)> = None;
917
918 if let Some(ref mut lesson_panel) = self.lesson_panel {
919 let validation = lesson_panel.validate_current_step(&command_str);
920
921 if validation.is_success() {
922 self.last_output = format!("{}{}\n\nMoving to next step...\n",
924 icons::success().content,
925 match &validation {
926 arct_core::ValidationResult::Success { message } => message,
927 _ => "Success!",
928 }
929 );
930
931 if !lesson_panel.next_step() {
932 if let Some(lesson) = lesson_panel.current_lesson.as_ref() {
934 lesson_completed_info = Some((lesson.id.clone(), lesson.difficulty));
935 }
936 self.last_output.push_str(&format!("\n{}Congratulations! You've completed this lesson!\n\nPress Ctrl+L to exit lesson mode or 'm' to select another lesson.\n", icons::celebration().content));
937 }
938 } else {
939 self.last_output = "Press Enter to continue...\n".to_string();
941 }
942
943 self.command_buffer.clear();
944 self.add_to_history(command_str.clone());
945 }
946
947 if let Some((lesson_id, difficulty)) = lesson_completed_info {
949 self.record_lesson_completion(lesson_id, difficulty);
950 }
951
952 return Ok(());
953 }
954
955 let cmd = self.analyzer.parse(&command_str)?;
957
958 if self.lesson_mode {
960 let vfs_output = self.execute_virtual_fs_command(&cmd);
962
963 let mut lesson_completed_info: Option<(String, arct_core::Difficulty)> = None;
965
966 if let Some(ref mut lesson_panel) = self.lesson_panel {
967 let validation = lesson_panel.validate_current_step(&command_str);
968
969 let mut output = String::new();
971
972 if let Some(vfs_out) = vfs_output {
974 output.push_str(&vfs_out);
975 output.push('\n');
976 }
977
978 if validation.is_success() {
980 output.push_str(&format!("{}{}\n\n",
982 icons::success().content,
983 match &validation {
984 arct_core::ValidationResult::Success { message } => message,
985 _ => "Correct!",
986 }
987 ));
988
989 if !lesson_panel.next_step() {
990 if let Some(lesson) = lesson_panel.current_lesson.as_ref() {
992 lesson_completed_info = Some((lesson.id.clone(), lesson.difficulty));
993 }
994 output.push_str(&format!("{}Congratulations! You've completed this lesson!\n\nPress Ctrl+L to exit lesson mode or 'm' to select another lesson.\n", icons::celebration().content));
995 } else {
996 output.push_str("Moving to next step...\n");
997 }
998 } else {
999 output.push_str(&match validation {
1001 arct_core::ValidationResult::Failure { message, hint } => {
1002 let mut fail_output = format!("{}{}\n", icons::error().content, message);
1003 if let Some(h) = hint {
1004 fail_output.push_str(&format!("\n{}Hint: {}\n", icons::hint().content, h));
1005 }
1006 fail_output.push_str("\nTry again!\n");
1007 fail_output
1008 }
1009 arct_core::ValidationResult::Partial { message, progress } => {
1010 format!("{}{} ({:.0}% correct)\n\nKeep trying!\n", icons::warning().content, message, progress)
1011 }
1012 _ => "Try again!\n".to_string(),
1013 });
1014 }
1015
1016 self.last_output = output;
1017 self.command_buffer.clear();
1018 self.add_to_history(command_str.clone());
1019 }
1020
1021 if let Some((lesson_id, difficulty)) = lesson_completed_info {
1023 self.record_lesson_completion(lesson_id, difficulty);
1024 }
1025
1026 return Ok(());
1027 }
1028
1029 let explanation = self.educator.explain(&cmd)?;
1031 self.last_explanation = Some(explanation);
1032
1033 match cmd.program.as_str() {
1035 "cd" => {
1036 self.add_to_history(command_str.clone());
1038 self.handle_cd_command(&cmd)?;
1039 self.command_buffer.clear();
1040 return Ok(());
1041 }
1042 "history" => {
1043 self.add_to_history(command_str.clone());
1045 self.handle_history_command(&cmd)?;
1046 self.command_buffer.clear();
1047 return Ok(());
1048 }
1049 "export" => {
1050 self.add_to_history(command_str.clone());
1052 self.handle_export_command(&cmd)?;
1053 self.command_buffer.clear();
1054 return Ok(());
1055 }
1056 "alias" => {
1057 self.add_to_history(command_str.clone());
1059 self.handle_alias_command(&cmd)?;
1060 self.command_buffer.clear();
1061 return Ok(());
1062 }
1063 _ => {
1064 if let Some(aliased_command) = self.aliases.get(cmd.program.as_str()) {
1066 let args_str = if !cmd.args.is_empty() {
1068 format!(" {}", cmd.args.join(" "))
1069 } else {
1070 String::new()
1071 };
1072 command_str = format!("{}{}", aliased_command, args_str);
1073 }
1074 }
1075 }
1076
1077 self.last_output = format!("{}Executing: {}\n", icons::loading().content, command_str);
1079
1080 let start_time = std::time::Instant::now();
1082
1083 let timeout_duration = std::time::Duration::from_secs(5);
1085 let env_vars = self.environment_vars.clone();
1086 let output_result = tokio::time::timeout(
1087 timeout_duration,
1088 self.shell_executor.execute(command_str.clone(), env_vars)
1089 ).await;
1090
1091 let output = match output_result {
1092 Ok(Ok(output)) => output,
1093 Ok(Err(e)) => format!("{}Error: {}", icons::error().content, e),
1094 Err(_) => format!("Command timed out after {} seconds", timeout_duration.as_secs()),
1095 };
1096
1097 let duration = start_time.elapsed();
1098
1099 self.last_output = output.clone();
1101
1102 let success = !output.starts_with(icons::error().content.as_ref()) && !output.contains("timed out");
1104
1105 self.output_scroll = 0;
1107
1108 self.session.record_command(
1110 command_str.clone(),
1111 Some(0),
1112 Some(duration.as_millis() as u64),
1113 );
1114
1115 if let Some(ref analytics) = self.analytics {
1117 let working_dir = self.session.state.working_directory.to_string_lossy().to_string();
1118 let _ = analytics.record_command(
1119 &command_str,
1120 success,
1121 &working_dir,
1122 &self.session_id,
1123 );
1124 }
1125
1126 self.add_to_history(command_str.clone());
1128
1129 self.record_command_for_stats(&command_str);
1131 self.check_and_unlock_achievements();
1132
1133 self.command_buffer.clear();
1135
1136 Ok(())
1137 }
1138
1139 fn handle_cd_command(&mut self, cmd: &arct_core::Command) -> Result<()> {
1141 use std::path::PathBuf;
1142
1143 if self.lesson_mode {
1145 if let Some(ref mut vfs) = self.virtual_fs {
1146 let target_str = if cmd.args.is_empty() {
1147 "~"
1148 } else {
1149 &cmd.args[0]
1150 };
1151
1152 match vfs.change_directory(target_str) {
1153 Ok(new_path) => {
1154 self.last_output = format!(
1155 "{}Changed directory to:\n {}\n\n{}You're in the virtual lesson filesystem\n",
1156 icons::success().content, new_path, icons::hint().content
1157 );
1158 self.output_scroll = 0;
1159 return Ok(());
1160 }
1161 Err(e) => {
1162 self.last_output = format!("{}cd: {}\n", icons::error().content, e);
1163 self.output_scroll = 0;
1164 return Ok(());
1165 }
1166 }
1167 }
1168 }
1169
1170 let target = if cmd.args.is_empty() {
1173 dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?
1175 } else {
1176 let target_str = &cmd.args[0];
1177
1178 if target_str == "~" {
1180 dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?
1181 } else if target_str.starts_with("~/") {
1182 let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?;
1183 home.join(&target_str[2..])
1184 } else {
1185 PathBuf::from(target_str)
1186 }
1187 };
1188
1189 match std::env::set_current_dir(&target) {
1191 Ok(_) => {
1192 self.session.state.working_directory = std::env::current_dir()?;
1194
1195 self.update_context()?;
1197
1198 let new_dir = std::env::current_dir()?;
1200 self.last_output = format!(
1201 "{}Changed directory to:\n {}\n",
1202 icons::success().content, new_dir.display()
1203 );
1204
1205 self.output_scroll = 0;
1207
1208 self.session.record_command(
1210 format!("cd {}", cmd.args.join(" ")),
1211 Some(0),
1212 Some(0),
1213 );
1214
1215 Ok(())
1216 }
1217 Err(e) => {
1218 self.last_output = format!(
1220 "{}cd: {}\n Cannot change to: {}\n",
1221 icons::error().content, e, target.display()
1222 );
1223 self.output_scroll = 0;
1224
1225 self.session.record_command(
1227 format!("cd {}", cmd.args.join(" ")),
1228 Some(1),
1229 Some(0),
1230 );
1231
1232 Ok(())
1233 }
1234 }
1235 }
1236
1237 pub fn update_context(&mut self) -> Result<()> {
1239 let working_dir = &self.session.state.working_directory;
1240 self.context = ContextDetector::detect(working_dir)?;
1241 Ok(())
1242 }
1243
1244 fn history_previous(&mut self) {
1246 if self.command_history.is_empty() {
1247 return;
1248 }
1249
1250 match self.history_position {
1251 None => {
1252 self.history_position = Some(0);
1254 self.command_buffer = self.command_history[0].clone();
1255 }
1256 Some(pos) => {
1257 if pos < self.command_history.len() - 1 {
1259 let new_pos = pos + 1;
1260 self.history_position = Some(new_pos);
1261 self.command_buffer = self.command_history[new_pos].clone();
1262 }
1263 }
1264 }
1265 }
1266
1267 fn history_next(&mut self) {
1269 match self.history_position {
1270 None => {
1271 }
1273 Some(0) => {
1274 self.history_position = None;
1276 self.command_buffer.clear();
1277 }
1278 Some(pos) => {
1279 let new_pos = pos - 1;
1281 self.history_position = Some(new_pos);
1282 self.command_buffer = self.command_history[new_pos].clone();
1283 }
1284 }
1285 }
1286
1287 fn add_to_history(&mut self, command: String) {
1289 if command.trim().is_empty() {
1290 return;
1291 }
1292
1293 if let Some(last) = self.command_history.first() {
1295 if last == &command {
1296 return;
1297 }
1298 }
1299
1300 self.command_history.insert(0, command);
1302
1303 if self.command_history.len() > 1000 {
1305 self.command_history.truncate(1000);
1306 }
1307
1308 self.save_session();
1310 }
1311
1312 fn save_session(&self) {
1314 let session_data = crate::persistence::SessionData {
1315 command_history: self.command_history.clone(),
1316 last_updated: chrono::Local::now().to_rfc3339(),
1317 user_stats: self.user_stats.clone(),
1318 completed_lessons: self.completed_lessons.clone(),
1319 challenge_manager: self.challenge_manager.clone(),
1320 };
1321
1322 if let Err(e) = crate::persistence::save_session(&session_data) {
1323 tracing::warn!("Failed to save session: {}", e);
1324 }
1325 }
1326
1327 fn handle_history_command(&mut self, cmd: &arct_core::Command) -> Result<()> {
1329 let limit = if cmd.args.is_empty() {
1331 50 } else {
1333 cmd.args[0].parse::<usize>().unwrap_or(50)
1334 };
1335
1336 if self.command_history.is_empty() {
1337 self.last_output = "No commands in history yet.\n".to_string();
1338 } else {
1339 let mut output = String::new();
1340 let total = self.command_history.len();
1341
1342 for (i, cmd) in self.command_history.iter().rev().enumerate().take(limit) {
1345 let index = total - self.command_history.len() + i + 1;
1346 output.push_str(&format!("{:5} {}\n", index, cmd));
1347 }
1348
1349 self.last_output = output;
1350 }
1351
1352 self.output_scroll = 0;
1354
1355 self.session.record_command(
1357 format!("history {}", if cmd.args.is_empty() { String::new() } else { cmd.args.join(" ") }),
1358 Some(0),
1359 Some(0),
1360 );
1361
1362 Ok(())
1363 }
1364
1365 fn handle_export_command(&mut self, cmd: &arct_core::Command) -> Result<()> {
1367 if cmd.args.is_empty() {
1368 if self.environment_vars.is_empty() {
1370 self.last_output = "No environment variables set.\n".to_string();
1371 } else {
1372 let mut output = String::new();
1373 output.push_str("Exported environment variables:\n\n");
1374 let mut vars: Vec<_> = self.environment_vars.iter().collect();
1375 vars.sort_by_key(|(k, _)| *k);
1376 for (key, value) in vars {
1377 output.push_str(&format!(" {}={}\n", key, value));
1378 }
1379 self.last_output = output;
1380 }
1381 } else {
1382 for arg in &cmd.args {
1384 if let Some((key, value)) = arg.split_once('=') {
1385 let key = key.trim().to_string();
1386 let value = value.trim().to_string();
1387
1388 let value = if (value.starts_with('"') && value.ends_with('"')) ||
1390 (value.starts_with('\'') && value.ends_with('\'')) {
1391 value[1..value.len()-1].to_string()
1392 } else {
1393 value
1394 };
1395
1396 self.environment_vars.insert(key.clone(), value.clone());
1397 self.last_output = format!("{}Exported: {}={}\n", icons::success().content, key, value);
1398
1399 self.config.shell.environment = self.environment_vars.clone();
1401 if let Err(e) = self.config.save() {
1402 tracing::warn!("Failed to save config: {}", e);
1403 }
1404 } else {
1405 self.last_output = format!("{}Invalid export syntax: {}\n Usage: export VAR=value\n", icons::error().content, arg);
1406 break;
1407 }
1408 }
1409 }
1410
1411 self.output_scroll = 0;
1413
1414 self.session.record_command(
1416 format!("export {}", cmd.args.join(" ")),
1417 Some(0),
1418 Some(0),
1419 );
1420
1421 Ok(())
1422 }
1423
1424 fn handle_alias_command(&mut self, cmd: &arct_core::Command) -> Result<()> {
1426 if cmd.args.is_empty() {
1427 if self.aliases.is_empty() {
1429 self.last_output = "No aliases defined.\n".to_string();
1430 } else {
1431 let mut output = String::new();
1432 output.push_str("Defined aliases:\n\n");
1433 let mut aliases: Vec<_> = self.aliases.iter().collect();
1434 aliases.sort_by_key(|(k, _)| *k);
1435 for (name, command) in aliases {
1436 output.push_str(&format!(" {}='{}'\n", name, command));
1437 }
1438 self.last_output = output;
1439 }
1440 } else {
1441 let arg = cmd.args.join(" ");
1443 if let Some((name, command)) = arg.split_once('=') {
1444 let name = name.trim().to_string();
1445 let command = command.trim().to_string();
1446
1447 let command = if (command.starts_with('"') && command.ends_with('"')) ||
1449 (command.starts_with('\'') && command.ends_with('\'')) {
1450 command[1..command.len()-1].to_string()
1451 } else {
1452 command
1453 };
1454
1455 self.aliases.insert(name.clone(), command.clone());
1456 self.last_output = format!("{}Alias created: {}='{}'\n", icons::success().content, name, command);
1457
1458 self.config.shell.aliases = self.aliases.clone();
1460 if let Err(e) = self.config.save() {
1461 tracing::warn!("Failed to save config: {}", e);
1462 }
1463 } else {
1464 self.last_output = format!("{}Invalid alias syntax: {}\n Usage: alias name='command'\n", icons::error().content, arg);
1465 }
1466 }
1467
1468 self.output_scroll = 0;
1470
1471 self.session.record_command(
1473 format!("alias {}", cmd.args.join(" ")),
1474 Some(0),
1475 Some(0),
1476 );
1477
1478 Ok(())
1479 }
1480
1481 fn handle_autocomplete(&mut self) -> Result<()> {
1483 if self.command_buffer.is_empty() {
1484 return Ok(());
1485 }
1486
1487 let working_dir = &self.session.state.working_directory;
1489 let result = self.autocompleter.complete(&self.command_buffer, working_dir)?;
1490
1491 if !result.common_prefix.is_empty() && result.common_prefix != self.command_buffer {
1493 let tokens: Vec<&str> = self.command_buffer.split_whitespace().collect();
1496
1497 if tokens.is_empty() {
1498 self.command_buffer = result.common_prefix.clone();
1499 } else if tokens.len() == 1 && !self.command_buffer.ends_with(' ') {
1500 self.command_buffer = result.common_prefix.clone();
1502 } else {
1503 let last_token = tokens.last().unwrap_or(&"");
1505 if let Some(idx) = self.command_buffer.rfind(last_token) {
1506 self.command_buffer.truncate(idx);
1507 self.command_buffer.push_str(&result.common_prefix);
1508 }
1509 }
1510 }
1511
1512 self.completion_suggestions = result.completions.into_iter().take(10).collect();
1514
1515 Ok(())
1516 }
1517
1518 pub fn toggle_ai_mode(&mut self) {
1520 if self.ai_provider.is_some() {
1521 self.ai_mode = !self.ai_mode;
1522 if self.ai_mode {
1523 self.ai_input_buffer.clear();
1525 self.ai_loading = false;
1526 }
1527 } else {
1528 self.last_output = format!("{}AI is not enabled. Configure it in ~/.config/arct/config.toml\n", icons::error().content);
1529 }
1530 }
1531
1532 pub fn toggle_lesson_mode(&mut self) {
1534 self.lesson_mode = !self.lesson_mode;
1535
1536 if self.lesson_mode {
1537 match arct_core::VirtualFileSystem::new("nav-basics", &self.session_id) {
1539 Ok(vfs) => {
1540 self.virtual_fs = Some(vfs);
1541 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);
1542 }
1543 Err(e) => {
1544 self.last_output = format!("{}Failed to initialize lesson environment: {}\n", icons::error().content, e);
1545 self.lesson_mode = false;
1546 return;
1547 }
1548 }
1549
1550 if self.lesson_panel.is_none() {
1552 self.lesson_panel = Self::initialize_lesson_panel();
1553 }
1554
1555 if self.lesson_menu.is_none() {
1557 self.lesson_menu = Some(crate::panels::lesson_menu::LessonMenuPanel::new());
1558 }
1559 } else {
1560 self.virtual_fs = None;
1562 self.last_output = format!("{}Lesson mode deactivated. Back to normal shell mode and real filesystem.\n", icons::learning().content);
1563 }
1564 }
1565
1566 pub async fn ask_ai(&mut self, question: String) -> Result<()> {
1568 if self.ai_provider.is_none() {
1569 return Ok(());
1570 }
1571
1572 if question.trim().is_empty() {
1573 return Ok(());
1574 }
1575
1576 self.ai_loading = true;
1577
1578 self.ai_conversation.push(arct_ai::Message::user(question.clone()));
1580
1581 let user_name = self.config.general.user_name.as_deref().unwrap_or("there");
1583 let system_prompt = format!(
1584 "You are an AI teaching assistant integrated into Arc Academy Terminal, \
1585 an interactive terminal learning application. Your role is to help users \
1586 learn shell commands and terminal skills.\n\n\
1587 You're helping {}, so address them by name occasionally to make the \
1588 interaction personal and engaging.\n\n\
1589 Guidelines:\n\
1590 - Teach shell commands with clear, executable examples\n\
1591 - Explain concepts in beginner-friendly language\n\
1592 - Provide commands the user can type themselves in the terminal\n\
1593 - Keep responses concise (3-4 sentences or a short example)\n\
1594 - Focus on common Linux/Unix commands (bash, grep, find, etc.)\n\
1595 - Suggest safer alternatives when appropriate\n\
1596 - You are NOT Claude Code - you cannot execute commands or use tools\n\
1597 - You are a teaching assistant helping someone learn the terminal\n\
1598 - Be encouraging and supportive in your teaching approach",
1599 user_name
1600 );
1601
1602 let mut messages = vec![
1603 arct_ai::Message::system(system_prompt),
1604 ];
1605 messages.extend(self.ai_conversation.clone());
1606
1607 let provider = self.ai_provider.as_ref()
1611 .expect("BUG: ai_provider must exist when ai_mode is true - this is a logic error");
1612 let response = provider.complete(&messages, None).await;
1613
1614 self.ai_loading = false;
1615
1616 match response {
1617 Ok(ai_response) => {
1618 let cleaned_content = Self::strip_markdown(&ai_response.content);
1620
1621 self.ai_conversation.push(arct_ai::Message::assistant(ai_response.content.clone()));
1623 self.ai_response = Some(cleaned_content);
1624 Ok(())
1625 }
1626 Err(e) => {
1627 self.ai_response = Some(format!("{}Error: {}", icons::error().content, e));
1628 Err(anyhow::anyhow!("AI request failed: {}", e))
1629 }
1630 }
1631 }
1632
1633 pub fn clear_ai_conversation(&mut self) {
1635 self.ai_conversation.clear();
1636 self.ai_response = None;
1637 self.ai_input_buffer.clear();
1638 }
1639
1640 fn strip_markdown(text: &str) -> String {
1642 let mut result = String::new();
1643 let mut in_code_block = false;
1644 let mut skip_line = false;
1645
1646 for line in text.lines() {
1647 if line.trim().starts_with("```") {
1649 in_code_block = !in_code_block;
1650 skip_line = true;
1651 }
1652
1653 if skip_line {
1654 skip_line = false;
1655 continue;
1656 }
1657
1658 let mut cleaned = line.to_string();
1660
1661 if cleaned.trim_start().starts_with('#') {
1663 cleaned = cleaned.trim_start().trim_start_matches('#').trim().to_string();
1664 }
1665
1666 cleaned = cleaned.replace("**", "").replace("*", "");
1668
1669 cleaned = cleaned.replace('`', "");
1671
1672 if let Some(stripped) = cleaned.trim_start().strip_prefix("- ") {
1674 let indent = cleaned.len() - cleaned.trim_start().len();
1675 cleaned = format!("{}{}", " ".repeat(indent), stripped);
1676 }
1677
1678 result.push_str(&cleaned);
1679 result.push('\n');
1680 }
1681
1682 result.trim_end().to_string()
1683 }
1684
1685 async fn handle_onboarding_event(&mut self, key: KeyEvent) -> Result<()> {
1687 if let Some(wizard) = self.onboarding.as_mut() {
1688 match key.code {
1689 KeyCode::Char(c) if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT => {
1690 wizard.handle_char(c);
1691 }
1692 KeyCode::Backspace => {
1693 wizard.handle_backspace();
1694 }
1695 KeyCode::Up => {
1696 wizard.handle_up();
1697 }
1698 KeyCode::Down => {
1699 let max_options = match wizard.step {
1700 crate::panels::onboarding::OnboardingStep::AskAI => 3,
1701 crate::panels::onboarding::OnboardingStep::AskAIProvider => 3,
1702 _ => 1,
1703 };
1704 wizard.handle_down(max_options);
1705 }
1706 KeyCode::Enter => {
1707 wizard.handle_enter();
1708
1709 if wizard.step == crate::panels::onboarding::OnboardingStep::Complete {
1711 if let Some(wizard) = self.onboarding.take() {
1713 self.complete_onboarding(wizard).await?;
1714 }
1715 }
1716 }
1717 _ => {}
1718 }
1719 }
1720 Ok(())
1721 }
1722
1723 async fn complete_onboarding(&mut self, wizard: crate::panels::onboarding::OnboardingWizard) -> Result<()> {
1725 if !wizard.user_name.is_empty() {
1727 self.config.general.user_name = Some(wizard.user_name.clone());
1728 }
1729
1730 if let Some(ai_enabled) = wizard.ai_enabled {
1731 self.config.ai.enabled = ai_enabled;
1732
1733 if ai_enabled {
1734 if wizard.ai_provider.as_deref() == Some("claude-code") {
1736 self.config.ai.provider = "claude-cli".to_string();
1738 self.config.ai.model = Some("claude-sonnet-4".to_string());
1739 } else if wizard.ai_provider.as_deref() == Some("own") {
1741 self.config.ai.provider = "local".to_string();
1743 self.config.ai.endpoint = Some("http://localhost:11434".to_string());
1744 self.config.ai.model = Some("llama3.2".to_string());
1745 } else if wizard.ai_provider.as_deref() == Some("managed") {
1746 self.config.ai.provider = "managed".to_string();
1748 }
1749 }
1750 }
1751
1752 self.config.general.setup_complete = true;
1754
1755 self.config.save()?;
1757
1758 if self.config.ai.enabled {
1760 match Self::create_ai_provider(&self.config.ai) {
1761 Ok(provider) => {
1762 self.ai_provider = Some(provider);
1763 }
1764 Err(e) => {
1765 self.last_output = format!("{}AI provider initialization failed: {}\n", icons::warning().content, e);
1767 }
1768 }
1769 }
1770
1771 self.onboarding = None;
1773
1774 let name = self.config.general.user_name.as_deref().unwrap_or("there");
1776 let mut welcome_msg = format!(
1777 "{}Welcome, {}!\n\n\
1778 You're all set to start learning shell commands!\n\n",
1779 icons::celebration().content, name
1780 );
1781
1782 if self.config.ai.enabled {
1784 match self.config.ai.provider.as_str() {
1785 "claude-cli" => {
1786 welcome_msg.push_str(
1787 &format!("{}Using Claude Code CLI - your Max subscription is ready!\n\
1788 Press Ctrl+A to ask Claude for help.\n\n", icons::ai().content)
1789 );
1790 }
1791 "anthropic" | "openai" => {
1792 welcome_msg.push_str(
1793 &format!("{}To use AI features, set your API key:\n\
1794 export ARCT_AI_API_KEY=\"your-api-key-here\"\n\n", icons::note().content)
1795 );
1796 }
1797 "local" => {
1798 welcome_msg.push_str(
1799 &format!("{}Using local LLM - make sure your server is running!\n\n", icons::info().content)
1800 );
1801 }
1802 _ => {}
1803 }
1804 }
1805
1806 welcome_msg.push_str("Press ? for help, or just start typing commands.\n");
1807 self.last_output = welcome_msg;
1808
1809 Ok(())
1810 }
1811
1812 async fn handle_settings_event(&mut self, key: KeyEvent) -> Result<()> {
1814 let (action, selected_field) = {
1816 let panel = match self.settings_panel.as_ref() {
1817 Some(p) => p,
1818 None => return Ok(()),
1819 };
1820
1821 let action = if panel.editing {
1822 match key.code {
1824 KeyCode::Char(c) if key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT => {
1825 SettingsAction::PushChar(c)
1826 }
1827 KeyCode::Backspace => SettingsAction::PopChar,
1828 KeyCode::Enter => SettingsAction::SaveEdit,
1829 KeyCode::Esc => SettingsAction::CancelEdit,
1830 _ => SettingsAction::None,
1831 }
1832 } else {
1833 match key.code {
1835 KeyCode::Up | KeyCode::Char('k') if key.modifiers == KeyModifiers::NONE => {
1836 SettingsAction::PreviousField
1837 }
1838 KeyCode::Down | KeyCode::Char('j') if key.modifiers == KeyModifiers::NONE => {
1839 SettingsAction::NextField
1840 }
1841 KeyCode::Enter => SettingsAction::StartEdit,
1842 KeyCode::Esc | KeyCode::Char('s') if key.modifiers == KeyModifiers::CONTROL => {
1843 SettingsAction::Close
1844 }
1845 _ => SettingsAction::None,
1846 }
1847 };
1848
1849 (action, panel.selected_field)
1850 };
1851
1852 match action {
1854 SettingsAction::PushChar(c) => {
1855 if let Some(panel) = self.settings_panel.as_mut() {
1856 panel.push_char(c);
1857 }
1858 }
1859 SettingsAction::PopChar => {
1860 if let Some(panel) = self.settings_panel.as_mut() {
1861 panel.pop_char();
1862 }
1863 }
1864 SettingsAction::SaveEdit => {
1865 if let Some(panel) = self.settings_panel.as_mut() {
1866 panel.save_edit(&mut self.config)?;
1867
1868 if selected_field == crate::panels::settings::SettingField::Theme {
1870 self.theme = Theme::from_name(&self.config.theme.default_theme);
1871 }
1872
1873 if selected_field == crate::panels::settings::SettingField::AIEnabled {
1875 if self.config.ai.enabled {
1876 match Self::create_ai_provider(&self.config.ai) {
1878 Ok(provider) => {
1879 self.ai_provider = Some(provider);
1880 }
1881 Err(e) => {
1882 tracing::warn!("Failed to initialize AI provider: {}", e);
1883 self.ai_provider = None;
1884 }
1885 }
1886 } else {
1887 self.ai_provider = None;
1888 self.ai_mode = false;
1889 }
1890 }
1891 }
1892 }
1893 SettingsAction::CancelEdit => {
1894 if let Some(panel) = self.settings_panel.as_mut() {
1895 panel.cancel_editing();
1896 }
1897 }
1898 SettingsAction::PreviousField => {
1899 if let Some(panel) = self.settings_panel.as_mut() {
1900 panel.previous_field();
1901 }
1902 }
1903 SettingsAction::NextField => {
1904 if let Some(panel) = self.settings_panel.as_mut() {
1905 panel.next_field();
1906 }
1907 }
1908 SettingsAction::StartEdit => {
1909 if let Some(panel) = self.settings_panel.as_mut() {
1910 panel.start_editing(&self.config);
1911 }
1912 }
1913 SettingsAction::Close => {
1914 self.settings_panel = None;
1915 }
1916 SettingsAction::None => {}
1917 }
1918
1919 Ok(())
1920 }
1921
1922 pub fn toggle_achievements_panel(&mut self) {
1924 if self.achievements_panel.is_some() {
1925 self.achievements_panel = None;
1926 } else {
1927 self.achievements_panel = Some(crate::panels::achievements::AchievementsPanel::new());
1928 }
1929 }
1930
1931 pub fn toggle_progress_panel(&mut self) {
1933 if self.progress_panel.is_some() {
1934 self.progress_panel = None;
1935 } else {
1936 self.progress_panel = Some(crate::panels::progress::ProgressPanel::new());
1937 }
1938 }
1939
1940 pub fn toggle_challenges_panel(&mut self) {
1942 if self.challenges_panel.is_some() {
1943 self.challenges_panel = None;
1944 } else {
1945 self.challenges_panel = Some(crate::panels::challenges::ChallengesPanel::new());
1946 }
1947 }
1948
1949 pub fn check_and_unlock_achievements(&mut self) {
1951 let newly_unlocked = self.user_stats.check_achievements();
1952
1953 for achievement in newly_unlocked {
1955 self.pending_achievements.push(achievement);
1956 }
1957
1958 if self.showing_notification.is_none() && !self.pending_achievements.is_empty() {
1960 self.show_next_achievement_notification();
1961 }
1962 }
1963
1964 fn show_next_achievement_notification(&mut self) {
1966 if let Some(achievement) = self.pending_achievements.first() {
1967 self.showing_notification = Some(crate::panels::notification::NotificationPanel::new(
1968 achievement.clone(),
1969 ));
1970 }
1971 }
1972
1973 fn dismiss_notification(&mut self) {
1975 self.showing_notification = None;
1976
1977 if !self.pending_achievements.is_empty() {
1979 self.pending_achievements.remove(0);
1980 }
1981
1982 if !self.pending_achievements.is_empty() {
1984 self.show_next_achievement_notification();
1985 }
1986 }
1987
1988 pub fn record_command_for_stats(&mut self, command: &str) {
1990 let command_name = command.split_whitespace().next().unwrap_or("");
1992 if !command_name.is_empty() {
1993 self.user_stats.record_command_use(command_name.to_string());
1994 }
1995 }
1996
1997 pub fn record_lesson_completion(&mut self, lesson_id: String, difficulty: arct_core::Difficulty) {
1999 self.user_stats.record_lesson_completion(lesson_id.clone(), difficulty);
2001
2002 self.completed_lessons.insert(lesson_id);
2004
2005 self.check_and_unlock_achievements();
2007 }
2008}
2009
2010enum SettingsAction {
2012 PushChar(char),
2013 PopChar,
2014 SaveEdit,
2015 CancelEdit,
2016 PreviousField,
2017 NextField,
2018 StartEdit,
2019 Close,
2020 None,
2021}