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/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/README.md +15 -8
- package/assets/i18n/en.json +9 -2
- package/assets/i18n/es.json +9 -2
- package/assets/i18n/ja.json +9 -2
- package/assets/i18n/ko.json +9 -2
- package/assets/i18n/zh.json +9 -2
- 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 +45 -21
- package/src/lib.rs +11 -3
- package/src/tui/commands.rs +242 -0
- package/src/tui.rs +251 -261
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,
|
|
@@ -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::{
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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) %
|
|
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 =
|
|
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
|
|
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(&
|
|
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(&
|
|
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.
|
|
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
|
-
|
|
883
|
+
ui_text(&self.state.settings.ui_language, "run_pass_next")
|
|
986
884
|
} else {
|
|
987
|
-
|
|
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
|
-
|
|
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.
|
|
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 /
|
|
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
|
|
1640
|
+
fn next_source_help(&self) -> String {
|
|
1659
1641
|
if self.state.settings.next_source == "ai" {
|
|
1660
|
-
"
|
|
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>,
|