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/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/README.md +13 -7
- package/assets/i18n/en.json +54 -0
- package/assets/i18n/es.json +54 -0
- package/assets/i18n/ja.json +54 -0
- package/assets/i18n/ko.json +54 -0
- package/assets/i18n/zh.json +54 -0
- package/docs/CONTRIBUTING.md +68 -23
- package/docs/MAINTAINING.md +67 -0
- package/package.json +1 -1
- package/src/ai.rs +95 -6
- package/src/core.rs +61 -216
- package/src/i18n.rs +45 -0
- package/src/lib.rs +2 -0
- package/src/tui.rs +429 -69
- package/src/update.rs +45 -0
package/src/tui.rs
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
use crate::{
|
|
2
|
-
ai::{
|
|
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
|
-
|
|
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: "
|
|
92
|
-
display: "/
|
|
93
|
-
desc_key: "
|
|
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: "
|
|
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: "
|
|
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
|
|
218
|
-
display: "/source
|
|
230
|
+
insert: "source local",
|
|
231
|
+
display: "/source local",
|
|
219
232
|
desc_key: "cmd_source",
|
|
220
233
|
keep_open: false,
|
|
221
|
-
help:
|
|
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) %
|
|
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(
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
-
.
|
|
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(
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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(&
|
|
921
|
+
self.write_text_output(&self.model_status_text());
|
|
705
922
|
}
|
|
706
923
|
"model" => {
|
|
707
|
-
self.state.settings.ai_model = arg
|
|
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(&
|
|
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(
|
|
997
|
+
self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy"));
|
|
773
998
|
return;
|
|
774
999
|
}
|
|
775
|
-
self.start_busy(
|
|
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(
|
|
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
|
|
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
|
-
|
|
1016
|
-
.
|
|
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:{} | {} |
|
|
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
|
-
|
|
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
|
|
1262
|
-
if self.
|
|
1263
|
-
"
|
|
1617
|
+
fn next_source_label(&self) -> &'static str {
|
|
1618
|
+
if self.state.settings.next_source == "ai" {
|
|
1619
|
+
"ai"
|
|
1264
1620
|
} else {
|
|
1265
|
-
|
|
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) {
|