practicode 0.1.2 → 0.1.4

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.4"
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.4"
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.4",
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,6 +108,20 @@ pub fn provider_status(provider: &str) -> String {
102
108
  }
103
109
  }
104
110
 
111
+ pub fn available_models(provider: &str) -> ModelCatalog {
112
+ match normalize_ai_provider(provider).as_str() {
113
+ "codex" => codex_models(),
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(),
122
+ }
123
+ }
124
+
105
125
  pub fn default_ai_next_prompt(request: &str) -> String {
106
126
  format!(
107
127
  "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 +152,80 @@ pub fn read_problem_notes(root: &Path) -> Result<String> {
132
152
  }
133
153
  }
134
154
 
155
+ fn codex_models() -> ModelCatalog {
156
+ if which("codex").is_none() {
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
+ };
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));
173
+ let mut command = Command::new("codex");
174
+ command.args(["app-server", "proxy"]);
175
+ let input = r#"{"id":1,"method":"model/list","params":{"limit":25}}"#;
176
+ let Ok(run) = run_capture(&mut command, &format!("{input}\n"), Duration::from_secs(2)) else {
177
+ return ModelCatalog {
178
+ models: Vec::new(),
179
+ message: Some("Could not query Codex model list.".to_string()),
180
+ };
181
+ };
182
+ if run.code != Some(0) {
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
+ }
204
+ }
205
+ }
206
+
207
+ fn parse_model_list(output: &str) -> Vec<String> {
208
+ output
209
+ .lines()
210
+ .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
211
+ .flat_map(|value| {
212
+ value
213
+ .pointer("/result/data")
214
+ .or_else(|| value.get("data"))
215
+ .and_then(|data| data.as_array())
216
+ .cloned()
217
+ .unwrap_or_default()
218
+ })
219
+ .filter_map(|model| {
220
+ model
221
+ .get("model")
222
+ .or_else(|| model.get("id"))
223
+ .and_then(|value| value.as_str())
224
+ .map(str::to_string)
225
+ })
226
+ .collect()
227
+ }
228
+
135
229
  fn run_codex_prompt(root: &Path, settings: &Settings, prompt: &str) -> String {
136
230
  let output_path = unique_temp_path("practicode-last-message", "txt");
137
231
  let mut command = Command::new("codex");
@@ -243,3 +337,15 @@ fn output_text(stdout: &str, stderr: &str) -> String {
243
337
  .collect::<Vec<_>>()
244
338
  .join("\n")
245
339
  }
340
+
341
+ #[cfg(test)]
342
+ mod tests {
343
+ use super::parse_model_list;
344
+
345
+ #[test]
346
+ fn parses_codex_model_list_response() {
347
+ let output =
348
+ r#"{"id":1,"result":{"data":[{"model":"gpt-test","displayName":"GPT Test"}]}}"#;
349
+ assert_eq!(parse_model_list(output), vec!["gpt-test"]);
350
+ }
351
+ }
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
+ ModelCatalog, append_problem_note, available_models, provider_status, read_problem_notes,
4
+ run_ai_next, 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
  },
