🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

practicode

Package Overview
Dependencies
Maintainers
1
Versions
20
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.6
to
0.1.9
+11
SECURITY.md
# Security Policy
## Reporting
Report vulnerabilities through GitHub Security Advisories for this repository when available. If that is not available, open a minimal public issue asking for a private security contact and avoid posting exploit details.
Do not include tokens, private prompts, `.env`, `.npmrc`, `.practicode/`, `problems/`, or `submissions/` contents in public reports.
## Scope
Security-sensitive areas include npm install scripts, release publishing, command execution, local judging, AI provider prompts, update checks, and local user data handling.
use crate::text::{byte_index, char_len, compose_hangul_jamo, display_width, prefix};
use ratatui::layout::{Position, Rect};
#[derive(Clone, Debug)]
pub struct TextEditor {
lines: Vec<String>,
row: usize,
col: usize,
scroll: usize,
}
impl Default for TextEditor {
fn default() -> Self {
Self {
lines: vec![String::new()],
row: 0,
col: 0,
scroll: 0,
}
}
}
impl TextEditor {
pub fn set_text(&mut self, text: &str) {
self.lines = text.split('\n').map(str::to_string).collect();
if text.ends_with('\n') {
self.lines.pop();
self.lines.push(String::new());
}
if self.lines.is_empty() {
self.lines.push(String::new());
}
self.row = 0;
self.col = 0;
self.scroll = 0;
}
pub fn text(&self) -> String {
self.lines.join("\n")
}
pub(super) fn visible_text(&mut self, height: usize) -> String {
if self.row < self.scroll {
self.scroll = self.row;
} else if height > 0 && self.row >= self.scroll + height {
self.scroll = self.row + 1 - height;
}
let line_width = ((self.lines.len().max(1)).to_string().len()).max(3);
self.lines
.iter()
.enumerate()
.skip(self.scroll)
.take(height.max(1))
.map(|(index, line)| {
let cursor = if index == self.row { ">" } else { " " };
format!("{cursor}{:>width$} {line}", index + 1, width = line_width)
})
.collect::<Vec<_>>()
.join("\n")
}
pub(super) fn cursor_position(&self, area: Rect) -> Option<Position> {
if self.row < self.scroll {
return None;
}
let visible_row = self.row - self.scroll;
let inner_height = area.height.saturating_sub(2) as usize;
if visible_row >= inner_height {
return None;
}
let line_width = ((self.lines.len().max(1)).to_string().len()).max(3);
let prefix_width = 1 + line_width + 1;
let line = self.lines.get(self.row)?;
let text_before_cursor = prefix(line, self.col);
let x = area
.x
.saturating_add(1)
.saturating_add((prefix_width + display_width(&text_before_cursor)) as u16)
.min(area.right().saturating_sub(2));
let y = area.y.saturating_add(1).saturating_add(visible_row as u16);
Some(Position::new(x, y))
}
pub fn insert_char(&mut self, char: char) {
self.ensure_cursor();
let byte = byte_index(&self.lines[self.row], self.col);
self.lines[self.row].insert(byte, char);
self.col += 1;
self.normalize_current_line();
}
pub fn insert_newline(&mut self) {
self.ensure_cursor();
let byte = byte_index(&self.lines[self.row], self.col);
let rest = self.lines[self.row].split_off(byte);
self.lines.insert(self.row + 1, rest);
self.row += 1;
self.col = 0;
}
pub fn backspace(&mut self) {
self.ensure_cursor();
if self.col > 0 {
let start = byte_index(&self.lines[self.row], self.col - 1);
let end = byte_index(&self.lines[self.row], self.col);
self.lines[self.row].replace_range(start..end, "");
self.col -= 1;
self.normalize_current_line();
} else if self.row > 0 {
let current = self.lines.remove(self.row);
self.row -= 1;
self.col = char_len(&self.lines[self.row]);
self.lines[self.row].push_str(&current);
}
}
pub(super) fn delete(&mut self) {
self.ensure_cursor();
if self.col < char_len(&self.lines[self.row]) {
let start = byte_index(&self.lines[self.row], self.col);
let end = byte_index(&self.lines[self.row], self.col + 1);
self.lines[self.row].replace_range(start..end, "");
self.normalize_current_line();
} else if self.row + 1 < self.lines.len() {
let next = self.lines.remove(self.row + 1);
self.lines[self.row].push_str(&next);
}
}
pub(super) fn move_left(&mut self) {
if self.col > 0 {
self.col -= 1;
} else if self.row > 0 {
self.row -= 1;
self.col = char_len(&self.lines[self.row]);
}
}
pub(super) fn move_right(&mut self) {
if self.col < char_len(&self.lines[self.row]) {
self.col += 1;
} else if self.row + 1 < self.lines.len() {
self.row += 1;
self.col = 0;
}
}
pub(super) fn move_up(&mut self) {
if self.row > 0 {
self.row -= 1;
self.col = self.col.min(char_len(&self.lines[self.row]));
}
}
pub(super) fn move_down(&mut self) {
if self.row + 1 < self.lines.len() {
self.row += 1;
self.col = self.col.min(char_len(&self.lines[self.row]));
}
}
fn ensure_cursor(&mut self) {
if self.lines.is_empty() {
self.lines.push(String::new());
}
self.row = self.row.min(self.lines.len() - 1);
self.col = self.col.min(char_len(&self.lines[self.row]));
}
fn normalize_current_line(&mut self) {
let normalized = compose_hangul_jamo(&self.lines[self.row]);
if normalized == self.lines[self.row] {
self.col = self.col.min(char_len(&self.lines[self.row]));
return;
}
let old_prefix = prefix(&self.lines[self.row], self.col);
self.lines[self.row] = normalized;
self.col = char_len(&compose_hangul_jamo(&old_prefix)).min(char_len(&self.lines[self.row]));
}
}
use crate::core::{Problem, localized, normalize_ui_language, ui_text};
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span, Text},
};
pub(super) fn render(problem: &Problem, ui_language: &str, light: bool) -> Text<'static> {
let lang = normalize_ui_language(ui_language);
let title_style = if light {
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
};
let section_style = if light {
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
};
let body_style = if light {
Style::default().fg(Color::Black)
} else {
Style::default().fg(Color::Rgb(229, 231, 235))
};
let meta_style = if light {
Style::default().fg(Color::Rgb(75, 85, 99))
} else {
Style::default().fg(Color::Rgb(156, 163, 175))
};
let code_style = if light {
Style::default()
.fg(Color::Black)
.bg(Color::Rgb(229, 231, 235))
} else {
Style::default()
.fg(Color::Rgb(243, 244, 246))
.bg(Color::Rgb(31, 41, 55))
};
let number = problem
.id
.split_once('-')
.map(|(number, _)| number)
.unwrap_or(&problem.id);
let mut lines = vec![
Line::from(Span::styled(
format!("{number}. {}", localized(&problem.title, &lang)),
title_style,
)),
Line::from(Span::styled(
format!(
"{}: {} {}: {}",
ui_text(&lang, "difficulty"),
problem.difficulty,
ui_text(&lang, "topics"),
problem.topics.join(", ")
),
meta_style,
)),
];
lines.push(Line::default());
for line in localized(&problem.statement, &lang).trim_end().lines() {
lines.push(Line::from(Span::styled(line.to_string(), body_style)));
}
push_problem_section(
&mut lines,
ui_text(&lang, "input"),
&localized(&problem.input, &lang),
section_style,
body_style,
);
push_problem_section(
&mut lines,
ui_text(&lang, "output"),
&localized(&problem.output, &lang),
section_style,
body_style,
);
lines.push(Line::default());
lines.push(Line::from(Span::styled(
ui_text(&lang, "examples").to_string(),
section_style,
)));
for (index, case) in problem.examples.iter().enumerate() {
lines.push(Line::from(Span::styled(
format!(" {} {}", ui_text(&lang, "example"), index + 1),
meta_style.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!(" {}", ui_text(&lang, "input")),
meta_style,
)));
push_code_lines(&mut lines, &case.input, code_style);
lines.push(Line::from(Span::styled(
format!(" {}", ui_text(&lang, "output")),
meta_style,
)));
push_code_lines(&mut lines, &case.output, code_style);
}
Text::from(lines)
}
fn push_problem_section(
lines: &mut Vec<Line<'static>>,
title: &str,
body: &str,
section_style: Style,
body_style: Style,
) {
lines.push(Line::default());
lines.push(Line::from(Span::styled(title.to_string(), section_style)));
for line in body.trim_end().lines() {
lines.push(Line::from(Span::styled(format!(" {line}"), body_style)));
}
}
fn push_code_lines(lines: &mut Vec<Line<'static>>, body: &str, code_style: Style) {
let body = body.trim_end();
if body.is_empty() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("<empty>".to_string(), code_style),
]));
return;
}
for line in body.lines() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(line.to_string(), code_style),
]));
}
}
use crate::core::{AppState, DIFFICULTIES, LANGUAGES, THEMES, UI_LANGUAGES};
pub(super) struct SettingsChange {
pub reload_editor: bool,
}
pub(super) fn row_count() -> usize {
4 + LANGUAGES.len() + UI_LANGUAGES.len()
}
pub(super) fn render(state: &AppState, cursor: Option<usize>) -> String {
let settings = &state.settings;
let ui_language = settings.ui_language.as_str();
let topics = list_or_none(&settings.topics, ui_language);
let avoid = list_or_none(&settings.avoid_topics, ui_language);
let generate_languages = list_or_all(&settings.generate_languages, ui_language);
let generate_ui_languages = list_or_all(&settings.generate_ui_languages, ui_language);
let mut lines = vec![
label(ui_language, "title").to_string(),
String::new(),
label(ui_language, "instructions").to_string(),
String::new(),
row(
cursor,
0,
&format!(
"{}: {}",
label(ui_language, "code_language"),
settings.language
),
),
row(
cursor,
1,
&format!(
"{}: {}",
label(ui_language, "ui_language"),
settings.ui_language
),
),
row(
cursor,
2,
&format!("{}: {}", label(ui_language, "theme"), settings.theme),
),
row(
cursor,
3,
&format!(
"{}: {}",
label(ui_language, "difficulty"),
settings.difficulty
),
),
String::new(),
format!("{}: {topics}", label(ui_language, "preferred_topics")),
format!("{}: {avoid}", label(ui_language, "avoid_topics")),
format!(
"{}: {generate_languages}",
label(ui_language, "generated_answer_languages")
),
format!(
"{}: {generate_ui_languages}",
label(ui_language, "generated_ui_languages")
),
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()
}
),
String::new(),
label(ui_language, "answer_toggles").to_string(),
];
for (index, language) in LANGUAGES.iter().enumerate() {
let row_index = 4 + index;
let checked = generate_language_enabled(state, language);
lines.push(row(
cursor,
row_index,
&format!("{} {language}", checkbox(checked)),
));
}
lines.push(String::new());
lines.push(label(ui_language, "ui_toggles").to_string());
for (index, language) in UI_LANGUAGES.iter().enumerate() {
let row_index = 4 + LANGUAGES.len() + index;
let checked = generate_ui_language_enabled(state, language);
lines.push(row(
cursor,
row_index,
&format!("{} {language}", checkbox(checked)),
));
}
lines.extend([
String::new(),
label(ui_language, "commands").to_string(),
"/profile".to_string(),
"/difficulty auto|easy|medium|hard".to_string(),
"/topics arrays, strings".to_string(),
"/avoid dp, graph".to_string(),
"/generate-languages all|python, rust".to_string(),
"/generate-ui all|en, ko".to_string(),
"/provider codex|claude".to_string(),
"/model auto".to_string(),
]);
lines.join("\n")
}
fn label<'a>(ui_language: &str, key: &'a str) -> &'a str {
if ui_language == "ko" {
match key {
"title" => "사용자 프로필",
"instructions" => "위/아래로 이동하고 Space 또는 Enter로 변경/토글",
"code_language" => "코드 언어",
"ui_language" => "UI 언어",
"theme" => "테마",
"difficulty" => "난이도",
"preferred_topics" => "선호 주제",
"avoid_topics" => "피할 주제",
"generated_answer_languages" => "생성 정답 언어",
"generated_ui_languages" => "생성 문제 언어",
"provider_default" => "auto (provider 기본값)",
"answer_toggles" => "생성 정답 언어 토글",
"ui_toggles" => "생성 문제 언어 토글",
"commands" => "명령",
"none" => "(없음)",
"all" => "전체",
_ => key,
}
} else {
match key {
"title" => "User profile",
"instructions" => "Use up/down to move. Press Space or Enter to cycle/toggle.",
"code_language" => "Code language",
"ui_language" => "UI language",
"theme" => "Theme",
"difficulty" => "Difficulty",
"preferred_topics" => "Preferred topics",
"avoid_topics" => "Avoid topics",
"generated_answer_languages" => "Generated answer languages",
"generated_ui_languages" => "Generated UI languages",
"provider_default" => "auto (provider default)",
"answer_toggles" => "Generated answer language toggles",
"ui_toggles" => "Generated problem text language toggles",
"commands" => "Commands",
"none" => "(none)",
"all" => "all",
_ => key,
}
}
}
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 row(cursor: Option<usize>, index: usize, text: &str) -> String {
let marker = if cursor == Some(index) { ">" } else { " " };
format!("{marker} {text}")
}
fn generate_language_enabled(state: &AppState, language: &str) -> bool {
state.settings.generate_languages.is_empty()
|| state
.settings
.generate_languages
.iter()
.any(|value| value == language)
}
fn generate_ui_language_enabled(state: &AppState, language: &str) -> bool {
state.settings.generate_ui_languages.is_empty()
|| state
.settings
.generate_ui_languages
.iter()
.any(|value| value == language)
}
fn toggle_generate_language(state: &mut AppState, language: &str) {
if state.settings.generate_languages.is_empty() {
state.settings.generate_languages = LANGUAGES
.iter()
.filter(|value| **value != language)
.map(|value| (*value).to_string())
.collect();
return;
}
if generate_language_enabled(state, language) {
if state.settings.generate_languages.len() > 1 {
state
.settings
.generate_languages
.retain(|value| value != language);
}
} else {
state.settings.generate_languages.push(language.to_string());
state.settings.generate_languages = LANGUAGES
.iter()
.filter(|value| {
state
.settings
.generate_languages
.iter()
.any(|selected| selected == *value)
})
.map(|value| (*value).to_string())
.collect();
if state.settings.generate_languages.len() == LANGUAGES.len() {
state.settings.generate_languages.clear();
}
}
}
fn toggle_generate_ui_language(state: &mut AppState, language: &str) {
if state.settings.generate_ui_languages.is_empty() {
state.settings.generate_ui_languages = UI_LANGUAGES
.iter()
.filter(|value| **value != language)
.map(|value| (*value).to_string())
.collect();
return;
}
if generate_ui_language_enabled(state, language) {
if state.settings.generate_ui_languages.len() > 1 {
state
.settings
.generate_ui_languages
.retain(|value| value != language);
}
} else {
state
.settings
.generate_ui_languages
.push(language.to_string());
state.settings.generate_ui_languages = UI_LANGUAGES
.iter()
.filter(|value| {
state
.settings
.generate_ui_languages
.iter()
.any(|selected| selected == *value)
})
.map(|value| (*value).to_string())
.collect();
if state.settings.generate_ui_languages.len() == UI_LANGUAGES.len() {
state.settings.generate_ui_languages.clear();
}
}
}
fn list_or_none(values: &[String], ui_language: &str) -> String {
if values.is_empty() {
label(ui_language, "none").to_string()
} else {
values.join(", ")
}
}
fn list_or_all(values: &[String], ui_language: &str) -> String {
if values.is_empty() {
label(ui_language, "all").to_string()
} else {
values.join(", ")
}
}
fn checkbox(checked: bool) -> &'static str {
if checked { "[x]" } else { "[ ]" }
}
+13
-3

