practicode 0.1.5 → 0.1.6

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.5"
360
+ version = "0.1.6"
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.5"
3
+ version = "0.1.6"
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
@@ -66,24 +66,30 @@ Submissions are saved as you type under `submissions/<problem-id>/solution.<ext>
66
66
  | `/code` | Return to the code editor |
67
67
  | `/next` | Open the next local problem, or ask AI to create one |
68
68
  | `/next easy string problem` | Ask AI for a custom next problem |
69
- | `/prev` | Go back through problem history |
70
- | `/list` | Browse problems with `up/down` or `j/k`, open with `Enter` |
69
+ | `/back` | Go back through problem history |
70
+ | `/problems` | Browse problems with `up/down` or `j/k`, open with `Enter` |
71
71
  | `/open 2` | Open by number, id, or slug |
72
- | `/giveup` | Show the reference answer |
72
+ | `/answer` | Show the reference answer |
73
73
  | `/hint` | Ask the selected AI for a concise hint |
74
74
  | `/hint explain my bug` | Ask the selected AI about the current problem and submission |
75
+ | `/profile` | Show your current practice profile |
76
+ | `/difficulty auto` | Set difficulty preference: `auto`, `easy`, `medium`, `hard` |
77
+ | `/topics arrays, strings` | Set preferred topics for future problems |
78
+ | `/avoid dp, graph` | Set topics to avoid in future problems |
75
79
  | `/provider codex` | Set AI provider and show local CLI/daemon status |
76
80
  | `/model auto` | Use the provider default model for `/hint` and AI-backed `/next` |
77
- | `/note prefer hashmap practice` | Append a standing note for future problem generation |
78
- | `/notes` | Show your local next-problem notes |
79
- | `/lang python` | Set code language: `python`, `ts`, `java`, `rust` |
81
+ | `/language python` | Set code language: `python`, `ts`, `java`, `rust` |
80
82
  | `/ui en` | Set UI language: `en`, `ko`, `ja`, `zh`, `es` |
81
83
  | `/theme dark` | Set theme: `dark` or `light` |
82
84
  | `/update` | Show update instructions when a newer version is available |
83
85
  | `/exit` | Quit |
84
86
 
87
+ Older command names such as `/prev`, `/list`, `/giveup`, and `/lang` still work as aliases.
88
+
85
89
  The default UI language is English. Switch it any time with `/ui ko`, `/ui ja`, `/ui zh`, or `/ui es`.
86
90
 
91
+ Your practice profile is saved in `.practicode/problem-state.json`. It keeps UI language, code language, theme, preferred difficulty, preferred topics, and topics to avoid. `auto` difficulty follows gradual progression; a fixed difficulty asks local selection and AI generation to prefer that level.
92
+
87
93
  ## AI Problems
88
94
 
89
95
  `/next <request>` passes your request into the selected AI problem generator.
@@ -139,6 +145,8 @@ External contributions use the fork and pull request flow in [docs/CONTRIBUTING.
139
145
 
140
146
  Maintainer-only review and release notes live in [docs/MAINTAINING.md](docs/MAINTAINING.md).
141
147
 
148
+ Code layout and extension boundaries live in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
149
+
142
150
  ## License
143
151
 
144
152
  practicode is MIT licensed. Third-party dependency license notes are in [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md).
@@ -32,6 +32,10 @@
32
32
  "cmd_giveup": "Show the reference answer",
33
33
  "cmd_hint": "Ask for a hint about the current problem",
34
34
  "cmd_ai": "Ask AI about the current problem and code",
35
+ "cmd_profile": "Show practice profile",
36
+ "cmd_difficulty": "Set preferred difficulty",
37
+ "cmd_topics": "Set preferred topics",
38
+ "cmd_avoid": "Set topics to avoid",
35
39
  "cmd_provider": "Set AI provider",
36
40
  "cmd_model": "Set AI model",
37
41
  "cmd_model_auto": "Use provider default model",
@@ -32,6 +32,10 @@
32
32
  "cmd_giveup": "Mostrar la respuesta de referencia",
33
33
  "cmd_hint": "Pedir una pista para el problema actual",
34
34
  "cmd_ai": "Preguntar a AI sobre el problema y codigo actuales",
35
+ "cmd_profile": "Mostrar perfil de practica",
36
+ "cmd_difficulty": "Configurar dificultad preferida",
37
+ "cmd_topics": "Configurar temas preferidos",
38
+ "cmd_avoid": "Configurar temas a evitar",
35
39
  "cmd_provider": "Configurar AI provider",
36
40
  "cmd_model": "Configurar AI model",
37
41
  "cmd_model_auto": "Usar el modelo predeterminado del provider",
@@ -32,6 +32,10 @@
32
32
  "cmd_giveup": "解答を見る",
33
33
  "cmd_hint": "現在の問題のヒントを依頼",
34
34
  "cmd_ai": "現在の問題とコードについて AI に質問",
35
+ "cmd_profile": "練習プロファイルを表示",
36
+ "cmd_difficulty": "希望難易度を設定",
37
+ "cmd_topics": "希望トピックを設定",
38
+ "cmd_avoid": "避けるトピックを設定",
35
39
  "cmd_provider": "AI provider を設定",
36
40
  "cmd_model": "AI model を設定",
37
41
  "cmd_model_auto": "provider の既定モデルを使用",
@@ -32,6 +32,10 @@
32
32
  "cmd_giveup": "정답 보기",
33
33
  "cmd_hint": "현재 문제 힌트 요청",
34
34
  "cmd_ai": "현재 문제와 코드에 대해 AI에게 질문",
35
+ "cmd_profile": "연습 프로파일 보기",
36
+ "cmd_difficulty": "선호 난이도 설정",
37
+ "cmd_topics": "선호 주제 설정",
38
+ "cmd_avoid": "피할 주제 설정",
35
39
  "cmd_provider": "AI provider 설정",
36
40
  "cmd_model": "AI model 설정",
37
41
  "cmd_model_auto": "provider 기본 모델 사용",
@@ -32,6 +32,10 @@
32
32
  "cmd_giveup": "显示参考答案",
33
33
  "cmd_hint": "请求当前题目的提示",
34
34
  "cmd_ai": "向 AI 询问当前题目和代码",
35
+ "cmd_profile": "显示练习配置",
36
+ "cmd_difficulty": "设置偏好难度",
37
+ "cmd_topics": "设置偏好主题",
38
+ "cmd_avoid": "设置要避开的主题",
35
39
  "cmd_provider": "设置 AI provider",
36
40
  "cmd_model": "设置 AI model",
37
41
  "cmd_model_auto": "使用 provider 默认模型",
@@ -1,24 +1,36 @@
1
1
  <svg width="1200" height="720" viewBox="0 0 1200 720" fill="none" xmlns="http://www.w3.org/2000/svg">
2
2
  <rect width="1200" height="720" fill="#070b12"/>
3
- <rect x="64" y="52" width="1072" height="616" rx="10" fill="#0b111a" stroke="#1f6f7a" stroke-width="2"/>
4
- <rect x="88" y="78" width="594" height="440" rx="4" fill="#0f1720" stroke="#00c2d1" stroke-width="2"/>
5
- <rect x="704" y="78" width="408" height="440" rx="4" fill="#0d141c" stroke="#31536b" stroke-width="2"/>
6
- <text x="112" y="112" fill="#d7e1ec" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="18" font-weight="700">Problem</text>
7
- <text x="728" y="112" fill="#d7e1ec" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="18" font-weight="700">solution.py</text>
8
- <text x="112" y="158" fill="#f8fafc" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="22" font-weight="700">001. Hello World</text>
9
- <text x="112" y="204" fill="#c8d3f5" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="16">Difficulty: easy</text>
10
- <text x="112" y="232" fill="#c8d3f5" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="16">Topics: io</text>
11
- <text x="112" y="286" fill="#f8fafc" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17">Print exactly Hello, World! to stdout.</text>
12
- <text x="112" y="352" fill="#00a8ff" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17" text-decoration="underline">Input</text>
13
- <text x="112" y="386" fill="#f8fafc" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17">No input.</text>
14
- <text x="112" y="442" fill="#00a8ff" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17" text-decoration="underline">Output</text>
15
- <text x="112" y="476" fill="#f6c177" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17">Hello, World!</text>
16
- <text x="728" y="158" fill="#6b7280" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17"> 1</text>
17
- <text x="776" y="158" fill="#7dd3fc" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17">print</text>
18
- <text x="828" y="158" fill="#f8fafc" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17">('Hello, World!')</text>
19
- <rect x="64" y="536" width="1072" height="28" fill="#152033"/>
20
- <text x="84" y="556" fill="#c8d3f5" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="15" font-weight="700">PRACTICODE | 001-hello-world | easy | idle | solved | code:written | python | next:ai | ai:codex/auto</text>
21
- <rect x="64" y="586" width="1072" height="58" rx="2" fill="#0b1017" stroke="#008cff" stroke-width="2"/>
22
- <text x="84" y="606" fill="#c8d3f5" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="15" font-weight="700">Command</text>
23
- <text x="84" y="630" fill="#f8fafc" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="18">/ai hint about edge cases</text>
3
+ <rect x="54" y="42" width="1092" height="636" rx="10" fill="#0b111a" stroke="#214c5c" stroke-width="2"/>
4
+
5
+ <rect x="76" y="68" width="560" height="430" rx="4" fill="#0f1720" stroke="#00c2d1" stroke-width="2"/>
6
+ <rect x="664" y="68" width="458" height="430" rx="4" fill="#0d141c" stroke="#f8e71c" stroke-width="2"/>
7
+
8
+ <text x="92" y="91" fill="#16d6e8" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="15" font-weight="700">Problem</text>
9
+ <text x="680" y="91" fill="#f8e71c" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="15" font-weight="700">&gt; solution.py</text>
10
+
11
+ <text x="96" y="128" fill="#f8fafc" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="22" font-weight="700">001. Hello World</text>
12
+ <text x="96" y="164" fill="#c8d3f5" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="16">Difficulty: easy Topics: io</text>
13
+ <text x="96" y="206" fill="#f8fafc" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17">Print exactly Hello, World! to stdout.</text>
14
+
15
+ <text x="96" y="286" fill="#00d2e8" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17" font-weight="700">Input</text>
16
+ <rect x="96" y="304" width="486" height="44" rx="3" fill="#101d28" stroke="#29495a"/>
17
+ <text x="112" y="332" fill="#f8fafc" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="16">No input.</text>
18
+
19
+ <text x="96" y="388" fill="#00d2e8" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="17" font-weight="700">Output</text>
20
+ <rect x="96" y="406" width="486" height="66" rx="3" fill="#101d28" stroke="#29495a"/>
21
+ <text x="112" y="446" fill="#f6c177" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="16">Hello, World!</text>
22
+
23
+ <text x="690" y="128" fill="#64748b" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="16"> 1</text>
24
+ <text x="738" y="128" fill="#7dd3fc" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="16">print</text>
25
+ <text x="790" y="128" fill="#f8fafc" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="16">('Hello, World!')</text>
26
+
27
+ <rect x="76" y="512" width="1046" height="30" fill="#152033"/>
28
+ <text x="94" y="533" fill="#c8d3f5" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="15" font-weight="700">PRACTICODE | 001-hello-world | easy | idle | assigned | code:written | python | Esc then / command</text>
29
+
30
+ <rect x="76" y="558" width="1046" height="48" rx="2" fill="#0b1017" stroke="#00c2d1" stroke-width="2"/>
31
+ <text x="94" y="579" fill="#16d6e8" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="15" font-weight="700">Command</text>
32
+ <text x="94" y="596" fill="#f8fafc" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="18">/</text>
33
+
34
+ <rect x="76" y="614" width="1046" height="46" rx="2" fill="#101923" stroke="#214c5c"/>
35
+ <text x="94" y="641" fill="#f8fafc" font-family="SFMono-Regular, Menlo, Consolas, monospace" font-size="15">/run /next /hint &lt;request&gt; /profile /difficulty auto /topics arrays, strings</text>
24
36
  </svg>
@@ -0,0 +1,25 @@
1
+ # Architecture
2
+
3
+ Practicode is local-first: user data stays under `.practicode/`, `problems/`, and `submissions/`.
4
+
5
+ ## Source Layout
6
+
7
+ - `src/core.rs` owns problem data, state loading/saving, judging, and file generation.
8
+ - `src/core/profile.rs` owns practice-profile defaults and normalization.
9
+ - `src/tui.rs` owns Ratatui rendering and interaction flow.
10
+ - `src/tui/commands.rs` owns the command palette catalog.
11
+ - `src/ai.rs` owns provider commands, daemon/model checks, and AI prompts.
12
+ - `src/update.rs` owns update checks.
13
+ - `src/text.rs` owns terminal text editing and markdown/plain rendering helpers.
14
+
15
+ ## Extension Rules
16
+
17
+ - Add domain logic under the owning module first; keep `tui.rs` as orchestration and rendering.
18
+ - Add user-visible commands in `src/tui/commands.rs`, then route behavior in `PracticodeApp::handle_command`.
19
+ - Add persisted profile settings to `Settings`, normalize them in `normalize_settings`, and cover old-state compatibility with tests.
20
+ - Keep provider-specific behavior in `src/ai.rs`; TUI should ask for status or start tasks, not know provider internals.
21
+ - Keep local user data backwards-compatible. Missing fields should default cleanly.
22
+
23
+ ## Release
24
+
25
+ See [MAINTAINING.md](MAINTAINING.md) for tag-based releases.
@@ -9,6 +9,7 @@ Maintainer-only review and release steps live in [MAINTAINING.md](MAINTAINING.md
9
9
  - Search existing issues and pull requests first.
10
10
  - Small bug fixes, docs fixes, tests, and localization updates can go straight to a pull request.
11
11
  - For larger UI, AI-generation, storage, or packaging changes, open an issue first so the scope is clear.
12
+ - Check [ARCHITECTURE.md](ARCHITECTURE.md) before adding commands, settings, provider behavior, or persisted state.
12
13
  - Do not commit local practice data from `.practicode/`, `problems/`, or `submissions/`.
13
14
  - Do not include secrets, tokens, private prompts, or generated answer keys in docs or examples.
14
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "practicode",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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
@@ -123,13 +123,22 @@ pub fn available_models(provider: &str) -> ModelCatalog {
123
123
  }
124
124
 
125
125
  pub fn default_ai_next_prompt(request: &str) -> String {
126
+ default_ai_next_prompt_with_settings(&Settings::default(), request)
127
+ }
128
+
129
+ pub fn default_ai_next_prompt_with_settings(settings: &Settings, request: &str) -> String {
126
130
  format!(
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.",
131
+ "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: {}. Practice profile: difficulty preference: {}; preferred topics: {}; avoid topics: {}; code language: {}; UI language: {}. Treat difficulty auto as gradual progression from state; otherwise prefer the requested difficulty unless the direct user request conflicts. 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.",
128
132
  if request.is_empty() {
129
133
  "(none)"
130
134
  } else {
131
135
  request
132
- }
136
+ },
137
+ settings.difficulty,
138
+ list_or_none(&settings.topics),
139
+ list_or_none(&settings.avoid_topics),
140
+ settings.language,
141
+ settings.ui_language
133
142
  )
134
143
  }
135
144
 
@@ -304,7 +313,9 @@ fn default_codex_next_command(root: &Path, settings: &Settings, request: &str) -
304
313
  exec.push_str(&format!(" --model {}", sh_quote(model)));
305
314
  }
306
315
  exec.push(' ');
307
- exec.push_str(&sh_quote(&default_ai_next_prompt(request)));
316
+ exec.push_str(&sh_quote(&default_ai_next_prompt_with_settings(
317
+ settings, request,
318
+ )));
308
319
  format!("{start}; {exec}")
309
320
  }
310
321
 
@@ -322,7 +333,9 @@ fn default_claude_next_command(root: &Path, settings: &Settings, request: &str)
322
333
  claude.push_str(&format!(" --model {}", sh_quote(model)));
323
334
  }
324
335
  claude.push_str(" -p ");
325
- claude.push_str(&sh_quote(&default_ai_next_prompt(request)));
336
+ claude.push_str(&sh_quote(&default_ai_next_prompt_with_settings(
337
+ settings, request,
338
+ )));
326
339
  format!(
327
340
  "claude daemon status >/dev/null 2>&1 || true; cd {}; {}",
328
341
  sh_quote(&root.display().to_string()),
@@ -338,6 +351,14 @@ fn output_text(stdout: &str, stderr: &str) -> String {
338
351
  .join("\n")
339
352
  }
340
353
 
354
+ fn list_or_none(values: &[String]) -> String {
355
+ if values.is_empty() {
356
+ "(none)".to_string()
357
+ } else {
358
+ values.join(", ")
359
+ }
360
+ }
361
+
341
362
  #[cfg(test)]
342
363
  mod tests {
343
364
  use super::parse_model_list;
@@ -0,0 +1,29 @@
1
+ pub const DIFFICULTIES: &[&str] = &["auto", "easy", "medium", "hard"];
2
+
3
+ pub fn default_difficulty() -> String {
4
+ "auto".to_string()
5
+ }
6
+
7
+ pub fn normalize_difficulty(difficulty: &str) -> String {
8
+ let difficulty = difficulty.trim().to_lowercase();
9
+ if DIFFICULTIES.contains(&difficulty.as_str()) {
10
+ difficulty
11
+ } else {
12
+ default_difficulty()
13
+ }
14
+ }
15
+
16
+ pub fn parse_topic_list(value: &str) -> Vec<String> {
17
+ let mut topics = Vec::new();
18
+ for topic in value.split(',') {
19
+ let topic = topic.trim().trim_start_matches('#').trim().to_lowercase();
20
+ if !topic.is_empty() && !topics.contains(&topic) {
21
+ topics.push(topic);
22
+ }
23
+ }
24
+ topics
25
+ }
26
+
27
+ pub fn normalize_topic_list(topics: &[String]) -> Vec<String> {
28
+ parse_topic_list(&topics.join(","))
29
+ }
package/src/core.rs CHANGED
@@ -9,6 +9,11 @@ use std::{
9
9
  time::Duration,
10
10
  };
11
11
 
12
+ mod profile;
13
+ pub use profile::{
14
+ DIFFICULTIES, default_difficulty, normalize_difficulty, normalize_topic_list, parse_topic_list,
15
+ };
16
+
12
17
  pub const LANGUAGES: &[&str] = &["python", "ts", "java", "rust"];
13
18
  pub use crate::i18n::{UI_LANGUAGES, normalize_ui_language, ui_text};
14
19
  pub const THEMES: &[&str] = &["dark", "light"];
@@ -25,6 +30,12 @@ pub struct Settings {
25
30
  pub ui_language: String,
26
31
  #[serde(default = "default_theme")]
27
32
  pub theme: String,
33
+ #[serde(default = "default_difficulty")]
34
+ pub difficulty: String,
35
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
36
+ pub topics: Vec<String>,
37
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
38
+ pub avoid_topics: Vec<String>,
28
39
  #[serde(default = "default_editor")]
29
40
  pub editor: String,
30
41
  #[serde(default = "default_next_source")]
@@ -43,6 +54,9 @@ impl Default for Settings {
43
54
  language: default_language(),
44
55
  ui_language: default_ui_language(),
45
56
  theme: default_theme(),
57
+ difficulty: default_difficulty(),
58
+ topics: Vec::new(),
59
+ avoid_topics: Vec::new(),
46
60
  editor: default_editor(),
47
61
  next_source: default_next_source(),
48
62
  ai_provider: default_ai_provider(),
@@ -364,6 +378,9 @@ pub fn normalize_settings(settings: &mut Settings) {
364
378
  if !THEMES.contains(&settings.theme.as_str()) {
365
379
  settings.theme = "dark".to_string();
366
380
  }
381
+ settings.difficulty = normalize_difficulty(&settings.difficulty);
382
+ settings.topics = normalize_topic_list(&settings.topics);
383
+ settings.avoid_topics = normalize_topic_list(&settings.avoid_topics);
367
384
  settings.next_source = normalize_next_source(&settings.next_source);
368
385
  settings.ai_provider = normalize_ai_provider(&settings.ai_provider);
369
386
  if settings.ai_model.trim().is_empty() {
@@ -376,8 +393,9 @@ pub fn problem_by_id<'a>(bank: &'a [Problem], problem_id: &str) -> Option<&'a Pr
376
393
  }
377
394
 
378
395
  pub fn normalize_language(language: &str) -> String {
379
- if LANGUAGES.contains(&language) {
380
- language.to_string()
396
+ let language = language.trim().to_lowercase();
397
+ if LANGUAGES.contains(&language.as_str()) {
398
+ language
381
399
  } else {
382
400
  "python".to_string()
383
401
  }
@@ -756,7 +774,11 @@ pub fn next_problem(
756
774
  .iter()
757
775
  .map(|item| item.id.as_str())
758
776
  .collect::<Vec<_>>();
759
- let preferred = &state.suggested_next_difficulty;
777
+ let preferred = if state.settings.difficulty == "auto" {
778
+ &state.suggested_next_difficulty
779
+ } else {
780
+ &state.settings.difficulty
781
+ };
760
782
  let problem = bank
761
783
  .iter()
762
784
  .find(|item| !seen.contains(&item.id.as_str()) && &item.difficulty == preferred)
@@ -0,0 +1,242 @@
1
+ #[derive(Clone, Copy)]
2
+ pub(super) struct CommandHint {
3
+ pub(super) insert: &'static str,
4
+ pub(super) display: &'static str,
5
+ pub(super) desc_key: &'static str,
6
+ pub(super) keep_open: bool,
7
+ pub(super) help: bool,
8
+ }
9
+
10
+ pub(super) const COMMAND_HINTS: &[CommandHint] = &[
11
+ CommandHint {
12
+ insert: "run",
13
+ display: "/run",
14
+ desc_key: "cmd_run",
15
+ keep_open: false,
16
+ help: true,
17
+ },
18
+ CommandHint {
19
+ insert: "code",
20
+ display: "/code",
21
+ desc_key: "cmd_code",
22
+ keep_open: false,
23
+ help: true,
24
+ },
25
+ CommandHint {
26
+ insert: "next",
27
+ display: "/next",
28
+ desc_key: "cmd_next",
29
+ keep_open: false,
30
+ help: true,
31
+ },
32
+ CommandHint {
33
+ insert: "back",
34
+ display: "/back",
35
+ desc_key: "cmd_prev",
36
+ keep_open: false,
37
+ help: true,
38
+ },
39
+ CommandHint {
40
+ insert: "problems",
41
+ display: "/problems",
42
+ desc_key: "cmd_list",
43
+ keep_open: false,
44
+ help: true,
45
+ },
46
+ CommandHint {
47
+ insert: "open ",
48
+ display: "/open <id>",
49
+ desc_key: "cmd_open",
50
+ keep_open: true,
51
+ help: true,
52
+ },
53
+ CommandHint {
54
+ insert: "answer",
55
+ display: "/answer",
56
+ desc_key: "cmd_giveup",
57
+ keep_open: false,
58
+ help: true,
59
+ },
60
+ CommandHint {
61
+ insert: "hint ",
62
+ display: "/hint <request>",
63
+ desc_key: "cmd_hint",
64
+ keep_open: true,
65
+ help: true,
66
+ },
67
+ CommandHint {
68
+ insert: "profile",
69
+ display: "/profile",
70
+ desc_key: "cmd_profile",
71
+ keep_open: false,
72
+ help: true,
73
+ },
74
+ CommandHint {
75
+ insert: "difficulty auto",
76
+ display: "/difficulty auto",
77
+ desc_key: "cmd_difficulty",
78
+ keep_open: false,
79
+ help: true,
80
+ },
81
+ CommandHint {
82
+ insert: "difficulty easy",
83
+ display: "/difficulty easy",
84
+ desc_key: "cmd_difficulty",
85
+ keep_open: false,
86
+ help: false,
87
+ },
88
+ CommandHint {
89
+ insert: "difficulty medium",
90
+ display: "/difficulty medium",
91
+ desc_key: "cmd_difficulty",
92
+ keep_open: false,
93
+ help: false,
94
+ },
95
+ CommandHint {
96
+ insert: "difficulty hard",
97
+ display: "/difficulty hard",
98
+ desc_key: "cmd_difficulty",
99
+ keep_open: false,
100
+ help: false,
101
+ },
102
+ CommandHint {
103
+ insert: "topics ",
104
+ display: "/topics <list>",
105
+ desc_key: "cmd_topics",
106
+ keep_open: true,
107
+ help: true,
108
+ },
109
+ CommandHint {
110
+ insert: "avoid ",
111
+ display: "/avoid <list>",
112
+ desc_key: "cmd_avoid",
113
+ keep_open: true,
114
+ help: true,
115
+ },
116
+ CommandHint {
117
+ insert: "provider codex",
118
+ display: "/provider codex",
119
+ desc_key: "cmd_provider",
120
+ keep_open: false,
121
+ help: true,
122
+ },
123
+ CommandHint {
124
+ insert: "provider claude",
125
+ display: "/provider claude",
126
+ desc_key: "cmd_provider",
127
+ keep_open: false,
128
+ help: false,
129
+ },
130
+ CommandHint {
131
+ insert: "model auto",
132
+ display: "/model auto",
133
+ desc_key: "cmd_model_auto",
134
+ keep_open: false,
135
+ help: true,
136
+ },
137
+ CommandHint {
138
+ insert: "model ",
139
+ display: "/model <name>",
140
+ desc_key: "cmd_model_custom",
141
+ keep_open: true,
142
+ help: false,
143
+ },
144
+ CommandHint {
145
+ insert: "language python",
146
+ display: "/language python",
147
+ desc_key: "cmd_lang",
148
+ keep_open: false,
149
+ help: true,
150
+ },
151
+ CommandHint {
152
+ insert: "language ts",
153
+ display: "/language ts",
154
+ desc_key: "cmd_lang",
155
+ keep_open: false,
156
+ help: false,
157
+ },
158
+ CommandHint {
159
+ insert: "language java",
160
+ display: "/language java",
161
+ desc_key: "cmd_lang",
162
+ keep_open: false,
163
+ help: false,
164
+ },
165
+ CommandHint {
166
+ insert: "language rust",
167
+ display: "/language rust",
168
+ desc_key: "cmd_lang",
169
+ keep_open: false,
170
+ help: false,
171
+ },
172
+ CommandHint {
173
+ insert: "ui en",
174
+ display: "/ui en",
175
+ desc_key: "cmd_ui",
176
+ keep_open: false,
177
+ help: true,
178
+ },
179
+ CommandHint {
180
+ insert: "ui ko",
181
+ display: "/ui ko",
182
+ desc_key: "cmd_ui",
183
+ keep_open: false,
184
+ help: false,
185
+ },
186
+ CommandHint {
187
+ insert: "ui ja",
188
+ display: "/ui ja",
189
+ desc_key: "cmd_ui",
190
+ keep_open: false,
191
+ help: false,
192
+ },
193
+ CommandHint {
194
+ insert: "ui zh",
195
+ display: "/ui zh",
196
+ desc_key: "cmd_ui",
197
+ keep_open: false,
198
+ help: false,
199
+ },
200
+ CommandHint {
201
+ insert: "ui es",
202
+ display: "/ui es",
203
+ desc_key: "cmd_ui",
204
+ keep_open: false,
205
+ help: false,
206
+ },
207
+ CommandHint {
208
+ insert: "theme dark",
209
+ display: "/theme dark",
210
+ desc_key: "cmd_theme",
211
+ keep_open: false,
212
+ help: true,
213
+ },
214
+ CommandHint {
215
+ insert: "theme light",
216
+ display: "/theme light",
217
+ desc_key: "cmd_theme",
218
+ keep_open: false,
219
+ help: false,
220
+ },
221
+ CommandHint {
222
+ insert: "update",
223
+ display: "/update",
224
+ desc_key: "cmd_update",
225
+ keep_open: false,
226
+ help: true,
227
+ },
228
+ CommandHint {
229
+ insert: "help",
230
+ display: "/help",
231
+ desc_key: "cmd_help",
232
+ keep_open: false,
233
+ help: true,
234
+ },
235
+ CommandHint {
236
+ insert: "exit",
237
+ display: "/exit",
238
+ desc_key: "cmd_exit",
239
+ keep_open: false,
240
+ help: true,
241
+ },
242
+ ];
package/src/tui.rs CHANGED
@@ -4,11 +4,12 @@ use crate::{
4
4
  run_ai_next, run_ai_prompt,
5
5
  },
6
6
  core::{
7
- AI_PROVIDERS, AppState, HistoryItem, LANGUAGES, PROBLEM_NOTES_PATH, Problem, THEMES,
8
- UI_LANGUAGES, ensure_problem_files, ensure_submission, ext_for, give_up, judge, load_bank,
9
- load_state, localized, next_problem, normalize_ai_provider, normalize_language,
10
- normalize_next_source, normalize_ui_language, previous_problem, problem_by_id, record_pass,
11
- save_state, template_for, ui_text,
7
+ AI_PROVIDERS, AppState, DIFFICULTIES, HistoryItem, LANGUAGES, PROBLEM_NOTES_PATH, Problem,
8
+ THEMES, UI_LANGUAGES, ensure_problem_files, ensure_submission, ext_for, give_up, judge,
9
+ load_bank, load_state, localized, next_problem, normalize_ai_provider,
10
+ normalize_difficulty, normalize_language, normalize_next_source, normalize_ui_language,
11
+ parse_topic_list, previous_problem, problem_by_id, record_pass, save_state, template_for,
12
+ ui_text,
12
13
  },
13
14
  text::{
14
15
  byte_index, char_len, compose_hangul_jamo, display_width, prefix, render_markdown_plain,
@@ -33,17 +34,13 @@ use std::{
33
34
  path::PathBuf,
34
35
  sync::mpsc::{self, Receiver},
35
36
  thread,
36
- time::Duration,
37
+ time::{Duration, Instant},
37
38
  };
38
39
 
39
- #[derive(Clone, Copy)]
40
- struct CommandHint {
41
- insert: &'static str,
42
- display: &'static str,
43
- desc_key: &'static str,
44
- keep_open: bool,
45
- help: bool,
46
- }
40
+ mod commands;
41
+ use self::commands::COMMAND_HINTS;
42
+
43
+ const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60);
47
44
 
48
45
  #[derive(Clone)]
49
46
  struct CommandChoice {
@@ -53,205 +50,6 @@ struct CommandChoice {
53
50
  keep_open: bool,
54
51
  }
55
52
 
56
- const COMMAND_HINTS: &[CommandHint] = &[
57
- CommandHint {
58
- insert: "run",
59
- display: "/run",
60
- desc_key: "cmd_run",
61
- keep_open: false,
62
- help: true,
63
- },
64
- CommandHint {
65
- insert: "code",
66
- display: "/code",
67
- desc_key: "cmd_code",
68
- keep_open: false,
69
- help: true,
70
- },
71
- CommandHint {
72
- insert: "next",
73
- display: "/next",
74
- desc_key: "cmd_next",
75
- keep_open: false,
76
- help: true,
77
- },
78
- CommandHint {
79
- insert: "prev",
80
- display: "/prev",
81
- desc_key: "cmd_prev",
82
- keep_open: false,
83
- help: true,
84
- },
85
- CommandHint {
86
- insert: "list",
87
- display: "/list",
88
- desc_key: "cmd_list",
89
- keep_open: false,
90
- help: true,
91
- },
92
- CommandHint {
93
- insert: "open ",
94
- display: "/open <id>",
95
- desc_key: "cmd_open",
96
- keep_open: true,
97
- help: true,
98
- },
99
- CommandHint {
100
- insert: "giveup",
101
- display: "/giveup",
102
- desc_key: "cmd_giveup",
103
- keep_open: false,
104
- help: true,
105
- },
106
- CommandHint {
107
- insert: "hint ",
108
- display: "/hint <request>",
109
- desc_key: "cmd_hint",
110
- keep_open: true,
111
- help: true,
112
- },
113
- CommandHint {
114
- insert: "provider codex",
115
- display: "/provider codex",
116
- desc_key: "cmd_provider",
117
- keep_open: false,
118
- help: true,
119
- },
120
- CommandHint {
121
- insert: "provider claude",
122
- display: "/provider claude",
123
- desc_key: "cmd_provider",
124
- keep_open: false,
125
- help: false,
126
- },
127
- CommandHint {
128
- insert: "model auto",
129
- display: "/model auto",
130
- desc_key: "cmd_model_auto",
131
- keep_open: false,
132
- help: true,
133
- },
134
- CommandHint {
135
- insert: "model ",
136
- display: "/model <name>",
137
- desc_key: "cmd_model_custom",
138
- keep_open: true,
139
- help: false,
140
- },
141
- CommandHint {
142
- insert: "note ",
143
- display: "/note <text>",
144
- desc_key: "cmd_note",
145
- keep_open: true,
146
- help: true,
147
- },
148
- CommandHint {
149
- insert: "notes",
150
- display: "/notes",
151
- desc_key: "cmd_notes",
152
- keep_open: false,
153
- help: true,
154
- },
155
- CommandHint {
156
- insert: "lang python",
157
- display: "/lang python",
158
- desc_key: "cmd_lang",
159
- keep_open: false,
160
- help: true,
161
- },
162
- CommandHint {
163
- insert: "lang ts",
164
- display: "/lang ts",
165
- desc_key: "cmd_lang",
166
- keep_open: false,
167
- help: false,
168
- },
169
- CommandHint {
170
- insert: "lang java",
171
- display: "/lang java",
172
- desc_key: "cmd_lang",
173
- keep_open: false,
174
- help: false,
175
- },
176
- CommandHint {
177
- insert: "lang rust",
178
- display: "/lang rust",
179
- desc_key: "cmd_lang",
180
- keep_open: false,
181
- help: false,
182
- },
183
- CommandHint {
184
- insert: "ui en",
185
- display: "/ui en",
186
- desc_key: "cmd_ui",
187
- keep_open: false,
188
- help: true,
189
- },
190
- CommandHint {
191
- insert: "ui ko",
192
- display: "/ui ko",
193
- desc_key: "cmd_ui",
194
- keep_open: false,
195
- help: false,
196
- },
197
- CommandHint {
198
- insert: "ui ja",
199
- display: "/ui ja",
200
- desc_key: "cmd_ui",
201
- keep_open: false,
202
- help: false,
203
- },
204
- CommandHint {
205
- insert: "ui zh",
206
- display: "/ui zh",
207
- desc_key: "cmd_ui",
208
- keep_open: false,
209
- help: false,
210
- },
211
- CommandHint {
212
- insert: "ui es",
213
- display: "/ui es",
214
- desc_key: "cmd_ui",
215
- keep_open: false,
216
- help: false,
217
- },
218
- CommandHint {
219
- insert: "theme dark",
220
- display: "/theme dark",
221
- desc_key: "cmd_theme",
222
- keep_open: false,
223
- help: true,
224
- },
225
- CommandHint {
226
- insert: "theme light",
227
- display: "/theme light",
228
- desc_key: "cmd_theme",
229
- keep_open: false,
230
- help: false,
231
- },
232
- CommandHint {
233
- insert: "update",
234
- display: "/update",
235
- desc_key: "cmd_update",
236
- keep_open: false,
237
- help: true,
238
- },
239
- CommandHint {
240
- insert: "help",
241
- display: "/help",
242
- desc_key: "cmd_help",
243
- keep_open: false,
244
- help: true,
245
- },
246
- CommandHint {
247
- insert: "exit",
248
- display: "/exit",
249
- desc_key: "cmd_exit",
250
- keep_open: false,
251
- help: true,
252
- },
253
- ];
254
-
255
53
  #[derive(Clone, Copy, Debug, Eq, PartialEq)]
256
54
  enum Focus {
257
55
  Code,
@@ -286,6 +84,7 @@ pub struct PracticodeApp {
286
84
  model_message: Option<String>,
287
85
  update_check: Option<UpdateCheck>,
288
86
  update_notice: Option<String>,
87
+ last_update_check: Option<Instant>,
289
88
  code_area: Rect,
290
89
  output_area: Rect,
291
90
  command_area: Rect,
@@ -334,6 +133,7 @@ impl PracticodeApp {
334
133
  model_message: None,
335
134
  update_check: None,
336
135
  update_notice: None,
136
+ last_update_check: None,
337
137
  code_area: Rect::default(),
338
138
  output_area: Rect::default(),
339
139
  command_area: Rect::default(),
@@ -350,6 +150,7 @@ impl PracticodeApp {
350
150
  terminal.draw(|frame| self.draw(frame))?;
351
151
  self.check_task();
352
152
  self.check_update();
153
+ self.maybe_start_periodic_update_check();
353
154
  self.start_model_check();
354
155
  self.check_models();
355
156
  if event::poll(Duration::from_millis(100))? {
@@ -360,7 +161,7 @@ impl PracticodeApp {
360
161
  }
361
162
  }
362
163
  if !self.busy_label.is_empty() {
363
- self.busy_frame = (self.busy_frame + 1) % 16;
164
+ self.busy_frame = (self.busy_frame + 1) % 32;
364
165
  }
365
166
  }
366
167
  self.save_code().ok();
@@ -971,23 +772,31 @@ impl PracticodeApp {
971
772
  }
972
773
  let (command, arg) = value.split_once(char::is_whitespace).unwrap_or((value, ""));
973
774
  let arg = arg.trim();
974
- if command != "list" {
775
+ if !matches!(command, "list" | "problems") {
975
776
  self.list_cursor = None;
976
777
  }
977
778
  match command {
978
779
  "run" | "r" => self.action_run()?,
979
780
  "code" | "edit" | "e" => self.action_edit()?,
980
781
  "next" | "n" => self.action_next(arg)?,
981
- "prev" | "previous" | "p" => self.action_previous()?,
982
- "giveup" | "give" | "g" => self.action_give_up()?,
983
- "list" => self.start_problem_list(),
782
+ "back" | "prev" | "previous" | "p" => self.action_previous()?,
783
+ "answer" | "giveup" | "give" | "g" => self.action_give_up()?,
784
+ "problems" | "list" => self.start_problem_list(),
984
785
  "open" | "o" if !arg.is_empty() => self.open_problem(arg)?,
985
- "lang" if arg.is_empty() => self.action_cycle_language()?,
986
- "lang" if LANGUAGES.contains(&arg) => self.set_language(arg)?,
786
+ "language" | "lang" if arg.is_empty() => self.action_cycle_language()?,
787
+ "language" | "lang" if LANGUAGES.contains(&arg) => self.set_language(arg)?,
987
788
  "ui" if arg.is_empty() => self.action_toggle_ui_language()?,
988
789
  "ui" => self.set_ui_language(&normalize_ui_language(arg))?,
989
790
  "theme" if arg.is_empty() => self.action_toggle_theme()?,
990
791
  "theme" if THEMES.contains(&arg) => self.set_theme(arg)?,
792
+ "profile" | "settings" if arg.is_empty() => self.show_profile(),
793
+ "profile" | "settings" if arg == "reset" => self.reset_profile()?,
794
+ "difficulty" | "level" if arg.is_empty() => self.show_profile(),
795
+ "difficulty" | "level" => self.set_difficulty(arg)?,
796
+ "topics" | "topic" if arg.is_empty() => self.show_profile(),
797
+ "topics" | "topic" => self.set_topics(arg, false)?,
798
+ "avoid" | "skip" if arg.is_empty() => self.show_profile(),
799
+ "avoid" | "skip" => self.set_topics(arg, true)?,
991
800
  "source" | "next-source" if arg.is_empty() => {
992
801
  self.write_text_output(&self.next_source_help());
993
802
  }
@@ -1044,7 +853,7 @@ impl PracticodeApp {
1044
853
  "hint" | "ask" | "ai" if !arg.is_empty() => self.start_ai_prompt(arg)?,
1045
854
  "note" if !arg.is_empty() => self.append_note(arg)?,
1046
855
  "note" | "notes" => self.show_notes()?,
1047
- "update" => self.show_update_notice(),
856
+ "update" => self.refresh_update_notice(),
1048
857
  "exit" | "quit" | "q" => self.should_quit = true,
1049
858
  _ => self.write_text_output(&format!("Unknown command: {value}\nTry /help.")),
1050
859
  }
@@ -1225,6 +1034,68 @@ impl PracticodeApp {
1225
1034
  Ok(())
1226
1035
  }
1227
1036
 
1037
+ fn set_difficulty(&mut self, difficulty: &str) -> Result<()> {
1038
+ let difficulty = difficulty.trim().to_lowercase();
1039
+ if !DIFFICULTIES.contains(&difficulty.as_str()) {
1040
+ self.write_text_output("Difficulty: auto, easy, medium, or hard.");
1041
+ return Ok(());
1042
+ }
1043
+ let normalized = normalize_difficulty(&difficulty);
1044
+ self.state.settings.difficulty = normalized.clone();
1045
+ if normalized != "auto" {
1046
+ self.state.suggested_next_difficulty = normalized;
1047
+ }
1048
+ save_state(&self.root, &self.state)?;
1049
+ self.show_profile();
1050
+ Ok(())
1051
+ }
1052
+
1053
+ fn set_topics(&mut self, topics: &str, avoid: bool) -> Result<()> {
1054
+ let topics = parse_topic_list(topics);
1055
+ if avoid {
1056
+ self.state.settings.avoid_topics = topics;
1057
+ } else {
1058
+ self.state.settings.topics = topics;
1059
+ }
1060
+ save_state(&self.root, &self.state)?;
1061
+ self.show_profile();
1062
+ Ok(())
1063
+ }
1064
+
1065
+ fn reset_profile(&mut self) -> Result<()> {
1066
+ self.state.settings.difficulty = "auto".to_string();
1067
+ self.state.settings.topics.clear();
1068
+ self.state.settings.avoid_topics.clear();
1069
+ save_state(&self.root, &self.state)?;
1070
+ self.show_profile();
1071
+ Ok(())
1072
+ }
1073
+
1074
+ fn show_profile(&mut self) {
1075
+ self.write_text_output(&self.profile_text());
1076
+ }
1077
+
1078
+ fn profile_text(&self) -> String {
1079
+ let settings = &self.state.settings;
1080
+ let topics = list_or_none(&settings.topics);
1081
+ let avoid = list_or_none(&settings.avoid_topics);
1082
+ format!(
1083
+ "Practice profile\n\nUI language: {}\nCode language: {}\nTheme: {}\nDifficulty: {}\nPreferred topics: {}\nAvoid topics: {}\nAI provider: {}\nAI model: {}\n\nCommands\n/profile\n/difficulty auto|easy|medium|hard\n/topics arrays, strings\n/avoid dp, graph\n/language python|ts|java|rust\n/ui en|ko|ja|zh|es\n/theme dark|light",
1084
+ settings.ui_language,
1085
+ settings.language,
1086
+ settings.theme,
1087
+ settings.difficulty,
1088
+ topics,
1089
+ avoid,
1090
+ settings.ai_provider,
1091
+ if settings.ai_model == "auto" {
1092
+ "auto (provider default)"
1093
+ } else {
1094
+ settings.ai_model.as_str()
1095
+ }
1096
+ )
1097
+ }
1098
+
1228
1099
  fn start_ai_prompt(&mut self, prompt: &str) -> Result<()> {
1229
1100
  if self.task_rx.is_some() {
1230
1101
  self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy"));
@@ -1271,8 +1142,10 @@ impl PracticodeApp {
1271
1142
  if let Some(result) = result {
1272
1143
  self.update_rx = None;
1273
1144
  self.update_check = Some(result.clone());
1274
- if let UpdateCheck::Available(version) = &result {
1275
- self.update_notice = Some(version.clone());
1145
+ match &result {
1146
+ UpdateCheck::Available(version) => self.update_notice = Some(version.clone()),
1147
+ UpdateCheck::Current | UpdateCheck::Disabled => self.update_notice = None,
1148
+ UpdateCheck::Failed => {}
1276
1149
  }
1277
1150
  }
1278
1151
  }
@@ -1281,6 +1154,7 @@ impl PracticodeApp {
1281
1154
  if self.update_rx.is_some() {
1282
1155
  return;
1283
1156
  }
1157
+ self.last_update_check = Some(Instant::now());
1284
1158
  let (tx, rx) = mpsc::channel();
1285
1159
  thread::spawn(move || {
1286
1160
  let _ = tx.send(check_latest_version());
@@ -1288,6 +1162,18 @@ impl PracticodeApp {
1288
1162
  self.update_rx = Some(rx);
1289
1163
  }
1290
1164
 
1165
+ fn maybe_start_periodic_update_check(&mut self) {
1166
+ if self.update_rx.is_some() {
1167
+ return;
1168
+ }
1169
+ if self
1170
+ .last_update_check
1171
+ .is_none_or(|last| last.elapsed() >= UPDATE_CHECK_INTERVAL)
1172
+ {
1173
+ self.start_update_check();
1174
+ }
1175
+ }
1176
+
1291
1177
  fn start_model_check(&mut self) {
1292
1178
  let provider = self.state.settings.ai_provider.clone();
1293
1179
  if self.model_rx.is_some() || self.available_models_provider == provider {
@@ -1387,6 +1273,13 @@ impl PracticodeApp {
1387
1273
  self.focus = Focus::Output;
1388
1274
  }
1389
1275
 
1276
+ fn refresh_update_notice(&mut self) {
1277
+ self.update_check = None;
1278
+ self.update_notice = None;
1279
+ self.start_update_check();
1280
+ self.show_update_notice();
1281
+ }
1282
+
1390
1283
  fn show_update_notice(&mut self) {
1391
1284
  let lang = self.state.settings.ui_language.clone();
1392
1285
  if let Some(version) = &self.update_notice {
@@ -1414,7 +1307,7 @@ impl PracticodeApp {
1414
1307
  fn show_notes(&mut self) -> Result<()> {
1415
1308
  let notes = read_problem_notes(&self.root)?;
1416
1309
  if notes.is_empty() {
1417
- self.write_text_output("No notes yet. Add one with /note <text>.");
1310
+ self.write_text_output("No notes yet. Use /topics or /avoid for standing preferences.");
1418
1311
  } else {
1419
1312
  self.write_text_output(&format!("Problem notes ({PROBLEM_NOTES_PATH})\n\n{notes}"));
1420
1313
  }
@@ -1644,7 +1537,7 @@ impl PracticodeApp {
1644
1537
  fn open_problem(&mut self, query: &str) -> Result<()> {
1645
1538
  self.list_cursor = None;
1646
1539
  let Some(problem) = self.find_problem(query).cloned() else {
1647
- self.write_text_output(&format!("Problem not found: {query}\nTry /list."));
1540
+ self.write_text_output(&format!("Problem not found: {query}\nTry /problems."));
1648
1541
  return Ok(());
1649
1542
  };
1650
1543
  self.problem = problem;
@@ -1746,14 +1639,14 @@ impl PracticodeApp {
1746
1639
 
1747
1640
  fn next_source_help(&self) -> String {
1748
1641
  if self.state.settings.next_source == "ai" {
1749
- "Next behavior: plain /next asks AI every time. Use /source local to use local problems first.".to_string()
1642
+ "Next behavior: plain /next asks AI every time. /next <request> also sends that request.".to_string()
1750
1643
  } else {
1751
1644
  "Next behavior: /next uses local problems first and asks AI when it needs a new one. Use /next <request> to ask AI directly.".to_string()
1752
1645
  }
1753
1646
  }
1754
1647
 
1755
1648
  fn busy_dots(&self) -> String {
1756
- ".".repeat(self.busy_frame / 4)
1649
+ ".".repeat((self.busy_frame / 8) % 4)
1757
1650
  }
1758
1651
 
1759
1652
  fn mode_hint(&self) -> &'static str {
@@ -1787,6 +1680,14 @@ impl PracticodeApp {
1787
1680
  }
1788
1681
  }
1789
1682
 
1683
+ fn list_or_none(values: &[String]) -> String {
1684
+ if values.is_empty() {
1685
+ "(none)".to_string()
1686
+ } else {
1687
+ values.join(", ")
1688
+ }
1689
+ }
1690
+
1790
1691
  #[derive(Clone, Debug)]
1791
1692
  pub struct TextEditor {
1792
1693
  lines: Vec<String>,