practicode 0.1.3 → 0.1.5

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 CHANGED
@@ -357,7 +357,7 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
357
357
 
358
358
  [[package]]
359
359
  name = "practicode"
360
- version = "0.1.3"
360
+ version = "0.1.5"
361
361
  dependencies = [
362
362
  "anyhow",
363
363
  "crossterm 0.29.0",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "practicode"
3
- version = "0.1.3"
3
+ version = "0.1.5"
4
4
  edition = "2024"
5
5
  description = "Local-first coding-test practice in a Rust terminal UI with optional AI help."
6
6
  readme = "README.md"
package/README.md CHANGED
@@ -63,6 +63,7 @@ Submissions are saved as you type under `submissions/<problem-id>/solution.<ext>
63
63
  | Command | Action |
64
64
  | --- | --- |
65
65
  | `/run` | Judge the current submission |
66
+ | `/code` | Return to the code editor |
66
67
  | `/next` | Open the next local problem, or ask AI to create one |
67
68
  | `/next easy string problem` | Ask AI for a custom next problem |
68
69
  | `/prev` | Go back through problem history |
@@ -78,7 +79,6 @@ Submissions are saved as you type under `submissions/<problem-id>/solution.<ext>
78
79
  | `/lang python` | Set code language: `python`, `ts`, `java`, `rust` |
79
80
  | `/ui en` | Set UI language: `en`, `ko`, `ja`, `zh`, `es` |
80
81
  | `/theme dark` | Set theme: `dark` or `light` |
81
- | `/source ai` | Prefer AI for next-problem generation |
82
82
  | `/update` | Show update instructions when a newer version is available |
83
83
  | `/exit` | Quit |
84
84
 
@@ -106,7 +106,6 @@ Claude Code is also supported:
106
106
  ```text
107
107
  /provider claude
108
108
  /model sonnet
109
- /source ai
110
109
  ```
111
110
 
112
111
  Generated problems and submissions stay local:
@@ -15,7 +15,7 @@
15
15
  "palette_hint": "up/down select | Enter run | Esc cancel",
16
16
  "hint_command": "Enter submit | Esc cancel",
17
17
  "hint_list": "up/down move | Enter open | Esc close",
18
- "hint_output": "Esc code | / command | ? help",
18
+ "hint_output": "Esc/click edit | / command | ? help",
19
19
  "hint_code": "Esc then / command",
20
20
  "hint_idle": "/ command | ? help",
21
21
  "help_title": "Help",
@@ -23,6 +23,7 @@
23
23
  "keys": "Keys",
24
24
  "debug_prints": "Debug prints",
25
25
  "cmd_run": "Judge the current submission",
26
+ "cmd_code": "Return to the code editor",
26
27
  "cmd_edit": "Return to the code editor",
27
28
  "cmd_next": "Open the next problem",
28
29
  "cmd_prev": "Open the previous problem",
@@ -50,5 +51,7 @@
50
51
  "update_check_disabled": "Update check is disabled.",
51
52
  "update_check_failed": "Could not check for updates.",
52
53
  "generating_next": "Generating next problem",
53
- "already_busy": "Already busy."
54
+ "already_busy": "Already busy.",
55
+ "run_pass_next": "Next: /next",
56
+ "run_fail_next": "Fix: press Esc or click this pane to edit, then /run"
54
57
  }
@@ -15,7 +15,7 @@
15
15
  "palette_hint": "arriba/abajo elegir | Enter ejecutar | Esc cancelar",
16
16
  "hint_command": "Enter ejecutar | Esc cancelar",
17
17
  "hint_list": "arriba/abajo mover | Enter abrir | Esc cerrar",
18
- "hint_output": "Esc codigo | / comando | ? ayuda",
18
+ "hint_output": "Esc/clic editar | / comando | ? ayuda",
19
19
  "hint_code": "Esc y luego / comando",
20
20
  "hint_idle": "/ comando | ? ayuda",
21
21
  "help_title": "Ayuda",
@@ -23,6 +23,7 @@
23
23
  "keys": "Teclas",
24
24
  "debug_prints": "Salida de depuracion",
25
25
  "cmd_run": "Evalua la solucion actual",
26
+ "cmd_code": "Volver al editor de codigo",
26
27
  "cmd_edit": "Volver al editor de codigo",
27
28
  "cmd_next": "Abrir el siguiente problema",
28
29
  "cmd_prev": "Abrir el problema anterior",
@@ -50,5 +51,7 @@
50
51
  "update_check_disabled": "La comprobacion de actualizaciones esta desactivada.",
51
52
  "update_check_failed": "No se pudo comprobar actualizaciones.",