@@ -18,5 +18,8 @@ {

"hint_list": "up/down move | Enter open | Esc close",
"hint_output": "Esc/click edit | / command | ? help",
"hint_output": "Esc/e edit | drag select to copy | / command",
"hint_settings": "up/down move | Space/Enter toggle | Esc close",
"hint_code": "Esc then / command",
"hint_idle": "/ command | ? help",
"hint_busy": "Working | q quit",
"hint_busy_next": "Space warmup | q quit",
"help_title": "Help",

@@ -30,2 +33,3 @@ "daily_loop": "Daily loop",

"cmd_next": "Open the next problem",
"cmd_generate": "Generate a new problem in the background",
"cmd_prev": "Open the previous problem",

@@ -37,6 +41,8 @@ "cmd_list": "Browse problems",

"cmd_ai": "Ask AI about the current problem and code",
"cmd_profile": "Show practice profile",
"cmd_profile": "Show user profile",
"cmd_difficulty": "Set preferred difficulty",
"cmd_topics": "Set preferred topics",
"cmd_avoid": "Set topics to avoid",
"cmd_generate_languages": "Set answer languages for generated problems",
"cmd_generate_ui": "Set UI languages for generated problems",
"cmd_provider": "Set AI provider",

@@ -62,4 +68,8 @@ "cmd_model": "Set AI model",

"already_busy": "Already busy.",
"busy_warmup": "Warmup: press Space when * hits the center.",
"busy_commands_paused": "Commands are paused until generation finishes.",
"hits": "Hits",
"misses": "Misses",
"run_pass_next": "Next: /next",
"run_fail_next": "Fix: press Esc or click this pane to edit, then /run"
"run_fail_next": "Fix: press Esc or e to edit, then /run"
}

