practicode 0.1.1 → 0.1.3

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
@@ -1,15 +1,19 @@
1
1
  use crate::{
2
- ai::{append_problem_note, read_problem_notes, run_ai_next, run_ai_prompt},
2
+ ai::{
3
+ append_problem_note, available_models, provider_status, read_problem_notes, run_ai_next,
4
+ run_ai_prompt,
5
+ },
3
6
  core::{
4
7
  AI_PROVIDERS, AppState, HistoryItem, LANGUAGES, PROBLEM_NOTES_PATH, Problem, THEMES,
5
8
  UI_LANGUAGES, ensure_problem_files, ensure_submission, ext_for, give_up, judge, load_bank,
6
9
  load_state, localized, next_problem, normalize_ai_provider, normalize_language,
7
10
  normalize_next_source, normalize_ui_language, previous_problem, problem_by_id, record_pass,
8
- render_problem, save_state, template_for, ui_text,
11
+ save_state, template_for, ui_text,
9
12
  },
10
13
  text::{
11
14
  byte_index, char_len, compose_hangul_jamo, display_width, prefix, render_markdown_plain,
12
15
  },
16
+ update::{CURRENT_VERSION, UpdateCheck, check_latest_version},
13
17
  };
14
18
  use anyhow::Result;
15
19
  use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
@@ -17,6 +21,7 @@ use ratatui::{
17
21
  DefaultTerminal, Frame,
18
22
  layout::{Constraint, Direction, Layout, Position, Rect},
19
23
  style::{Color, Modifier, Style},
24
+ text::{Line, Span, Text},
20
25
  widgets::{Block, Borders, Clear, Paragraph, Wrap},
21
26
  };
