practicode
Advanced tools
| { | ||
| "problem": "Problem", | ||
| "output": "Output", | ||
| "command": "Command", | ||
| "commands": "Commands", | ||
| "difficulty": "Difficulty", | ||
| "topics": "Topics", | ||
| "input": "Input", | ||
| "examples": "Examples", | ||
| "example": "Example", | ||
| "answer": "Answer", | ||
| "source": "source", | ||
| "update": "update", | ||
| "command_placeholder": "Type /, move with up/down, Enter runs", | ||
| "palette_hint": "up/down select | Enter run | Esc cancel", | ||
| "hint_command": "Enter submit | Esc cancel", | ||
| "hint_list": "up/down move | Enter open | Esc close", | ||
| "hint_output": "Esc code | / command | ? help", | ||
| "hint_code": "Esc then / command", | ||
| "hint_idle": "/ command | ? help", | ||
| "help_title": "Help", | ||
| "daily_loop": "Daily loop", | ||
| "keys": "Keys", | ||
| "debug_prints": "Debug prints", | ||
| "cmd_run": "Judge the current submission", | ||
| "cmd_edit": "Return to the code editor", | ||
| "cmd_next": "Open the next problem", | ||
| "cmd_prev": "Open the previous problem", | ||
| "cmd_list": "Browse problems", | ||
| "cmd_open": "Open by number, id, or slug", | ||
| "cmd_giveup": "Show the reference answer", | ||
| "cmd_hint": "Ask for a hint about the current problem", | ||
| "cmd_ai": "Ask AI about the current problem and code", | ||
| "cmd_provider": "Set AI provider", | ||
| "cmd_model": "Set AI model", | ||
| "cmd_model_auto": "Use provider default model", | ||
| "cmd_model_available": "Use an available provider model", | ||
| "cmd_model_custom": "Type a model name", | ||
| "cmd_note": "Add a next-problem note", | ||
| "cmd_notes": "Show saved notes", | ||
| "cmd_lang": "Set code language", | ||
| "cmd_ui": "Set UI language", | ||
| "cmd_theme": "Set theme", | ||
| "cmd_source": "Set next-problem source", | ||
| "cmd_update": "Show update instructions", | ||
| "cmd_help": "Open help", | ||
| "cmd_exit": "Quit", | ||
| "update_available": "Update available", | ||
| "update_none": "practicode is up to date.", | ||
| "update_check_disabled": "Update check is disabled.", | ||
| "update_check_failed": "Could not check for updates.", | ||
| "generating_next": "Generating next problem", | ||
| "already_busy": "Already busy." | ||
| } |
| { | ||
| "problem": "Problema", | ||
| "output": "Salida", | ||
| "command": "Comando", | ||
| "commands": "Comandos", | ||
| "difficulty": "Dificultad", | ||
| "topics": "Temas", | ||
| "input": "Entrada", | ||
| "examples": "Ejemplos", | ||
| "example": "Ejemplo", | ||
| "answer": "Respuesta", | ||
| "source": "fuente", | ||
| "update": "actualizacion", | ||
| "command_placeholder": "Escribe /, elige con arriba/abajo, Enter ejecuta", | ||
| "palette_hint": "arriba/abajo elegir | Enter ejecutar | Esc cancelar", | ||
| "hint_command": "Enter ejecutar | Esc cancelar", | ||
| "hint_list": "arriba/abajo mover | Enter abrir | Esc cerrar", | ||
| "hint_output": "Esc codigo | / comando | ? ayuda", | ||
| "hint_code": "Esc y luego / comando", | ||
| "hint_idle": "/ comando | ? ayuda", | ||
| "help_title": "Ayuda", | ||
| "daily_loop": "Flujo diario", | ||
| "keys": "Teclas", | ||
| "debug_prints": "Salida de depuracion", | ||
| "cmd_run": "Evalua la solucion actual", | ||
| "cmd_edit": "Volver al editor de codigo", | ||
| "cmd_next": "Abrir el siguiente problema", | ||
| "cmd_prev": "Abrir el problema anterior", | ||
| "cmd_list": "Abrir la lista de problemas", | ||
| "cmd_open": "Abrir por numero, id o slug", | ||
| "cmd_giveup": "Mostrar la respuesta de referencia", | ||
| "cmd_hint": "Pedir una pista para el problema actual", | ||
| "cmd_ai": "Preguntar a AI sobre el problema y codigo actuales", | ||
| "cmd_provider": "Configurar AI provider", | ||
| "cmd_model": "Configurar AI model", | ||
| "cmd_model_auto": "Usar el modelo predeterminado del provider", | ||
| "cmd_model_available": "Usar un modelo disponible del provider", | ||
| "cmd_model_custom": "Escribir un nombre de modelo", | ||
| "cmd_note": "Agregar nota para generar problemas", | ||
| "cmd_notes": "Ver notas guardadas", | ||
| "cmd_lang": "Configurar lenguaje de codigo", | ||
| "cmd_ui": "Configurar idioma de UI", | ||
| "cmd_theme": "Configurar tema", | ||
| "cmd_source": "Configurar fuente del siguiente problema", | ||
| "cmd_update": "Mostrar instrucciones de actualizacion", | ||
| "cmd_help": "Abrir ayuda", | ||
| "cmd_exit": "Salir", | ||
| "update_available": "Hay una nueva version", | ||
| "update_none": "practicode esta actualizado.", | ||
| "update_check_disabled": "La comprobacion de actualizaciones esta desactivada.", | ||
| "update_check_failed": "No se pudo comprobar actualizaciones.", | ||
| "generating_next": "Generando el siguiente problema", | ||
| "already_busy": "Ya hay una tarea en curso." | ||
| } |
| { | ||
| "problem": "問題", | ||
| "output": "出力", | ||
| "command": "コマンド", | ||
| "commands": "コマンド", | ||
| "difficulty": "難易度", | ||
| "topics": "トピック", | ||
| "input": "入力", | ||
| "examples": "例", | ||
| "example": "例", | ||
| "answer": "解答", | ||
| "source": "source", | ||
| "update": "update", | ||
| "command_placeholder": "/ を入力し上下で選択、Enter で実行", | ||
| "palette_hint": "上下 選択 | Enter 実行 | Esc キャンセル", | ||
| "hint_command": "Enter 実行 | Esc キャンセル", | ||
| "hint_list": "上下 移動 | Enter 開く | Esc 閉じる", | ||
| "hint_output": "Esc コード | / コマンド | ? ヘルプ", | ||
| "hint_code": "Esc の後 / コマンド", | ||
| "hint_idle": "/ コマンド | ? ヘルプ", | ||
| "help_title": "ヘルプ", | ||
| "daily_loop": "基本フロー", | ||
| "keys": "キー", | ||
| "debug_prints": "デバッグ出力", | ||
| "cmd_run": "現在の提出を判定", | ||
| "cmd_edit": "コードエディタに戻る", | ||
| "cmd_next": "次の問題を開く", | ||
| "cmd_prev": "前の問題を開く", | ||
| "cmd_list": "問題一覧を開く", | ||
| "cmd_open": "番号、id、slug で問題を開く", | ||
| "cmd_giveup": "解答を見る", | ||
| "cmd_hint": "現在の問題のヒントを依頼", | ||
| "cmd_ai": "現在の問題とコードについて AI に質問", | ||
| "cmd_provider": "AI provider を設定", | ||
| "cmd_model": "AI model を設定", | ||
| "cmd_model_auto": "provider の既定モデルを使用", | ||
| "cmd_model_available": "利用可能な provider モデルを使用", | ||
| "cmd_model_custom": "モデル名を直接入力", | ||
| "cmd_note": "次の問題生成メモを追加", | ||
| "cmd_notes": "保存済みメモを見る", | ||
| "cmd_lang": "コード言語を設定", | ||
| "cmd_ui": "UI 言語を設定", | ||
| "cmd_theme": "テーマを設定", | ||
| "cmd_source": "次の問題の取得元を設定", | ||
| "cmd_update": "更新手順を表示", | ||
| "cmd_help": "ヘルプを開く", | ||
| "cmd_exit": "終了", | ||
| "update_available": "新しいバージョンがあります", | ||
| "update_none": "practicode は最新です。", | ||
| "update_check_disabled": "更新確認は無効です。", | ||
| "update_check_failed": "更新を確認できませんでした。", | ||
| "generating_next": "次の問題を生成中", | ||
| "already_busy": "すでに処理中です。" | ||
| } |
| { | ||
| "problem": "문제", | ||
| "output": "출력", | ||
| "command": "명령", | ||
| "commands": "명령", | ||
| "difficulty": "난이도", | ||
| "topics": "주제", | ||
| "input": "입력", | ||
| "examples": "예시", | ||
| "example": "예시", | ||
| "answer": "정답", | ||
| "source": "출처", | ||
| "update": "업데이트", | ||
| "command_placeholder": "/ 입력 후 위/아래로 선택, Enter 실행", | ||
| "palette_hint": "위/아래 선택 | Enter 실행 | Esc 취소", | ||
| "hint_command": "Enter 실행 | Esc 취소", | ||
| "hint_list": "위/아래 이동 | Enter 열기 | Esc 닫기", | ||
| "hint_output": "Esc 코드 | / 명령 | ? 도움말", | ||
| "hint_code": "Esc 후 / 명령", | ||
| "hint_idle": "/ 명령 | ? 도움말", | ||
| "help_title": "도움말", | ||
| "daily_loop": "기본 흐름", | ||
| "keys": "키", | ||
| "debug_prints": "디버그 출력", | ||
| "cmd_run": "현재 제출을 채점", | ||
| "cmd_edit": "코드 편집기로 돌아가기", | ||
| "cmd_next": "다음 문제 열기", | ||
| "cmd_prev": "이전 문제 열기", | ||
| "cmd_list": "문제 목록 열기", | ||
| "cmd_open": "번호, id, slug로 문제 열기", | ||
| "cmd_giveup": "정답 보기", | ||
| "cmd_hint": "현재 문제 힌트 요청", | ||
| "cmd_ai": "현재 문제와 코드에 대해 AI에게 질문", | ||
| "cmd_provider": "AI provider 설정", | ||
| "cmd_model": "AI model 설정", | ||
| "cmd_model_auto": "provider 기본 모델 사용", | ||
| "cmd_model_available": "사용 가능한 provider 모델 선택", | ||
| "cmd_model_custom": "모델 이름 직접 입력", | ||
| "cmd_note": "다음 문제 생성 메모 추가", | ||
| "cmd_notes": "저장된 메모 보기", | ||
| "cmd_lang": "코드 언어 설정", | ||
| "cmd_ui": "UI 언어 설정", | ||
| "cmd_theme": "테마 설정", | ||
| "cmd_source": "다음 문제 출처 설정", | ||
| "cmd_update": "업데이트 안내 보기", | ||
| "cmd_help": "도움말 열기", | ||
| "cmd_exit": "종료", | ||
| "update_available": "새 버전 사용 가능", | ||
| "update_none": "practicode가 최신 버전입니다.", | ||
| "update_check_disabled": "업데이트 확인이 꺼져 있습니다.", | ||
| "update_check_failed": "업데이트를 확인할 수 없습니다.", | ||
| "generating_next": "다음 문제 생성 중", | ||
| "already_busy": "이미 작업 중입니다." | ||
| } |
| { | ||
| "problem": "题目", | ||
| "output": "输出", | ||
| "command": "命令", | ||
| "commands": "命令", | ||
| "difficulty": "难度", | ||
| "topics": "主题", | ||
| "input": "输入", | ||
| "examples": "示例", | ||
| "example": "示例", | ||
| "answer": "答案", | ||
| "source": "来源", | ||
| "update": "更新", | ||
| "command_placeholder": "输入 / 后用上下键选择,Enter 执行", | ||
| "palette_hint": "上下 选择 | Enter 执行 | Esc 取消", | ||
| "hint_command": "Enter 执行 | Esc 取消", | ||
| "hint_list": "上下 移动 | Enter 打开 | Esc 关闭", | ||
| "hint_output": "Esc 代码 | / 命令 | ? 帮助", | ||
| "hint_code": "Esc 后输入 / 命令", | ||
| "hint_idle": "/ 命令 | ? 帮助", | ||
| "help_title": "帮助", | ||
| "daily_loop": "日常流程", | ||
| "keys": "按键", | ||
| "debug_prints": "调试输出", | ||
| "cmd_run": "评测当前提交", | ||
| "cmd_edit": "回到代码编辑器", | ||
| "cmd_next": "打开下一题", | ||
| "cmd_prev": "打开上一题", | ||
| "cmd_list": "打开题目列表", | ||
| "cmd_open": "按编号、id 或 slug 打开题目", | ||
| "cmd_giveup": "显示参考答案", | ||
| "cmd_hint": "请求当前题目的提示", | ||
| "cmd_ai": "向 AI 询问当前题目和代码", | ||
| "cmd_provider": "设置 AI provider", | ||
| "cmd_model": "设置 AI model", | ||
| "cmd_model_auto": "使用 provider 默认模型", | ||
| "cmd_model_available": "使用可用的 provider 模型", | ||
| "cmd_model_custom": "输入模型名称", | ||
| "cmd_note": "添加下次出题备注", | ||
| "cmd_notes": "查看保存的备注", | ||
| "cmd_lang": "设置代码语言", | ||
| "cmd_ui": "设置 UI 语言", | ||
| "cmd_theme": "设置主题", | ||
| "cmd_source": "设置下一题来源", | ||
| "cmd_update": "显示更新说明", | ||
| "cmd_help": "打开帮助", | ||
| "cmd_exit": "退出", | ||
| "update_available": "有新版本可用", | ||
| "update_none": "practicode 已是最新版本。", | ||
| "update_check_disabled": "更新检查已禁用。", | ||
| "update_check_failed": "无法检查更新。", | ||
| "generating_next": "正在生成下一题", | ||
| "already_busy": "已有任务正在运行。" | ||
| } |
| # Contributing | ||
| Thanks for helping improve practicode. This guide is for contributors opening issues or pull requests. | ||
| Maintainer-only review and release steps live in [MAINTAINING.md](MAINTAINING.md). | ||
| ## Before You Start | ||
| - Search existing issues and pull requests first. | ||
| - Small bug fixes, docs fixes, tests, and localization updates can go straight to a pull request. | ||
| - For larger UI, AI-generation, storage, or packaging changes, open an issue first so the scope is clear. | ||
| - Do not commit local practice data from `.practicode/`, `problems/`, or `submissions/`. | ||
| - Do not include secrets, tokens, private prompts, or generated answer keys in docs or examples. | ||
| ## Fork And Pull Request Flow | ||
| 1. Fork `baba9811/practicode` on GitHub. | ||
| 2. Clone your fork and add the original repo as `upstream`. | ||
| ```bash | ||
| git clone https://github.com/<your-user>/practicode.git | ||
| cd practicode | ||
| git remote add upstream https://github.com/baba9811/practicode.git | ||
| ``` | ||
| 3. Create a focused branch from the latest `main`. | ||
| ```bash | ||
| git fetch upstream | ||
| git checkout -b fix-short-name upstream/main | ||
| ``` | ||
| 4. Make one focused change. | ||
| 5. Run the smallest checks that cover your change. | ||
| 6. Push your branch and open a pull request into `baba9811/practicode:main`. | ||
| ```bash | ||
| git push origin fix-short-name | ||
| ``` | ||
| When opening the pull request, include what changed, how you checked it, and screenshots for visible TUI changes. | ||
| ## Local Setup | ||
| Prerequisites: | ||
| - Rust stable with Cargo, rustfmt, and clippy. | ||
| - Node.js 18+ for the npm wrapper and package checks. | ||
| - Optional local runtimes for judging: Python, Node.js, JDK, and Rust. | ||
| Common commands: | ||
| ```bash | ||
| cargo run -- | ||
| cargo run -- --smoke | ||
| cargo test | ||
| npm run smoke | ||
| ``` | ||
| Full local check: | ||
| ```bash | ||
| make test | ||
| ``` | ||
| ## Project Map | ||
| | Path | Role | | ||
| | --- | --- | | ||
| | `src/core.rs` | Problem storage, state, rendering, judging | | ||
| | `src/i18n.rs` | Loads UI strings from `assets/i18n/*.json` | | ||
| | `src/tui.rs` | Ratatui app, editor, command parser | | ||
| | `src/ai.rs` | Codex/Claude command integration and notes | | ||
| | `src/text.rs` | UTF-8 cursor math and Hangul composition | | ||
| | `src/process.rs` | Process execution helpers | | ||
| | `tests/` | Integration tests split by module | | ||
| ## Change Guidelines | ||
| - Keep pull requests small and reviewable. | ||
| - Reuse existing helpers and patterns before adding new code. | ||
| - Prefer the Rust standard library. Add crates only when they remove real complexity. | ||
| - Put UI strings in [assets/i18n](../assets/i18n), not inline in Rust code. | ||
| - Keep English localization complete first; other locales can be partial because the runtime falls back per key to English. | ||
| - Keep the root [README](../README.md) focused on users. | ||
| - Use relative links for repo-local docs and assets. | ||
| ## Problem Authoring | ||
| AI generation reads [problem-authoring-notes.md](problem-authoring-notes.md) every time it creates a problem. | ||
| Local generated data stays ignored by git: | ||
| | Path | Purpose | | ||
| | --- | --- | | ||
| | `.practicode/problem_bank.json` | Local/custom/generated problems | | ||
| | `.practicode/problem_notes.md` | Personal problem-generation notes | | ||
| | `.practicode/problem-state.json` | Current problem, history, settings | | ||
| | `problems/` | Generated problem markdown/index files | | ||
| | `submissions/` | Local answer files | | ||
| ## Pull Request Checklist | ||
| - The change is focused and explained. | ||
| - Relevant checks were run, or the PR says why they were not. | ||
| - Visible TUI changes include a screenshot or short terminal description. | ||
| - User-facing text is in `assets/i18n/*.json`. | ||
| - Local generated data, secrets, tokens, and answer keys are not committed. | ||
| ## References | ||
| - GitHub contributing guide: https://docs.github.com/en/get-started/exploring-projects-on-github/contributing-to-open-source | ||
| - GitHub contributing guidelines: https://docs.github.com/en/communities/setting-up-your-project-for-healthy-contributions/setting-guidelines-for-repository-contributors | ||
| - GitHub pull requests: https://docs.github.com/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests | ||
| - Open Source Guides community notes: https://opensource.guide/building-community/ | ||
| - WAI-ARIA combobox keyboard interaction: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/ | ||
| - Command Line Interface Guidelines: https://clig.dev/ | ||
| - Ratatui terminal UI library: https://ratatui.rs/ | ||
| - Crossterm terminal backend/events: https://github.com/crossterm-rs/crossterm | ||
| - Kattis problem package format: https://www.kattis.com/problem-package-format/ | ||
| - ICPC judging guidelines: https://icpc.global/regionals/regional-contest-cookbook-judging-guidelines |
| # Maintaining | ||
| This guide is for maintainers with commit, tag, or publishing responsibility. | ||
| Contributor-facing workflow lives in [CONTRIBUTING.md](CONTRIBUTING.md). | ||
| ## Triage | ||
| - Keep issues actionable: expected behavior, actual behavior, reproduction steps, OS, terminal, and install method. | ||
| - Use small scopes. Ask broad proposals to become one issue or pull request per behavior change. | ||
| - Label approachable work as `good first issue` or `help wanted` when the fix is clear. | ||
| - Close duplicates with a link to the canonical issue. | ||
| ## Pull Request Review | ||
| - Review the diff file by file. | ||
| - Check correctness, local-first behavior, terminal UX, docs, and tests before style. | ||
| - Prefer focused pull requests. Ask contributors to split unrelated changes. | ||
| - Require checks or a clear reason checks were not run. | ||
| - For visible TUI changes, ask for a screenshot or a short terminal description. | ||
| - Merge only after CI passes and the branch is up to date enough to avoid obvious conflicts. | ||
| ## Release | ||
| `main` runs CI only. Releases are tag-based and publish to crates.io and npm through GitHub Actions. | ||
| Preflight: | ||
| ```bash | ||
| git checkout main | ||
| git pull --ff-only origin main | ||
| make test | ||
| ``` | ||
| Release: | ||
| ```bash | ||
| make release VERSION=0.1.3 | ||
| ``` | ||
| The release script checks versions, runs tests, creates the version commit and tag, and pushes `main` plus the tag. | ||
| Verify publication: | ||
| ```bash | ||
| gh run list --limit 5 | ||
| npm view practicode version | ||
| cargo search practicode --limit 1 | ||
| ``` | ||
| Do not print or commit tokens. Local `.env` and `.npmrc` are ignored; GitHub Actions uses `NPM_TOKEN` and `CRATES_TOKEN` repository secrets. | ||
| ## Documentation Ownership | ||
| | File | Audience | | ||
| | --- | --- | | ||
| | [../README.md](../README.md) | Users installing and running practicode | | ||
| | [CONTRIBUTING.md](CONTRIBUTING.md) | External contributors opening issues or pull requests | | ||
| | [MAINTAINING.md](MAINTAINING.md) | Maintainers reviewing, triaging, and releasing | | ||
| | [problem-authoring-notes.md](problem-authoring-notes.md) | AI/local problem generation rules | | ||
| ## Maintainer References | ||
| - GitHub reviewing pull requests: https://docs.github.com/articles/reviewing-proposed-changes-in-a-pull-request | ||
| - GitHub helping reviewers: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/getting-started/helping-others-review-your-changes | ||
| - GitHub healthy contributions: https://docs.github.com/en/communities/setting-up-your-project-for-healthy-contributions | ||
| - Git contributing through forks: https://git-scm.com/book/en/v2/GitHub-Contributing-to-a-Project |
| #!/usr/bin/env bash | ||
| set -euo pipefail | ||
| current=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1) | ||
| npm_current=$(node -p "require('./package.json').version") | ||
| if [[ "$current" != "$npm_current" ]]; then | ||
| echo "Cargo.toml version ($current) does not match package.json ($npm_current)" >&2 | ||
| exit 1 | ||
| fi | ||
| if [[ -n "$(git status --porcelain)" ]]; then | ||
| echo "working tree is dirty; commit or stash changes first" >&2 | ||
| exit 1 | ||
| fi | ||
| branch=$(git branch --show-current) | ||
| if [[ "$branch" != "main" ]]; then | ||
| echo "release from main, not $branch" >&2 | ||
| exit 1 | ||
| fi | ||
| git fetch origin main --tags | ||
| if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then | ||
| echo "local main is not synced with origin/main" >&2 | ||
| exit 1 | ||
| fi | ||
| echo "Current version: $current" | ||
| version="${1:-}" | ||
| if [[ -z "$version" ]]; then | ||
| read -r -p "Next version: " version | ||
| fi | ||
| if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | ||
| echo "version must look like 0.1.1" >&2 | ||
| exit 2 | ||
| fi | ||
| node -e ' | ||
| const [current, next] = process.argv.slice(1).map(v => v.split(".").map(Number)); | ||
| const ok = next[0] > current[0] || | ||
| (next[0] === current[0] && (next[1] > current[1] || | ||
| (next[1] === current[1] && next[2] > current[2]))); | ||
| process.exit(ok ? 0 : 1); | ||
| ' "$current" "$version" || { | ||
| echo "next version must be greater than $current" >&2 | ||
| exit 2 | ||
| } | ||
| if git rev-parse -q --verify "refs/tags/v$version" >/dev/null; then | ||
| echo "tag v$version already exists locally" >&2 | ||
| exit 1 | ||
| fi | ||
| if git ls-remote --exit-code --tags origin "v$version" >/dev/null 2>&1; then | ||
| echo "tag v$version already exists on origin" >&2 | ||
| exit 1 | ||
| fi | ||
| echo "Releasing v$version" | ||
| VERSION="$version" perl -0pi -e 's/^version = ".*"/version = "$ENV{VERSION}"/m' Cargo.toml | ||
| node -e "const fs=require('fs'); const p=require('./package.json'); p.version=process.env.VERSION; fs.writeFileSync('package.json', JSON.stringify(p, null, 2) + '\n')" | ||
| cargo test | ||
| npm pack --dry-run >/dev/null | ||
| git add Cargo.toml Cargo.lock package.json | ||
| git commit -m "Release v$version" | ||
| git tag "v$version" | ||
| git push origin main "v$version" |
+45
| use std::{collections::HashMap, sync::OnceLock}; | ||
| pub const UI_LANGUAGES: &[&str] = &["en", "ko", "ja", "zh", "es"]; | ||
| static EN: OnceLock<HashMap<String, String>> = OnceLock::new(); | ||
| static KO: OnceLock<HashMap<String, String>> = OnceLock::new(); | ||
| static JA: OnceLock<HashMap<String, String>> = OnceLock::new(); | ||
| static ZH: OnceLock<HashMap<String, String>> = OnceLock::new(); | ||
| static ES: OnceLock<HashMap<String, String>> = OnceLock::new(); | ||
| pub fn normalize_ui_language(language: &str) -> String { | ||
| let lower = language.trim().to_lowercase(); | ||
| let short = lower | ||
| .split(['-', '_']) | ||
| .next() | ||
| .filter(|value| !value.is_empty()) | ||
| .unwrap_or("en"); | ||
| if UI_LANGUAGES.contains(&short) { | ||
| short.to_string() | ||
| } else { | ||
| "en".to_string() | ||
| } | ||
| } | ||
| pub fn ui_text(lang: &str, key: &str) -> &'static str { | ||
| catalog(&normalize_ui_language(lang)) | ||
| .get(key) | ||
| .or_else(|| catalog("en").get(key)) | ||
| .map(String::as_str) | ||
| .unwrap_or("") | ||
| } | ||
| fn catalog(lang: &str) -> &'static HashMap<String, String> { | ||
| match lang { | ||
| "ko" => KO.get_or_init(|| load(include_str!("../assets/i18n/ko.json"))), | ||
| "ja" => JA.get_or_init(|| load(include_str!("../assets/i18n/ja.json"))), | ||
| "zh" => ZH.get_or_init(|| load(include_str!("../assets/i18n/zh.json"))), | ||
| "es" => ES.get_or_init(|| load(include_str!("../assets/i18n/es.json"))), | ||
| _ => EN.get_or_init(|| load(include_str!("../assets/i18n/en.json"))), | ||
| } | ||
| } | ||
| fn load(text: &str) -> HashMap<String, String> { | ||
| serde_json::from_str(text).expect("valid i18n catalog") | ||
| } |
| use crate::process::{run_capture, which}; | ||
| use std::{env, process::Command, time::Duration}; | ||
| pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); | ||
| #[derive(Clone, Debug, Eq, PartialEq)] | ||
| pub enum UpdateCheck { | ||
| Disabled, | ||
| Current, | ||
| Available(String), | ||
| Failed, | ||
| } | ||
| pub fn check_latest_version() -> UpdateCheck { | ||
| if env::var("PRACTICODE_NO_UPDATE_CHECK").ok().as_deref() == Some("1") { | ||
| return UpdateCheck::Disabled; | ||
| } | ||
| let Some(npm) = which("npm") else { | ||
| return UpdateCheck::Failed; | ||
| }; | ||
| let mut command = Command::new(npm); | ||
| command.args(["view", "practicode", "version", "--silent"]); | ||
| match run_capture(&mut command, "", Duration::from_secs(5)) { | ||
| Ok(output) if output.code == Some(0) => { | ||
| let latest = output.stdout.trim(); | ||
| if is_newer(latest, CURRENT_VERSION) { | ||
| UpdateCheck::Available(latest.to_string()) | ||
| } else { | ||
| UpdateCheck::Current | ||
| } | ||
| } | ||
| _ => UpdateCheck::Failed, | ||
| } | ||
| } | ||
| pub fn is_newer(latest: &str, current: &str) -> bool { | ||
| version_parts(latest) > version_parts(current) | ||
| } | ||
| fn version_parts(version: &str) -> Vec<u64> { | ||
| version | ||
| .split('.') | ||
| .map(|part| part.parse::<u64>().unwrap_or(0)) | ||
| .collect() | ||
| } |
| # Third-Party Licenses | ||
| This file summarizes third-party dependency license metadata for practicode | ||
| 0.1.0, checked from Cargo.lock and Cargo metadata. | ||
| This is not legal advice. | ||
| ## Project License | ||
| practicode is licensed under MIT. The current dependency set is compatible with | ||
| that choice: all Rust dependencies use permissive licenses, and the npm package | ||
| has no npm dependencies. | ||
| ## npm Dependencies | ||
| None. | ||
| ## Direct Rust Dependencies | ||
| | Crate | Version | License | | ||
| | --- | --- | --- | | ||
| | anyhow | 1.0.103 | MIT OR Apache-2.0 | | ||
| | crossterm | 0.29.0 | MIT | | ||
| | ratatui | 0.29.0 | MIT | | ||
| | serde | 1.0.228 | MIT OR Apache-2.0 | | ||
| | serde_json | 1.0.150 | MIT OR Apache-2.0 | | ||
| | unicode-width | 0.2.0 | MIT OR Apache-2.0 | | ||
| | wait-timeout | 0.2.1 | MIT/Apache-2.0 | | ||
| ## Transitive Rust Dependency License Groups | ||
| The locked Rust dependency graph contains 87 third-party packages. | ||
| | License expression | Packages | | ||
| | --- | ---: | | ||
| | MIT OR Apache-2.0 | 50 | | ||
| | MIT | 20 | | ||
| | MIT/Apache-2.0 | 5 | | ||
| | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | 5 | | ||
| | Apache-2.0 OR MIT | 1 | | ||
| | Apache-2.0/MIT | 1 | | ||
| | MIT / Apache-2.0 | 1 | | ||
| | Unlicense OR MIT | 1 | | ||
| | Zlib | 1 | | ||
| | Apache-2.0 OR BSL-1.0 | 1 | | ||
| | (MIT OR Apache-2.0) AND Unicode-3.0 | 1 | | ||
| No GPL, LGPL, AGPL, or MPL dependencies were detected in the locked Rust | ||
| dependency graph. | ||
| ## Release Notes | ||
| The crates.io and npm packages distribute practicode source, not vendored | ||
| third-party source or prebuilt third-party binaries. The npm installer builds | ||
| the Rust binary locally with Cargo. | ||
| If practicode later ships prebuilt binaries, include full third-party notices | ||
| and license texts with those binary artifacts. |
+1
-1
@@ -360,3 +360,3 @@ # This file is automatically @generated by Cargo. | ||
| name = "practicode" | ||
| version = "0.1.0" | ||
| version = "0.1.4" | ||
| dependencies = [ | ||
@@ -363,0 +363,0 @@ "anyhow", |
+1
-1
| [package] | ||
| name = "practicode" | ||
| version = "0.1.0" | ||
| version = "0.1.4" | ||
| edition = "2024" | ||
@@ -5,0 +5,0 @@ description = "Local-first coding-test practice in a Rust terminal UI with optional AI help." |
+4
-2
| { | ||
| "name": "practicode", | ||
| "version": "0.1.0", | ||
| "version": "0.1.4", | ||
| "description": "Local-first coding-test practice in a Rust terminal UI with optional AI help.", | ||
@@ -15,2 +15,3 @@ "license": "MIT", | ||
| "scripts": { | ||
| "start": "node bin/practicode.js", | ||
| "build": "cargo build --release --locked", | ||
@@ -29,3 +30,4 @@ "postinstall": "node scripts/npm-postinstall.js", | ||
| "LICENSE", | ||
| "README.md" | ||
| "README.md", | ||
| "THIRD_PARTY_LICENSES.md" | ||
| ], | ||
@@ -32,0 +34,0 @@ "keywords": [ |
+45
-100
@@ -14,50 +14,34 @@ # practicode | ||
| `practicode` is a small Rust TUI for stdin/stdout practice: problem on the left, code on the right, judge loop in the same terminal. | ||
| No browser tab shuffle, no paste dance, just solve and run. | ||
| ## Why It Exists | ||
| ## Start | ||
| - Fast local judging for Python, TypeScript, Java, and Rust | ||
| - Gradual problem flow with local history | ||
| - AI-powered `/next <request>` when you want a custom problem | ||
| - Personal problem-generation notes | ||
| - Small stack: Rust, Ratatui, Crossterm, and plain process execution | ||
| ### Prerequisites | ||
| ## Quick Start | ||
| - Node.js 18+ for npm installation. | ||
| - Rust and Cargo. The npm package builds the Rust binary during install, and also on first run if needed. | ||
| - A runtime for the language you practice in: Python, Node.js for TypeScript, JDK for Java, or Rust. | ||
| ```bash | ||
| git clone https://github.com/baba9811/practicode.git | ||
| cd practicode | ||
| cargo run -- | ||
| ``` | ||
| ### npm | ||
| Want a local binary? | ||
| ```bash | ||
| cargo install practicode | ||
| npm install -g practicode | ||
| practicode | ||
| ``` | ||
| Prefer npm from GitHub? | ||
| ### Cargo | ||
| ```bash | ||
| npm install -g github:baba9811/practicode | ||
| cargo install practicode | ||
| practicode | ||
| ``` | ||
| Working from a local checkout? | ||
| ### Local checkout | ||
| ```bash | ||
| cargo install --path . | ||
| practicode | ||
| git clone https://github.com/baba9811/practicode.git | ||
| cd practicode | ||
| npm install | ||
| npm start | ||
| ``` | ||
| Or with npm: | ||
| ```bash | ||
| npm install -g . | ||
| practicode | ||
| ``` | ||
| The npm wrapper builds the Rust binary with Cargo, so Rust/Cargo is still required. | ||
| ## Daily Loop | ||
@@ -69,6 +53,9 @@ | ||
| write code | ||
| Esc, then /run | ||
| Esc, then /next easy string problem | ||
| Esc, then / | ||
| choose /run | ||
| choose /next when it passes | ||
| ``` | ||
| 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. | ||
| Submissions are saved as you type under `submissions/<problem-id>/solution.<ext>`. | ||
@@ -78,4 +65,2 @@ | ||
| Press `Esc`, then `/`, to focus the command bar. | ||
| | Command | Action | | ||
@@ -90,19 +75,20 @@ | --- | --- | | ||
| | `/giveup` | Show the reference answer | | ||
| | `/ai hint` | Ask the selected AI about the current problem and submission | | ||
| | `/provider codex` | Set AI provider: `codex` or `claude` | | ||
| | `/model sonnet` | Set the model for `/ai` and AI-backed `/next`; use `auto` for the CLI default | | ||
| | `/hint` | Ask the selected AI for a concise hint | | ||
| | `/hint explain my bug` | Ask the selected AI about the current problem and submission | | ||
| | `/provider codex` | Set AI provider and show local CLI/daemon status | | ||
| | `/model auto` | Use the provider default model for `/hint` and AI-backed `/next` | | ||
| | `/note prefer hashmap practice` | Append a standing note for future problem generation | | ||
| | `/notes` | Show your local next-problem notes | | ||
| | `/lang python` | Set language: `python`, `ts`, `java`, `rust` | | ||
| | `/ui ko` | Set UI language: `ko`, `en` | | ||
| | `/theme` | Toggle dark/light theme | | ||
| | `/lang python` | Set code language: `python`, `ts`, `java`, `rust` | | ||
| | `/ui en` | Set UI language: `en`, `ko`, `ja`, `zh`, `es` | | ||
| | `/theme dark` | Set theme: `dark` or `light` | | ||
| | `/source ai` | Prefer AI for next-problem generation | | ||
| | `/update` | Show update instructions when a newer version is available | | ||
| | `/exit` | Quit | | ||
| The editor owns normal typing keys. | ||
| Press `Esc`, then `/`, when you want the command bar. | ||
| The default UI language is English. Switch it any time with `/ui ko`, `/ui ja`, `/ui zh`, or `/ui es`. | ||
| ## Custom Problem Generation | ||
| ## AI Problems | ||
| `/next <request>` passes your request into the selected AI problem generator. Examples: | ||
| `/next <request>` passes your request into the selected AI problem generator. | ||
@@ -115,17 +101,5 @@ ```text | ||
| AI generation reads [docs/problem-authoring-notes.md](docs/problem-authoring-notes.md) every time it creates a problem. Add personal preferences from inside the TUI: | ||
| Codex is the default provider: | ||
| ```text | ||
| /note Prefer concise statements. | ||
| /note I want more string and hashmap practice. | ||
| /note Avoid DP until I ask for it. | ||
| ``` | ||
| Those notes are stored in `.practicode/problem_notes.md`, so they stay local. | ||
| ## AI Providers | ||
| Codex is the default: | ||
| ```text | ||
| /provider codex | ||
@@ -143,10 +117,7 @@ /model auto | ||
| `/ai <prompt>` uses the current provider for coaching. AI-backed `/next` uses the same provider and model. | ||
| If you want a custom daemon or wrapper script, set `/ai-next-command <shell command>`; practicode passes `PRACTICODE_NEXT_REQUEST`, `PRACTICODE_AI_PROVIDER`, and `PRACTICODE_AI_MODEL`. | ||
| Generated problems and submissions stay local: | ||
| Generated problem banks stay local: | ||
| | Path | Purpose | | ||
| | --- | --- | | ||
| | `.practicode/problem_bank.json` | Local/custom/generated problem bank | | ||
| | `.practicode/problem_bank.json` | Local/custom/generated problems | | ||
| | `.practicode/problem_notes.md` | Optional personal problem-generation notes | | ||
@@ -159,49 +130,23 @@ | `.practicode/problem-state.json` | Current problem, history, settings | | ||
| ## Safety | ||
| ## Update | ||
| `/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. | ||
| The app checks for newer npm releases in the background and shows `/update` in the status line when one is available. Disable that check with `PRACTICODE_NO_UPDATE_CHECK=1`. | ||
| ## Debug Prints | ||
| `/run` shows raw stdout when a case fails. If you want debug output without changing the judged answer, print to stderr: | ||
| ```python | ||
| import sys | ||
| print("debug", value, file=sys.stderr) | ||
| ```bash | ||
| npm update -g practicode | ||
| cargo install --force practicode | ||
| ``` | ||
| ## Development | ||
| ## Safety | ||
| ```bash | ||
| cargo test | ||
| cargo run -- --smoke | ||
| cargo run -- | ||
| ``` | ||
| `/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. | ||
| The source is split by boring responsibility: | ||
| ## Contributing | ||
| | Path | Role | | ||
| | --- | --- | | ||
| | `src/core.rs` | Problem bank, state, rendering, judging | | ||
| | `src/tui.rs` | Ratatui app, editor, command parser | | ||
| | `src/ai.rs` | Codex/Claude command integration and notes | | ||
| | `src/text.rs` | UTF-8 cursor math and Hangul composition | | ||
| | `src/process.rs` | Process execution helpers | | ||
| | `tests/` | Integration tests split by module | | ||
| External contributions use the fork and pull request flow in [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md). | ||
| ## Discovery Notes | ||
| Maintainer-only review and release notes live in [docs/MAINTAINING.md](docs/MAINTAINING.md). | ||
| Recommended GitHub topics for this repo: | ||
| `coding-practice`, `competitive-programming`, `algorithms`, `ratatui`, `tui`, `rust`, `codex`, `claude-code`, `local-first`. | ||
| ## License | ||
| ## References | ||
| - Ratatui terminal UI library: https://ratatui.rs/ | ||
| - Crossterm terminal backend/events: https://github.com/crossterm-rs/crossterm | ||
| - Codex CLI open-source repo: https://github.com/openai/codex | ||
| - Claude Code CLI reference: https://docs.anthropic.com/en/docs/claude-code/cli-reference | ||
| - Kattis problem package format: https://www.kattis.com/problem-package-format/ | ||
| - ICPC judging guidelines: https://icpc.global/regionals/regional-contest-cookbook-judging-guidelines | ||
| - GitHub README image guidance: https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-readmes | ||
| - GitHub repository topics: https://docs.github.com/articles/classifying-your-repository-with-topics | ||
| practicode is MIT licensed. Third-party dependency license notes are in [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md). |
+144
-6
@@ -6,9 +6,10 @@ use crate::{ | ||
| }, | ||
| process::{run_capture, sh_quote, shell_process, unique_temp_path}, | ||
| process::{run_capture, sh_quote, shell_process, unique_temp_path, which}, | ||
| }; | ||
| use anyhow::Result; | ||
| use std::{ | ||
| env, | ||
| fs::{self, OpenOptions}, | ||
| io::Write, | ||
| path::Path, | ||
| path::{Path, PathBuf}, | ||
| process::Command, | ||
@@ -18,2 +19,8 @@ time::Duration, | ||
| #[derive(Clone, Debug, Default)] | ||
| pub struct ModelCatalog { | ||
| pub models: Vec<String>, | ||
| pub message: Option<String>, | ||
| } | ||
| pub fn run_ai_prompt(root: &Path, problem: &Problem, settings: &Settings, prompt: &str) -> String { | ||
@@ -44,3 +51,3 @@ let solution = match ensure_submission(root, problem, settings) { | ||
| if state.settings.next_source != "ai" && !force { | ||
| return "AI next is disabled; using local problem bank.".to_string(); | ||
| return "AI next is disabled; using local problems.".to_string(); | ||
| } | ||
@@ -84,5 +91,42 @@ let provider = normalize_ai_provider(&state.settings.ai_provider); | ||
| pub fn provider_status(provider: &str) -> String { | ||
| match normalize_ai_provider(provider).as_str() { | ||
| "claude" => { | ||
| if which("claude").is_some() { | ||
| "Claude CLI found.".to_string() | ||
| } else { | ||
| "Claude CLI not found. Install Claude Code or choose /provider codex.".to_string() | ||
| } | ||
| } | ||
| _ => { | ||
| if which("codex").is_none() { | ||
| return "Codex CLI not found. Install Codex CLI or choose /provider claude." | ||
| .to_string(); | ||
| } | ||
| if codex_daemon_path().is_some_and(|path| path.exists()) { | ||
| "Codex CLI found. App-server daemon is available.".to_string() | ||
| } else { | ||
| "Codex CLI found. App-server daemon is not available; practicode will use codex exec directly.".to_string() | ||
| } | ||
| } | ||
| } | ||
| } | ||
| pub fn available_models(provider: &str) -> ModelCatalog { | ||
| match normalize_ai_provider(provider).as_str() { | ||
| "codex" => codex_models(), | ||
| "claude" => ModelCatalog { | ||
| models: Vec::new(), | ||
| message: Some( | ||
| "Claude CLI does not expose a model list; use /model <name> for a known model." | ||
| .to_string(), | ||
| ), | ||
| }, | ||
| _ => ModelCatalog::default(), | ||
| } | ||
| } | ||
| pub fn default_ai_next_prompt(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. The app has a built-in starter problem 001-hello-world, so do not duplicate it. Create exactly one new non-duplicate coding practice problem. User request for this problem: {}. Update .practicode/problem_bank.json, the local problem files, the index, and state files. 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: {}. 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() { | ||
@@ -115,2 +159,76 @@ "(none)" | ||
| fn codex_models() -> ModelCatalog { | ||
| if which("codex").is_none() { | ||
| return ModelCatalog { | ||
| models: Vec::new(), | ||
| message: Some( | ||
| "Codex CLI not found; choose /provider claude or install Codex CLI.".to_string(), | ||
| ), | ||
| }; | ||
| } | ||
| if codex_daemon_path().is_none_or(|path| !path.exists()) { | ||
| return ModelCatalog { | ||
| models: Vec::new(), | ||
| message: Some("Codex app-server daemon is unavailable; install the standalone Codex app to list models, or use /model <name>.".to_string()), | ||
| }; | ||
| } | ||
| let mut start = Command::new("codex"); | ||
| start.args(["app-server", "daemon", "start"]); | ||
| let _ = run_capture(&mut start, "", Duration::from_secs(5)); | ||
| let mut command = Command::new("codex"); | ||
| command.args(["app-server", "proxy"]); | ||
| let input = r#"{"id":1,"method":"model/list","params":{"limit":25}}"#; | ||
| let Ok(run) = run_capture(&mut command, &format!("{input}\n"), Duration::from_secs(2)) else { | ||
| return ModelCatalog { | ||
| models: Vec::new(), | ||
| message: Some("Could not query Codex model list.".to_string()), | ||
| }; | ||
| }; | ||
| if run.code != Some(0) { | ||
| let detail = output_text(&run.stdout, &run.stderr); | ||
| return ModelCatalog { | ||
| models: Vec::new(), | ||
| message: Some(if detail.is_empty() { | ||
| "Could not query Codex model list.".to_string() | ||
| } else { | ||
| format!("Could not query Codex model list: {detail}") | ||
| }), | ||
| }; | ||
| } | ||
| let models = parse_model_list(&run.stdout); | ||
| if models.is_empty() { | ||
| ModelCatalog { | ||
| models, | ||
| message: Some("Codex app-server returned no models.".to_string()), | ||
| } | ||
| } else { | ||
| ModelCatalog { | ||
| models, | ||
| message: None, | ||
| } | ||
| } | ||
| } | ||
| fn parse_model_list(output: &str) -> Vec<String> { | ||
| output | ||
| .lines() | ||
| .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok()) | ||
| .flat_map(|value| { | ||
| value | ||
| .pointer("/result/data") | ||
| .or_else(|| value.get("data")) | ||
| .and_then(|data| data.as_array()) | ||
| .cloned() | ||
| .unwrap_or_default() | ||
| }) | ||
| .filter_map(|model| { | ||
| model | ||
| .get("model") | ||
| .or_else(|| model.get("id")) | ||
| .and_then(|value| value.as_str()) | ||
| .map(str::to_string) | ||
| }) | ||
| .collect() | ||
| } | ||
| fn run_codex_prompt(root: &Path, settings: &Settings, prompt: &str) -> String { | ||
@@ -185,5 +303,5 @@ let output_path = unique_temp_path("practicode-last-message", "txt"); | ||
| fn default_codex_next_command(root: &Path, settings: &Settings, request: &str) -> String { | ||
| let start = "codex app-server daemon start >/dev/null 2>&1 || true"; | ||
| 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 --cd {} --sandbox workspace-write", | ||
| "codex exec --ephemeral --cd {} --sandbox workspace-write", | ||
| sh_quote(&root.display().to_string()) | ||
@@ -199,2 +317,10 @@ ); | ||
| fn codex_daemon_path() -> Option<PathBuf> { | ||
| env::var_os("HOME").map(|home| { | ||
| PathBuf::from(home) | ||
| .join(".codex/packages/standalone/current") | ||
| .join(if cfg!(windows) { "codex.exe" } else { "codex" }) | ||
| }) | ||
| } | ||
| fn default_claude_next_command(root: &Path, settings: &Settings, request: &str) -> String { | ||
@@ -221,1 +347,13 @@ let mut claude = "claude --permission-mode acceptEdits".to_string(); | ||
| } | ||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::parse_model_list; | ||
| #[test] | ||
| fn parses_codex_model_list_response() { | ||
| let output = | ||
| r#"{"id":1,"result":{"data":[{"model":"gpt-test","displayName":"GPT Test"}]}}"#; | ||
| assert_eq!(parse_model_list(output), vec!["gpt-test"]); | ||
| } | ||
| } |
+116
-32
@@ -13,3 +13,3 @@ use crate::process::{CommandSpec, run_capture, which}; | ||
| pub const LANGUAGES: &[&str] = &["python", "ts", "java", "rust"]; | ||
| pub const UI_LANGUAGES: &[&str] = &["ko", "en"]; | ||
| pub use crate::i18n::{UI_LANGUAGES, normalize_ui_language, ui_text}; | ||
| pub const THEMES: &[&str] = &["dark", "light"]; | ||
@@ -124,3 +124,3 @@ pub const AI_PROVIDERS: &[&str] = &["codex", "claude"]; | ||
| pub fn default_ui_language() -> String { | ||
| "ko".to_string() | ||
| "en".to_string() | ||
| } | ||
@@ -168,16 +168,30 @@ | ||
| topics: vec!["io".to_string()], | ||
| title: map2("ko", "Hello World", "en", "Hello World"), | ||
| statement: map2( | ||
| "ko", | ||
| "표준 출력으로 정확히 `Hello, World!`를 출력하세요.", | ||
| "en", | ||
| "Print exactly `Hello, World!` to stdout.", | ||
| ), | ||
| input: map2("ko", "입력은 없습니다.", "en", "No input."), | ||
| output: map2( | ||
| "ko", | ||
| "`Hello, World!` 한 줄", | ||
| "en", | ||
| "One line: `Hello, World!`", | ||
| ), | ||
| title: localized_map(&[ | ||
| ("en", "Hello World"), | ||
| ("ko", "Hello World"), | ||
| ("ja", "Hello World"), | ||
| ("zh", "Hello World"), | ||
| ("es", "Hello World"), | ||
| ]), | ||
| statement: localized_map(&[ | ||
| ("en", "Print exactly `Hello, World!` to stdout."), | ||
| ("ko", "표준 출력으로 정확히 `Hello, World!`를 출력하세요."), | ||
| ("ja", "標準出力に正確に `Hello, World!` を出力してください。"), | ||
| ("zh", "向标准输出准确打印 `Hello, World!`。"), | ||
| ("es", "Imprime exactamente `Hello, World!` en stdout."), | ||
| ]), | ||
| input: localized_map(&[ | ||
| ("en", "No input."), | ||
| ("ko", "입력은 없습니다."), | ||
| ("ja", "入力はありません。"), | ||
| ("zh", "没有输入。"), | ||
| ("es", "No hay entrada."), | ||
| ]), | ||
| output: localized_map(&[ | ||
| ("en", "One line: `Hello, World!`"), | ||
| ("ko", "`Hello, World!` 한 줄"), | ||
| ("ja", "1行: `Hello, World!`"), | ||
| ("zh", "一行: `Hello, World!`"), | ||
| ("es", "Una linea: `Hello, World!`"), | ||
| ]), | ||
| examples: vec![IoCase { | ||
@@ -216,2 +230,9 @@ input: String::new(), | ||
| pub fn localized_map(entries: &[(&str, &str)]) -> HashMap<String, String> { | ||
| entries | ||
| .iter() | ||
| .map(|(key, value)| ((*key).to_string(), (*value).to_string())) | ||
| .collect() | ||
| } | ||
| pub fn load_bank(root: &Path) -> Result<Vec<Problem>> { | ||
@@ -346,5 +367,3 @@ let path = root.join(BANK_PATH); | ||
| settings.language = normalize_language(&settings.language); | ||
| if !UI_LANGUAGES.contains(&settings.ui_language.as_str()) { | ||
| settings.ui_language = "ko".to_string(); | ||
| } | ||
| settings.ui_language = normalize_ui_language(&settings.ui_language); | ||
| if !THEMES.contains(&settings.theme.as_str()) { | ||
@@ -389,5 +408,6 @@ settings.theme = "dark".to_string(); | ||
| pub fn localized(map: &HashMap<String, String>, lang: &str) -> String { | ||
| map.get(lang) | ||
| let lang = normalize_ui_language(lang); | ||
| map.get(lang.as_str()) | ||
| .or_else(|| map.get("en")) | ||
| .or_else(|| map.get("ko")) | ||
| .or_else(|| map.get("en")) | ||
| .or_else(|| map.values().next()) | ||
@@ -424,7 +444,3 @@ .cloned() | ||
| pub fn render_problem(problem: &Problem, ui_language: &str) -> String { | ||
| let lang = if UI_LANGUAGES.contains(&ui_language) { | ||
| ui_language | ||
| } else { | ||
| "ko" | ||
| }; | ||
| let lang = normalize_ui_language(ui_language); | ||
| let examples = problem | ||
@@ -436,5 +452,8 @@ .examples | ||
| format!( | ||
| "### Example {}\n\nInput\n\n{}\n\nOutput\n\n{}", | ||
| "### {} {}\n\n{}\n\n{}\n\n{}\n\n{}", | ||
| ui_text(&lang, "example"), | ||
| index + 1, | ||
| ui_text(&lang, "input"), | ||
| fenced_text(&case.input), | ||
| ui_text(&lang, "output"), | ||
| fenced_text(&case.output) | ||
@@ -451,9 +470,14 @@ ) | ||
| format!( | ||
| "# {number}. {}\n\nDifficulty: {}\nTopics: {}\n\n{}\n\n## Input\n\n{}\n\n## Output\n\n{}\n\n## Examples\n\n{}", | ||
| localized(&problem.title, lang), | ||
| "# {number}. {}\n\n{}: {}\n{}: {}\n\n{}\n\n## {}\n\n{}\n\n## {}\n\n{}\n\n## {}\n\n{}", | ||
| localized(&problem.title, &lang), | ||
| ui_text(&lang, "difficulty"), | ||
| problem.difficulty, | ||
| ui_text(&lang, "topics"), | ||
| problem.topics.join(", "), | ||
| localized(&problem.statement, lang), | ||
| localized(&problem.input, lang), | ||
| localized(&problem.output, lang), | ||
| localized(&problem.statement, &lang), | ||
| ui_text(&lang, "input"), | ||
| localized(&problem.input, &lang), | ||
| ui_text(&lang, "output"), | ||
| localized(&problem.output, &lang), | ||
| ui_text(&lang, "examples"), | ||
| examples | ||
@@ -463,2 +487,62 @@ ) | ||
| pub fn render_problem_tui(problem: &Problem, ui_language: &str) -> String { | ||
| let lang = normalize_ui_language(ui_language); | ||
| let number = problem | ||
| .id | ||
| .split_once('-') | ||
| .map(|(number, _)| number) | ||
| .unwrap_or(&problem.id); | ||
| let mut lines = vec![ | ||
| format!("{number}. {}", localized(&problem.title, &lang)), | ||
| format!( | ||
| "{}: {} {}: {}", | ||
| ui_text(&lang, "difficulty"), | ||
| problem.difficulty, | ||
| ui_text(&lang, "topics"), | ||
| problem.topics.join(", ") | ||
| ), | ||
| String::new(), | ||
| localized(&problem.statement, &lang), | ||
| ]; | ||
| push_tui_section( | ||
| &mut lines, | ||
| ui_text(&lang, "input"), | ||
| &localized(&problem.input, &lang), | ||
| ); | ||
| push_tui_section( | ||
| &mut lines, | ||
| ui_text(&lang, "output"), | ||
| &localized(&problem.output, &lang), | ||
| ); | ||
| lines.push(String::new()); | ||
| lines.push(ui_text(&lang, "examples").to_string()); | ||
| for (index, case) in problem.examples.iter().enumerate() { | ||
| lines.push(format!(" {} {}", ui_text(&lang, "example"), index + 1)); | ||
| lines.push(format!(" {}:", ui_text(&lang, "input"))); | ||
| push_case_text(&mut lines, &case.input); | ||
| lines.push(format!(" {}:", ui_text(&lang, "output"))); | ||
| push_case_text(&mut lines, &case.output); | ||
| } | ||
| lines.join("\n").trim_end().to_string() | ||
| } | ||
| fn push_tui_section(lines: &mut Vec<String>, title: &str, body: &str) { | ||
| lines.push(String::new()); | ||
| lines.push(title.to_string()); | ||
| for line in body.trim_end().lines() { | ||
| lines.push(format!(" {line}")); | ||
| } | ||
| } | ||
| fn push_case_text(lines: &mut Vec<String>, body: &str) { | ||
| let body = body.trim_end(); | ||
| if body.is_empty() { | ||
| lines.push(" <empty>".to_string()); | ||
| } else { | ||
| for line in body.lines() { | ||
| lines.push(format!(" {line}")); | ||
| } | ||
| } | ||
| } | ||
| pub fn fenced_text(value: &str) -> String { | ||
@@ -465,0 +549,0 @@ let mut body = value.to_string(); |
+2
-0
@@ -7,5 +7,7 @@ use anyhow::{Context, Result}; | ||
| pub mod core; | ||
| pub mod i18n; | ||
| pub mod process; | ||
| pub mod text; | ||
| pub mod tui; | ||
| pub mod update; | ||
@@ -12,0 +14,0 @@ pub fn run_cli() -> Result<()> { |
+810
-106
| use crate::{ | ||
| ai::{append_problem_note, read_problem_notes, run_ai_next, run_ai_prompt}, | ||
| ai::{ | ||
| ModelCatalog, append_problem_note, available_models, provider_status, read_problem_notes, | ||
| run_ai_next, run_ai_prompt, | ||
| }, | ||
| core::{ | ||
@@ -7,4 +10,4 @@ AI_PROVIDERS, AppState, HistoryItem, LANGUAGES, PROBLEM_NOTES_PATH, Problem, THEMES, | ||
| load_state, localized, next_problem, normalize_ai_provider, normalize_language, | ||
| normalize_next_source, previous_problem, problem_by_id, record_pass, render_problem, | ||
| save_state, template_for, | ||
| normalize_next_source, normalize_ui_language, previous_problem, problem_by_id, record_pass, | ||
| save_state, template_for, ui_text, | ||
| }, | ||
@@ -14,2 +17,3 @@ text::{ | ||
| }, | ||
| update::{CURRENT_VERSION, UpdateCheck, check_latest_version}, | ||
| }; | ||
@@ -22,3 +26,4 @@ use anyhow::Result; | ||
| style::{Color, Modifier, Style}, | ||
| widgets::{Block, Borders, Paragraph, Wrap}, | ||
| text::{Line, Span, Text}, | ||
| widgets::{Block, Borders, Clear, Paragraph, Wrap}, | ||
| }; | ||
@@ -34,43 +39,232 @@ use std::{ | ||
| pub const HELP: &str = r#"# Help | ||
| #[derive(Clone, Copy)] | ||
| struct CommandHint { | ||
| insert: &'static str, | ||
| display: &'static str, | ||
| desc_key: &'static str, | ||
| keep_open: bool, | ||
| help: bool, | ||
| } | ||
| ## Daily loop | ||
| #[derive(Clone)] | ||
| struct CommandChoice { | ||
| insert: String, | ||
| display: String, | ||
| desc_key: &'static str, | ||
| keep_open: bool, | ||
| } | ||
| 1. Type code in the right pane. | ||
| 2. Press `Esc`, then `/run`. | ||
| 3. Use `/next` when it passes. | ||
| const COMMAND_HINTS: &[CommandHint] = &[ | ||
| CommandHint { | ||
| insert: "run", | ||
| display: "/run", | ||
| desc_key: "cmd_run", | ||
| keep_open: false, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "edit", | ||
| display: "/edit", | ||
| desc_key: "cmd_edit", | ||
| keep_open: false, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "next", | ||
| display: "/next", | ||
| desc_key: "cmd_next", | ||
| keep_open: false, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "prev", | ||
| display: "/prev", | ||
| desc_key: "cmd_prev", | ||
| keep_open: false, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "list", | ||
| display: "/list", | ||
| desc_key: "cmd_list", | ||
| keep_open: false, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "open ", | ||
| display: "/open <id>", | ||
| desc_key: "cmd_open", | ||
| keep_open: true, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "giveup", | ||
| display: "/giveup", | ||
| desc_key: "cmd_giveup", | ||
| keep_open: false, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "hint ", | ||
| display: "/hint <request>", | ||
| desc_key: "cmd_hint", | ||
| keep_open: true, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "provider codex", | ||
| display: "/provider codex", | ||
| desc_key: "cmd_provider", | ||
| keep_open: false, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "provider claude", | ||
| display: "/provider claude", | ||
| desc_key: "cmd_provider", | ||
| keep_open: false, | ||
| help: false, | ||
| }, | ||
| CommandHint { | ||
| insert: "model auto", | ||
| display: "/model auto", | ||
| desc_key: "cmd_model_auto", | ||
| keep_open: false, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "model ", | ||
| display: "/model <name>", | ||
| desc_key: "cmd_model_custom", | ||
| keep_open: true, | ||
| help: false, | ||
| }, | ||
| CommandHint { | ||
| insert: "note ", | ||
| display: "/note <text>", | ||
| desc_key: "cmd_note", | ||
| keep_open: true, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "notes", | ||
| display: "/notes", | ||
| desc_key: "cmd_notes", | ||
| keep_open: false, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "lang python", | ||
| display: "/lang python", | ||
| desc_key: "cmd_lang", | ||
| keep_open: false, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "lang ts", | ||
| display: "/lang ts", | ||
| desc_key: "cmd_lang", | ||
| keep_open: false, | ||
| help: false, | ||
| }, | ||
| CommandHint { | ||
| insert: "lang java", | ||
| display: "/lang java", | ||
| desc_key: "cmd_lang", | ||
| keep_open: false, | ||
| help: false, | ||
| }, | ||
| CommandHint { | ||
| insert: "lang rust", | ||
| display: "/lang rust", | ||
| desc_key: "cmd_lang", | ||
| keep_open: false, | ||
| help: false, | ||
| }, | ||
| CommandHint { | ||
| insert: "ui en", | ||
| display: "/ui en", | ||
| desc_key: "cmd_ui", | ||
| keep_open: false, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "ui ko", | ||
| display: "/ui ko", | ||
| desc_key: "cmd_ui", | ||
| keep_open: false, | ||
| help: false, | ||
| }, | ||
| CommandHint { | ||
| insert: "ui ja", | ||
| display: "/ui ja", | ||
| desc_key: "cmd_ui", | ||
| keep_open: false, | ||
| help: false, | ||
| }, | ||
| CommandHint { | ||
| insert: "ui zh", | ||
| display: "/ui zh", | ||
| desc_key: "cmd_ui", | ||
| keep_open: false, | ||
| help: false, | ||
| }, | ||
| CommandHint { | ||
| insert: "ui es", | ||
| display: "/ui es", | ||
| desc_key: "cmd_ui", | ||
| keep_open: false, | ||
| help: false, | ||
| }, | ||
| CommandHint { | ||
| insert: "theme dark", | ||
| display: "/theme dark", | ||
| desc_key: "cmd_theme", | ||
| keep_open: false, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "theme light", | ||
| display: "/theme light", | ||
| desc_key: "cmd_theme", | ||
| keep_open: false, | ||
| help: false, | ||
| }, | ||
| CommandHint { | ||
| insert: "source local", | ||
| display: "/source local", | ||
| desc_key: "cmd_source", | ||
| keep_open: false, | ||
| help: false, | ||
| }, | ||
| CommandHint { | ||
| insert: "source ai", | ||
| display: "/source ai", | ||
| desc_key: "cmd_source", | ||
| keep_open: false, | ||
| help: false, | ||
| }, | ||
| CommandHint { | ||
| insert: "update", | ||
| display: "/update", | ||
| desc_key: "cmd_update", | ||
| keep_open: false, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "help", | ||
| display: "/help", | ||
| desc_key: "cmd_help", | ||
| keep_open: false, | ||
| help: true, | ||
| }, | ||
| CommandHint { | ||
| insert: "exit", | ||
| display: "/exit", | ||
| desc_key: "cmd_exit", | ||
| keep_open: false, | ||
| help: true, | ||
| }, | ||
| ]; | ||
| ## Commands | ||
| - `/run` judge current submission | ||
| - `/edit` focus the code editor | ||
| - `/next [request]` next problem, optionally with a request | ||
| - `/prev` previous problem | ||
| - `/list` choose from problem list | ||
| - `/open 2` open by number, id, or slug | ||
| - `/giveup` show answer | ||
| - `/ai hint` ask the selected AI about current problem + code | ||
| - `/provider codex|claude` | ||
| - `/model auto|sonnet|opus|...` | ||
| - `/note prefer strings this week` | ||
| - `/notes` show next-problem notes | ||
| - `/lang python|ts|java|rust` | ||
| - `/ui ko|en` | ||
| - `/theme dark|light` | ||
| - `/source bank|ai` | ||
| - `/exit` quit | ||
| ## Keys | ||
| - `Esc` leaves the editor or output pane | ||
| - `/` opens the command bar when the editor is not focused | ||
| - `?` opens this help when the editor is not focused | ||
| - `up/down` or `j/k` move in `/list` | ||
| ## Debug prints | ||
| - stdout prints are shown when a case fails | ||
| - stderr prints are shown without affecting the expected stdout | ||
| "#; | ||
| #[derive(Clone, Copy, Debug, Eq, PartialEq)] | ||
@@ -92,4 +286,6 @@ enum Focus { | ||
| command_cursor: usize, | ||
| command_palette_cursor: usize, | ||
| output: String, | ||
| output_is_markdown: bool, | ||
| showing_model_status: bool, | ||
| show_output: bool, | ||
@@ -102,2 +298,9 @@ focus: Focus, | ||
| task_rx: Option<Receiver<TaskResult>>, | ||
| update_rx: Option<Receiver<UpdateCheck>>, | ||
| model_rx: Option<Receiver<ModelCatalog>>, | ||
| available_models: Vec<String>, | ||
| available_models_provider: String, | ||
| model_message: Option<String>, | ||
| update_check: Option<UpdateCheck>, | ||
| update_notice: Option<String>, | ||
| should_quit: bool, | ||
@@ -130,4 +333,6 @@ } | ||
| command_cursor: 0, | ||
| command_palette_cursor: 0, | ||
| output: String::new(), | ||
| output_is_markdown: false, | ||
| showing_model_status: false, | ||
| show_output: false, | ||
@@ -140,2 +345,9 @@ focus: Focus::Code, | ||
| task_rx: None, | ||
| update_rx: None, | ||
| model_rx: None, | ||
| available_models: Vec::new(), | ||
| available_models_provider: String::new(), | ||
| model_message: None, | ||
| update_check: None, | ||
| update_notice: None, | ||
| should_quit: false, | ||
@@ -148,5 +360,10 @@ }; | ||
| pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> { | ||
| self.start_update_check(); | ||
| self.start_model_check(); | ||
| while !self.should_quit { | ||
| terminal.draw(|frame| self.draw(frame))?; | ||
| self.check_task(); | ||
| self.check_update(); | ||
| self.start_model_check(); | ||
| self.check_models(); | ||
| if event::poll(Duration::from_millis(100))? | ||
@@ -159,3 +376,3 @@ && let Event::Key(key) = event::read()? | ||
| if !self.busy_label.is_empty() { | ||
| self.busy_frame = (self.busy_frame + 1) % 4; | ||
| self.busy_frame = (self.busy_frame + 1) % 16; | ||
| } | ||
@@ -187,2 +404,6 @@ } | ||
| pub fn handle_key_for_test(&mut self, key: KeyEvent) -> Result<()> { | ||
| self.handle_key(key) | ||
| } | ||
| pub fn busy_label(&self) -> &str { | ||
@@ -196,2 +417,33 @@ &self.busy_label | ||
| pub fn status_text_for_test(&self) -> String { | ||
| self.status_text() | ||
| } | ||
| pub fn output_for_test(&self) -> &str { | ||
| &self.output | ||
| } | ||
| pub fn command_suggestions_for_test(&self) -> Vec<String> { | ||
| self.command_suggestions() | ||
| .into_iter() | ||
| .map(|choice| choice.display) | ||
| .collect() | ||
| } | ||
| pub fn set_available_models_for_test(&mut self, models: Vec<&str>) { | ||
| self.available_models = models.into_iter().map(str::to_string).collect(); | ||
| self.available_models_provider = self.state.settings.ai_provider.clone(); | ||
| self.model_message = None; | ||
| } | ||
| pub fn set_model_message_for_test(&mut self, message: &str) { | ||
| self.available_models.clear(); | ||
| self.available_models_provider = self.state.settings.ai_provider.clone(); | ||
| self.model_message = Some(message.to_string()); | ||
| } | ||
| pub fn pane_title_for_test(title: &str, active: bool) -> String { | ||
| Self::pane_title(title, active) | ||
| } | ||
| fn draw(&mut self, frame: &mut Frame) { | ||
@@ -212,8 +464,9 @@ let size = frame.area(); | ||
| let problem = Paragraph::new(render_markdown_plain(&render_problem( | ||
| &self.problem, | ||
| &self.state.settings.ui_language, | ||
| ))) | ||
| .block(Self::block("Problem", self.state.settings.theme == "light")) | ||
| .wrap(Wrap { trim: false }); | ||
| let problem = Paragraph::new(self.problem_text()) | ||
| .block(Self::block( | ||
| ui_text(&self.state.settings.ui_language, "problem"), | ||
| self.state.settings.theme == "light", | ||
| false, | ||
| )) | ||
| .wrap(Wrap { trim: false }); | ||
| frame.render_widget(problem, body[0]); | ||
@@ -223,3 +476,3 @@ | ||
| let text = if !self.busy_label.is_empty() { | ||
| format!("{}{}", self.busy_body, ".".repeat(self.busy_frame)) | ||
| format!("{}{}", self.busy_body, self.busy_dots()) | ||
| } else if self.output_is_markdown { | ||
@@ -231,3 +484,7 @@ render_markdown_plain(&self.output) | ||
| let output = Paragraph::new(text) | ||
| .block(Self::block("Output", self.state.settings.theme == "light")) | ||
| .block(Self::block( | ||
| ui_text(&self.state.settings.ui_language, "output"), | ||
| self.state.settings.theme == "light", | ||
| self.focus != Focus::Command, | ||
| )) | ||
| .wrap(Wrap { trim: false }); | ||
@@ -240,4 +497,7 @@ frame.render_widget(output, body[1]); | ||
| let title = format!("solution.{}", ext_for(&self.state.settings.language)); | ||
| let code = Paragraph::new(code) | ||
| .block(Self::block(&title, self.state.settings.theme == "light")); | ||
| let code = Paragraph::new(code).block(Self::block( | ||
| &title, | ||
| self.state.settings.theme == "light", | ||
| self.focus == Focus::Code, | ||
| )); | ||
| frame.render_widget(code, body[1]); | ||
@@ -263,22 +523,222 @@ } | ||
| } else { | ||
| "/run, /next easy string problem, /ai hint, /help".to_string() | ||
| ui_text(&self.state.settings.ui_language, "command_placeholder").to_string() | ||
| }; | ||
| let command = Paragraph::new(command_text) | ||
| .block(Self::block("Command", self.state.settings.theme == "light")) | ||
| .block(Self::block( | ||
| ui_text(&self.state.settings.ui_language, "command"), | ||
| self.state.settings.theme == "light", | ||
| self.focus == Focus::Command, | ||
| )) | ||
| .wrap(Wrap { trim: false }); | ||
| frame.render_widget(command, vertical[2]); | ||
| self.draw_command_palette(frame, vertical[2]); | ||
| self.set_terminal_cursor(frame, body[1], vertical[2]); | ||
| } | ||
| fn block(title: &str, light: bool) -> Block<'_> { | ||
| fn problem_text(&self) -> Text<'static> { | ||
| let lang = normalize_ui_language(&self.state.settings.ui_language); | ||
| let light = self.state.settings.theme == "light"; | ||
| 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 = self | ||
| .problem | ||
| .id | ||
| .split_once('-') | ||
| .map(|(number, _)| number) | ||
| .unwrap_or(&self.problem.id); | ||
| let mut lines = vec![ | ||
| Line::from(Span::styled( | ||
| format!("{number}. {}", localized(&self.problem.title, &lang)), | ||
| title_style, | ||
| )), | ||
| Line::from(Span::styled( | ||
| format!( | ||
| "{}: {} {}: {}", | ||
| ui_text(&lang, "difficulty"), | ||
| self.problem.difficulty, | ||
| ui_text(&lang, "topics"), | ||
| self.problem.topics.join(", ") | ||
| ), | ||
| meta_style, | ||
| )), | ||
| ]; | ||
| lines.push(Line::default()); | ||
| for line in localized(&self.problem.statement, &lang).trim_end().lines() { | ||
| lines.push(Line::from(Span::styled(line.to_string(), body_style))); | ||
| } | ||
| Self::push_problem_section( | ||
| &mut lines, | ||
| ui_text(&lang, "input"), | ||
| &localized(&self.problem.input, &lang), | ||
| section_style, | ||
| body_style, | ||
| ); | ||
| Self::push_problem_section( | ||
| &mut lines, | ||
| ui_text(&lang, "output"), | ||
| &localized(&self.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 self.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, | ||
| ))); | ||
| Self::push_code_lines(&mut lines, &case.input, code_style); | ||
| lines.push(Line::from(Span::styled( | ||
| format!(" {}", ui_text(&lang, "output")), | ||
| meta_style, | ||
| ))); | ||
| Self::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), | ||
| ])); | ||
| } | ||
| } | ||
| fn draw_command_palette(&self, frame: &mut Frame, command_area: Rect) { | ||
| let suggestions = self.command_suggestions(); | ||
| if suggestions.is_empty() || command_area.y < 3 { | ||
| return; | ||
| } | ||
| let height = ((suggestions.len() + 3) as u16).min(14).min(command_area.y); | ||
| let area = Rect::new( | ||
| command_area.x, | ||
| command_area.y - height, | ||
| command_area.width, | ||
| height, | ||
| ); | ||
| let selected = self.command_palette_cursor.min(suggestions.len() - 1); | ||
| let visible = height.saturating_sub(2) as usize; | ||
| let start = selected.saturating_sub(visible.saturating_sub(1)); | ||
| let mut lines = suggestions | ||
| .iter() | ||
| .enumerate() | ||
| .skip(start) | ||
| .take(visible) | ||
| .map(|(index, hint)| { | ||
| let marker = if index == selected { ">" } else { " " }; | ||
| format!( | ||
| "{marker} {:<16} {}", | ||
| hint.display, | ||
| ui_text(&self.state.settings.ui_language, hint.desc_key) | ||
| ) | ||
| }) | ||
| .collect::<Vec<_>>(); | ||
| lines.push(ui_text(&self.state.settings.ui_language, "palette_hint").to_string()); | ||
| frame.render_widget(Clear, area); | ||
| frame.render_widget( | ||
| Paragraph::new(lines.join("\n")).block(Self::block( | ||
| ui_text(&self.state.settings.ui_language, "commands"), | ||
| self.state.settings.theme == "light", | ||
| true, | ||
| )), | ||
| area, | ||
| ); | ||
| } | ||
| fn block(title: &str, light: bool, active: bool) -> Block<'static> { | ||
| let border = if active { | ||
| if light { | ||
| Style::default() | ||
| .fg(Color::Magenta) | ||
| .add_modifier(Modifier::BOLD) | ||
| } else { | ||
| Style::default() | ||
| .fg(Color::Yellow) | ||
| .add_modifier(Modifier::BOLD) | ||
| } | ||
| } else if light { | ||
| Style::default().fg(Color::Blue) | ||
| } else { | ||
| Style::default().fg(Color::Cyan) | ||
| }; | ||
| Block::default() | ||
| .borders(Borders::ALL) | ||
| .title(title) | ||
| .border_style(if light { | ||
| Style::default().fg(Color::Blue) | ||
| } else { | ||
| Style::default().fg(Color::Cyan) | ||
| }) | ||
| .title(Self::pane_title(title, active)) | ||
| .border_style(border) | ||
| } | ||
| fn pane_title(title: &str, active: bool) -> String { | ||
| if active { | ||
| format!("> {title}") | ||
| } else { | ||
| title.to_string() | ||
| } | ||
| } | ||
| fn handle_key(&mut self, key: KeyEvent) -> Result<()> { | ||
@@ -297,10 +757,14 @@ match self.focus { | ||
| self.command_cursor = 0; | ||
| self.command_palette_cursor = 0; | ||
| self.focus = Focus::None; | ||
| } | ||
| KeyCode::Enter => { | ||
| let value = self.command.trim().to_string(); | ||
| self.command.clear(); | ||
| self.command_cursor = 0; | ||
| self.focus = Focus::None; | ||
| self.submit_command(&value)?; | ||
| if !self.accept_command_palette()? { | ||
| let value = self.command.trim().to_string(); | ||
| self.command.clear(); | ||
| self.command_cursor = 0; | ||
| self.command_palette_cursor = 0; | ||
| self.focus = Focus::None; | ||
| self.submit_command(&value)?; | ||
| } | ||
| } | ||
@@ -313,2 +777,4 @@ KeyCode::Backspace => self.delete_command_before_cursor(), | ||
| } | ||
| KeyCode::Up => self.move_command_palette(-1), | ||
| KeyCode::Down => self.move_command_palette(1), | ||
| KeyCode::Home => self.command_cursor = 0, | ||
@@ -319,2 +785,3 @@ KeyCode::End => self.command_cursor = char_len(&self.command), | ||
| self.command_cursor = 0; | ||
| self.command_palette_cursor = 0; | ||
| self.focus = Focus::None; | ||
@@ -412,2 +879,3 @@ self.handle_command("help")?; | ||
| } | ||
| self.command_palette_cursor = 0; | ||
| self.focus = Focus::Command; | ||
@@ -428,3 +896,3 @@ } | ||
| self.list_cursor = None; | ||
| self.write_output(HELP); | ||
| self.write_output(&self.help_text()); | ||
| return Ok(()); | ||
@@ -453,18 +921,12 @@ } | ||
| "ui" if arg.is_empty() => self.action_toggle_ui_language()?, | ||
| "ui" if UI_LANGUAGES.contains(&arg) => self.set_ui_language(arg)?, | ||
| "ui" => self.set_ui_language(&normalize_ui_language(arg))?, | ||
| "theme" if arg.is_empty() => self.action_toggle_theme()?, | ||
| "theme" if THEMES.contains(&arg) => self.set_theme(arg)?, | ||
| "source" | "next-source" if arg.is_empty() => { | ||
| self.write_text_output(&format!( | ||
| "Next source: {}", | ||
| self.state.settings.next_source | ||
| )); | ||
| self.write_text_output(&format!("Next source: {}", self.next_source_label())); | ||
| } | ||
| "source" | "next-source" if matches!(arg, "bank" | "ai") => { | ||
| "source" | "next-source" if matches!(arg, "bank" | "local" | "ai") => { | ||
| self.state.settings.next_source = normalize_next_source(arg); | ||
| save_state(&self.root, &self.state)?; | ||
| self.write_text_output(&format!( | ||
| "Next source: {}", | ||
| self.state.settings.next_source | ||
| )); | ||
| self.write_text_output(&format!("Next source: {}", self.next_source_label())); | ||
| } | ||
@@ -479,4 +941,5 @@ "ai-next-command" if !arg.is_empty() => { | ||
| self.write_text_output(&format!( | ||
| "AI provider: {}", | ||
| self.state.settings.ai_provider | ||
| "AI provider: {}\n{}", | ||
| self.state.settings.ai_provider, | ||
| provider_status(&self.state.settings.ai_provider) | ||
| )); | ||
@@ -486,19 +949,36 @@ } | ||
| self.state.settings.ai_provider = normalize_ai_provider(arg); | ||
| self.model_rx = None; | ||
| self.available_models.clear(); | ||
| self.available_models_provider.clear(); | ||
| self.model_message = None; | ||
| save_state(&self.root, &self.state)?; | ||
| self.write_text_output(&format!( | ||
| "AI provider: {}", | ||
| self.state.settings.ai_provider | ||
| "AI provider: {}\n{}", | ||
| self.state.settings.ai_provider, | ||
| provider_status(&self.state.settings.ai_provider) | ||
| )); | ||
| } | ||
| "model" if arg.is_empty() => { | ||
| self.write_text_output(&format!("AI model: {}", self.state.settings.ai_model)); | ||
| self.start_model_check(); | ||
| self.check_models(); | ||
| self.write_model_status(); | ||
| } | ||
| "model" => { | ||
| self.state.settings.ai_model = arg.to_string(); | ||
| self.state.settings.ai_model = if arg == "auto" { | ||
| "auto".to_string() | ||
| } else { | ||
| arg.to_string() | ||
| }; | ||
| save_state(&self.root, &self.state)?; | ||
| self.write_text_output(&format!("AI model: {arg}")); | ||
| self.start_model_check(); | ||
| self.check_models(); | ||
| self.write_model_status(); | ||
| } | ||
| "ai" if !arg.is_empty() => self.start_ai_prompt(arg)?, | ||
| "hint" if arg.is_empty() => { | ||
| self.start_ai_prompt("Give one concise hint for the current problem.")? | ||
| } | ||
| "hint" | "ask" | "ai" if !arg.is_empty() => self.start_ai_prompt(arg)?, | ||
| "note" if !arg.is_empty() => self.append_note(arg)?, | ||
| "note" | "notes" => self.show_notes()?, | ||
| "update" => self.show_update_notice(), | ||
| "exit" | "quit" | "q" => self.should_quit = true, | ||
@@ -562,6 +1042,9 @@ _ => self.write_text_output(&format!("Unknown command: {value}\nTry /help.")), | ||
| if self.task_rx.is_some() { | ||
| self.write_text_output("Already busy."); | ||
| self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy")); | ||
| return; | ||
| } | ||
| self.start_busy("next", "Generating next problem"); | ||
| self.start_busy( | ||
| "next", | ||
| ui_text(&self.state.settings.ui_language, "generating_next"), | ||
| ); | ||
| let root = self.root.clone(); | ||
@@ -669,5 +1152,5 @@ let state = self.state.clone(); | ||
| fn set_ui_language(&mut self, language: &str) -> Result<()> { | ||
| self.state.settings.ui_language = language.to_string(); | ||
| self.state.settings.ui_language = normalize_ui_language(language); | ||
| save_state(&self.root, &self.state)?; | ||
| self.write_text_output(&format!("UI language: {language}")); | ||
| self.write_text_output(&format!("UI language: {}", self.state.settings.ui_language)); | ||
| Ok(()) | ||
@@ -685,3 +1168,3 @@ } | ||
| if self.task_rx.is_some() { | ||
| self.write_text_output("Already busy."); | ||
| self.write_text_output(ui_text(&self.state.settings.ui_language, "already_busy")); | ||
| return Ok(()); | ||
@@ -725,2 +1208,85 @@ } | ||
| fn check_update(&mut self) { | ||
| let result = self.update_rx.as_ref().and_then(|rx| rx.try_recv().ok()); | ||
| if let Some(result) = result { | ||
| self.update_rx = None; | ||
| self.update_check = Some(result.clone()); | ||
| if let UpdateCheck::Available(version) = &result { | ||
| self.update_notice = Some(version.clone()); | ||
| } | ||
| } | ||
| } | ||
| fn start_update_check(&mut self) { | ||
| if self.update_rx.is_some() { | ||
| return; | ||
| } | ||
| let (tx, rx) = mpsc::channel(); | ||
| thread::spawn(move || { | ||
| let _ = tx.send(check_latest_version()); | ||
| }); | ||
| self.update_rx = Some(rx); | ||
| } | ||
| fn start_model_check(&mut self) { | ||
| let provider = self.state.settings.ai_provider.clone(); | ||
| if self.model_rx.is_some() || self.available_models_provider == provider { | ||
| return; | ||
| } | ||
| let query_provider = provider.clone(); | ||
| let (tx, rx) = mpsc::channel(); | ||
| thread::spawn(move || { | ||
| let _ = tx.send(available_models(&query_provider)); | ||
| }); | ||
| self.available_models_provider = provider; | ||
| self.model_rx = Some(rx); | ||
| } | ||
| fn check_models(&mut self) { | ||
| let models = self.model_rx.as_ref().and_then(|rx| rx.try_recv().ok()); | ||
| if let Some(catalog) = models { | ||
| self.model_rx = None; | ||
| self.available_models = catalog.models; | ||
| self.model_message = catalog.message; | ||
| if self.showing_model_status { | ||
| self.output = self.model_status_text(); | ||
| self.output_is_markdown = false; | ||
| self.show_output = true; | ||
| } | ||
| } | ||
| } | ||
| fn model_status_text(&self) -> String { | ||
| let mut lines = vec![ | ||
| format!("AI provider: {}", self.state.settings.ai_provider), | ||
| format!( | ||
| "AI model: {}", | ||
| if self.state.settings.ai_model == "auto" { | ||
| "auto (provider default)" | ||
| } else { | ||
| self.state.settings.ai_model.as_str() | ||
| } | ||
| ), | ||
| "Use /model auto to let the provider choose its default.".to_string(), | ||
| ]; | ||
| if self.model_rx.is_some() { | ||
| lines.push("Loading provider model list...".to_string()); | ||
| } else if self.available_models.is_empty() { | ||
| lines.push( | ||
| self.model_message | ||
| .clone() | ||
| .unwrap_or_else(|| "Provider model list is unavailable.".to_string()), | ||
| ); | ||
| lines.push("Use /model <name> for a known model.".to_string()); | ||
| } else { | ||
| lines.push("Available models:".to_string()); | ||
| lines.extend( | ||
| self.available_models | ||
| .iter() | ||
| .map(|model| format!("- /model {model}")), | ||
| ); | ||
| } | ||
| lines.join("\n") | ||
| } | ||
| fn start_busy(&mut self, label: &str, body: &str) { | ||
@@ -741,2 +1307,3 @@ self.busy_label = label.to_string(); | ||
| fn write_output(&mut self, output: &str) { | ||
| self.showing_model_status = false; | ||
| self.output = output.to_string(); | ||
@@ -749,2 +1316,3 @@ self.output_is_markdown = true; | ||
| fn write_text_output(&mut self, output: &str) { | ||
| self.showing_model_status = false; | ||
| self.output = output.trim_end().to_string(); | ||
@@ -756,2 +1324,28 @@ self.output_is_markdown = false; | ||
| fn write_model_status(&mut self) { | ||
| self.output = self.model_status_text(); | ||
| self.output_is_markdown = false; | ||
| self.showing_model_status = true; | ||
| self.show_output = true; | ||
| self.focus = Focus::Output; | ||
| } | ||
| fn show_update_notice(&mut self) { | ||
| let lang = self.state.settings.ui_language.clone(); | ||
| if let Some(version) = &self.update_notice { | ||
| self.write_text_output(&format!( | ||
| "{}: practicode {version} (current {CURRENT_VERSION})\n\nnpm update -g practicode\ncargo install --force practicode", | ||
| ui_text(&lang, "update_available") | ||
| )); | ||
| } else if self.update_rx.is_some() { | ||
| self.write_text_output("Checking for updates..."); | ||
| } else if matches!(self.update_check, Some(UpdateCheck::Disabled)) { | ||
| self.write_text_output(ui_text(&lang, "update_check_disabled")); | ||
| } else if matches!(self.update_check, Some(UpdateCheck::Failed)) { | ||
| self.write_text_output(ui_text(&lang, "update_check_failed")); | ||
| } else { | ||
| self.write_text_output(ui_text(&lang, "update_none")); | ||
| } | ||
| } | ||
| fn append_note(&mut self, note: &str) -> Result<()> { | ||
@@ -777,2 +1371,3 @@ append_problem_note(&self.root, note)?; | ||
| self.command_cursor += 1; | ||
| self.command_palette_cursor = 0; | ||
| self.normalize_command_input(); | ||
@@ -789,2 +1384,3 @@ } | ||
| self.command_cursor -= 1; | ||
| self.command_palette_cursor = 0; | ||
| self.normalize_command_input(); | ||
@@ -800,5 +1396,77 @@ } | ||
| self.command.replace_range(start..end, ""); | ||
| self.command_palette_cursor = 0; | ||
| self.normalize_command_input(); | ||
| } | ||
| fn command_suggestions(&self) -> Vec<CommandChoice> { | ||
| if self.focus != Focus::Command { | ||
| return Vec::new(); | ||
| } | ||
| let Some(query) = self.command.trim_start().strip_prefix('/') else { | ||
| return Vec::new(); | ||
| }; | ||
| let query = query.to_lowercase(); | ||
| self.command_choices() | ||
| .into_iter() | ||
| .filter(|hint| hint.insert.starts_with(query.trim_start())) | ||
| .collect() | ||
| } | ||
| fn command_choices(&self) -> Vec<CommandChoice> { | ||
| let mut choices = Vec::new(); | ||
| for hint in COMMAND_HINTS { | ||
| if hint.insert == "model " { | ||
| for model in self | ||
| .available_models | ||
| .iter() | ||
| .filter(|model| *model != "auto") | ||
| { | ||
| choices.push(CommandChoice { | ||
| insert: format!("model {model}"), | ||
| display: format!("/model {model}"), | ||
| desc_key: "cmd_model_available", | ||
| keep_open: false, | ||
| }); | ||
| } | ||
| } | ||
| choices.push(CommandChoice { | ||
| insert: hint.insert.to_string(), | ||
| display: hint.display.to_string(), | ||
| desc_key: hint.desc_key, | ||
| keep_open: hint.keep_open, | ||
| }); | ||
| } | ||
| choices | ||
| } | ||
| fn move_command_palette(&mut self, delta: isize) { | ||
| let len = self.command_suggestions().len(); | ||
| if len == 0 { | ||
| return; | ||
| } | ||
| let cursor = self.command_palette_cursor as isize; | ||
| self.command_palette_cursor = ((cursor + delta).rem_euclid(len as isize)) as usize; | ||
| } | ||
| fn accept_command_palette(&mut self) -> Result<bool> { | ||
| let suggestions = self.command_suggestions(); | ||
| if suggestions.is_empty() { | ||
| return Ok(false); | ||
| } | ||
| let hint = &suggestions[self.command_palette_cursor.min(suggestions.len() - 1)]; | ||
| if hint.keep_open { | ||
| self.command = format!("/{}", hint.insert); | ||
| self.command_cursor = char_len(&self.command); | ||
| self.command_palette_cursor = 0; | ||
| return Ok(true); | ||
| } | ||
| let value = hint.insert.clone(); | ||
| self.command.clear(); | ||
| self.command_cursor = 0; | ||
| self.command_palette_cursor = 0; | ||
| self.focus = Focus::None; | ||
| self.submit_command(&value)?; | ||
| Ok(true) | ||
| } | ||
| fn normalize_command_input(&mut self) { | ||
@@ -998,34 +1666,70 @@ let normalized = compose_hangul_jamo(&self.command); | ||
| let code_status = self.submission_status(&self.problem).0; | ||
| let activity = if self.busy_label.is_empty() { | ||
| "idle".to_string() | ||
| } else { | ||
| format!("{}{}", self.busy_body, self.busy_dots()) | ||
| }; | ||
| let tail = self | ||
| .update_notice | ||
| .as_ref() | ||
| .map(|version| { | ||
| format!( | ||
| "{}:{version} /update", | ||
| ui_text(&self.state.settings.ui_language, "update") | ||
| ) | ||
| }) | ||
| .unwrap_or_else(|| self.mode_hint().to_string()); | ||
| format!( | ||
| " PRACTICODE | {} | {} | {} | {} | code:{} | {} | next:{} | ai:{}/{} | {} ", | ||
| " PRACTICODE | {} | {} | {} | {} | code:{} | {} | {} ", | ||
| self.problem.id, | ||
| self.problem.difficulty, | ||
| self.busy_status(), | ||
| self.problem_status(&self.problem), | ||
| activity, | ||
| code_status, | ||
| self.state.settings.language, | ||
| self.state.settings.next_source, | ||
| self.state.settings.ai_provider, | ||
| self.state.settings.ai_model, | ||
| self.mode_hint(), | ||
| tail, | ||
| ) | ||
| } | ||
| fn busy_status(&self) -> String { | ||
| if self.busy_label.is_empty() { | ||
| "idle".to_string() | ||
| fn next_source_label(&self) -> &'static str { | ||
| if self.state.settings.next_source == "ai" { | ||
| "ai" | ||
| } else { | ||
| format!("busy:{}{}", self.busy_label, ".".repeat(self.busy_frame)) | ||
| "local" | ||
| } | ||
| } | ||
| fn busy_dots(&self) -> String { | ||
| ".".repeat(self.busy_frame / 4) | ||
| } | ||
| fn mode_hint(&self) -> &'static str { | ||
| let lang = &self.state.settings.ui_language; | ||
| match (self.focus, self.list_cursor.is_some(), self.show_output) { | ||
| (Focus::Command, _, _) => "Enter submit | Esc cancel", | ||
| (_, true, _) => "up/down move | Enter open | Esc close", | ||
| (_, _, true) => "Esc code | / command | ? help", | ||
| (Focus::Code, _, _) => "Esc then / command", | ||
| _ => "/ command | ? help", | ||
| (Focus::Command, _, _) => ui_text(lang, "hint_command"), | ||
| (_, true, _) => ui_text(lang, "hint_list"), | ||
| (_, _, true) => ui_text(lang, "hint_output"), | ||
| (Focus::Code, _, _) => ui_text(lang, "hint_code"), | ||
| _ => ui_text(lang, "hint_idle"), | ||
| } | ||
| } | ||
| fn help_text(&self) -> String { | ||
| let lang = &self.state.settings.ui_language; | ||
| let commands = COMMAND_HINTS | ||
| .iter() | ||
| .filter(|hint| hint.help) | ||
| .map(|hint| format!("- `{}` {}", hint.display, ui_text(lang, hint.desc_key))) | ||
| .collect::<Vec<_>>() | ||
| .join("\n"); | ||
| format!( | ||
| "# {}\n\n## {}\n\n1. Type code in the right pane.\n2. Press `Esc`, then choose `/run` from the command palette.\n3. Use `/next` when it passes.\n\n## {}\n\n{}\n\n## {}\n\n- `/` opens the command palette outside the editor.\n- `↑/↓` selects a command and `Enter` accepts it.\n- `Esc` cancels the command palette or leaves output.\n\n## {}\n\n- stdout is shown when a case fails.\n- stderr is shown without affecting the expected stdout.", | ||
| ui_text(lang, "help_title"), | ||
| ui_text(lang, "daily_loop"), | ||
| ui_text(lang, "commands"), | ||
| commands, | ||
| ui_text(lang, "keys"), | ||
| ui_text(lang, "debug_prints"), | ||
| ) | ||
| } | ||
| } | ||
@@ -1032,0 +1736,0 @@ |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
174136
44.04%27
68.75%320
540%146
-27.36%