52
53
  "generating_next": "Generando el siguiente problema",
53
- "already_busy": "Ya hay una tarea en curso."
54
+ "already_busy": "Ya hay una tarea en curso.",
55
+ "run_pass_next": "Siguiente: /next",
56
+ "run_fail_next": "Corrige: pulsa Esc o haz clic en este panel para editar, luego /run"
54
57
  }
@@ -15,7 +15,7 @@
15
15
  "palette_hint": "上下 選択 | Enter 実行 | Esc キャンセル",
16
16
  "hint_command": "Enter 実行 | Esc キャンセル",
17
17
  "hint_list": "上下 移動 | Enter 開く | Esc 閉じる",
18
- "hint_output": "Esc コード | / コマンド | ? ヘルプ",
18
+ "hint_output": "Esc/クリック 編集 | / コマンド | ? ヘルプ",
19
19
  "hint_code": "Esc の後 / コマンド",
20
20
  "hint_idle": "/ コマンド | ? ヘルプ",
21
21
  "help_title": "ヘルプ",
@@ -23,6 +23,7 @@
23
23
  "keys": "キー",
24
24
  "debug_prints": "デバッグ出力",
25
25
  "cmd_run": "現在の提出を判定",
26
+ "cmd_code": "コードエディタに戻る",
26
27
  "cmd_edit": "コードエディタに戻る",
27
28
  "cmd_next": "次の問題を開く",
28
29
  "cmd_prev": "前の問題を開く",
@@ -50,5 +51,7 @@
50
51
  "update_check_disabled": "更新確認は無効です。",
51
52
  "update_check_failed": "更新を確認できませんでした。",
52
53
  "generating_next": "次の問題を生成中",
53
- "already_busy": "すでに処理中です。"
54
+ "already_busy": "すでに処理中です。",
55
+ "run_pass_next": "次: /next",
56
+ "run_fail_next": "修正: Esc またはこのペインをクリックして編集し、/run"
54
57
  }
@@ -15,7 +15,7 @@
15
15
  "palette_hint": "위/아래 선택 | Enter 실행 | Esc 취소",
16
16
  "hint_command": "Enter 실행 | Esc 취소",
17
17
  "hint_list": "위/아래 이동 | Enter 열기 | Esc 닫기",
18
- "hint_output": "Esc 코드 | / 명령 | ? 도움말",
18
+ "hint_output": "Esc/클릭 편집 | / 명령 | ? 도움말",
19
19
  "hint_code": "Esc 후 / 명령",
20
20
  "hint_idle": "/ 명령 | ? 도움말",
21
21
  "help_title": "도움말",
@@ -23,6 +23,7 @@
23
23
  "keys": "키",
24
24
  "debug_prints": "디버그 출력",
25
25
  "cmd_run": "현재 제출을 채점",
26
+ "cmd_code": "코드 편집기로 돌아가기",
26
27
  "cmd_edit": "코드 편집기로 돌아가기",
27
28
  "cmd_next": "다음 문제 열기",
28
29
  "cmd_prev": "이전 문제 열기",
@@ -50,5 +51,7 @@
50
51
  "update_check_disabled": "업데이트 확인이 꺼져 있습니다.",
51
52
  "update_check_failed": "업데이트를 확인할 수 없습니다.",
52
53
  "generating_next": "다음 문제 생성 중",
53
- "already_busy": "이미 작업 중입니다."
54
+ "already_busy": "이미 작업 중입니다.",
55
+ "run_pass_next": "다음: /next",
56
+ "run_fail_next": "수정: Esc를 누르거나 이 창을 클릭해 편집한 뒤 /run"
54
57
  }
@@ -15,7 +15,7 @@
15
15
  "palette_hint": "上下 选择 | Enter 执行 | Esc 取消",
16
16
  "hint_command": "Enter 执行 | Esc 取消",
17
17
  "hint_list": "上下 移动 | Enter 打开 | Esc 关闭",
18
- "hint_output": "Esc 代码 | / 命令 | ? 帮助",
18
+ "hint_output": "Esc/点击 编辑 | / 命令 | ? 帮助",
19
19
  "hint_code": "Esc 后输入 / 命令",
20
20
  "hint_idle": "/ 命令 | ? 帮助",
21
21
  "help_title": "帮助",
@@ -23,6 +23,7 @@
23
23
  "keys": "按键",
24
24
  "debug_prints": "调试输出",
25
25
  "cmd_run": "评测当前提交",
26
+ "cmd_code": "回到代码编辑器",
26
27
  "cmd_edit": "回到代码编辑器",
27
28
  "cmd_next": "打开下一题",
