practicode 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui.rs CHANGED
@@ -4,12 +4,13 @@ use crate::{
4
4
  run_ai_generate, run_ai_next, run_ai_prompt,
5
5
  },
6
6
  core::{
7
- AI_PROVIDERS, AppState, DIFFICULTIES, HistoryItem, LANGUAGES, PROBLEM_NOTES_PATH, Problem,
8
- STATE_PATH, THEMES, UI_LANGUAGES, ensure_problem_files, ensure_submission, ext_for,
9
- give_up, judge, load_bank, load_state, localized, next_problem, normalize_ai_provider,
10
- normalize_difficulty, normalize_language, normalize_next_source, normalize_ui_language,
11
- parse_language_list, parse_topic_list, parse_ui_language_list, previous_problem,
12
- problem_by_id, record_pass, save_state, template_for, ui_text,
7
+ AI_PROVIDERS, AppState, CLAUDE_AI_EFFORTS, CODEX_AI_EFFORTS, DIFFICULTIES, HistoryItem,
8
+ LANGUAGES, PROBLEM_NOTES_PATH, Problem, STATE_PATH, THEMES, UI_LANGUAGES,
9
+ ensure_problem_files, ensure_submission, ext_for, give_up, judge, load_bank, load_state,
10
+ localized, next_problem, normalize_ai_effort, normalize_ai_provider, normalize_difficulty,
11
+ normalize_language, normalize_next_source, normalize_ui_language, parse_language_list,
12
+ parse_topic_list, parse_ui_language_list, previous_problem, problem_by_id, record_pass,
13
+ save_state, template_for, ui_text,
13
14
  },
