practicode 0.1.0 → 0.1.2

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,16 @@
1
1
  use crate::{
2
- ai::{append_problem_note, read_problem_notes, run_ai_next, run_ai_prompt},
2
+ ai::{append_problem_note, provider_status, read_problem_notes, run_ai_next, run_ai_prompt},
3
3
  core::{
4
4
  AI_PROVIDERS, AppState, HistoryItem, LANGUAGES, PROBLEM_NOTES_PATH, Problem, THEMES,
5
5
  UI_LANGUAGES, ensure_problem_files, ensure_submission, ext_for, give_up, judge, load_bank,
6
6
  load_state, localized, next_problem, normalize_ai_provider, normalize_language,
7
- normalize_next_source, previous_problem, problem_by_id, record_pass, render_problem,
8
- save_state, template_for,
7
+ normalize_next_source, normalize_ui_language, previous_problem, problem_by_id, record_pass,
8
+ render_problem_tui, save_state, template_for, ui_text,
9
9
  },
10
10
  text::{
11
11
  byte_index, char_len, compose_hangul_jamo, display_width, prefix, render_markdown_plain,
12
12
  },
13
+ update::{CURRENT_VERSION, UpdateCheck, check_latest_version},
13
14
  };
14
15
  use anyhow::Result;
15
16
  use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
@@ -17,7 +18,7 @@ use ratatui::{
17
18
  DefaultTerminal, Frame,
18
19
  layout::{Constraint, Direction, Layout, Position, Rect},
19
20
  style::{Color, Modifier, Style},
20
- widgets::{Block, Borders, Paragraph, Wrap},
21
+ widgets::{Block, Borders, Clear, Paragraph, Wrap},
21
22
  };
