practicode 0.1.9 → 0.1.10

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.
@@ -0,0 +1,279 @@
1
+ use super::*;
2
+
3
+ impl PracticodeApp {
4
+ pub(super) fn start_ai_prompt(&mut self, prompt: &str) -> Result<()> {
5
+ if self.task_rx.is_some() {
6
+ self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy"));
7
+ return Ok(());
8
+ }
9
+ self.save_code()?;
10
+ let label = normalize_ai_provider(&self.state.settings.ai_provider);
11
+ self.start_busy("ai", &format!("{label} is thinking"));
12
+ let root = self.root.clone();
13
+ let problem = self.problem.clone();
14
+ let settings = self.state.settings.clone();
15
+ let prompt = prompt.to_string();
16
+ let (tx, rx) = mpsc::channel();
17
+ thread::spawn(move || {
18
+ let output = run_ai_prompt(&root, &problem, &settings, &prompt);
19
+ let _ = tx.send(TaskResult::AiPrompt(output));
20
+ });
21
+ self.task_rx = Some(rx);
22
+ Ok(())
23
+ }
24
+
25
+ pub(super) fn check_task(&mut self) {
26
+ let task = self.task_rx.as_ref().and_then(|rx| rx.try_recv().ok());
27
+ if let Some(task) = task {
28
+ self.task_rx = None;
29
+ self.stop_busy();
30
+ match task {
31
+ TaskResult::AiPrompt(output) => self.write_output(&output),
32
+ TaskResult::Next {
33
+ output,
34
+ old_problem,
35
+ fallback_to_local,
36
+ } => {
37
+ if let Err(error) =
38
+ self.finish_next_problem(output, old_problem, fallback_to_local)
39
+ {
40
+ self.write_text_output(&format!("Next failed\n{error}"));
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }
46
+
47
+ pub(super) fn check_background_generation(&mut self) {
48
+ let output = self.generate_rx.as_ref().and_then(|rx| rx.try_recv().ok());
49
+ let Some(output) = output else {
50
+ return;
51
+ };
52
+ self.generate_rx = None;
53
+ self.generate_started = None;
54
+ let old_len = self.generate_bank_len;
55
+ match load_bank(&self.root) {
56
+ Ok(bank) => {
57
+ let added = bank.len().saturating_sub(old_len);
58
+ self.bank = bank;
59
+ let _ = save_state(&self.root, &self.state);
60
+ self.generate_notice = Some(if added > 0 {
61
+ format!("Generated {added} problem in background. Use /next.")
62
+ } else if output.contains("failed") {
63
+ "Background generation failed. Use /generate to retry.".to_string()
64
+ } else {
65
+ "Background generation finished. Use /problems to review.".to_string()
66
+ });
67
+ }
68
+ Err(error) => {
69
+ self.generate_notice = Some(format!(
70
+ "Background generation finished, but bank reload failed: {error}"
71
+ ));
72
+ }
73
+ }
74
+ }
75
+
76
+ pub(super) fn check_update(&mut self) {
77
+ let result = self.update_rx.as_ref().and_then(|rx| rx.try_recv().ok());
78
+ if let Some(result) = result {
79
+ self.update_rx = None;
80
+ self.update_check = Some(result.clone());
81
+ match &result {
82
+ UpdateCheck::Available(version) => self.update_notice = Some(version.clone()),
83
+ UpdateCheck::Current | UpdateCheck::Disabled => self.update_notice = None,
84
+ UpdateCheck::Failed => {}
85
+ }
86
+ }
87
+ }
88
+
89
+ pub(super) fn start_update_check(&mut self) {
90
+ if self.update_rx.is_some() {
91
+ return;
92
+ }
93
+ self.last_update_check = Some(Instant::now());
94
+ let (tx, rx) = mpsc::channel();
95
+ thread::spawn(move || {
96
+ let _ = tx.send(check_latest_version());
97
+ });
98
+ self.update_rx = Some(rx);
99
+ }
100
+
101
+ pub(super) fn maybe_start_periodic_update_check(&mut self) {
102
+ if self.update_rx.is_some() {
103
+ return;
104
+ }
105
+ if self
106
+ .last_update_check
107
+ .is_none_or(|last| last.elapsed() >= UPDATE_CHECK_INTERVAL)
108
+ {
109
+ self.start_update_check();
110
+ }
111
+ }
112
+
113
+ pub(super) fn start_model_check(&mut self) {
114
+ let provider = self.state.settings.ai_provider.clone();
115
+ if self.model_rx.is_some() || self.available_models_provider == provider {
116
+ return;
117
+ }
118
+ let query_provider = provider.clone();
119
+ let (tx, rx) = mpsc::channel();
120
+ thread::spawn(move || {
121
+ let _ = tx.send(available_models(&query_provider));
122
+ });
123
+ self.available_models_provider = provider;
124
+ self.model_rx = Some(rx);
125
+ }
126
+
127
+ pub(super) fn check_models(&mut self) {
128
+ let models = self.model_rx.as_ref().and_then(|rx| rx.try_recv().ok());
129
+ if let Some(catalog) = models {
130
+ self.model_rx = None;
131
+ self.available_models = catalog.models;
132
+ self.model_message = catalog.message;
133
+ if self.showing_model_status {
134
+ self.output = self.model_status_text();
135
+ self.output_is_markdown = false;
136
+ self.show_output = true;
137
+ }
138
+ }
139
+ }
140
+
141
+ pub(super) fn model_status_text(&self) -> String {
142
+ let mut lines = vec![
143
+ format!("AI provider: {}", self.state.settings.ai_provider),
144
+ format!(
145
+ "AI model: {}",
146
+ if self.state.settings.ai_model == "auto" {
147
+ "auto (provider default)"
148
+ } else {
149
+ self.state.settings.ai_model.as_str()
150
+ }
151
+ ),
152
+ "Use /model auto to let the provider choose its default.".to_string(),
153
+ ];
154
+ if self.model_rx.is_some() {
155
+ lines.push("Loading provider model list...".to_string());
156
+ } else if self.available_models.is_empty() {
157
+ lines.push(
158
+ self.model_message
159
+ .clone()
160
+ .unwrap_or_else(|| "Provider model list is unavailable.".to_string()),
161
+ );
162
+ lines.push("Use /model <name> for a known model.".to_string());
163
+ } else {
164
+ lines.push("Available models:".to_string());
165
+ lines.extend(
166
+ self.available_models
167
+ .iter()
168
+ .map(|model| format!("- /model {model}")),
169
+ );
170
+ }
171
+ lines.join("\n")
172
+ }
173
+
174
+ pub(super) fn start_busy(&mut self, label: &str, body: &str) {
175
+ self.settings_cursor = None;
176
+ self.busy_label = label.to_string();
177
+ self.busy_body = body.to_string();
178
+ self.busy_started = Some(Instant::now());
179
+ self.busy_frame = 0;
180
+ self.busy_hits = 0;
181
+ self.busy_misses = 0;
182
+ self.show_output = true;
183
+ self.focus = Focus::Output;
184
+ }
185
+
186
+ pub(super) fn stop_busy(&mut self) {
187
+ self.busy_label.clear();
188
+ self.busy_body.clear();
189
+ self.busy_started = None;
190
+ self.busy_frame = 0;
191
+ }
192
+
193
+ pub(super) fn handle_busy_key(&mut self, key: KeyEvent) -> bool {
194
+ if self.task_rx.is_none() {
195
+ return false;
196
+ }
197
+ if key.code == KeyCode::Char('q') && key.modifiers.is_empty() {
198
+ self.should_quit = true;
199
+ } else if self.busy_label == "next"
200
+ && key.code == KeyCode::Char(' ')
201
+ && key.modifiers.is_empty()
202
+ {
203
+ if self.busy_game_on_target() {
204
+ self.busy_hits += 1;
205
+ } else {
206
+ self.busy_misses += 1;
207
+ }
208
+ }
209
+ self.focus = Focus::Output;
210
+ true
211
+ }
212
+
213
+ pub(super) fn write_output(&mut self, output: &str) {
214
+ self.settings_cursor = None;
215
+ self.showing_model_status = false;
216
+ self.output = output.to_string();
217
+ self.output_is_markdown = true;
218
+ self.show_output = true;
219
+ self.focus = Focus::Output;
220
+ }
221
+
222
+ pub(super) fn write_text_output(&mut self, output: &str) {
223
+ self.settings_cursor = None;
224
+ self.showing_model_status = false;
225
+ self.output = output.trim_end().to_string();
226
+ self.output_is_markdown = false;
227
+ self.show_output = true;
228
+ self.focus = Focus::Output;
229
+ }
230
+
231
+ pub(super) fn write_model_status(&mut self) {
232
+ self.output = self.model_status_text();
233
+ self.output_is_markdown = false;
234
+ self.showing_model_status = true;
235
+ self.show_output = true;
236
+ self.focus = Focus::Output;
237
+ }
238
+
239
+ pub(super) fn refresh_update_notice(&mut self) {
240
+ self.update_check = None;
241
+ self.update_notice = None;
242
+ self.start_update_check();
243
+ self.show_update_notice();
244
+ }
245
+
246
+ pub(super) fn show_update_notice(&mut self) {
247
+ let lang = self.state.settings.ui_language.clone();
248
+ if let Some(version) = &self.update_notice {
249
+ self.write_text_output(&format!(
250
+ "{}: practicode {version} (current {CURRENT_VERSION})\n\nnpm update -g practicode\ncargo install --force practicode",
251
+ ui_text(&lang, "update_available")
252
+ ));
253
+ } else if self.update_rx.is_some() {
254
+ self.write_text_output("Checking for updates...");
255
+ } else if matches!(self.update_check, Some(UpdateCheck::Disabled)) {
256
+ self.write_text_output(ui_text(&lang, "update_check_disabled"));
257
+ } else if matches!(self.update_check, Some(UpdateCheck::Failed)) {
258
+ self.write_text_output(ui_text(&lang, "update_check_failed"));
259
+ } else {
260
+ self.write_text_output(ui_text(&lang, "update_none"));
261
+ }
262
+ }
263
+
264
+ pub(super) fn append_note(&mut self, note: &str) -> Result<()> {
265
+ append_problem_note(&self.root, note)?;
266
+ self.write_text_output(&format!("Problem note saved to {PROBLEM_NOTES_PATH}."));
267
+ Ok(())
268
+ }
269
+
270
+ pub(super) fn show_notes(&mut self) -> Result<()> {
271
+ let notes = read_problem_notes(&self.root)?;
272
+ if notes.is_empty() {
273
+ self.write_text_output("No notes yet. Use /topics or /avoid for standing preferences.");
274
+ } else {
275
+ self.write_text_output(&format!("Problem notes ({PROBLEM_NOTES_PATH})\n\n{notes}"));
276
+ }
277
+ Ok(())
278
+ }
279
+ }
@@ -0,0 +1,342 @@
1
+ use super::*;
2
+
3
+ impl PracticodeApp {
4
+ pub(super) fn draw(&mut self, frame: &mut Frame) {
5
+ let size = frame.area();
6
+ let vertical = Layout::default()
7
+ .direction(Direction::Vertical)
8
+ .constraints([
9
+ Constraint::Min(1),
10
+ Constraint::Length(1),
11
+ Constraint::Length(3),
12
+ ])
13
+ .split(size);
14
+ let body = Layout::default()
15
+ .direction(Direction::Horizontal)
16
+ .constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
17
+ .split(vertical[0]);
18
+ self.code_area = body[1];
19
+ self.output_area = body[1];
20
+ self.command_area = vertical[2];
21
+
22
+ let light = self.state.settings.theme == "light";
23
+ let problem = Paragraph::new(problem_view::render(
24
+ &self.problem,
25
+ &self.state.settings.ui_language,
26
+ light,
27
+ ))
28
+ .style(Self::pane_style(light))
29
+ .block(Self::block(
30
+ ui_text(&self.state.settings.ui_language, "problem"),
31
+ light,
32
+ false,
33
+ ))
34
+ .wrap(Wrap { trim: false });
35
+ frame.render_widget(problem, body[0]);
36
+
37
+ if self.show_output {
38
+ let text = self.output_text();
39
+ let output = Paragraph::new(text)
40
+ .style(Self::pane_style(light))
41
+ .block(Self::block(
42
+ ui_text(&self.state.settings.ui_language, "output"),
43
+ light,
44
+ self.focus != Focus::Command,
45
+ ))
46
+ .wrap(Wrap { trim: false });
47
+ frame.render_widget(output, body[1]);
48
+ } else {
49
+ let code = self
50
+ .editor
51
+ .visible_text(body[1].height.saturating_sub(2) as usize);
52
+ let title = format!("solution.{}", ext_for(&self.state.settings.language));
53
+ let code = Paragraph::new(code)
54
+ .style(Self::pane_style(light))
55
+ .block(Self::block(&title, light, self.focus == Focus::Code));
56
+ frame.render_widget(code, body[1]);
57
+ }
58
+
59
+ let status = Paragraph::new(self.status_text()).style(if light {
60
+ Style::default()
61
+ .fg(Color::Blue)
62
+ .bg(Color::Rgb(219, 234, 254))
63
+ .add_modifier(Modifier::BOLD)
64
+ } else {
65
+ Style::default()
66
+ .fg(Color::Rgb(200, 211, 245))
67
+ .bg(Color::Rgb(21, 32, 51))
68
+ .add_modifier(Modifier::BOLD)
69
+ });
70
+ frame.render_widget(status, vertical[1]);
71
+
72
+ let command_text = if self.focus == Focus::Command || !self.command.is_empty() {
73
+ self.command.clone()
74
+ } else {
75
+ ui_text(&self.state.settings.ui_language, "command_placeholder").to_string()
76
+ };
77
+ let command = Paragraph::new(command_text)
78
+ .style(Self::pane_style(light))
79
+ .block(Self::block(
80
+ ui_text(&self.state.settings.ui_language, "command"),
81
+ light,
82
+ self.focus == Focus::Command,
83
+ ))
84
+ .wrap(Wrap { trim: false });
85
+ frame.render_widget(command, vertical[2]);
86
+ self.draw_command_palette(frame, vertical[2]);
87
+ self.set_terminal_cursor(frame, body[1], vertical[2]);
88
+ }
89
+
90
+ pub(super) fn wants_mouse_capture(&self) -> bool {
91
+ !self.show_output
92
+ }
93
+
94
+ pub(super) fn sync_mouse_capture(&mut self) {
95
+ let want = self.wants_mouse_capture();
96
+ if want == self.mouse_capture {
97
+ return;
98
+ }
99
+ let result = if want {
100
+ execute!(stdout(), EnableMouseCapture)
101
+ } else {
102
+ execute!(stdout(), DisableMouseCapture)
103
+ };
104
+ if result.is_ok() {
105
+ self.mouse_capture = want;
106
+ }
107
+ }
108
+
109
+ pub(super) fn disable_mouse_capture(&mut self) {
110
+ if self.mouse_capture {
111
+ let _ = execute!(stdout(), DisableMouseCapture);
112
+ self.mouse_capture = false;
113
+ }
114
+ }
115
+
116
+ pub(super) fn output_text(&self) -> Text<'static> {
117
+ let light = self.state.settings.theme == "light";
118
+ let title_style = if light {
119
+ Style::default()
120
+ .fg(Color::Blue)
121
+ .add_modifier(Modifier::BOLD)
122
+ } else {
123
+ Style::default()
124
+ .fg(Color::Yellow)
125
+ .add_modifier(Modifier::BOLD)
126
+ };
127
+ let label_style = if light {
128
+ Style::default()
129
+ .fg(Color::Magenta)
130
+ .add_modifier(Modifier::BOLD)
131
+ } else {
132
+ Style::default()
133
+ .fg(Color::Cyan)
134
+ .add_modifier(Modifier::BOLD)
135
+ };
136
+ let body_style = if light {
137
+ Style::default().fg(Color::Black)
138
+ } else {
139
+ Style::default().fg(Color::Rgb(229, 231, 235))
140
+ };
141
+ let code_style = if light {
142
+ Style::default()
143
+ .fg(Color::Black)
144
+ .bg(Color::Rgb(229, 231, 235))
145
+ } else {
146
+ Style::default()
147
+ .fg(Color::Rgb(243, 244, 246))
148
+ .bg(Color::Rgb(31, 41, 55))
149
+ };
150
+ if !self.busy_label.is_empty() {
151
+ let elapsed = self
152
+ .busy_started
153
+ .map(|started| started.elapsed().as_secs())
154
+ .unwrap_or_default();
155
+ let mut lines = vec![Line::from(Span::styled(
156
+ format!("{}{} {}s", self.busy_body, self.busy_dots(), elapsed),
157
+ title_style,
158
+ ))];
159
+ if self.busy_label == "next" {
160
+ lines.extend([
161
+ Line::default(),
162
+ Line::from(Span::styled(self.busy_game_track(), code_style)),
163
+ Line::from(Span::styled(
164
+ ui_text(&self.state.settings.ui_language, "busy_warmup").to_string(),
165
+ body_style,
166
+ )),
167
+ Line::from(Span::styled(
168
+ format!(
169
+ "{}: {} {}: {}",
170
+ ui_text(&self.state.settings.ui_language, "hits"),
171
+ self.busy_hits,
172
+ ui_text(&self.state.settings.ui_language, "misses"),
173
+ self.busy_misses
174
+ ),
175
+ label_style,
176
+ )),
177
+ Line::from(Span::styled(
178
+ ui_text(&self.state.settings.ui_language, "busy_commands_paused")
179
+ .to_string(),
180
+ body_style,
181
+ )),
182
+ ]);
183
+ }
184
+ return Text::from(lines);
185
+ }
186
+ let output = if self.output_is_markdown {
187
+ render_markdown_plain(&self.output)
188
+ } else {
189
+ self.output.clone()
190
+ };
191
+ let mut lines = Vec::new();
192
+ for line in output.lines() {
193
+ if line.is_empty() {
194
+ lines.push(Line::default());
195
+ } else if line.starts_with("PASS ")
196
+ || line.starts_with("FAIL ")
197
+ || line.starts_with("Case ")
198
+ || line.starts_with("Next:")
199
+ || line.starts_with("Fix:")
200
+ {
201
+ lines.push(Line::from(Span::styled(line.to_string(), title_style)));
202
+ } else if matches!(
203
+ line,
204
+ "Input" | "Expected" | "Got" | "Stdout" | "Stderr" | "Compile" | "Error"
205
+ ) {
206
+ lines.push(Line::from(Span::styled(line.to_string(), label_style)));
207
+ } else if line.starts_with(" ") {
208
+ lines.push(Line::from(vec![
209
+ Span::raw(" "),
210
+ Span::styled(line.trim_start().to_string(), code_style),
211
+ ]));
212
+ } else {
213
+ lines.push(Line::from(Span::styled(line.to_string(), body_style)));
214
+ }
215
+ }
216
+ Text::from(lines)
217
+ }
218
+
219
+ pub(super) fn draw_command_palette(&self, frame: &mut Frame, command_area: Rect) {
220
+ let suggestions = self.command_suggestions();
221
+ if suggestions.is_empty() || command_area.y < 3 {
222
+ return;
223
+ }
224
+ let height = ((suggestions.len() + 3) as u16).min(14).min(command_area.y);
225
+ let area = Rect::new(
226
+ command_area.x,
227
+ command_area.y - height,
228
+ command_area.width,
229
+ height,
230
+ );
231
+ let selected = self.command_palette_cursor.min(suggestions.len() - 1);
232
+ let visible = height.saturating_sub(2) as usize;
233
+ let start = selected.saturating_sub(visible.saturating_sub(1));
234
+ let mut lines = suggestions
235
+ .iter()
236
+ .enumerate()
237
+ .skip(start)
238
+ .take(visible)
239
+ .map(|(index, hint)| {
240
+ let marker = if index == selected { ">" } else { " " };
241
+ format!(
242
+ "{marker} {:<16} {}",
243
+ hint.display,
244
+ ui_text(&self.state.settings.ui_language, hint.desc_key)
245
+ )
246
+ })
247
+ .collect::<Vec<_>>();
248
+ lines.push(ui_text(&self.state.settings.ui_language, "palette_hint").to_string());
249
+ frame.render_widget(Clear, area);
250
+ let light = self.state.settings.theme == "light";
251
+ frame.render_widget(
252
+ Paragraph::new(lines.join("\n"))
253
+ .style(Self::pane_style(light))
254
+ .block(Self::block(
255
+ ui_text(&self.state.settings.ui_language, "commands"),
256
+ light,
257
+ true,
258
+ )),
259
+ area,
260
+ );
261
+ }
262
+
263
+ pub(super) fn block(title: &str, light: bool, active: bool) -> Block<'static> {
264
+ let border = if active {
265
+ if light {
266
+ Style::default()
267
+ .fg(Color::Magenta)
268
+ .add_modifier(Modifier::BOLD)
269
+ } else {
270
+ Style::default()
271
+ .fg(Color::Yellow)
272
+ .add_modifier(Modifier::BOLD)
273
+ }
274
+ } else if light {
275
+ Style::default().fg(Color::Blue)
276
+ } else {
277
+ Style::default().fg(Color::Cyan)
278
+ };
279
+ let border = border.bg(Self::pane_bg(light));
280
+ Block::default()
281
+ .borders(Borders::ALL)
282
+ .title(Self::pane_title(title, active))
283
+ .style(Self::pane_style(light))
284
+ .border_style(border)
285
+ }
286
+
287
+ pub(super) fn pane_style(light: bool) -> Style {
288
+ if light {
289
+ Style::default()
290
+ .fg(Color::Rgb(17, 24, 39))
291
+ .bg(Self::pane_bg(light))
292
+ } else {
293
+ Style::default()
294
+ .fg(Color::Rgb(229, 231, 235))
295
+ .bg(Self::pane_bg(light))
296
+ }
297
+ }
298
+
299
+ pub(super) fn pane_bg(light: bool) -> Color {
300
+ if light {
301
+ Color::Rgb(248, 250, 252)
302
+ } else {
303
+ Color::Rgb(17, 24, 39)
304
+ }
305
+ }
306
+
307
+ pub fn pane_style_for_test(light: bool) -> Style {
308
+ Self::pane_style(light)
309
+ }
310
+
311
+ pub(super) fn pane_title(title: &str, active: bool) -> String {
312
+ if active {
313
+ format!("> {title}")
314
+ } else {
315
+ title.to_string()
316
+ }
317
+ }
318
+ pub(super) fn set_terminal_cursor(
319
+ &self,
320
+ frame: &mut Frame,
321
+ code_area: Rect,
322
+ command_area: Rect,
323
+ ) {
324
+ match self.focus {
325
+ Focus::Command => {
326
+ let before = prefix(&self.command, self.command_cursor);
327
+ let x = command_area
328
+ .x
329
+ .saturating_add(1)
330
+ .saturating_add(display_width(&before) as u16)
331
+ .min(command_area.right().saturating_sub(2));
332
+ frame.set_cursor_position(Position::new(x, command_area.y.saturating_add(1)));
333
+ }
334
+ Focus::Code if !self.show_output => {
335
+ if let Some(position) = self.editor.cursor_position(code_area) {
336
+ frame.set_cursor_position(position);
337
+ }
338
+ }
339
+ _ => {}
340
+ }
341
+ }
342
+ }