practicode 0.1.5 → 0.1.7
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 +241 -152
- package/Cargo.toml +2 -2
- package/README.md +63 -17
- package/assets/i18n/en.json +5 -0
- package/assets/i18n/es.json +5 -0
- package/assets/i18n/ja.json +5 -0
- package/assets/i18n/ko.json +5 -0
- package/assets/i18n/zh.json +5 -0
- package/assets/practicode-terminal.svg +33 -21
- package/docs/ARCHITECTURE.md +25 -0
- package/docs/CONTRIBUTING.md +1 -0
- package/package.json +1 -1
- package/src/ai.rs +25 -4
- package/src/core/profile.rs +29 -0
- package/src/core.rs +25 -3
- package/src/lib.rs +44 -1
- package/src/tui/commands.rs +249 -0
- package/src/tui.rs +165 -252
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,
|
|
8
|
-
UI_LANGUAGES, ensure_problem_files, ensure_submission, ext_for, give_up, judge,
|
|
9
|
-
load_state, localized, next_problem, normalize_ai_provider,
|
|
10
|
-
|
|
11
|
-
save_state, template_for,
|
|
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,
|
|
@@ -33,17 +34,13 @@ use std::{
|
|
|
33
34
|
path::PathBuf,
|
|
34
35
|
sync::mpsc::{self, Receiver},
|
|
35
36
|
thread,
|
|
36
|
-
time::Duration,
|
|
37
|
+
time::{Duration, Instant},
|
|
37
38
|
};
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
desc_key: &'static str,
|
|
44
|
-
keep_open: bool,
|
|
45
|
-
help: bool,
|
|
46
|
-
}
|
|
40
|
+
mod commands;
|
|
41
|
+
use self::commands::COMMAND_HINTS;
|
|
42
|
+
|
|
43
|
+
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60);
|
|
47
44
|
|
|
48
45
|
#[derive(Clone)]
|
|
49
46
|
struct CommandChoice {
|
|
@@ -53,205 +50,6 @@ struct CommandChoice {
|
|
|
53
50
|
keep_open: bool,
|
|
54
51
|
}
|
|
55
52
|
|
|
56
|
-
const COMMAND_HINTS: &[CommandHint] = &[
|
|
57
|
-
CommandHint {
|
|
58
|
-
insert: "run",
|
|
59
|
-
display: "/run",
|
|
60
|
-
desc_key: "cmd_run",
|
|
61
|
-
keep_open: false,
|
|
62
|
-
help: true,
|
|
63
|
-
},
|
|
64
|
-
CommandHint {
|
|
65
|
-
insert: "code",
|
|
66
|
-
display: "/code",
|
|
67
|
-
desc_key: "cmd_code",
|
|
68
|
-
keep_open: false,
|
|
69
|
-
help: true,
|
|
70
|
-
},
|
|
71
|
-
CommandHint {
|
|
72
|
-
insert: "next",
|
|
73
|
-
display: "/next",
|
|
74
|
-
desc_key: "cmd_next",
|
|
75
|
-
keep_open: false,
|
|
76
|
-
help: true,
|
|
77
|
-
},
|
|
78
|
-
CommandHint {
|
|
79
|
-
insert: "prev",
|
|
80
|
-
display: "/prev",
|
|
81
|
-
desc_key: "cmd_prev",
|
|
82
|
-
keep_open: false,
|
|
83
|
-
help: true,
|
|
84
|
-
},
|
|
85
|
-
CommandHint {
|
|
86
|
-
insert: "list",
|
|
87
|
-
display: "/list",
|
|
88
|
-
desc_key: "cmd_list",
|
|
89
|
-
keep_open: false,
|
|
90
|
-
help: true,
|
|
91
|
-
},
|
|
92
|
-
CommandHint {
|
|
93
|
-
insert: "open ",
|
|
94
|
-
display: "/open <id>",
|
|
95
|
-
desc_key: "cmd_open",
|
|
96
|
-
keep_open: true,
|
|
97
|
-
help: true,
|
|
98
|
-
},
|
|
99
|
-
CommandHint {
|
|
100
|
-
insert: "giveup",
|
|
101
|
-
display: "/giveup",
|
|
102
|
-
desc_key: "cmd_giveup",
|
|
103
|
-
keep_open: false,
|
|
104
|
-
help: true,
|
|
105
|
-
},
|
|
106
|
-
CommandHint {
|
|
107
|
-
insert: "hint ",
|
|
108
|
-
display: "/hint <request>",
|
|
109
|
-
desc_key: "cmd_hint",
|
|
110
|
-
keep_open: true,
|
|
111
|
-
help: true,
|
|
112
|
-
},
|
|
113
|
-
CommandHint {
|
|
114
|
-
insert: "provider codex",
|
|
115
|
-
display: "/provider codex",
|
|
116
|
-
desc_key: "cmd_provider",
|
|
117
|
-
keep_open: false,
|
|
118
|
-
help: true,
|
|
119
|
-
},
|
|
120
|
-
CommandHint {
|
|
121
|
-
insert: "provider claude",
|
|
122
|
-
display: "/provider claude",
|
|
123
|
-
desc_key: "cmd_provider",
|
|
124
|
-
keep_open: false,
|
|
125
|
-
help: false,
|
|
126
|
-
},
|
|
127
|
-
CommandHint {
|
|
128
|
-
insert: "model auto",
|
|
129
|
-
display: "/model auto",
|
|
130
|
-
desc_key: "cmd_model_auto",
|
|
131
|
-
keep_open: false,
|
|
132
|
-
help: true,
|
|
133
|
-
},
|
|
134
|
-
CommandHint {
|
|
135
|
-
insert: "model ",
|
|
136
|
-
display: "/model <name>",
|
|
137
|
-
desc_key: "cmd_model_custom",
|
|
138
|
-
keep_open: true,
|
|
139
|
-
help: false,
|
|
140
|
-
},
|
|
141
|
-
CommandHint {
|
|
142
|
-
insert: "note ",
|
|
143
|
-
display: "/note <text>",
|
|
144
|
-
desc_key: "cmd_note",
|
|
145
|
-
keep_open: true,
|
|
146
|
-
help: true,
|
|
147
|
-
},
|
|
148
|
-
CommandHint {
|
|
149
|
-
insert: "notes",
|
|
150
|
-
display: "/notes",
|
|
151
|
-
desc_key: "cmd_notes",
|
|
152
|
-
keep_open: false,
|
|
153
|
-
help: true,
|
|
154
|
-
},
|
|
155
|
-
CommandHint {
|
|
156
|
-
insert: "lang python",
|
|
157
|
-
display: "/lang python",
|
|
158
|
-
desc_key: "cmd_lang",
|
|
159
|
-
keep_open: false,
|
|
160
|
-
help: true,
|
|
161
|
-
},
|
|
162
|
-
CommandHint {
|
|
163
|
-
insert: "lang ts",
|
|
164
|
-
display: "/lang ts",
|
|
165
|
-
desc_key: "cmd_lang",
|
|
166
|
-
keep_open: false,
|
|
167
|
-
help: false,
|
|
168
|
-
},
|
|
169
|
-
CommandHint {
|
|
170
|
-
insert: "lang java",
|
|
171
|
-
display: "/lang java",
|
|
172
|
-
desc_key: "cmd_lang",
|
|
173
|
-
keep_open: false,
|
|
174
|
-
help: false,
|
|
175
|
-
},
|
|
176
|
-
CommandHint {
|
|
177
|
-
insert: "lang rust",
|
|
178
|
-
display: "/lang rust",
|
|
179
|
-
desc_key: "cmd_lang",
|
|
180
|
-
keep_open: false,
|
|
181
|
-
help: false,
|
|
182
|
-
},
|
|
183
|
-
CommandHint {
|
|
184
|
-
insert: "ui en",
|
|
185
|
-
display: "/ui en",
|
|
186
|
-
desc_key: "cmd_ui",
|
|
187
|
-
keep_open: false,
|
|
188
|
-
help: true,
|
|
189
|
-
},
|
|
190
|
-
CommandHint {
|
|
191
|
-
insert: "ui ko",
|
|
192
|
-
display: "/ui ko",
|
|
193
|
-
desc_key: "cmd_ui",
|
|
194
|
-
keep_open: false,
|
|
195
|
-
help: false,
|
|
196
|
-
},
|
|
197
|
-
CommandHint {
|
|
198
|
-
insert: "ui ja",
|
|
199
|
-
display: "/ui ja",
|
|
200
|
-
desc_key: "cmd_ui",
|
|
201
|
-
keep_open: false,
|
|
202
|
-
help: false,
|
|
203
|
-
},
|
|
204
|
-
CommandHint {
|
|
205
|
-
insert: "ui zh",
|
|
206
|
-
display: "/ui zh",
|
|
207
|
-
desc_key: "cmd_ui",
|
|
208
|
-
keep_open: false,
|
|
209
|
-
help: false,
|
|
210
|
-
},
|
|
211
|
-
CommandHint {
|
|
212
|
-
insert: "ui es",
|
|
213
|
-
display: "/ui es",
|
|
214
|
-
desc_key: "cmd_ui",
|
|
215
|
-
keep_open: false,
|
|
216
|
-
help: false,
|
|
217
|
-
},
|
|
218
|
-
CommandHint {
|
|
219
|
-
insert: "theme dark",
|
|
220
|
-
display: "/theme dark",
|
|
221
|
-
desc_key: "cmd_theme",
|
|
222
|
-
keep_open: false,
|
|
223
|
-
help: true,
|
|
224
|
-
},
|
|
225
|
-
CommandHint {
|
|
226
|
-
insert: "theme light",
|
|
227
|
-
display: "/theme light",
|
|
228
|
-
desc_key: "cmd_theme",
|
|
229
|
-
keep_open: false,
|
|
230
|
-
help: false,
|
|
231
|
-
},
|
|
232
|
-
CommandHint {
|
|
233
|
-
insert: "update",
|
|
234
|
-
display: "/update",
|
|
235
|
-
desc_key: "cmd_update",
|
|
236
|
-
keep_open: false,
|
|
237
|
-
help: true,
|
|
238
|
-
},
|
|
239
|
-
CommandHint {
|
|
240
|
-
insert: "help",
|
|
241
|
-
display: "/help",
|
|
242
|
-
desc_key: "cmd_help",
|
|
243
|
-
keep_open: false,
|
|
244
|
-
help: true,
|
|
245
|
-
},
|
|
246
|
-
CommandHint {
|
|
247
|
-
insert: "exit",
|
|
248
|
-
display: "/exit",
|
|
249
|
-
desc_key: "cmd_exit",
|
|
250
|
-
keep_open: false,
|
|
251
|
-
help: true,
|
|
252
|
-
},
|
|
253
|
-
];
|
|
254
|
-
|
|
255
53
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
256
54
|
enum Focus {
|
|
257
55
|
Code,
|
|
@@ -286,6 +84,7 @@ pub struct PracticodeApp {
|
|
|
286
84
|
model_message: Option<String>,
|
|
287
85
|
update_check: Option<UpdateCheck>,
|
|
288
86
|
update_notice: Option<String>,
|
|
87
|
+
last_update_check: Option<Instant>,
|
|
289
88
|
code_area: Rect,
|
|
290
89
|
output_area: Rect,
|
|
291
90
|
command_area: Rect,
|
|
@@ -297,7 +96,7 @@ enum TaskResult {
|
|
|
297
96
|
Next {
|
|
298
97
|
output: String,
|
|
299
98
|
old_problem: String,
|
|
300
|
-
|
|
99
|
+
fallback_to_local: bool,
|
|
301
100
|
},
|
|
302
101
|
}
|
|
303
102
|
|
|
@@ -334,6 +133,7 @@ impl PracticodeApp {
|
|
|
334
133
|
model_message: None,
|
|
335
134
|
update_check: None,
|
|
336
135
|
update_notice: None,
|
|
136
|
+
last_update_check: None,
|
|
337
137
|
code_area: Rect::default(),
|
|
338
138
|
output_area: Rect::default(),
|
|
339
139
|
command_area: Rect::default(),
|
|
@@ -350,6 +150,7 @@ impl PracticodeApp {
|
|
|
350
150
|
terminal.draw(|frame| self.draw(frame))?;
|
|
351
151
|
self.check_task();
|
|
352
152
|
self.check_update();
|
|
153
|
+
self.maybe_start_periodic_update_check();
|
|
353
154
|
self.start_model_check();
|
|
354
155
|
self.check_models();
|
|
355
156
|
if event::poll(Duration::from_millis(100))? {
|
|
@@ -360,7 +161,7 @@ impl PracticodeApp {
|
|
|
360
161
|
}
|
|
361
162
|
}
|
|
362
163
|
if !self.busy_label.is_empty() {
|
|
363
|
-
self.busy_frame = (self.busy_frame + 1) %
|
|
164
|
+
self.busy_frame = (self.busy_frame + 1) % 32;
|
|
364
165
|
}
|
|
365
166
|
}
|
|
366
167
|
self.save_code().ok();
|
|
@@ -409,6 +210,10 @@ impl PracticodeApp {
|
|
|
409
210
|
self.task_rx.is_some()
|
|
410
211
|
}
|
|
411
212
|
|
|
213
|
+
pub fn should_quit_for_test(&self) -> bool {
|
|
214
|
+
self.should_quit
|
|
215
|
+
}
|
|
216
|
+
|
|
412
217
|
pub fn status_text_for_test(&self) -> String {
|
|
413
218
|
self.status_text()
|
|
414
219
|
}
|
|
@@ -801,6 +606,10 @@ impl PracticodeApp {
|
|
|
801
606
|
}
|
|
802
607
|
|
|
803
608
|
fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
|
|
609
|
+
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
|
610
|
+
self.should_quit = true;
|
|
611
|
+
return Ok(());
|
|
612
|
+
}
|
|
804
613
|
match self.focus {
|
|
805
614
|
Focus::Command => self.handle_command_key(key),
|
|
806
615
|
Focus::Code => self.handle_code_key(key),
|
|
@@ -971,23 +780,32 @@ impl PracticodeApp {
|
|
|
971
780
|
}
|
|
972
781
|
let (command, arg) = value.split_once(char::is_whitespace).unwrap_or((value, ""));
|
|
973
782
|
let arg = arg.trim();
|
|
974
|
-
if command
|
|
783
|
+
if !matches!(command, "list" | "problems") {
|
|
975
784
|
self.list_cursor = None;
|
|
976
785
|
}
|
|
977
786
|
match command {
|
|
978
787
|
"run" | "r" => self.action_run()?,
|
|
979
788
|
"code" | "edit" | "e" => self.action_edit()?,
|
|
980
789
|
"next" | "n" => self.action_next(arg)?,
|
|
981
|
-
"
|
|
982
|
-
"
|
|
983
|
-
"
|
|
790
|
+
"generate" | "gen" | "new" => self.action_generate(arg),
|
|
791
|
+
"back" | "prev" | "previous" | "p" => self.action_previous()?,
|
|
792
|
+
"answer" | "giveup" | "give" | "g" => self.action_give_up()?,
|
|
793
|
+
"problems" | "list" => self.start_problem_list(),
|
|
984
794
|
"open" | "o" if !arg.is_empty() => self.open_problem(arg)?,
|
|
985
|
-
"lang" if arg.is_empty() => self.action_cycle_language()?,
|
|
986
|
-
"lang" if LANGUAGES.contains(&arg) => self.set_language(arg)?,
|
|
795
|
+
"language" | "lang" if arg.is_empty() => self.action_cycle_language()?,
|
|
796
|
+
"language" | "lang" if LANGUAGES.contains(&arg) => self.set_language(arg)?,
|
|
987
797
|
"ui" if arg.is_empty() => self.action_toggle_ui_language()?,
|
|
988
798
|
"ui" => self.set_ui_language(&normalize_ui_language(arg))?,
|
|
989
799
|
"theme" if arg.is_empty() => self.action_toggle_theme()?,
|
|
990
800
|
"theme" if THEMES.contains(&arg) => self.set_theme(arg)?,
|
|
801
|
+
"profile" | "settings" if arg.is_empty() => self.show_profile(),
|
|
802
|
+
"profile" | "settings" if arg == "reset" => self.reset_profile()?,
|
|
803
|
+
"difficulty" | "level" if arg.is_empty() => self.show_profile(),
|
|
804
|
+
"difficulty" | "level" => self.set_difficulty(arg)?,
|
|
805
|
+
"topics" | "topic" if arg.is_empty() => self.show_profile(),
|
|
806
|
+
"topics" | "topic" => self.set_topics(arg, false)?,
|
|
807
|
+
"avoid" | "skip" if arg.is_empty() => self.show_profile(),
|
|
808
|
+
"avoid" | "skip" => self.set_topics(arg, true)?,
|
|
991
809
|
"source" | "next-source" if arg.is_empty() => {
|
|
992
810
|
self.write_text_output(&self.next_source_help());
|
|
993
811
|
}
|
|
@@ -1044,7 +862,7 @@ impl PracticodeApp {
|
|
|
1044
862
|
"hint" | "ask" | "ai" if !arg.is_empty() => self.start_ai_prompt(arg)?,
|
|
1045
863
|
"note" if !arg.is_empty() => self.append_note(arg)?,
|
|
1046
864
|
"note" | "notes" => self.show_notes()?,
|
|
1047
|
-
"update" => self.
|
|
865
|
+
"update" => self.refresh_update_notice(),
|
|
1048
866
|
"exit" | "quit" | "q" => self.should_quit = true,
|
|
1049
867
|
_ => self.write_text_output(&format!("Unknown command: {value}\nTry /help.")),
|
|
1050
868
|
}
|
|
@@ -1082,14 +900,6 @@ impl PracticodeApp {
|
|
|
1082
900
|
fn action_next(&mut self, request: &str) -> Result<()> {
|
|
1083
901
|
let request = request.trim();
|
|
1084
902
|
let old_problem = self.state.current_problem.clone();
|
|
1085
|
-
if !request.is_empty() {
|
|
1086
|
-
self.start_next_problem(old_problem, true, request.to_string());
|
|
1087
|
-
return Ok(());
|
|
1088
|
-
}
|
|
1089
|
-
if self.state.settings.next_source == "ai" {
|
|
1090
|
-
self.start_next_problem(old_problem, false, String::new());
|
|
1091
|
-
return Ok(());
|
|
1092
|
-
}
|
|
1093
903
|
if let Some(problem) = next_problem(&self.root, &self.bank, &mut self.state)? {
|
|
1094
904
|
self.problem = problem;
|
|
1095
905
|
self.load_code_editor()?;
|
|
@@ -1097,11 +907,24 @@ impl PracticodeApp {
|
|
|
1097
907
|
self.focus = Focus::Code;
|
|
1098
908
|
return Ok(());
|
|
1099
909
|
}
|
|
1100
|
-
self.start_next_problem(old_problem, true,
|
|
910
|
+
self.start_next_problem(old_problem, true, request.to_string());
|
|
1101
911
|
Ok(())
|
|
1102
912
|
}
|
|
1103
913
|
|
|
1104
|
-
fn
|
|
914
|
+
fn action_generate(&mut self, request: &str) {
|
|
915
|
+
self.start_next_problem(
|
|
916
|
+
self.state.current_problem.clone(),
|
|
917
|
+
false,
|
|
918
|
+
request.trim().to_string(),
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
fn start_next_problem(
|
|
923
|
+
&mut self,
|
|
924
|
+
old_problem: String,
|
|
925
|
+
fallback_to_local: bool,
|
|
926
|
+
request: String,
|
|
927
|
+
) {
|
|
1105
928
|
if self.task_rx.is_some() {
|
|
1106
929
|
self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy"));
|
|
1107
930
|
return;
|
|
@@ -1114,11 +937,11 @@ impl PracticodeApp {
|
|
|
1114
937
|
let state = self.state.clone();
|
|
1115
938
|
let (tx, rx) = mpsc::channel();
|
|
1116
939
|
thread::spawn(move || {
|
|
1117
|
-
let output = run_ai_next(&root, &state,
|
|
940
|
+
let output = run_ai_next(&root, &state, true, &request);
|
|
1118
941
|
let _ = tx.send(TaskResult::Next {
|
|
1119
942
|
output,
|
|
1120
943
|
old_problem,
|
|
1121
|
-
|
|
944
|
+
fallback_to_local,
|
|
1122
945
|
});
|
|
1123
946
|
});
|
|
1124
947
|
self.task_rx = Some(rx);
|
|
@@ -1128,17 +951,17 @@ impl PracticodeApp {
|
|
|
1128
951
|
&mut self,
|
|
1129
952
|
output: String,
|
|
1130
953
|
old_problem: String,
|
|
1131
|
-
|
|
954
|
+
fallback_to_local: bool,
|
|
1132
955
|
) -> Result<()> {
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
self.state = load_state(&self.root, &self.bank)?;
|
|
1136
|
-
}
|
|
956
|
+
self.bank = load_bank(&self.root)?;
|
|
957
|
+
self.state = load_state(&self.root, &self.bank)?;
|
|
1137
958
|
self.problem = problem_by_id(&self.bank, &self.state.current_problem)
|
|
1138
959
|
.cloned()
|
|
1139
960
|
.unwrap_or_else(|| self.bank[0].clone());
|
|
1140
961
|
if self.state.current_problem == old_problem {
|
|
1141
|
-
if
|
|
962
|
+
if fallback_to_local
|
|
963
|
+
&& let Some(problem) = next_problem(&self.root, &self.bank, &mut self.state)?
|
|
964
|
+
{
|
|
1142
965
|
self.problem = problem;
|
|
1143
966
|
} else {
|
|
1144
967
|
self.write_text_output(&format!(
|
|
@@ -1225,6 +1048,68 @@ impl PracticodeApp {
|
|
|
1225
1048
|
Ok(())
|
|
1226
1049
|
}
|
|
1227
1050
|
|
|
1051
|
+
fn set_difficulty(&mut self, difficulty: &str) -> Result<()> {
|
|
1052
|
+
let difficulty = difficulty.trim().to_lowercase();
|
|
1053
|
+
if !DIFFICULTIES.contains(&difficulty.as_str()) {
|
|
1054
|
+
self.write_text_output("Difficulty: auto, easy, medium, or hard.");
|
|
1055
|
+
return Ok(());
|
|
1056
|
+
}
|
|
1057
|
+
let normalized = normalize_difficulty(&difficulty);
|
|
1058
|
+
self.state.settings.difficulty = normalized.clone();
|
|
1059
|
+
if normalized != "auto" {
|
|
1060
|
+
self.state.suggested_next_difficulty = normalized;
|
|
1061
|
+
}
|
|
1062
|
+
save_state(&self.root, &self.state)?;
|
|
1063
|
+
self.show_profile();
|
|
1064
|
+
Ok(())
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
fn set_topics(&mut self, topics: &str, avoid: bool) -> Result<()> {
|
|
1068
|
+
let topics = parse_topic_list(topics);
|
|
1069
|
+
if avoid {
|
|
1070
|
+
self.state.settings.avoid_topics = topics;
|
|
1071
|
+
} else {
|
|
1072
|
+
self.state.settings.topics = topics;
|
|
1073
|
+
}
|
|
1074
|
+
save_state(&self.root, &self.state)?;
|
|
1075
|
+
self.show_profile();
|
|
1076
|
+
Ok(())
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
fn reset_profile(&mut self) -> Result<()> {
|
|
1080
|
+
self.state.settings.difficulty = "auto".to_string();
|
|
1081
|
+
self.state.settings.topics.clear();
|
|
1082
|
+
self.state.settings.avoid_topics.clear();
|
|
1083
|
+
save_state(&self.root, &self.state)?;
|
|
1084
|
+
self.show_profile();
|
|
1085
|
+
Ok(())
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
fn show_profile(&mut self) {
|
|
1089
|
+
self.write_text_output(&self.profile_text());
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
fn profile_text(&self) -> String {
|
|
1093
|
+
let settings = &self.state.settings;
|
|
1094
|
+
let topics = list_or_none(&settings.topics);
|
|
1095
|
+
let avoid = list_or_none(&settings.avoid_topics);
|
|
1096
|
+
format!(
|
|
1097
|
+
"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",
|
|
1098
|
+
settings.ui_language,
|
|
1099
|
+
settings.language,
|
|
1100
|
+
settings.theme,
|
|
1101
|
+
settings.difficulty,
|
|
1102
|
+
topics,
|
|
1103
|
+
avoid,
|
|
1104
|
+
settings.ai_provider,
|
|
1105
|
+
if settings.ai_model == "auto" {
|
|
1106
|
+
"auto (provider default)"
|
|
1107
|
+
} else {
|
|
1108
|
+
settings.ai_model.as_str()
|
|
1109
|
+
}
|
|
1110
|
+
)
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1228
1113
|
fn start_ai_prompt(&mut self, prompt: &str) -> Result<()> {
|
|
1229
1114
|
if self.task_rx.is_some() {
|
|
1230
1115
|
self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy"));
|
|
@@ -1256,9 +1141,11 @@ impl PracticodeApp {
|
|
|
1256
1141
|
TaskResult::Next {
|
|
1257
1142
|
output,
|
|
1258
1143
|
old_problem,
|
|
1259
|
-
|
|
1144
|
+
fallback_to_local,
|
|
1260
1145
|
} => {
|
|
1261
|
-
if let Err(error) =
|
|
1146
|
+
if let Err(error) =
|
|
1147
|
+
self.finish_next_problem(output, old_problem, fallback_to_local)
|
|
1148
|
+
{
|
|
1262
1149
|
self.write_text_output(&format!("Next failed\n{error}"));
|
|
1263
1150
|
}
|
|
1264
1151
|
}
|
|
@@ -1271,8 +1158,10 @@ impl PracticodeApp {
|
|
|
1271
1158
|
if let Some(result) = result {
|
|
1272
1159
|
self.update_rx = None;
|
|
1273
1160
|
self.update_check = Some(result.clone());
|
|
1274
|
-
|
|
1275
|
-
self.update_notice = Some(version.clone())
|
|
1161
|
+
match &result {
|
|
1162
|
+
UpdateCheck::Available(version) => self.update_notice = Some(version.clone()),
|
|
1163
|
+
UpdateCheck::Current | UpdateCheck::Disabled => self.update_notice = None,
|
|
1164
|
+
UpdateCheck::Failed => {}
|
|
1276
1165
|
}
|
|
1277
1166
|
}
|
|
1278
1167
|
}
|
|
@@ -1281,6 +1170,7 @@ impl PracticodeApp {
|
|
|
1281
1170
|
if self.update_rx.is_some() {
|
|
1282
1171
|
return;
|
|
1283
1172
|
}
|
|
1173
|
+
self.last_update_check = Some(Instant::now());
|
|
1284
1174
|
let (tx, rx) = mpsc::channel();
|
|
1285
1175
|
thread::spawn(move || {
|
|
1286
1176
|
let _ = tx.send(check_latest_version());
|
|
@@ -1288,6 +1178,18 @@ impl PracticodeApp {
|
|
|
1288
1178
|
self.update_rx = Some(rx);
|
|
1289
1179
|
}
|
|
1290
1180
|
|
|
1181
|
+
fn maybe_start_periodic_update_check(&mut self) {
|
|
1182
|
+
if self.update_rx.is_some() {
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
if self
|
|
1186
|
+
.last_update_check
|
|
1187
|
+
.is_none_or(|last| last.elapsed() >= UPDATE_CHECK_INTERVAL)
|
|
1188
|
+
{
|
|
1189
|
+
self.start_update_check();
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1291
1193
|
fn start_model_check(&mut self) {
|
|
1292
1194
|
let provider = self.state.settings.ai_provider.clone();
|
|
1293
1195
|
if self.model_rx.is_some() || self.available_models_provider == provider {
|
|
@@ -1387,6 +1289,13 @@ impl PracticodeApp {
|
|
|
1387
1289
|
self.focus = Focus::Output;
|
|
1388
1290
|
}
|
|
1389
1291
|
|
|
1292
|
+
fn refresh_update_notice(&mut self) {
|
|
1293
|
+
self.update_check = None;
|
|
1294
|
+
self.update_notice = None;
|
|
1295
|
+
self.start_update_check();
|
|
1296
|
+
self.show_update_notice();
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1390
1299
|
fn show_update_notice(&mut self) {
|
|
1391
1300
|
let lang = self.state.settings.ui_language.clone();
|
|
1392
1301
|
if let Some(version) = &self.update_notice {
|
|
@@ -1414,7 +1323,7 @@ impl PracticodeApp {
|
|
|
1414
1323
|
fn show_notes(&mut self) -> Result<()> {
|
|
1415
1324
|
let notes = read_problem_notes(&self.root)?;
|
|
1416
1325
|
if notes.is_empty() {
|
|
1417
|
-
self.write_text_output("No notes yet.
|
|
1326
|
+
self.write_text_output("No notes yet. Use /topics or /avoid for standing preferences.");
|
|
1418
1327
|
} else {
|
|
1419
1328
|
self.write_text_output(&format!("Problem notes ({PROBLEM_NOTES_PATH})\n\n{notes}"));
|
|
1420
1329
|
}
|
|
@@ -1644,7 +1553,7 @@ impl PracticodeApp {
|
|
|
1644
1553
|
fn open_problem(&mut self, query: &str) -> Result<()> {
|
|
1645
1554
|
self.list_cursor = None;
|
|
1646
1555
|
let Some(problem) = self.find_problem(query).cloned() else {
|
|
1647
|
-
self.write_text_output(&format!("Problem not found: {query}\nTry /
|
|
1556
|
+
self.write_text_output(&format!("Problem not found: {query}\nTry /problems."));
|
|
1648
1557
|
return Ok(());
|
|
1649
1558
|
};
|
|
1650
1559
|
self.problem = problem;
|
|
@@ -1745,15 +1654,11 @@ impl PracticodeApp {
|
|
|
1745
1654
|
}
|
|
1746
1655
|
|
|
1747
1656
|
fn next_source_help(&self) -> String {
|
|
1748
|
-
|
|
1749
|
-
"Next behavior: plain /next asks AI every time. Use /source local to use local problems first.".to_string()
|
|
1750
|
-
} else {
|
|
1751
|
-
"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()
|
|
1752
|
-
}
|
|
1657
|
+
"Next behavior: /next opens unsolved local problems first and asks AI only when none remain. Use /generate <request> to create a problem immediately.".to_string()
|
|
1753
1658
|
}
|
|
1754
1659
|
|
|
1755
1660
|
fn busy_dots(&self) -> String {
|
|
1756
|
-
".".repeat(self.busy_frame / 4)
|
|
1661
|
+
".".repeat((self.busy_frame / 8) % 4)
|
|
1757
1662
|
}
|
|
1758
1663
|
|
|
1759
1664
|
fn mode_hint(&self) -> &'static str {
|
|
@@ -1787,6 +1692,14 @@ impl PracticodeApp {
|
|
|
1787
1692
|
}
|
|
1788
1693
|
}
|
|
1789
1694
|
|
|
1695
|
+
fn list_or_none(values: &[String]) -> String {
|
|
1696
|
+
if values.is_empty() {
|
|
1697
|
+
"(none)".to_string()
|
|
1698
|
+
} else {
|
|
1699
|
+
values.join(", ")
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1790
1703
|
#[derive(Clone, Debug)]
|
|
1791
1704
|
pub struct TextEditor {
|
|
1792
1705
|
lines: Vec<String>,
|