practicode 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.lock CHANGED
@@ -357,7 +357,7 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
357
357
 
358
358
  [[package]]
359
359
  name = "practicode"
360
- version = "0.1.2"
360
+ version = "0.1.3"
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.2"
3
+ version = "0.1.3"
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
@@ -69,9 +69,10 @@ Submissions are saved as you type under `submissions/<problem-id>/solution.<ext>
69
69
  | `/list` | Browse problems with `up/down` or `j/k`, open with `Enter` |
70
70
  | `/open 2` | Open by number, id, or slug |
71
71
  | `/giveup` | Show the reference answer |
72
- | `/ai hint` | Ask the selected AI about the current problem and submission |
72
+ | `/hint` | Ask the selected AI for a concise hint |
73
+ | `/hint explain my bug` | Ask the selected AI about the current problem and submission |
73
74
  | `/provider codex` | Set AI provider and show local CLI/daemon status |
74
- | `/model auto` | Set the model for `/ai` and AI-backed `/next` |
75
+ | `/model auto` | Use the provider default model for `/hint` and AI-backed `/next` |
75
76
  | `/note prefer hashmap practice` | Append a standing note for future problem generation |
76
77
  | `/notes` | Show your local next-problem notes |
77
78
  | `/lang python` | Set code language: `python`, `ts`, `java`, `rust` |
@@ -133,9 +134,11 @@ cargo install --force practicode
133
134
 
134
135
  `/run` executes your local submission as a normal process. practicode runs it from `.practicode/build/<problem-id>/run`, but this is not an OS sandbox. Only run code you trust.
135
136
 
136
- ## Contributors
137
+ ## Contributing
137
138
 
138
- Development, release, problem-authoring, and design references live in [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md).
139
+ External contributions use the fork and pull request flow in [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md).
140
+
141
+ Maintainer-only review and release notes live in [docs/MAINTAINING.md](docs/MAINTAINING.md).
139
142
 
140
143
  ## License
141
144
 
@@ -29,9 +29,13 @@
29
29
  "cmd_list": "Browse problems",
30
30
  "cmd_open": "Open by number, id, or slug",
31
31
  "cmd_giveup": "Show the reference answer",
32
+ "cmd_hint": "Ask for a hint about the current problem",
32
33
  "cmd_ai": "Ask AI about the current problem and code",
33
34
  "cmd_provider": "Set AI provider",
34
35
  "cmd_model": "Set AI model",
36
+ "cmd_model_auto": "Use provider default model",
37
+ "cmd_model_available": "Use an available provider model",
38
+ "cmd_model_custom": "Type a model name",
35
39
  "cmd_note": "Add a next-problem note",
36
40
  "cmd_notes": "Show saved notes",
37
41
  "cmd_lang": "Set code language",
@@ -29,9 +29,13 @@
29
29
  "cmd_list": "Abrir la lista de problemas",
30
30
  "cmd_open": "Abrir por numero, id o slug",
31
31
  "cmd_giveup": "Mostrar la respuesta de referencia",
32
+ "cmd_hint": "Pedir una pista para el problema actual",
32
33
  "cmd_ai": "Preguntar a AI sobre el problema y codigo actuales",
33
34
  "cmd_provider": "Configurar AI provider",
34
35
  "cmd_model": "Configurar AI model",
36
+ "cmd_model_auto": "Usar el modelo predeterminado del provider",
37
+ "cmd_model_available": "Usar un modelo disponible del provider",
38
+ "cmd_model_custom": "Escribir un nombre de modelo",
35
39
  "cmd_note": "Agregar nota para generar problemas",
36
40
  "cmd_notes": "Ver notas guardadas",
37
41
  "cmd_lang": "Configurar lenguaje de codigo",
@@ -29,9 +29,13 @@
29
29
  "cmd_list": "問題一覧を開く",
30
30
  "cmd_open": "番号、id、slug で問題を開く",
31
31
  "cmd_giveup": "解答を見る",
32
+ "cmd_hint": "現在の問題のヒントを依頼",
32
33
  "cmd_ai": "現在の問題とコードについて AI に質問",
33
34
  "cmd_provider": "AI provider を設定",
34
35
  "cmd_model": "AI model を設定",
36
+ "cmd_model_auto": "provider の既定モデルを使用",
37
+ "cmd_model_available": "利用可能な provider モデルを使用",
38
+ "cmd_model_custom": "モデル名を直接入力",
35
39
  "cmd_note": "次の問題生成メモを追加",
36
40
  "cmd_notes": "保存済みメモを見る",
37
41
  "cmd_lang": "コード言語を設定",
@@ -29,9 +29,13 @@
29
29
  "cmd_list": "문제 목록 열기",
30
30
  "cmd_open": "번호, id, slug로 문제 열기",
31
31
  "cmd_giveup": "정답 보기",
32
+ "cmd_hint": "현재 문제 힌트 요청",
32
33
  "cmd_ai": "현재 문제와 코드에 대해 AI에게 질문",
33
34
  "cmd_provider": "AI provider 설정",
34
35
  "cmd_model": "AI model 설정",
36
+ "cmd_model_auto": "provider 기본 모델 사용",
37
+ "cmd_model_available": "사용 가능한 provider 모델 선택",
38
+ "cmd_model_custom": "모델 이름 직접 입력",
35
39
  "cmd_note": "다음 문제 생성 메모 추가",
36
40
  "cmd_notes": "저장된 메모 보기",
37
41
  "cmd_lang": "코드 언어 설정",
@@ -29,9 +29,13 @@
29
29
  "cmd_list": "打开题目列表",
30
30
  "cmd_open": "按编号、id 或 slug 打开题目",
31
31
  "cmd_giveup": "显示参考答案",
32
+ "cmd_hint": "请求当前题目的提示",
32
33
  "cmd_ai": "向 AI 询问当前题目和代码",
33
34
  "cmd_provider": "设置 AI provider",
34
35
  "cmd_model": "设置 AI model",
36
+ "cmd_model_auto": "使用 provider 默认模型",
37
+ "cmd_model_available": "使用可用的 provider 模型",
38
+ "cmd_model_custom": "输入模型名称",
35
39
  "cmd_note": "添加下次出题备注",
36
40
  "cmd_notes": "查看保存的备注",
37
41
  "cmd_lang": "设置代码语言",
@@ -1,14 +1,54 @@
1
1
  # Contributing
2
2
 
3
- This repo is a Rust coding-practice workspace with a Ratatui terminal UI.
3
+ Thanks for helping improve practicode. This guide is for contributors opening issues or pull requests.
4
4
 
5
- ## Prerequisites
5
+ Maintainer-only review and release steps live in [MAINTAINING.md](MAINTAINING.md).
6
+
7
+ ## Before You Start
8
+
9
+ - Search existing issues and pull requests first.
10
+ - Small bug fixes, docs fixes, tests, and localization updates can go straight to a pull request.
11
+ - For larger UI, AI-generation, storage, or packaging changes, open an issue first so the scope is clear.
12
+ - Do not commit local practice data from `.practicode/`, `problems/`, or `submissions/`.
13
+ - Do not include secrets, tokens, private prompts, or generated answer keys in docs or examples.
14
+
15
+ ## Fork And Pull Request Flow
16
+
17
+ 1. Fork `baba9811/practicode` on GitHub.
18
+ 2. Clone your fork and add the original repo as `upstream`.
19
+
20
+ ```bash
21
+ git clone https://github.com/<your-user>/practicode.git
22
+ cd practicode
23
+ git remote add upstream https://github.com/baba9811/practicode.git
24
+ ```
25
+
26
+ 3. Create a focused branch from the latest `main`.
27
+
28
+ ```bash
29
+ git fetch upstream
30
+ git checkout -b fix-short-name upstream/main
31
+ ```
32
+
33
+ 4. Make one focused change.
34
+ 5. Run the smallest checks that cover your change.
35
+ 6. Push your branch and open a pull request into `baba9811/practicode:main`.
36
+
37
+ ```bash
38
+ git push origin fix-short-name
39
+ ```
40
+
41
+ When opening the pull request, include what changed, how you checked it, and screenshots for visible TUI changes.
42
+
43
+ ## Local Setup
44
+
45
+ Prerequisites:
6
46
 
7
47
  - Rust stable with Cargo, rustfmt, and clippy.
8
48
  - Node.js 18+ for the npm wrapper and package checks.
9
49
  - Optional local runtimes for judging: Python, Node.js, JDK, and Rust.
10
50
 
11
- ## Development
51
+ Common commands:
12
52
 
13
53
  ```bash