@@ -18,5 +18,8 @@ {

"hint_list": "arriba/abajo mover | Enter abrir | Esc cerrar",
"hint_output": "Esc/clic editar | / comando | ? ayuda",
"hint_output": "Esc/e editar | arrastra para copiar | / comando",
"hint_settings": "arriba/abajo mover | Space/Enter alternar | Esc cerrar",
"hint_code": "Esc y luego / comando",
"hint_idle": "/ comando | ? ayuda",
"hint_busy": "Trabajando | q salir",
"hint_busy_next": "Space calentamiento | q salir",
"help_title": "Ayuda",

@@ -30,2 +33,3 @@ "daily_loop": "Flujo diario",

"cmd_next": "Abrir el siguiente problema",
"cmd_generate": "Generar un problema nuevo en segundo plano",
"cmd_prev": "Abrir el problema anterior",

@@ -37,6 +41,8 @@ "cmd_list": "Abrir la lista de problemas",

"cmd_ai": "Preguntar a AI sobre el problema y codigo actuales",
"cmd_profile": "Mostrar perfil de practica",
"cmd_profile": "Mostrar perfil de usuario",
"cmd_difficulty": "Configurar dificultad preferida",
"cmd_topics": "Configurar temas preferidos",
"cmd_avoid": "Configurar temas a evitar",
"cmd_generate_languages": "Configurar lenguajes de respuesta generados",
"cmd_generate_ui": "Configurar idiomas de UI generados",
"cmd_provider": "Configurar AI provider",

@@ -62,4 +68,8 @@ "cmd_model": "Configurar AI model",

"already_busy": "Ya hay una tarea en curso.",
"busy_warmup": "Calentamiento: pulsa Space cuando * llegue al centro.",
"busy_commands_paused": "Los comandos quedan pausados hasta que termine la generacion.",
"hits": "Aciertos",
"misses": "Fallos",
"run_pass_next": "Siguiente: /next",
"run_fail_next": "Corrige: pulsa Esc o haz clic en este panel para editar, luego /run"
"run_fail_next": "Corrige: pulsa Esc o e para editar, luego /run"
}