22
27
  use std::{
@@ -37,6 +42,14 @@ struct CommandHint {
37
42
  help: bool,
38
43
  }
39
44
 
45
+ #[derive(Clone)]
46
+ struct CommandChoice {
47
+ insert: String,
48
+ display: String,
49
+ desc_key: &'static str,
50
+ keep_open: bool,
51
+ }
52
+
40
53
  const COMMAND_HINTS: &[CommandHint] = &[
41
54
  CommandHint {
42
55
  insert: "run",
@@ -88,9 +101,9 @@ const COMMAND_HINTS: &[CommandHint] = &[
88
101
  help: true,
89
102
  },
90
103
  CommandHint {
91
- insert: "ai ",
92
- display: "/ai <prompt>",
93
- desc_key: "cmd_ai",
104
+ insert: "hint ",
105
+ display: "/hint <request>",
106
+ desc_key: "cmd_hint",
94
107
  keep_open: true,
95
108
  help: true,
96
109
  },
@@ -111,14 +124,14 @@ const COMMAND_HINTS: &[CommandHint] = &[
111
124
  CommandHint {
112
125
  insert: "model auto",
113
126
  display: "/model auto",
114
- desc_key: "cmd_model",
127
+ desc_key: "cmd_model_auto",
115
128
  keep_open: false,
116
129
  help: true,
117
130
  },
118
131
  CommandHint {
119
132
  insert: "model ",
120
133
  display: "/model <name>",
121
- desc_key: "cmd_model",
134
+ desc_key: "cmd_model_custom",
122
135
  keep_open: true,
123
136
  help: false,
124
137
  },
@@ -214,11 +227,11 @@ const COMMAND_HINTS: &[CommandHint] = &[
214
227
  help: false,
215
228
  },
216
229
  CommandHint {
217
- insert: "source bank",
218
- display: "/source bank",
230
+ insert: "source local",
231
+ display: "/source local",
219
232
  desc_key: "cmd_source",
220
233
  keep_open: false,
221
- help: true,
234
+ help: false,
222
235
  },
223
236
  CommandHint {
224
237
  insert: "source ai",
@@ -227,6 +240,13 @@ const COMMAND_HINTS: &[CommandHint] = &[
227
240
  keep_open: false,
228
241
  help: false,
229
242
  },
243
+ CommandHint {
244
+ insert: "update",
245
+ display: "/update",
246
+ desc_key: "cmd_update",
247
+ keep_open: false,
248
+ help: true,
249
+ },
230
250
  CommandHint {
231
251
  insert: "help",
232
252
  display: "/help",
@@ -269,6 +289,12 @@ pub struct PracticodeApp {
269
289
  busy_body: String,
270
290
  busy_frame: usize,
271
291
  task_rx: Option<Receiver<TaskResult>>,
292
+ update_rx: Option<Receiver<UpdateCheck>>,
293
+ model_rx: Option<Receiver<Vec<String>>>,
294
+ available_models: Vec<String>,
295
+ available_models_provider: String,
296
+ update_check: Option<UpdateCheck>,
297
+ update_notice: Option<String>,
272
298
  should_quit: bool,
273
299
  }
274
300
 
@@ -306,6 +332,12 @@ impl PracticodeApp {
306
332
  busy_body: String::new(),
307
333
  busy_frame: 0,
308
334
  task_rx: None,
335
+ update_rx: None,
336
+ model_rx: None,
337
+ available_models: Vec::new(),
338
+ available_models_provider: String::new(),
339
+ update_check: None,
340
+ update_notice: None,
309
341
  should_quit: false,
310
342
  };
311
343
  app.load_code_editor()?;
@@ -313,9 +345,14 @@ impl PracticodeApp {
313
345
  }
314
346
 
315
347
  pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
348
+ self.start_update_check();
349
+ self.start_model_check();
316
350
  while !self.should_quit {
317
351
  terminal.draw(|frame| self.draw(frame))?;
318
352
  self.check_task();
353
+ self.check_update();
354
+ self.start_model_check();
355
+ self.check_models();
319
356
  if event::poll(Duration::from_millis(100))?
320
357
  && let Event::Key(key) = event::read()?
321
358
  && key.kind != KeyEventKind::Release
@@ -323,7 +360,7 @@ impl PracticodeApp {
323
360
  self.handle_key(key)?;
324
361
  }
325
362
  if !self.busy_label.is_empty() {
326
- self.busy_frame = (self.busy_frame + 1) % 4;
363
+ self.busy_frame = (self.busy_frame + 1) % 16;
327
364
  }
328
365
  }
329
366
  self.save_code().ok();
@@ -362,6 +399,26 @@ impl PracticodeApp {
362
399
  self.task_rx.is_some()
363
400
  }
364
401
 
402
+ pub fn status_text_for_test(&self) -> String {
403
+ self.status_text()
404
+ }
405
+
406
+ pub fn command_suggestions_for_test(&self) -> Vec<String> {
407
+ self.command_suggestions()
408
+ .into_iter()
409
+ .map(|choice| choice.display)
410
+ .collect()
411
+ }
412
+
413
+ pub fn set_available_models_for_test(&mut self, models: Vec<&str>) {
414
+ self.available_models = models.into_iter().map(str::to_string).collect();
415
+ self.available_models_provider = self.state.settings.ai_provider.clone();
416
+ }
417
+
418
+ pub fn pane_title_for_test(title: &str, active: bool) -> String {
419
+ Self::pane_title(title, active)
420
+ }
421
+
365
422
  fn draw(&mut self, frame: &mut Frame) {
366
423
  let size = frame.area();
367
424
  let vertical = Layout::default()
@@ -377,20 +434,18 @@ impl PracticodeApp {
377
434
  .constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
378
435
  .split(vertical[0]);
379
436
 
380
- let problem = Paragraph::new(render_markdown_plain(&render_problem(
381
- &self.problem,
382
- &self.state.settings.ui_language,
383
- )))
384
- .block(Self::block(
385
- ui_text(&self.state.settings.ui_language, "problem"),
386
- self.state.settings.theme == "light",
387
- ))
388
- .wrap(Wrap { trim: false });
437
+ let problem = Paragraph::new(self.problem_text())
438
+ .block(Self::block(
439
+ ui_text(&self.state.settings.ui_language, "problem"),
440
+ self.state.settings.theme == "light",
441
+ false,
442
+ ))
443
+ .wrap(Wrap { trim: false });
389
444
  frame.render_widget(problem, body[0]);
390
445
 
391
446
  if self.show_output {
392
447
  let text = if !self.busy_label.is_empty() {
393
- format!("{}{}", self.busy_body, ".".repeat(self.busy_frame))
448
+ format!("{}{}", self.busy_body, self.busy_dots())
394
449
  } else if self.output_is_markdown {
395
450
  render_markdown_plain(&self.output)
396
451
  } else {
@@ -400,6 +455,7 @@ impl PracticodeApp {
400
455
  .block(Self::block(
401
456
  ui_text(&self.state.settings.ui_language, "output"),
402
457
  self.state.settings.theme == "light",
458
+ self.focus != Focus::Command,
403
459
  ))
404
460
  .wrap(Wrap { trim: false });
405
461
  frame.render_widget(output, body[1]);
@@ -408,8 +464,11 @@ impl PracticodeApp {
408
464
  .editor
409
465
  .visible_text(body[1].height.saturating_sub(2) as usize);
410
466
  let title = format!("solution.{}", ext_for(&self.state.settings.language));
411
- let code = Paragraph::new(code)
412
- .block(Self::block(&title, self.state.settings.theme == "light"));
467
+ let code = Paragraph::new(code).block(Self::block(
468
+ &title,
469
+ self.state.settings.theme == "light",
470
+ self.focus == Focus::Code,
471
+ ));
413
472
  frame.render_widget(code, body[1]);
414
473
  }
415
474
 
@@ -436,6 +495,7 @@ impl PracticodeApp {
436
495
  .block(Self::block(
437
496
  ui_text(&self.state.settings.ui_language, "command"),
438
497
  self.state.settings.theme == "light",
498
+ self.focus == Focus::Command,
439
499
  ))
440
500
  .wrap(Wrap { trim: false });
441
501
  frame.render_widget(command, vertical[2]);
@@ -443,12 +503,147 @@ impl PracticodeApp {
443
503
  self.set_terminal_cursor(frame, body[1], vertical[2]);
444
504
  }
445
505
 
506
+ fn problem_text(&self) -> Text<'static> {
507
+ let lang = normalize_ui_language(&self.state.settings.ui_language);
508
+ let light = self.state.settings.theme == "light";
509
+ let title_style = if light {
510
+ Style::default()
511
+ .fg(Color::Blue)
512
+ .add_modifier(Modifier::BOLD)
513
+ } else {
514
+ Style::default()
515
+ .fg(Color::Yellow)
516
+ .add_modifier(Modifier::BOLD)
517
+ };
518
+ let section_style = if light {
519
+ Style::default()
520
+ .fg(Color::Magenta)
521
+ .add_modifier(Modifier::BOLD)
522
+ } else {
523
+ Style::default()
524
+ .fg(Color::Cyan)
525
+ .add_modifier(Modifier::BOLD)
526
+ };
527
+ let body_style = if light {
528
+ Style::default().fg(Color::Black)
529
+ } else {
530
+ Style::default().fg(Color::Rgb(229, 231, 235))
531
+ };
532
+ let meta_style = if light {
533
+ Style::default().fg(Color::Rgb(75, 85, 99))
534
+ } else {
535
+ Style::default().fg(Color::Rgb(156, 163, 175))
536
+ };
537
+ let code_style = if light {
538
+ Style::default()
539
+ .fg(Color::Black)
540
+ .bg(Color::Rgb(229, 231, 235))
541
+ } else {
542
+ Style::default()
543
+ .fg(Color::Rgb(243, 244, 246))
544
+ .bg(Color::Rgb(31, 41, 55))
545
+ };
546
+ let number = self
547
+ .problem
548
+ .id
549
+ .split_once('-')
550
+ .map(|(number, _)| number)
551
+ .unwrap_or(&self.problem.id);
552
+ let mut lines = vec![
553
+ Line::from(Span::styled(
554
+ format!("{number}. {}", localized(&self.problem.title, &lang)),
555
+ title_style,
556
+ )),
557
+ Line::from(Span::styled(
558
+ format!(
559
+ "{}: {} {}: {}",
560
+ ui_text(&lang, "difficulty"),
561
+ self.problem.difficulty,
562
+ ui_text(&lang, "topics"),
563
+ self.problem.topics.join(", ")
564
+ ),
565
+ meta_style,
566
+ )),
567
+ ];
568
+ lines.push(Line::default());
569
+ for line in localized(&self.problem.statement, &lang).trim_end().lines() {
570
+ lines.push(Line::from(Span::styled(line.to_string(), body_style)));
571
+ }
572
+ Self::push_problem_section(
573
+ &mut lines,
574
+ ui_text(&lang, "input"),
575
+ &localized(&self.problem.input, &lang),
576
+ section_style,
577
+ body_style,
578
+ );
579
+ Self::push_problem_section(
580
+ &mut lines,
581
+ ui_text(&lang, "output"),
582
+ &localized(&self.problem.output, &lang),
583
+ section_style,
584
+ body_style,
585
+ );
586
+ lines.push(Line::default());
587
+ lines.push(Line::from(Span::styled(
588
+ ui_text(&lang, "examples").to_string(),
589
+ section_style,
590
+ )));
591
+ for (index, case) in self.problem.examples.iter().enumerate() {
592
+ lines.push(Line::from(Span::styled(
593
+ format!(" {} {}", ui_text(&lang, "example"), index + 1),
594
+ meta_style.add_modifier(Modifier::BOLD),
595
+ )));
596
+ lines.push(Line::from(Span::styled(
597
+ format!(" {}", ui_text(&lang, "input")),
598
+ meta_style,
599
+ )));
600
+ Self::push_code_lines(&mut lines, &case.input, code_style);
601
+ lines.push(Line::from(Span::styled(
602
+ format!(" {}", ui_text(&lang, "output")),
603
+ meta_style,
604
+ )));
605
+ Self::push_code_lines(&mut lines, &case.output, code_style);
606
+ }
607
+ Text::from(lines)
608
+ }
609
+
610
+ fn push_problem_section(
611
+ lines: &mut Vec<Line<'static>>,
612
+ title: &str,
613
+ body: &str,
614
+ section_style: Style,
615
+ body_style: Style,
616
+ ) {
617
+ lines.push(Line::default());
618
+ lines.push(Line::from(Span::styled(title.to_string(), section_style)));
619
+ for line in body.trim_end().lines() {
620
+ lines.push(Line::from(Span::styled(format!(" {line}"), body_style)));
621
+ }
622
+ }
623
+
624
+ fn push_code_lines(lines: &mut Vec<Line<'static>>, body: &str, code_style: Style) {
625
+ let body = body.trim_end();
626
+ if body.is_empty() {
627
+ lines.push(Line::from(vec![
628
+ Span::raw(" "),
629
+ Span::styled("<empty>".to_string(), code_style),
630
+ ]));
631
+ return;
632
+ }
633
+ for line in body.lines() {
634
+ lines.push(Line::from(vec![
635
+ Span::raw(" "),
636
+ Span::styled(line.to_string(), code_style),
637
+ ]));
638
+ }
639
+ }
640
+
446
641
  fn draw_command_palette(&self, frame: &mut Frame, command_area: Rect) {
447
642
  let suggestions = self.command_suggestions();
448
643
  if suggestions.is_empty() || command_area.y < 3 {
449
644
  return;
450
645
  }
451
- let height = ((suggestions.len() + 3) as u16).min(10).min(command_area.y);
646
+ let height = ((suggestions.len() + 3) as u16).min(14).min(command_area.y);
452
647
  let area = Rect::new(
453
648
  command_area.x,
454
649
  command_area.y - height,
@@ -456,10 +651,13 @@ impl PracticodeApp {
456
651
  height,
457
652
  );
458
653
  let selected = self.command_palette_cursor.min(suggestions.len() - 1);
654
+ let visible = height.saturating_sub(2) as usize;
655
+ let start = selected.saturating_sub(visible.saturating_sub(1));
459
656
  let mut lines = suggestions
460
657
  .iter()
461
658
  .enumerate()
462
- .take(height.saturating_sub(2) as usize)
659
+ .skip(start)
660
+ .take(visible)
463
661
  .map(|(index, hint)| {
464
662
  let marker = if index == selected { ">" } else { " " };
465
663
  format!(
@@ -475,20 +673,40 @@ impl PracticodeApp {
475
673
  Paragraph::new(lines.join("\n")).block(Self::block(
476
674
  ui_text(&self.state.settings.ui_language, "commands"),
477
675
  self.state.settings.theme == "light",
676
+ true,
478
677
  )),
479
678
  area,
480
679
  );
481
680
  }
482
681
 
483
- fn block(title: &str, light: bool) -> Block<'_> {
682
+ fn block(title: &str, light: bool, active: bool) -> Block<'static> {
683
+ let border = if active {
684
+ if light {
685
+ Style::default()
686
+ .fg(Color::Magenta)
687
+ .add_modifier(Modifier::BOLD)
688
+ } else {
689
+ Style::default()
690
+ .fg(Color::Yellow)
691
+ .add_modifier(Modifier::BOLD)
692
+ }
693
+ } else if light {
694
+ Style::default().fg(Color::Blue)
695
+ } else {
696
+ Style::default().fg(Color::Cyan)
697
+ };
484
698
  Block::default()
485
699
  .borders(Borders::ALL)
486
- .title(title)
487
- .border_style(if light {
488
- Style::default().fg(Color::Blue)
489
- } else {
490
- Style::default().fg(Color::Cyan)
491
- })
700
+ .title(Self::pane_title(title, active))
701
+ .border_style(border)
702
+ }
703
+
704
+ fn pane_title(title: &str, active: bool) -> String {
705
+ if active {
706
+ format!("> {title}")
707
+ } else {
708
+ title.to_string()
709
+ }
492
710
  }
493
711
 
494
712
  fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
@@ -667,18 +885,12 @@ impl PracticodeApp {
667
885
  "theme" if arg.is_empty() => self.action_toggle_theme()?,
668
886
  "theme" if THEMES.contains(&arg) => self.set_theme(arg)?,
669
887
  "source" | "next-source" if arg.is_empty() => {
670
- self.write_text_output(&format!(
671
- "Next source: {}",
672
- self.state.settings.next_source
673
- ));
888
+ self.write_text_output(&format!("Next source: {}", self.next_source_label()));
674
889
  }
675
- "source" | "next-source" if matches!(arg, "bank" | "ai") => {
890
+ "source" | "next-source" if matches!(arg, "bank" | "local" | "ai") => {
676
891
  self.state.settings.next_source = normalize_next_source(arg);
677
892
  save_state(&self.root, &self.state)?;
678
- self.write_text_output(&format!(
679
- "Next source: {}",
680
- self.state.settings.next_source
681
- ));
893
+ self.write_text_output(&format!("Next source: {}", self.next_source_label()));
682
894
  }
683
895
  "ai-next-command" if !arg.is_empty() => {
684
896
  self.state.settings.ai_next_command = arg.to_string();
@@ -688,29 +900,42 @@ impl PracticodeApp {
688
900
  }
689
901
  "provider" | "ai-provider" if arg.is_empty() => {
690
902
  self.write_text_output(&format!(
691
- "AI provider: {}",
692
- self.state.settings.ai_provider
903
+ "AI provider: {}\n{}",
904
+ self.state.settings.ai_provider,
905
+ provider_status(&self.state.settings.ai_provider)
693
906
  ));
694
907
  }
695
908
  "provider" | "ai-provider" if AI_PROVIDERS.contains(&arg) => {
696
909
  self.state.settings.ai_provider = normalize_ai_provider(arg);
910
+ self.model_rx = None;
911
+ self.available_models.clear();
912
+ self.available_models_provider.clear();
697
913
  save_state(&self.root, &self.state)?;
698
914
  self.write_text_output(&format!(
699
- "AI provider: {}",
700
- self.state.settings.ai_provider
915
+ "AI provider: {}\n{}",
916
+ self.state.settings.ai_provider,
917
+ provider_status(&self.state.settings.ai_provider)
701
918
  ));
702
919
  }
703
920
  "model" if arg.is_empty() => {
704
- self.write_text_output(&format!("AI model: {}", self.state.settings.ai_model));
921
+ self.write_text_output(&self.model_status_text());
705
922
  }
706
923
  "model" => {
707
- self.state.settings.ai_model = arg.to_string();
924
+ self.state.settings.ai_model = if arg == "auto" {
925
+ "auto".to_string()
926
+ } else {
927
+ arg.to_string()
928
+ };
708
929
  save_state(&self.root, &self.state)?;
709
- self.write_text_output(&format!("AI model: {arg}"));
930
+ self.write_text_output(&self.model_status_text());
931
+ }
932
+ "hint" if arg.is_empty() => {
933
+ self.start_ai_prompt("Give one concise hint for the current problem.")?
710
934
  }
711
- "ai" if !arg.is_empty() => self.start_ai_prompt(arg)?,
935
+ "hint" | "ask" | "ai" if !arg.is_empty() => self.start_ai_prompt(arg)?,
712
936
  "note" if !arg.is_empty() => self.append_note(arg)?,
713
937
  "note" | "notes" => self.show_notes()?,
938
+ "update" => self.show_update_notice(),
714
939
  "exit" | "quit" | "q" => self.should_quit = true,
715
940
  _ => self.write_text_output(&format!("Unknown command: {value}\nTry /help.")),
716
941
  }
@@ -769,10 +994,13 @@ impl PracticodeApp {
769
994
 
770
995
  fn start_next_problem(&mut self, old_problem: String, force: bool, request: String) {
771
996
  if self.task_rx.is_some() {
772
- self.write_text_output("Already busy.");
997
+ self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy"));
773
998
  return;
774
999
  }
775
- self.start_busy("next", "Generating next problem");
1000
+ self.start_busy(
1001
+ "next",
1002
+ ui_text(&self.state.settings.ui_language, "generating_next"),
1003
+ );
776
1004
  let root = self.root.clone();
777
1005
  let state = self.state.clone();
778
1006
  let (tx, rx) = mpsc::channel();
@@ -890,7 +1118,7 @@ impl PracticodeApp {
890
1118
 
891
1119
  fn start_ai_prompt(&mut self, prompt: &str) -> Result<()> {
892
1120
  if self.task_rx.is_some() {
893
- self.write_text_output("Already busy.");
1121
+ self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy"));
894
1122
  return Ok(());
895
1123
  }
896
1124
  self.save_code()?;
@@ -929,6 +1157,78 @@ impl PracticodeApp {
929
1157
  }
930
1158
  }
931
1159
 
1160
+ fn check_update(&mut self) {
1161
+ let result = self.update_rx.as_ref().and_then(|rx| rx.try_recv().ok());
1162
+ if let Some(result) = result {
1163
+ self.update_rx = None;
1164
+ self.update_check = Some(result.clone());
1165
+ if let UpdateCheck::Available(version) = &result {
1166
+ self.update_notice = Some(version.clone());
1167
+ }
1168
+ }
1169
+ }
1170
+
1171
+ fn start_update_check(&mut self) {
1172
+ if self.update_rx.is_some() {
1173
+ return;
1174
+ }
1175
+ let (tx, rx) = mpsc::channel();
1176
+ thread::spawn(move || {
1177
+ let _ = tx.send(check_latest_version());
1178
+ });
1179
+ self.update_rx = Some(rx);
1180
+ }
1181
+
1182
+ fn start_model_check(&mut self) {
1183
+ let provider = self.state.settings.ai_provider.clone();
1184
+ if self.model_rx.is_some() || self.available_models_provider == provider {
1185
+ return;
1186
+ }
1187
+ let query_provider = provider.clone();
1188
+ let (tx, rx) = mpsc::channel();
1189
+ thread::spawn(move || {
1190
+ let _ = tx.send(available_models(&query_provider));
1191
+ });
1192
+ self.available_models_provider = provider;
1193
+ self.model_rx = Some(rx);
1194
+ }
1195
+
1196
+ fn check_models(&mut self) {
1197
+ let models = self.model_rx.as_ref().and_then(|rx| rx.try_recv().ok());
1198
+ if let Some(models) = models {
1199
+ self.model_rx = None;
1200
+ self.available_models = models;
1201
+ }
1202
+ }
1203
+
1204
+ fn model_status_text(&self) -> String {
1205
+ let mut lines = vec![
1206
+ format!(
1207
+ "AI model: {}",
1208
+ if self.state.settings.ai_model == "auto" {
1209
+ "auto (provider default)"
1210
+ } else {
1211
+ self.state.settings.ai_model.as_str()
1212
+ }
1213
+ ),
1214
+ "Use /model auto to let the provider choose its default.".to_string(),
1215
+ ];
1216
+ if self.available_models.is_empty() {
1217
+ lines.push(
1218
+ "Provider model list is unavailable; use /model <name> for a known model."
1219
+ .to_string(),
1220
+ );
1221
+ } else {
1222
+ lines.push("Available models:".to_string());
1223
+ lines.extend(
1224
+ self.available_models
1225
+ .iter()
1226
+ .map(|model| format!("- /model {model}")),
1227
+ );
1228
+ }
1229
+ lines.join("\n")
1230
+ }
1231
+
932
1232
  fn start_busy(&mut self, label: &str, body: &str) {
933
1233
  self.busy_label = label.to_string();
934
1234
  self.busy_body = body.to_string();
@@ -957,6 +1257,24 @@ impl PracticodeApp {
957
1257
  self.focus = Focus::Output;
958
1258
  }
959
1259
 
1260
+ fn show_update_notice(&mut self) {
1261
+ let lang = self.state.settings.ui_language.clone();
1262
+ if let Some(version) = &self.update_notice {
1263
+ self.write_text_output(&format!(
1264
+ "{}: practicode {version} (current {CURRENT_VERSION})\n\nnpm update -g practicode\ncargo install --force practicode",
1265
+ ui_text(&lang, "update_available")
1266
+ ));
1267
+ } else if self.update_rx.is_some() {
1268
+ self.write_text_output("Checking for updates...");
1269
+ } else if matches!(self.update_check, Some(UpdateCheck::Disabled)) {
1270
+ self.write_text_output(ui_text(&lang, "update_check_disabled"));
1271
+ } else if matches!(self.update_check, Some(UpdateCheck::Failed)) {
1272
+ self.write_text_output(ui_text(&lang, "update_check_failed"));
1273
+ } else {
1274
+ self.write_text_output(ui_text(&lang, "update_none"));
1275
+ }
1276
+ }
1277
+
960
1278
  fn append_note(&mut self, note: &str) -> Result<()> {
961
1279
  append_problem_note(&self.root, note)?;
962
1280
  self.write_text_output(&format!("Problem note saved to {PROBLEM_NOTES_PATH}."));
@@ -1004,7 +1322,7 @@ impl PracticodeApp {
1004
1322
  self.normalize_command_input();
1005
1323
  }
1006
1324
 
1007
- fn command_suggestions(&self) -> Vec<&'static CommandHint> {
1325
+ fn command_suggestions(&self) -> Vec<CommandChoice> {
1008
1326
  if self.focus != Focus::Command {
1009
1327
  return Vec::new();
1010
1328
  }
@@ -1012,13 +1330,39 @@ impl PracticodeApp {
1012
1330
  return Vec::new();
1013
1331
  };
1014
1332
  let query = query.to_lowercase();
1015
- COMMAND_HINTS
1016
- .iter()
1333
+ self.command_choices()
1334
+ .into_iter()
1017
1335
  .filter(|hint| hint.insert.starts_with(query.trim_start()))
1018
- .take(7)
1019
1336
  .collect()
1020
1337
  }
1021
1338
 
1339
+ fn command_choices(&self) -> Vec<CommandChoice> {
1340
+ let mut choices = Vec::new();
1341
+ for hint in COMMAND_HINTS {
1342
+ if hint.insert == "model " {
1343
+ for model in self
1344
+ .available_models
1345
+ .iter()
1346
+ .filter(|model| *model != "auto")
1347
+ {
1348
+ choices.push(CommandChoice {
1349
+ insert: format!("model {model}"),
1350
+ display: format!("/model {model}"),
1351
+ desc_key: "cmd_model_available",
1352
+ keep_open: false,
1353
+ });
1354
+ }
1355
+ }
1356
+ choices.push(CommandChoice {
1357
+ insert: hint.insert.to_string(),
1358
+ display: hint.display.to_string(),
1359
+ desc_key: hint.desc_key,
1360
+ keep_open: hint.keep_open,
1361
+ });
1362
+ }
1363
+ choices
1364
+ }
1365
+
1022
1366
  fn move_command_palette(&mut self, delta: isize) {
1023
1367
  let len = self.command_suggestions().len();
1024
1368
  if len == 0 {
@@ -1033,19 +1377,19 @@ impl PracticodeApp {
1033
1377
  if suggestions.is_empty() {
1034
1378
  return Ok(false);
1035
1379
  }
1036
- let hint = suggestions[self.command_palette_cursor.min(suggestions.len() - 1)];
1380
+ let hint = &suggestions[self.command_palette_cursor.min(suggestions.len() - 1)];
1037
1381
  if hint.keep_open {
1038
1382
  self.command = format!("/{}", hint.insert);
1039
1383
  self.command_cursor = char_len(&self.command);
1040
1384
  self.command_palette_cursor = 0;
1041
1385
  return Ok(true);
1042
1386
  }
1043
- let value = hint.insert;
1387
+ let value = hint.insert.clone();
1044
1388
  self.command.clear();
1045
1389
  self.command_cursor = 0;
1046
1390
  self.command_palette_cursor = 0;
1047
1391
  self.focus = Focus::None;
1048
- self.submit_command(value)?;
1392
+ self.submit_command(&value)?;
1049
1393
  Ok(true)
1050
1394
  }
1051
1395
 
@@ -1243,29 +1587,45 @@ impl PracticodeApp {
1243
1587
 
1244
1588
  fn status_text(&self) -> String {
1245
1589
  let code_status = self.submission_status(&self.problem).0;
1590
+ let activity = if self.busy_label.is_empty() {
1591
+ "idle".to_string()
1592
+ } else {
1593
+ format!("{}{}", self.busy_body, self.busy_dots())
1594
+ };
1595
+ let tail = self
1596
+ .update_notice
1597
+ .as_ref()
1598
+ .map(|version| {
1599
+ format!(
1600
+ "{}:{version} /update",
1601
+ ui_text(&self.state.settings.ui_language, "update")
1602
+ )
1603
+ })
1604
+ .unwrap_or_else(|| self.mode_hint().to_string());
1246
1605
  format!(
1247
- " PRACTICODE | {} | {} | {} | {} | code:{} | {} | next:{} | ai:{}/{} | {} ",
1606
+ " PRACTICODE | {} | {} | {} | {} | code:{} | {} | {} ",
1248
1607
  self.problem.id,
1249
1608
  self.problem.difficulty,
1250
- self.busy_status(),
1251
1609
  self.problem_status(&self.problem),
1610
+ activity,
1252
1611
  code_status,
1253
1612
  self.state.settings.language,
1254
- self.state.settings.next_source,
1255
- self.state.settings.ai_provider,
1256
- self.state.settings.ai_model,
1257
- self.mode_hint(),
1613
+ tail,
1258
1614
  )
1259
1615
  }
1260
1616
 
1261
- fn busy_status(&self) -> String {
1262
- if self.busy_label.is_empty() {
1263
- "idle".to_string()
1617
+ fn next_source_label(&self) -> &'static str {
1618
+ if self.state.settings.next_source == "ai" {
1619
+ "ai"
1264
1620
  } else {
1265
- format!("busy:{}{}", self.busy_label, ".".repeat(self.busy_frame))
1621
+ "local"
1266
1622
  }
1267
1623
  }
1268
1624
 
1625
+ fn busy_dots(&self) -> String {
1626
+ ".".repeat(self.busy_frame / 4)
1627
+ }
1628
+
1269
1629
  fn mode_hint(&self) -> &'static str {
1270
1630
  let lang = &self.state.settings.ui_language;
1271
1631
  match (self.focus, self.list_cursor.is_some(), self.show_output) {