🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

practicode

Package Overview
Dependencies
Maintainers
1
Versions
24
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

practicode - npm Package Compare versions

Comparing version
0.1.10
to
0.1.11
+4
-1
assets/i18n/en.json

@@ -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",

[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."

{
"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",

@@ -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() {

@@ -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() {

@@ -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);
}

@@ -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,

@@ -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

@@ -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",

@@ -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() {

@@ -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"),

@@ -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}"));

@@ -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());
}
}