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 +1 -1
- package/Cargo.toml +1 -1
- package/README.md +7 -4
- package/assets/i18n/en.json +4 -0
- package/assets/i18n/es.json +4 -0
- package/assets/i18n/ja.json +4 -0
- package/assets/i18n/ko.json +4 -0
- package/assets/i18n/zh.json +4 -0
- package/docs/CONTRIBUTING.md +65 -25
- package/docs/MAINTAINING.md +67 -0
- package/package.json +1 -1
- package/src/ai.rs +106 -0
- package/src/tui.rs +363 -38
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
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
|
-
| `/
|
|
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` |
|
|
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
|
-
##
|
|
137
|
+
## Contributing
|
|
137
138
|
|
|
138
|
-
|
|
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
|
|
package/assets/i18n/en.json
CHANGED
|
@@ -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",
|
package/assets/i18n/es.json
CHANGED
|
@@ -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",
|
package/assets/i18n/ja.json
CHANGED
|
@@ -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": "コード言語を設定",
|
package/assets/i18n/ko.json
CHANGED
|
@@ -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": "코드 언어 설정",
|
package/assets/i18n/zh.json
CHANGED
|
@@ -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": "设置代码语言",
|
package/docs/CONTRIBUTING.md
CHANGED
|
@@ -1,14 +1,54 @@
|
|
|
1
1
|
# Contributing
|
|
2
2
|
|
|
3
|
-
This
|
|
3
|
+
Thanks for helping improve practicode. This guide is for contributors opening issues or pull requests.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
##
|
|
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
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::{
|
|
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
|
-
|
|
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: "
|
|
93
|
-
display: "/
|
|
94
|
-
desc_key: "
|
|
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: "
|
|
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: "
|
|
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(
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
.
|
|
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(
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
1076
|
-
.
|
|
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
|
|