practicode
Advanced tools
@@ -50,3 +50,6 @@ { | ||
| "cmd_model_custom": "Type a model name", | ||
| "cmd_note": "Add a next-problem note", | ||
| "cmd_effort": "Set AI effort", | ||
| "cmd_effort_auto": "Use provider default effort", | ||
| "cmd_effort_max": "Use Claude max effort", | ||
| "cmd_note": "Edit problem-generation notes", | ||
| "cmd_notes": "Show saved notes", | ||
@@ -53,0 +56,0 @@ "cmd_lang": "Set code language", |
@@ -50,3 +50,6 @@ { | ||
| "cmd_model_custom": "Escribir un nombre de modelo", | ||
| "cmd_note": "Agregar nota para generar problemas", | ||
| "cmd_effort": "Configurar AI effort", | ||
| "cmd_effort_auto": "Usar el effort predeterminado del provider", | ||
| "cmd_effort_max": "Usar Claude max effort", | ||
| "cmd_note": "Editar notas para generar problemas", | ||
| "cmd_notes": "Ver notas guardadas", | ||
@@ -53,0 +56,0 @@ "cmd_lang": "Configurar lenguaje de codigo", |
@@ -50,3 +50,6 @@ { | ||
| "cmd_model_custom": "モデル名を直接入力", | ||
| "cmd_note": "次の問題生成メモを追加", | ||
| "cmd_effort": "AI effort を設定", | ||
| "cmd_effort_auto": "provider の既定 effort を使用", | ||
| "cmd_effort_max": "Claude max effort を使用", | ||
| "cmd_note": "問題生成メモを編集", | ||
| "cmd_notes": "保存済みメモを見る", | ||
@@ -53,0 +56,0 @@ "cmd_lang": "コード言語を設定", |
@@ -50,3 +50,6 @@ { | ||
| "cmd_model_custom": "모델 이름 직접 입력", | ||
| "cmd_note": "다음 문제 생성 메모 추가", | ||
| "cmd_effort": "AI effort 설정", | ||
| "cmd_effort_auto": "provider 기본 effort 사용", | ||
| "cmd_effort_max": "Claude max effort 사용", | ||
| "cmd_note": "문제 생성 메모 편집", | ||
| "cmd_notes": "저장된 메모 보기", | ||
@@ -53,0 +56,0 @@ "cmd_lang": "코드 언어 설정", |
@@ -50,3 +50,6 @@ { | ||
| "cmd_model_custom": "输入模型名称", | ||
| "cmd_note": "添加下次出题备注", | ||
| "cmd_effort": "设置 AI effort", | ||
| "cmd_effort_auto": "使用 provider 默认 effort", | ||
| "cmd_effort_max": "使用 Claude max effort", | ||
| "cmd_note": "编辑出题备注", | ||
| "cmd_notes": "查看保存的备注", | ||
@@ -53,0 +56,0 @@ "cmd_lang": "设置代码语言", |
+1
-1
@@ -456,3 +456,3 @@ # This file is automatically @generated by Cargo. | ||
| name = "practicode" | ||
| version = "0.1.10" | ||
| version = "0.1.11" | ||
| dependencies = [ | ||
@@ -459,0 +459,0 @@ "anyhow", |
+1
-1
| [package] | ||
| name = "practicode" | ||
| version = "0.1.10" | ||
| version = "0.1.11" | ||
| edition = "2024" | ||
@@ -5,0 +5,0 @@ description = "Local-first coding-test practice in a Rust terminal UI with optional AI help." |
+1
-1
| { | ||
| "name": "practicode", | ||
| "version": "0.1.10", | ||
| "version": "0.1.11", | ||
| "description": "Local-first coding-test practice in a Rust terminal UI with optional AI help.", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
+7
-2
@@ -114,2 +114,5 @@ # practicode | ||
| | `/model auto` | Use the provider default model for `/hint` and AI-backed `/next` | | ||
| | `/effort auto` | Use the provider default effort, or set `low`, `medium`, `high`, `xhigh`; Claude also supports `max` | | ||
| | `/note` | Edit problem-generation notes used by AI-backed `/next` and `/generate` | | ||
| | `/notes` | Show saved problem-generation notes | | ||
| | `/language python` | Set code language: `python`, `ts`, `java`, `rust` | | ||
@@ -125,5 +128,5 @@ | `/ui en` | Set UI language: `en`, `ko`, `ja`, `zh`, `es` | | ||
| 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. | ||
| 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. | ||
| 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`. | ||
| 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`. | ||
@@ -149,2 +152,3 @@ ## Problem Flow | ||
| /model auto | ||
| /effort auto | ||
| ``` | ||
@@ -157,2 +161,3 @@ | ||
| /model sonnet | ||
| /effort high | ||
| ``` | ||
@@ -159,0 +164,0 @@ |
+103
-15
| use crate::{ | ||
| core::{ | ||
| AppState, LANGUAGES, PROBLEM_NOTES_PATH, Problem, Settings, UI_LANGUAGES, | ||
| ensure_submission, normalize_ai_provider, render_problem, | ||
| AppState, CLAUDE_AI_EFFORTS, LANGUAGES, PROBLEM_NOTES_PATH, Problem, Settings, | ||
| UI_LANGUAGES, ensure_submission, normalize_ai_provider, render_problem, | ||
| }, | ||
@@ -24,2 +24,6 @@ process::{run_capture, sh_quote, shell_process, unique_temp_path, which}, | ||
| const CODEX_MODEL_FALLBACKS: &[&str] = | ||
| &["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"]; | ||
| const CLAUDE_MODEL_FALLBACKS: &[&str] = &["sonnet", "opus", "fable", "claude-fable-5"]; | ||
| pub fn run_ai_prompt(root: &Path, problem: &Problem, settings: &Settings, prompt: &str) -> String { | ||
@@ -63,3 +67,4 @@ let solution = match ensure_submission(root, problem, settings) { | ||
| .env("PRACTICODE_AI_PROVIDER", &provider) | ||
| .env("PRACTICODE_AI_MODEL", &state.settings.ai_model); | ||
| .env("PRACTICODE_AI_MODEL", &state.settings.ai_model) | ||
| .env("PRACTICODE_AI_EFFORT", &state.settings.ai_effort); | ||
| match run_capture(&mut process, "", Duration::from_secs(900)) { | ||
@@ -96,3 +101,4 @@ Ok(run) if run.code == Some(0) => { | ||
| .env("PRACTICODE_AI_PROVIDER", &provider) | ||
| .env("PRACTICODE_AI_MODEL", &state.settings.ai_model); | ||
| .env("PRACTICODE_AI_MODEL", &state.settings.ai_model) | ||
| .env("PRACTICODE_AI_EFFORT", &state.settings.ai_effort); | ||
| match run_capture(&mut process, "", Duration::from_secs(900)) { | ||
@@ -157,7 +163,11 @@ Ok(run) if run.code == Some(0) => { | ||
| "claude" => ModelCatalog { | ||
| models: Vec::new(), | ||
| message: Some( | ||
| "Claude CLI does not expose a model list; use /model <name> for a known model." | ||
| .to_string(), | ||
| ), | ||
| models: CLAUDE_MODEL_FALLBACKS | ||
| .iter() | ||
| .map(|model| (*model).to_string()) | ||
| .collect(), | ||
| message: Some(format!( | ||
| "Bundled Claude presets from Claude Code {} --help. Efforts: {}.", | ||
| claude_version().unwrap_or_else(|| "current".to_string()), | ||
| CLAUDE_AI_EFFORTS.join(", ") | ||
| )), | ||
| }, | ||
@@ -238,3 +248,3 @@ _ => ModelCatalog::default(), | ||
| return ModelCatalog { | ||
| models: Vec::new(), | ||
| models: codex_cached_models(), | ||
| message: Some("Codex app-server daemon is unavailable; install the standalone Codex app to list models, or use /model <name>.".to_string()), | ||
@@ -251,4 +261,6 @@ }; | ||
| return ModelCatalog { | ||
| models: Vec::new(), | ||
| message: Some("Could not query Codex model list.".to_string()), | ||
| models: codex_cached_models(), | ||
| message: Some( | ||
| "Could not query Codex model list; using bundled/cache model presets.".to_string(), | ||
| ), | ||
| }; | ||
@@ -259,7 +271,9 @@ }; | ||
| return ModelCatalog { | ||
| models: Vec::new(), | ||
| models: codex_cached_models(), | ||
| message: Some(if detail.is_empty() { | ||
| "Could not query Codex model list.".to_string() | ||
| "Could not query Codex model list; using bundled/cache model presets.".to_string() | ||
| } else { | ||
| format!("Could not query Codex model list: {detail}") | ||
| format!( | ||
| "Could not query Codex model list; using bundled/cache model presets: {detail}" | ||
| ) | ||
| }), | ||
@@ -282,2 +296,47 @@ }; | ||
| fn codex_cached_models() -> Vec<String> { | ||
| let mut models = env::var_os("HOME") | ||
| .and_then(|home| { | ||
| fs::read_to_string(PathBuf::from(home).join(".codex/models_cache.json")).ok() | ||
| }) | ||
| .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok()) | ||
| .and_then(|value| { | ||
| value | ||
| .get("models") | ||
| .and_then(|models| models.as_array()) | ||
| .cloned() | ||
| }) | ||
| .unwrap_or_default() | ||
| .into_iter() | ||
| .filter_map(|model| { | ||
| model | ||
| .get("slug") | ||
| .and_then(|value| value.as_str()) | ||
| .map(str::to_string) | ||
| }) | ||
| .collect::<Vec<_>>(); | ||
| if models.is_empty() { | ||
| models = CODEX_MODEL_FALLBACKS | ||
| .iter() | ||
| .map(|model| (*model).to_string()) | ||
| .collect(); | ||
| } | ||
| models | ||
| } | ||
| fn claude_version() -> Option<String> { | ||
| which("claude").and_then(|_| { | ||
| let mut command = Command::new("claude"); | ||
| command.arg("--version"); | ||
| run_capture(&mut command, "", Duration::from_secs(2)) | ||
| .ok() | ||
| .and_then(|run| { | ||
| output_text(&run.stdout, &run.stderr) | ||
| .lines() | ||
| .next() | ||
| .map(str::to_string) | ||
| }) | ||
| }) | ||
| } | ||
| fn parse_model_list(output: &str) -> Vec<String> { | ||
@@ -320,2 +379,3 @@ output | ||
| } | ||
| add_codex_effort_args(&mut command, settings); | ||
| command.args(["-o", &output_path.display().to_string(), prompt]); | ||
@@ -353,2 +413,5 @@ let result = run_capture(&mut command, "", Duration::from_secs(600)); | ||
| } | ||
| if let Some(effort) = settings.effort_arg() { | ||
| command.args(["--effort", effort]); | ||
| } | ||
| command.args(["-p", prompt]); | ||
@@ -384,2 +447,3 @@ match run_capture(&mut command, "", Duration::from_secs(600)) { | ||
| } | ||
| push_codex_effort_arg(&mut exec, settings); | ||
| exec.push(' '); | ||
@@ -401,2 +465,3 @@ exec.push_str(&sh_quote(&default_ai_next_prompt_with_settings( | ||
| } | ||
| push_codex_effort_arg(&mut exec, settings); | ||
| exec.push(' '); | ||
@@ -422,2 +487,5 @@ exec.push_str(&sh_quote(&default_ai_generate_prompt_with_settings( | ||
| } | ||
| if let Some(effort) = settings.effort_arg() { | ||
| claude.push_str(&format!(" --effort {}", sh_quote(effort))); | ||
| } | ||
| claude.push_str(" -p "); | ||
@@ -439,2 +507,5 @@ claude.push_str(&sh_quote(&default_ai_next_prompt_with_settings( | ||
| } | ||
| if let Some(effort) = settings.effort_arg() { | ||
| claude.push_str(&format!(" --effort {}", sh_quote(effort))); | ||
| } | ||
| claude.push_str(" -p "); | ||
@@ -459,2 +530,19 @@ claude.push_str(&sh_quote(&default_ai_generate_prompt_with_settings( | ||
| fn add_codex_effort_args(command: &mut Command, settings: &Settings) { | ||
| if let Some(effort) = settings.effort_arg() { | ||
| let effort = if effort == "max" { "xhigh" } else { effort }; | ||
| command.args(["-c", &format!("model_reasoning_effort=\"{effort}\"")]); | ||
| } | ||
| } | ||
| fn push_codex_effort_arg(command: &mut String, settings: &Settings) { | ||
| if let Some(effort) = settings.effort_arg() { | ||
| let effort = if effort == "max" { "xhigh" } else { effort }; | ||
| command.push_str(&format!( | ||
| " -c {}", | ||
| sh_quote(&format!("model_reasoning_effort=\"{effort}\"")) | ||
| )); | ||
| } | ||
| } | ||
| fn list_or_none(values: &[String]) -> String { | ||
@@ -461,0 +549,0 @@ if values.is_empty() { |
+18
-0
@@ -84,2 +84,20 @@ use super::*; | ||
| } | ||
| pub fn normalize_ai_effort(provider: &str, effort: &str) -> String { | ||
| let effort = effort.trim().to_lowercase(); | ||
| let provider = normalize_ai_provider(provider); | ||
| let allowed = if provider == "claude" { | ||
| CLAUDE_AI_EFFORTS | ||
| } else { | ||
| CODEX_AI_EFFORTS | ||
| }; | ||
| if allowed.contains(&effort.as_str()) { | ||
| effort | ||
| } else if provider == "codex" && effort == "max" { | ||
| "xhigh".to_string() | ||
| } else { | ||
| "auto".to_string() | ||
| } | ||
| } | ||
| pub fn template_for(language: &str) -> String { | ||
@@ -86,0 +104,0 @@ match normalize_language(language).as_str() { |
+18
-0
@@ -6,2 +6,4 @@ use super::*; | ||
| pub const AI_PROVIDERS: &[&str] = &["codex", "claude"]; | ||
| pub const CODEX_AI_EFFORTS: &[&str] = &["auto", "low", "medium", "high", "xhigh"]; | ||
| pub const CLAUDE_AI_EFFORTS: &[&str] = &["auto", "low", "medium", "high", "xhigh", "max"]; | ||
| pub const BANK_PATH: &str = ".practicode/problem_bank.json"; | ||
@@ -37,2 +39,4 @@ pub const STATE_PATH: &str = ".practicode/problem-state.json"; | ||
| pub ai_model: String, | ||
| #[serde(default = "default_ai_effort")] | ||
| pub ai_effort: String, | ||
| #[serde(default, skip_serializing_if = "String::is_empty")] | ||
@@ -57,2 +61,3 @@ pub ai_next_command: String, | ||
| ai_model: default_ai_model(), | ||
| ai_effort: default_ai_effort(), | ||
| ai_next_command: String::new(), | ||
@@ -76,2 +81,11 @@ } | ||
| } | ||
| pub fn effort_arg(&self) -> Option<&str> { | ||
| let effort = self.ai_effort.trim(); | ||
| if effort.is_empty() || effort == "auto" { | ||
| None | ||
| } else { | ||
| Some(effort) | ||
| } | ||
| } | ||
| } | ||
@@ -155,4 +169,8 @@ | ||
| pub fn default_ai_effort() -> String { | ||
| "auto".to_string() | ||
| } | ||
| pub fn default_suggested_difficulty() -> String { | ||
| "easy".to_string() | ||
| } |
@@ -80,2 +80,3 @@ use super::*; | ||
| } | ||
| settings.ai_effort = normalize_ai_effort(&settings.ai_provider, &settings.ai_effort); | ||
| } |
+11
-6
@@ -7,8 +7,9 @@ use crate::{ | ||
| core::{ | ||
| AI_PROVIDERS, AppState, DIFFICULTIES, HistoryItem, LANGUAGES, PROBLEM_NOTES_PATH, Problem, | ||
| STATE_PATH, THEMES, UI_LANGUAGES, ensure_problem_files, ensure_submission, ext_for, | ||
| give_up, judge, load_bank, load_state, localized, next_problem, normalize_ai_provider, | ||
| normalize_difficulty, normalize_language, normalize_next_source, normalize_ui_language, | ||
| parse_language_list, parse_topic_list, parse_ui_language_list, previous_problem, | ||
| problem_by_id, record_pass, save_state, template_for, ui_text, | ||
| AI_PROVIDERS, AppState, CLAUDE_AI_EFFORTS, CODEX_AI_EFFORTS, DIFFICULTIES, HistoryItem, | ||
| LANGUAGES, PROBLEM_NOTES_PATH, Problem, STATE_PATH, THEMES, UI_LANGUAGES, | ||
| ensure_problem_files, ensure_submission, ext_for, give_up, judge, load_bank, load_state, | ||
| localized, next_problem, normalize_ai_effort, normalize_ai_provider, normalize_difficulty, | ||
| normalize_language, normalize_next_source, normalize_ui_language, parse_language_list, | ||
| parse_topic_list, parse_ui_language_list, previous_problem, problem_by_id, record_pass, | ||
| save_state, template_for, ui_text, | ||
| }, | ||
@@ -82,2 +83,3 @@ text::{ | ||
| editor: TextEditor, | ||
| note_editor: TextEditor, | ||
| command: String, | ||
@@ -89,2 +91,3 @@ command_cursor: usize, | ||
| showing_model_status: bool, | ||
| editing_notes: bool, | ||
| show_output: bool, | ||
@@ -143,2 +146,3 @@ focus: Focus, | ||
| editor: TextEditor::default(), | ||
| note_editor: TextEditor::default(), | ||
| command: String::new(), | ||
@@ -150,2 +154,3 @@ command_cursor: 0, | ||
| showing_model_status: false, | ||
| editing_notes: false, | ||
| show_output: false, | ||
@@ -152,0 +157,0 @@ focus: Focus::Code, |
+67
-2
@@ -5,2 +5,3 @@ use super::*; | ||
| pub(super) fn action_edit(&mut self) -> Result<()> { | ||
| self.editing_notes = false; | ||
| self.load_code_editor()?; | ||
@@ -255,2 +256,10 @@ self.settings_cursor = None; | ||
| pub(super) fn set_ai_effort(&mut self, effort: &str) -> Result<()> { | ||
| self.state.settings.ai_effort = | ||
| normalize_ai_effort(&self.state.settings.ai_provider, effort); | ||
| save_state(&self.root, &self.state)?; | ||
| self.write_model_status(); | ||
| Ok(()) | ||
| } | ||
| pub(super) fn reset_profile(&mut self) -> Result<()> { | ||
@@ -272,2 +281,3 @@ self.state.settings.difficulty = "auto".to_string(); | ||
| pub(super) fn show_profile_with_intro(&mut self, intro: &str) { | ||
| self.editing_notes = false; | ||
| self.showing_model_status = false; | ||
@@ -289,3 +299,8 @@ if self.settings_cursor.is_none() { | ||
| pub(super) fn profile_text(&self) -> String { | ||
| settings_panel::render(&self.state, self.settings_cursor) | ||
| settings_panel::render( | ||
| &self.state, | ||
| self.settings_cursor, | ||
| &self.available_models, | ||
| self.model_rx.is_some(), | ||
| ) | ||
| } | ||
@@ -308,3 +323,23 @@ | ||
| }; | ||
| let change = settings_panel::apply_selected(&mut self.state, row); | ||
| if row == settings_panel::AI_MODEL_ROW | ||
| && self.available_models_provider != self.state.settings.ai_provider | ||
| { | ||
| self.start_model_check(); | ||
| self.check_models(); | ||
| if self.model_rx.is_some() { | ||
| self.show_profile(); | ||
| return Ok(()); | ||
| } | ||
| } | ||
| let change = settings_panel::apply_selected(&mut self.state, row, &self.available_models); | ||
| if change.edit_notes { | ||
| self.start_note_editor()?; | ||
| return Ok(()); | ||
| } | ||
| if change.provider_changed { | ||
| self.model_rx = None; | ||
| self.available_models.clear(); | ||
| self.available_models_provider.clear(); | ||
| self.model_message = None; | ||
| } | ||
| if change.reload_editor { | ||
@@ -317,2 +352,32 @@ self.load_code_editor()?; | ||
| } | ||
| pub(super) fn start_note_editor(&mut self) -> Result<()> { | ||
| self.save_code()?; | ||
| self.note_editor | ||
| .set_text(&read_problem_notes(&self.root).unwrap_or_default()); | ||
| self.settings_cursor = None; | ||
| self.showing_model_status = false; | ||
| self.editing_notes = true; | ||
| self.show_output = true; | ||
| self.focus = Focus::Output; | ||
| Ok(()) | ||
| } | ||
| pub(super) fn save_notes(&self) -> Result<()> { | ||
| let path = self.root.join(PROBLEM_NOTES_PATH); | ||
| if let Some(parent) = path.parent() { | ||
| fs::create_dir_all(parent)?; | ||
| } | ||
| let text = self.note_editor.text(); | ||
| let text = text.trim_end(); | ||
| fs::write(path, if text.is_empty() { "" } else { text })?; | ||
| Ok(()) | ||
| } | ||
| pub(super) fn close_note_editor(&mut self) -> Result<()> { | ||
| self.save_notes()?; | ||
| self.editing_notes = false; | ||
| self.show_profile(); | ||
| Ok(()) | ||
| } | ||
| } |
@@ -89,2 +89,7 @@ use super::*; | ||
| self.state.settings.ai_provider = normalize_ai_provider(arg); | ||
| self.state.settings.ai_model = "auto".to_string(); | ||
| self.state.settings.ai_effort = normalize_ai_effort( | ||
| &self.state.settings.ai_provider, | ||
| &self.state.settings.ai_effort, | ||
| ); | ||
| self.model_rx = None; | ||
@@ -117,2 +122,6 @@ self.available_models.clear(); | ||
| } | ||
| "effort" | "reasoning" | "ai-effort" if arg.is_empty() => { | ||
| self.write_model_status(); | ||
| } | ||
| "effort" | "reasoning" | "ai-effort" => self.set_ai_effort(arg)?, | ||
| "hint" if arg.is_empty() => { | ||
@@ -123,3 +132,4 @@ self.start_ai_prompt("Give one concise hint for the current problem.")? | ||
| "note" if !arg.is_empty() => self.append_note(arg)?, | ||
| "note" | "notes" => self.show_notes()?, | ||
| "note" => self.start_note_editor()?, | ||
| "notes" => self.show_notes()?, | ||
| "update" => self.refresh_update_notice(), | ||
@@ -126,0 +136,0 @@ "exit" | "quit" | "q" => self.should_quit = true, |
@@ -52,2 +52,5 @@ use super::*; | ||
| for hint in COMMAND_HINTS { | ||
| if hint.insert == "effort max" && self.state.settings.ai_provider != "claude" { | ||
| continue; | ||
| } | ||
| if hint.insert == "model " { | ||
@@ -54,0 +57,0 @@ for model in self |
+56
-0
@@ -166,2 +166,58 @@ #[derive(Clone, Copy)] | ||
| CommandHint { | ||
| insert: "note", | ||
| display: "/note", | ||
| desc_key: "cmd_note", | ||
| keep_open: false, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "notes", | ||
| display: "/notes", | ||
| desc_key: "cmd_notes", | ||
| keep_open: false, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "effort auto", | ||
| display: "/effort auto", | ||
| desc_key: "cmd_effort_auto", | ||
| keep_open: false, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "effort low", | ||
| display: "/effort low", | ||
| desc_key: "cmd_effort", | ||
| keep_open: false, | ||
| help: false, | ||
| }, | ||
| CommandHint { | ||
| insert: "effort medium", | ||
| display: "/effort medium", | ||
| desc_key: "cmd_effort", | ||
| keep_open: false, | ||
| help: false, | ||
| }, | ||
| CommandHint { | ||
| insert: "effort high", | ||
| display: "/effort high", | ||
| desc_key: "cmd_effort", | ||
| keep_open: false, | ||
| help: false, | ||
| }, | ||
| CommandHint { | ||
| insert: "effort xhigh", | ||
| display: "/effort xhigh", | ||
| desc_key: "cmd_effort", | ||
| keep_open: false, | ||
| help: false, | ||
| }, | ||
| CommandHint { | ||
| insert: "effort max", | ||
| display: "/effort max", | ||
| desc_key: "cmd_effort_max", | ||
| keep_open: false, | ||
| help: false, | ||
| }, | ||
| CommandHint { | ||
| insert: "language python", | ||
@@ -168,0 +224,0 @@ display: "/language python", |
+37
-0
@@ -12,2 +12,5 @@ use super::*; | ||
| } | ||
| if self.editing_notes { | ||
| return self.handle_note_key(key); | ||
| } | ||
| match self.focus { | ||
@@ -116,2 +119,36 @@ Focus::Command => self.handle_command_key(key), | ||
| pub(super) fn handle_note_key(&mut self, key: KeyEvent) -> Result<()> { | ||
| match key.code { | ||
| KeyCode::Esc => self.close_note_editor()?, | ||
| KeyCode::Char(char) if !key.modifiers.contains(KeyModifiers::CONTROL) => { | ||
| self.note_editor.insert_char(char); | ||
| self.save_notes()?; | ||
| } | ||
| KeyCode::Enter => { | ||
| self.note_editor.insert_newline(); | ||
| self.save_notes()?; | ||
| } | ||
| KeyCode::Backspace => { | ||
| self.note_editor.backspace(); | ||
| self.save_notes()?; | ||
| } | ||
| KeyCode::Delete => { | ||
| self.note_editor.delete(); | ||
| self.save_notes()?; | ||
| } | ||
| KeyCode::Tab => { | ||
| for _ in 0..4 { | ||
| self.note_editor.insert_char(' '); | ||
| } | ||
| self.save_notes()?; | ||
| } | ||
| KeyCode::Left => self.note_editor.move_left(), | ||
| KeyCode::Right => self.note_editor.move_right(), | ||
| KeyCode::Up => self.note_editor.move_up(), | ||
| KeyCode::Down => self.note_editor.move_down(), | ||
| _ => {} | ||
| } | ||
| Ok(()) | ||
| } | ||
| pub(super) fn handle_global_key(&mut self, key: KeyEvent) -> Result<()> { | ||
@@ -118,0 +155,0 @@ if self.settings_cursor.is_some() { |
+158
-59
@@ -1,12 +0,28 @@ | ||
| use crate::core::{AppState, DIFFICULTIES, LANGUAGES, THEMES, UI_LANGUAGES}; | ||
| use crate::core::{ | ||
| AI_PROVIDERS, AppState, CLAUDE_AI_EFFORTS, CODEX_AI_EFFORTS, DIFFICULTIES, LANGUAGES, THEMES, | ||
| UI_LANGUAGES, normalize_ai_effort, | ||
| }; | ||
| pub(super) struct SettingsChange { | ||
| pub reload_editor: bool, | ||
| pub provider_changed: bool, | ||
| pub edit_notes: bool, | ||
| } | ||
| const AI_PROVIDER_ROW: usize = 4; | ||
| pub(super) const AI_MODEL_ROW: usize = 5; | ||
| const AI_EFFORT_ROW: usize = 6; | ||
| const NOTE_ROW: usize = 7; | ||
| const TOGGLE_START: usize = 8; | ||
| pub(super) fn row_count() -> usize { | ||
| 4 + LANGUAGES.len() + UI_LANGUAGES.len() | ||
| TOGGLE_START + LANGUAGES.len() + UI_LANGUAGES.len() | ||
| } | ||
| pub(super) fn render(state: &AppState, cursor: Option<usize>) -> String { | ||
| pub(super) fn render( | ||
| state: &AppState, | ||
| cursor: Option<usize>, | ||
| available_models: &[String], | ||
| models_loading: bool, | ||
| ) -> String { | ||
| let settings = &state.settings; | ||
@@ -66,11 +82,43 @@ let ui_language = settings.ui_language.as_str(); | ||
| ), | ||
| format!("AI provider: {}", settings.ai_provider), | ||
| format!( | ||
| "AI model: {}", | ||
| if settings.ai_model == "auto" { | ||
| label(ui_language, "provider_default") | ||
| } else { | ||
| settings.ai_model.as_str() | ||
| } | ||
| row( | ||
| cursor, | ||
| AI_PROVIDER_ROW, | ||
| &format!("AI provider: {}", settings.ai_provider), | ||
| ), | ||
| row( | ||
| cursor, | ||
| AI_MODEL_ROW, | ||
| &format!( | ||
| "AI model: {}{}", | ||
| if settings.ai_model == "auto" { | ||
| label(ui_language, "provider_default") | ||
| } else { | ||
| settings.ai_model.as_str() | ||
| }, | ||
| if models_loading { | ||
| " (loading)" | ||
| } else if available_models.is_empty() { | ||
| " (/model to load)" | ||
| } else { | ||
| "" | ||
| } | ||
| ), | ||
| ), | ||
| row( | ||
| cursor, | ||
| AI_EFFORT_ROW, | ||
| &format!( | ||
| "AI effort: {}", | ||
| if settings.ai_effort == "auto" { | ||
| label(ui_language, "provider_default") | ||
| } else { | ||
| settings.ai_effort.as_str() | ||
| } | ||
| ), | ||
| ), | ||
| row( | ||
| cursor, | ||
| NOTE_ROW, | ||
| &format!("{}: Enter", label(ui_language, "problem_notes")), | ||
| ), | ||
| String::new(), | ||
@@ -80,3 +128,3 @@ label(ui_language, "answer_toggles").to_string(), | ||
| for (index, language) in LANGUAGES.iter().enumerate() { | ||
| let row_index = 4 + index; | ||
| let row_index = TOGGLE_START + index; | ||
| let checked = generate_language_enabled(state, language); | ||
@@ -92,3 +140,3 @@ lines.push(row( | ||
| for (index, language) in UI_LANGUAGES.iter().enumerate() { | ||
| let row_index = 4 + LANGUAGES.len() + index; | ||
| let row_index = TOGGLE_START + LANGUAGES.len() + index; | ||
| let checked = generate_ui_language_enabled(state, language); | ||
@@ -112,2 +160,5 @@ lines.push(row( | ||
| "/model auto".to_string(), | ||
| "/effort auto|low|medium|high|xhigh|max".to_string(), | ||
| "/note".to_string(), | ||
| "/notes".to_string(), | ||
| ]); | ||
@@ -117,2 +168,73 @@ lines.join("\n") | ||
| pub(super) fn apply_selected( | ||
| state: &mut AppState, | ||
| selected: usize, | ||
| available_models: &[String], | ||
| ) -> SettingsChange { | ||
| let mut change = SettingsChange { | ||
| reload_editor: false, | ||
| provider_changed: false, | ||
| edit_notes: false, | ||
| }; | ||
| match selected { | ||
| 0 => { | ||
| let current = LANGUAGES | ||
| .iter() | ||
| .position(|language| language == &state.settings.language) | ||
| .unwrap_or(0); | ||
| state.settings.language = LANGUAGES[(current + 1) % LANGUAGES.len()].to_string(); | ||
| change.reload_editor = true; | ||
| } | ||
| 1 => { | ||
| let current = UI_LANGUAGES | ||
| .iter() | ||
| .position(|language| language == &state.settings.ui_language) | ||
| .unwrap_or(0); | ||
| state.settings.ui_language = | ||
| UI_LANGUAGES[(current + 1) % UI_LANGUAGES.len()].to_string(); | ||
| } | ||
| 2 => { | ||
| let current = THEMES | ||
| .iter() | ||
| .position(|theme| theme == &state.settings.theme) | ||
| .unwrap_or(0); | ||
| state.settings.theme = THEMES[(current + 1) % THEMES.len()].to_string(); | ||
| } | ||
| 3 => { | ||
| let current = DIFFICULTIES | ||
| .iter() | ||
| .position(|difficulty| difficulty == &state.settings.difficulty) | ||
| .unwrap_or(0); | ||
| let difficulty = DIFFICULTIES[(current + 1) % DIFFICULTIES.len()].to_string(); | ||
| state.settings.difficulty = difficulty.clone(); | ||
| if difficulty != "auto" { | ||
| state.suggested_next_difficulty = difficulty; | ||
| } | ||
| } | ||
| AI_PROVIDER_ROW => { | ||
| let current = AI_PROVIDERS | ||
| .iter() | ||
| .position(|provider| provider == &state.settings.ai_provider) | ||
| .unwrap_or(0); | ||
| state.settings.ai_provider = | ||
| AI_PROVIDERS[(current + 1) % AI_PROVIDERS.len()].to_string(); | ||
| state.settings.ai_model = "auto".to_string(); | ||
| state.settings.ai_effort = | ||
| normalize_ai_effort(&state.settings.ai_provider, &state.settings.ai_effort); | ||
| change.provider_changed = true; | ||
| } | ||
| AI_MODEL_ROW => cycle_ai_model(state, available_models), | ||
| AI_EFFORT_ROW => cycle_ai_effort(state), | ||
| NOTE_ROW => change.edit_notes = true, | ||
| row if row < TOGGLE_START + LANGUAGES.len() => { | ||
| toggle_generate_language(state, LANGUAGES[row - TOGGLE_START]); | ||
| } | ||
| row if row < row_count() => { | ||
| toggle_generate_ui_language(state, UI_LANGUAGES[row - TOGGLE_START - LANGUAGES.len()]); | ||
| } | ||
| _ => {} | ||
| } | ||
| change | ||
| } | ||
| fn label<'a>(ui_language: &str, key: &'a str) -> &'a str { | ||
@@ -132,2 +254,3 @@ if ui_language == "ko" { | ||
| "provider_default" => "auto (provider 기본값)", | ||
| "problem_notes" => "문제 생성 메모 편집", | ||
| "answer_toggles" => "생성 정답 언어 토글", | ||
@@ -153,2 +276,3 @@ "ui_toggles" => "생성 문제 언어 토글", | ||
| "provider_default" => "auto (provider default)", | ||
| "problem_notes" => "Edit problem notes", | ||
| "answer_toggles" => "Generated answer language toggles", | ||
@@ -164,50 +288,25 @@ "ui_toggles" => "Generated problem text language toggles", | ||
| pub(super) fn apply_selected(state: &mut AppState, selected: usize) -> SettingsChange { | ||
| let mut reload_editor = false; | ||
| match selected { | ||
| 0 => { | ||
| let current = LANGUAGES | ||
| .iter() | ||
| .position(|language| language == &state.settings.language) | ||
| .unwrap_or(0); | ||
| state.settings.language = LANGUAGES[(current + 1) % LANGUAGES.len()].to_string(); | ||
| reload_editor = true; | ||
| } | ||
| 1 => { | ||
| let current = UI_LANGUAGES | ||
| .iter() | ||
| .position(|language| language == &state.settings.ui_language) | ||
| .unwrap_or(0); | ||
| state.settings.ui_language = | ||
| UI_LANGUAGES[(current + 1) % UI_LANGUAGES.len()].to_string(); | ||
| } | ||
| 2 => { | ||
| let current = THEMES | ||
| .iter() | ||
| .position(|theme| theme == &state.settings.theme) | ||
| .unwrap_or(0); | ||
| state.settings.theme = THEMES[(current + 1) % THEMES.len()].to_string(); | ||
| } | ||
| 3 => { | ||
| let current = DIFFICULTIES | ||
| .iter() | ||
| .position(|difficulty| difficulty == &state.settings.difficulty) | ||
| .unwrap_or(0); | ||
| let difficulty = DIFFICULTIES[(current + 1) % DIFFICULTIES.len()].to_string(); | ||
| state.settings.difficulty = difficulty.clone(); | ||
| if difficulty != "auto" { | ||
| state.suggested_next_difficulty = difficulty; | ||
| } | ||
| } | ||
| row if row < 4 + LANGUAGES.len() => { | ||
| toggle_generate_language(state, LANGUAGES[row - 4]); | ||
| } | ||
| row if row < row_count() => { | ||
| toggle_generate_ui_language(state, UI_LANGUAGES[row - 4 - LANGUAGES.len()]); | ||
| } | ||
| _ => {} | ||
| } | ||
| SettingsChange { reload_editor } | ||
| fn cycle_ai_model(state: &mut AppState, available_models: &[String]) { | ||
| let mut models = vec!["auto"]; | ||
| models.extend(available_models.iter().map(String::as_str)); | ||
| let current = models | ||
| .iter() | ||
| .position(|model| model == &state.settings.ai_model) | ||
| .unwrap_or(0); | ||
| state.settings.ai_model = models[(current + 1) % models.len()].to_string(); | ||
| } | ||
| fn cycle_ai_effort(state: &mut AppState) { | ||
| let efforts = if state.settings.ai_provider == "claude" { | ||
| CLAUDE_AI_EFFORTS | ||
| } else { | ||
| CODEX_AI_EFFORTS | ||
| }; | ||
| let current = efforts | ||
| .iter() | ||
| .position(|effort| effort == &state.settings.ai_effort) | ||
| .unwrap_or(0); | ||
| state.settings.ai_effort = efforts[(current + 1) % efforts.len()].to_string(); | ||
| } | ||
| fn row(cursor: Option<usize>, index: usize, text: &str) -> String { | ||
@@ -214,0 +313,0 @@ let marker = if cursor == Some(index) { ">" } else { " " }; |
@@ -78,2 +78,5 @@ use super::*; | ||
| } | ||
| if self.editing_notes { | ||
| return "notes: type to edit, Esc profile"; | ||
| } | ||
| match (self.focus, self.list_cursor.is_some(), self.show_output) { | ||
@@ -80,0 +83,0 @@ (Focus::Command, _, _) => ui_text(lang, "hint_command"), |
+25
-1
@@ -137,2 +137,6 @@ use super::*; | ||
| self.show_output = true; | ||
| } else if self.settings_cursor.is_some() { | ||
| self.output = self.profile_text(); | ||
| self.output_is_markdown = false; | ||
| self.show_output = true; | ||
| } | ||
@@ -153,3 +157,12 @@ } | ||
| ), | ||
| format!( | ||
| "AI effort: {}", | ||
| if self.state.settings.ai_effort == "auto" { | ||
| "auto (provider default)" | ||
| } else { | ||
| self.state.settings.ai_effort.as_str() | ||
| } | ||
| ), | ||
| "Use /model auto to let the provider choose its default.".to_string(), | ||
| "Use /effort auto to let the provider choose its default.".to_string(), | ||
| ]; | ||
@@ -166,2 +179,11 @@ if self.model_rx.is_some() { | ||
| } else { | ||
| if let Some(message) = &self.model_message { | ||
| lines.push(message.clone()); | ||
| } | ||
| let efforts = if self.state.settings.ai_provider == "claude" { | ||
| CLAUDE_AI_EFFORTS | ||
| } else { | ||
| CODEX_AI_EFFORTS | ||
| }; | ||
| lines.push(format!("Available efforts: {}", efforts.join(", "))); | ||
| lines.push("Available models:".to_string()); | ||
@@ -218,2 +240,3 @@ lines.extend( | ||
| self.settings_cursor = None; | ||
| self.editing_notes = false; | ||
| self.showing_model_status = false; | ||
@@ -228,2 +251,3 @@ self.output = output.to_string(); | ||
| self.settings_cursor = None; | ||
| self.editing_notes = false; | ||
| self.showing_model_status = false; | ||
@@ -278,3 +302,3 @@ self.output = output.trim_end().to_string(); | ||
| if notes.is_empty() { | ||
| self.write_text_output("No notes yet. Use /topics or /avoid for standing preferences."); | ||
| self.write_text_output("No notes yet. Use /note to edit problem-generation notes."); | ||
| } else { | ||
@@ -281,0 +305,0 @@ self.write_text_output(&format!("Problem notes ({PROBLEM_NOTES_PATH})\n\n{notes}")); |
+72
-19
@@ -18,27 +18,48 @@ use super::*; | ||
| .split(vertical[0]); | ||
| self.code_area = body[1]; | ||
| self.output_area = body[1]; | ||
| self.code_area = if self.show_output { | ||
| Rect::default() | ||
| } else { | ||
| body[1] | ||
| }; | ||
| self.output_area = if self.show_output { | ||
| vertical[0] | ||
| } else { | ||
| self.code_area | ||
| }; | ||
| self.command_area = vertical[2]; | ||
| let light = self.state.settings.theme == "light"; | ||
| let problem = Paragraph::new(problem_view::render( | ||
| &self.problem, | ||
| &self.state.settings.ui_language, | ||
| light, | ||
| )) | ||
| .style(Self::pane_style(light)) | ||
| .block(Self::block( | ||
| ui_text(&self.state.settings.ui_language, "problem"), | ||
| light, | ||
| false, | ||
| )) | ||
| .wrap(Wrap { trim: false }); | ||
| frame.render_widget(problem, body[0]); | ||
| if !self.show_output { | ||
| let problem = Paragraph::new(problem_view::render( | ||
| &self.problem, | ||
| &self.state.settings.ui_language, | ||
| light, | ||
| )) | ||
| .style(Self::pane_style(light)) | ||
| .block(Self::block( | ||
| ui_text(&self.state.settings.ui_language, "problem"), | ||
| light, | ||
| false, | ||
| )) | ||
| .wrap(Wrap { trim: false }); | ||
| frame.render_widget(problem, body[0]); | ||
| } | ||
| if self.show_output { | ||
| let text = self.output_text(); | ||
| let text = if self.editing_notes { | ||
| Text::from( | ||
| self.note_editor | ||
| .visible_text(self.output_area.height.saturating_sub(2) as usize), | ||
| ) | ||
| } else { | ||
| self.output_text() | ||
| }; | ||
| let output = Paragraph::new(text) | ||
| .style(Self::pane_style(light)) | ||
| .block(Self::block( | ||
| ui_text(&self.state.settings.ui_language, "output"), | ||
| if self.editing_notes { | ||
| PROBLEM_NOTES_PATH | ||
| } else { | ||
| ui_text(&self.state.settings.ui_language, "output") | ||
| }, | ||
| light, | ||
@@ -48,3 +69,3 @@ self.focus != Focus::Command, | ||
| .wrap(Wrap { trim: false }); | ||
| frame.render_widget(output, body[1]); | ||
| frame.render_widget(output, self.output_area); | ||
| } else { | ||
@@ -89,3 +110,3 @@ let code = self | ||
| self.draw_command_palette(frame, vertical[2]); | ||
| self.set_terminal_cursor(frame, body[1], vertical[2]); | ||
| self.set_terminal_cursor(frame, self.code_area, vertical[2]); | ||
| } | ||
@@ -342,2 +363,7 @@ | ||
| } | ||
| Focus::Output if self.editing_notes => { | ||
| if let Some(position) = self.note_editor.cursor_position(self.output_area) { | ||
| frame.set_cursor_position(position); | ||
| } | ||
| } | ||
| _ => {} | ||
@@ -347,1 +373,28 @@ } | ||
| } | ||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use ratatui::{Terminal, backend::TestBackend}; | ||
| fn tmp_root(name: &str) -> PathBuf { | ||
| let root = | ||
| std::env::temp_dir().join(format!("practicode-view-{name}-{}", std::process::id())); | ||
| let _ = fs::remove_dir_all(&root); | ||
| fs::create_dir_all(&root).unwrap(); | ||
| root | ||
| } | ||
| #[test] | ||
| fn output_uses_full_body_so_terminal_drag_selection_has_no_side_pane() { | ||
| let mut app = PracticodeApp::new(tmp_root("full-output")).unwrap(); | ||
| app.handle_command("help").unwrap(); | ||
| let backend = TestBackend::new(80, 24); | ||
| let mut terminal = Terminal::new(backend).unwrap(); | ||
| terminal.draw(|frame| app.draw(frame)).unwrap(); | ||
| assert_eq!(app.output_area, Rect::new(0, 0, 80, 20)); | ||
| assert_eq!(app.code_area, Rect::default()); | ||
| } | ||
| } |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
251740
7.26%420
3.7%216
2.37%4
33.33%