practicode
Advanced tools
+11
| # 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(¤t); | ||
| } | ||
| } | ||
| 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" | ||
| } |
+13
-3
@@ -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" | ||
| } |
+13
-3
@@ -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" | ||
| } |
+13
-3
@@ -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" | ||
| } |
+13
-3
@@ -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" |
+2
-2
| [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" |
+10
-5
@@ -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 @@ |
+7
-2
| { | ||
| "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 | ||
|  | ||
|  | ||
|  | ||
|  | ||
| [Socket.dev package health](https://socket.dev/npm/package/practicode) | ||
@@ -16,4 +19,11 @@  | ||
| ## 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 { |
+63
-4
@@ -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" { |
+46
-7
| 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")); | ||
| } | ||
| } |
+21
-0
@@ -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
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
No bug tracker
MaintenancePackage does not have a linked bug tracker in package.json.
231440
22.06%34
13.33%405
14.08%1
-50%211
37.91%1
-50%