28
29
  "cmd_prev": "打开上一题",
@@ -50,5 +51,7 @@
50
51
  "update_check_disabled": "更新检查已禁用。",
51
52
  "update_check_failed": "无法检查更新。",
52
53
  "generating_next": "正在生成下一题",
53
- "already_busy": "已有任务正在运行。"
54
+ "already_busy": "已有任务正在运行。",
55
+ "run_pass_next": "下一步: /next",
56
+ "run_fail_next": "修复: 按 Esc 或点击此面板编辑,然后 /run"
54
57
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "practicode",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Local-first coding-test practice in a Rust terminal UI with optional AI help.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/ai.rs CHANGED
@@ -15,6 +15,12 @@ use std::{
15
15
  time::Duration,
16
16
  };
17
17
 
18
+ #[derive(Clone, Debug, Default)]
19
+ pub struct ModelCatalog {
20
+ pub models: Vec<String>,
21
+ pub message: Option<String>,
22
+ }
23
+
18
24
  pub fn run_ai_prompt(root: &Path, problem: &Problem, settings: &Settings, prompt: &str) -> String {
19
25
  let solution = match ensure_submission(root, problem, settings) {
20
26
  Ok(path) => path,
@@ -102,10 +108,17 @@ pub fn provider_status(provider: &str) -> String {
102
108
  }
103
109
  }
104
110
 
105
- pub fn available_models(provider: &str) -> Vec<String> {
111
+ pub fn available_models(provider: &str) -> ModelCatalog {
106
112
  match normalize_ai_provider(provider).as_str() {
107
113
  "codex" => codex_models(),
108
- _ => Vec::new(),
114
+ "claude" => ModelCatalog {
115
+ models: Vec::new(),
116
+ message: Some(
117
+ "Claude CLI does not expose a model list; use /model <name> for a known model."
118
+ .to_string(),
119
+ ),
120
+ },
121
+ _ => ModelCatalog::default(),
109
122
  }
110
123
  }
111
124
 
@@ -139,20 +152,56 @@ pub fn read_problem_notes(root: &Path) -> Result<String> {
139
152
  }
140
153
  }
141
154
 
142
- fn codex_models() -> Vec<String> {
155
+ fn codex_models() -> ModelCatalog {
143
156
  if which("codex").is_none() {
144
- return Vec::new();
157
+ return ModelCatalog {
158
+ models: Vec::new(),
159
+ message: Some(
160
+ "Codex CLI not found; choose /provider claude or install Codex CLI.".to_string(),
161
+ ),
162
+ };
145
163
  }
164
+ if codex_daemon_path().is_none_or(|path| !path.exists()) {
165
+ return ModelCatalog {
166
+ models: Vec::new(),
167
+ message: Some("Codex app-server daemon is unavailable; install the standalone Codex app to list models, or use /model <name>.".to_string()),
168
+ };
169
+ }
170
+ let mut start = Command::new("codex");
171
+ start.args(["app-server", "daemon", "start"]);
172
+ let _ = run_capture(&mut start, "", Duration::from_secs(5));
146
173
  let mut command = Command::new("codex");
147
174
  command.args(["app-server", "proxy"]);
148
175
  let input = r#"{"id":1,"method":"model/list","params":{"limit":25}}"#;
149
176
  let Ok(run) = run_capture(&mut command, &format!("{input}\n"), Duration::from_secs(2)) else {
150
- return Vec::new();
177
+ return ModelCatalog {
178
+ models: Vec::new(),
179
+ message: Some("Could not query Codex model list.".to_string()),
180
+ };
151
181
  };
152
182
  if run.code != Some(0) {
153
- return Vec::new();
183
+ let detail = output_text(&run.stdout, &run.stderr);
184
+ return ModelCatalog {
185
+ models: Vec::new(),
186
+ message: Some(if detail.is_empty() {
187
+ "Could not query Codex model list.".to_string()
188
+ } else {
189
+ format!("Could not query Codex model list: {detail}")
190
+ }),
191
+ };
192
+ }
193
+ let models = parse_model_list(&run.stdout);
194
+ if models.is_empty() {
195
+ ModelCatalog {
196
+ models,
197
+ message: Some("Codex app-server returned no models.".to_string()),
198
+ }
199
+ } else {
200
+ ModelCatalog {
201
+ models,
202
+ message: None,
203
+ }
154
204
  }
155
- parse_model_list(&run.stdout)
156
205
  }
157
206
 
158
207
  fn parse_model_list(output: &str) -> Vec<String> {
package/src/core.rs CHANGED
@@ -601,8 +601,8 @@ pub fn judge(root: &Path, problem: &Problem, settings: &Settings) -> JudgeResult
601
601
  let run = match run_capture(&mut process, &case.input, Duration::from_secs(5)) {
602
602
  Ok(run) => run,
603
603
  Err(error) => {
604
- lines.push(format!("case {}: FAIL", index + 1));
605
- lines.push(error.to_string());
604
+ lines.push(format!("Case {}: FAIL", index + 1));
605
+ push_labeled_block(&mut lines, "Error", &error.to_string());
606
606
  break;
607
607
  }
608
608
  };
@@ -610,28 +610,20 @@ pub fn judge(root: &Path, problem: &Problem, settings: &Settings) -> JudgeResult
610
610
  let expected = case.output.trim();
611
611
  if !run.timed_out && run.code == Some(0) && got == expected {
612
612
  passed += 1;
613
- lines.push(format!("case {}: PASS", index + 1));
613
+ lines.push(format!("Case {}: PASS", index + 1));
614
614
  if !run.stderr.trim().is_empty() {
615
- lines.push("stderr:".to_string());
616
- lines.push(run.stderr.trim_end().to_string());
615
+ push_labeled_block(&mut lines, "Stderr", run.stderr.trim_end());
617
616
  }
618
617
  } else {
619
- lines.push(format!("case {}: FAIL", index + 1));
618
+ lines.push(format!("Case {}: FAIL", index + 1));
620
619
  if run.timed_out {
621
- lines.push("timeout: 5s".to_string());
620
+ push_labeled_block(&mut lines, "Error", "timeout: 5s");
622
621
  }
623
- lines.push(format!("input: {:?}", case.input));
624
- lines.push(format!("expected: {:?}", expected));
625
- lines.push(format!("got: {:?}", got));
626
- lines.push("stdout:".to_string());
627
- lines.push(if run.stdout.trim_end().is_empty() {
628
- "<empty>".to_string()
629
- } else {
630
- run.stdout.trim_end().to_string()
631
- });
622
+ push_labeled_block(&mut lines, "Input", case.input.trim_end());
623
+ push_labeled_block(&mut lines, "Expected", expected);
624
+ push_labeled_block(&mut lines, "Got", run.stdout.trim_end());
632
625
  if !run.stderr.trim().is_empty() {
633
- lines.push("stderr:".to_string());
634
- lines.push(run.stderr.trim_end().to_string());
626
+ push_labeled_block(&mut lines, "Stderr", run.stderr.trim_end());
635
627
  }
636
628
  break;
637
629
  }
@@ -645,6 +637,16 @@ pub fn judge(root: &Path, problem: &Problem, settings: &Settings) -> JudgeResult
645
637
  }
646
638
  }
647
639
 
640
+ fn push_labeled_block(lines: &mut Vec<String>, label: &str, body: &str) {
641
+ lines.push(String::new());
642
+ lines.push(label.to_string());
643
+ if body.is_empty() {
644
+ lines.push(" <empty>".to_string());
645
+ } else {
646
+ lines.extend(body.lines().map(|line| format!(" {line}")));
647
+ }
648
+ }
649
+
648
650
  pub fn command_for(root: &Path, path: &Path, language: &str) -> Result<Option<CommandSpec>> {
649
651
  match language {
650
652
  "python" => Ok(which("python3")
package/src/lib.rs CHANGED
@@ -1,5 +1,9 @@
1
1
  use anyhow::{Context, Result};
2
- use crossterm::{cursor::SetCursorStyle, execute};
2
+ use crossterm::{
3
+ cursor::SetCursorStyle,
4
+ event::{DisableMouseCapture, EnableMouseCapture},
5
+ execute,
6
+ };
3
7
  use std::{env, io::stdout};
4
8
 
5
9
  pub mod ai;
@@ -25,9 +29,13 @@ pub fn run_cli() -> Result<()> {
25
29
 
26
30
  let mut app = tui::PracticodeApp::new(root)?;
27
31
  let mut terminal = ratatui::init();
28
- let _ = execute!(stdout(), SetCursorStyle::SteadyBar);
32
+ let _ = execute!(stdout(), SetCursorStyle::SteadyBar, EnableMouseCapture);
29
33
  let result = app.run(&mut terminal);
30
34
  ratatui::restore();
31
- let _ = execute!(stdout(), SetCursorStyle::DefaultUserShape);
35
+ let _ = execute!(
36
+ stdout(),
37
+ SetCursorStyle::DefaultUserShape,
38
+ DisableMouseCapture
39
+ );
32
40
  result
33
41
  }
package/src/tui.rs CHANGED
@@ -1,7 +1,7 @@
1
1
  use crate::{
2
2
  ai::{
3
- append_problem_note, available_models, provider_status, read_problem_notes, run_ai_next,
4
- run_ai_prompt,
3
+ ModelCatalog, append_problem_note, available_models, provider_status, read_problem_notes,
4
+ run_ai_next, run_ai_prompt,
5
5
  },
6
6
  core::{
7
7
  AI_PROVIDERS, AppState, HistoryItem, LANGUAGES, PROBLEM_NOTES_PATH, Problem, THEMES,
@@ -16,7 +16,10 @@ use crate::{
16
16
  update::{CURRENT_VERSION, UpdateCheck, check_latest_version},
17
17
  };
18
18
  use anyhow::Result;
19
- use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
19
+ use crossterm::event::{
20
+ self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
21
+ MouseEventKind,
22
+ };
20
23
  use ratatui::{
21
24
  DefaultTerminal, Frame,
22
25
  layout::{Constraint, Direction, Layout, Position, Rect},
@@ -59,9 +62,9 @@ const COMMAND_HINTS: &[CommandHint] = &[
59
62
  help: true,
60
63
  },
61
64
  CommandHint {
62
- insert: "edit",
63
- display: "/edit",
64
- desc_key: "cmd_edit",
65
+ insert: "code",
66
+ display: "/code",
67
+ desc_key: "cmd_code",
65
68
  keep_open: false,
66
69
  help: true,
67
70
  },
@@ -226,20 +229,6 @@ const COMMAND_HINTS: &[CommandHint] = &[
226
229
  keep_open: false,
227
230
  help: false,
228
231
  },
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
232
  CommandHint {
244
233
  insert: "update",
245
234
  display: "/update",
@@ -282,6 +271,7 @@ pub struct PracticodeApp {
282
271
  command_palette_cursor: usize,
283
272
  output: String,
284
273
  output_is_markdown: bool,
274
+ showing_model_status: bool,
285
275
  show_output: bool,
286
276
  focus: Focus,
287
277
  list_cursor: Option<usize>,
@@ -290,11 +280,15 @@ pub struct PracticodeApp {
290
280
  busy_frame: usize,
291
281
  task_rx: Option<Receiver<TaskResult>>,
292
282
  update_rx: Option<Receiver<UpdateCheck>>,
293
- model_rx: Option<Receiver<Vec<String>>>,
283
+ model_rx: Option<Receiver<ModelCatalog>>,
294
284
  available_models: Vec<String>,
295
285
  available_models_provider: String,
286
+ model_message: Option<String>,
296
287
  update_check: Option<UpdateCheck>,
297
288
  update_notice: Option<String>,
289
+ code_area: Rect,
290
+ output_area: Rect,
291
+ command_area: Rect,
298
292
  should_quit: bool,
299
293
  }
300
294
 
@@ -325,6 +319,7 @@ impl PracticodeApp {
325
319
  command_palette_cursor: 0,
326
320
  output: String::new(),
327
321
  output_is_markdown: false,
322
+ showing_model_status: false,
328
323
  show_output: false,
329
324
  focus: Focus::Code,
330
325
  list_cursor: None,
@@ -336,8 +331,12 @@ impl PracticodeApp {
336
331
  model_rx: None,
337
332
  available_models: Vec::new(),
338
333
  available_models_provider: String::new(),
334
+ model_message: None,
339
335
  update_check: None,
340
336
  update_notice: None,
337
+ code_area: Rect::default(),
338
+ output_area: Rect::default(),
339
+ command_area: Rect::default(),
341
340
  should_quit: false,
342
341
  };
343
342
  app.load_code_editor()?;
@@ -353,11 +352,12 @@ impl PracticodeApp {
353
352
  self.check_update();
354
353
  self.start_model_check();
355
354
  self.check_models();
356
- if event::poll(Duration::from_millis(100))?
357
- && let Event::Key(key) = event::read()?
358
- && key.kind != KeyEventKind::Release
359
- {
360
- self.handle_key(key)?;
355
+ if event::poll(Duration::from_millis(100))? {
356
+ match event::read()? {
357
+ Event::Key(key) if key.kind != KeyEventKind::Release => self.handle_key(key)?,
358
+ Event::Mouse(mouse) => self.handle_mouse(mouse)?,
359
+ _ => {}
360
+ }
361
361
  }
362
362
  if !self.busy_label.is_empty() {
363
363
  self.busy_frame = (self.busy_frame + 1) % 16;
@@ -391,6 +391,16 @@ impl PracticodeApp {
391
391
  self.handle_key(key)
392
392
  }
393
393
 
394
+ pub fn handle_mouse_for_test(&mut self, mouse: MouseEvent) -> Result<()> {
395
+ self.handle_mouse(mouse)
396
+ }
397
+
398
+ pub fn set_pane_areas_for_test(&mut self, code: Rect, output: Rect, command: Rect) {
399
+ self.code_area = code;
400
+ self.output_area = output;
401
+ self.command_area = command;
402
+ }
403
+
394
404
  pub fn busy_label(&self) -> &str {
395
405
  &self.busy_label
396
406
  }
@@ -403,6 +413,10 @@ impl PracticodeApp {
403
413
  self.status_text()
404
414
  }
405
415
 
416
+ pub fn output_for_test(&self) -> &str {
417
+ &self.output
418
+ }
419
+
406
420
  pub fn command_suggestions_for_test(&self) -> Vec<String> {
407
421
  self.command_suggestions()
408
422
  .into_iter()
@@ -413,6 +427,13 @@ impl PracticodeApp {
413
427
  pub fn set_available_models_for_test(&mut self, models: Vec<&str>) {
414
428
  self.available_models = models.into_iter().map(str::to_string).collect();
415
429
  self.available_models_provider = self.state.settings.ai_provider.clone();
430
+ self.model_message = None;
431
+ }
432
+
433
+ pub fn set_model_message_for_test(&mut self, message: &str) {
434
+ self.available_models.clear();
435
+ self.available_models_provider = self.state.settings.ai_provider.clone();
436
+ self.model_message = Some(message.to_string());
416
437
  }
417
438
 
418
439
  pub fn pane_title_for_test(title: &str, active: bool) -> String {
@@ -433,6 +454,9 @@ impl PracticodeApp {
433
454
  .direction(Direction::Horizontal)
434
455
  .constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
435
456
  .split(vertical[0]);
457
+ self.code_area = body[1];
458
+ self.output_area = body[1];
459
+ self.command_area = vertical[2];
436
460
 
437
461
  let problem = Paragraph::new(self.problem_text())
438
462
  .block(Self::block(
@@ -444,13 +468,7 @@ impl PracticodeApp {
444
468
  frame.render_widget(problem, body[0]);
445
469
 
446
470
  if self.show_output {
447
- let text = if !self.busy_label.is_empty() {
448
- format!("{}{}", self.busy_body, self.busy_dots())
449
- } else if self.output_is_markdown {
450
- render_markdown_plain(&self.output)
451
- } else {
452
- self.output.clone()
453
- };
471
+ let text = self.output_text();
454
472
  let output = Paragraph::new(text)
455
473
  .block(Self::block(
456
474
  ui_text(&self.state.settings.ui_language, "output"),
@@ -503,6 +521,79 @@ impl PracticodeApp {
503
521
  self.set_terminal_cursor(frame, body[1], vertical[2]);
504
522
  }
505
523
 
524
+ fn output_text(&self) -> Text<'static> {
525
+ let light = self.state.settings.theme == "light";
526
+ let title_style = if light {
527
+ Style::default()
528
+ .fg(Color::Blue)
529
+ .add_modifier(Modifier::BOLD)
530
+ } else {
531
+ Style::default()
532
+ .fg(Color::Yellow)
533
+ .add_modifier(Modifier::BOLD)
534
+ };
535
+ let label_style = if light {
536
+ Style::default()
537
+ .fg(Color::Magenta)
538
+ .add_modifier(Modifier::BOLD)
539
+ } else {
540
+ Style::default()
541
+ .fg(Color::Cyan)
542
+ .add_modifier(Modifier::BOLD)
543
+ };
544
+ let body_style = if light {
545
+ Style::default().fg(Color::Black)
546
+ } else {
547
+ Style::default().fg(Color::Rgb(229, 231, 235))
548
+ };
549
+ let code_style = if light {
550
+ Style::default()
551
+ .fg(Color::Black)
552
+ .bg(Color::Rgb(229, 231, 235))
553
+ } else {
554
+ Style::default()
555
+ .fg(Color::Rgb(243, 244, 246))
556
+ .bg(Color::Rgb(31, 41, 55))
557
+ };
558
+ if !self.busy_label.is_empty() {
559
+ return Text::from(Line::from(Span::styled(
560
+ format!("{}{}", self.busy_body, self.busy_dots()),
561
+ title_style,
562
+ )));
563
+ }
564
+ let output = if self.output_is_markdown {
565
+ render_markdown_plain(&self.output)
566
+ } else {
567
+ self.output.clone()
568
+ };
569
+ let mut lines = Vec::new();
570
+ for line in output.lines() {
571
+ if line.is_empty() {
572
+ lines.push(Line::default());
573
+ } else if line.starts_with("PASS ")
574
+ || line.starts_with("FAIL ")
575
+ || line.starts_with("Case ")
576
+ || line.starts_with("Next:")
577
+ || line.starts_with("Fix:")
578
+ {
579
+ lines.push(Line::from(Span::styled(line.to_string(), title_style)));
580
+ } else if matches!(
581
+ line,
582
+ "Input" | "Expected" | "Got" | "Stdout" | "Stderr" | "Compile" | "Error"
583
+ ) {
584
+ lines.push(Line::from(Span::styled(line.to_string(), label_style)));
585
+ } else if line.starts_with(" ") {
586
+ lines.push(Line::from(vec![
587
+ Span::raw(" "),
588
+ Span::styled(line.trim_start().to_string(), code_style),
589
+ ]));
590
+ } else {
591
+ lines.push(Line::from(Span::styled(line.to_string(), body_style)));
592
+ }
593
+ }
594
+ Text::from(lines)
595
+ }
596
+
506
597
  fn problem_text(&self) -> Text<'static> {
507
598
  let lang = normalize_ui_language(&self.state.settings.ui_language);
508
599
  let light = self.state.settings.theme == "light";
@@ -717,6 +808,19 @@ impl PracticodeApp {
717
808
  }
718
809
  }
719
810
 
811
+ fn handle_mouse(&mut self, mouse: MouseEvent) -> Result<()> {
812
+ if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
813
+ return Ok(());
814
+ }
815
+ let position = Position::new(mouse.column, mouse.row);
816
+ if self.command_area.contains(position) {
817
+ self.focus_command();
818
+ } else if self.code_area.contains(position) || self.output_area.contains(position) {
819
+ self.action_edit()?;
820
+ }
821
+ Ok(())
822
+ }
823
+
720
824
  fn handle_command_key(&mut self, key: KeyEvent) -> Result<()> {
721
825
  match key.code {
722
826
  KeyCode::Esc => {
@@ -872,7 +976,7 @@ impl PracticodeApp {
872
976
  }
873
977
  match command {
874
978
  "run" | "r" => self.action_run()?,
875
- "edit" | "e" => self.action_edit()?,
979
+ "code" | "edit" | "e" => self.action_edit()?,
876
980
  "next" | "n" => self.action_next(arg)?,
877
981
  "prev" | "previous" | "p" => self.action_previous()?,
878
982
  "giveup" | "give" | "g" => self.action_give_up()?,
@@ -885,12 +989,12 @@ impl PracticodeApp {
885
989
  "theme" if arg.is_empty() => self.action_toggle_theme()?,
886
990
  "theme" if THEMES.contains(&arg) => self.set_theme(arg)?,
887
991
  "source" | "next-source" if arg.is_empty() => {
888
- self.write_text_output(&format!("Next source: {}", self.next_source_label()));
992
+ self.write_text_output(&self.next_source_help());
889
993
  }
890
994
  "source" | "next-source" if matches!(arg, "bank" | "local" | "ai") => {
891
995
  self.state.settings.next_source = normalize_next_source(arg);
892
996
  save_state(&self.root, &self.state)?;
893
- self.write_text_output(&format!("Next source: {}", self.next_source_label()));
997
+ self.write_text_output(&self.next_source_help());
894
998
  }
895
999
  "ai-next-command" if !arg.is_empty() => {
896
1000
  self.state.settings.ai_next_command = arg.to_string();
@@ -910,6 +1014,7 @@ impl PracticodeApp {
910
1014
  self.model_rx = None;
911
1015
  self.available_models.clear();
912
1016
  self.available_models_provider.clear();
1017
+ self.model_message = None;
913
1018
  save_state(&self.root, &self.state)?;
914
1019
  self.write_text_output(&format!(
915
1020
  "AI provider: {}\n{}",
@@ -918,7 +1023,9 @@ impl PracticodeApp {
918
1023
  ));
919
1024
  }
920
1025
  "model" if arg.is_empty() => {
921
- self.write_text_output(&self.model_status_text());
1026
+ self.start_model_check();
1027
+ self.check_models();
1028
+ self.write_model_status();
922
1029
  }
923
1030
  "model" => {
924
1031
  self.state.settings.ai_model = if arg == "auto" {
@@ -927,7 +1034,9 @@ impl PracticodeApp {
927
1034
  arg.to_string()
928
1035
  };
929
1036
  save_state(&self.root, &self.state)?;
930
- self.write_text_output(&self.model_status_text());
1037
+ self.start_model_check();
1038
+ self.check_models();
1039
+ self.write_model_status();
931
1040
  }
932
1041
  "hint" if arg.is_empty() => {
933
1042
  self.start_ai_prompt("Give one concise hint for the current problem.")?
@@ -962,9 +1071,9 @@ impl PracticodeApp {
962
1071
  result.total_cases
963
1072
  );
964
1073
  let next_step = if result.passed {
965
- "Next: /next"
1074
+ ui_text(&self.state.settings.ui_language, "run_pass_next")
966
1075
  } else {
967
- "Fix code, then /run"
1076
+ ui_text(&self.state.settings.ui_language, "run_fail_next")
968
1077
  };
969
1078
  self.write_text_output(&format!("{headline}\n{}\n\n{next_step}", result.output));
970
1079
  Ok(())
@@ -1195,14 +1304,21 @@ impl PracticodeApp {
1195
1304
 
1196
1305
  fn check_models(&mut self) {
1197
1306
  let models = self.model_rx.as_ref().and_then(|rx| rx.try_recv().ok());
1198
- if let Some(models) = models {
1307
+ if let Some(catalog) = models {
1199
1308
  self.model_rx = None;
1200
- self.available_models = models;
1309
+ self.available_models = catalog.models;
1310
+ self.model_message = catalog.message;
1311
+ if self.showing_model_status {
1312
+ self.output = self.model_status_text();
1313
+ self.output_is_markdown = false;
1314
+ self.show_output = true;
1315
+ }
1201
1316
  }
1202
1317
  }
1203
1318
 
1204
1319
  fn model_status_text(&self) -> String {
1205
1320
  let mut lines = vec![
1321
+ format!("AI provider: {}", self.state.settings.ai_provider),
1206
1322
  format!(
1207
1323
  "AI model: {}",
1208
1324
  if self.state.settings.ai_model == "auto" {
@@ -1213,11 +1329,15 @@ impl PracticodeApp {
1213
1329
  ),
1214
1330
  "Use /model auto to let the provider choose its default.".to_string(),
1215
1331
  ];
1216
- if self.available_models.is_empty() {
1332
+ if self.model_rx.is_some() {
1333
+ lines.push("Loading provider model list...".to_string());
1334
+ } else if self.available_models.is_empty() {
1217
1335
  lines.push(
1218
- "Provider model list is unavailable; use /model <name> for a known model."
1219
- .to_string(),
1336
+ self.model_message
1337
+ .clone()
1338
+ .unwrap_or_else(|| "Provider model list is unavailable.".to_string()),
1220
1339
  );
1340
+ lines.push("Use /model <name> for a known model.".to_string());
1221
1341
  } else {
1222
1342
  lines.push("Available models:".to_string());
1223
1343
  lines.extend(
@@ -1244,6 +1364,7 @@ impl PracticodeApp {
1244
1364
  }
1245
1365
 
1246
1366
  fn write_output(&mut self, output: &str) {
1367
+ self.showing_model_status = false;
1247
1368
  self.output = output.to_string();
1248
1369
  self.output_is_markdown = true;
1249
1370
  self.show_output = true;
@@ -1251,12 +1372,21 @@ impl PracticodeApp {
1251
1372
  }
1252
1373
 
1253
1374
  fn write_text_output(&mut self, output: &str) {
1375
+ self.showing_model_status = false;
1254
1376
  self.output = output.trim_end().to_string();
1255
1377
  self.output_is_markdown = false;
1256
1378
  self.show_output = true;
1257
1379
  self.focus = Focus::Output;
1258
1380
  }
1259
1381
 
1382
+ fn write_model_status(&mut self) {
1383
+ self.output = self.model_status_text();
1384
+ self.output_is_markdown = false;
1385
+ self.showing_model_status = true;
1386
+ self.show_output = true;
1387
+ self.focus = Focus::Output;
1388
+ }
1389
+
1260
1390
  fn show_update_notice(&mut self) {
1261
1391
  let lang = self.state.settings.ui_language.clone();
1262
1392
  if let Some(version) = &self.update_notice {
@@ -1614,11 +1744,11 @@ impl PracticodeApp {
1614
1744
  )
1615
1745
  }
1616
1746
 
1617
- fn next_source_label(&self) -> &'static str {
1747
+ fn next_source_help(&self) -> String {
1618
1748
  if self.state.settings.next_source == "ai" {
1619
- "ai"
1749
+ "Next behavior: plain /next asks AI every time. Use /source local to use local problems first.".to_string()
1620
1750
  } else {
1621
- "local"
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()
1622
1752
  }
1623
1753
  }
1624
1754