practicode 0.1.10 → 0.1.11
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 -2
- package/assets/i18n/en.json +4 -1
- package/assets/i18n/es.json +4 -1
- package/assets/i18n/ja.json +4 -1
- package/assets/i18n/ko.json +4 -1
- package/assets/i18n/zh.json +4 -1
- package/package.json +1 -1
- package/src/ai.rs +103 -15
- package/src/core/language.rs +18 -0
- package/src/core/model.rs +18 -0
- package/src/core/state.rs +1 -0
- package/src/tui/actions.rs +67 -2
- package/src/tui/command_handlers.rs +11 -1
- package/src/tui/command_input.rs +3 -0
- package/src/tui/commands.rs +56 -0
- package/src/tui/events.rs +37 -0
- package/src/tui/settings_panel.rs +158 -59
- package/src/tui/status.rs +3 -0
- package/src/tui/tasks.rs +25 -1
- package/src/tui/view.rs +72 -19
- package/src/tui.rs +11 -6
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/README.md
CHANGED
|
@@ -111,6 +111,9 @@ Submissions are saved as you type under `submissions/<problem-id>/solution.<ext>
|
|
|
111
111
|
| `/generate-ui ko, en` | Limit generated problem text languages, or use `all` |
|
|
112
112
|
| `/provider codex` | Set AI provider and show local CLI/daemon status |
|
|
113
113
|
| `/model auto` | Use the provider default model for `/hint` and AI-backed `/next` |
|
|
114
|
+
| `/effort auto` | Use the provider default effort, or set `low`, `medium`, `high`, `xhigh`; Claude also supports `max` |
|
|
115
|
+
| `/note` | Edit problem-generation notes used by AI-backed `/next` and `/generate` |
|
|
116
|
+
| `/notes` | Show saved problem-generation notes |
|
|
114
117
|
| `/language python` | Set code language: `python`, `ts`, `java`, `rust` |
|
|
115
118
|
| `/ui en` | Set UI language: `en`, `ko`, `ja`, `zh`, `es` |
|
|
116
119
|
| `/theme dark` | Set theme: `dark` or `light` |
|
|
@@ -121,9 +124,9 @@ Older command names such as `/prev`, `/list`, `/giveup`, and `/lang` still work
|
|
|
121
124
|
|
|
122
125
|
The default UI language is English. Switch it any time with `/ui ko`, `/ui ja`, `/ui zh`, or `/ui es`.
|
|
123
126
|
|
|
124
|
-
Your user profile is saved in `.practicode/problem-state.json`. It keeps UI language, code language, theme, preferred difficulty, preferred topics, topics to avoid,
|
|
127
|
+
Your user profile is saved in `.practicode/problem-state.json`. It keeps UI language, code language, theme, preferred difficulty, preferred topics, topics to avoid, generation language scope, and AI provider/model/effort. `auto` difficulty follows gradual progression; a fixed difficulty asks local selection and AI generation to prefer that level.
|
|
125
128
|
|
|
126
|
-
Inside `/profile`, use `up/down` to move and `Space` or `Enter` to cycle common settings
|
|
129
|
+
Inside `/profile`, use `up/down` to move and `Space` or `Enter` to cycle common settings, AI provider/model/effort, or generated answer/UI languages. The notes row opens an editor for `.practicode/problem_notes.md`. Use slash commands for free-form lists such as `/topics arrays, strings`.
|
|
127
130
|
|
|
128
131
|
## Problem Flow
|
|
129
132
|
|
|
@@ -144,6 +147,7 @@ Codex is the default provider:
|
|
|
144
147
|
```text
|
|
145
148
|
/provider codex
|
|
146
149
|
/model auto
|
|
150
|
+
/effort auto
|
|
147
151
|
```
|
|
148
152
|
|
|
149
153
|
Claude Code is also supported:
|
|
@@ -151,6 +155,7 @@ Claude Code is also supported:
|
|
|
151
155
|
```text
|
|
152
156
|
/provider claude
|
|
153
157
|
/model sonnet
|
|
158
|
+
/effort high
|
|
154
159
|
```
|
|
155
160
|
|
|
156
161
|
Generated problems and submissions stay local:
|
package/assets/i18n/en.json
CHANGED
|
@@ -47,7 +47,10 @@
|
|
|
47
47
|
"cmd_model_auto": "Use provider default model",
|
|
48
48
|
"cmd_model_available": "Use an available provider model",
|
|
49
49
|
"cmd_model_custom": "Type a model name",
|
|
50
|
-
"
|
|
50
|
+
"cmd_effort": "Set AI effort",
|
|
51
|
+
"cmd_effort_auto": "Use provider default effort",
|
|
52
|
+
"cmd_effort_max": "Use Claude max effort",
|
|
53
|
+
"cmd_note": "Edit problem-generation notes",
|
|
51
54
|
"cmd_notes": "Show saved notes",
|
|
52
55
|
"cmd_lang": "Set code language",
|
|
53
56
|
"cmd_ui": "Set UI language",
|
package/assets/i18n/es.json
CHANGED
|
@@ -47,7 +47,10 @@
|
|
|
47
47
|
"cmd_model_auto": "Usar el modelo predeterminado del provider",
|
|
48
48
|
"cmd_model_available": "Usar un modelo disponible del provider",
|
|
49
49
|
"cmd_model_custom": "Escribir un nombre de modelo",
|
|
50
|
-
"
|
|
50
|
+
"cmd_effort": "Configurar AI effort",
|
|
51
|
+
"cmd_effort_auto": "Usar el effort predeterminado del provider",
|
|
52
|
+
"cmd_effort_max": "Usar Claude max effort",
|
|
53
|
+
"cmd_note": "Editar notas para generar problemas",
|
|
51
54
|
"cmd_notes": "Ver notas guardadas",
|
|
52
55
|
"cmd_lang": "Configurar lenguaje de codigo",
|
|
53
56
|
"cmd_ui": "Configurar idioma de UI",
|
package/assets/i18n/ja.json
CHANGED
|
@@ -47,7 +47,10 @@
|
|
|
47
47
|
"cmd_model_auto": "provider の既定モデルを使用",
|
|
48
48
|
"cmd_model_available": "利用可能な provider モデルを使用",
|
|
49
49
|
"cmd_model_custom": "モデル名を直接入力",
|
|
50
|
-
"
|
|
50
|
+
"cmd_effort": "AI effort を設定",
|
|
51
|
+
"cmd_effort_auto": "provider の既定 effort を使用",
|
|
52
|
+
"cmd_effort_max": "Claude max effort を使用",
|
|
53
|
+
"cmd_note": "問題生成メモを編集",
|
|
51
54
|
"cmd_notes": "保存済みメモを見る",
|
|
52
55
|
"cmd_lang": "コード言語を設定",
|
|
53
56
|
"cmd_ui": "UI 言語を設定",
|
package/assets/i18n/ko.json
CHANGED
|
@@ -47,7 +47,10 @@
|
|
|
47
47
|
"cmd_model_auto": "provider 기본 모델 사용",
|
|
48
48
|
"cmd_model_available": "사용 가능한 provider 모델 선택",
|
|
49
49
|
"cmd_model_custom": "모델 이름 직접 입력",
|
|
50
|
-
"
|
|
50
|
+
"cmd_effort": "AI effort 설정",
|
|
51
|
+
"cmd_effort_auto": "provider 기본 effort 사용",
|
|
52
|
+
"cmd_effort_max": "Claude max effort 사용",
|
|
53
|
+
"cmd_note": "문제 생성 메모 편집",
|
|
51
54
|
"cmd_notes": "저장된 메모 보기",
|
|
52
55
|
"cmd_lang": "코드 언어 설정",
|
|
53
56
|
"cmd_ui": "UI 언어 설정",
|
package/assets/i18n/zh.json
CHANGED
|
@@ -47,7 +47,10 @@
|
|
|
47
47
|
"cmd_model_auto": "使用 provider 默认模型",
|
|
48
48
|
"cmd_model_available": "使用可用的 provider 模型",
|
|
49
49
|
"cmd_model_custom": "输入模型名称",
|
|
50
|
-
"
|
|
50
|
+
"cmd_effort": "设置 AI effort",
|
|
51
|
+
"cmd_effort_auto": "使用 provider 默认 effort",
|
|
52
|
+
"cmd_effort_max": "使用 Claude max effort",
|
|
53
|
+
"cmd_note": "编辑出题备注",
|
|
51
54
|
"cmd_notes": "查看保存的备注",
|
|
52
55
|
"cmd_lang": "设置代码语言",
|
|
53
56
|
"cmd_ui": "设置 UI 语言",
|
package/package.json
CHANGED
package/src/ai.rs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
use crate::{
|
|
2
2
|
core::{
|
|
3
|
-
AppState, LANGUAGES, PROBLEM_NOTES_PATH, Problem, Settings,
|
|
4
|
-
ensure_submission, normalize_ai_provider, render_problem,
|
|
3
|
+
AppState, CLAUDE_AI_EFFORTS, LANGUAGES, PROBLEM_NOTES_PATH, Problem, Settings,
|
|
4
|
+
UI_LANGUAGES, ensure_submission, normalize_ai_provider, render_problem,
|
|
5
5
|
},
|
|
6
6
|
process::{run_capture, sh_quote, shell_process, unique_temp_path, which},
|
|
7
7
|
};
|
|
@@ -21,6 +21,10 @@ pub struct ModelCatalog {
|
|
|
21
21
|
pub message: Option<String>,
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
const CODEX_MODEL_FALLBACKS: &[&str] =
|
|
25
|
+
&["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"];
|
|
26
|
+
const CLAUDE_MODEL_FALLBACKS: &[&str] = &["sonnet", "opus", "fable", "claude-fable-5"];
|
|
27
|
+
|
|
24
28
|
pub fn run_ai_prompt(root: &Path, problem: &Problem, settings: &Settings, prompt: &str) -> String {
|
|
25
29
|
let solution = match ensure_submission(root, problem, settings) {
|
|
26
30
|
Ok(path) => path,
|
|
@@ -59,7 +63,8 @@ pub fn run_ai_next(root: &Path, state: &AppState, force: bool, request: &str) ->
|
|
|
59
63
|
.current_dir(root)
|
|
60
64
|
.env("PRACTICODE_NEXT_REQUEST", request)
|
|
61
65
|
.env("PRACTICODE_AI_PROVIDER", &provider)
|
|
62
|
-
.env("PRACTICODE_AI_MODEL", &state.settings.ai_model)
|
|
66
|
+
.env("PRACTICODE_AI_MODEL", &state.settings.ai_model)
|
|
67
|
+
.env("PRACTICODE_AI_EFFORT", &state.settings.ai_effort);
|
|
63
68
|
match run_capture(&mut process, "", Duration::from_secs(900)) {
|
|
64
69
|
Ok(run) if run.code == Some(0) => {
|
|
65
70
|
let output = output_text(&run.stdout, &run.stderr);
|
|
@@ -91,7 +96,8 @@ pub fn run_ai_generate(root: &Path, state: &AppState, request: &str) -> String {
|
|
|
91
96
|
.env("PRACTICODE_NEXT_REQUEST", request)
|
|
92
97
|
.env("PRACTICODE_GENERATE_BACKGROUND", "1")
|
|
93
98
|
.env("PRACTICODE_AI_PROVIDER", &provider)
|
|
94
|
-
.env("PRACTICODE_AI_MODEL", &state.settings.ai_model)
|
|
99
|
+
.env("PRACTICODE_AI_MODEL", &state.settings.ai_model)
|
|
100
|
+
.env("PRACTICODE_AI_EFFORT", &state.settings.ai_effort);
|
|
95
101
|
match run_capture(&mut process, "", Duration::from_secs(900)) {
|
|
96
102
|
Ok(run) if run.code == Some(0) => {
|
|
97
103
|
let output = output_text(&run.stdout, &run.stderr);
|
|
@@ -151,11 +157,15 @@ pub fn available_models(provider: &str) -> ModelCatalog {
|
|
|
151
157
|
match normalize_ai_provider(provider).as_str() {
|
|
152
158
|
"codex" => codex_models(),
|
|
153
159
|
"claude" => ModelCatalog {
|
|
154
|
-
models:
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
160
|
+
models: CLAUDE_MODEL_FALLBACKS
|
|
161
|
+
.iter()
|
|
162
|
+
.map(|model| (*model).to_string())
|
|
163
|
+
.collect(),
|
|
164
|
+
message: Some(format!(
|
|
165
|
+
"Bundled Claude presets from Claude Code {} --help. Efforts: {}.",
|
|
166
|
+
claude_version().unwrap_or_else(|| "current".to_string()),
|
|
167
|
+
CLAUDE_AI_EFFORTS.join(", ")
|
|
168
|
+
)),
|
|
159
169
|
},
|
|
160
170
|
_ => ModelCatalog::default(),
|
|
161
171
|
}
|
|
@@ -231,7 +241,7 @@ fn codex_models() -> ModelCatalog {
|
|
|
231
241
|
}
|
|
232
242
|
if codex_daemon_path().is_none_or(|path| !path.exists()) {
|
|
233
243
|
return ModelCatalog {
|
|
234
|
-
models:
|
|
244
|
+
models: codex_cached_models(),
|
|
235
245
|
message: Some("Codex app-server daemon is unavailable; install the standalone Codex app to list models, or use /model <name>.".to_string()),
|
|
236
246
|
};
|
|
237
247
|
}
|
|
@@ -243,18 +253,22 @@ fn codex_models() -> ModelCatalog {
|
|
|
243
253
|
let input = r#"{"id":1,"method":"model/list","params":{"limit":25}}"#;
|
|
244
254
|
let Ok(run) = run_capture(&mut command, &format!("{input}\n"), Duration::from_secs(2)) else {
|
|
245
255
|
return ModelCatalog {
|
|
246
|
-
models:
|
|
247
|
-
message: Some(
|
|
256
|
+
models: codex_cached_models(),
|
|
257
|
+
message: Some(
|
|
258
|
+
"Could not query Codex model list; using bundled/cache model presets.".to_string(),
|
|
259
|
+
),
|
|
248
260
|
};
|
|
249
261
|
};
|
|
250
262
|
if run.code != Some(0) {
|
|
251
263
|
let detail = output_text(&run.stdout, &run.stderr);
|
|
252
264
|
return ModelCatalog {
|
|
253
|
-
models:
|
|
265
|
+
models: codex_cached_models(),
|
|
254
266
|
message: Some(if detail.is_empty() {
|
|
255
|
-
"Could not query Codex model list.".to_string()
|
|
267
|
+
"Could not query Codex model list; using bundled/cache model presets.".to_string()
|
|
256
268
|
} else {
|
|
257
|
-
format!(
|
|
269
|
+
format!(
|
|
270
|
+
"Could not query Codex model list; using bundled/cache model presets: {detail}"
|
|
271
|
+
)
|
|
258
272
|
}),
|
|
259
273
|
};
|
|
260
274
|
}
|
|
@@ -272,6 +286,51 @@ fn codex_models() -> ModelCatalog {
|
|
|
272
286
|
}
|
|
273
287
|
}
|
|
274
288
|
|
|
289
|
+
fn codex_cached_models() -> Vec<String> {
|
|
290
|
+
let mut models = env::var_os("HOME")
|
|
291
|
+
.and_then(|home| {
|
|
292
|
+
fs::read_to_string(PathBuf::from(home).join(".codex/models_cache.json")).ok()
|
|
293
|
+
})
|
|
294
|
+
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
|
|
295
|
+
.and_then(|value| {
|
|
296
|
+
value
|
|
297
|
+
.get("models")
|
|
298
|
+
.and_then(|models| models.as_array())
|
|
299
|
+
.cloned()
|
|
300
|
+
})
|
|
301
|
+
.unwrap_or_default()
|
|
302
|
+
.into_iter()
|
|
303
|
+
.filter_map(|model| {
|
|
304
|
+
model
|
|
305
|
+
.get("slug")
|
|
306
|
+
.and_then(|value| value.as_str())
|
|
307
|
+
.map(str::to_string)
|
|
308
|
+
})
|
|
309
|
+
.collect::<Vec<_>>();
|
|
310
|
+
if models.is_empty() {
|
|
311
|
+
models = CODEX_MODEL_FALLBACKS
|
|
312
|
+
.iter()
|
|
313
|
+
.map(|model| (*model).to_string())
|
|
314
|
+
.collect();
|
|
315
|
+
}
|
|
316
|
+
models
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
fn claude_version() -> Option<String> {
|
|
320
|
+
which("claude").and_then(|_| {
|
|
321
|
+
let mut command = Command::new("claude");
|
|
322
|
+
command.arg("--version");
|
|
323
|
+
run_capture(&mut command, "", Duration::from_secs(2))
|
|
324
|
+
.ok()
|
|
325
|
+
.and_then(|run| {
|
|
326
|
+
output_text(&run.stdout, &run.stderr)
|
|
327
|
+
.lines()
|
|
328
|
+
.next()
|
|
329
|
+
.map(str::to_string)
|
|
330
|
+
})
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
275
334
|
fn parse_model_list(output: &str) -> Vec<String> {
|
|
276
335
|
output
|
|
277
336
|
.lines()
|
|
@@ -309,6 +368,7 @@ fn run_codex_prompt(root: &Path, settings: &Settings, prompt: &str) -> String {
|
|
|
309
368
|
if let Some(model) = settings.model_arg() {
|
|
310
369
|
command.args(["--model", model]);
|
|
311
370
|
}
|
|
371
|
+
add_codex_effort_args(&mut command, settings);
|
|
312
372
|
command.args(["-o", &output_path.display().to_string(), prompt]);
|
|
313
373
|
let result = run_capture(&mut command, "", Duration::from_secs(600));
|
|
314
374
|
let last_message = fs::read_to_string(&output_path).unwrap_or_default();
|
|
@@ -341,6 +401,9 @@ fn run_claude_prompt(root: &Path, settings: &Settings, prompt: &str) -> String {
|
|
|
341
401
|
if let Some(model) = settings.model_arg() {
|
|
342
402
|
command.args(["--model", model]);
|
|
343
403
|
}
|
|
404
|
+
if let Some(effort) = settings.effort_arg() {
|
|
405
|
+
command.args(["--effort", effort]);
|
|
406
|
+
}
|
|
344
407
|
command.args(["-p", prompt]);
|
|
345
408
|
match run_capture(&mut command, "", Duration::from_secs(600)) {
|
|
346
409
|
Ok(run) if run.code == Some(0) => {
|
|
@@ -371,6 +434,7 @@ fn default_codex_next_command(root: &Path, settings: &Settings, request: &str) -
|
|
|
371
434
|
if let Some(model) = settings.model_arg() {
|
|
372
435
|
exec.push_str(&format!(" --model {}", sh_quote(model)));
|
|
373
436
|
}
|
|
437
|
+
push_codex_effort_arg(&mut exec, settings);
|
|
374
438
|
exec.push(' ');
|
|
375
439
|
exec.push_str(&sh_quote(&default_ai_next_prompt_with_settings(
|
|
376
440
|
settings, request,
|
|
@@ -387,6 +451,7 @@ fn default_codex_generate_command(root: &Path, settings: &Settings, request: &st
|
|
|
387
451
|
if let Some(model) = settings.model_arg() {
|
|
388
452
|
exec.push_str(&format!(" --model {}", sh_quote(model)));
|
|
389
453
|
}
|
|
454
|
+
push_codex_effort_arg(&mut exec, settings);
|
|
390
455
|
exec.push(' ');
|
|
391
456
|
exec.push_str(&sh_quote(&default_ai_generate_prompt_with_settings(
|
|
392
457
|
settings, request,
|
|
@@ -407,6 +472,9 @@ fn default_claude_next_command(root: &Path, settings: &Settings, request: &str)
|
|
|
407
472
|
if let Some(model) = settings.model_arg() {
|
|
408
473
|
claude.push_str(&format!(" --model {}", sh_quote(model)));
|
|
409
474
|
}
|
|
475
|
+
if let Some(effort) = settings.effort_arg() {
|
|
476
|
+
claude.push_str(&format!(" --effort {}", sh_quote(effort)));
|
|
477
|
+
}
|
|
410
478
|
claude.push_str(" -p ");
|
|
411
479
|
claude.push_str(&sh_quote(&default_ai_next_prompt_with_settings(
|
|
412
480
|
settings, request,
|
|
@@ -423,6 +491,9 @@ fn default_claude_generate_command(root: &Path, settings: &Settings, request: &s
|
|
|
423
491
|
if let Some(model) = settings.model_arg() {
|
|
424
492
|
claude.push_str(&format!(" --model {}", sh_quote(model)));
|
|
425
493
|
}
|
|
494
|
+
if let Some(effort) = settings.effort_arg() {
|
|
495
|
+
claude.push_str(&format!(" --effort {}", sh_quote(effort)));
|
|
496
|
+
}
|
|
426
497
|
claude.push_str(" -p ");
|
|
427
498
|
claude.push_str(&sh_quote(&default_ai_generate_prompt_with_settings(
|
|
428
499
|
settings, request,
|
|
@@ -442,6 +513,23 @@ fn output_text(stdout: &str, stderr: &str) -> String {
|
|
|
442
513
|
.join("\n")
|
|
443
514
|
}
|
|
444
515
|
|
|
516
|
+
fn add_codex_effort_args(command: &mut Command, settings: &Settings) {
|
|
517
|
+
if let Some(effort) = settings.effort_arg() {
|
|
518
|
+
let effort = if effort == "max" { "xhigh" } else { effort };
|
|
519
|
+
command.args(["-c", &format!("model_reasoning_effort=\"{effort}\"")]);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
fn push_codex_effort_arg(command: &mut String, settings: &Settings) {
|
|
524
|
+
if let Some(effort) = settings.effort_arg() {
|
|
525
|
+
let effort = if effort == "max" { "xhigh" } else { effort };
|
|
526
|
+
command.push_str(&format!(
|
|
527
|
+
" -c {}",
|
|
528
|
+
sh_quote(&format!("model_reasoning_effort=\"{effort}\""))
|
|
529
|
+
));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
445
533
|
fn list_or_none(values: &[String]) -> String {
|
|
446
534
|
if values.is_empty() {
|
|
447
535
|
"(none)".to_string()
|
package/src/core/language.rs
CHANGED
|
@@ -81,6 +81,24 @@ pub fn normalize_ai_provider(provider: &str) -> String {
|
|
|
81
81
|
"codex".to_string()
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
pub fn normalize_ai_effort(provider: &str, effort: &str) -> String {
|
|
86
|
+
let effort = effort.trim().to_lowercase();
|
|
87
|
+
let provider = normalize_ai_provider(provider);
|
|
88
|
+
let allowed = if provider == "claude" {
|
|
89
|
+
CLAUDE_AI_EFFORTS
|
|
90
|
+
} else {
|
|
91
|
+
CODEX_AI_EFFORTS
|
|
92
|
+
};
|
|
93
|
+
if allowed.contains(&effort.as_str()) {
|
|
94
|
+
effort
|
|
95
|
+
} else if provider == "codex" && effort == "max" {
|
|
96
|
+
"xhigh".to_string()
|
|
97
|
+
} else {
|
|
98
|
+
"auto".to_string()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
84
102
|
pub fn template_for(language: &str) -> String {
|
|
85
103
|
match normalize_language(language).as_str() {
|
|
86
104
|
"python" => "# Read from stdin and print to stdout.\nimport sys\n\n\n".to_string(),
|
package/src/core/model.rs
CHANGED
|
@@ -3,6 +3,8 @@ use super::*;
|
|
|
3
3
|
pub const LANGUAGES: &[&str] = &["python", "ts", "java", "rust"];
|
|
4
4
|
pub const THEMES: &[&str] = &["dark", "light"];
|
|
5
5
|
pub const AI_PROVIDERS: &[&str] = &["codex", "claude"];
|
|
6
|
+
pub const CODEX_AI_EFFORTS: &[&str] = &["auto", "low", "medium", "high", "xhigh"];
|
|
7
|
+
pub const CLAUDE_AI_EFFORTS: &[&str] = &["auto", "low", "medium", "high", "xhigh", "max"];
|
|
6
8
|
pub const BANK_PATH: &str = ".practicode/problem_bank.json";
|
|
7
9
|
pub const STATE_PATH: &str = ".practicode/problem-state.json";
|
|
8
10
|
pub const PROBLEM_NOTES_PATH: &str = ".practicode/problem_notes.md";
|
|
@@ -33,6 +35,8 @@ pub struct Settings {
|
|
|
33
35
|
pub ai_provider: String,
|
|
34
36
|
#[serde(default = "default_ai_model")]
|
|
35
37
|
pub ai_model: String,
|
|
38
|
+
#[serde(default = "default_ai_effort")]
|
|
39
|
+
pub ai_effort: String,
|
|
36
40
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
37
41
|
pub ai_next_command: String,
|
|
38
42
|
}
|
|
@@ -52,6 +56,7 @@ impl Default for Settings {
|
|
|
52
56
|
next_source: default_next_source(),
|
|
53
57
|
ai_provider: default_ai_provider(),
|
|
54
58
|
ai_model: default_ai_model(),
|
|
59
|
+
ai_effort: default_ai_effort(),
|
|
55
60
|
ai_next_command: String::new(),
|
|
56
61
|
}
|
|
57
62
|
}
|
|
@@ -70,6 +75,15 @@ impl Settings {
|
|
|
70
75
|
Some(model)
|
|
71
76
|
}
|
|
72
77
|
}
|
|
78
|
+
|
|
79
|
+
pub fn effort_arg(&self) -> Option<&str> {
|
|
80
|
+
let effort = self.ai_effort.trim();
|
|
81
|
+
if effort.is_empty() || effort == "auto" {
|
|
82
|
+
None
|
|
83
|
+
} else {
|
|
84
|
+
Some(effort)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
73
87
|
}
|
|
74
88
|
|
|
75
89
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
@@ -148,6 +162,10 @@ pub fn default_ai_model() -> String {
|
|
|
148
162
|
"auto".to_string()
|
|
149
163
|
}
|
|
150
164
|
|
|
165
|
+
pub fn default_ai_effort() -> String {
|
|
166
|
+
"auto".to_string()
|
|
167
|
+
}
|
|
168
|
+
|
|
151
169
|
pub fn default_suggested_difficulty() -> String {
|
|
152
170
|
"easy".to_string()
|
|
153
171
|
}
|
package/src/core/state.rs
CHANGED
package/src/tui/actions.rs
CHANGED
|
@@ -2,6 +2,7 @@ use super::*;
|
|
|
2
2
|
|
|
3
3
|
impl PracticodeApp {
|
|
4
4
|
pub(super) fn action_edit(&mut self) -> Result<()> {
|
|
5
|
+
self.editing_notes = false;
|
|
5
6
|
self.load_code_editor()?;
|
|
6
7
|
self.settings_cursor = None;
|
|
7
8
|
self.show_output = false;
|
|
@@ -251,6 +252,14 @@ impl PracticodeApp {
|
|
|
251
252
|
Ok(())
|
|
252
253
|
}
|
|
253
254
|
|
|
255
|
+
pub(super) fn set_ai_effort(&mut self, effort: &str) -> Result<()> {
|
|
256
|
+
self.state.settings.ai_effort =
|
|
257
|
+
normalize_ai_effort(&self.state.settings.ai_provider, effort);
|
|
258
|
+
save_state(&self.root, &self.state)?;
|
|
259
|
+
self.write_model_status();
|
|
260
|
+
Ok(())
|
|
261
|
+
}
|
|
262
|
+
|
|
254
263
|
pub(super) fn reset_profile(&mut self) -> Result<()> {
|
|
255
264
|
self.state.settings.difficulty = "auto".to_string();
|
|
256
265
|
self.state.settings.topics.clear();
|
|
@@ -267,6 +276,7 @@ impl PracticodeApp {
|
|
|
267
276
|
}
|
|
268
277
|
|
|
269
278
|
pub(super) fn show_profile_with_intro(&mut self, intro: &str) {
|
|
279
|
+
self.editing_notes = false;
|
|
270
280
|
self.showing_model_status = false;
|
|
271
281
|
if self.settings_cursor.is_none() {
|
|
272
282
|
self.settings_cursor = Some(0);
|
|
@@ -283,7 +293,12 @@ impl PracticodeApp {
|
|
|
283
293
|
}
|
|
284
294
|
|
|
285
295
|
pub(super) fn profile_text(&self) -> String {
|
|
286
|
-
settings_panel::render(
|
|
296
|
+
settings_panel::render(
|
|
297
|
+
&self.state,
|
|
298
|
+
self.settings_cursor,
|
|
299
|
+
&self.available_models,
|
|
300
|
+
self.model_rx.is_some(),
|
|
301
|
+
)
|
|
287
302
|
}
|
|
288
303
|
|
|
289
304
|
pub(super) fn settings_row_count(&self) -> usize {
|
|
@@ -301,7 +316,27 @@ impl PracticodeApp {
|
|
|
301
316
|
let Some(row) = self.settings_cursor else {
|
|
302
317
|
return Ok(());
|
|
303
318
|
};
|
|
304
|
-
|
|
319
|
+
if row == settings_panel::AI_MODEL_ROW
|
|
320
|
+
&& self.available_models_provider != self.state.settings.ai_provider
|
|
321
|
+
{
|
|
322
|
+
self.start_model_check();
|
|
323
|
+
self.check_models();
|
|
324
|
+
if self.model_rx.is_some() {
|
|
325
|
+
self.show_profile();
|
|
326
|
+
return Ok(());
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
let change = settings_panel::apply_selected(&mut self.state, row, &self.available_models);
|
|
330
|
+
if change.edit_notes {
|
|
331
|
+
self.start_note_editor()?;
|
|
332
|
+
return Ok(());
|
|
333
|
+
}
|
|
334
|
+
if change.provider_changed {
|
|
335
|
+
self.model_rx = None;
|
|
336
|
+
self.available_models.clear();
|
|
337
|
+
self.available_models_provider.clear();
|
|
338
|
+
self.model_message = None;
|
|
339
|
+
}
|
|
305
340
|
if change.reload_editor {
|
|
306
341
|
self.load_code_editor()?;
|
|
307
342
|
}
|
|
@@ -309,4 +344,34 @@ impl PracticodeApp {
|
|
|
309
344
|
self.show_profile();
|
|
310
345
|
Ok(())
|
|
311
346
|
}
|
|
347
|
+
|
|
348
|
+
pub(super) fn start_note_editor(&mut self) -> Result<()> {
|
|
349
|
+
self.save_code()?;
|
|
350
|
+
self.note_editor
|
|
351
|
+
.set_text(&read_problem_notes(&self.root).unwrap_or_default());
|
|
352
|
+
self.settings_cursor = None;
|
|
353
|
+
self.showing_model_status = false;
|
|
354
|
+
self.editing_notes = true;
|
|
355
|
+
self.show_output = true;
|
|
356
|
+
self.focus = Focus::Output;
|
|
357
|
+
Ok(())
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
pub(super) fn save_notes(&self) -> Result<()> {
|
|
361
|
+
let path = self.root.join(PROBLEM_NOTES_PATH);
|
|
362
|
+
if let Some(parent) = path.parent() {
|
|
363
|
+
fs::create_dir_all(parent)?;
|
|
364
|
+
}
|
|
365
|
+
let text = self.note_editor.text();
|
|
366
|
+
let text = text.trim_end();
|
|
367
|
+
fs::write(path, if text.is_empty() { "" } else { text })?;
|
|
368
|
+
Ok(())
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
pub(super) fn close_note_editor(&mut self) -> Result<()> {
|
|
372
|
+
self.save_notes()?;
|
|
373
|
+
self.editing_notes = false;
|
|
374
|
+
self.show_profile();
|
|
375
|
+
Ok(())
|
|
376
|
+
}
|
|
312
377
|
}
|
|
@@ -86,6 +86,11 @@ impl PracticodeApp {
|
|
|
86
86
|
}
|
|
87
87
|
"provider" | "ai-provider" if AI_PROVIDERS.contains(&arg) => {
|
|
88
88
|
self.state.settings.ai_provider = normalize_ai_provider(arg);
|
|
89
|
+
self.state.settings.ai_model = "auto".to_string();
|
|
90
|
+
self.state.settings.ai_effort = normalize_ai_effort(
|
|
91
|
+
&self.state.settings.ai_provider,
|
|
92
|
+
&self.state.settings.ai_effort,
|
|
93
|
+
);
|
|
89
94
|
self.model_rx = None;
|
|
90
95
|
self.available_models.clear();
|
|
91
96
|
self.available_models_provider.clear();
|
|
@@ -113,12 +118,17 @@ impl PracticodeApp {
|
|
|
113
118
|
self.check_models();
|
|
114
119
|
self.write_model_status();
|
|
115
120
|
}
|
|
121
|
+
"effort" | "reasoning" | "ai-effort" if arg.is_empty() => {
|
|
122
|
+
self.write_model_status();
|
|
123
|
+
}
|
|
124
|
+
"effort" | "reasoning" | "ai-effort" => self.set_ai_effort(arg)?,
|
|
116
125
|
"hint" if arg.is_empty() => {
|
|
117
126
|
self.start_ai_prompt("Give one concise hint for the current problem.")?
|
|
118
127
|
}
|
|
119
128
|
"hint" | "ask" | "ai" if !arg.is_empty() => self.start_ai_prompt(arg)?,
|
|
120
129
|
"note" if !arg.is_empty() => self.append_note(arg)?,
|
|
121
|
-
"note"
|
|
130
|
+
"note" => self.start_note_editor()?,
|
|
131
|
+
"notes" => self.show_notes()?,
|
|
122
132
|
"update" => self.refresh_update_notice(),
|
|
123
133
|
"exit" | "quit" | "q" => self.should_quit = true,
|
|
124
134
|
_ => self.write_text_output(&format!("Unknown command: {value}\nTry /help.")),
|
package/src/tui/command_input.rs
CHANGED
|
@@ -49,6 +49,9 @@ impl PracticodeApp {
|
|
|
49
49
|
pub(super) fn command_choices(&self) -> Vec<CommandChoice> {
|
|
50
50
|
let mut choices = Vec::new();
|
|
51
51
|
for hint in COMMAND_HINTS {
|
|
52
|
+
if hint.insert == "effort max" && self.state.settings.ai_provider != "claude" {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
52
55
|
if hint.insert == "model " {
|
|
53
56
|
for model in self
|
|
54
57
|
.available_models
|
package/src/tui/commands.rs
CHANGED
|
@@ -162,6 +162,62 @@ pub(super) const COMMAND_HINTS: &[CommandHint] = &[
|
|
|
162
162
|
keep_open: true,
|
|
163
163
|
help: false,
|
|
164
164
|
},
|
|
165
|
+
CommandHint {
|
|
166
|
+
insert: "note",
|
|
167
|
+
display: "/note",
|
|
168
|
+
desc_key: "cmd_note",
|
|
169
|
+
keep_open: false,
|
|
170
|
+
help: true,
|
|
171
|
+
},
|
|
172
|
+
CommandHint {
|
|
173
|
+
insert: "notes",
|
|
174
|
+
display: "/notes",
|
|
175
|
+
desc_key: "cmd_notes",
|
|
176
|
+
keep_open: false,
|
|
177
|
+
help: true,
|
|
178
|
+
},
|
|
179
|
+
CommandHint {
|
|
180
|
+
insert: "effort auto",
|
|
181
|
+
display: "/effort auto",
|
|
182
|
+
desc_key: "cmd_effort_auto",
|
|
183
|
+
keep_open: false,
|
|
184
|
+
help: true,
|
|
185
|
+
},
|
|
186
|
+
CommandHint {
|
|
187
|
+
insert: "effort low",
|
|
188
|
+
display: "/effort low",
|
|
189
|
+
desc_key: "cmd_effort",
|
|
190
|
+
keep_open: false,
|
|
191
|
+
help: false,
|
|
192
|
+
},
|
|
193
|
+
CommandHint {
|
|
194
|
+
insert: "effort medium",
|
|
195
|
+
display: "/effort medium",
|
|
196
|
+
desc_key: "cmd_effort",
|
|
197
|
+
keep_open: false,
|
|
198
|
+
help: false,
|
|
199
|
+
},
|
|
200
|
+
CommandHint {
|
|
201
|
+
insert: "effort high",
|
|
202
|
+
display: "/effort high",
|
|
203
|
+
desc_key: "cmd_effort",
|
|
204
|
+
keep_open: false,
|
|
205
|
+
help: false,
|
|
206
|
+
},
|
|
207
|
+
CommandHint {
|
|
208
|
+
insert: "effort xhigh",
|
|
209
|
+
display: "/effort xhigh",
|
|
210
|
+
desc_key: "cmd_effort",
|
|
211
|
+
keep_open: false,
|
|
212
|
+
help: false,
|
|
213
|
+
},
|
|
214
|
+
CommandHint {
|
|
215
|
+
insert: "effort max",
|
|
216
|
+
display: "/effort max",
|
|
217
|
+
desc_key: "cmd_effort_max",
|
|
218
|
+
keep_open: false,
|
|
219
|
+
help: false,
|
|
220
|
+
},
|
|
165
221
|
CommandHint {
|
|
166
222
|
insert: "language python",
|
|
167
223
|
display: "/language python",
|
package/src/tui/events.rs
CHANGED
|
@@ -9,6 +9,9 @@ impl PracticodeApp {
|
|
|
9
9
|
if self.handle_busy_key(key) {
|
|
10
10
|
return Ok(());
|
|
11
11
|
}
|
|
12
|
+
if self.editing_notes {
|
|
13
|
+
return self.handle_note_key(key);
|
|
14
|
+
}
|
|
12
15
|
match self.focus {
|
|
13
16
|
Focus::Command => self.handle_command_key(key),
|
|
14
17
|
Focus::Code => self.handle_code_key(key),
|
|
@@ -112,6 +115,40 @@ impl PracticodeApp {
|
|
|
112
115
|
Ok(())
|
|
113
116
|
}
|
|
114
117
|
|
|
118
|
+
pub(super) fn handle_note_key(&mut self, key: KeyEvent) -> Result<()> {
|
|
119
|
+
match key.code {
|
|
120
|
+
KeyCode::Esc => self.close_note_editor()?,
|
|
121
|
+
KeyCode::Char(char) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
122
|
+
self.note_editor.insert_char(char);
|
|
123
|
+
self.save_notes()?;
|
|
124
|
+
}
|
|
125
|
+
KeyCode::Enter => {
|
|
126
|
+
self.note_editor.insert_newline();
|
|
127
|
+
self.save_notes()?;
|
|
128
|
+
}
|
|
129
|
+
KeyCode::Backspace => {
|
|
130
|
+
self.note_editor.backspace();
|
|
131
|
+
self.save_notes()?;
|
|
132
|
+
}
|
|
133
|
+
KeyCode::Delete => {
|
|
134
|
+
self.note_editor.delete();
|
|
135
|
+
self.save_notes()?;
|
|
136
|
+
}
|
|
137
|
+
KeyCode::Tab => {
|
|
138
|
+
for _ in 0..4 {
|
|
139
|
+
self.note_editor.insert_char(' ');
|
|
140
|
+
}
|
|
141
|
+
self.save_notes()?;
|
|
142
|
+
}
|
|
143
|
+
KeyCode::Left => self.note_editor.move_left(),
|
|
144
|
+
KeyCode::Right => self.note_editor.move_right(),
|
|
145
|
+
KeyCode::Up => self.note_editor.move_up(),
|
|
146
|
+
KeyCode::Down => self.note_editor.move_down(),
|
|
147
|
+
_ => {}
|
|
148
|
+
}
|
|
149
|
+
Ok(())
|
|
150
|
+
}
|
|
151
|
+
|
|
115
152
|
pub(super) fn handle_global_key(&mut self, key: KeyEvent) -> Result<()> {
|
|
116
153
|
if self.settings_cursor.is_some() {
|
|
117
154
|
match key.code {
|
|
@@ -1,14 +1,30 @@
|
|
|
1
|
-
use crate::core::{
|
|
1
|
+
use crate::core::{
|
|
2
|
+
AI_PROVIDERS, AppState, CLAUDE_AI_EFFORTS, CODEX_AI_EFFORTS, DIFFICULTIES, LANGUAGES, THEMES,
|
|
3
|
+
UI_LANGUAGES, normalize_ai_effort,
|
|
4
|
+
};
|
|
2
5
|
|
|
3
6
|
pub(super) struct SettingsChange {
|
|
4
7
|
pub reload_editor: bool,
|
|
8
|
+
pub provider_changed: bool,
|
|
9
|
+
pub edit_notes: bool,
|
|
5
10
|
}
|
|
6
11
|
|
|
12
|
+
const AI_PROVIDER_ROW: usize = 4;
|
|
13
|
+
pub(super) const AI_MODEL_ROW: usize = 5;
|
|
14
|
+
const AI_EFFORT_ROW: usize = 6;
|
|
15
|
+
const NOTE_ROW: usize = 7;
|
|
16
|
+
const TOGGLE_START: usize = 8;
|
|
17
|
+
|
|
7
18
|
pub(super) fn row_count() -> usize {
|
|
8
|
-
|
|
19
|
+
TOGGLE_START + LANGUAGES.len() + UI_LANGUAGES.len()
|
|
9
20
|
}
|
|
10
21
|
|
|
11
|
-
pub(super) fn render(
|
|
22
|
+
pub(super) fn render(
|
|
23
|
+
state: &AppState,
|
|
24
|
+
cursor: Option<usize>,
|
|
25
|
+
available_models: &[String],
|
|
26
|
+
models_loading: bool,
|
|
27
|
+
) -> String {
|
|
12
28
|
let settings = &state.settings;
|
|
13
29
|
let ui_language = settings.ui_language.as_str();
|
|
14
30
|
let topics = list_or_none(&settings.topics, ui_language);
|
|
@@ -63,20 +79,52 @@ pub(super) fn render(state: &AppState, cursor: Option<usize>) -> String {
|
|
|
63
79
|
"{}: {generate_ui_languages}",
|
|
64
80
|
label(ui_language, "generated_ui_languages")
|
|
65
81
|
),
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
row(
|
|
83
|
+
cursor,
|
|
84
|
+
AI_PROVIDER_ROW,
|
|
85
|
+
&format!("AI provider: {}", settings.ai_provider),
|
|
86
|
+
),
|
|
87
|
+
row(
|
|
88
|
+
cursor,
|
|
89
|
+
AI_MODEL_ROW,
|
|
90
|
+
&format!(
|
|
91
|
+
"AI model: {}{}",
|
|
92
|
+
if settings.ai_model == "auto" {
|
|
93
|
+
label(ui_language, "provider_default")
|
|
94
|
+
} else {
|
|
95
|
+
settings.ai_model.as_str()
|
|
96
|
+
},
|
|
97
|
+
if models_loading {
|
|
98
|
+
" (loading)"
|
|
99
|
+
} else if available_models.is_empty() {
|
|
100
|
+
" (/model to load)"
|
|
101
|
+
} else {
|
|
102
|
+
""
|
|
103
|
+
}
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
row(
|
|
107
|
+
cursor,
|
|
108
|
+
AI_EFFORT_ROW,
|
|
109
|
+
&format!(
|
|
110
|
+
"AI effort: {}",
|
|
111
|
+
if settings.ai_effort == "auto" {
|
|
112
|
+
label(ui_language, "provider_default")
|
|
113
|
+
} else {
|
|
114
|
+
settings.ai_effort.as_str()
|
|
115
|
+
}
|
|
116
|
+
),
|
|
117
|
+
),
|
|
118
|
+
row(
|
|
119
|
+
cursor,
|
|
120
|
+
NOTE_ROW,
|
|
121
|
+
&format!("{}: Enter", label(ui_language, "problem_notes")),
|
|
74
122
|
),
|
|
75
123
|
String::new(),
|
|
76
124
|
label(ui_language, "answer_toggles").to_string(),
|
|
77
125
|
];
|
|
78
126
|
for (index, language) in LANGUAGES.iter().enumerate() {
|
|
79
|
-
let row_index =
|
|
127
|
+
let row_index = TOGGLE_START + index;
|
|
80
128
|
let checked = generate_language_enabled(state, language);
|
|
81
129
|
lines.push(row(
|
|
82
130
|
cursor,
|
|
@@ -87,7 +135,7 @@ pub(super) fn render(state: &AppState, cursor: Option<usize>) -> String {
|
|
|
87
135
|
lines.push(String::new());
|
|
88
136
|
lines.push(label(ui_language, "ui_toggles").to_string());
|
|
89
137
|
for (index, language) in UI_LANGUAGES.iter().enumerate() {
|
|
90
|
-
let row_index =
|
|
138
|
+
let row_index = TOGGLE_START + LANGUAGES.len() + index;
|
|
91
139
|
let checked = generate_ui_language_enabled(state, language);
|
|
92
140
|
lines.push(row(
|
|
93
141
|
cursor,
|
|
@@ -106,10 +154,84 @@ pub(super) fn render(state: &AppState, cursor: Option<usize>) -> String {
|
|
|
106
154
|
"/generate-ui all|en, ko".to_string(),
|
|
107
155
|
"/provider codex|claude".to_string(),
|
|
108
156
|
"/model auto".to_string(),
|
|
157
|
+
"/effort auto|low|medium|high|xhigh|max".to_string(),
|
|
158
|
+
"/note".to_string(),
|
|
159
|
+
"/notes".to_string(),
|
|
109
160
|
]);
|
|
110
161
|
lines.join("\n")
|
|
111
162
|
}
|
|
112
163
|
|
|
164
|
+
pub(super) fn apply_selected(
|
|
165
|
+
state: &mut AppState,
|
|
166
|
+
selected: usize,
|
|
167
|
+
available_models: &[String],
|
|
168
|
+
) -> SettingsChange {
|
|
169
|
+
let mut change = SettingsChange {
|
|
170
|
+
reload_editor: false,
|
|
171
|
+
provider_changed: false,
|
|
172
|
+
edit_notes: false,
|
|
173
|
+
};
|
|
174
|
+
match selected {
|
|
175
|
+
0 => {
|
|
176
|
+
let current = LANGUAGES
|
|
177
|
+
.iter()
|
|
178
|
+
.position(|language| language == &state.settings.language)
|
|
179
|
+
.unwrap_or(0);
|
|
180
|
+
state.settings.language = LANGUAGES[(current + 1) % LANGUAGES.len()].to_string();
|
|
181
|
+
change.reload_editor = true;
|
|
182
|
+
}
|
|
183
|
+
1 => {
|
|
184
|
+
let current = UI_LANGUAGES
|
|
185
|
+
.iter()
|
|
186
|
+
.position(|language| language == &state.settings.ui_language)
|
|
187
|
+
.unwrap_or(0);
|
|
188
|
+
state.settings.ui_language =
|
|
189
|
+
UI_LANGUAGES[(current + 1) % UI_LANGUAGES.len()].to_string();
|
|
190
|
+
}
|
|
191
|
+
2 => {
|
|
192
|
+
let current = THEMES
|
|
193
|
+
.iter()
|
|
194
|
+
.position(|theme| theme == &state.settings.theme)
|
|
195
|
+
.unwrap_or(0);
|
|
196
|
+
state.settings.theme = THEMES[(current + 1) % THEMES.len()].to_string();
|
|
197
|
+
}
|
|
198
|
+
3 => {
|
|
199
|
+
let current = DIFFICULTIES
|
|
200
|
+
.iter()
|
|
201
|
+
.position(|difficulty| difficulty == &state.settings.difficulty)
|
|
202
|
+
.unwrap_or(0);
|
|
203
|
+
let difficulty = DIFFICULTIES[(current + 1) % DIFFICULTIES.len()].to_string();
|
|
204
|
+
state.settings.difficulty = difficulty.clone();
|
|
205
|
+
if difficulty != "auto" {
|
|
206
|
+
state.suggested_next_difficulty = difficulty;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
AI_PROVIDER_ROW => {
|
|
210
|
+
let current = AI_PROVIDERS
|
|
211
|
+
.iter()
|
|
212
|
+
.position(|provider| provider == &state.settings.ai_provider)
|
|
213
|
+
.unwrap_or(0);
|
|
214
|
+
state.settings.ai_provider =
|
|
215
|
+
AI_PROVIDERS[(current + 1) % AI_PROVIDERS.len()].to_string();
|
|
216
|
+
state.settings.ai_model = "auto".to_string();
|
|
217
|
+
state.settings.ai_effort =
|
|
218
|
+
normalize_ai_effort(&state.settings.ai_provider, &state.settings.ai_effort);
|
|
219
|
+
change.provider_changed = true;
|
|
220
|
+
}
|
|
221
|
+
AI_MODEL_ROW => cycle_ai_model(state, available_models),
|
|
222
|
+
AI_EFFORT_ROW => cycle_ai_effort(state),
|
|
223
|
+
NOTE_ROW => change.edit_notes = true,
|
|
224
|
+
row if row < TOGGLE_START + LANGUAGES.len() => {
|
|
225
|
+
toggle_generate_language(state, LANGUAGES[row - TOGGLE_START]);
|
|
226
|
+
}
|
|
227
|
+
row if row < row_count() => {
|
|
228
|
+
toggle_generate_ui_language(state, UI_LANGUAGES[row - TOGGLE_START - LANGUAGES.len()]);
|
|
229
|
+
}
|
|
230
|
+
_ => {}
|
|
231
|
+
}
|
|
232
|
+
change
|
|
233
|
+
}
|
|
234
|
+
|
|
113
235
|
fn label<'a>(ui_language: &str, key: &'a str) -> &'a str {
|
|
114
236
|
if ui_language == "ko" {
|
|
115
237
|
match key {
|
|
@@ -124,6 +246,7 @@ fn label<'a>(ui_language: &str, key: &'a str) -> &'a str {
|
|
|
124
246
|
"generated_answer_languages" => "생성 정답 언어",
|
|
125
247
|
"generated_ui_languages" => "생성 문제 언어",
|
|
126
248
|
"provider_default" => "auto (provider 기본값)",
|
|
249
|
+
"problem_notes" => "문제 생성 메모 편집",
|
|
127
250
|
"answer_toggles" => "생성 정답 언어 토글",
|
|
128
251
|
"ui_toggles" => "생성 문제 언어 토글",
|
|
129
252
|
"commands" => "명령",
|
|
@@ -144,6 +267,7 @@ fn label<'a>(ui_language: &str, key: &'a str) -> &'a str {
|
|
|
144
267
|
"generated_answer_languages" => "Generated answer languages",
|
|
145
268
|
"generated_ui_languages" => "Generated UI languages",
|
|
146
269
|
"provider_default" => "auto (provider default)",
|
|
270
|
+
"problem_notes" => "Edit problem notes",
|
|
147
271
|
"answer_toggles" => "Generated answer language toggles",
|
|
148
272
|
"ui_toggles" => "Generated problem text language toggles",
|
|
149
273
|
"commands" => "Commands",
|
|
@@ -154,52 +278,27 @@ fn label<'a>(ui_language: &str, key: &'a str) -> &'a str {
|
|
|
154
278
|
}
|
|
155
279
|
}
|
|
156
280
|
|
|
157
|
-
|
|
158
|
-
let mut
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
.iter()
|
|
179
|
-
.position(|theme| theme == &state.settings.theme)
|
|
180
|
-
.unwrap_or(0);
|
|
181
|
-
state.settings.theme = THEMES[(current + 1) % THEMES.len()].to_string();
|
|
182
|
-
}
|
|
183
|
-
3 => {
|
|
184
|
-
let current = DIFFICULTIES
|
|
185
|
-
.iter()
|
|
186
|
-
.position(|difficulty| difficulty == &state.settings.difficulty)
|
|
187
|
-
.unwrap_or(0);
|
|
188
|
-
let difficulty = DIFFICULTIES[(current + 1) % DIFFICULTIES.len()].to_string();
|
|
189
|
-
state.settings.difficulty = difficulty.clone();
|
|
190
|
-
if difficulty != "auto" {
|
|
191
|
-
state.suggested_next_difficulty = difficulty;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
row if row < 4 + LANGUAGES.len() => {
|
|
195
|
-
toggle_generate_language(state, LANGUAGES[row - 4]);
|
|
196
|
-
}
|
|
197
|
-
row if row < row_count() => {
|
|
198
|
-
toggle_generate_ui_language(state, UI_LANGUAGES[row - 4 - LANGUAGES.len()]);
|
|
199
|
-
}
|
|
200
|
-
_ => {}
|
|
201
|
-
}
|
|
202
|
-
SettingsChange { reload_editor }
|
|
281
|
+
fn cycle_ai_model(state: &mut AppState, available_models: &[String]) {
|
|
282
|
+
let mut models = vec!["auto"];
|
|
283
|
+
models.extend(available_models.iter().map(String::as_str));
|
|
284
|
+
let current = models
|
|
285
|
+
.iter()
|
|
286
|
+
.position(|model| model == &state.settings.ai_model)
|
|
287
|
+
.unwrap_or(0);
|
|
288
|
+
state.settings.ai_model = models[(current + 1) % models.len()].to_string();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
fn cycle_ai_effort(state: &mut AppState) {
|
|
292
|
+
let efforts = if state.settings.ai_provider == "claude" {
|
|
293
|
+
CLAUDE_AI_EFFORTS
|
|
294
|
+
} else {
|
|
295
|
+
CODEX_AI_EFFORTS
|
|
296
|
+
};
|
|
297
|
+
let current = efforts
|
|
298
|
+
.iter()
|
|
299
|
+
.position(|effort| effort == &state.settings.ai_effort)
|
|
300
|
+
.unwrap_or(0);
|
|
301
|
+
state.settings.ai_effort = efforts[(current + 1) % efforts.len()].to_string();
|
|
203
302
|
}
|
|
204
303
|
|
|
205
304
|
fn row(cursor: Option<usize>, index: usize, text: &str) -> String {
|
package/src/tui/status.rs
CHANGED
|
@@ -75,6 +75,9 @@ impl PracticodeApp {
|
|
|
75
75
|
ui_text(lang, "hint_busy")
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
|
+
if self.editing_notes {
|
|
79
|
+
return "notes: type to edit, Esc profile";
|
|
80
|
+
}
|
|
78
81
|
match (self.focus, self.list_cursor.is_some(), self.show_output) {
|
|
79
82
|
(Focus::Command, _, _) => ui_text(lang, "hint_command"),
|
|
80
83
|
(_, true, _) => ui_text(lang, "hint_list"),
|
package/src/tui/tasks.rs
CHANGED
|
@@ -134,6 +134,10 @@ impl PracticodeApp {
|
|
|
134
134
|
self.output = self.model_status_text();
|
|
135
135
|
self.output_is_markdown = false;
|
|
136
136
|
self.show_output = true;
|
|
137
|
+
} else if self.settings_cursor.is_some() {
|
|
138
|
+
self.output = self.profile_text();
|
|
139
|
+
self.output_is_markdown = false;
|
|
140
|
+
self.show_output = true;
|
|
137
141
|
}
|
|
138
142
|
}
|
|
139
143
|
}
|
|
@@ -149,7 +153,16 @@ impl PracticodeApp {
|
|
|
149
153
|
self.state.settings.ai_model.as_str()
|
|
150
154
|
}
|
|
151
155
|
),
|
|
156
|
+
format!(
|
|
157
|
+
"AI effort: {}",
|
|
158
|
+
if self.state.settings.ai_effort == "auto" {
|
|
159
|
+
"auto (provider default)"
|
|
160
|
+
} else {
|
|
161
|
+
self.state.settings.ai_effort.as_str()
|
|
162
|
+
}
|
|
163
|
+
),
|
|
152
164
|
"Use /model auto to let the provider choose its default.".to_string(),
|
|
165
|
+
"Use /effort auto to let the provider choose its default.".to_string(),
|
|
153
166
|
];
|
|
154
167
|
if self.model_rx.is_some() {
|
|
155
168
|
lines.push("Loading provider model list...".to_string());
|
|
@@ -161,6 +174,15 @@ impl PracticodeApp {
|
|
|
161
174
|
);
|
|
162
175
|
lines.push("Use /model <name> for a known model.".to_string());
|
|
163
176
|
} else {
|
|
177
|
+
if let Some(message) = &self.model_message {
|
|
178
|
+
lines.push(message.clone());
|
|
179
|
+
}
|
|
180
|
+
let efforts = if self.state.settings.ai_provider == "claude" {
|
|
181
|
+
CLAUDE_AI_EFFORTS
|
|
182
|
+
} else {
|
|
183
|
+
CODEX_AI_EFFORTS
|
|
184
|
+
};
|
|
185
|
+
lines.push(format!("Available efforts: {}", efforts.join(", ")));
|
|
164
186
|
lines.push("Available models:".to_string());
|
|
165
187
|
lines.extend(
|
|
166
188
|
self.available_models
|
|
@@ -212,6 +234,7 @@ impl PracticodeApp {
|
|
|
212
234
|
|
|
213
235
|
pub(super) fn write_output(&mut self, output: &str) {
|
|
214
236
|
self.settings_cursor = None;
|
|
237
|
+
self.editing_notes = false;
|
|
215
238
|
self.showing_model_status = false;
|
|
216
239
|
self.output = output.to_string();
|
|
217
240
|
self.output_is_markdown = true;
|
|
@@ -221,6 +244,7 @@ impl PracticodeApp {
|
|
|
221
244
|
|
|
222
245
|
pub(super) fn write_text_output(&mut self, output: &str) {
|
|
223
246
|
self.settings_cursor = None;
|
|
247
|
+
self.editing_notes = false;
|
|
224
248
|
self.showing_model_status = false;
|
|
225
249
|
self.output = output.trim_end().to_string();
|
|
226
250
|
self.output_is_markdown = false;
|
|
@@ -270,7 +294,7 @@ impl PracticodeApp {
|
|
|
270
294
|
pub(super) fn show_notes(&mut self) -> Result<()> {
|
|
271
295
|
let notes = read_problem_notes(&self.root)?;
|
|
272
296
|
if notes.is_empty() {
|
|
273
|
-
self.write_text_output("No notes yet. Use /
|
|
297
|
+
self.write_text_output("No notes yet. Use /note to edit problem-generation notes.");
|
|
274
298
|
} else {
|
|
275
299
|
self.write_text_output(&format!("Problem notes ({PROBLEM_NOTES_PATH})\n\n{notes}"));
|
|
276
300
|
}
|
package/src/tui/view.rs
CHANGED
|
@@ -15,36 +15,57 @@ impl PracticodeApp {
|
|
|
15
15
|
.direction(Direction::Horizontal)
|
|
16
16
|
.constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
|
|
17
17
|
.split(vertical[0]);
|
|
18
|
-
self.code_area =
|
|
19
|
-
|
|
18
|
+
self.code_area = if self.show_output {
|
|
19
|
+
Rect::default()
|
|
20
|
+
} else {
|
|
21
|
+
body[1]
|
|
22
|
+
};
|
|
23
|
+
self.output_area = if self.show_output {
|
|
24
|
+
vertical[0]
|
|
25
|
+
} else {
|
|
26
|
+
self.code_area
|
|
27
|
+
};
|
|
20
28
|
self.command_area = vertical[2];
|
|
21
29
|
|
|
22
30
|
let light = self.state.settings.theme == "light";
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
if !self.show_output {
|
|
32
|
+
let problem = Paragraph::new(problem_view::render(
|
|
33
|
+
&self.problem,
|
|
34
|
+
&self.state.settings.ui_language,
|
|
35
|
+
light,
|
|
36
|
+
))
|
|
37
|
+
.style(Self::pane_style(light))
|
|
38
|
+
.block(Self::block(
|
|
39
|
+
ui_text(&self.state.settings.ui_language, "problem"),
|
|
40
|
+
light,
|
|
41
|
+
false,
|
|
42
|
+
))
|
|
43
|
+
.wrap(Wrap { trim: false });
|
|
44
|
+
frame.render_widget(problem, body[0]);
|
|
45
|
+
}
|
|
36
46
|
|
|
37
47
|
if self.show_output {
|
|
38
|
-
let text = self.
|
|
48
|
+
let text = if self.editing_notes {
|
|
49
|
+
Text::from(
|
|
50
|
+
self.note_editor
|
|
51
|
+
.visible_text(self.output_area.height.saturating_sub(2) as usize),
|
|
52
|
+
)
|
|
53
|
+
} else {
|
|
54
|
+
self.output_text()
|
|
55
|
+
};
|
|
39
56
|
let output = Paragraph::new(text)
|
|
40
57
|
.style(Self::pane_style(light))
|
|
41
58
|
.block(Self::block(
|
|
42
|
-
|
|
59
|
+
if self.editing_notes {
|
|
60
|
+
PROBLEM_NOTES_PATH
|
|
61
|
+
} else {
|
|
62
|
+
ui_text(&self.state.settings.ui_language, "output")
|
|
63
|
+
},
|
|
43
64
|
light,
|
|
44
65
|
self.focus != Focus::Command,
|
|
45
66
|
))
|
|
46
67
|
.wrap(Wrap { trim: false });
|
|
47
|
-
frame.render_widget(output,
|
|
68
|
+
frame.render_widget(output, self.output_area);
|
|
48
69
|
} else {
|
|
49
70
|
let code = self
|
|
50
71
|
.editor
|
|
@@ -84,7 +105,7 @@ impl PracticodeApp {
|
|
|
84
105
|
.wrap(Wrap { trim: false });
|
|
85
106
|
frame.render_widget(command, vertical[2]);
|
|
86
107
|
self.draw_command_palette(frame, vertical[2]);
|
|
87
|
-
self.set_terminal_cursor(frame,
|
|
108
|
+
self.set_terminal_cursor(frame, self.code_area, vertical[2]);
|
|
88
109
|
}
|
|
89
110
|
|
|
90
111
|
pub(super) fn wants_mouse_capture(&self) -> bool {
|
|
@@ -336,7 +357,39 @@ impl PracticodeApp {
|
|
|
336
357
|
frame.set_cursor_position(position);
|
|
337
358
|
}
|
|
338
359
|
}
|
|
360
|
+
Focus::Output if self.editing_notes => {
|
|
361
|
+
if let Some(position) = self.note_editor.cursor_position(self.output_area) {
|
|
362
|
+
frame.set_cursor_position(position);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
339
365
|
_ => {}
|
|
340
366
|
}
|
|
341
367
|
}
|
|
342
368
|
}
|
|
369
|
+
|
|
370
|
+
#[cfg(test)]
|
|
371
|
+
mod tests {
|
|
372
|
+
use super::*;
|
|
373
|
+
use ratatui::{Terminal, backend::TestBackend};
|
|
374
|
+
|
|
375
|
+
fn tmp_root(name: &str) -> PathBuf {
|
|
376
|
+
let root =
|
|
377
|
+
std::env::temp_dir().join(format!("practicode-view-{name}-{}", std::process::id()));
|
|
378
|
+
let _ = fs::remove_dir_all(&root);
|
|
379
|
+
fs::create_dir_all(&root).unwrap();
|
|
380
|
+
root
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#[test]
|
|
384
|
+
fn output_uses_full_body_so_terminal_drag_selection_has_no_side_pane() {
|
|
385
|
+
let mut app = PracticodeApp::new(tmp_root("full-output")).unwrap();
|
|
386
|
+
app.handle_command("help").unwrap();
|
|
387
|
+
|
|
388
|
+
let backend = TestBackend::new(80, 24);
|
|
389
|
+
let mut terminal = Terminal::new(backend).unwrap();
|
|
390
|
+
terminal.draw(|frame| app.draw(frame)).unwrap();
|
|
391
|
+
|
|
392
|
+
assert_eq!(app.output_area, Rect::new(0, 0, 80, 20));
|
|
393
|
+
assert_eq!(app.code_area, Rect::default());
|
|
394
|
+
}
|
|
395
|
+
}
|
package/src/tui.rs
CHANGED
|
@@ -4,12 +4,13 @@ use crate::{
|
|
|
4
4
|
run_ai_generate, run_ai_next, run_ai_prompt,
|
|
5
5
|
},
|
|
6
6
|
core::{
|
|
7
|
-
AI_PROVIDERS, AppState,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
AI_PROVIDERS, AppState, CLAUDE_AI_EFFORTS, CODEX_AI_EFFORTS, DIFFICULTIES, HistoryItem,
|
|
8
|
+
LANGUAGES, PROBLEM_NOTES_PATH, Problem, STATE_PATH, THEMES, UI_LANGUAGES,
|
|
9
|
+
ensure_problem_files, ensure_submission, ext_for, give_up, judge, load_bank, load_state,
|
|
10
|
+
localized, next_problem, normalize_ai_effort, normalize_ai_provider, normalize_difficulty,
|
|
11
|
+
normalize_language, normalize_next_source, normalize_ui_language, parse_language_list,
|
|
12
|
+
parse_topic_list, parse_ui_language_list, previous_problem, problem_by_id, record_pass,
|
|
13
|
+
save_state, template_for, ui_text,
|
|
13
14
|
},
|
|
14
15
|
text::{
|
|
15
16
|
byte_index, char_len, compose_hangul_jamo, display_width, prefix, render_markdown_plain,
|
|
@@ -78,12 +79,14 @@ pub struct PracticodeApp {
|
|
|
78
79
|
state: AppState,
|
|
79
80
|
problem: Problem,
|
|
80
81
|
editor: TextEditor,
|
|
82
|
+
note_editor: TextEditor,
|
|
81
83
|
command: String,
|
|
82
84
|
command_cursor: usize,
|
|
83
85
|
command_palette_cursor: usize,
|
|
84
86
|
output: String,
|
|
85
87
|
output_is_markdown: bool,
|
|
86
88
|
showing_model_status: bool,
|
|
89
|
+
editing_notes: bool,
|
|
87
90
|
show_output: bool,
|
|
88
91
|
focus: Focus,
|
|
89
92
|
list_cursor: Option<usize>,
|
|
@@ -137,12 +140,14 @@ impl PracticodeApp {
|
|
|
137
140
|
state,
|
|
138
141
|
problem,
|
|
139
142
|
editor: TextEditor::default(),
|
|
143
|
+
note_editor: TextEditor::default(),
|
|
140
144
|
command: String::new(),
|
|
141
145
|
command_cursor: 0,
|
|
142
146
|
command_palette_cursor: 0,
|
|
143
147
|
output: String::new(),
|
|
144
148
|
output_is_markdown: false,
|
|
145
149
|
showing_model_status: false,
|
|
150
|
+
editing_notes: false,
|
|
146
151
|
show_output: false,
|
|
147
152
|
focus: Focus::Code,
|
|
148
153
|
list_cursor: None,
|