14
15
  text::{
15
16
  byte_index, char_len, compose_hangul_jamo, display_width, prefix, render_markdown_plain,
@@ -39,10 +40,18 @@ use std::{
39
40
  time::{Duration, Instant},
40
41
  };
41
42
 
43
+ mod actions;
44
+ mod command_handlers;
45
+ mod command_input;
42
46
  mod commands;
43
47
  mod editor;
48
+ mod events;
49
+ mod problem_list;
44
50
  mod problem_view;
45
51
  mod settings_panel;
52
+ mod status;
53
+ mod tasks;
54
+ mod view;
46
55
  use self::commands::COMMAND_HINTS;
47
56
  pub use self::editor::TextEditor;
48
57
 
@@ -70,12 +79,14 @@ pub struct PracticodeApp {
70
79
  state: AppState,
71
80
  problem: Problem,
72
81
  editor: TextEditor,
82
+ note_editor: TextEditor,
73
83
  command: String,
74
84
  command_cursor: usize,
75
85
  command_palette_cursor: usize,
76
86
  output: String,
77
87
  output_is_markdown: bool,
78
88
  showing_model_status: bool,
89
+ editing_notes: bool,
79
90
  show_output: bool,
80
91
  focus: Focus,
81
92
  list_cursor: Option<usize>,
@@ -129,12 +140,14 @@ impl PracticodeApp {
129
140
  state,
130
141
  problem,
131
142
  editor: TextEditor::default(),
143
+ note_editor: TextEditor::default(),
132
144
  command: String::new(),
133
145
  command_cursor: 0,
134
146
  command_palette_cursor: 0,
135
147
  output: String::new(),
136
148
  output_is_markdown: false,
137
149
  showing_model_status: false,
150
+ editing_notes: false,
138
151
  show_output: false,
139
152
  focus: Focus::Code,
140
153
  list_cursor: None,
@@ -294,1611 +307,4 @@ impl PracticodeApp {
294
307
  pub fn pane_title_for_test(title: &str, active: bool) -> String {
295
308
  Self::pane_title(title, active)
296
309
  }
297
-
298
- fn draw(&mut self, frame: &mut Frame) {
299
- let size = frame.area();
300
- let vertical = Layout::default()
301
- .direction(Direction::Vertical)
302
- .constraints([
303
- Constraint::Min(1),
304
- Constraint::Length(1),
305
- Constraint::Length(3),
306
- ])
307
- .split(size);
308
- let body = Layout::default()
309
- .direction(Direction::Horizontal)
310
- .constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
311
- .split(vertical[0]);
312
- self.code_area = body[1];
313
- self.output_area = body[1];
314
- self.command_area = vertical[2];
315
-
316
- let light = self.state.settings.theme == "light";
317
- let problem = Paragraph::new(problem_view::render(
318
- &self.problem,
319
- &self.state.settings.ui_language,
320
- light,
321
- ))
322
- .style(Self::pane_style(light))
323
- .block(Self::block(
324
- ui_text(&self.state.settings.ui_language, "problem"),
325
- light,
326
- false,
327
- ))
328
- .wrap(Wrap { trim: false });
329
- frame.render_widget(problem, body[0]);
330
-
331
- if self.show_output {
332
- let text = self.output_text();
333
- let output = Paragraph::new(text)
334
- .style(Self::pane_style(light))
335
- .block(Self::block(
336
- ui_text(&self.state.settings.ui_language, "output"),
337
- light,
338
- self.focus != Focus::Command,
339
- ))
340
- .wrap(Wrap { trim: false });
341
- frame.render_widget(output, body[1]);
342
- } else {
343
- let code = self
344
- .editor
345
- .visible_text(body[1].height.saturating_sub(2) as usize);
346
- let title = format!("solution.{}", ext_for(&self.state.settings.language));
347
- let code = Paragraph::new(code)
348
- .style(Self::pane_style(light))
349
- .block(Self::block(&title, light, self.focus == Focus::Code));
350
- frame.render_widget(code, body[1]);
351
- }
352
-
353
- let status = Paragraph::new(self.status_text()).style(if light {
354
- Style::default()
355
- .fg(Color::Blue)
356
- .bg(Color::Rgb(219, 234, 254))
357
- .add_modifier(Modifier::BOLD)
358
- } else {
359
- Style::default()
360
- .fg(Color::Rgb(200, 211, 245))
361
- .bg(Color::Rgb(21, 32, 51))
362
- .add_modifier(Modifier::BOLD)
363
- });
364
- frame.render_widget(status, vertical[1]);
365
-
366
- let command_text = if self.focus == Focus::Command || !self.command.is_empty() {
367
- self.command.clone()
368
- } else {
369
- ui_text(&self.state.settings.ui_language, "command_placeholder").to_string()
370
- };
371
- let command = Paragraph::new(command_text)
372
- .style(Self::pane_style(light))
373
- .block(Self::block(
374
- ui_text(&self.state.settings.ui_language, "command"),
375
- light,
376
- self.focus == Focus::Command,
377
- ))
378
- .wrap(Wrap { trim: false });
379
- frame.render_widget(command, vertical[2]);
380
- self.draw_command_palette(frame, vertical[2]);
381
- self.set_terminal_cursor(frame, body[1], vertical[2]);
382
- }
383
-
384
- fn wants_mouse_capture(&self) -> bool {
385
- !self.show_output
386
- }
387
-
388
- fn sync_mouse_capture(&mut self) {
389
- let want = self.wants_mouse_capture();
390
- if want == self.mouse_capture {
391
- return;
392
- }
393
- let result = if want {
394
- execute!(stdout(), EnableMouseCapture)
395
- } else {
396
- execute!(stdout(), DisableMouseCapture)
397
- };
398
- if result.is_ok() {
399
- self.mouse_capture = want;
400
- }
401
- }
402
-
403
- fn disable_mouse_capture(&mut self) {
404
- if self.mouse_capture {
405
- let _ = execute!(stdout(), DisableMouseCapture);
406
- self.mouse_capture = false;
407
- }
408
- }
409
-
410
- fn output_text(&self) -> Text<'static> {
411
- let light = self.state.settings.theme == "light";
412
- let title_style = if light {
413
- Style::default()
414
- .fg(Color::Blue)
415
- .add_modifier(Modifier::BOLD)
416
- } else {
417
- Style::default()
418
- .fg(Color::Yellow)
419
- .add_modifier(Modifier::BOLD)
420
- };
421
- let label_style = if light {
422
- Style::default()
423
- .fg(Color::Magenta)
424
- .add_modifier(Modifier::BOLD)
425
- } else {
426
- Style::default()
427
- .fg(Color::Cyan)
428
- .add_modifier(Modifier::BOLD)
429
- };
430
- let body_style = if light {
431
- Style::default().fg(Color::Black)
432
- } else {
433
- Style::default().fg(Color::Rgb(229, 231, 235))
434
- };
435
- let code_style = if light {
436
- Style::default()
437
- .fg(Color::Black)
438
- .bg(Color::Rgb(229, 231, 235))
439
- } else {
440
- Style::default()
441
- .fg(Color::Rgb(243, 244, 246))
442
- .bg(Color::Rgb(31, 41, 55))
443
- };
444
- if !self.busy_label.is_empty() {
445
- let elapsed = self
446
- .busy_started
447
- .map(|started| started.elapsed().as_secs())
448
- .unwrap_or_default();
449
- let mut lines = vec![Line::from(Span::styled(
450
- format!("{}{} {}s", self.busy_body, self.busy_dots(), elapsed),
451
- title_style,
452
- ))];
453
- if self.busy_label == "next" {
454
- lines.extend([
455
- Line::default(),
456
- Line::from(Span::styled(self.busy_game_track(), code_style)),
457
- Line::from(Span::styled(
458
- ui_text(&self.state.settings.ui_language, "busy_warmup").to_string(),
459
- body_style,
460
- )),
461
- Line::from(Span::styled(
462
- format!(
463
- "{}: {} {}: {}",
464
- ui_text(&self.state.settings.ui_language, "hits"),
465
- self.busy_hits,
466
- ui_text(&self.state.settings.ui_language, "misses"),
467
- self.busy_misses
468
- ),
469
- label_style,
470
- )),
471
- Line::from(Span::styled(
472
- ui_text(&self.state.settings.ui_language, "busy_commands_paused")
473
- .to_string(),
474
- body_style,
475
- )),
476
- ]);
477
- }
478
- return Text::from(lines);
479
- }
480
- let output = if self.output_is_markdown {
481
- render_markdown_plain(&self.output)
482
- } else {
483
- self.output.clone()
484
- };
485
- let mut lines = Vec::new();
486
- for line in output.lines() {
487
- if line.is_empty() {
488
- lines.push(Line::default());
489
- } else if line.starts_with("PASS ")
490
- || line.starts_with("FAIL ")
491
- || line.starts_with("Case ")
492
- || line.starts_with("Next:")
493
- || line.starts_with("Fix:")
494
- {
495
- lines.push(Line::from(Span::styled(line.to_string(), title_style)));
496
- } else if matches!(
497
- line,
498
- "Input" | "Expected" | "Got" | "Stdout" | "Stderr" | "Compile" | "Error"
499
- ) {
500
- lines.push(Line::from(Span::styled(line.to_string(), label_style)));
501
- } else if line.starts_with(" ") {
502
- lines.push(Line::from(vec![
503
- Span::raw(" "),
504
- Span::styled(line.trim_start().to_string(), code_style),
505
- ]));
506
- } else {
507
- lines.push(Line::from(Span::styled(line.to_string(), body_style)));
508
- }
509
- }
510
- Text::from(lines)
511
- }
512
-
513
- fn draw_command_palette(&self, frame: &mut Frame, command_area: Rect) {
514
- let suggestions = self.command_suggestions();
515
- if suggestions.is_empty() || command_area.y < 3 {
516
- return;
517
- }
518
- let height = ((suggestions.len() + 3) as u16).min(14).min(command_area.y);
519
- let area = Rect::new(
520
- command_area.x,
521
- command_area.y - height,
522
- command_area.width,
523
- height,
524
- );
525
- let selected = self.command_palette_cursor.min(suggestions.len() - 1);
526
- let visible = height.saturating_sub(2) as usize;
527
- let start = selected.saturating_sub(visible.saturating_sub(1));
528
- let mut lines = suggestions
529
- .iter()
530
- .enumerate()
531
- .skip(start)
532
- .take(visible)
533
- .map(|(index, hint)| {
534
- let marker = if index == selected { ">" } else { " " };
535
- format!(
536
- "{marker} {:<16} {}",
537
- hint.display,
538
- ui_text(&self.state.settings.ui_language, hint.desc_key)
539
- )
540
- })
541
- .collect::<Vec<_>>();
542
- lines.push(ui_text(&self.state.settings.ui_language, "palette_hint").to_string());
543
- frame.render_widget(Clear, area);
544
- let light = self.state.settings.theme == "light";
545
- frame.render_widget(
546
- Paragraph::new(lines.join("\n"))
547
- .style(Self::pane_style(light))
548
- .block(Self::block(
549
- ui_text(&self.state.settings.ui_language, "commands"),
550
- light,
551
- true,
552
- )),
553
- area,
554
- );
555
- }
556
-
557
- fn block(title: &str, light: bool, active: bool) -> Block<'static> {
558
- let border = if active {
559
- if light {
560
- Style::default()
561
- .fg(Color::Magenta)
562
- .add_modifier(Modifier::BOLD)
563
- } else {
564
- Style::default()
565
- .fg(Color::Yellow)
566
- .add_modifier(Modifier::BOLD)
567
- }
568
- } else if light {
569
- Style::default().fg(Color::Blue)
570
- } else {
571
- Style::default().fg(Color::Cyan)
572
- };
573
- let border = border.bg(Self::pane_bg(light));
574
- Block::default()
575
- .borders(Borders::ALL)
576
- .title(Self::pane_title(title, active))
577
- .style(Self::pane_style(light))
578
- .border_style(border)
579
- }
580
-
581
- fn pane_style(light: bool) -> Style {
582
- if light {
583
- Style::default()
584
- .fg(Color::Rgb(17, 24, 39))
585
- .bg(Self::pane_bg(light))
586
- } else {
587
- Style::default()
588
- .fg(Color::Rgb(229, 231, 235))
589
- .bg(Self::pane_bg(light))
590
- }
591
- }
592
-
593
- fn pane_bg(light: bool) -> Color {
594
- if light {
595
- Color::Rgb(248, 250, 252)
596
- } else {
597
- Color::Rgb(17, 24, 39)
598
- }
599
- }
600
-
601
- pub fn pane_style_for_test(light: bool) -> Style {
602
- Self::pane_style(light)
603
- }
604
-
605
- fn pane_title(title: &str, active: bool) -> String {
606
- if active {
607
- format!("> {title}")
608
- } else {
609
- title.to_string()
610
- }
611
- }
612
-
613
- fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
614
- if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
615
- self.should_quit = true;
616
- return Ok(());
617
- }
618
- if self.handle_busy_key(key) {
619
- return Ok(());
620
- }
621
- match self.focus {
622
- Focus::Command => self.handle_command_key(key),
623
- Focus::Code => self.handle_code_key(key),
624
- _ => self.handle_global_key(key),
625
- }
626
- }
627
-
628
- fn handle_mouse(&mut self, mouse: MouseEvent) -> Result<()> {
629
- if self.task_rx.is_some() {
630
- self.focus = Focus::Output;
631
- return Ok(());
632
- }
633
- if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
634
- return Ok(());
635
- }
636
- let position = Position::new(mouse.column, mouse.row);
637
- if self.command_area.contains(position) {
638
- self.focus_command();
639
- } else if self.show_output && self.output_area.contains(position) {
640
- self.focus = Focus::Output;
641
- } else if self.code_area.contains(position) {
642
- self.action_edit()?;
643
- }
644
- Ok(())
645
- }
646
-
647
- fn handle_command_key(&mut self, key: KeyEvent) -> Result<()> {
648
- match key.code {
649
- KeyCode::Esc => {
650
- self.command.clear();
651
- self.command_cursor = 0;
652
- self.command_palette_cursor = 0;
653
- self.focus = Focus::None;
654
- }
655
- KeyCode::Enter => {
656
- if !self.accept_command_palette()? {
657
- let value = self.command.trim().to_string();
658
- self.command.clear();
659
- self.command_cursor = 0;
660
- self.command_palette_cursor = 0;
661
- self.focus = Focus::None;
662
- self.submit_command(&value)?;
663
- }
664
- }
665
- KeyCode::Backspace => self.delete_command_before_cursor(),
666
- KeyCode::Delete => self.delete_command_at_cursor(),
667
- KeyCode::Left => self.command_cursor = self.command_cursor.saturating_sub(1),
668
- KeyCode::Right => {
669
- self.command_cursor = (self.command_cursor + 1).min(char_len(&self.command));
670
- }
671
- KeyCode::Up => self.move_command_palette(-1),
672
- KeyCode::Down => self.move_command_palette(1),
673
- KeyCode::Home => self.command_cursor = 0,
674
- KeyCode::End => self.command_cursor = char_len(&self.command),
675
- KeyCode::Char('?') if self.command.trim().is_empty() || self.command.trim() == "/" => {
676
- self.command.clear();
677
- self.command_cursor = 0;
678
- self.command_palette_cursor = 0;
679
- self.focus = Focus::None;
680
- self.handle_command("help")?;
681
- }
682
- KeyCode::Char(char) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
683
- self.insert_command_char(char);
684
- }
685
- _ => {}
686
- }
687
- Ok(())
688
- }
689
-
690
- fn handle_code_key(&mut self, key: KeyEvent) -> Result<()> {
691
- match key.code {
692
- KeyCode::Esc => self.focus = Focus::None,
693
- KeyCode::Char(char) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
694
- self.editor.insert_char(char);
695
- self.save_code()?;
696
- }
697
- KeyCode::Enter => {
698
- self.editor.insert_newline();
699
- self.save_code()?;
700
- }
701
- KeyCode::Backspace => {
702
- self.editor.backspace();
703
- self.save_code()?;
704
- }
705
- KeyCode::Delete => {
706
- self.editor.delete();
707
- self.save_code()?;
708
- }
709
- KeyCode::Tab => {
710
- for _ in 0..4 {
711
- self.editor.insert_char(' ');
712
- }
713
- self.save_code()?;
714
- }
715
- KeyCode::Left => self.editor.move_left(),
716
- KeyCode::Right => self.editor.move_right(),
717
- KeyCode::Up => self.editor.move_up(),
718
- KeyCode::Down => self.editor.move_down(),
719
- _ => {}
720
- }
721
- Ok(())
722
- }
723
-
724
- fn handle_global_key(&mut self, key: KeyEvent) -> Result<()> {
725
- if self.settings_cursor.is_some() {
726
- match key.code {
727
- KeyCode::Up | KeyCode::Char('k') => self.move_settings_cursor(-1),
728
- KeyCode::Down | KeyCode::Char('j') => self.move_settings_cursor(1),
729
- KeyCode::Char(' ') | KeyCode::Enter => self.change_selected_setting()?,
730
- KeyCode::Esc => {
731
- self.settings_cursor = None;
732
- self.show_output = false;
733
- self.focus = Focus::Code;
734
- }
735
- _ => self.handle_global_shortcut(key)?,
736
- }
737
- return Ok(());
738
- }
739
- if let Some(cursor) = self.list_cursor {
740
- match key.code {
741
- KeyCode::Up | KeyCode::Char('k') => self.move_list_cursor(-1),
742
- KeyCode::Down | KeyCode::Char('j') => self.move_list_cursor(1),
743
- KeyCode::Enter => self.open_selected_problem()?,
744
- KeyCode::Esc => {
745
- self.list_cursor = None;
746
- self.write_text_output("Closed list.");
747
- }
748
- _ => {
749
- self.list_cursor = Some(cursor);
750
- self.handle_global_shortcut(key)?;
751
- }
752
- }
753
- return Ok(());
754
- }
755
- if key.code == KeyCode::Esc && self.show_output {
756
- self.show_output = false;
757
- self.focus = Focus::Code;
758
- return Ok(());
759
- }
760
- self.handle_global_shortcut(key)
761
- }
762
-
763
- fn handle_global_shortcut(&mut self, key: KeyEvent) -> Result<()> {
764
- match key.code {
765
- KeyCode::Char('/') => self.focus_command(),
766
- KeyCode::Char('?') => self.handle_command("help")?,
767
- KeyCode::Char('r') => self.action_run()?,
768
- KeyCode::Char('n') => self.action_next("")?,
769
- KeyCode::Char('p') => self.action_previous()?,
770
- KeyCode::Char('g') => self.action_give_up()?,
771
- KeyCode::Char('e') => self.action_edit()?,
772
- KeyCode::Char('l') => self.action_cycle_language()?,
773
- KeyCode::Char('u') => self.action_toggle_ui_language()?,
774
- KeyCode::Char('q') => self.should_quit = true,
775
- _ => {}
776
- }
777
- Ok(())
778
- }
779
-
780
- fn focus_command(&mut self) {
781
- if self.command.is_empty() {
782
- self.command.push('/');
783
- self.command_cursor = 1;
784
- }
785
- self.command_palette_cursor = 0;
786
- self.focus = Focus::Command;
787
- }
788
-
789
- fn submit_command(&mut self, value: &str) -> Result<()> {
790
- let value = value
791
- .trim()
792
- .strip_prefix('/')
793
- .unwrap_or(value.trim())
794
- .trim();
795
- self.handle_command(value)
796
- }
797
-
798
- fn handle_command(&mut self, value: &str) -> Result<()> {
799
- if self.task_rx.is_some() {
800
- let command = value
801
- .trim()
802
- .strip_prefix('/')
803
- .unwrap_or(value.trim())
804
- .split_whitespace()
805
- .next()
806
- .unwrap_or("");
807
- if matches!(command, "exit" | "quit" | "q") {
808
- self.should_quit = true;
809
- } else {
810
- self.focus = Focus::Output;
811
- }
812
- return Ok(());
813
- }
814
- if value.is_empty() || matches!(value, "help" | "h" | "?") {
815
- self.list_cursor = None;
816
- self.write_output(&self.help_text());
817
- return Ok(());
818
- }
819
- if value.starts_with("vim") {
820
- self.list_cursor = None;
821
- self.write_text_output("The code editor is already open on the right.");
822
- return Ok(());
823
- }
824
- let (command, arg) = value.split_once(char::is_whitespace).unwrap_or((value, ""));
825
- let arg = arg.trim();
826
- if !matches!(command, "list" | "problems") {
827
- self.list_cursor = None;
828
- }
829
- match command {
830
- "run" | "r" => self.action_run()?,
831
- "code" | "edit" | "e" => self.action_edit()?,
832
- "next" | "n" => self.action_next(arg)?,
833
- "generate" | "gen" | "new" => self.action_generate(arg),
834
- "back" | "prev" | "previous" | "p" => self.action_previous()?,
835
- "answer" | "giveup" | "give" | "g" => self.action_give_up()?,
836
- "problems" | "list" => self.start_problem_list(),
837
- "open" | "o" if !arg.is_empty() => self.open_problem(arg)?,
838
- "language" | "lang" if arg.is_empty() => self.action_cycle_language()?,
839
- "language" | "lang" if LANGUAGES.contains(&arg) => self.set_language(arg)?,
840
- "ui" if arg.is_empty() => self.action_toggle_ui_language()?,
841
- "ui" => self.set_ui_language(&normalize_ui_language(arg))?,
842
- "theme" if arg.is_empty() => self.action_toggle_theme()?,
843
- "theme" if THEMES.contains(&arg) => self.set_theme(arg)?,
844
- "profile" | "settings" if arg.is_empty() => self.show_profile(),
845
- "profile" | "settings" if arg == "reset" => self.reset_profile()?,
846
- "difficulty" | "level" if arg.is_empty() => self.show_profile(),
847
- "difficulty" | "level" => self.set_difficulty(arg)?,
848
- "topics" | "topic" if arg.is_empty() => self.show_profile(),
849
- "topics" | "topic" => self.set_topics(arg, false)?,
850
- "avoid" | "skip" if arg.is_empty() => self.show_profile(),
851
- "avoid" | "skip" => self.set_topics(arg, true)?,
852
- "generate-languages" | "gen-languages" | "gen-lang" if arg.is_empty() => {
853
- self.show_profile()
854
- }
855
- "generate-languages" | "gen-languages" | "gen-lang" => {
856
- self.set_generate_languages(arg, false)?
857
- }
858
- "generate-ui" | "gen-ui" if arg.is_empty() => self.show_profile(),
859
- "generate-ui" | "gen-ui" => self.set_generate_languages(arg, true)?,
860
- "source" | "next-source" if arg.is_empty() => {
861
- self.write_text_output(&self.next_source_help());
862
- }
863
- "source" | "next-source" if matches!(arg, "bank" | "local" | "ai") => {
864
- self.state.settings.next_source = normalize_next_source(arg);
865
- save_state(&self.root, &self.state)?;
866
- self.write_text_output(&self.next_source_help());
867
- }
868
- "ai-next-command" if !arg.is_empty() => {
869
- self.state.settings.ai_next_command = arg.to_string();
870
- self.state.settings.next_source = "ai".to_string();
871
- save_state(&self.root, &self.state)?;
872
- self.write_text_output("AI next command saved.");
873
- }
874
- "provider" | "ai-provider" if arg.is_empty() => {
875
- self.write_text_output(&format!(
876
- "AI provider: {}\n{}",
877
- self.state.settings.ai_provider,
878
- provider_status(&self.state.settings.ai_provider)
879
- ));
880
- }
881
- "provider" | "ai-provider" if AI_PROVIDERS.contains(&arg) => {
882
- self.state.settings.ai_provider = normalize_ai_provider(arg);
883
- self.model_rx = None;
884
- self.available_models.clear();
885
- self.available_models_provider.clear();
886
- self.model_message = None;
887
- save_state(&self.root, &self.state)?;
888
- self.write_text_output(&format!(
889
- "AI provider: {}\n{}",
890
- self.state.settings.ai_provider,
891
- provider_status(&self.state.settings.ai_provider)
892
- ));
893
- }
894
- "model" if arg.is_empty() => {
895
- self.start_model_check();
896
- self.check_models();
897
- self.write_model_status();
898
- }
899
- "model" => {
900
- self.state.settings.ai_model = if arg == "auto" {
901
- "auto".to_string()
902
- } else {
903
- arg.to_string()
904
- };
905
- save_state(&self.root, &self.state)?;
906
- self.start_model_check();
907
- self.check_models();
908
- self.write_model_status();
909
- }
910
- "hint" if arg.is_empty() => {
911
- self.start_ai_prompt("Give one concise hint for the current problem.")?
912
- }
913
- "hint" | "ask" | "ai" if !arg.is_empty() => self.start_ai_prompt(arg)?,
914
- "note" if !arg.is_empty() => self.append_note(arg)?,
915
- "note" | "notes" => self.show_notes()?,
916
- "update" => self.refresh_update_notice(),
917
- "exit" | "quit" | "q" => self.should_quit = true,
918
- _ => self.write_text_output(&format!("Unknown command: {value}\nTry /help.")),
919
- }
920
- Ok(())
921
- }
922
-
923
- fn action_edit(&mut self) -> Result<()> {
924
- self.load_code_editor()?;
925
- self.settings_cursor = None;
926
- self.show_output = false;
927
- self.focus = Focus::Code;
928
- Ok(())
929
- }
930
-
931
- fn action_run(&mut self) -> Result<()> {
932
- self.save_code()?;
933
- let result = judge(&self.root, &self.problem, &self.state.settings);
934
- if result.passed {
935
- record_pass(&self.root, &self.problem, &mut self.state)?;
936
- }
937
- let headline = format!(
938
- "{} {}/{}",
939
- if result.passed { "PASS" } else { "FAIL" },
940
- result.passed_cases,
941
- result.total_cases
942
- );
943
- let next_step = if result.passed {
944
- ui_text(&self.state.settings.ui_language, "run_pass_next")
945
- } else {
946
- ui_text(&self.state.settings.ui_language, "run_fail_next")
947
- };
948
- self.write_text_output(&format!("{headline}\n{}\n\n{next_step}", result.output));
949
- Ok(())
950
- }
951
-
952
- fn action_next(&mut self, request: &str) -> Result<()> {
953
- self.check_background_generation();
954
- let request = request.trim();
955
- let old_problem = self.state.current_problem.clone();
956
- if let Some(problem) = next_problem(&self.root, &self.bank, &mut self.state)? {
957
- self.generate_notice = None;
958
- self.problem = problem;
959
- self.load_code_editor()?;
960
- self.settings_cursor = None;
961
- self.show_output = false;
962
- self.focus = Focus::Code;
963
- return Ok(());
964
- }
965
- if self.generate_rx.is_some() {
966
- self.write_text_output(
967
- "A background generation is already running. Keep solving; /next will pick up the new problem when it finishes.",
968
- );
969
- return Ok(());
970
- }
971
- self.start_next_problem(old_problem, true, request.to_string());
972
- Ok(())
973
- }
974
-
975
- fn action_generate(&mut self, request: &str) {
976
- self.check_background_generation();
977
- if self.task_rx.is_some() || self.generate_rx.is_some() {
978
- let message = "Generation is already running; skipped duplicate /generate.";
979
- self.generate_notice = Some(message.to_string());
980
- self.write_text_output(message);
981
- return;
982
- }
983
- self.start_background_generation(request.trim().to_string());
984
- }
985
-
986
- fn start_background_generation(&mut self, request: String) {
987
- let root = self.root.clone();
988
- let state = self.state.clone();
989
- let (tx, rx) = mpsc::channel();
990
- thread::spawn(move || {
991
- let _ = tx.send(run_ai_generate(&root, &state, &request));
992
- });
993
- self.generate_bank_len = self.bank.len();
994
- self.generate_started = Some(Instant::now());
995
- self.generate_notice = Some("Generating in background.".to_string());
996
- self.generate_rx = Some(rx);
997
- self.settings_cursor = None;
998
- self.show_output = false;
999
- self.focus = Focus::Code;
1000
- }
1001
-
1002
- fn start_next_problem(
1003
- &mut self,
1004
- old_problem: String,
1005
- fallback_to_local: bool,
1006
- request: String,
1007
- ) {
1008
- if self.task_rx.is_some() {
1009
- self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy"));
1010
- return;
1011
- }
1012
- self.start_busy(
1013
- "next",
1014
- ui_text(&self.state.settings.ui_language, "generating_next"),
1015
- );
1016
- let root = self.root.clone();
1017
- let state = self.state.clone();
1018
- let (tx, rx) = mpsc::channel();
1019
- thread::spawn(move || {
1020
- let output = run_ai_next(&root, &state, true, &request);
1021
- let _ = tx.send(TaskResult::Next {
1022
- output,
1023
- old_problem,
1024
- fallback_to_local,
1025
- });
1026
- });
1027
- self.task_rx = Some(rx);
1028
- }
1029
-
1030
- fn finish_next_problem(
1031
- &mut self,
1032
- output: String,
1033
- old_problem: String,
1034
- fallback_to_local: bool,
1035
- ) -> Result<()> {
1036
- self.bank = load_bank(&self.root)?;
1037
- self.state = load_state(&self.root, &self.bank)?;
1038
- self.problem = problem_by_id(&self.bank, &self.state.current_problem)
1039
- .cloned()
1040
- .unwrap_or_else(|| self.bank[0].clone());
1041
- if self.state.current_problem == old_problem {
1042
- if fallback_to_local
1043
- && let Some(problem) = next_problem(&self.root, &self.bank, &mut self.state)?
1044
- {
1045
- self.problem = problem;
1046
- } else {
1047
- self.write_text_output(&format!(
1048
- "{}{}No next problem is available yet.",
1049
- if output.is_empty() { "" } else { &output },
1050
- if output.is_empty() { "" } else { "\n\n" }
1051
- ));
1052
- return Ok(());
1053
- }
1054
- }
1055
- self.load_code_editor()?;
1056
- self.settings_cursor = None;
1057
- self.show_output = false;
1058
- self.focus = Focus::Code;
1059
- Ok(())
1060
- }
1061
-
1062
- fn action_previous(&mut self) -> Result<()> {
1063
- let old_problem = self.state.current_problem.clone();
1064
- self.problem = previous_problem(&self.root, &self.bank, &mut self.state)?;
1065
- if self.state.current_problem == old_problem {
1066
- self.write_text_output("Already at the first known problem.");
1067
- } else {
1068
- self.load_code_editor()?;
1069
- self.settings_cursor = None;
1070
- self.show_output = false;
1071
- self.focus = Focus::Code;
1072
- }
1073
- Ok(())
1074
- }
1075
-
1076
- fn action_give_up(&mut self) -> Result<()> {
1077
- let answer = give_up(&self.root, &self.problem, &mut self.state)?;
1078
- let language = normalize_language(&self.state.settings.language);
1079
- self.write_output(&format!(
1080
- "Answer for {language}:\n\n```{language}\n{}\n```",
1081
- answer.trim_end()
1082
- ));
1083
- Ok(())
1084
- }
1085
-
1086
- fn action_cycle_language(&mut self) -> Result<()> {
1087
- let current = LANGUAGES
1088
- .iter()
1089
- .position(|language| language == &self.state.settings.language)
1090
- .unwrap_or(0);
1091
- self.set_language(LANGUAGES[(current + 1) % LANGUAGES.len()])
1092
- }
1093
-
1094
- fn action_toggle_ui_language(&mut self) -> Result<()> {
1095
- let current = UI_LANGUAGES
1096
- .iter()
1097
- .position(|language| language == &self.state.settings.ui_language)
1098
- .unwrap_or(0);
1099
- self.set_ui_language(UI_LANGUAGES[(current + 1) % UI_LANGUAGES.len()])
1100
- }
1101
-
1102
- fn action_toggle_theme(&mut self) -> Result<()> {
1103
- let current = THEMES
1104
- .iter()
1105
- .position(|theme| theme == &self.state.settings.theme)
1106
- .unwrap_or(0);
1107
- self.set_theme(THEMES[(current + 1) % THEMES.len()])
1108
- }
1109
-
1110
- fn set_language(&mut self, language: &str) -> Result<()> {
1111
- self.state.settings.language = language.to_string();
1112
- save_state(&self.root, &self.state)?;
1113
- self.load_code_editor()?;
1114
- self.settings_cursor = None;
1115
- self.show_output = false;
1116
- self.focus = Focus::Code;
1117
- Ok(())
1118
- }
1119
-
1120
- fn set_ui_language(&mut self, language: &str) -> Result<()> {
1121
- self.state.settings.ui_language = normalize_ui_language(language);
1122
- save_state(&self.root, &self.state)?;
1123
- self.write_text_output(&format!("UI language: {}", self.state.settings.ui_language));
1124
- Ok(())
1125
- }
1126
-
1127
- fn set_theme(&mut self, theme: &str) -> Result<()> {
1128
- self.state.settings.theme = theme.to_string();
1129
- save_state(&self.root, &self.state)?;
1130
- self.write_text_output(&format!("Theme: {theme}"));
1131
- Ok(())
1132
- }
1133
-
1134
- fn set_difficulty(&mut self, difficulty: &str) -> Result<()> {
1135
- let difficulty = difficulty.trim().to_lowercase();
1136
- if !DIFFICULTIES.contains(&difficulty.as_str()) {
1137
- self.write_text_output("Difficulty: auto, easy, medium, or hard.");
1138
- return Ok(());
1139
- }
1140
- let normalized = normalize_difficulty(&difficulty);
1141
- self.state.settings.difficulty = normalized.clone();
1142
- if normalized != "auto" {
1143
- self.state.suggested_next_difficulty = normalized;
1144
- }
1145
- save_state(&self.root, &self.state)?;
1146
- self.show_profile();
1147
- Ok(())
1148
- }
1149
-
1150
- fn set_topics(&mut self, topics: &str, avoid: bool) -> Result<()> {
1151
- let topics = parse_topic_list(topics);
1152
- if avoid {
1153
- self.state.settings.avoid_topics = topics;
1154
- } else {
1155
- self.state.settings.topics = topics;
1156
- }
1157
- save_state(&self.root, &self.state)?;
1158
- self.show_profile();
1159
- Ok(())
1160
- }
1161
-
1162
- fn set_generate_languages(&mut self, value: &str, ui: bool) -> Result<()> {
1163
- if ui {
1164
- self.state.settings.generate_ui_languages = parse_ui_language_list(value);
1165
- } else {
1166
- self.state.settings.generate_languages = parse_language_list(value);
1167
- }
1168
- save_state(&self.root, &self.state)?;
1169
- self.show_profile();
1170
- Ok(())
1171
- }
1172
-
1173
- fn reset_profile(&mut self) -> Result<()> {
1174
- self.state.settings.difficulty = "auto".to_string();
1175
- self.state.settings.topics.clear();
1176
- self.state.settings.avoid_topics.clear();
1177
- self.state.settings.generate_languages.clear();
1178
- self.state.settings.generate_ui_languages.clear();
1179
- save_state(&self.root, &self.state)?;
1180
- self.show_profile();
1181
- Ok(())
1182
- }
1183
-
1184
- fn show_profile(&mut self) {
1185
- self.show_profile_with_intro("");
1186
- }
1187
-
1188
- fn show_profile_with_intro(&mut self, intro: &str) {
1189
- self.showing_model_status = false;
1190
- if self.settings_cursor.is_none() {
1191
- self.settings_cursor = Some(0);
1192
- }
1193
- let profile = self.profile_text();
1194
- self.output = if intro.trim().is_empty() {
1195
- profile
1196
- } else {
1197
- format!("{}\n\n{profile}", intro.trim_end())
1198
- };
1199
- self.output_is_markdown = false;
1200
- self.show_output = true;
1201
- self.focus = Focus::Output;
1202
- }
1203
-
1204
- fn profile_text(&self) -> String {
1205
- settings_panel::render(&self.state, self.settings_cursor)
1206
- }
1207
-
1208
- fn settings_row_count(&self) -> usize {
1209
- settings_panel::row_count()
1210
- }
1211
-
1212
- fn move_settings_cursor(&mut self, delta: isize) {
1213
- let len = self.settings_row_count() as isize;
1214
- let cursor = self.settings_cursor.unwrap_or(0) as isize;
1215
- self.settings_cursor = Some(((cursor + delta).rem_euclid(len)) as usize);
1216
- self.show_profile();
1217
- }
1218
-
1219
- fn change_selected_setting(&mut self) -> Result<()> {
1220
- let Some(row) = self.settings_cursor else {
1221
- return Ok(());
1222
- };
1223
- let change = settings_panel::apply_selected(&mut self.state, row);
1224
- if change.reload_editor {
1225
- self.load_code_editor()?;
1226
- }
1227
- save_state(&self.root, &self.state)?;
1228
- self.show_profile();
1229
- Ok(())
1230
- }
1231
-
1232
- fn start_ai_prompt(&mut self, prompt: &str) -> Result<()> {
1233
- if self.task_rx.is_some() {
1234
- self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy"));
1235
- return Ok(());
1236
- }
1237
- self.save_code()?;
1238
- let label = normalize_ai_provider(&self.state.settings.ai_provider);
1239
- self.start_busy("ai", &format!("{label} is thinking"));
1240
- let root = self.root.clone();
1241
- let problem = self.problem.clone();
1242
- let settings = self.state.settings.clone();
1243
- let prompt = prompt.to_string();
1244
- let (tx, rx) = mpsc::channel();
1245
- thread::spawn(move || {
1246
- let output = run_ai_prompt(&root, &problem, &settings, &prompt);
1247
- let _ = tx.send(TaskResult::AiPrompt(output));
1248
- });
1249
- self.task_rx = Some(rx);
1250
- Ok(())
1251
- }
1252
-
1253
- fn check_task(&mut self) {
1254
- let task = self.task_rx.as_ref().and_then(|rx| rx.try_recv().ok());
1255
- if let Some(task) = task {
1256
- self.task_rx = None;
1257
- self.stop_busy();
1258
- match task {
1259
- TaskResult::AiPrompt(output) => self.write_output(&output),
1260
- TaskResult::Next {
1261
- output,
1262
- old_problem,
1263
- fallback_to_local,
1264
- } => {
1265
- if let Err(error) =
1266
- self.finish_next_problem(output, old_problem, fallback_to_local)
1267
- {
1268
- self.write_text_output(&format!("Next failed\n{error}"));
1269
- }
1270
- }
1271
- }
1272
- }
1273
- }
1274
-
1275
- fn check_background_generation(&mut self) {
1276
- let output = self.generate_rx.as_ref().and_then(|rx| rx.try_recv().ok());
1277
- let Some(output) = output else {
1278
- return;
1279
- };
1280
- self.generate_rx = None;
1281
- self.generate_started = None;
1282
- let old_len = self.generate_bank_len;
1283
- match load_bank(&self.root) {
1284
- Ok(bank) => {
1285
- let added = bank.len().saturating_sub(old_len);
1286
- self.bank = bank;
1287
- let _ = save_state(&self.root, &self.state);
1288
- self.generate_notice = Some(if added > 0 {
1289
- format!("Generated {added} problem in background. Use /next.")
1290
- } else if output.contains("failed") {
1291
- "Background generation failed. Use /generate to retry.".to_string()
1292
- } else {
1293
- "Background generation finished. Use /problems to review.".to_string()
1294
- });
1295
- }
1296
- Err(error) => {
1297
- self.generate_notice = Some(format!(
1298
- "Background generation finished, but bank reload failed: {error}"
1299
- ));
1300
- }
1301
- }
1302
- }
1303
-
1304
- fn check_update(&mut self) {
1305
- let result = self.update_rx.as_ref().and_then(|rx| rx.try_recv().ok());
1306
- if let Some(result) = result {
1307
- self.update_rx = None;
1308
- self.update_check = Some(result.clone());
1309
- match &result {
1310
- UpdateCheck::Available(version) => self.update_notice = Some(version.clone()),
1311
- UpdateCheck::Current | UpdateCheck::Disabled => self.update_notice = None,
1312
- UpdateCheck::Failed => {}
1313
- }
1314
- }
1315
- }
1316
-
1317
- fn start_update_check(&mut self) {
1318
- if self.update_rx.is_some() {
1319
- return;
1320
- }
1321
- self.last_update_check = Some(Instant::now());
1322
- let (tx, rx) = mpsc::channel();
1323
- thread::spawn(move || {
1324
- let _ = tx.send(check_latest_version());
1325
- });
1326
- self.update_rx = Some(rx);
1327
- }
1328
-
1329
- fn maybe_start_periodic_update_check(&mut self) {
1330
- if self.update_rx.is_some() {
1331
- return;
1332
- }
1333
- if self
1334
- .last_update_check
1335
- .is_none_or(|last| last.elapsed() >= UPDATE_CHECK_INTERVAL)
1336
- {
1337
- self.start_update_check();
1338
- }
1339
- }
1340
-
1341
- fn start_model_check(&mut self) {
1342
- let provider = self.state.settings.ai_provider.clone();
1343
- if self.model_rx.is_some() || self.available_models_provider == provider {
1344
- return;
1345
- }
1346
- let query_provider = provider.clone();
1347
- let (tx, rx) = mpsc::channel();
1348
- thread::spawn(move || {
1349
- let _ = tx.send(available_models(&query_provider));
1350
- });
1351
- self.available_models_provider = provider;
1352
- self.model_rx = Some(rx);
1353
- }
1354
-
1355
- fn check_models(&mut self) {
1356
- let models = self.model_rx.as_ref().and_then(|rx| rx.try_recv().ok());
1357
- if let Some(catalog) = models {
1358
- self.model_rx = None;
1359
- self.available_models = catalog.models;
1360
- self.model_message = catalog.message;
1361
- if self.showing_model_status {
1362
- self.output = self.model_status_text();
1363
- self.output_is_markdown = false;
1364
- self.show_output = true;
1365
- }
1366
- }
1367
- }
1368
-
1369
- fn model_status_text(&self) -> String {
1370
- let mut lines = vec![
1371
- format!("AI provider: {}", self.state.settings.ai_provider),
1372
- format!(
1373
- "AI model: {}",
1374
- if self.state.settings.ai_model == "auto" {
1375
- "auto (provider default)"
1376
- } else {
1377
- self.state.settings.ai_model.as_str()
1378
- }
1379
- ),
1380
- "Use /model auto to let the provider choose its default.".to_string(),
1381
- ];
1382
- if self.model_rx.is_some() {
1383
- lines.push("Loading provider model list...".to_string());
1384
- } else if self.available_models.is_empty() {
1385
- lines.push(
1386
- self.model_message
1387
- .clone()
1388
- .unwrap_or_else(|| "Provider model list is unavailable.".to_string()),
1389
- );
1390
- lines.push("Use /model <name> for a known model.".to_string());
1391
- } else {
1392
- lines.push("Available models:".to_string());
1393
- lines.extend(
1394
- self.available_models
1395
- .iter()
1396
- .map(|model| format!("- /model {model}")),
1397
- );
1398
- }
1399
- lines.join("\n")
1400
- }
1401
-
1402
- fn start_busy(&mut self, label: &str, body: &str) {
1403
- self.settings_cursor = None;
1404
- self.busy_label = label.to_string();
1405
- self.busy_body = body.to_string();
1406
- self.busy_started = Some(Instant::now());
1407
- self.busy_frame = 0;
1408
- self.busy_hits = 0;
1409
- self.busy_misses = 0;
1410
- self.show_output = true;
1411
- self.focus = Focus::Output;
1412
- }
1413
-
1414
- fn stop_busy(&mut self) {
1415
- self.busy_label.clear();
1416
- self.busy_body.clear();
1417
- self.busy_started = None;
1418
- self.busy_frame = 0;
1419
- }
1420
-
1421
- fn handle_busy_key(&mut self, key: KeyEvent) -> bool {
1422
- if self.task_rx.is_none() {
1423
- return false;
1424
- }
1425
- if key.code == KeyCode::Char('q') && key.modifiers.is_empty() {
1426
- self.should_quit = true;
1427
- } else if self.busy_label == "next"
1428
- && key.code == KeyCode::Char(' ')
1429
- && key.modifiers.is_empty()
1430
- {
1431
- if self.busy_game_on_target() {
1432
- self.busy_hits += 1;
1433
- } else {
1434
- self.busy_misses += 1;
1435
- }
1436
- }
1437
- self.focus = Focus::Output;
1438
- true
1439
- }
1440
-
1441
- fn write_output(&mut self, output: &str) {
1442
- self.settings_cursor = None;
1443
- self.showing_model_status = false;
1444
- self.output = output.to_string();
1445
- self.output_is_markdown = true;
1446
- self.show_output = true;
1447
- self.focus = Focus::Output;
1448
- }
1449
-
1450
- fn write_text_output(&mut self, output: &str) {
1451
- self.settings_cursor = None;
1452
- self.showing_model_status = false;
1453
- self.output = output.trim_end().to_string();
1454
- self.output_is_markdown = false;
1455
- self.show_output = true;
1456
- self.focus = Focus::Output;
1457
- }
1458
-
1459
- fn write_model_status(&mut self) {
1460
- self.output = self.model_status_text();
1461
- self.output_is_markdown = false;
1462
- self.showing_model_status = true;
1463
- self.show_output = true;
1464
- self.focus = Focus::Output;
1465
- }
1466
-
1467
- fn refresh_update_notice(&mut self) {
1468
- self.update_check = None;
1469
- self.update_notice = None;
1470
- self.start_update_check();
1471
- self.show_update_notice();
1472
- }
1473
-
1474
- fn show_update_notice(&mut self) {
1475
- let lang = self.state.settings.ui_language.clone();
1476
- if let Some(version) = &self.update_notice {
1477
- self.write_text_output(&format!(
1478
- "{}: practicode {version} (current {CURRENT_VERSION})\n\nnpm update -g practicode\ncargo install --force practicode",
1479
- ui_text(&lang, "update_available")
1480
- ));
1481
- } else if self.update_rx.is_some() {
1482
- self.write_text_output("Checking for updates...");
1483
- } else if matches!(self.update_check, Some(UpdateCheck::Disabled)) {
1484
- self.write_text_output(ui_text(&lang, "update_check_disabled"));
1485
- } else if matches!(self.update_check, Some(UpdateCheck::Failed)) {
1486
- self.write_text_output(ui_text(&lang, "update_check_failed"));
1487
- } else {
1488
- self.write_text_output(ui_text(&lang, "update_none"));
1489
- }
1490
- }
1491
-
1492
- fn append_note(&mut self, note: &str) -> Result<()> {
1493
- append_problem_note(&self.root, note)?;
1494
- self.write_text_output(&format!("Problem note saved to {PROBLEM_NOTES_PATH}."));
1495
- Ok(())
1496
- }
1497
-
1498
- fn show_notes(&mut self) -> Result<()> {
1499
- let notes = read_problem_notes(&self.root)?;
1500
- if notes.is_empty() {
1501
- self.write_text_output("No notes yet. Use /topics or /avoid for standing preferences.");
1502
- } else {
1503
- self.write_text_output(&format!("Problem notes ({PROBLEM_NOTES_PATH})\n\n{notes}"));
1504
- }
1505
- Ok(())
1506
- }
1507
-
1508
- fn insert_command_char(&mut self, char: char) {
1509
- let byte = byte_index(&self.command, self.command_cursor);
1510
- self.command.insert(byte, char);
1511
- self.command_cursor += 1;
1512
- self.command_palette_cursor = 0;
1513
- self.normalize_command_input();
1514
- }
1515
-
1516
- fn delete_command_before_cursor(&mut self) {
1517
- if self.command_cursor == 0 {
1518
- return;
1519
- }
1520
- let start = byte_index(&self.command, self.command_cursor - 1);
1521
- let end = byte_index(&self.command, self.command_cursor);
1522
- self.command.replace_range(start..end, "");
1523
- self.command_cursor -= 1;
1524
- self.command_palette_cursor = 0;
1525
- self.normalize_command_input();
1526
- }
1527
-
1528
- fn delete_command_at_cursor(&mut self) {
1529
- if self.command_cursor >= char_len(&self.command) {
1530
- return;
1531
- }
1532
- let start = byte_index(&self.command, self.command_cursor);
1533
- let end = byte_index(&self.command, self.command_cursor + 1);
1534
- self.command.replace_range(start..end, "");
1535
- self.command_palette_cursor = 0;
1536
- self.normalize_command_input();
1537
- }
1538
-
1539
- fn command_suggestions(&self) -> Vec<CommandChoice> {
1540
- if self.focus != Focus::Command {
1541
- return Vec::new();
1542
- }
1543
- let Some(query) = self.command.trim_start().strip_prefix('/') else {
1544
- return Vec::new();
1545
- };
1546
- let query = query.to_lowercase();
1547
- self.command_choices()
1548
- .into_iter()
1549
- .filter(|hint| hint.insert.starts_with(query.trim_start()))
1550
- .collect()
1551
- }
1552
-
1553
- fn command_choices(&self) -> Vec<CommandChoice> {
1554
- let mut choices = Vec::new();
1555
- for hint in COMMAND_HINTS {
1556
- if hint.insert == "model " {
1557
- for model in self
1558
- .available_models
1559
- .iter()
1560
- .filter(|model| *model != "auto")
1561
- {
1562
- choices.push(CommandChoice {
1563
- insert: format!("model {model}"),
1564
- display: format!("/model {model}"),
1565
- desc_key: "cmd_model_available",
1566
- keep_open: false,
1567
- });
1568
- }
1569
- }
1570
- choices.push(CommandChoice {
1571
- insert: hint.insert.to_string(),
1572
- display: hint.display.to_string(),
1573
- desc_key: hint.desc_key,
1574
- keep_open: hint.keep_open,
1575
- });
1576
- }
1577
- choices
1578
- }
1579
-
1580
- fn move_command_palette(&mut self, delta: isize) {
1581
- let len = self.command_suggestions().len();
1582
- if len == 0 {
1583
- return;
1584
- }
1585
- let cursor = self.command_palette_cursor as isize;
1586
- self.command_palette_cursor = ((cursor + delta).rem_euclid(len as isize)) as usize;
1587
- }
1588
-
1589
- fn accept_command_palette(&mut self) -> Result<bool> {
1590
- let suggestions = self.command_suggestions();
1591
- if suggestions.is_empty() {
1592
- return Ok(false);
1593
- }
1594
- let hint = &suggestions[self.command_palette_cursor.min(suggestions.len() - 1)];
1595
- if hint.keep_open {
1596
- self.command = format!("/{}", hint.insert);
1597
- self.command_cursor = char_len(&self.command);
1598
- self.command_palette_cursor = 0;
1599
- return Ok(true);
1600
- }
1601
- let value = hint.insert.clone();
1602
- self.command.clear();
1603
- self.command_cursor = 0;
1604
- self.command_palette_cursor = 0;
1605
- self.focus = Focus::None;
1606
- self.submit_command(&value)?;
1607
- Ok(true)
1608
- }
1609
-
1610
- fn normalize_command_input(&mut self) {
1611
- let normalized = compose_hangul_jamo(&self.command);
1612
- if normalized == self.command {
1613
- self.command_cursor = self.command_cursor.min(char_len(&self.command));
1614
- return;
1615
- }
1616
- let old_prefix = prefix(&self.command, self.command_cursor);
1617
- self.command = normalized;
1618
- self.command_cursor =
1619
- char_len(&compose_hangul_jamo(&old_prefix)).min(char_len(&self.command));
1620
- }
1621
-
1622
- fn set_terminal_cursor(&self, frame: &mut Frame, code_area: Rect, command_area: Rect) {
1623
- match self.focus {
1624
- Focus::Command => {
1625
- let before = prefix(&self.command, self.command_cursor);
1626
- let x = command_area
1627
- .x
1628
- .saturating_add(1)
1629
- .saturating_add(display_width(&before) as u16)
1630
- .min(command_area.right().saturating_sub(2));
1631
- frame.set_cursor_position(Position::new(x, command_area.y.saturating_add(1)));
1632
- }
1633
- Focus::Code if !self.show_output => {
1634
- if let Some(position) = self.editor.cursor_position(code_area) {
1635
- frame.set_cursor_position(position);
1636
- }
1637
- }
1638
- _ => {}
1639
- }
1640
- }
1641
-
1642
- fn load_code_editor(&mut self) -> Result<()> {
1643
- let path = ensure_submission(&self.root, &self.problem, &self.state.settings)?;
1644
- let text = fs::read_to_string(path).unwrap_or_default();
1645
- self.editor.set_text(&text);
1646
- Ok(())
1647
- }
1648
-
1649
- fn save_code(&self) -> Result<()> {
1650
- let path = ensure_submission(&self.root, &self.problem, &self.state.settings)?;
1651
- fs::write(path, self.editor.text())?;
1652
- Ok(())
1653
- }
1654
-
1655
- fn start_problem_list(&mut self) {
1656
- self.list_cursor = Some(self.current_problem_index());
1657
- self.write_text_output(&self.render_problem_list());
1658
- }
1659
-
1660
- fn render_problem_list(&self) -> String {
1661
- let status_by_id = self
1662
- .state
1663
- .history
1664
- .iter()
1665
- .map(|item| (item.id.as_str(), item.status.as_str()))
1666
- .collect::<HashMap<_, _>>();
1667
- let cursor = self
1668
- .list_cursor
1669
- .unwrap_or_else(|| self.current_problem_index());
1670
- let mut lines = vec![
1671
- "Problems".to_string(),
1672
- String::new(),
1673
- " # ID Difficulty Status Code Title".to_string(),
1674
- ];
1675
- for (index, problem) in self.bank.iter().enumerate() {
1676
- let marker = if index == cursor { ">" } else { " " };
1677
- let current = if problem.id == self.problem.id {
1678
- "*"
1679
- } else {
1680
- " "
1681
- };
1682
- let title = localized(&problem.title, &self.state.settings.ui_language);
1683
- let code_status = self.submission_status(problem).0;
1684
- lines.push(format!(
1685
- "{marker} {current} {:>2} {:<18} {:<10} {:<10} {:<9} {title}",
1686
- index + 1,
1687
- problem.id,
1688
- problem.difficulty,
1689
- status_by_id
1690
- .get(problem.id.as_str())
1691
- .copied()
1692
- .unwrap_or("-"),
1693
- code_status,
1694
- ));
1695
- }
1696
- lines.push("\nup/down or j/k select | enter open | esc close".to_string());
1697
- lines.join("\n")
1698
- }
1699
-
1700
- fn current_problem_index(&self) -> usize {
1701
- self.bank
1702
- .iter()
1703
- .position(|problem| problem.id == self.problem.id)
1704
- .unwrap_or(0)
1705
- }
1706
-
1707
- fn move_list_cursor(&mut self, delta: isize) {
1708
- if self.bank.is_empty() {
1709
- return;
1710
- }
1711
- let cursor = self
1712
- .list_cursor
1713
- .unwrap_or_else(|| self.current_problem_index()) as isize;
1714
- let len = self.bank.len() as isize;
1715
- self.list_cursor = Some(((cursor + delta).rem_euclid(len)) as usize);
1716
- self.write_text_output(&self.render_problem_list());
1717
- }
1718
-
1719
- fn open_selected_problem(&mut self) -> Result<()> {
1720
- if let Some(cursor) = self.list_cursor {
1721
- let problem_id = self.bank[cursor].id.clone();
1722
- self.list_cursor = None;
1723
- self.open_problem(&problem_id)?;
1724
- }
1725
- Ok(())
1726
- }
1727
-
1728
- fn open_problem(&mut self, query: &str) -> Result<()> {
1729
- self.list_cursor = None;
1730
- let Some(problem) = self.find_problem(query).cloned() else {
1731
- self.write_text_output(&format!("Problem not found: {query}\nTry /problems."));
1732
- return Ok(());
1733
- };
1734
- self.problem = problem;
1735
- self.state.current_problem = self.problem.id.clone();
1736
- if !self
1737
- .state
1738
- .history
1739
- .iter()
1740
- .any(|item| item.id == self.problem.id)
1741
- {
1742
- self.state.history.push(HistoryItem {
1743
- id: self.problem.id.clone(),
1744
- status: "assigned".to_string(),
1745
- });
1746
- }
1747
- save_state(&self.root, &self.state)?;
1748
- ensure_problem_files(&self.root, &self.problem)?;
1749
- self.load_code_editor()?;
1750
- self.show_output = false;
1751
- self.focus = Focus::Code;
1752
- Ok(())
1753
- }
1754
-
1755
- fn find_problem(&self, query: &str) -> Option<&Problem> {
1756
- let needle = if query.trim().chars().all(|c| c.is_ascii_digit()) {
1757
- format!("{:03}", query.trim().parse::<usize>().ok()?)
1758
- } else {
1759
- query.trim().to_lowercase()
1760
- };
1761
- self.bank.iter().find(|problem| {
1762
- needle == problem.id.to_lowercase()
1763
- || needle == problem.slug.to_lowercase()
1764
- || problem.id.starts_with(&needle)
1765
- })
1766
- }
1767
-
1768
- fn problem_status(&self, problem: &Problem) -> String {
1769
- if self.state.solved.contains(&problem.id) {
1770
- return "solved".to_string();
1771
- }
1772
- self.state
1773
- .history
1774
- .iter()
1775
- .rev()
1776
- .find(|item| item.id == problem.id)
1777
- .map(|item| item.status.clone())
1778
- .unwrap_or_else(|| "not_started".to_string())
1779
- }
1780
-
1781
- fn submission_status(&self, problem: &Problem) -> (String, String) {
1782
- let language = normalize_language(&self.state.settings.language);
1783
- let path = self
1784
- .root
1785
- .join("submissions")
1786
- .join(&problem.id)
1787
- .join(format!("solution.{}", ext_for(&language)));
1788
- if !path.exists() {
1789
- return ("missing".to_string(), format!("({language})"));
1790
- }
1791
- let content = fs::read_to_string(&path).unwrap_or_default();
1792
- let relative = path.strip_prefix(&self.root).unwrap_or(&path).display();
1793
- if content == template_for(&language) {
1794
- ("template".to_string(), format!("({relative})"))
1795
- } else if content.trim().is_empty() {
1796
- ("empty".to_string(), format!("({relative})"))
1797
- } else {
1798
- ("written".to_string(), format!("({relative})"))
1799
- }
1800
- }
1801
-
1802
- fn status_text(&self) -> String {
1803
- let code_status = self.submission_status(&self.problem).0;
1804
- let activity = if self.busy_label.is_empty() {
1805
- "idle".to_string()
1806
- } else {
1807
- format!("{}{}", self.busy_body, self.busy_dots())
1808
- };
1809
- let tail = if let Some(version) = self.update_notice.as_ref() {
1810
- format!(
1811
- "{}:{version} /update",
1812
- ui_text(&self.state.settings.ui_language, "update")
1813
- )
1814
- } else if self.task_rx.is_some() {
1815
- self.mode_hint().to_string()
1816
- } else if let Some(status) = self.background_generation_status() {
1817
- status
1818
- } else {
1819
- self.mode_hint().to_string()
1820
- };
1821
- format!(
1822
- " PRACTICODE | {} | {} | {} | {} | code:{} | {} | {} ",
1823
- self.problem.id,
1824
- self.problem.difficulty,
1825
- self.problem_status(&self.problem),
1826
- activity,
1827
- code_status,
1828
- self.state.settings.language,
1829
- tail,
1830
- )
1831
- }
1832
-
1833
- fn next_source_help(&self) -> String {
1834
- "Next behavior: /next opens unsolved local problems first and asks AI only when none remain. Use /generate <request> to create a problem in the background.".to_string()
1835
- }
1836
-
1837
- fn background_generation_status(&self) -> Option<String> {
1838
- if self.generate_rx.is_some() {
1839
- let elapsed = self
1840
- .generate_started
1841
- .map(|started| started.elapsed().as_secs())
1842
- .unwrap_or_default();
1843
- Some(format!("bg generate {elapsed}s"))
1844
- } else {
1845
- self.generate_notice.clone()
1846
- }
1847
- }
1848
-
1849
- fn busy_dots(&self) -> String {
1850
- ".".repeat((self.busy_frame / 8) % 4)
1851
- }
1852
-
1853
- fn busy_game_track(&self) -> String {
1854
- let width = 9;
1855
- let target = width / 2;
1856
- let position = (self.busy_frame / 2) % width;
1857
- let mut cells = vec!['-'; width];
1858
- cells[target] = '|';
1859
- cells[position] = if position == target { 'X' } else { '*' };
1860
- format!("[{}]", cells.into_iter().collect::<String>())
1861
- }
1862
-
1863
- fn busy_game_on_target(&self) -> bool {
1864
- (self.busy_frame / 2) % 9 == 4
1865
- }
1866
-
1867
- fn mode_hint(&self) -> &'static str {
1868
- let lang = &self.state.settings.ui_language;
1869
- if self.task_rx.is_some() {
1870
- return if self.busy_label == "next" {
1871
- ui_text(lang, "hint_busy_next")
1872
- } else {
1873
- ui_text(lang, "hint_busy")
1874
- };
1875
- }
1876
- match (self.focus, self.list_cursor.is_some(), self.show_output) {
1877
- (Focus::Command, _, _) => ui_text(lang, "hint_command"),
1878
- (_, true, _) => ui_text(lang, "hint_list"),
1879
- (_, _, true) if self.settings_cursor.is_some() => ui_text(lang, "hint_settings"),
1880
- (_, _, true) => ui_text(lang, "hint_output"),
1881
- (Focus::Code, _, _) => ui_text(lang, "hint_code"),
1882
- _ => ui_text(lang, "hint_idle"),
1883
- }
1884
- }
1885
-
1886
- fn help_text(&self) -> String {
1887
- let lang = &self.state.settings.ui_language;
1888
- let commands = COMMAND_HINTS
1889
- .iter()
1890
- .filter(|hint| hint.help)
1891
- .map(|hint| format!("- `{}` {}", hint.display, ui_text(lang, hint.desc_key)))
1892
- .collect::<Vec<_>>()
1893
- .join("\n");
1894
- format!(
1895
- "# {}\n\n## {}\n\n1. Type code in the right pane.\n2. Press `Esc`, then choose `/run` from the command palette.\n3. Use `/next` when it passes.\n\n## {}\n\n{}\n\n## {}\n\n- `/` opens the command palette outside the editor.\n- `↑/↓` selects a command and `Enter` accepts it.\n- `Esc` cancels the command palette or leaves output.\n\n## {}\n\n- stdout is shown when a case fails.\n- stderr is shown without affecting the expected stdout.",
1896
- ui_text(lang, "help_title"),
1897
- ui_text(lang, "daily_loop"),
1898
- ui_text(lang, "commands"),
1899
- commands,
1900
- ui_text(lang, "keys"),
1901
- ui_text(lang, "debug_prints"),
1902
- )
1903
- }
1904
310
  }