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 CHANGED
@@ -453,7 +453,7 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
453
453
 
454
454
  [[package]]
455
455
  name = "practicode"
456
- version = "0.1.10"
456
+ version = "0.1.11"
457
457
  dependencies = [
458
458
  "anyhow",
459
459
  "crossterm",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "practicode"
3
- version = "0.1.10"
3
+ version = "0.1.11"
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
@@ -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, and generation language scope. `auto` difficulty follows gradual progression; a fixed difficulty asks local selection and AI generation to prefer that level.
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 or enable/disable generated answer/UI languages. Use slash commands for free-form lists such as `/topics arrays, strings`.
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:
@@ -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
- "cmd_note": "Add a next-problem note",
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",
@@ -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
- "cmd_note": "Agregar nota para generar problemas",
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",
@@ -47,7 +47,10 @@
47
47
  "cmd_model_auto": "provider の既定モデルを使用",
48
48
  "cmd_model_available": "利用可能な provider モデルを使用",
49
49
  "cmd_model_custom": "モデル名を直接入力",
50
- "cmd_note": "次の問題生成メモを追加",
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 言語を設定",
@@ -47,7 +47,10 @@
47
47
  "cmd_model_auto": "provider 기본 모델 사용",
48
48
  "cmd_model_available": "사용 가능한 provider 모델 선택",
49
49
  "cmd_model_custom": "모델 이름 직접 입력",
50
- "cmd_note": "다음 문제 생성 메모 추가",
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 언어 설정",
@@ -47,7 +47,10 @@
47
47
  "cmd_model_auto": "使用 provider 默认模型",
48
48
  "cmd_model_available": "使用可用的 provider 模型",
49
49
  "cmd_model_custom": "输入模型名称",
50
- "cmd_note": "添加下次出题备注",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "practicode",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
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
@@ -1,7 +1,7 @@
1
1
  use crate::{
2
2
  core::{
3
- AppState, LANGUAGES, PROBLEM_NOTES_PATH, Problem, Settings, UI_LANGUAGES,
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: Vec::new(),
155
- message: Some(
156
- "Claude CLI does not expose a model list; use /model <name> for a known model."
157
- .to_string(),
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: Vec::new(),
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: Vec::new(),
247
- message: Some("Could not query Codex model list.".to_string()),
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: Vec::new(),
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!("Could not query Codex model list: {detail}")
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()
@@ -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
@@ -77,4 +77,5 @@ pub fn normalize_settings(settings: &mut Settings) {
77
77
  if settings.ai_model.trim().is_empty() {
78
78
  settings.ai_model = default_ai_model();
79
79
  }
80
+ settings.ai_effort = normalize_ai_effort(&settings.ai_provider, &settings.ai_effort);
80
81
  }
@@ -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(&self.state, self.settings_cursor)
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
- let change = settings_panel::apply_selected(&mut self.state, row);
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" | "notes" => self.show_notes()?,
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.")),
@@ -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
@@ -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::{AppState, DIFFICULTIES, LANGUAGES, THEMES, UI_LANGUAGES};
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
- 4 + LANGUAGES.len() + UI_LANGUAGES.len()
19
+ TOGGLE_START + LANGUAGES.len() + UI_LANGUAGES.len()
9
20
  }
10
21
 
11
- pub(super) fn render(state: &AppState, cursor: Option<usize>) -> String {
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
- format!("AI provider: {}", settings.ai_provider),
67
- format!(
68
- "AI model: {}",
69
- if settings.ai_model == "auto" {
70
- label(ui_language, "provider_default")
71
- } else {
72
- settings.ai_model.as_str()
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 = 4 + 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 = 4 + LANGUAGES.len() + 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
- pub(super) fn apply_selected(state: &mut AppState, selected: usize) -> SettingsChange {
158
- let mut reload_editor = false;
159
- match selected {
160
- 0 => {
161
- let current = LANGUAGES
162
- .iter()
163
- .position(|language| language == &state.settings.language)
164
- .unwrap_or(0);
165
- state.settings.language = LANGUAGES[(current + 1) % LANGUAGES.len()].to_string();
166
- reload_editor = true;
167
- }
168
- 1 => {
169
- let current = UI_LANGUAGES
170
- .iter()
171
- .position(|language| language == &state.settings.ui_language)
172
- .unwrap_or(0);
173
- state.settings.ui_language =
174
- UI_LANGUAGES[(current + 1) % UI_LANGUAGES.len()].to_string();
175
- }
176
- 2 => {
177
- let current = THEMES
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 /topics or /avoid for standing preferences.");
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 = body[1];
19
- self.output_area = body[1];
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
- let problem = Paragraph::new(problem_view::render(
24
- &self.problem,
25
- &self.state.settings.ui_language,
26
- light,
27
- ))
28
- .style(Self::pane_style(light))
29
- .block(Self::block(
30
- ui_text(&self.state.settings.ui_language, "problem"),
31
- light,
32
- false,
33
- ))
34
- .wrap(Wrap { trim: false });
35
- frame.render_widget(problem, body[0]);
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.output_text();
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
- ui_text(&self.state.settings.ui_language, "output"),
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, body[1]);
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, body[1], vertical[2]);
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, DIFFICULTIES, HistoryItem, LANGUAGES, PROBLEM_NOTES_PATH, Problem,
8
- STATE_PATH, THEMES, UI_LANGUAGES, ensure_problem_files, ensure_submission, ext_for,
9
- give_up, judge, load_bank, load_state, localized, next_problem, normalize_ai_provider,
10
- normalize_difficulty, normalize_language, normalize_next_source, normalize_ui_language,
11
- parse_language_list, parse_topic_list, parse_ui_language_list, previous_problem,
12
- problem_by_id, record_pass, save_state, template_for, ui_text,
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,