@@ -18,5 +18,8 @@ {

"hint_list": "上下 移動 | Enter 開く | Esc 閉じる",
"hint_output": "Esc/クリック 編集 | / コマンド | ? ヘルプ",
"hint_output": "Esc/e 編集 | ドラッグ選択でコピー | / コマンド",
"hint_settings": "上下 移動 | Space/Enter 切替 | Esc 閉じる",
"hint_code": "Esc の後 / コマンド",
"hint_idle": "/ コマンド | ? ヘルプ",
"hint_busy": "処理中 | q 終了",
"hint_busy_next": "Space ウォームアップ | q 終了",
"help_title": "ヘルプ",

@@ -30,2 +33,3 @@ "daily_loop": "基本フロー",

"cmd_next": "次の問題を開く",
"cmd_generate": "新しい問題をバックグラウンド生成",
"cmd_prev": "前の問題を開く",

@@ -37,6 +41,8 @@ "cmd_list": "問題一覧を開く",

"cmd_ai": "現在の問題とコードについて AI に質問",
"cmd_profile": "練習プロファイルを表示",
"cmd_profile": "ユーザープロファイルを表示",
"cmd_difficulty": "希望難易度を設定",
"cmd_topics": "希望トピックを設定",
"cmd_avoid": "避けるトピックを設定",
"cmd_generate_languages": "生成問題の解答言語を設定",
"cmd_generate_ui": "生成問題の UI 言語を設定",
"cmd_provider": "AI provider を設定",

@@ -62,4 +68,8 @@ "cmd_model": "AI model を設定",

"already_busy": "すでに処理中です。",
"busy_warmup": "ウォームアップ: * が中央に来たら Space。",
"busy_commands_paused": "生成が終わるまでコマンドは一時停止します。",
"hits": "成功",
"misses": "ミス",
"run_pass_next": "次: /next",
"run_fail_next": "修正: Esc またはこのペインをクリックして編集し、/run"
"run_fail_next": "修正: Esc または e で編集し、/run"
}

@@ -18,5 +18,8 @@ {

"hint_list": "위/아래 이동 | Enter 열기 | Esc 닫기",
"hint_output": "Esc/클릭 편집 | / 명령 | ? 도움말",
"hint_output": "Esc/e 편집 | 드래그 선택으로 복사 | / 명령",
"hint_settings": "위/아래 이동 | Space/Enter 토글 | Esc 닫기",
"hint_code": "Esc 후 / 명령",
"hint_idle": "/ 명령 | ? 도움말",
"hint_busy": "작업 중 | q 종료",
"hint_busy_next": "Space 워밍업 | q 종료",
"help_title": "도움말",

@@ -30,2 +33,3 @@ "daily_loop": "기본 흐름",

"cmd_next": "다음 문제 열기",
"cmd_generate": "새 문제를 백그라운드에서 생성",
"cmd_prev": "이전 문제 열기",

@@ -37,6 +41,8 @@ "cmd_list": "문제 목록 열기",

"cmd_ai": "현재 문제와 코드에 대해 AI에게 질문",
"cmd_profile": "연습 프로파일 보기",
"cmd_profile": "사용자 프로필 보기",
"cmd_difficulty": "선호 난이도 설정",
"cmd_topics": "선호 주제 설정",
"cmd_avoid": "피할 주제 설정",
"cmd_generate_languages": "생성 문제의 정답 언어 설정",
"cmd_generate_ui": "생성 문제의 UI 언어 설정",
"cmd_provider": "AI provider 설정",

@@ -62,4 +68,8 @@ "cmd_model": "AI model 설정",

"already_busy": "이미 작업 중입니다.",
"busy_warmup": "워밍업: *가 가운데에 왔을 때 Space를 누르세요.",
"busy_commands_paused": "생성이 끝날 때까지 명령은 잠시 멈춥니다.",
"hits": "성공",
"misses": "실패",
"run_pass_next": "다음: /next",
"run_fail_next": "수정: Esc를 누르거나 이 창을 클릭해 편집한 뒤 /run"
"run_fail_next": "수정: Esc 또는 e로 편집한 뒤 /run"
}

@@ -18,5 +18,8 @@ {

"hint_list": "上下 移动 | Enter 打开 | Esc 关闭",
"hint_output": "Esc/点击 编辑 | / 命令 | ? 帮助",
"hint_output": "Esc/e 编辑 | 拖拽选择复制 | / 命令",
"hint_settings": "上下移动 | Space/Enter 切换 | Esc 关闭",
"hint_code": "Esc 后输入 / 命令",
"hint_idle": "/ 命令 | ? 帮助",
"hint_busy": "处理中 | q 退出",
"hint_busy_next": "Space 热身 | q 退出",
"help_title": "帮助",

@@ -30,2 +33,3 @@ "daily_loop": "日常流程",

"cmd_next": "打开下一题",
"cmd_generate": "后台生成新题",
"cmd_prev": "打开上一题",

@@ -37,6 +41,8 @@ "cmd_list": "打开题目列表",

"cmd_ai": "向 AI 询问当前题目和代码",
"cmd_profile": "显示练习配置",
"cmd_profile": "显示用户配置",
"cmd_difficulty": "设置偏好难度",
"cmd_topics": "设置偏好主题",
"cmd_avoid": "设置要避开的主题",
"cmd_generate_languages": "设置生成题目的答案语言",
"cmd_generate_ui": "设置生成题目的 UI 语言",
"cmd_provider": "设置 AI provider",

@@ -62,4 +68,8 @@ "cmd_model": "设置 AI model",

"already_busy": "已有任务正在运行。",
"busy_warmup": "热身:当 * 到达中间时按 Space。",
"busy_commands_paused": "生成完成前命令会暂停。",
"hits": "命中",
"misses": "失误",
"run_pass_next": "下一步: /next",
"run_fail_next": "修复: 按 Esc 或点击此面板编辑,然后 /run"
"run_fail_next": "修复: 按 Esc 或 e 编辑,然后 /run"
}
+241
-152

@@ -18,2 +18,17 @@ # This file is automatically @generated by Cargo.