@@ -270,6 +282,7 @@ pub struct PracticodeApp {
270
282
  command_palette_cursor: usize,
271
283
  output: String,
272
284
  output_is_markdown: bool,
285
+ showing_model_status: bool,
273
286
  show_output: bool,
274
287
  focus: Focus,
275
288
  list_cursor: Option<usize>,
@@ -278,6 +291,10 @@ pub struct PracticodeApp {
278
291
  busy_frame: usize,
279
292
  task_rx: Option<Receiver<TaskResult>>,
280
293
  update_rx: Option<Receiver<UpdateCheck>>,
294
+ model_rx: Option<Receiver<ModelCatalog>>,
295
+ available_models: Vec<String>,
296
+ available_models_provider: String,
297
+ model_message: Option<String>,
281
298
  update_check: Option<UpdateCheck>,
282
299
  update_notice: Option<String>,
283
300
  should_quit: bool,
@@ -310,6 +327,7 @@ impl PracticodeApp {
310
327
  command_palette_cursor: 0,
311
328
  output: String::new(),
312
329
  output_is_markdown: false,
330
+ showing_model_status: false,
313
331
  show_output: false,
314
332
  focus: Focus::Code,
315
333
  list_cursor: None,
@@ -318,6 +336,10 @@ impl PracticodeApp {
318
336
  busy_frame: 0,
319
337
  task_rx: None,
320
338
  update_rx: None,
339
+ model_rx: None,
340
+ available_models: Vec::new(),
341
+ available_models_provider: String::new(),
342
+ model_message: None,
321
343
  update_check: None,
322
344
  update_notice: None,
323
345
  should_quit: false,
@@ -328,10 +350,13 @@ impl PracticodeApp {
328
350
 
329
351
  pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
330
352
  self.start_update_check();
353
+ self.start_model_check();
331
354
  while !self.should_quit {
332
355
  terminal.draw(|frame| self.draw(frame))?;
333
356
  self.check_task();
334
357
  self.check_update();
358
+ self.start_model_check();
359
+ self.check_models();
335
360
  if event::poll(Duration::from_millis(100))?
336
361
  && let Event::Key(key) = event::read()?
337
362
  && key.kind != KeyEventKind::Release
@@ -382,6 +407,33 @@ impl PracticodeApp {
382
407
  self.status_text()
383
408
  }
384
409
 
410
+ pub fn output_for_test(&self) -> &str {
411
+ &self.output
412
+ }
413
+
414
+ pub fn command_suggestions_for_test(&self) -> Vec<String> {
415
+ self.command_suggestions()
416
+ .into_iter()
417
+ .map(|choice| choice.display)
418
+ .collect()
419
+ }
420
+
421
+ pub fn set_available_models_for_test(&mut self, models: Vec<&str>) {
422
+ self.available_models = models.into_iter().map(str::to_string).collect();
423
+ self.available_models_provider = self.state.settings.ai_provider.clone();
424
+ self.model_message = None;
425
+ }
426
+
427
+ pub fn set_model_message_for_test(&mut self, message: &str) {
428
+ self.available_models.clear();
429
+ self.available_models_provider = self.state.settings.ai_provider.clone();
430
+ self.model_message = Some(message.to_string());
431
+ }
432
+
433
+ pub fn pane_title_for_test(title: &str, active: bool) -> String {
434
+ Self::pane_title(title, active)
435
+ }
436
+
385
437
  fn draw(&mut self, frame: &mut Frame) {
386
438
  let size = frame.area();
387
439
  let vertical = Layout::default()
@@ -397,15 +449,13 @@ impl PracticodeApp {
397
449
  .constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
398
450
  .split(vertical[0]);
399
451
 
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 });
452
+ let problem = Paragraph::new(self.problem_text())
453
+ .block(Self::block(
454
+ ui_text(&self.state.settings.ui_language, "problem"),
455
+ self.state.settings.theme == "light",
456
+ false,
457
+ ))
458
+ .wrap(Wrap { trim: false });
409
459
  frame.render_widget(problem, body[0]);
410
460
 
411
461
  if self.show_output {
@@ -420,6 +470,7 @@ impl PracticodeApp {
420
470
  .block(Self::block(
421
471
  ui_text(&self.state.settings.ui_language, "output"),
422
472
  self.state.settings.theme == "light",
473
+ self.focus != Focus::Command,
423
474
  ))
424
475
  .wrap(Wrap { trim: false });
425
476
  frame.render_widget(output, body[1]);
@@ -428,8 +479,11 @@ impl PracticodeApp {
428
479
  .editor
429
480
  .visible_text(body[1].height.saturating_sub(2) as usize);
430
481
  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"));
482
+ let code = Paragraph::new(code).block(Self::block(
483
+ &title,
484
+ self.state.settings.theme == "light",
485
+ self.focus == Focus::Code,
486
+ ));
433
487
  frame.render_widget(code, body[1]);
434
488
  }
435
489
 
@@ -456,6 +510,7 @@ impl PracticodeApp {
456
510
  .block(Self::block(
457
511
  ui_text(&self.state.settings.ui_language, "command"),
458
512
  self.state.settings.theme == "light",
513
+ self.focus == Focus::Command,
459
514
  ))
460
515
  .wrap(Wrap { trim: false });
461
516
  frame.render_widget(command, vertical[2]);
@@ -463,12 +518,147 @@ impl PracticodeApp {
463
518
  self.set_terminal_cursor(frame, body[1], vertical[2]);
464
519
  }
465
520
 
521
+ fn problem_text(&self) -> Text<'static> {
522
+ let lang = normalize_ui_language(&self.state.settings.ui_language);
523
+ let light = self.state.settings.theme == "light";
524
+ let title_style = if light {
525
+ Style::default()
526
+ .fg(Color::Blue)
527
+ .add_modifier(Modifier::BOLD)
528
+ } else {
529
+ Style::default()
530
+ .fg(Color::Yellow)
531
+ .add_modifier(Modifier::BOLD)
532
+ };
533
+ let section_style = if light {
534
+ Style::default()
535
+ .fg(Color::Magenta)
536
+ .add_modifier(Modifier::BOLD)
537
+ } else {
538
+ Style::default()
539
+ .fg(Color::Cyan)
540
+ .add_modifier(Modifier::BOLD)
541
+ };
542
+ let body_style = if light {
543
+ Style::default().fg(Color::Black)
544
+ } else {
545
+ Style::default().fg(Color::Rgb(229, 231, 235))
546
+ };
547
+ let meta_style = if light {
548
+ Style::default().fg(Color::Rgb(75, 85, 99))
549
+ } else {
550
+ Style::default().fg(Color::Rgb(156, 163, 175))
551
+ };
552
+ let code_style = if light {
553
+ Style::default()
554
+ .fg(Color::Black)
555
+ .bg(Color::Rgb(229, 231, 235))
556
+ } else {
557
+ Style::default()
558
+ .fg(Color::Rgb(243, 244, 246))
559
+ .bg(Color::Rgb(31, 41, 55))
560
+ };
561
+ let number = self
562
+ .problem
563
+ .id
564
+ .split_once('-')
565
+ .map(|(number, _)| number)
566
+ .unwrap_or(&self.problem.id);
567
+ let mut lines = vec![
568
+ Line::from(Span::styled(
569
+ format!("{number}. {}", localized(&self.problem.title, &lang)),
570
+ title_style,
571
+ )),
572
+ Line::from(Span::styled(
573
+ format!(
574
+ "{}: {} {}: {}",
575
+ ui_text(&lang, "difficulty"),
576
+ self.problem.difficulty,
577
+ ui_text(&lang, "topics"),
578
+ self.problem.topics.join(", ")
579
+ ),
580
+ meta_style,
581
+ )),
582
+ ];
583
+ lines.push(Line::default());
584
+ for line in localized(&self.problem.statement, &lang).trim_end().lines() {
585
+ lines.push(Line::from(Span::styled(line.to_string(), body_style)));
586
+ }
587
+ Self::push_problem_section(
588
+ &mut lines,
589
+ ui_text(&lang, "input"),
590
+ &localized(&self.problem.input, &lang),
591
+ section_style,
592
+ body_style,
593
+ );
594
+ Self::push_problem_section(
595
+ &mut lines,
596
+ ui_text(&lang, "output"),
597
+ &localized(&self.problem.output, &lang),
598
+ section_style,
599
+ body_style,
600
+ );
601
+ lines.push(Line::default());
602
+ lines.push(Line::from(Span::styled(
603
+ ui_text(&lang, "examples").to_string(),
604
+ section_style,
605
+ )));
606
+ for (index, case) in self.problem.examples.iter().enumerate() {
607
+ lines.push(Line::from(Span::styled(
608
+ format!(" {} {}", ui_text(&lang, "example"), index + 1),
609
+ meta_style.add_modifier(Modifier::BOLD),
610
+ )));
611
+ lines.push(Line::from(Span::styled(
612
+ format!(" {}", ui_text(&lang, "input")),
613
+ meta_style,
614
+ )));
615
+ Self::push_code_lines(&mut lines, &case.input, code_style);
616
+ lines.push(Line::from(Span::styled(
617
+ format!(" {}", ui_text(&lang, "output")),
618
+ meta_style,
619
+ )));
620
+ Self::push_code_lines(&mut lines, &case.output, code_style);
621
+ }
622
+ Text::from(lines)
623
+ }
624
+
625
+ fn push_problem_section(
626
+ lines: &mut Vec<Line<'static>>,
627
+ title: &str,
628
+ body: &str,
629
+ section_style: Style,
630
+ body_style: Style,
631
+ ) {
632
+ lines.push(Line::default());
633
+ lines.push(Line::from(Span::styled(title.to_string(), section_style)));
634
+ for line in body.trim_end().lines() {
635
+ lines.push(Line::from(Span::styled(format!(" {line}"), body_style)));
636
+ }
637
+ }
638
+
639
+ fn push_code_lines(lines: &mut Vec<Line<'static>>, body: &str, code_style: Style) {
640
+ let body = body.trim_end();
641
+ if body.is_empty() {
642
+ lines.push(Line::from(vec![
643
+ Span::raw(" "),
644
+ Span::styled("<empty>".to_string(), code_style),
645
+ ]));
646
+ return;
647
+ }
648
+ for line in body.lines() {
649
+ lines.push(Line::from(vec![
650
+ Span::raw(" "),
651
+ Span::styled(line.to_string(), code_style),
652
+ ]));
653
+ }
654
+ }
655
+
466
656
  fn draw_command_palette(&self, frame: &mut Frame, command_area: Rect) {
467
657
  let suggestions = self.command_suggestions();
468
658
  if suggestions.is_empty() || command_area.y < 3 {
469
659
  return;
470
660
  }
471
- let height = ((suggestions.len() + 3) as u16).min(10).min(command_area.y);
661
+ let height = ((suggestions.len() + 3) as u16).min(14).min(command_area.y);
472
662
  let area = Rect::new(
473
663
  command_area.x,
474
664
  command_area.y - height,
@@ -476,10 +666,13 @@ impl PracticodeApp {
476
666
  height,
477
667
  );
478
668
  let selected = self.command_palette_cursor.min(suggestions.len() - 1);
669
+ let visible = height.saturating_sub(2) as usize;
670
+ let start = selected.saturating_sub(visible.saturating_sub(1));
479
671
  let mut lines = suggestions
480
672
  .iter()
481
673
  .enumerate()
482
- .take(height.saturating_sub(2) as usize)
674
+ .skip(start)
675
+ .take(visible)
483
676
  .map(|(index, hint)| {
484
677
  let marker = if index == selected { ">" } else { " " };
485
678
  format!(
@@ -495,20 +688,40 @@ impl PracticodeApp {
495
688
  Paragraph::new(lines.join("\n")).block(Self::block(
496
689
  ui_text(&self.state.settings.ui_language, "commands"),
497
690
  self.state.settings.theme == "light",
691
+ true,
498
692
  )),
499
693
  area,
500
694
  );
501
695
  }
502
696
 
503
- fn block(title: &str, light: bool) -> Block<'_> {
697
+ fn block(title: &str, light: bool, active: bool) -> Block<'static> {
698
+ let border = if active {
699
+ if light {
700
+ Style::default()
701
+ .fg(Color::Magenta)
702
+ .add_modifier(Modifier::BOLD)
703
+ } else {
704
+ Style::default()
705
+ .fg(Color::Yellow)
706
+ .add_modifier(Modifier::BOLD)
707
+ }
708
+ } else if light {
709
+ Style::default().fg(Color::Blue)
710
+ } else {
711
+ Style::default().fg(Color::Cyan)
712
+ };
504
713
  Block::default()
505
714
  .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
- })
715
+ .title(Self::pane_title(title, active))
716
+ .border_style(border)
717
+ }
718
+
719
+ fn pane_title(title: &str, active: bool) -> String {
720
+ if active {
721
+ format!("> {title}")
722
+ } else {
723
+ title.to_string()
724
+ }
512
725
  }
513
726
 
514
727
  fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
@@ -709,6 +922,10 @@ impl PracticodeApp {
709
922
  }
710
923
  "provider" | "ai-provider" if AI_PROVIDERS.contains(&arg) => {
711
924
  self.state.settings.ai_provider = normalize_ai_provider(arg);
925
+ self.model_rx = None;
926
+ self.available_models.clear();
927
+ self.available_models_provider.clear();
928
+ self.model_message = None;
712
929
  save_state(&self.root, &self.state)?;
713
930
  self.write_text_output(&format!(
714
931
  "AI provider: {}\n{}",
@@ -717,14 +934,25 @@ impl PracticodeApp {
717
934
  ));
718
935
  }
719
936
  "model" if arg.is_empty() => {
720
- self.write_text_output(&format!("AI model: {}", self.state.settings.ai_model));
937
+ self.start_model_check();
938
+ self.check_models();
939
+ self.write_model_status();
721
940
  }
722
941
  "model" => {
723
- self.state.settings.ai_model = arg.to_string();
942
+ self.state.settings.ai_model = if arg == "auto" {
943
+ "auto".to_string()
944
+ } else {
945
+ arg.to_string()
946
+ };
724
947
  save_state(&self.root, &self.state)?;
725
- self.write_text_output(&format!("AI model: {arg}"));
948
+ self.start_model_check();
949
+ self.check_models();
950
+ self.write_model_status();
951
+ }
952
+ "hint" if arg.is_empty() => {
953
+ self.start_ai_prompt("Give one concise hint for the current problem.")?
726
954
  }
727
- "ai" if !arg.is_empty() => self.start_ai_prompt(arg)?,
955
+ "hint" | "ask" | "ai" if !arg.is_empty() => self.start_ai_prompt(arg)?,
728
956
  "note" if !arg.is_empty() => self.append_note(arg)?,
729
957
  "note" | "notes" => self.show_notes()?,
730
958
  "update" => self.show_update_notice(),
@@ -971,6 +1199,67 @@ impl PracticodeApp {
971
1199
  self.update_rx = Some(rx);
972
1200
  }
973
1201
 
1202
+ fn start_model_check(&mut self) {
1203
+ let provider = self.state.settings.ai_provider.clone();
1204
+ if self.model_rx.is_some() || self.available_models_provider == provider {
1205
+ return;
1206
+ }
1207
+ let query_provider = provider.clone();
1208
+ let (tx, rx) = mpsc::channel();
1209
+ thread::spawn(move || {
1210
+ let _ = tx.send(available_models(&query_provider));
1211
+ });
1212
+ self.available_models_provider = provider;
1213
+ self.model_rx = Some(rx);
1214
+ }
1215
+
1216
+ fn check_models(&mut self) {
1217
+ let models = self.model_rx.as_ref().and_then(|rx| rx.try_recv().ok());
1218
+ if let Some(catalog) = models {
1219
+ self.model_rx = None;
1220
+ self.available_models = catalog.models;
1221
+ self.model_message = catalog.message;
1222
+ if self.showing_model_status {
1223
+ self.output = self.model_status_text();
1224
+ self.output_is_markdown = false;
1225
+ self.show_output = true;
1226
+ }
1227
+ }
1228
+ }
1229
+
1230
+ fn model_status_text(&self) -> String {
1231
+ let mut lines = vec![
1232
+ format!("AI provider: {}", self.state.settings.ai_provider),
1233
+ format!(
1234
+ "AI model: {}",
1235
+ if self.state.settings.ai_model == "auto" {
1236
+ "auto (provider default)"
1237
+ } else {
1238
+ self.state.settings.ai_model.as_str()
1239
+ }
1240
+ ),
1241
+ "Use /model auto to let the provider choose its default.".to_string(),
1242
+ ];
1243
+ if self.model_rx.is_some() {
1244
+ lines.push("Loading provider model list...".to_string());
1245
+ } else if self.available_models.is_empty() {
1246
+ lines.push(
1247
+ self.model_message
1248
+ .clone()
1249
+ .unwrap_or_else(|| "Provider model list is unavailable.".to_string()),
1250
+ );
1251
+ lines.push("Use /model <name> for a known model.".to_string());
1252
+ } else {
1253
+ lines.push("Available models:".to_string());
1254
+ lines.extend(
1255
+ self.available_models
1256
+ .iter()
1257
+ .map(|model| format!("- /model {model}")),
1258
+ );
1259
+ }
1260
+ lines.join("\n")
1261
+ }
1262
+
974
1263
  fn start_busy(&mut self, label: &str, body: &str) {
975
1264
  self.busy_label = label.to_string();
976
1265
  self.busy_body = body.to_string();
@@ -986,6 +1275,7 @@ impl PracticodeApp {
986
1275
  }
987
1276
 
988
1277
  fn write_output(&mut self, output: &str) {
1278
+ self.showing_model_status = false;
989
1279
  self.output = output.to_string();
990
1280
  self.output_is_markdown = true;
991
1281
  self.show_output = true;
@@ -993,12 +1283,21 @@ impl PracticodeApp {
993
1283
  }
994
1284
 
995
1285
  fn write_text_output(&mut self, output: &str) {
1286
+ self.showing_model_status = false;
996
1287
  self.output = output.trim_end().to_string();
997
1288
  self.output_is_markdown = false;
998
1289
  self.show_output = true;
999
1290
  self.focus = Focus::Output;
1000
1291
  }
1001
1292
 
1293
+ fn write_model_status(&mut self) {
1294
+ self.output = self.model_status_text();
1295
+ self.output_is_markdown = false;
1296
+ self.showing_model_status = true;
1297
+ self.show_output = true;
1298
+ self.focus = Focus::Output;
1299
+ }
1300
+
1002
1301
  fn show_update_notice(&mut self) {
1003
1302
  let lang = self.state.settings.ui_language.clone();
1004
1303
  if let Some(version) = &self.update_notice {
@@ -1064,7 +1363,7 @@ impl PracticodeApp {
1064
1363
  self.normalize_command_input();
1065
1364
  }
1066
1365
 
1067
- fn command_suggestions(&self) -> Vec<&'static CommandHint> {
1366
+ fn command_suggestions(&self) -> Vec<CommandChoice> {
1068
1367
  if self.focus != Focus::Command {
1069
1368
  return Vec::new();
1070
1369
  }
@@ -1072,13 +1371,39 @@ impl PracticodeApp {
1072
1371
  return Vec::new();
1073
1372
  };
1074
1373
  let query = query.to_lowercase();
1075
- COMMAND_HINTS
1076
- .iter()
1374
+ self.command_choices()
1375
+ .into_iter()
1077
1376
  .filter(|hint| hint.insert.starts_with(query.trim_start()))
1078
- .take(7)
1079
1377
  .collect()
1080
1378
  }
1081
1379
 
1380
+ fn command_choices(&self) -> Vec<CommandChoice> {
1381
+ let mut choices = Vec::new();
1382
+ for hint in COMMAND_HINTS {
1383
+ if hint.insert == "model " {
1384
+ for model in self
1385
+ .available_models
1386
+ .iter()
1387
+ .filter(|model| *model != "auto")
1388
+ {
1389
+ choices.push(CommandChoice {
1390
+ insert: format!("model {model}"),
1391
+ display: format!("/model {model}"),
1392
+ desc_key: "cmd_model_available",
1393
+ keep_open: false,
1394
+ });
1395
+ }
1396
+ }
1397
+ choices.push(CommandChoice {
1398
+ insert: hint.insert.to_string(),
1399
+ display: hint.display.to_string(),
1400
+ desc_key: hint.desc_key,
1401
+ keep_open: hint.keep_open,
1402
+ });
1403
+ }
1404
+ choices
1405
+ }
1406
+
1082
1407
  fn move_command_palette(&mut self, delta: isize) {
1083
1408
  let len = self.command_suggestions().len();
1084
1409
  if len == 0 {
@@ -1093,19 +1418,19 @@ impl PracticodeApp {
1093
1418
  if suggestions.is_empty() {
1094
1419
  return Ok(false);
1095
1420
  }
1096
- let hint = suggestions[self.command_palette_cursor.min(suggestions.len() - 1)];
1421
+ let hint = &suggestions[self.command_palette_cursor.min(suggestions.len() - 1)];
1097
1422
  if hint.keep_open {
1098
1423
  self.command = format!("/{}", hint.insert);
1099
1424
  self.command_cursor = char_len(&self.command);
1100
1425
  self.command_palette_cursor = 0;
1101
1426
  return Ok(true);
1102
1427
  }
1103
- let value = hint.insert;
1428
+ let value = hint.insert.clone();
1104
1429
  self.command.clear();
1105
1430
  self.command_cursor = 0;
1106
1431
  self.command_palette_cursor = 0;
1107
1432
  self.focus = Focus::None;
1108
- self.submit_command(value)?;
1433
+ self.submit_command(&value)?;
1109
1434
  Ok(true)
1110
1435
  }
1111
1436