22
23
  use std::{
23
24
  collections::HashMap,
@@ -28,46 +29,227 @@ use std::{
28
29
  time::Duration,
29
30
  };
30
31
 
31
- pub const HELP: &str = r#"# Help
32
-
33
- ## Daily loop
34
-
35
- 1. Type code in the right pane.
36
- 2. Press `Esc`, then `/run`.
37
- 3. Use `/next` when it passes.
38
-
39
- ## Commands
40
-
41
- - `/run` judge current submission
42
- - `/edit` focus the code editor
43
- - `/next [request]` next problem, optionally with a request
44
- - `/prev` previous problem
45
- - `/list` choose from problem list
46
- - `/open 2` open by number, id, or slug
47
- - `/giveup` show answer
48
- - `/ai hint` ask the selected AI about current problem + code
49
- - `/provider codex|claude`
50
- - `/model auto|sonnet|opus|...`
51
- - `/note prefer strings this week`
52
- - `/notes` show next-problem notes
53
- - `/lang python|ts|java|rust`
54
- - `/ui ko|en`
55
- - `/theme dark|light`
56
- - `/source bank|ai`
57
- - `/exit` quit
58
-
59
- ## Keys
60
-
61
- - `Esc` leaves the editor or output pane
62
- - `/` opens the command bar when the editor is not focused
63
- - `?` opens this help when the editor is not focused
64
- - `up/down` or `j/k` move in `/list`
65
-
66
- ## Debug prints
32
+ #[derive(Clone, Copy)]
33
+ struct CommandHint {
34
+ insert: &'static str,
35
+ display: &'static str,
36
+ desc_key: &'static str,
37
+ keep_open: bool,
38
+ help: bool,
39
+ }
67
40
 
68
- - stdout prints are shown when a case fails
69
- - stderr prints are shown without affecting the expected stdout
70
- "#;
41
+ const COMMAND_HINTS: &[CommandHint] = &[
42
+ CommandHint {
43
+ insert: "run",
44
+ display: "/run",
45
+ desc_key: "cmd_run",
46
+ keep_open: false,
47
+ help: true,
48
+ },
49
+ CommandHint {
50
+ insert: "edit",
51
+ display: "/edit",
52
+ desc_key: "cmd_edit",
53
+ keep_open: false,
54
+ help: true,
55
+ },
56
+ CommandHint {
57
+ insert: "next",
58
+ display: "/next",
59
+ desc_key: "cmd_next",
60
+ keep_open: false,
61
+ help: true,
62
+ },
63
+ CommandHint {
64
+ insert: "prev",
65
+ display: "/prev",
66
+ desc_key: "cmd_prev",
67
+ keep_open: false,
68
+ help: true,
69
+ },
70
+ CommandHint {
71
+ insert: "list",
72
+ display: "/list",
73
+ desc_key: "cmd_list",
74
+ keep_open: false,
75
+ help: true,
76
+ },
77
+ CommandHint {
78
+ insert: "open ",
79
+ display: "/open <id>",
80
+ desc_key: "cmd_open",
81
+ keep_open: true,
82
+ help: true,
83
+ },
84
+ CommandHint {
85
+ insert: "giveup",
86
+ display: "/giveup",
87
+ desc_key: "cmd_giveup",
88
+ keep_open: false,
89
+ help: true,
90
+ },
91
+ CommandHint {
92
+ insert: "ai ",
93
+ display: "/ai <prompt>",
94
+ desc_key: "cmd_ai",
95
+ keep_open: true,
96
+ help: true,
97
+ },
98
+ CommandHint {
99
+ insert: "provider codex",
100
+ display: "/provider codex",
101
+ desc_key: "cmd_provider",
102
+ keep_open: false,
103
+ help: true,
104
+ },
105
+ CommandHint {
106
+ insert: "provider claude",
107
+ display: "/provider claude",
108
+ desc_key: "cmd_provider",
109
+ keep_open: false,
110
+ help: false,
111
+ },
112
+ CommandHint {
113
+ insert: "model auto",
114
+ display: "/model auto",
115
+ desc_key: "cmd_model",
116
+ keep_open: false,
117
+ help: true,
118
+ },
119
+ CommandHint {
120
+ insert: "model ",
121
+ display: "/model <name>",
122
+ desc_key: "cmd_model",
123
+ keep_open: true,
124
+ help: false,
125
+ },
126
+ CommandHint {
127
+ insert: "note ",
128
+ display: "/note <text>",
129
+ desc_key: "cmd_note",
130
+ keep_open: true,
131
+ help: true,
132
+ },
133
+ CommandHint {
134
+ insert: "notes",
135
+ display: "/notes",
136
+ desc_key: "cmd_notes",
137
+ keep_open: false,
138
+ help: true,
139
+ },
140
+ CommandHint {
141
+ insert: "lang python",
142
+ display: "/lang python",
143
+ desc_key: "cmd_lang",
144
+ keep_open: false,
145
+ help: true,
146
+ },
147
+ CommandHint {
148
+ insert: "lang ts",
149
+ display: "/lang ts",
150
+ desc_key: "cmd_lang",
151
+ keep_open: false,
152
+ help: false,
153
+ },
154
+ CommandHint {
155
+ insert: "lang java",
156
+ display: "/lang java",
157
+ desc_key: "cmd_lang",
158
+ keep_open: false,
159
+ help: false,
160
+ },
161
+ CommandHint {
162
+ insert: "lang rust",
163
+ display: "/lang rust",
164
+ desc_key: "cmd_lang",
165
+ keep_open: false,
166
+ help: false,
167
+ },
168
+ CommandHint {
169
+ insert: "ui en",
170
+ display: "/ui en",
171
+ desc_key: "cmd_ui",
172
+ keep_open: false,
173
+ help: true,
174
+ },
175
+ CommandHint {
176
+ insert: "ui ko",
177
+ display: "/ui ko",
178
+ desc_key: "cmd_ui",
179
+ keep_open: false,
180
+ help: false,
181
+ },
182
+ CommandHint {
183
+ insert: "ui ja",
184
+ display: "/ui ja",
185
+ desc_key: "cmd_ui",
186
+ keep_open: false,
187
+ help: false,
188
+ },
189
+ CommandHint {
190
+ insert: "ui zh",
191
+ display: "/ui zh",
192
+ desc_key: "cmd_ui",
193
+ keep_open: false,
194
+ help: false,
195
+ },
196
+ CommandHint {
197
+ insert: "ui es",
198
+ display: "/ui es",
199
+ desc_key: "cmd_ui",
200
+ keep_open: false,
201
+ help: false,
202
+ },
203
+ CommandHint {
204
+ insert: "theme dark",
205
+ display: "/theme dark",
206
+ desc_key: "cmd_theme",
207
+ keep_open: false,
208
+ help: true,
209
+ },
210
+ CommandHint {
211
+ insert: "theme light",
212
+ display: "/theme light",
213
+ desc_key: "cmd_theme",
214
+ keep_open: false,
215
+ help: false,
216
+ },
217
+ CommandHint {
218
+ insert: "source local",
219
+ display: "/source local",
220
+ desc_key: "cmd_source",
221
+ keep_open: false,
222
+ help: false,
223
+ },
224
+ CommandHint {
225
+ insert: "source ai",
226
+ display: "/source ai",
227
+ desc_key: "cmd_source",
228
+ keep_open: false,
229
+ help: false,
230
+ },
231
+ CommandHint {
232
+ insert: "update",
233
+ display: "/update",
234
+ desc_key: "cmd_update",
235
+ keep_open: false,
236
+ help: true,
237
+ },
238
+ CommandHint {
239
+ insert: "help",
240
+ display: "/help",
241
+ desc_key: "cmd_help",
242
+ keep_open: false,
243
+ help: true,
244
+ },
245
+ CommandHint {
246
+ insert: "exit",
247
+ display: "/exit",
248
+ desc_key: "cmd_exit",
249
+ keep_open: false,
250
+ help: true,
251
+ },
252
+ ];
71
253
 
72
254
  #[derive(Clone, Copy, Debug, Eq, PartialEq)]
73
255
  enum Focus {
@@ -85,6 +267,7 @@ pub struct PracticodeApp {
85
267
  editor: TextEditor,
86
268
  command: String,
87
269
  command_cursor: usize,
270
+ command_palette_cursor: usize,
88
271
  output: String,
89
272
  output_is_markdown: bool,
90
273
  show_output: bool,
@@ -94,6 +277,9 @@ pub struct PracticodeApp {
94
277
  busy_body: String,
95
278
  busy_frame: usize,
96
279
  task_rx: Option<Receiver<TaskResult>>,
280
+ update_rx: Option<Receiver<UpdateCheck>>,
281
+ update_check: Option<UpdateCheck>,
282
+ update_notice: Option<String>,
97
283
  should_quit: bool,
98
284
  }
99
285
 
@@ -121,6 +307,7 @@ impl PracticodeApp {
121
307
  editor: TextEditor::default(),
122
308
  command: String::new(),
123
309
  command_cursor: 0,
310
+ command_palette_cursor: 0,
124
311
  output: String::new(),
125
312
  output_is_markdown: false,
126
313
  show_output: false,
@@ -130,6 +317,9 @@ impl PracticodeApp {
130
317
  busy_body: String::new(),
131
318
  busy_frame: 0,
132
319
  task_rx: None,
320
+ update_rx: None,
321
+ update_check: None,
322
+ update_notice: None,
133
323
  should_quit: false,
134
324
  };
135
325
  app.load_code_editor()?;
@@ -137,9 +327,11 @@ impl PracticodeApp {
137
327
  }
138
328
 
139
329
  pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
330
+ self.start_update_check();
140
331
  while !self.should_quit {
141
332
  terminal.draw(|frame| self.draw(frame))?;
142
333
  self.check_task();
334
+ self.check_update();
143
335
  if event::poll(Duration::from_millis(100))?
144
336
  && let Event::Key(key) = event::read()?
145
337
  && key.kind != KeyEventKind::Release
@@ -147,7 +339,7 @@ impl PracticodeApp {
147
339
  self.handle_key(key)?;
148
340
  }
149
341
  if !self.busy_label.is_empty() {
150
- self.busy_frame = (self.busy_frame + 1) % 4;
342
+ self.busy_frame = (self.busy_frame + 1) % 16;
151
343
  }
152
344
  }
153
345
  self.save_code().ok();
@@ -174,6 +366,10 @@ impl PracticodeApp {
174
366
  self.command_cursor
175
367
  }
176
368
 
369
+ pub fn handle_key_for_test(&mut self, key: KeyEvent) -> Result<()> {
370
+ self.handle_key(key)
371
+ }
372
+
177
373
  pub fn busy_label(&self) -> &str {
178
374
  &self.busy_label
179
375
  }
@@ -182,6 +378,10 @@ impl PracticodeApp {
182
378
  self.task_rx.is_some()
183
379
  }
184
380
 
381
+ pub fn status_text_for_test(&self) -> String {
382
+ self.status_text()
383
+ }
384
+
185
385
  fn draw(&mut self, frame: &mut Frame) {
186
386
  let size = frame.area();
187
387
  let vertical = Layout::default()
@@ -197,24 +397,30 @@ impl PracticodeApp {
197
397
  .constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
198
398
  .split(vertical[0]);
199
399
 
200
- let problem = Paragraph::new(render_markdown_plain(&render_problem(
400
+ let problem = Paragraph::new(render_problem_tui(
201
401
  &self.problem,
202
402
  &self.state.settings.ui_language,
203
- )))
204
- .block(Self::block("Problem", self.state.settings.theme == "light"))
403
+ ))
404
+ .block(Self::block(
405
+ ui_text(&self.state.settings.ui_language, "problem"),
406
+ self.state.settings.theme == "light",
407
+ ))
205
408
  .wrap(Wrap { trim: false });
206
409
  frame.render_widget(problem, body[0]);
207
410
 
208
411
  if self.show_output {
209
412
  let text = if !self.busy_label.is_empty() {
210
- format!("{}{}", self.busy_body, ".".repeat(self.busy_frame))
413
+ format!("{}{}", self.busy_body, self.busy_dots())
211
414
  } else if self.output_is_markdown {
212
415
  render_markdown_plain(&self.output)
213
416
  } else {
214
417
  self.output.clone()
215
418
  };
216
419
  let output = Paragraph::new(text)
217
- .block(Self::block("Output", self.state.settings.theme == "light"))
420
+ .block(Self::block(
421
+ ui_text(&self.state.settings.ui_language, "output"),
422
+ self.state.settings.theme == "light",
423
+ ))
218
424
  .wrap(Wrap { trim: false });
219
425
  frame.render_widget(output, body[1]);
220
426
  } else {
@@ -244,15 +450,56 @@ impl PracticodeApp {
244
450
  let command_text = if self.focus == Focus::Command || !self.command.is_empty() {
245
451
  self.command.clone()
246
452
  } else {
247
- "/run, /next easy string problem, /ai hint, /help".to_string()
453
+ ui_text(&self.state.settings.ui_language, "command_placeholder").to_string()
248
454
  };
249
455
  let command = Paragraph::new(command_text)
250
- .block(Self::block("Command", self.state.settings.theme == "light"))
456
+ .block(Self::block(
457
+ ui_text(&self.state.settings.ui_language, "command"),
458
+ self.state.settings.theme == "light",
459
+ ))
251
460
  .wrap(Wrap { trim: false });
252
461
  frame.render_widget(command, vertical[2]);
462
+ self.draw_command_palette(frame, vertical[2]);
253
463
  self.set_terminal_cursor(frame, body[1], vertical[2]);
254
464
  }
255
465
 
466
+ fn draw_command_palette(&self, frame: &mut Frame, command_area: Rect) {
467
+ let suggestions = self.command_suggestions();
468
+ if suggestions.is_empty() || command_area.y < 3 {
469
+ return;
470
+ }
471
+ let height = ((suggestions.len() + 3) as u16).min(10).min(command_area.y);
472
+ let area = Rect::new(
473
+ command_area.x,
474
+ command_area.y - height,
475
+ command_area.width,
476
+ height,
477
+ );
478
+ let selected = self.command_palette_cursor.min(suggestions.len() - 1);
479
+ let mut lines = suggestions
480
+ .iter()
481
+ .enumerate()
482
+ .take(height.saturating_sub(2) as usize)
483
+ .map(|(index, hint)| {
484
+ let marker = if index == selected { ">" } else { " " };
485
+ format!(
486
+ "{marker} {:<16} {}",
487
+ hint.display,
488
+ ui_text(&self.state.settings.ui_language, hint.desc_key)
489
+ )
490
+ })
491
+ .collect::<Vec<_>>();
492
+ lines.push(ui_text(&self.state.settings.ui_language, "palette_hint").to_string());
493
+ frame.render_widget(Clear, area);
494
+ frame.render_widget(
495
+ Paragraph::new(lines.join("\n")).block(Self::block(
496
+ ui_text(&self.state.settings.ui_language, "commands"),
497
+ self.state.settings.theme == "light",
498
+ )),
499
+ area,
500
+ );
501
+ }
502
+
256
503
  fn block(title: &str, light: bool) -> Block<'_> {
257
504
  Block::default()
258
505
  .borders(Borders::ALL)
@@ -277,14 +524,18 @@ impl PracticodeApp {
277
524
  KeyCode::Esc => {
278
525
  self.command.clear();
279
526
  self.command_cursor = 0;
527
+ self.command_palette_cursor = 0;
280
528
  self.focus = Focus::None;
281
529
  }
282
530
  KeyCode::Enter => {
283
- let value = self.command.trim().to_string();
284
- self.command.clear();
285
- self.command_cursor = 0;
286
- self.focus = Focus::None;
287
- self.submit_command(&value)?;
531
+ if !self.accept_command_palette()? {
532
+ let value = self.command.trim().to_string();
533
+ self.command.clear();
534
+ self.command_cursor = 0;
535
+ self.command_palette_cursor = 0;
536
+ self.focus = Focus::None;
537
+ self.submit_command(&value)?;
538
+ }
288
539
  }
289
540
  KeyCode::Backspace => self.delete_command_before_cursor(),
290
541
  KeyCode::Delete => self.delete_command_at_cursor(),
@@ -292,11 +543,14 @@ impl PracticodeApp {
292
543
  KeyCode::Right => {
293
544
  self.command_cursor = (self.command_cursor + 1).min(char_len(&self.command));
294
545
  }
546
+ KeyCode::Up => self.move_command_palette(-1),
547
+ KeyCode::Down => self.move_command_palette(1),
295
548
  KeyCode::Home => self.command_cursor = 0,
296
549
  KeyCode::End => self.command_cursor = char_len(&self.command),
297
550
  KeyCode::Char('?') if self.command.trim().is_empty() || self.command.trim() == "/" => {
298
551
  self.command.clear();
299
552
  self.command_cursor = 0;
553
+ self.command_palette_cursor = 0;
300
554
  self.focus = Focus::None;
301
555
  self.handle_command("help")?;
302
556
  }
@@ -389,6 +643,7 @@ impl PracticodeApp {
389
643
  self.command.push('/');
390
644
  self.command_cursor = 1;
391
645
  }
646
+ self.command_palette_cursor = 0;
392
647
  self.focus = Focus::Command;
393
648
  }
394
649
 
@@ -404,7 +659,7 @@ impl PracticodeApp {
404
659
  fn handle_command(&mut self, value: &str) -> Result<()> {
405
660
  if value.is_empty() || matches!(value, "help" | "h" | "?") {
406
661
  self.list_cursor = None;
407
- self.write_output(HELP);
662
+ self.write_output(&self.help_text());
408
663
  return Ok(());
409
664
  }
410
665
  if value.starts_with("vim") {
@@ -428,22 +683,16 @@ impl PracticodeApp {
428
683
  "lang" if arg.is_empty() => self.action_cycle_language()?,
429
684
  "lang" if LANGUAGES.contains(&arg) => self.set_language(arg)?,
430
685
  "ui" if arg.is_empty() => self.action_toggle_ui_language()?,
431
- "ui" if UI_LANGUAGES.contains(&arg) => self.set_ui_language(arg)?,
686
+ "ui" => self.set_ui_language(&normalize_ui_language(arg))?,
432
687
  "theme" if arg.is_empty() => self.action_toggle_theme()?,
433
688
  "theme" if THEMES.contains(&arg) => self.set_theme(arg)?,
434
689
  "source" | "next-source" if arg.is_empty() => {
435
- self.write_text_output(&format!(
436
- "Next source: {}",
437
- self.state.settings.next_source
438
- ));
690
+ self.write_text_output(&format!("Next source: {}", self.next_source_label()));
439
691
  }
440
- "source" | "next-source" if matches!(arg, "bank" | "ai") => {
692
+ "source" | "next-source" if matches!(arg, "bank" | "local" | "ai") => {
441
693
  self.state.settings.next_source = normalize_next_source(arg);
442
694
  save_state(&self.root, &self.state)?;
443
- self.write_text_output(&format!(
444
- "Next source: {}",
445
- self.state.settings.next_source
446
- ));
695
+ self.write_text_output(&format!("Next source: {}", self.next_source_label()));
447
696
  }
448
697
  "ai-next-command" if !arg.is_empty() => {
449
698
  self.state.settings.ai_next_command = arg.to_string();
@@ -453,16 +702,18 @@ impl PracticodeApp {
453
702
  }
454
703
  "provider" | "ai-provider" if arg.is_empty() => {
455
704
  self.write_text_output(&format!(
456
- "AI provider: {}",
457
- self.state.settings.ai_provider
705
+ "AI provider: {}\n{}",
706
+ self.state.settings.ai_provider,
707
+ provider_status(&self.state.settings.ai_provider)
458
708
  ));
459
709
  }
460
710
  "provider" | "ai-provider" if AI_PROVIDERS.contains(&arg) => {
461
711
  self.state.settings.ai_provider = normalize_ai_provider(arg);
462
712
  save_state(&self.root, &self.state)?;
463
713
  self.write_text_output(&format!(
464
- "AI provider: {}",
465
- self.state.settings.ai_provider
714
+ "AI provider: {}\n{}",
715
+ self.state.settings.ai_provider,
716
+ provider_status(&self.state.settings.ai_provider)
466
717
  ));
467
718
  }
468
719
  "model" if arg.is_empty() => {
@@ -476,6 +727,7 @@ impl PracticodeApp {
476
727
  "ai" if !arg.is_empty() => self.start_ai_prompt(arg)?,
477
728
  "note" if !arg.is_empty() => self.append_note(arg)?,
478
729
  "note" | "notes" => self.show_notes()?,
730
+ "update" => self.show_update_notice(),
479
731
  "exit" | "quit" | "q" => self.should_quit = true,
480
732
  _ => self.write_text_output(&format!("Unknown command: {value}\nTry /help.")),
481
733
  }
@@ -534,10 +786,13 @@ impl PracticodeApp {
534
786
 
535
787
  fn start_next_problem(&mut self, old_problem: String, force: bool, request: String) {
536
788
  if self.task_rx.is_some() {
537
- self.write_text_output("Already busy.");
789
+ self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy"));
538
790
  return;
539
791
  }
540
- self.start_busy("next", "Generating next problem");
792
+ self.start_busy(
793
+ "next",
794
+ ui_text(&self.state.settings.ui_language, "generating_next"),
795
+ );
541
796
  let root = self.root.clone();
542
797
  let state = self.state.clone();
543
798
  let (tx, rx) = mpsc::channel();
@@ -640,9 +895,9 @@ impl PracticodeApp {
640
895
  }
641
896
 
642
897
  fn set_ui_language(&mut self, language: &str) -> Result<()> {
643
- self.state.settings.ui_language = language.to_string();
898
+ self.state.settings.ui_language = normalize_ui_language(language);
644
899
  save_state(&self.root, &self.state)?;
645
- self.write_text_output(&format!("UI language: {language}"));
900
+ self.write_text_output(&format!("UI language: {}", self.state.settings.ui_language));
646
901
  Ok(())
647
902
  }
648
903
 
@@ -655,7 +910,7 @@ impl PracticodeApp {
655
910
 
656
911
  fn start_ai_prompt(&mut self, prompt: &str) -> Result<()> {
657
912
  if self.task_rx.is_some() {
658
- self.write_text_output("Already busy.");
913
+ self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy"));
659
914
  return Ok(());
660
915
  }
661
916
  self.save_code()?;
@@ -694,6 +949,28 @@ impl PracticodeApp {
694
949
  }
695
950
  }
696
951
 
952
+ fn check_update(&mut self) {
953
+ let result = self.update_rx.as_ref().and_then(|rx| rx.try_recv().ok());
954
+ if let Some(result) = result {
955
+ self.update_rx = None;
956
+ self.update_check = Some(result.clone());
957
+ if let UpdateCheck::Available(version) = &result {
958
+ self.update_notice = Some(version.clone());
959
+ }
960
+ }
961
+ }
962
+
963
+ fn start_update_check(&mut self) {
964
+ if self.update_rx.is_some() {
965
+ return;
966
+ }
967
+ let (tx, rx) = mpsc::channel();
968
+ thread::spawn(move || {
969
+ let _ = tx.send(check_latest_version());
970
+ });
971
+ self.update_rx = Some(rx);
972
+ }
973
+
697
974
  fn start_busy(&mut self, label: &str, body: &str) {
698
975
  self.busy_label = label.to_string();
699
976
  self.busy_body = body.to_string();
@@ -722,6 +999,24 @@ impl PracticodeApp {
722
999
  self.focus = Focus::Output;
723
1000
  }
724
1001
 
1002
+ fn show_update_notice(&mut self) {
1003
+ let lang = self.state.settings.ui_language.clone();
1004
+ if let Some(version) = &self.update_notice {
1005
+ self.write_text_output(&format!(
1006
+ "{}: practicode {version} (current {CURRENT_VERSION})\n\nnpm update -g practicode\ncargo install --force practicode",
1007
+ ui_text(&lang, "update_available")
1008
+ ));
1009
+ } else if self.update_rx.is_some() {
1010
+ self.write_text_output("Checking for updates...");
1011
+ } else if matches!(self.update_check, Some(UpdateCheck::Disabled)) {
1012
+ self.write_text_output(ui_text(&lang, "update_check_disabled"));
1013
+ } else if matches!(self.update_check, Some(UpdateCheck::Failed)) {
1014
+ self.write_text_output(ui_text(&lang, "update_check_failed"));
1015
+ } else {
1016
+ self.write_text_output(ui_text(&lang, "update_none"));
1017
+ }
1018
+ }
1019
+
725
1020
  fn append_note(&mut self, note: &str) -> Result<()> {
726
1021
  append_problem_note(&self.root, note)?;
727
1022
  self.write_text_output(&format!("Problem note saved to {PROBLEM_NOTES_PATH}."));
@@ -742,6 +1037,7 @@ impl PracticodeApp {
742
1037
  let byte = byte_index(&self.command, self.command_cursor);
743
1038
  self.command.insert(byte, char);
744
1039
  self.command_cursor += 1;
1040
+ self.command_palette_cursor = 0;
745
1041
  self.normalize_command_input();
746
1042
  }
747
1043
 
@@ -753,6 +1049,7 @@ impl PracticodeApp {
753
1049
  let end = byte_index(&self.command, self.command_cursor);
754
1050
  self.command.replace_range(start..end, "");
755
1051
  self.command_cursor -= 1;
1052
+ self.command_palette_cursor = 0;
756
1053
  self.normalize_command_input();
757
1054
  }
758
1055
 
@@ -763,9 +1060,55 @@ impl PracticodeApp {
763
1060
  let start = byte_index(&self.command, self.command_cursor);
764
1061
  let end = byte_index(&self.command, self.command_cursor + 1);
765
1062
  self.command.replace_range(start..end, "");
1063
+ self.command_palette_cursor = 0;
766
1064
  self.normalize_command_input();
767
1065
  }
768
1066
 
1067
+ fn command_suggestions(&self) -> Vec<&'static CommandHint> {
1068
+ if self.focus != Focus::Command {
1069
+ return Vec::new();
1070
+ }
1071
+ let Some(query) = self.command.trim_start().strip_prefix('/') else {
1072
+ return Vec::new();
1073
+ };
1074
+ let query = query.to_lowercase();
1075
+ COMMAND_HINTS
1076
+ .iter()
1077
+ .filter(|hint| hint.insert.starts_with(query.trim_start()))
1078
+ .take(7)
1079
+ .collect()
1080
+ }
1081
+
1082
+ fn move_command_palette(&mut self, delta: isize) {
1083
+ let len = self.command_suggestions().len();
1084
+ if len == 0 {
1085
+ return;
1086
+ }
1087
+ let cursor = self.command_palette_cursor as isize;
1088
+ self.command_palette_cursor = ((cursor + delta).rem_euclid(len as isize)) as usize;
1089
+ }
1090
+
1091
+ fn accept_command_palette(&mut self) -> Result<bool> {
1092
+ let suggestions = self.command_suggestions();
1093
+ if suggestions.is_empty() {
1094
+ return Ok(false);
1095
+ }
1096
+ let hint = suggestions[self.command_palette_cursor.min(suggestions.len() - 1)];
1097
+ if hint.keep_open {
1098
+ self.command = format!("/{}", hint.insert);
1099
+ self.command_cursor = char_len(&self.command);
1100
+ self.command_palette_cursor = 0;
1101
+ return Ok(true);
1102
+ }
1103
+ let value = hint.insert;
1104
+ self.command.clear();
1105
+ self.command_cursor = 0;
1106
+ self.command_palette_cursor = 0;
1107
+ self.focus = Focus::None;
1108
+ self.submit_command(value)?;
1109
+ Ok(true)
1110
+ }
1111
+
769
1112
  fn normalize_command_input(&mut self) {
770
1113
  let normalized = compose_hangul_jamo(&self.command);
771
1114
  if normalized == self.command {
@@ -960,38 +1303,74 @@ impl PracticodeApp {
960
1303
 
961
1304
  fn status_text(&self) -> String {
962
1305
  let code_status = self.submission_status(&self.problem).0;
1306
+ let activity = if self.busy_label.is_empty() {
1307
+ "idle".to_string()
1308
+ } else {
1309
+ format!("{}{}", self.busy_body, self.busy_dots())
1310
+ };
1311
+ let tail = self
1312
+ .update_notice
1313
+ .as_ref()
1314
+ .map(|version| {
1315
+ format!(
1316
+ "{}:{version} /update",
1317
+ ui_text(&self.state.settings.ui_language, "update")
1318
+ )
1319
+ })
1320
+ .unwrap_or_else(|| self.mode_hint().to_string());
963
1321
  format!(
964
- " PRACTICODE | {} | {} | {} | {} | code:{} | {} | next:{} | ai:{}/{} | {} ",
1322
+ " PRACTICODE | {} | {} | {} | {} | code:{} | {} | {} ",
965
1323
  self.problem.id,
966
1324
  self.problem.difficulty,
967
- self.busy_status(),
968
1325
  self.problem_status(&self.problem),
1326
+ activity,
969
1327
  code_status,
970
1328
  self.state.settings.language,
971
- self.state.settings.next_source,
972
- self.state.settings.ai_provider,
973
- self.state.settings.ai_model,
974
- self.mode_hint(),
1329
+ tail,
975
1330
  )
976
1331
  }
977
1332
 
978
- fn busy_status(&self) -> String {
979
- if self.busy_label.is_empty() {
980
- "idle".to_string()
1333
+ fn next_source_label(&self) -> &'static str {
1334
+ if self.state.settings.next_source == "ai" {
1335
+ "ai"
981
1336
  } else {
982
- format!("busy:{}{}", self.busy_label, ".".repeat(self.busy_frame))
1337
+ "local"
983
1338
  }
984
1339
  }
985
1340
 
1341
+ fn busy_dots(&self) -> String {
1342
+ ".".repeat(self.busy_frame / 4)
1343
+ }
1344
+
986
1345
  fn mode_hint(&self) -> &'static str {
1346
+ let lang = &self.state.settings.ui_language;
987
1347
  match (self.focus, self.list_cursor.is_some(), self.show_output) {
988
- (Focus::Command, _, _) => "Enter submit | Esc cancel",
989
- (_, true, _) => "up/down move | Enter open | Esc close",
990
- (_, _, true) => "Esc code | / command | ? help",
991
- (Focus::Code, _, _) => "Esc then / command",
992
- _ => "/ command | ? help",
1348
+ (Focus::Command, _, _) => ui_text(lang, "hint_command"),
1349
+ (_, true, _) => ui_text(lang, "hint_list"),
1350
+ (_, _, true) => ui_text(lang, "hint_output"),
1351
+ (Focus::Code, _, _) => ui_text(lang, "hint_code"),
1352
+ _ => ui_text(lang, "hint_idle"),
993
1353
  }
994
1354
  }
1355
+
1356
+ fn help_text(&self) -> String {
1357
+ let lang = &self.state.settings.ui_language;
1358
+ let commands = COMMAND_HINTS
1359
+ .iter()
1360
+ .filter(|hint| hint.help)
1361
+ .map(|hint| format!("- `{}` {}", hint.display, ui_text(lang, hint.desc_key)))
1362
+ .collect::<Vec<_>>()
1363
+ .join("\n");
1364
+ format!(
1365
+ "# {}\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.",
1366
+ ui_text(lang, "help_title"),
1367
+ ui_text(lang, "daily_loop"),
1368
+ ui_text(lang, "commands"),
1369
+ commands,
1370
+ ui_text(lang, "keys"),
1371
+ ui_text(lang, "debug_prints"),
1372
+ )
1373
+ }
995
1374
  }
996
1375
 
997
1376
  #[derive(Clone, Debug)]