[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"

@@ -25,6 +40,6 @@ version = "2.13.0"

[[package]]
name = "cassowary"
version = "0.3.0"
name = "by_address"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"

@@ -48,5 +63,5 @@ [[package]]

name = "compact_str"
version = "0.8.2"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e"
checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
dependencies = [

@@ -72,18 +87,2 @@ "castaway",

name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags",
"crossterm_winapi",
"mio",
"parking_lot",
"rustix 0.38.44",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm"
version = "0.29.0"

@@ -99,3 +98,3 @@ source = "registry+https://github.com/rust-lang/crates.io-index"

"parking_lot",
"rustix 1.1.4",
"rustix",
"signal-hook",

@@ -150,2 +149,8 @@ "signal-hook-mio",

[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "derive_more"

@@ -200,16 +205,22 @@ version = "2.1.1"

"libc",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
name = "fast-srgb8"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
[[package]]
name = "foldhash"
version = "0.1.5"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.15.5"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [

@@ -222,2 +233,13 @@ "allocator-api2",

[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "heck"

@@ -258,5 +280,5 @@ version = "0.5.0"

name = "itertools"
version = "0.13.0"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [

@@ -273,2 +295,13 @@ "either",

[[package]]
name = "kasuari"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
dependencies = [
"hashbrown 0.16.1",
"portable-atomic",
"thiserror",
]
[[package]]
name = "libc"

@@ -280,8 +313,17 @@ version = "0.2.186"

[[package]]
name = "linux-raw-sys"
version = "0.4.15"
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "line-clipping"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8"
dependencies = [
"bitflags",
]
[[package]]
name = "linux-raw-sys"

@@ -315,7 +357,7 @@ version = "0.12.1"

name = "lru"
version = "0.12.5"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9"
dependencies = [
"hashbrown",
"hashbrown 0.17.1",
]

@@ -338,6 +380,54 @@

"wasi",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]]
name = "palette"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6"
dependencies = [
"approx",
"fast-srgb8",
"libm",
"palette_derive",
]
[[package]]
name = "palette_derive"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30"
dependencies = [
"by_address",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "parking_lot"

@@ -366,17 +456,23 @@ version = "0.12.5"

[[package]]
name = "paste"
version = "1.0.15"
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "practicode"
version = "0.1.6"
version = "0.1.9"
dependencies = [
"anyhow",
"crossterm 0.29.0",
"crossterm",
"ratatui",
"serde",
"serde_json",
"unicode-width 0.2.0",
"unicode-width",
"wait-timeout",

@@ -405,22 +501,67 @@ ]

name = "ratatui"
version = "0.29.0"
version = "0.30.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d"
dependencies = [
"instability",
"ratatui-core",
"ratatui-crossterm",
"ratatui-widgets",
"serde",
]
[[package]]
name = "ratatui-core"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
dependencies = [
"bitflags",
"cassowary",
"compact_str",
"crossterm 0.28.1",
"indoc",
"instability",
"hashbrown 0.17.1",
"itertools",
"kasuari",
"lru",
"paste",
"palette",
"serde",
"strum",
"thiserror",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.2.0",
"unicode-width",
]
[[package]]
name = "ratatui-crossterm"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0"
dependencies = [
"cfg-if",
"crossterm",
"instability",
"ratatui-core",
]
[[package]]
name = "ratatui-widgets"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
dependencies = [
"bitflags",
"hashbrown 0.17.1",
"indoc",
"instability",
"itertools",
"line-clipping",
"ratatui-core",
"serde",
"strum",
"time",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "redox_syscall"

@@ -445,15 +586,2 @@ version = "0.5.18"

name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.59.0",
]
[[package]]
name = "rustix"
version = "1.1.4"

@@ -466,4 +594,4 @@ source = "registry+https://github.com/rust-lang/crates.io-index"

"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"linux-raw-sys",
"windows-sys",
]

@@ -589,5 +717,5 @@

name = "strum"
version = "0.26.3"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [

@@ -599,5 +727,5 @@ "strum_macros",

name = "strum_macros"
version = "0.26.4"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [

@@ -607,3 +735,2 @@ "heck",

"quote",
"rustversion",
"syn",

@@ -624,2 +751,43 @@ ]

[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "time"
version = "0.3.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
dependencies = [
"deranged",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "unicode-ident"

@@ -638,9 +806,9 @@ version = "1.0.24"

name = "unicode-truncate"
version = "1.1.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
dependencies = [
"itertools",
"unicode-segmentation",
"unicode-width 0.1.14",
"unicode-width",
]

@@ -650,8 +818,2 @@

name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.0"

@@ -706,11 +868,2 @@ source = "registry+https://github.com/rust-lang/crates.io-index"

name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"

@@ -724,66 +877,2 @@ source = "registry+https://github.com/rust-lang/crates.io-index"

[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zmij"

@@ -790,0 +879,0 @@ version = "1.0.21"

[package]
name = "practicode"
version = "0.1.6"
version = "0.1.9"
edition = "2024"

@@ -16,3 +16,3 @@ description = "Local-first coding-test practice in a Rust terminal UI with optional AI help."

crossterm = "0.29"
ratatui = "0.29"
ratatui = { version = "0.30", default-features = false, features = ["crossterm"] }
serde = { version = "1", features = ["derive"] }

@@ -19,0 +19,0 @@ serde_json = "1"

@@ -8,6 +8,9 @@ # Architecture

- `src/core.rs` owns problem data, state loading/saving, judging, and file generation.
- `src/core/profile.rs` owns practice-profile defaults and normalization.
- `src/tui.rs` owns Ratatui rendering and interaction flow.
- `src/core/profile.rs` owns user-profile defaults and normalization.
- `src/tui.rs` owns the Ratatui app shell, event routing, and workflow orchestration.
- `src/tui/commands.rs` owns the command palette catalog.
- `src/ai.rs` owns provider commands, daemon/model checks, and AI prompts.
- `src/tui/editor.rs` owns the in-terminal code editor state.
- `src/tui/problem_view.rs` owns problem-statement rendering.
- `src/tui/settings_panel.rs` owns `/profile` setup-panel rendering and keyboard toggles.
- `src/ai.rs` owns provider commands, daemon/model checks, and AI prompts for foreground `/next` generation and background `/generate` prefetch.
- `src/update.rs` owns update checks.

@@ -18,6 +21,8 @@ - `src/text.rs` owns terminal text editing and markdown/plain rendering helpers.

- Add domain logic under the owning module first; keep `tui.rs` as orchestration and rendering.
- Add domain logic under the owning module first; keep `tui.rs` as orchestration, not a catch-all.
- Add user-visible commands in `src/tui/commands.rs`, then route behavior in `PracticodeApp::handle_command`.
- Add persisted profile settings to `Settings`, normalize them in `normalize_settings`, and cover old-state compatibility with tests.
- Add persisted user profile settings to `Settings`, normalize them in `normalize_settings`, and cover old-state compatibility with tests.
- Keep provider-specific behavior in `src/ai.rs`; TUI should ask for status or start tasks, not know provider internals.
- Keep foreground and background generation flows separate: `/next` may block when no local problem exists, while `/generate` must preserve the current problem and user profile state.
- Keep output panes copy-friendly. Mouse capture should be enabled for the visible code editor, but disabled while output, hints, answers, lists, or settings panels are shown so terminal drag selection keeps working.
- Keep local user data backwards-compatible. Missing fields should default cleanly.

@@ -24,0 +29,0 @@

@@ -48,2 +48,3 @@ # Maintaining

npm view practicode version
npm view practicode dist.signatures dist.attestations --json
cargo search practicode --limit 1

@@ -54,2 +55,6 @@ ```

For npm supply-chain posture, keep `publishConfig.provenance` enabled and keep the release job's `id-token: write` permission. When the npm package's Trusted Publisher setting is configured for this repository and `.github/workflows/release.yml`, remove the long-lived `NPM_TOKEN` dependency from the npm publish steps and disallow token publishing in the npm package settings.
Socket.dev indexes the npm package page at <https://socket.dev/npm/package/practicode>. It may lag behind npm immediately after a release; verify npm first with `npm view practicode version`, then re-check Socket after indexing catches up. If Socket flags the npm `postinstall` script, confirm it still only runs the locked Cargo build documented in the README.
## Documentation Ownership

@@ -56,0 +61,0 @@

{
"name": "practicode",
"version": "0.1.6",
"version": "0.1.9",
"description": "Local-first coding-test practice in a Rust terminal UI with optional AI help.",

@@ -11,2 +11,5 @@ "license": "MIT",

"homepage": "https://github.com/baba9811/practicode#readme",
"bugs": {
"url": "https://github.com/baba9811/practicode/issues"
},
"bin": {

@@ -31,2 +34,3 @@ "practicode": "bin/practicode.js"

"README.md",
"SECURITY.md",
"THIRD_PARTY_LICENSES.md"

@@ -46,4 +50,5 @@ ],

"publishConfig": {
"access": "public"
"access": "public",
"provenance": true
}
}
+72
-14

@@ -7,3 +7,6 @@ # practicode

![AI ready](https://img.shields.io/badge/AI-Codex%20%2B%20Claude-111827)
![crates.io](https://img.shields.io/crates/v/practicode?logo=rust)
![npm](https://img.shields.io/npm/v/practicode?logo=npm)
![CI](https://github.com/baba9811/practicode/actions/workflows/ci.yml/badge.svg)
[Socket.dev package health](https://socket.dev/npm/package/practicode)

@@ -16,4 +19,11 @@ ![practicode terminal UI](assets/practicode-terminal.svg)

## Start
## What You Get
- Local stdin/stdout judging for Python, TypeScript, Java, and Rust.
- A two-pane terminal UI with problem text, editor, output, and command palette.
- Local-first problem history under ignored `.practicode/`, `problems/`, and `submissions/` paths.
- Optional Codex or Claude Code help for hints and generated next problems.
## Install
### Prerequisites

@@ -32,2 +42,4 @@

The npm package has a `postinstall` step that runs `cargo build --release --locked` from the package root so the Rust TUI binary is ready. Set `PRACTICODE_SKIP_BUILD=1` to skip that install-time build; the `practicode` launcher will try the same locked Cargo build on first run if the binary is missing.
### Cargo

@@ -49,7 +61,16 @@

### Check Install
```bash
practicode --version
practicode --smoke
practicode --help
```
## Daily Loop
The code editor starts focused.
On first run, practicode opens a setup panel. After that, the code editor starts focused.
```text
first run: review /profile setup
write code

@@ -61,6 +82,16 @@ Esc, then /

Typing `/` outside the editor opens the command palette. Use `up/down` to move, `Enter` to run or complete the selected command, and `Esc` to cancel.
Typing `/` outside the editor opens the command palette. Use `up/down` to move, `Enter` to run or complete the selected command, and `Esc` to cancel. Press `?` for in-app help or `Ctrl+C` to quit.
Hints, failed cases, answers, and user profile panels are copy-friendly: drag in the output pane to use your terminal's normal text selection/copy behavior. When the code editor is visible in the right pane, mouse focus is enabled for that editor. Use `Esc`, `e`, or `/code` to return from output to code.
Submissions are saved as you type under `submissions/<problem-id>/solution.<ext>`.
## CLI Flags
| Flag | Action |
| --- | --- |
| `--help`, `-h` | Show non-interactive help |
| `--version`, `-V` | Print the installed version |
| `--smoke` | Print the current problem title and exit |
## Commands

@@ -72,4 +103,4 @@

| `/code` | Return to the code editor |
| `/next` | Open the next local problem, or ask AI to create one |
| `/next easy string problem` | Ask AI for a custom next problem |
| `/next` | Open the next unsolved problem, or ask AI only when none remain |
| `/generate easy string problem` | Ask AI to create a new problem in the background |
| `/back` | Go back through problem history |

@@ -81,6 +112,8 @@ | `/problems` | Browse problems with `up/down` or `j/k`, open with `Enter` |

| `/hint explain my bug` | Ask the selected AI about the current problem and submission |
| `/profile` | Show your current practice profile |
| `/profile` | Show your current user profile |
| `/difficulty auto` | Set difficulty preference: `auto`, `easy`, `medium`, `hard` |
| `/topics arrays, strings` | Set preferred topics for future problems |
| `/avoid dp, graph` | Set topics to avoid in future problems |
| `/generate-languages python, rust` | Limit generated answer languages, or use `all` |
| `/generate-ui ko, en` | Limit generated problem text languages, or use `all` |
| `/provider codex` | Set AI provider and show local CLI/daemon status |

@@ -98,12 +131,18 @@ | `/model auto` | Use the provider default model for `/hint` and AI-backed `/next` |

Your practice profile is saved in `.practicode/problem-state.json`. It keeps UI language, code language, theme, preferred difficulty, preferred topics, and topics to avoid. `auto` difficulty follows gradual progression; a fixed difficulty asks local selection and AI generation to prefer that level.
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.
## AI Problems
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`.
`/next <request>` passes your request into the selected AI problem generator.
## Problem Flow
`/next` is local-first: it opens the next unsolved local problem before generating anything. When no unsolved problem remains, it asks the selected AI provider to create one.
Use `/generate <request>` when you explicitly want to create a new problem in the background while you keep solving the current one. The generated problem stays local and `/next` will pick it up later.
If `/next` has to generate because no local problem remains, it runs in the foreground. Editing and commands are paused so state cannot change halfway through that AI task. Press `Space` for the warmup timing drill, or `q`/`Ctrl+C` to quit.
```text
/next a slightly harder string problem
/next hashmap practice, easy
/next sorting problem, no graph yet
/generate a slightly harder string problem
/generate hashmap practice, easy
/generate sorting problem, no graph yet
```

@@ -146,6 +185,25 @@

## Safety
## Safety And Security
`/run` executes your local submission as a normal process. practicode runs it from `.practicode/build/<problem-id>/run`, but this is not an OS sandbox. Only run code you trust.
- `/run` executes your local submission as a normal process. practicode runs it from `.practicode/build/<problem-id>/run`, but this is not an OS sandbox. Only run code you trust.
- `/hint` sends the current problem and submission to the selected AI provider CLI.
- AI-backed `/next` and `/generate` can run a custom shell command from `settings.ai_next_command`; save only commands you trust.
- npm installs run the package `postinstall` script described above. It only invokes Cargo with the checked-in lockfile from this package root; it does not read local `.env`/`.npmrc` files or contact the configured AI provider.
- npm releases are published from GitHub Actions with registry signatures and provenance enabled in `package.json`. The release workflow is also prepared for npm Trusted Publishing/OIDC; maintainers should prefer that over long-lived publish tokens when the package setting is enabled on npm.
- Local `.env`, `.npmrc`, `.practicode/`, `problems/`, and `submissions/` are ignored by git. Do not commit tokens, private prompts, or answer keys.
## Development Checks
```bash
cargo fmt --check
cargo test
cargo clippy --all-targets --all-features -- -D warnings
cargo run -- --smoke
cargo audit --deny warnings
npm pack --dry-run
npm run smoke
```
CI runs the Rust audit gate with `cargo audit --deny warnings`; release publishing stops before crates.io/npm publish if that audit fails. This repo has no npm dependencies or lockfile today, so `npm audit` and `pnpm audit` are not applicable until a matching lockfile is added.
## Contributing

@@ -152,0 +210,0 @@

+103
-4
use crate::{
core::{
AppState, PROBLEM_NOTES_PATH, Problem, Settings, ensure_submission, normalize_ai_provider,
render_problem,
AppState, LANGUAGES, PROBLEM_NOTES_PATH, Problem, Settings, UI_LANGUAGES,
ensure_submission, normalize_ai_provider, render_problem,
},

@@ -81,2 +81,34 @@ process::{run_capture, sh_quote, shell_process, unique_temp_path, which},

pub fn run_ai_generate(root: &Path, state: &AppState, request: &str) -> String {
let provider = normalize_ai_provider(&state.settings.ai_provider);
let command = if state.settings.next_ai_command().trim().is_empty() {
default_ai_generate_command(root, &state.settings, request)
} else {
state.settings.next_ai_command().to_string()
};
let mut process = shell_process(&command);
process
.current_dir(root)
.env("PRACTICODE_NEXT_REQUEST", request)
.env("PRACTICODE_GENERATE_BACKGROUND", "1")
.env("PRACTICODE_AI_PROVIDER", &provider)
.env("PRACTICODE_AI_MODEL", &state.settings.ai_model);
match run_capture(&mut process, "", Duration::from_secs(900)) {
Ok(run) if run.code == Some(0) => {
let output = output_text(&run.stdout, &run.stderr);
format!("{provider} background generation finished\n{output}")
.trim()
.to_string()
}
Ok(run) => {
let output = output_text(&run.stdout, &run.stderr);
format!(
"{provider} background generation failed ({})\n{output}",
run.code.unwrap_or(-1)
)
}
Err(error) => format!("{provider} background generation failed\n{error}"),
}
}
pub fn default_ai_next_command(root: &Path, settings: &Settings, request: &str) -> String {

@@ -89,2 +121,9 @@ match normalize_ai_provider(&settings.ai_provider).as_str() {

pub fn default_ai_generate_command(root: &Path, settings: &Settings, request: &str) -> String {
match normalize_ai_provider(&settings.ai_provider).as_str() {
"claude" => default_claude_generate_command(root, settings, request),
_ => default_codex_generate_command(root, settings, request),
}
}
pub fn provider_status(provider: &str) -> String {

@@ -133,3 +172,3 @@ match normalize_ai_provider(provider).as_str() {

format!(
"Read AGENTS.md, docs/problem-authoring-notes.md if present, .practicode/problem_notes.md if present, problems/INDEX.md if present, .practicode/problem_bank.json if present, and .practicode/problem-state.json. Create exactly one new non-duplicate coding practice problem. The built-in 001-hello-world already exists, so do not duplicate it. User request: {}. Practice profile: difficulty preference: {}; preferred topics: {}; avoid topics: {}; code language: {}; UI language: {}. Treat difficulty auto as gradual progression from state; otherwise prefer the requested difficulty unless the direct user request conflicts. Make the smallest valid edits: update .practicode/problem_bank.json, one problem directory, problems/INDEX.md, and .practicode/problem-state.json. Do not include the answer in the problem statement.",
"Read AGENTS.md, docs/problem-authoring-notes.md if present, .practicode/problem_notes.md if present, problems/INDEX.md if present, .practicode/problem_bank.json if present, and .practicode/problem-state.json. Create exactly one new non-duplicate coding practice problem. The built-in 001-hello-world already exists, so do not duplicate it. User request: {}. User profile: difficulty preference: {}; preferred topics: {}; avoid topics: {}; code language: {}; UI language: {}; generated answer languages: {}; generated UI languages: {}. Treat difficulty auto as gradual progression from state; otherwise prefer the requested difficulty unless the direct user request conflicts. Make the smallest valid edits: update .practicode/problem_bank.json, one problem directory, problems/INDEX.md, and .practicode/problem-state.json. Do not include the answer in the problem statement.",
if request.is_empty() {

@@ -144,6 +183,26 @@ "(none)"

settings.language,
settings.ui_language
settings.ui_language,
list_or_all(&settings.generate_languages, LANGUAGES),
list_or_all(&settings.generate_ui_languages, UI_LANGUAGES)
)
}
pub fn default_ai_generate_prompt_with_settings(settings: &Settings, request: &str) -> String {
format!(
"Read AGENTS.md, docs/problem-authoring-notes.md if present, .practicode/problem_notes.md if present, problems/INDEX.md if present, .practicode/problem_bank.json if present, and .practicode/problem-state.json. Create exactly one new non-duplicate coding practice problem for later use. The built-in 001-hello-world already exists, so do not duplicate it. User request: {}. User profile: difficulty preference: {}; preferred topics: {}; avoid topics: {}; current code language: {}; current UI language: {}; generated answer languages: {}; generated UI languages: {}. Treat difficulty auto as gradual progression from state; otherwise prefer the requested difficulty unless the direct user request conflicts. Make the smallest valid edits: update .practicode/problem_bank.json, one problem directory, and problems/INDEX.md. Preserve .practicode/problem-state.json current_problem, history, solved, and settings; do not switch the current problem. Do not include the answer in the problem statement.",
if request.is_empty() {
"(none)"
} else {
request
},
settings.difficulty,
list_or_none(&settings.topics),
list_or_none(&settings.avoid_topics),
settings.language,
settings.ui_language,
list_or_all(&settings.generate_languages, LANGUAGES),
list_or_all(&settings.generate_ui_languages, UI_LANGUAGES)
)
}
pub fn append_problem_note(root: &Path, note: &str) -> Result<()> {

@@ -326,2 +385,18 @@ let path = root.join(PROBLEM_NOTES_PATH);

fn default_codex_generate_command(root: &Path, settings: &Settings, request: &str) -> String {
let start = "if [ -x \"$HOME/.codex/packages/standalone/current/codex\" ]; then codex app-server daemon start >/dev/null 2>&1 || true; fi";
let mut exec = format!(
"codex exec --ephemeral --cd {} --sandbox workspace-write",
sh_quote(&root.display().to_string())
);
if let Some(model) = settings.model_arg() {
exec.push_str(&format!(" --model {}", sh_quote(model)));
}
exec.push(' ');
exec.push_str(&sh_quote(&default_ai_generate_prompt_with_settings(
settings, request,
)));
format!("{start}; {exec}")
}
fn codex_daemon_path() -> Option<PathBuf> {

@@ -351,2 +426,18 @@ env::var_os("HOME").map(|home| {

fn default_claude_generate_command(root: &Path, settings: &Settings, request: &str) -> String {
let mut claude = "claude --permission-mode acceptEdits".to_string();
if let Some(model) = settings.model_arg() {
claude.push_str(&format!(" --model {}", sh_quote(model)));
}
claude.push_str(" -p ");
claude.push_str(&sh_quote(&default_ai_generate_prompt_with_settings(
settings, request,
)));
format!(
"claude daemon status >/dev/null 2>&1 || true; cd {}; {}",
sh_quote(&root.display().to_string()),
claude
)
}
fn output_text(stdout: &str, stderr: &str) -> String {

@@ -368,2 +459,10 @@ [stdout.trim(), stderr.trim()]

fn list_or_all(values: &[String], all: &[&str]) -> String {
if values.is_empty() {
all.join(", ")
} else {
values.join(", ")
}
}
#[cfg(test)]

@@ -370,0 +469,0 @@ mod tests {

@@ -39,2 +39,6 @@ use crate::process::{CommandSpec, run_capture, which};

pub avoid_topics: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub generate_languages: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub generate_ui_languages: Vec<String>,
#[serde(default = "default_editor")]

@@ -61,2 +65,4 @@ pub editor: String,

avoid_topics: Vec::new(),
generate_languages: Vec::new(),
generate_ui_languages: Vec::new(),
editor: default_editor(),

@@ -296,8 +302,15 @@ next_source: default_next_source(),

}
for language in LANGUAGES {
if !problem.answers.contains_key(*language) {
if problem.answers.is_empty() {
bail!(
"{} problem {} must contain at least one answer",
path.display(),
problem.id
);
}
for language in problem.answers.keys() {
if !LANGUAGES.contains(&language.as_str()) {
bail!(
"{} problem {} missing {language} answer",
"{} problem {} has unsupported answer language {language}",
path.display(),
problem.id
problem.id,
);

@@ -387,2 +400,4 @@ }

settings.avoid_topics = normalize_topic_list(&settings.avoid_topics);
settings.generate_languages = normalize_language_list(&settings.generate_languages);
settings.generate_ui_languages = normalize_ui_language_list(&settings.generate_ui_languages);
settings.next_source = normalize_next_source(&settings.next_source);

@@ -408,2 +423,46 @@ settings.ai_provider = normalize_ai_provider(&settings.ai_provider);

pub fn parse_language_list(value: &str) -> Vec<String> {
let mut languages = Vec::new();
for language in value.split(',') {
let language = language.trim().to_lowercase();
if language == "all" {
return Vec::new();
}
if LANGUAGES.contains(&language.as_str()) && !languages.contains(&language) {
languages.push(language);
}
}
languages
}
pub fn normalize_language_list(languages: &[String]) -> Vec<String> {
parse_language_list(&languages.join(","))
}
pub fn parse_ui_language_list(value: &str) -> Vec<String> {
let mut languages = Vec::new();
for language in value.split(',') {
let lower = language.trim().to_lowercase();
if lower == "all" {
return Vec::new();
}
let language = lower
.split(['-', '_'])
.next()
.filter(|value| UI_LANGUAGES.contains(value))
.unwrap_or("");
if language == "all" {
return Vec::new();
}
if !language.is_empty() && !languages.iter().any(|value| value == language) {
languages.push(language.to_string());
}
}
languages
}
pub fn normalize_ui_language_list(languages: &[String]) -> Vec<String> {
parse_ui_language_list(&languages.join(","))
}
pub fn normalize_next_source(source: &str) -> String {

@@ -410,0 +469,0 @@ if source == "ai" {

use anyhow::{Context, Result};
use crossterm::{
cursor::SetCursorStyle,
event::{DisableMouseCapture, EnableMouseCapture},
execute,
};
use crossterm::{cursor::SetCursorStyle, event::DisableMouseCapture, execute};
use std::{env, io::stdout};

@@ -17,5 +13,35 @@

pub fn cli_help_text() -> &'static str {
"practicode - local-first coding-test practice in your terminal
Usage:
practicode Open the TUI
practicode --smoke Print the current problem title and exit
practicode --version Print the installed version
practicode --help Show this help
Inside the TUI:
Esc then / Open the command palette
? Open help
Ctrl+C Quit"
}
pub fn run_cli() -> Result<()> {
let root = env::current_dir().context("read current directory")?;
if env::args().any(|arg| arg == "--smoke") {
let args = env::args().skip(1).collect::<Vec<_>>();
if args
.iter()
.any(|arg| matches!(arg.as_str(), "-h" | "--help"))
{
println!("{}", cli_help_text());
return Ok(());
}
if args
.iter()
.any(|arg| matches!(arg.as_str(), "-V" | "--version"))
{
println!("practicode {}", update::CURRENT_VERSION);
return Ok(());
}
if args.iter().any(|arg| arg == "--smoke") {
let bank = core::load_bank(&root)?;

@@ -33,3 +59,3 @@ let state = core::load_state(&root, &bank)?;

let mut terminal = ratatui::init();
let _ = execute!(stdout(), SetCursorStyle::SteadyBar, EnableMouseCapture);
let _ = execute!(stdout(), SetCursorStyle::SteadyBar);
let result = app.run(&mut terminal);

@@ -44,1 +70,14 @@ ratatui::restore();

}
#[cfg(test)]
mod tests {
use super::cli_help_text;
#[test]
fn cli_help_lists_non_interactive_flags_and_tui_exit() {
let help = cli_help_text();
assert!(help.contains("--smoke"));
assert!(help.contains("--version"));
assert!(help.contains("Ctrl+C"));
}
}

@@ -33,2 +33,9 @@ #[derive(Clone, Copy)]

CommandHint {
insert: "generate ",
display: "/generate <request>",
desc_key: "cmd_generate",
keep_open: true,
help: true,
},
CommandHint {
insert: "back",

@@ -118,2 +125,16 @@ display: "/back",

CommandHint {
insert: "generate-languages ",
display: "/generate-languages <list|all>",
desc_key: "cmd_generate_languages",
keep_open: true,
help: true,
},
CommandHint {
insert: "generate-ui ",
display: "/generate-ui <list|all>",
desc_key: "cmd_generate_ui",
keep_open: true,
help: true,
},
CommandHint {
insert: "provider codex",

@@ -120,0 +141,0 @@ display: "/provider codex",

Sorry, the diff of this file is too big to display