14
54
  cargo run --
@@ -23,7 +63,7 @@ Full local check:
23
63
  make test
24
64
  ```
25
65
 
26
- The source is split by boring responsibility:
66
+ ## Project Map
27
67
 
28
68
  | Path | Role |
29
69
  | --- | --- |
@@ -35,6 +75,16 @@ The source is split by boring responsibility:
35
75
  | `src/process.rs` | Process execution helpers |
36
76
  | `tests/` | Integration tests split by module |
37
77
 
78
+ ## Change Guidelines
79
+
80
+ - Keep pull requests small and reviewable.
81
+ - Reuse existing helpers and patterns before adding new code.
82
+ - Prefer the Rust standard library. Add crates only when they remove real complexity.
83
+ - Put UI strings in [assets/i18n](../assets/i18n), not inline in Rust code.
84
+ - Keep English localization complete first; other locales can be partial because the runtime falls back per key to English.
85
+ - Keep the root [README](../README.md) focused on users.
86
+ - Use relative links for repo-local docs and assets.
87
+
38
88
  ## Problem Authoring
39
89
 
40
90
  AI generation reads [problem-authoring-notes.md](problem-authoring-notes.md) every time it creates a problem.
@@ -49,32 +99,22 @@ Local generated data stays ignored by git:
49
99
  | `problems/` | Generated problem markdown/index files |
50
100
  | `submissions/` | Local answer files |
51
101
 
52
- ## Release
53
-
54
- `main` runs CI only. Releases are tag-based and publish to crates.io and npm through GitHub Actions.
55
-
56
- ```bash
57
- make release VERSION=0.1.1
58
- ```
59
-
60
- The release script checks versions, runs tests, creates the version commit and tag, and pushes `main` plus the tag. Do not print or commit tokens; GitHub Actions uses repository secrets.
61
-
62
- ## Documentation
63
-
64
- Keep the root [README](../README.md) focused on users. Put contributor workflow, implementation notes, release notes, and design references here or in nearby `docs/` files.
65
-
66
- Use relative links for repo-local docs and assets. The terminal screenshot is stored at [assets/practicode-terminal.svg](../assets/practicode-terminal.svg).
67
-
68
- ## Localization
102
+ ## Pull Request Checklist
69
103
 
70
- UI strings live in [assets/i18n](../assets/i18n). Keep English complete first; other locales can be partial because the runtime falls back per key to English.
104
+ - The change is focused and explained.
105
+ - Relevant checks were run, or the PR says why they were not.
106
+ - Visible TUI changes include a screenshot or short terminal description.
107
+ - User-facing text is in `assets/i18n/*.json`.
108
+ - Local generated data, secrets, tokens, and answer keys are not committed.
71
109
 
72
- ## UX And Documentation References
110
+ ## References
73
111
 
112
+ - GitHub contributing guide: https://docs.github.com/en/get-started/exploring-projects-on-github/contributing-to-open-source
113
+ - GitHub contributing guidelines: https://docs.github.com/en/communities/setting-up-your-project-for-healthy-contributions/setting-guidelines-for-repository-contributors
114
+ - GitHub pull requests: https://docs.github.com/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests
115
+ - Open Source Guides community notes: https://opensource.guide/building-community/
74
116
  - WAI-ARIA combobox keyboard interaction: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
75
117
  - Command Line Interface Guidelines: https://clig.dev/
76
- - GitHub README guidance: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-readmes
77
- - GitHub relative links and images: https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#relative-links
78
118
  - Ratatui terminal UI library: https://ratatui.rs/
79
119
  - Crossterm terminal backend/events: https://github.com/crossterm-rs/crossterm
80
120
  - Kattis problem package format: https://www.kattis.com/problem-package-format/
@@ -0,0 +1,67 @@
1
+ # Maintaining
2
+
3
+ This guide is for maintainers with commit, tag, or publishing responsibility.
4
+
5
+ Contributor-facing workflow lives in [CONTRIBUTING.md](CONTRIBUTING.md).
6
+
7
+ ## Triage
8
+
9
+ - Keep issues actionable: expected behavior, actual behavior, reproduction steps, OS, terminal, and install method.
10
+ - Use small scopes. Ask broad proposals to become one issue or pull request per behavior change.
11
+ - Label approachable work as `good first issue` or `help wanted` when the fix is clear.
12
+ - Close duplicates with a link to the canonical issue.
13
+
14
+ ## Pull Request Review
15
+
16
+ - Review the diff file by file.
17
+ - Check correctness, local-first behavior, terminal UX, docs, and tests before style.
18
+ - Prefer focused pull requests. Ask contributors to split unrelated changes.
19
+ - Require checks or a clear reason checks were not run.
20
+ - For visible TUI changes, ask for a screenshot or a short terminal description.
21
+ - Merge only after CI passes and the branch is up to date enough to avoid obvious conflicts.
22
+
23
+ ## Release
24
+
25
+ `main` runs CI only. Releases are tag-based and publish to crates.io and npm through GitHub Actions.
26
+
27
+ Preflight:
28
+
29
+ ```bash
30
+ git checkout main
31
+ git pull --ff-only origin main
32
+ make test
33
+ ```
34
+
35
+ Release:
36
+
37
+ ```bash
38
+ make release VERSION=0.1.3
39
+ ```
40
+
41
+ The release script checks versions, runs tests, creates the version commit and tag, and pushes `main` plus the tag.
42
+
43
+ Verify publication:
44
+
45
+ ```bash
46
+ gh run list --limit 5
47
+ npm view practicode version
48
+ cargo search practicode --limit 1
49
+ ```
50
+
51
+ Do not print or commit tokens. Local `.env` and `.npmrc` are ignored; GitHub Actions uses `NPM_TOKEN` and `CRATES_TOKEN` repository secrets.
52
+
53
+ ## Documentation Ownership
54
+
55
+ | File | Audience |
56
+ | --- | --- |
57
+ | [../README.md](../README.md) | Users installing and running practicode |
58
+ | [CONTRIBUTING.md](CONTRIBUTING.md) | External contributors opening issues or pull requests |
59
+ | [MAINTAINING.md](MAINTAINING.md) | Maintainers reviewing, triaging, and releasing |
60
+ | [problem-authoring-notes.md](problem-authoring-notes.md) | AI/local problem generation rules |
61
+
62
+ ## Maintainer References
63
+
64
+ - GitHub reviewing pull requests: https://docs.github.com/articles/reviewing-proposed-changes-in-a-pull-request
65
+ - GitHub helping reviewers: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/getting-started/helping-others-review-your-changes
66
+ - GitHub healthy contributions: https://docs.github.com/en/communities/setting-up-your-project-for-healthy-contributions
67
+ - Git contributing through forks: https://git-scm.com/book/en/v2/GitHub-Contributing-to-a-Project
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "practicode",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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
@@ -102,6 +102,13 @@ pub fn provider_status(provider: &str) -> String {
102
102
  }
103
103
  }
104
104
 
105
+ pub fn available_models(provider: &str) -> Vec<String> {
106
+ match normalize_ai_provider(provider).as_str() {
107
+ "codex" => codex_models(),
108
+ _ => Vec::new(),
109
+ }
110
+ }
111
+
105
112
  pub fn default_ai_next_prompt(request: &str) -> String {
106
113
  format!(
107
114
  "Read AGENTS.md, docs/problem-authoring-notes.md if present, .practicode/problem_notes.md if present, problems/INDEX.md if present, .practicode/problem_bank.json if present, and .practicode/problem-state.json. Create exactly one new non-duplicate coding practice problem. The built-in 001-hello-world already exists, so do not duplicate it. User request: {}. Make the smallest valid edits: update .practicode/problem_bank.json, one problem directory, problems/INDEX.md, and .practicode/problem-state.json. Do not include the answer in the problem statement.",
@@ -132,6 +139,44 @@ pub fn read_problem_notes(root: &Path) -> Result<String> {
132
139
  }
133
140
  }
134
141
 
142
+ fn codex_models() -> Vec<String> {
143
+ if which("codex").is_none() {
144
+ return Vec::new();
145
+ }
146
+ let mut command = Command::new("codex");
147
+ command.args(["app-server", "proxy"]);
148
+ let input = r#"{"id":1,"method":"model/list","params":{"limit":25}}"#;
149
+ let Ok(run) = run_capture(&mut command, &format!("{input}\n"), Duration::from_secs(2)) else {
150
+ return Vec::new();
151
+ };
152
+ if run.code != Some(0) {
153
+ return Vec::new();
154
+ }
155
+ parse_model_list(&run.stdout)
156
+ }
157
+
158
+ fn parse_model_list(output: &str) -> Vec<String> {
159
+ output
160
+ .lines()
161
+ .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
162
+ .flat_map(|value| {
163
+ value
164
+ .pointer("/result/data")
165
+ .or_else(|| value.get("data"))
166
+ .and_then(|data| data.as_array())
167
+ .cloned()
168
+ .unwrap_or_default()
169
+ })
170
+ .filter_map(|model| {
171
+ model
172
+ .get("model")
173
+ .or_else(|| model.get("id"))
174
+ .and_then(|value| value.as_str())
175
+ .map(str::to_string)
176
+ })
177
+ .collect()
178
+ }
179
+
135
180
  fn run_codex_prompt(root: &Path, settings: &Settings, prompt: &str) -> String {
136
181
  let output_path = unique_temp_path("practicode-last-message", "txt");
137
182
  let mut command = Command::new("codex");
@@ -243,3 +288,15 @@ fn output_text(stdout: &str, stderr: &str) -> String {
243
288
  .collect::<Vec<_>>()
244
289
  .join("\n")
245
290
  }
291
+
292
+ #[cfg(test)]
293
+ mod tests {
294
+ use super::parse_model_list;
295
+
296
+ #[test]
297
+ fn parses_codex_model_list_response() {
298
+ let output =
299
+ r#"{"id":1,"result":{"data":[{"model":"gpt-test","displayName":"GPT Test"}]}}"#;
300
+ assert_eq!(parse_model_list(output), vec!["gpt-test"]);
301
+ }
302
+ }
package/src/tui.rs CHANGED
@@ -1,11 +1,14 @@
1
1
  use crate::{
2
- ai::{append_problem_note, provider_status, read_problem_notes, run_ai_next, run_ai_prompt},
2
+ ai::{
3
+ append_problem_note, available_models, provider_status, read_problem_notes, run_ai_next,
4
+ run_ai_prompt,
5
+ },
3
6
  core::{
4
7
  AI_PROVIDERS, AppState, HistoryItem, LANGUAGES, PROBLEM_NOTES_PATH, Problem, THEMES,
5
8
  UI_LANGUAGES, ensure_problem_files, ensure_submission, ext_for, give_up, judge, load_bank,
6
9
  load_state, localized, next_problem, normalize_ai_provider, normalize_language,
7
10
  normalize_next_source, normalize_ui_language, previous_problem, problem_by_id, record_pass,
8
- render_problem_tui, save_state, template_for, ui_text,
11
+ save_state, template_for, ui_text,
9
12
  },
10
13
  text::{
11
14
  byte_index, char_len, compose_hangul_jamo, display_width, prefix, render_markdown_plain,
@@ -18,6 +21,7 @@ use ratatui::{
18
21
  DefaultTerminal, Frame,
19
22
  layout::{Constraint, Direction, Layout, Position, Rect},
20
23
  style::{Color, Modifier, Style},
24
+ text::{Line, Span, Text},
21
25
  widgets::{Block, Borders, Clear, Paragraph, Wrap},
22
26
  };
23
27
  use std::{
@@ -38,6 +42,14 @@ struct CommandHint {
38
42
  help: bool,
39
43
  }
40
44
 
45
+ #[derive(Clone)]
46
+ struct CommandChoice {
47
+ insert: String,
48
+ display: String,
49
+ desc_key: &'static str,
50
+ keep_open: bool,
51
+ }
52
+
41
53
  const COMMAND_HINTS: &[CommandHint] = &[
42
54
  CommandHint {
43
55
  insert: "run",
@@ -89,9 +101,9 @@ const COMMAND_HINTS: &[CommandHint] = &[
89
101
  help: true,
90
102
  },
91
103
  CommandHint {
92
- insert: "ai ",
93
- display: "/ai <prompt>",
94
- desc_key: "cmd_ai",
104
+ insert: "hint ",
105
+ display: "/hint <request>",
106
+ desc_key: "cmd_hint",
95
107
  keep_open: true,
96
108
  help: true,
97
109
  },
@@ -112,14 +124,14 @@ const COMMAND_HINTS: &[CommandHint] = &[
112
124
  CommandHint {
113
125
  insert: "model auto",
114
126
  display: "/model auto",
115
- desc_key: "cmd_model",
127
+ desc_key: "cmd_model_auto",
116
128
  keep_open: false,
117
129
  help: true,
118
130
  },
119
131
  CommandHint {
120
132
  insert: "model ",
121
133
  display: "/model <name>",
122
- desc_key: "cmd_model",
134
+ desc_key: "cmd_model_custom",
123
135
  keep_open: true,
124
136
  help: false,
125
137
  },
@@ -278,6 +290,9 @@ pub struct PracticodeApp {
278
290
  busy_frame: usize,
279
291
  task_rx: Option<Receiver<TaskResult>>,
280
292
  update_rx: Option<Receiver<UpdateCheck>>,
293
+ model_rx: Option<Receiver<Vec<String>>>,
294
+ available_models: Vec<String>,
295
+ available_models_provider: String,
281
296
  update_check: Option<UpdateCheck>,
282
297
  update_notice: Option<String>,
283
298
  should_quit: bool,
@@ -318,6 +333,9 @@ impl PracticodeApp {
318
333
  busy_frame: 0,
319
334
  task_rx: None,
320
335
  update_rx: None,
336
+ model_rx: None,
337
+ available_models: Vec::new(),
338
+ available_models_provider: String::new(),
321
339
  update_check: None,
322
340
  update_notice: None,
323
341
  should_quit: false,
@@ -328,10 +346,13 @@ impl PracticodeApp {
328
346
 
329
347
  pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
330
348
  self.start_update_check();
349
+ self.start_model_check();
331
350
  while !self.should_quit {
332
351
  terminal.draw(|frame| self.draw(frame))?;
333
352
  self.check_task();
334
353
  self.check_update();
354
+ self.start_model_check();
355
+ self.check_models();
335
356
  if event::poll(Duration::from_millis(100))?
336
357
  && let Event::Key(key) = event::read()?
337
358
  && key.kind != KeyEventKind::Release
@@ -382,6 +403,22 @@ impl PracticodeApp {
382
403
  self.status_text()
383
404
  }
384
405
 
406
+ pub fn command_suggestions_for_test(&self) -> Vec<String> {
407
+ self.command_suggestions()
408
+ .into_iter()
409
+ .map(|choice| choice.display)
410
+ .collect()
411
+ }
412
+
413
+ pub fn set_available_models_for_test(&mut self, models: Vec<&str>) {
414
+ self.available_models = models.into_iter().map(str::to_string).collect();
415
+ self.available_models_provider = self.state.settings.ai_provider.clone();
416
+ }
417
+
418
+ pub fn pane_title_for_test(title: &str, active: bool) -> String {
419
+ Self::pane_title(title, active)
420
+ }
421
+
385
422
  fn draw(&mut self, frame: &mut Frame) {
386
423
  let size = frame.area();
387
424
  let vertical = Layout::default()
@@ -397,15 +434,13 @@ impl PracticodeApp {
397
434
  .constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
398
435
  .split(vertical[0]);
399
436
 
400
- let problem = Paragraph::new(render_problem_tui(
401
- &self.problem,
402
- &self.state.settings.ui_language,
403
- ))
404
- .block(Self::block(
405
- ui_text(&self.state.settings.ui_language, "problem"),
406
- self.state.settings.theme == "light",
407
- ))
408
- .wrap(Wrap { trim: false });
437
+ let problem = Paragraph::new(self.problem_text())
438
+ .block(Self::block(
439
+ ui_text(&self.state.settings.ui_language, "problem"),
440
+ self.state.settings.theme == "light",
441
+ false,
442
+ ))
443
+ .wrap(Wrap { trim: false });
409
444
  frame.render_widget(problem, body[0]);
410
445
 
411
446
  if self.show_output {
@@ -420,6 +455,7 @@ impl PracticodeApp {
420
455
  .block(Self::block(
421
456
  ui_text(&self.state.settings.ui_language, "output"),
422
457
  self.state.settings.theme == "light",
458
+ self.focus != Focus::Command,
423
459
  ))
424
460
  .wrap(Wrap { trim: false });
425
461
  frame.render_widget(output, body[1]);
@@ -428,8 +464,11 @@ impl PracticodeApp {
428
464
  .editor
429
465
  .visible_text(body[1].height.saturating_sub(2) as usize);
430
466
  let title = format!("solution.{}", ext_for(&self.state.settings.language));
431
- let code = Paragraph::new(code)
432
- .block(Self::block(&title, self.state.settings.theme == "light"));
467
+ let code = Paragraph::new(code).block(Self::block(
468
+ &title,
469
+ self.state.settings.theme == "light",
470
+ self.focus == Focus::Code,
471
+ ));
433
472
  frame.render_widget(code, body[1]);
434
473
  }
435
474
 
@@ -456,6 +495,7 @@ impl PracticodeApp {
456
495
  .block(Self::block(
457
496
  ui_text(&self.state.settings.ui_language, "command"),
458
497
  self.state.settings.theme == "light",
498
+ self.focus == Focus::Command,
459
499
  ))
460
500
  .wrap(Wrap { trim: false });
461
501
  frame.render_widget(command, vertical[2]);
@@ -463,12 +503,147 @@ impl PracticodeApp {
463
503
  self.set_terminal_cursor(frame, body[1], vertical[2]);
464
504
  }
465
505
 
506
+ fn problem_text(&self) -> Text<'static> {
507
+ let lang = normalize_ui_language(&self.state.settings.ui_language);
508
+ let light = self.state.settings.theme == "light";
509
+ let title_style = if light {
510
+ Style::default()
511
+ .fg(Color::Blue)
512
+ .add_modifier(Modifier::BOLD)
513
+ } else {
514
+ Style::default()
515
+ .fg(Color::Yellow)
516
+ .add_modifier(Modifier::BOLD)
517
+ };
518
+ let section_style = if light {
519
+ Style::default()
520
+ .fg(Color::Magenta)
521
+ .add_modifier(Modifier::BOLD)
522
+ } else {
523
+ Style::default()
524
+ .fg(Color::Cyan)
525
+ .add_modifier(Modifier::BOLD)
526
+ };
527
+ let body_style = if light {
528
+ Style::default().fg(Color::Black)
529
+ } else {
530
+ Style::default().fg(Color::Rgb(229, 231, 235))
531
+ };
532
+ let meta_style = if light {
533
+ Style::default().fg(Color::Rgb(75, 85, 99))
534
+ } else {
535
+ Style::default().fg(Color::Rgb(156, 163, 175))
536
+ };
537
+ let code_style = if light {
538
+ Style::default()
539
+ .fg(Color::Black)
540
+ .bg(Color::Rgb(229, 231, 235))
541
+ } else {
542
+ Style::default()
543
+ .fg(Color::Rgb(243, 244, 246))
544
+ .bg(Color::Rgb(31, 41, 55))
545
+ };
546
+ let number = self
547
+ .problem
548
+ .id
549
+ .split_once('-')
550
+ .map(|(number, _)| number)
551
+ .unwrap_or(&self.problem.id);
552
+ let mut lines = vec![
553
+ Line::from(Span::styled(
554
+ format!("{number}. {}", localized(&self.problem.title, &lang)),
555
+ title_style,
556
+ )),
557
+ Line::from(Span::styled(
558
+ format!(
559
+ "{}: {} {}: {}",
560
+ ui_text(&lang, "difficulty"),
561
+ self.problem.difficulty,
562
+ ui_text(&lang, "topics"),
563
+ self.problem.topics.join(", ")
564
+ ),
565
+ meta_style,
566
+ )),
567
+ ];
568
+ lines.push(Line::default());
569
+ for line in localized(&self.problem.statement, &lang).trim_end().lines() {
570
+ lines.push(Line::from(Span::styled(line.to_string(), body_style)));
571
+ }
572
+ Self::push_problem_section(
573
+ &mut lines,
574
+ ui_text(&lang, "input"),
575
+ &localized(&self.problem.input, &lang),
576
+ section_style,
577
+ body_style,
578
+ );
579
+ Self::push_problem_section(
580
+ &mut lines,
581
+ ui_text(&lang, "output"),
582
+ &localized(&self.problem.output, &lang),
583
+ section_style,
584
+ body_style,
585
+ );
586
+ lines.push(Line::default());
587
+ lines.push(Line::from(Span::styled(
588
+ ui_text(&lang, "examples").to_string(),
589
+ section_style,
590
+ )));
591
+ for (index, case) in self.problem.examples.iter().enumerate() {
592
+ lines.push(Line::from(Span::styled(
593
+ format!(" {} {}", ui_text(&lang, "example"), index + 1),
594
+ meta_style.add_modifier(Modifier::BOLD),
595
+ )));
596
+ lines.push(Line::from(Span::styled(
597
+ format!(" {}", ui_text(&lang, "input")),
598
+ meta_style,
599
+ )));
600
+ Self::push_code_lines(&mut lines, &case.input, code_style);
601
+ lines.push(Line::from(Span::styled(
602
+ format!(" {}", ui_text(&lang, "output")),
603
+ meta_style,
604
+ )));
605
+ Self::push_code_lines(&mut lines, &case.output, code_style);
606
+ }
607
+ Text::from(lines)
608
+ }
609
+
610
+ fn push_problem_section(
611
+ lines: &mut Vec<Line<'static>>,
612
+ title: &str,
613
+ body: &str,
614
+ section_style: Style,
615
+ body_style: Style,
616
+ ) {
617
+ lines.push(Line::default());
618
+ lines.push(Line::from(Span::styled(title.to_string(), section_style)));
619
+ for line in body.trim_end().lines() {
620
+ lines.push(Line::from(Span::styled(format!(" {line}"), body_style)));
621
+ }
622
+ }
623
+
624
+ fn push_code_lines(lines: &mut Vec<Line<'static>>, body: &str, code_style: Style) {
625
+ let body = body.trim_end();
626
+ if body.is_empty() {
627
+ lines.push(Line::from(vec![
628
+ Span::raw(" "),
629
+ Span::styled("<empty>".to_string(), code_style),
630
+ ]));
631
+ return;
632
+ }
633
+ for line in body.lines() {
634
+ lines.push(Line::from(vec![
635
+ Span::raw(" "),
636
+ Span::styled(line.to_string(), code_style),
637
+ ]));
638
+ }
639
+ }
640
+
466
641
  fn draw_command_palette(&self, frame: &mut Frame, command_area: Rect) {
467
642
  let suggestions = self.command_suggestions();
468
643
  if suggestions.is_empty() || command_area.y < 3 {
469
644
  return;
470
645
  }
471
- let height = ((suggestions.len() + 3) as u16).min(10).min(command_area.y);
646
+ let height = ((suggestions.len() + 3) as u16).min(14).min(command_area.y);
472
647
  let area = Rect::new(
473
648
  command_area.x,
474
649
  command_area.y - height,
@@ -476,10 +651,13 @@ impl PracticodeApp {
476
651
  height,
477
652
  );
478
653
  let selected = self.command_palette_cursor.min(suggestions.len() - 1);
654
+ let visible = height.saturating_sub(2) as usize;
655
+ let start = selected.saturating_sub(visible.saturating_sub(1));
479
656
  let mut lines = suggestions
480
657
  .iter()
481
658
  .enumerate()
482
- .take(height.saturating_sub(2) as usize)
659
+ .skip(start)
660
+ .take(visible)
483
661
  .map(|(index, hint)| {
484
662
  let marker = if index == selected { ">" } else { " " };
485
663
  format!(
@@ -495,20 +673,40 @@ impl PracticodeApp {
495
673
  Paragraph::new(lines.join("\n")).block(Self::block(
496
674
  ui_text(&self.state.settings.ui_language, "commands"),
497
675
  self.state.settings.theme == "light",
676
+ true,
498
677
  )),
499
678
  area,
500
679
  );
501
680
  }
502
681
 
503
- fn block(title: &str, light: bool) -> Block<'_> {
682
+ fn block(title: &str, light: bool, active: bool) -> Block<'static> {
683
+ let border = if active {
684
+ if light {
685
+ Style::default()
686
+ .fg(Color::Magenta)
687
+ .add_modifier(Modifier::BOLD)
688
+ } else {
689
+ Style::default()
690
+ .fg(Color::Yellow)
691
+ .add_modifier(Modifier::BOLD)
692
+ }
693
+ } else if light {
694
+ Style::default().fg(Color::Blue)
695
+ } else {
696
+ Style::default().fg(Color::Cyan)
697
+ };
504
698
  Block::default()
505
699
  .borders(Borders::ALL)
506
- .title(title)
507
- .border_style(if light {
508
- Style::default().fg(Color::Blue)
509
- } else {
510
- Style::default().fg(Color::Cyan)
511
- })
700
+ .title(Self::pane_title(title, active))
701
+ .border_style(border)
702
+ }
703
+
704
+ fn pane_title(title: &str, active: bool) -> String {
705
+ if active {
706
+ format!("> {title}")
707
+ } else {
708
+ title.to_string()
709
+ }
512
710
  }
513
711
 
514
712
  fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
@@ -709,6 +907,9 @@ impl PracticodeApp {
709
907
  }
710
908
  "provider" | "ai-provider" if AI_PROVIDERS.contains(&arg) => {
711
909
  self.state.settings.ai_provider = normalize_ai_provider(arg);
910
+ self.model_rx = None;
911
+ self.available_models.clear();
912
+ self.available_models_provider.clear();
712
913
  save_state(&self.root, &self.state)?;
713
914
  self.write_text_output(&format!(
714
915
  "AI provider: {}\n{}",
@@ -717,14 +918,21 @@ impl PracticodeApp {
717
918
  ));
718
919
  }
719
920
  "model" if arg.is_empty() => {
720
- self.write_text_output(&format!("AI model: {}", self.state.settings.ai_model));
921
+ self.write_text_output(&self.model_status_text());
721
922
  }
722
923
  "model" => {
723
- self.state.settings.ai_model = arg.to_string();
924
+ self.state.settings.ai_model = if arg == "auto" {
925
+ "auto".to_string()
926
+ } else {
927
+ arg.to_string()
928
+ };
724
929
  save_state(&self.root, &self.state)?;
725
- self.write_text_output(&format!("AI model: {arg}"));
930
+ self.write_text_output(&self.model_status_text());
726
931
  }
727
- "ai" if !arg.is_empty() => self.start_ai_prompt(arg)?,
932
+ "hint" if arg.is_empty() => {
933
+ self.start_ai_prompt("Give one concise hint for the current problem.")?
934
+ }
935
+ "hint" | "ask" | "ai" if !arg.is_empty() => self.start_ai_prompt(arg)?,
728
936
  "note" if !arg.is_empty() => self.append_note(arg)?,
729
937
  "note" | "notes" => self.show_notes()?,
730
938
  "update" => self.show_update_notice(),
@@ -971,6 +1179,56 @@ impl PracticodeApp {
971
1179
  self.update_rx = Some(rx);
972
1180
  }
973
1181
 
1182
+ fn start_model_check(&mut self) {
1183
+ let provider = self.state.settings.ai_provider.clone();
1184
+ if self.model_rx.is_some() || self.available_models_provider == provider {
1185
+ return;
1186
+ }
1187
+ let query_provider = provider.clone();
1188
+ let (tx, rx) = mpsc::channel();
1189
+ thread::spawn(move || {
1190
+ let _ = tx.send(available_models(&query_provider));
1191
+ });
1192
+ self.available_models_provider = provider;
1193
+ self.model_rx = Some(rx);
1194
+ }
1195
+
1196
+ fn check_models(&mut self) {
1197
+ let models = self.model_rx.as_ref().and_then(|rx| rx.try_recv().ok());
1198
+ if let Some(models) = models {
1199
+ self.model_rx = None;
1200
+ self.available_models = models;
1201
+ }
1202
+ }
1203
+
1204
+ fn model_status_text(&self) -> String {
1205
+ let mut lines = vec![
1206
+ format!(
1207
+ "AI model: {}",
1208
+ if self.state.settings.ai_model == "auto" {
1209
+ "auto (provider default)"
1210
+ } else {
1211
+ self.state.settings.ai_model.as_str()
1212
+ }
1213
+ ),
1214
+ "Use /model auto to let the provider choose its default.".to_string(),
1215
+ ];
1216
+ if self.available_models.is_empty() {
1217
+ lines.push(
1218
+ "Provider model list is unavailable; use /model <name> for a known model."
1219
+ .to_string(),
1220
+ );
1221
+ } else {
1222
+ lines.push("Available models:".to_string());
1223
+ lines.extend(
1224
+ self.available_models
1225
+ .iter()
1226
+ .map(|model| format!("- /model {model}")),
1227
+ );
1228
+ }
1229
+ lines.join("\n")
1230
+ }
1231
+
974
1232
  fn start_busy(&mut self, label: &str, body: &str) {
975
1233
  self.busy_label = label.to_string();
976
1234
  self.busy_body = body.to_string();
@@ -1064,7 +1322,7 @@ impl PracticodeApp {
1064
1322
  self.normalize_command_input();
1065
1323
  }
1066
1324
 
1067
- fn command_suggestions(&self) -> Vec<&'static CommandHint> {
1325
+ fn command_suggestions(&self) -> Vec<CommandChoice> {
1068
1326
  if self.focus != Focus::Command {
1069
1327
  return Vec::new();
1070
1328
  }
@@ -1072,13 +1330,39 @@ impl PracticodeApp {
1072
1330
  return Vec::new();
1073
1331
  };
1074
1332
  let query = query.to_lowercase();
1075
- COMMAND_HINTS
1076
- .iter()
1333
+ self.command_choices()
1334
+ .into_iter()
1077
1335
  .filter(|hint| hint.insert.starts_with(query.trim_start()))
1078
- .take(7)
1079
1336
  .collect()
1080
1337
  }
1081
1338
 
1339
+ fn command_choices(&self) -> Vec<CommandChoice> {
1340
+ let mut choices = Vec::new();
1341
+ for hint in COMMAND_HINTS {
1342
+ if hint.insert == "model " {
1343
+ for model in self
1344
+ .available_models
1345
+ .iter()
1346
+ .filter(|model| *model != "auto")
1347
+ {
1348
+ choices.push(CommandChoice {
1349
+ insert: format!("model {model}"),
1350
+ display: format!("/model {model}"),
1351
+ desc_key: "cmd_model_available",
1352
+ keep_open: false,
1353
+ });
1354
+ }
1355
+ }
1356
+ choices.push(CommandChoice {
1357
+ insert: hint.insert.to_string(),
1358
+ display: hint.display.to_string(),
1359
+ desc_key: hint.desc_key,
1360
+ keep_open: hint.keep_open,
1361
+ });
1362
+ }
1363
+ choices
1364
+ }
1365
+
1082
1366
  fn move_command_palette(&mut self, delta: isize) {
1083
1367
  let len = self.command_suggestions().len();
1084
1368
  if len == 0 {
@@ -1093,19 +1377,19 @@ impl PracticodeApp {
1093
1377
  if suggestions.is_empty() {
1094
1378
  return Ok(false);
1095
1379
  }
1096
- let hint = suggestions[self.command_palette_cursor.min(suggestions.len() - 1)];
1380
+ let hint = &suggestions[self.command_palette_cursor.min(suggestions.len() - 1)];
1097
1381
  if hint.keep_open {
1098
1382
  self.command = format!("/{}", hint.insert);
1099
1383
  self.command_cursor = char_len(&self.command);
1100
1384
  self.command_palette_cursor = 0;
1101
1385
  return Ok(true);
1102
1386
  }
1103
- let value = hint.insert;
1387
+ let value = hint.insert.clone();
1104
1388
  self.command.clear();
1105
1389
  self.command_cursor = 0;
1106
1390
  self.command_palette_cursor = 0;
1107
1391
  self.focus = Focus::None;
1108
- self.submit_command(value)?;
1392
+ self.submit_command(&value)?;
1109
1393
  Ok(true)
1110
1394
  }
1111
1395