practicode 0.1.4 → 0.1.6

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,11 +4,12 @@ use crate::{
4
4
  run_ai_next, run_ai_prompt,
5
5
  },
6
6
  core::{
7
- AI_PROVIDERS, AppState, HistoryItem, LANGUAGES, PROBLEM_NOTES_PATH, Problem, THEMES,
8
- UI_LANGUAGES, ensure_problem_files, ensure_submission, ext_for, give_up, judge, load_bank,
9
- load_state, localized, next_problem, normalize_ai_provider, normalize_language,
10
- normalize_next_source, normalize_ui_language, previous_problem, problem_by_id, record_pass,
11
- save_state, template_for, ui_text,
7
+ AI_PROVIDERS, AppState, DIFFICULTIES, HistoryItem, LANGUAGES, PROBLEM_NOTES_PATH, Problem,
8
+ THEMES, UI_LANGUAGES, ensure_problem_files, ensure_submission, ext_for, give_up, judge,
9
+ load_bank, load_state, localized, next_problem, normalize_ai_provider,
10
+ normalize_difficulty, normalize_language, normalize_next_source, normalize_ui_language,
11
+ parse_topic_list, previous_problem, problem_by_id, record_pass, save_state, template_for,
12
+ ui_text,
12
13
  },
13
14
  text::{
14
15
  byte_index, char_len, compose_hangul_jamo, display_width, prefix, render_markdown_plain,
@@ -16,7 +17,10 @@ use crate::{
16
17
  update::{CURRENT_VERSION, UpdateCheck, check_latest_version},
17
18
  };
18
19
  use anyhow::Result;
19
- use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
20
+ use crossterm::event::{
21
+ self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
22
+ MouseEventKind,
23
+ };
20
24
  use ratatui::{
21
25
  DefaultTerminal, Frame,
22
26
  layout::{Constraint, Direction, Layout, Position, Rect},
@@ -30,17 +34,13 @@ use std::{
30
34
  path::PathBuf,
31
35
  sync::mpsc::{self, Receiver},
32
36
  thread,
33
- time::Duration,
37
+ time::{Duration, Instant},
34
38
  };
35
39
 
36
- #[derive(Clone, Copy)]
37
- struct CommandHint {
38
- insert: &'static str,
39
- display: &'static str,
40
- desc_key: &'static str,
41
- keep_open: bool,
42
- help: bool,
43
- }
40
+ mod commands;
41
+ use self::commands::COMMAND_HINTS;
42
+
43
+ const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60);
44
44
 
45
45
  #[derive(Clone)]
46
46
  struct CommandChoice {
@@ -50,219 +50,6 @@ struct CommandChoice {
50
50
  keep_open: bool,
51
51
  }
52
52
 
53
- const COMMAND_HINTS: &[CommandHint] = &[
54
- CommandHint {
55
- insert: "run",
56
- display: "/run",
57
- desc_key: "cmd_run",
58
- keep_open: false,
59
- help: true,
60
- },
61
- CommandHint {
62
- insert: "edit",
63
- display: "/edit",
64
- desc_key: "cmd_edit",
65
- keep_open: false,
66
- help: true,
67
- },
68
- CommandHint {
69
- insert: "next",
70
- display: "/next",
71
- desc_key: "cmd_next",
72
- keep_open: false,
73
- help: true,
74
- },
75
- CommandHint {
76
- insert: "prev",
77
- display: "/prev",
78
- desc_key: "cmd_prev",
79
- keep_open: false,
80
- help: true,
81
- },
82
- CommandHint {
83
- insert: "list",
84
- display: "/list",
85
- desc_key: "cmd_list",
86
- keep_open: false,
87
- help: true,
88
- },
89
- CommandHint {
90
- insert: "open ",
91
- display: "/open <id>",
92
- desc_key: "cmd_open",
93
- keep_open: true,
94
- help: true,
95
- },
96
- CommandHint {
97
- insert: "giveup",
98
- display: "/giveup",
99
- desc_key: "cmd_giveup",
100
- keep_open: false,
101
- help: true,
102
- },
103
- CommandHint {
104
- insert: "hint ",
105
- display: "/hint <request>",
106
- desc_key: "cmd_hint",
107
- keep_open: true,
108
- help: true,
109
- },
110
- CommandHint {
111
- insert: "provider codex",
112
- display: "/provider codex",
113
- desc_key: "cmd_provider",
114
- keep_open: false,
115
- help: true,
116
- },
117
- CommandHint {
118
- insert: "provider claude",
119
- display: "/provider claude",
120
- desc_key: "cmd_provider",
121
- keep_open: false,
122
- help: false,
123
- },
124
- CommandHint {
125
- insert: "model auto",
126
- display: "/model auto",
127
- desc_key: "cmd_model_auto",
128
- keep_open: false,
129
- help: true,
130
- },
131
- CommandHint {
132
- insert: "model ",
133
- display: "/model <name>",
134
- desc_key: "cmd_model_custom",
135
- keep_open: true,
136
- help: false,
137
- },
138
- CommandHint {
139
- insert: "note ",
140
- display: "/note <text>",
141
- desc_key: "cmd_note",
142
- keep_open: true,
143
- help: true,
144
- },
145
- CommandHint {
146
- insert: "notes",
147
- display: "/notes",
148
- desc_key: "cmd_notes",
149
- keep_open: false,
150
- help: true,
151
- },
152
- CommandHint {
153
- insert: "lang python",
154
- display: "/lang python",
155
- desc_key: "cmd_lang",
156
- keep_open: false,
157
- help: true,
158
- },
159
- CommandHint {
160
- insert: "lang ts",
161
- display: "/lang ts",
162
- desc_key: "cmd_lang",
163
- keep_open: false,
164
- help: false,
165
- },
166
- CommandHint {
167
- insert: "lang java",
168
- display: "/lang java",
169
- desc_key: "cmd_lang",
170
- keep_open: false,
171
- help: false,
172
- },
173
- CommandHint {
174
- insert: "lang rust",
175
- display: "/lang rust",
176
- desc_key: "cmd_lang",
177
- keep_open: false,
178
- help: false,
179
- },
180
- CommandHint {
181
- insert: "ui en",
182
- display: "/ui en",
183
- desc_key: "cmd_ui",
184
- keep_open: false,
185
- help: true,
186
- },
187
- CommandHint {
188
- insert: "ui ko",
189
- display: "/ui ko",
190
- desc_key: "cmd_ui",
191
- keep_open: false,
192
- help: false,
193
- },
194
- CommandHint {
195
- insert: "ui ja",
196
- display: "/ui ja",
197
- desc_key: "cmd_ui",
198
- keep_open: false,
199
- help: false,
200
- },
201
- CommandHint {
202
- insert: "ui zh",
203
- display: "/ui zh",
204
- desc_key: "cmd_ui",
205
- keep_open: false,
206
- help: false,
207
- },
208
- CommandHint {
209
- insert: "ui es",
210
- display: "/ui es",
211
- desc_key: "cmd_ui",
212
- keep_open: false,
213
- help: false,
214
- },
215
- CommandHint {
216
- insert: "theme dark",
217
- display: "/theme dark",
218
- desc_key: "cmd_theme",
219
- keep_open: false,
220
- help: true,
221
- },
222
- CommandHint {
223
- insert: "theme light",
224
- display: "/theme light",
225
- desc_key: "cmd_theme",
226
- keep_open: false,
227
- help: false,
228
- },
229
- CommandHint {
230
- insert: "source local",
231
- display: "/source local",
232
- desc_key: "cmd_source",
233
- keep_open: false,
234
- help: false,
235
- },
236
- CommandHint {
237
- insert: "source ai",
238
- display: "/source ai",
239
- desc_key: "cmd_source",
240
- keep_open: false,
241
- help: false,
242
- },
243
- CommandHint {
244
- insert: "update",
245
- display: "/update",
246
- desc_key: "cmd_update",
247
- keep_open: false,
248
- help: true,
249
- },
250
- CommandHint {
251
- insert: "help",
252
- display: "/help",
253
- desc_key: "cmd_help",
254
- keep_open: false,
255
- help: true,
256
- },
257
- CommandHint {
258
- insert: "exit",
259
- display: "/exit",
260
- desc_key: "cmd_exit",
261
- keep_open: false,
262
- help: true,
263
- },
264
- ];
265
-
266
53
  #[derive(Clone, Copy, Debug, Eq, PartialEq)]
267
54
  enum Focus {
268
55
  Code,
@@ -297,6 +84,10 @@ pub struct PracticodeApp {
297
84
  model_message: Option<String>,
298
85
  update_check: Option<UpdateCheck>,
299
86
  update_notice: Option<String>,
87
+ last_update_check: Option<Instant>,
88
+ code_area: Rect,
89
+ output_area: Rect,
90
+ command_area: Rect,
300
91
  should_quit: bool,
301
92
  }
302
93
 
@@ -342,6 +133,10 @@ impl PracticodeApp {
342
133
  model_message: None,
343
134
  update_check: None,
344
135
  update_notice: None,
136
+ last_update_check: None,
137
+ code_area: Rect::default(),
138
+ output_area: Rect::default(),
139
+ command_area: Rect::default(),
345
140
  should_quit: false,
346
141
  };
347
142
  app.load_code_editor()?;
@@ -355,16 +150,18 @@ impl PracticodeApp {
355
150
  terminal.draw(|frame| self.draw(frame))?;
356
151
  self.check_task();
357
152
  self.check_update();
153
+ self.maybe_start_periodic_update_check();
358
154
  self.start_model_check();
359
155
  self.check_models();
360
- if event::poll(Duration::from_millis(100))?
361
- && let Event::Key(key) = event::read()?
362
- && key.kind != KeyEventKind::Release
363
- {
364
- self.handle_key(key)?;
156
+ if event::poll(Duration::from_millis(100))? {
157
+ match event::read()? {
158
+ Event::Key(key) if key.kind != KeyEventKind::Release => self.handle_key(key)?,
159
+ Event::Mouse(mouse) => self.handle_mouse(mouse)?,
160
+ _ => {}
161
+ }
365
162
  }
366
163
  if !self.busy_label.is_empty() {
367
- self.busy_frame = (self.busy_frame + 1) % 16;
164
+ self.busy_frame = (self.busy_frame + 1) % 32;
368
165
  }
369
166
  }
370
167
  self.save_code().ok();
@@ -395,6 +192,16 @@ impl PracticodeApp {
395
192
  self.handle_key(key)
396
193
  }
397
194
 
195
+ pub fn handle_mouse_for_test(&mut self, mouse: MouseEvent) -> Result<()> {
196
+ self.handle_mouse(mouse)
197
+ }
198
+
199
+ pub fn set_pane_areas_for_test(&mut self, code: Rect, output: Rect, command: Rect) {
200
+ self.code_area = code;
201
+ self.output_area = output;
202
+ self.command_area = command;
203
+ }
204
+
398
205
  pub fn busy_label(&self) -> &str {
399
206
  &self.busy_label
400
207
  }
@@ -448,6 +255,9 @@ impl PracticodeApp {
448
255
  .direction(Direction::Horizontal)
449
256
  .constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
450
257
  .split(vertical[0]);
258
+ self.code_area = body[1];
259
+ self.output_area = body[1];
260
+ self.command_area = vertical[2];
451
261
 
452
262
  let problem = Paragraph::new(self.problem_text())
453
263
  .block(Self::block(
@@ -459,13 +269,7 @@ impl PracticodeApp {
459
269
  frame.render_widget(problem, body[0]);
460
270
 
461
271
  if self.show_output {
462
- let text = if !self.busy_label.is_empty() {
463
- format!("{}{}", self.busy_body, self.busy_dots())
464
- } else if self.output_is_markdown {
465
- render_markdown_plain(&self.output)
466
- } else {
467
- self.output.clone()
468
- };
272
+ let text = self.output_text();
469
273
  let output = Paragraph::new(text)
470
274
  .block(Self::block(
471
275
  ui_text(&self.state.settings.ui_language, "output"),
@@ -518,6 +322,79 @@ impl PracticodeApp {
518
322
  self.set_terminal_cursor(frame, body[1], vertical[2]);
519
323
  }
520
324
 
325
+ fn output_text(&self) -> Text<'static> {
326
+ let light = self.state.settings.theme == "light";
327
+ let title_style = if light {
328
+ Style::default()
329
+ .fg(Color::Blue)
330
+ .add_modifier(Modifier::BOLD)
331
+ } else {
332
+ Style::default()
333
+ .fg(Color::Yellow)
334
+ .add_modifier(Modifier::BOLD)
335
+ };
336
+ let label_style = if light {
337
+ Style::default()
338
+ .fg(Color::Magenta)
339
+ .add_modifier(Modifier::BOLD)
340
+ } else {
341
+ Style::default()
342
+ .fg(Color::Cyan)
343
+ .add_modifier(Modifier::BOLD)
344
+ };
345
+ let body_style = if light {
346
+ Style::default().fg(Color::Black)
347
+ } else {
348
+ Style::default().fg(Color::Rgb(229, 231, 235))
349
+ };
350
+ let code_style = if light {
351
+ Style::default()
352
+ .fg(Color::Black)
353
+ .bg(Color::Rgb(229, 231, 235))
354
+ } else {
355
+ Style::default()
356
+ .fg(Color::Rgb(243, 244, 246))
357
+ .bg(Color::Rgb(31, 41, 55))
358
+ };
359
+ if !self.busy_label.is_empty() {
360
+ return Text::from(Line::from(Span::styled(
361
+ format!("{}{}", self.busy_body, self.busy_dots()),
362
+ title_style,
363
+ )));
364
+ }
365
+ let output = if self.output_is_markdown {
366
+ render_markdown_plain(&self.output)
367
+ } else {
368
+ self.output.clone()
369
+ };
370
+ let mut lines = Vec::new();
371
+ for line in output.lines() {
372
+ if line.is_empty() {
373
+ lines.push(Line::default());
374
+ } else if line.starts_with("PASS ")
375
+ || line.starts_with("FAIL ")
376
+ || line.starts_with("Case ")
377
+ || line.starts_with("Next:")
378
+ || line.starts_with("Fix:")
379
+ {
380
+ lines.push(Line::from(Span::styled(line.to_string(), title_style)));
381
+ } else if matches!(
382
+ line,
383
+ "Input" | "Expected" | "Got" | "Stdout" | "Stderr" | "Compile" | "Error"
384
+ ) {
385
+ lines.push(Line::from(Span::styled(line.to_string(), label_style)));
386
+ } else if line.starts_with(" ") {
387
+ lines.push(Line::from(vec![
388
+ Span::raw(" "),
389
+ Span::styled(line.trim_start().to_string(), code_style),
390
+ ]));
391
+ } else {
392
+ lines.push(Line::from(Span::styled(line.to_string(), body_style)));
393
+ }
394
+ }
395
+ Text::from(lines)
396
+ }
397
+
521
398
  fn problem_text(&self) -> Text<'static> {
522
399
  let lang = normalize_ui_language(&self.state.settings.ui_language);
523
400
  let light = self.state.settings.theme == "light";
@@ -732,6 +609,19 @@ impl PracticodeApp {
732
609
  }
733
610
  }
734
611
 
612
+ fn handle_mouse(&mut self, mouse: MouseEvent) -> Result<()> {
613
+ if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
614
+ return Ok(());
615
+ }
616
+ let position = Position::new(mouse.column, mouse.row);
617
+ if self.command_area.contains(position) {
618
+ self.focus_command();
619
+ } else if self.code_area.contains(position) || self.output_area.contains(position) {
620
+ self.action_edit()?;
621
+ }
622
+ Ok(())
623
+ }
624
+
735
625
  fn handle_command_key(&mut self, key: KeyEvent) -> Result<()> {
736
626
  match key.code {
737
627
  KeyCode::Esc => {
@@ -882,30 +772,38 @@ impl PracticodeApp {
882
772
  }
883
773
  let (command, arg) = value.split_once(char::is_whitespace).unwrap_or((value, ""));
884
774
  let arg = arg.trim();
885
- if command != "list" {
775
+ if !matches!(command, "list" | "problems") {
886
776
  self.list_cursor = None;
887
777
  }
888
778
  match command {
889
779
  "run" | "r" => self.action_run()?,
890
- "edit" | "e" => self.action_edit()?,
780
+ "code" | "edit" | "e" => self.action_edit()?,
891
781
  "next" | "n" => self.action_next(arg)?,
892
- "prev" | "previous" | "p" => self.action_previous()?,
893
- "giveup" | "give" | "g" => self.action_give_up()?,
894
- "list" => self.start_problem_list(),
782
+ "back" | "prev" | "previous" | "p" => self.action_previous()?,
783
+ "answer" | "giveup" | "give" | "g" => self.action_give_up()?,
784
+ "problems" | "list" => self.start_problem_list(),
895
785
  "open" | "o" if !arg.is_empty() => self.open_problem(arg)?,
896
- "lang" if arg.is_empty() => self.action_cycle_language()?,
897
- "lang" if LANGUAGES.contains(&arg) => self.set_language(arg)?,
786
+ "language" | "lang" if arg.is_empty() => self.action_cycle_language()?,
787
+ "language" | "lang" if LANGUAGES.contains(&arg) => self.set_language(arg)?,
898
788
  "ui" if arg.is_empty() => self.action_toggle_ui_language()?,
899
789
  "ui" => self.set_ui_language(&normalize_ui_language(arg))?,
900
790
  "theme" if arg.is_empty() => self.action_toggle_theme()?,
901
791
  "theme" if THEMES.contains(&arg) => self.set_theme(arg)?,
792
+ "profile" | "settings" if arg.is_empty() => self.show_profile(),
793
+ "profile" | "settings" if arg == "reset" => self.reset_profile()?,
794
+ "difficulty" | "level" if arg.is_empty() => self.show_profile(),
795
+ "difficulty" | "level" => self.set_difficulty(arg)?,
796
+ "topics" | "topic" if arg.is_empty() => self.show_profile(),
797
+ "topics" | "topic" => self.set_topics(arg, false)?,
798
+ "avoid" | "skip" if arg.is_empty() => self.show_profile(),
799
+ "avoid" | "skip" => self.set_topics(arg, true)?,
902
800
  "source" | "next-source" if arg.is_empty() => {
903
- self.write_text_output(&format!("Next source: {}", self.next_source_label()));
801
+ self.write_text_output(&self.next_source_help());
904
802
  }
905
803
  "source" | "next-source" if matches!(arg, "bank" | "local" | "ai") => {
906
804
  self.state.settings.next_source = normalize_next_source(arg);
907
805
  save_state(&self.root, &self.state)?;
908
- self.write_text_output(&format!("Next source: {}", self.next_source_label()));
806
+ self.write_text_output(&self.next_source_help());
909
807
  }
910
808
  "ai-next-command" if !arg.is_empty() => {
911
809
  self.state.settings.ai_next_command = arg.to_string();
@@ -955,7 +853,7 @@ impl PracticodeApp {
955
853
  "hint" | "ask" | "ai" if !arg.is_empty() => self.start_ai_prompt(arg)?,
956
854
  "note" if !arg.is_empty() => self.append_note(arg)?,
957
855
  "note" | "notes" => self.show_notes()?,
958
- "update" => self.show_update_notice(),
856
+ "update" => self.refresh_update_notice(),
959
857
  "exit" | "quit" | "q" => self.should_quit = true,
960
858
  _ => self.write_text_output(&format!("Unknown command: {value}\nTry /help.")),
961
859
  }
@@ -982,9 +880,9 @@ impl PracticodeApp {
982
880
  result.total_cases
983
881
  );
984
882
  let next_step = if result.passed {
985
- "Next: /next"
883
+ ui_text(&self.state.settings.ui_language, "run_pass_next")
986
884
  } else {
987
- "Fix code, then /run"
885
+ ui_text(&self.state.settings.ui_language, "run_fail_next")
988
886
  };
989
887
  self.write_text_output(&format!("{headline}\n{}\n\n{next_step}", result.output));
990
888
  Ok(())
@@ -1136,6 +1034,68 @@ impl PracticodeApp {
1136
1034
  Ok(())
1137
1035
  }
1138
1036
 
1037
+ fn set_difficulty(&mut self, difficulty: &str) -> Result<()> {
1038
+ let difficulty = difficulty.trim().to_lowercase();
1039
+ if !DIFFICULTIES.contains(&difficulty.as_str()) {
1040
+ self.write_text_output("Difficulty: auto, easy, medium, or hard.");
1041
+ return Ok(());
1042
+ }
1043
+ let normalized = normalize_difficulty(&difficulty);
1044
+ self.state.settings.difficulty = normalized.clone();
1045
+ if normalized != "auto" {
1046
+ self.state.suggested_next_difficulty = normalized;
1047
+ }
1048
+ save_state(&self.root, &self.state)?;
1049
+ self.show_profile();
1050
+ Ok(())
1051
+ }
1052
+
1053
+ fn set_topics(&mut self, topics: &str, avoid: bool) -> Result<()> {
1054
+ let topics = parse_topic_list(topics);
1055
+ if avoid {
1056
+ self.state.settings.avoid_topics = topics;
1057
+ } else {
1058
+ self.state.settings.topics = topics;
1059
+ }
1060
+ save_state(&self.root, &self.state)?;
1061
+ self.show_profile();
1062
+ Ok(())
1063
+ }
1064
+
1065
+ fn reset_profile(&mut self) -> Result<()> {
1066
+ self.state.settings.difficulty = "auto".to_string();
1067
+ self.state.settings.topics.clear();
1068
+ self.state.settings.avoid_topics.clear();
1069
+ save_state(&self.root, &self.state)?;
1070
+ self.show_profile();
1071
+ Ok(())
1072
+ }
1073
+
1074
+ fn show_profile(&mut self) {
1075
+ self.write_text_output(&self.profile_text());
1076
+ }
1077
+
1078
+ fn profile_text(&self) -> String {
1079
+ let settings = &self.state.settings;
1080
+ let topics = list_or_none(&settings.topics);
1081
+ let avoid = list_or_none(&settings.avoid_topics);
1082
+ format!(
1083
+ "Practice profile\n\nUI language: {}\nCode language: {}\nTheme: {}\nDifficulty: {}\nPreferred topics: {}\nAvoid topics: {}\nAI provider: {}\nAI model: {}\n\nCommands\n/profile\n/difficulty auto|easy|medium|hard\n/topics arrays, strings\n/avoid dp, graph\n/language python|ts|java|rust\n/ui en|ko|ja|zh|es\n/theme dark|light",
1084
+ settings.ui_language,
1085
+ settings.language,
1086
+ settings.theme,
1087
+ settings.difficulty,
1088
+ topics,
1089
+ avoid,
1090
+ settings.ai_provider,
1091
+ if settings.ai_model == "auto" {
1092
+ "auto (provider default)"
1093
+ } else {
1094
+ settings.ai_model.as_str()
1095
+ }
1096
+ )
1097
+ }
1098
+
1139
1099
  fn start_ai_prompt(&mut self, prompt: &str) -> Result<()> {
1140
1100
  if self.task_rx.is_some() {
1141
1101
  self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy"));
@@ -1182,8 +1142,10 @@ impl PracticodeApp {
1182
1142
  if let Some(result) = result {
1183
1143
  self.update_rx = None;
1184
1144
  self.update_check = Some(result.clone());
1185
- if let UpdateCheck::Available(version) = &result {
1186
- self.update_notice = Some(version.clone());
1145
+ match &result {
1146
+ UpdateCheck::Available(version) => self.update_notice = Some(version.clone()),
1147
+ UpdateCheck::Current | UpdateCheck::Disabled => self.update_notice = None,
1148
+ UpdateCheck::Failed => {}
1187
1149
  }
1188
1150
  }
1189
1151
  }
@@ -1192,6 +1154,7 @@ impl PracticodeApp {
1192
1154
  if self.update_rx.is_some() {
1193
1155
  return;
1194
1156
  }
1157
+ self.last_update_check = Some(Instant::now());
1195
1158
  let (tx, rx) = mpsc::channel();
1196
1159
  thread::spawn(move || {
1197
1160
  let _ = tx.send(check_latest_version());
@@ -1199,6 +1162,18 @@ impl PracticodeApp {
1199
1162
  self.update_rx = Some(rx);
1200
1163
  }
1201
1164
 
1165
+ fn maybe_start_periodic_update_check(&mut self) {
1166
+ if self.update_rx.is_some() {
1167
+ return;
1168
+ }
1169
+ if self
1170
+ .last_update_check
1171
+ .is_none_or(|last| last.elapsed() >= UPDATE_CHECK_INTERVAL)
1172
+ {
1173
+ self.start_update_check();
1174
+ }
1175
+ }
1176
+
1202
1177
  fn start_model_check(&mut self) {
1203
1178
  let provider = self.state.settings.ai_provider.clone();
1204
1179
  if self.model_rx.is_some() || self.available_models_provider == provider {
@@ -1298,6 +1273,13 @@ impl PracticodeApp {
1298
1273
  self.focus = Focus::Output;
1299
1274
  }
1300
1275
 
1276
+ fn refresh_update_notice(&mut self) {
1277
+ self.update_check = None;
1278
+ self.update_notice = None;
1279
+ self.start_update_check();
1280
+ self.show_update_notice();
1281
+ }
1282
+
1301
1283
  fn show_update_notice(&mut self) {
1302
1284
  let lang = self.state.settings.ui_language.clone();
1303
1285
  if let Some(version) = &self.update_notice {
@@ -1325,7 +1307,7 @@ impl PracticodeApp {
1325
1307
  fn show_notes(&mut self) -> Result<()> {
1326
1308
  let notes = read_problem_notes(&self.root)?;
1327
1309
  if notes.is_empty() {
1328
- self.write_text_output("No notes yet. Add one with /note <text>.");
1310
+ self.write_text_output("No notes yet. Use /topics or /avoid for standing preferences.");
1329
1311
  } else {
1330
1312
  self.write_text_output(&format!("Problem notes ({PROBLEM_NOTES_PATH})\n\n{notes}"));
1331
1313
  }
@@ -1555,7 +1537,7 @@ impl PracticodeApp {
1555
1537
  fn open_problem(&mut self, query: &str) -> Result<()> {
1556
1538
  self.list_cursor = None;
1557
1539
  let Some(problem) = self.find_problem(query).cloned() else {
1558
- self.write_text_output(&format!("Problem not found: {query}\nTry /list."));
1540
+ self.write_text_output(&format!("Problem not found: {query}\nTry /problems."));
1559
1541
  return Ok(());
1560
1542
  };
1561
1543
  self.problem = problem;
@@ -1655,16 +1637,16 @@ impl PracticodeApp {
1655
1637
  )
1656
1638
  }
1657
1639
 
1658
- fn next_source_label(&self) -> &'static str {
1640
+ fn next_source_help(&self) -> String {
1659
1641
  if self.state.settings.next_source == "ai" {
1660
- "ai"
1642
+ "Next behavior: plain /next asks AI every time. /next <request> also sends that request.".to_string()
1661
1643
  } else {
1662
- "local"
1644
+ "Next behavior: /next uses local problems first and asks AI when it needs a new one. Use /next <request> to ask AI directly.".to_string()
1663
1645
  }
1664
1646
  }
1665
1647
 
1666
1648
  fn busy_dots(&self) -> String {
1667
- ".".repeat(self.busy_frame / 4)
1649
+ ".".repeat((self.busy_frame / 8) % 4)
1668
1650
  }
1669
1651
 
1670
1652
  fn mode_hint(&self) -> &'static str {
@@ -1698,6 +1680,14 @@ impl PracticodeApp {
1698
1680
  }
1699
1681
  }
1700
1682
 
1683
+ fn list_or_none(values: &[String]) -> String {
1684
+ if values.is_empty() {
1685
+ "(none)".to_string()
1686
+ } else {
1687
+ values.join(", ")
1688
+ }
1689
+ }
1690
+
1701
1691
  #[derive(Clone, Debug)]
1702
1692
  pub struct TextEditor {
1703
1693
  lines: Vec<String>,